Add downloadable VMs and improve catalog processing

Package Apple Silicon VM exports and cached runtime switching with local cabinet controls and conagent provisioning. Preserve experimental SPIKE 2 emulation.

Improve preparation concurrency, Ghidra checkpoints, signature importing, sound indexing and client-side spectrograms. Include regression tests and validation notes.
This commit is contained in:
Verstack Local
2026-09-14 20:03:59 -05:00
parent acdc5c9e6c
commit f73167de85
248 changed files with 89365 additions and 701 deletions
+2
View File
@@ -6,6 +6,8 @@ This is a development slice with a real Game of Thrones 1.37.0 extraction and na
The new [Theia game workbench](workbench/README.md) is available separately while full-corpus validation continues. See [redesign status and acceptance evidence](docs/redesign-status.md). The compatible static client remains the Rust service default.
The [Emulator view](emulator/README.md) provides isolated client sessions, a working screen/audio and switch diagnostic, and experimental Pokémon LE SD-runtime launch targets. Open **Emulator** in the workbench activity bar. See [architecture and browser execution options](docs/emulation.md).
For repository contents, cloning, commit identity, and pushing with a forwarded SSH agent, see the [Git workflow](docs/git-workflow.md). Local firmware, archive data, tools, credentials, and generated builds stay outside Git.
## Run
+1
View File
@@ -4,6 +4,7 @@
"import_roots": [
"./samples"
],
"preparation_workers": 1,
"workspace_bytes": 8589934592,
"bind": "127.0.0.1:8080",
"plugins": {
+336
View File
@@ -0,0 +1,336 @@
# Emulation in Verstack
The current catalog entry is **Download VM**, which exports Apple Silicon bundles.
See [the export acceptance audit](vm-export-acceptance.md) for current evidence
and remaining checks. The server emulator described below remains available for
experimental SPIKE 2 work; it is no longer the catalog's primary emulation view.
Later sections record the export implementation and successive validation runs.
The testable Emulator workbench view and isolated session worker are implemented.
See [testing and setup](../emulator/README.md) for the current launch choices,
commands, verification, and limitations. The diagnostic is a synthetic media test
using the real software cabinet broker; it is explicitly distinct from game boot.
## Current implementation
The user selected **a separate emulator session per client**. Each browser tab
owns a process group, writable state, cabinet broker, input stream, framebuffer,
audio queue, and private session credential. The worker supports two simultaneous
sessions by default, with aggregate Docker CPU/RAM/process limits and RAM-backed
scratch. Immutable runtime files are shared read-only. Closing a client expires
its session; stop removes its processes and scratch. Reconnection with retained
ownership and persistent saves are future work.
The Theia activity bar opens a dockable Emulator view. A dedicated same-origin
`/emulator` proxy keeps its control requests independent of archive mutations.
The current transport is framed JPEG video and PCM audio over streaming HTTP,
with bounded buffers. This makes the LAN pilot usable without additional media
ports. WebRTC with synchronized audio/video, codec negotiation and adaptive
bandwidth remains a future transport improvement.
Input requests require the session credential and an increasing sequence number.
Holds release on blur, stream disconnect, stop, and a server-side heartbeat timeout.
All 45 table contacts use the supplied dashboard coordinates and node addresses;
they are connected to named broker inputs without inventing CPU SPI assignments.
The Pro mapping remains provisional for LE game testing. Physical outputs are
still unavailable in the donor broker.
## Runtime and donor adaptations
The source came from the user-supplied `~/pokemon_emulator`. Vendored source and
its local changes are recorded in [provenance](../emulator/vendor/PROVENANCE.md).
Only process, device-shim, machine and netbridge source plus table assets are
included; game binaries and system libraries are not committed.
Runtime selection is data-driven. Materialize any extracted SD snapshot, or pair
an extracted game update with a separate compatible SD snapshot using
`--system-snapshot`. ELF headers select the ARM32 SPIKE 2 or ARM64 SPIKE 3 lane;
no Pokémon title, edition, version or partition-number allowlist remains.
The archive retains source identity; materialization verifies BLAKE3 files and
can share verified unchanged files between runtimes. The original runtime
manifest remains readable for existing installations.
Each private session presents its selected game at `/games/title`, with an alias
for its original directory name. It reconstructs library SONAME links and checks
executable/loader architecture, dependencies and symbol versions. Optional menu
and boot-display targets are discovered per runtime. Country/free-play/decoder
patches and donor-specific debug offsets are disabled. Shared system runtimes do
not imply that every game's switch/node profile or hardware protocol is emulated.
The browser worker is the only paced audio FIFO reader. It sends bounded PCM
chunks instead of recording an indefinitely growing raw/WAV file. Capturing and
streaming are independent of the guest's rendering performance.
## Browser execution option
Rendering the table and controls in the browser works with server emulation and
does not require moving game execution. This is the practical first hybrid.
Full game execution in WebAssembly is a separate research track. The supplied
game is an ARM64 Linux executable, not C/C++ source that Emscripten can simply
recompile. A browser implementation would need an ARM64 execution engine,
Linux/runtime and graphics compatibility, and the cabinet interfaces, or a
separate compatible source port. Existing browser VPX/display assets do not
establish browser execution of the SPIKE 3 game. Prototype boot and measure
memory/startup/frame timing before exposing this as a supported engine.
An ARM64 worker on the LAN is another option: clients keep the same stream and
controls while execution avoids cross-architecture translation. A GPU primarily
changes rendering/encoding costs; CPU translation is a separate bottleneck.
Technical references: [QEMU user-mode emulation](https://www.qemu.org/docs/master/user/main.html),
[Emscripten porting](https://emscripten.org/docs/porting/index.html), and
[Emscripten build inputs](https://emscripten.org/docs/compiling/Building-Projects.html).
## Worker isolation configuration
The user explicitly approved disabling Docker's AppArmor profile for the emulator
worker. `manage.sh` configures `apparmor=unconfined` on that container so
bubblewrap can create its nested mount namespace. Host AppArmor is unchanged.
The worker retains its capability allowlist, no-new-privileges setting, read-only
runtime mounts, private session scratch, and aggregate resource limits.
The worker also uses the explicitly approved `pivot_root` seccomp exception.
`emulator/seccomp.json` differs from the exported `seccomp-baseline.json` only by
allowing that syscall. `manage.sh` applies this profile to the emulator worker.
These files target the current x86-64 worker, not a general cross-architecture
policy.
## Next acceptance work
The real Stern boot displays render through the browser from the supplied King
Kong LE 0.97 SPIKE 2 SD and Pokémon 0.83 SPIKE 3 SD. Pokémon 0.85 launches using
the separate 0.81 SD system runtime and passes namespace/library startup, but
still waits during cabinet hardware initialization. Game audio and gameplay
remain unconfirmed. Cross-architecture tests cover shim symbol resolution,
mmap ABI, GLES framebuffer completeness and color readback.
Stream/switch diagnostics pass independently of that compatibility limitation.
Keep measuring real game rendering, audio continuity, input effects and ball
lifecycle on this x86-64 server. Establish game-specific device mappings and patch
signatures before claiming gameplay compatibility. Add owner resume, persistent
save policy and stronger per-session resource isolation before broader use.
WASM game execution is not implemented.
## Game startup fixes (2026-09-14)
Actual game launches exposed failures not covered by boot-display checks:
- The SPIKE 2 Mesa overlay omitted glibc compatibility libraries such as `librt`, mixing the worker's loader/pthread library with an older SD library. The overlay now carries the matching family together.
- Game renderer threads request explicit real-time scheduling. When `pthread_create` returns `EPERM`, the shim retries with inherited scheduling while retaining the caller's other attributes. No extra worker capabilities are required.
- Virtual UART replies now signal an eventfd, waking `select` and `poll` even when a reply arrives after the wait starts. SPI polling includes the real controller's transfer delay.
- SPIKE 3 power-board version replies use the selected runtime's firmware metadata. The startup-ready event uses the four-byte contract recovered from the game's analysis.
- SPIKE 2 sessions provide a private i.MX6 sysfs model, based on the supplied `spike2_emu/run_game.sh`, for revision, backlight and power attributes.
The UI distinguishes initialization from rendering, shows elapsed startup time, and opens logs after a prolonged no-frame wait or process exit. A broker socket alone no longer means that the game is running.
The actual Pokémon LE 0.81 game now renders its Pokémon “Startup In Progress” screen and produces PCM data. This establishes game rendering, not a completed gameplay acceptance test. Evidence is in `data/validation/emulator-games/`. Native, ARM32 and ARM64 regressions exercise delayed UART replies and the denied-real-time-thread fallback.
King Kong's game-level exit code 4 came from its NVRAM migration check: it created a mode-000 `.crc32` file and then could not reopen it without the cabinet's root DAC override. Private `/data/nv/` files now receive owner read/write access when created with no owner permissions. Missing hardware mixer elements no longer trigger ALSA's NULL-element assertion. The game now remains alive and renders the Stern splash; its SPIKE 2 node-bus transport and hardware video decoding remain incomplete, and King Kong audio/gameplay are not verified. Session logs include `dump/debug_log.txt` so fatal game errors are visible alongside launcher output.
Final King Kong validation stayed alive for 210 seconds and produced 1,848 captured frames after the private-NVRAM and absent-mixer fixes. Its missing `/dev/ttymxc1` node bus is now reported explicitly instead of implying cabinet startup is complete. The browser test received 463 actual Pokémon frames and roughly 3.9 MiB of PCM during its capture window; the visible screen still said “Startup In Progress.” No gameplay acceptance claim is made.
### Linux video selection and SPIKE 2 UART experiment (2026-09-14)
The web worker explicitly sets `SPIKE3_VIDEO_BRIDGE=0` in the guest environment.
The inherited macOS VideoToolbox bridge remains available to its original launcher;
this Linux worker uses guest software decoding. This removes the unsupported
bridge probe, but does not fix the malformed HEVC packets reported by Pokémon.
ARM32 sessions set `SPIKE3_EXPERIMENTAL_SPIKE2_UART=1`, which routes
`/dev/ttymxc1` through the existing SPIKE 3 netbridge transport. The shim logs
that this is experimental. Other launchers retain the original device behavior
unless they explicitly enable the flag. New sessions pick up both changes.
A live King Kong LE 0.97 test opened the device and reached `ExchangeData`,
then reported empty replies (`received 0`, expected lengths 118).
The machine-service log captured requests including `0a00`, `070101`,
`8002f18d00`, and `8003f0226b00`. These are not the STX/ETX-framed messages
accepted by the SPIKE 3 decoder. The alias therefore exposes the next failure;
it does not establish a working SPIKE 2 protocol. An adapter must translate
legacy wire requests into the shared node model and encode legacy responses.
The first 24 raw transport writes are logged (at most 64 bytes each), including
writes that the current frame decoder cannot recognize.
ARM32 and ARM64 UART tests cover delayed replies through both device names.
The ARM64 run also passed the server/runtime/startup regression suite (15 tests).
These tests verify transport behavior, not complete game boot.
Browser video offload would use WebCodecs for decoding and optionally WebGPU
for composition. The current guest consumes decoded frames as DRM textures;
a browser-only video stream cannot replace those textures without moving
composition or returning decoded pixels to the guest. Neither option repairs
invalid compressed packets. No browser decoder was added by this experiment.
## Download VM display repair (2026-09-14)
The first exported Pokémon 0.83 VM (`9bc62c7bcd1241bcbec91b60de623179`)
booted with working sound but a blank QEMU window on the user's Mac. Rebooting
that exact image under isolated QEMU TCG with virtio-gpu reproduced missing
`/dev/dri`: `modprobe drm`, `drm_kms_helper`, and `virtio_gpu` failed. The Debian
kernel package had been extracted without its post-install depmod hook; only
the separate initramfs tree had dependency indexes. Sound worked because
emu-init explicitly inserted its dependency modules.
The vendored image builder now generates module indexes in the final rootfs.
Adding those indexes to the same image made all three GPU modules load and
created card0 and renderD128. A real QEMU framebuffer capture then showed the
Stern logo and SPIKE 3 EMU text from boot_display. This proves display recovery,
not complete gameplay or macOS VideoToolbox behavior.
The exported dashboard now includes Service / Enter, Back, +, and in the
always-visible cabinet button row. Browser clicks on all four emitted switch
events, and an actual guest control channel acknowledged service Enter press
and release. Five dashboard tests and fourteen export tests passed. No
analysis, existing emulator, or workbench services were interrupted by these
tests. Full universal image updating and native Mac gameplay remain open.
## Universal client validation (2026-09-14)
Downloaded quick export `9221e0c6d9fc49049f3c7b85f0f692ed` from the live
catalog and executed its bundled client against actual cached components:
- Built Pokémon LE 0.83 with explicit shared runtime `legacy` (0.81 SD).
The image's game SHA-256 was
`1e5becb85840d0cc7a1d334b7cc43bdb83e51c7c7f8243bead8db679e9ffb22d`.
- Built Pokémon LE 0.85 with automatic selection. The API selected
`1314f21f-4f8f-4ed8-a1b3-cdc6a0505ea1`, the nearest 0.83 SD. The image's
game SHA-256 was
`5f8bd90b23c35d15252315b2003fb99d0c5950bca2017a085db49f7b52862293`.
- Compared each image's extracted game and libc against its downloaded component;
both matched. Both filesystems passed read-only e2fsck. The 0.85 launch alias
points to `pokemon_le/game`.
- Switched to 0.85 and back to cached 0.83; neither image was rebuilt or modified.
Then invoked the actual packaged `Update.command --game ... --default-runtime`
against the live API. It reused 0.85 and saved both active.json and connection.json.
- Booted the quick-built 0.83 image in isolated QEMU TCG. virtio_gpu loaded,
DRM devices appeared, and a framebuffer capture showed the Stern boot display.
This is guest/display validation, not native macOS full-game validation.
Temporary evidence is in `/tmp/verstack-universal-build-result.json`,
`/tmp/verstack-universal-switch-result.json`, and `/tmp/verstack-universal-gpu.log`.
The obsolete blank-display export was moved to `/tmp/verstack-retired-exports`
to free archive disk space; the repaired standard export remains published as
`614867564d0d4f0f9a2ef334956e5514`. All validation containers were stopped after
use. Existing emulator, workbench, and active catalog jobs were not interrupted.
Remaining operational work includes standard-build scratch/storage behavior on
this nearly full host and the user's native Mac gameplay confirmation. The
bundles README's earlier statement that universal image updating is untested
is superseded by the validation above.
## Standard export scratch validation (2026-09-14)
Standard exports now build in a private temporary directory below
`VERSTACK_EXPORT_SCRATCH` (default `/tmp/verstack-vm-exports`). Admission checks
persistent output space and scratch space separately, with reserves; when both
paths share a filesystem their requirements are combined. Success and failure
remove the private build tree, including Docker-owned files. Source runtimes are
never removed. Persistent export storage keeps the final ZIP and job logs.
Standard artifact cache keys now cover the builder, SD-selection policy, repair
script, Mac launcher, and vendored payload. Scheduler/HTTP/documentation edits
no longer force an unchanged standard VM rebuild. The validated 0.83 archive
was migrated only after verifying its previous source revision and all payload
input hashes; its previous revision is retained in the job receipt.
A real API request built standard Pokémon 0.85 as export
`4630e41d9efd461285a985c74ae24f83`, selecting the nearest 0.83 SD. It completed
with the export scratch directory empty and no persistent staging tree. The
full 2,634,755,536-byte download through the workbench proxy matched SHA-256
`1b3e8ee5db6b8deff2f4948d651537faaa02569ca166d9877b8925313a72c781`.
Both 0.83 and 0.85 standard requests then reused their completed downloads.
Seventeen export tests passed, including success/failure cleanup and cache
invalidation boundaries. Native Mac full-game verification remains pending.
### Exported local conagent follow-up (2026-09-14)
The native Mac test now reaches the game boot screen but reports the country
restriction. The exported raw Pokémon 0.83 game hash
`1e5becb85840d0cc7a1d334b7cc43bdb83e51c7c7f8243bead8db679e9ffb22d`
was outside the upstream provisioning allowlist, causing the export to disable
its local backend. Its two factory comparison sites match the original 0.83
profile; an exact-binary encrypted EEPROM round-trip test passed with that hash.
The generated export now provisions its private image during the Linux build,
ships its local TLS/config files, and starts the existing supervised service on
Mac without first-launch Docker/LLVM compilation. Guest startup explicitly
loads virtio_net before configuring the restricted local network. Original
source binaries are unchanged; the generated image records installed hashes
and retains the pre-provisioning game/EEPROM for recovery.
Quick-client image cache identity now includes the local VM recipe revision.
A finished-image marker and required image/kernel files prevent a partial build
with an existing launcher from being treated as ready. Component downloads stay
cached independently. Regression coverage checks reuse, missing-image rebuilds,
and recipe updates while retaining the previous VM.
These changes are not yet proof of native Mac gameplay or successful country
check completion. Guest conagent startup validation is still in progress.
Replacement standard export `5cc3a773fd354a5f868710cc5e2a454c` contains
2,641,996,757 bytes, SHA-256
`908c6fe94be57123446bc19b78053f4b6efb2abc6b215b593315344c498e166d`.
A full download through the workbench matched both values. Validation booted the
extracted ZIP in a two-vCPU TCG container, substituting only Linux host options,
QEMU 7's equivalent reconnect option, software audio/video and snapshot mode.
The guest had eth0 10.0.2.15/24 with its default route, DRM devices, the real
conagent process with local_transport.so mapped, and a successful guest HTTP
health response from the local service. The game displayed the Pokémon startup
screen and software HEVC produced a frame. The later framebuffer was black;
no genuine conagent authentication request was observed during this run.
This does **not** establish country-check completion, service-menu navigation,
attract mode, or native Mac gameplay. The native test is pending.
The isolated test VM was stopped and its extracted bundle removed. Build staging
was automatically removed after both the initial packaging-permissions failure
and the successful retry. Small diagnostic records remain under
`/tmp/verstack-conagent-validation`; published downloads remain in the export
store. The initial failure was root ownership of private local IC files; the
builder now transfers ownership to the export-stage user while preserving 0600
permissions. No catalog job or existing emulator session was stopped.
### Pristine cabinet records and version switching follow-up
Reconstructing upstream's original 0.83 generated-game hash confirmed exactly
which legacy changes it contained. Applying the supplied country-renderer,
free-play, node-update and factory-feature scripts to raw hash `1e5bec...`
reproduces `e8add023b60be684509f25cd54972981cd2adf15b0ad09d1e99ba33c29a4f54a`
with 82 changed bytes. This comparison used a temporary copy, removed afterward.
It explains why the upstream 0.83 provisioning path did not need to establish
all pristine factory feature predicates.
The pristine 0.83 reader at ELF VA `0x79dc90` and writer at `0x79dc30` validate
and install cabinet marker `5a a5 00 ff` at logical EEPROM `0x3c`; the writer
clears factory feature 3. Its checksum helper at `0x6f3830` is byte-identical to
the verified LE 0.85 helper. The export provisioner now writes that record for
pristine 0.83 as well as 0.85, through the existing EEPROM mask. The earlier
0.83 export `5cc3a773...` lacked this record and is superseded by the corrected
build. The actual game still receives only the two synthetic digest constants;
legacy feature-getter, country-renderer and node-update patches are not applied.
The user subsequently reported that Pokémon runs on their Mac, identifying
upstream revision `37d70e6`. Both exports use that revision, so the tested export
ID remains uncertain; this confirms a native successful launch without assigning
it specifically to either archive. Service-menu navigation remains unconfirmed.
Preserve both published archives until the tested export is identified.
The isolated headless test of `723fe5e4` completed the game's own factory and
cabinet record validators under GDB: both returned 1. This checks record validity,
not the complete startup or authentication flow. The test VM was stopped and its
extracted bundle removed; small logs remain in
`/tmp/verstack-conagent-headless`.
The exact LE 0.85 hash `5f8bd90b23c35d15252315b2003fb99d0c5950bca2017a085db49f7b52862293`
uses digest literals at `0x7a3348` / `0x7a3700`, codec constants at Ghidra
`0x02716800`, `0x02727020`, `0x02727030` (image base `0x00100000`), and
cabinet reader/writer at ELF VA `0x8929d0` / `0x892970`. Checksum helper
`0x6f0690` sums bytes modulo 65536. Exact-binary tests cover both releases'
encrypted records, marker bytes, unchanged regions and patched constants.
Node firmware identity now comes from the selected game's LPC node HEX names:
0.83 contains 1.33.0, LE 0.85 contains 1.35.0. Conflicting versions are rejected;
when no metadata exists the existing 1.33.0 default remains. This changes the
advertised version, not the existing board/switch model. Quick client retries
failed component preparation once per fresh invocation; ordinary polling never
starts a retry loop. The export API integration test proves recovery after a
stored failure and checks that only one replacement worker is created.
+118 -5
View File
@@ -3,6 +3,9 @@
The service runs one import/extraction worker and two Ghidra analysis workers by
default. Set `analysis_workers` in the service configuration to an integer from
1 through 8 to change the analysis limit. An omitted setting means 2.
`preparation_workers` controls concurrent imports, extraction, and media decoding
(1 through 8; defaults to 1 for compatibility with small workspaces). This host
is configured for 3 preparation workers. Settings take effect on service restart.
An import retains its original, extracts files, and prepares requested assets on
the import worker. It then saves an `analysis_input` snapshot reference and enters
@@ -28,21 +31,23 @@ worker thread, and shutdown interrupts every active plugin process group.
Scratch capacity is partitioned, rather than giving each concurrent operation the
entire configured `workspace_bytes` allowance:
- Each analysis gets `min(4 GiB, workspace_bytes / (analysis_workers + 2))`.
- Import/extraction gets the remainder after reserving every analysis slot.
- Each analysis gets `min(6 GiB, workspace_bytes / (analysis_workers + 2))`.
- Each import/extraction slot gets the remainder after reserving every analysis
slot, divided by `preparation_workers`. Increasing this count reduces the
maximum scratch allowance for an individual large image.
- Input materialization uses at most half a slot's allowance. Original import
staging also uses half, leaving room for downloaded or uploaded source bytes.
- Plugin execution and output capture are checked against that slot's allowance.
For a 64 GiB workspace and two analysis workers, this gives 4 GiB per analysis and
56 GiB for import/extraction. These are scratch allowances, not JVM heap limits or
For a 64 GiB workspace and two analysis workers, this gives 6 GiB per analysis and
52 GiB for import/extraction. These are scratch allowances, not JVM heap limits or
OS memory isolation. Ghidra's configured `max_heap` and `max_cpu` apply separately
to each analysis process. Trusted plugins are monitored for excess scratch use;
the monitor is not a hard filesystem quota. Reserve host RAM for JVMs and other
processes in addition to scratch, and limit individual extraction tools' threads
when CPU contention warrants it.
`GET /api/info` exposes `analysis_workers`, `analysis_workspace_bytes`, and
`GET /api/info` exposes `analysis_workers`, `preparation_workers`, `analysis_workspace_bytes`, and
`import_workspace_bytes`. Existing activity endpoints show running imports and
the `Waiting for code analysis` handoff without a frontend upgrade.
@@ -57,3 +62,111 @@ separate preparation and analysis lanes so a long analysis cannot hold up import
imports during analysis, admission limits, isolated cancellation, shutdown and
retry, durable handoff across restart, legacy job scheduling, and per-analysis
scratch limits. Test plugins use explicit release signals to prove overlap.
## Recovery
`POST /api/imports/{id}/retry` refreshes unfinished stages with the current
configured tools and reuses successful stage outputs. For an extraction that
previously treated a container as opaque, `POST /api/imports/{id}/reextract`
also accepts completed imports and reruns the pipeline from the retained original.
It resets the task's output references and warnings; published snapshots remain
archived. Active imports cannot be reset.
The extractor recognizes ImageUSB `.bin` backups by their UTF-16 header and
validates the payload length before passing the disk after the 512-byte wrapper
to the filesystem extractor. Partitioned backups are read directly at that
offset, avoiding a second whole-image copy during decryption. Sparse padding
remains sparse in scratch storage.
Ghidra adapter revision 3 exports functions using up to `max_cpu` independent
decompilers, with bounded pending work and deterministic output ordering. An
auto-analysis timeout aborts before decompilation and database export. Successful
outputs still require reopening the database and verifying the exported symbols.
Adapter version 4 additionally supports an optional `checkpoint_dir` for recovery
of completed program exports. Checkpoints verify the input hash, analysis
settings, producer script hashes, and every retained file hash. Reuse always
reopens the saved GZF and checks its symbols again; the source checkpoint receipt
is archived alongside the new output. Corrupt or incompatible checkpoints fall
back to fresh analysis. Checkpoint directories are operator-managed scratch
storage, separate from the archive, and may be removed when no jobs use them.
Their location does not change function-comparison compatibility.
Repeated imports can reuse a completed analysis of the same input snapshot when
the tool command, version, source digest, revision, schema, and settings match and the
saved inventory covers every selected program. Selecting a subset does not
repeat a verified superset analysis. Missing programs or changed profiles run
the tool again. Function inventories up to 512 MiB can be indexed, synchronized,
and compared; media metadata retains its separate 64 MiB limit.
Plugin result manifests allow up to 32 MiB so inventories with over 100,000
function-code paths can be published. Output paths are still checked against
the complete on-disk inventory before capture.
## Large code-export throughput
Verification now retains a directory index even above the old 65,536-node /
32 MiB soft cache limit. Previously, a ~125,000-function export bypassed that
cache and reparsed the same large tree for each file. An oversized directory
replaces the prior cache and remains resident until the next directory; memory
is bounded by the soft budget or one largest decoded directory.
Each verification owns a fresh backend read view and directory cache, so normal
browsing and other verifications cannot evict its index. Bytes are still reread
and BLAKE3-verified. The archive writer covers the backend commit only; completed
output verification and catalog indexing can proceed outside that writer lock.
Maintenance remains excluded while executions are active.
Progress distinguishes waiting for the archive writer, saving, verifying (with
file counts), and indexing. Progress updates carry a timestamp that changes only when the reported progress changes.
Activity reads use a separate read-only WAL connection, so a large symbol-index
transaction cannot freeze the Jobs panel. The Jobs view
defaults to active/queued tasks, puts running tasks first, and provides status,
release, search and compact-view controls. History is retained, not deleted.
On the 54-CPU / 172-GiB host, the current configuration uses four analysis slots,
8 Ghidra CPUs and an 8-GiB JVM heap per analysis. The 80-GiB scratch allocation
reserves 6 GiB per analysis and divides the remaining 56 GiB across three
preparation slots (about 18.7 GiB each), fitting the hosts 87-GiB
RAM filesystem with headroom for other temporary files. Python plugins receive a
per-job CPU budget of 7 threads on this host, rather than each automatically
using nearly every CPU. These are cooperative limits, not per-job OS quotas.
Deep analysis of distinct large games still takes time; adding more queue
entries does not increase the number of simultaneous Ghidra processes.
Regression checks include a real 65,537-file backend directory, archive
corruption detection, queue concurrency/cancellation, and the Jobs filter UI.
### Signature synchronization uses existing analysis
Signature sync indexes saved function exports and compares those observations.
It does not enumerate all SD executables or schedule fresh Ghidra auto-analysis.
Previously analyzed libraries remain eligible for signature comparison. New
analysis is requested explicitly through imports or Choose programs.
Legacy `include_missing` and `include_system` API flags are accepted for client
compatibility but cannot enable automatic analysis. On resuming an old active
sync plan, the worker cancels its queued owned jobs, preserves running and
independently requested analyses, and rebuilds coverage from saved results.
The activity response reports actual analysis/preparation slot occupancy in
`execution`; a planned or queued task does not occupy an execution slot. Active
imports remain visible even when more than 200 completed imports exist.
## TODO: resource governor
Replace static slot allocation with admission based on observed CPU use,
process memory, tmpfs scratch headroom, archive free space, and I/O pressure.
- Lend idle analysis capacity to extraction/decoding without multiplying nested
plugin thread pools beyond a shared host budget. Account for Ghidra's explicit
`max_cpu` and heap settings, which are independent of Python worker limits.
- Estimate scratch demand from SD/container sizes; let large images reserve more
capacity and run fewer at once instead of failing a smaller per-slot allowance.
- Pause new admissions before archive storage fills. Include pending publications
and VM exports when estimating disk demand; tmpfs capacity alone is insufficient.
- Use bounded adjustments and hysteresis to avoid oscillation. Keep manual caps,
prioritize fairly across releases, and expose the limiting resource in Jobs.
- Validate mixed 1015-ROM queues, cancellation, maintenance exclusion, and
recovery under memory/disk pressure before enabling automatic scaling.
The current change is static configuration only; it does not implement these
adaptive controls. Archive publication remains serialized.
+53
View File
@@ -0,0 +1,53 @@
# Preparation concurrency restart handoff
Side conversation change, 2026-09-14. The user requested higher extraction and
decoding concurrency, future governor notes, and a staged build for the main
thread to activate without interrupting its work.
## Change
- `preparation_workers` is configurable from 1 through 8; omitted means 1.
- Local `config.json` now sets 3; existing `analysis_workers` stays 4.
- Scheduler starts the configured preparation workers. Execution gates, API
capacity reporting, and maintenance exclusion use the same count.
- The 80-GiB scratch budget reserves 24 GiB for analysis and divides 56 GiB
among three preparation slots, approximately 18.7 GiB per slot. Large imports
now have less individual scratch capacity; input staging has a further
half-slot limit. Do not assume every large SD image fits this static split.
- Python jobs receive seven threads on the 54-CPU host. Ghidra's CPU and heap
settings remain separate. Adaptive scaling is TODO in `docs/job-queue.md`.
- `processing_enabled` remains false: explicit imports still run, but this
does not resume paused automatic/legacy jobs.
## Verification and staging
All seven `queue_concurrency` tests passed, including three overlapping
preparations, fourth-job admission blocking, divided scratch allowances, and
maintenance rejection with just one preparation active. `cargo check --offline
--tests` and `git diff --check` passed.
The staged release binary and SHA256 receipt are in
`/tmp/verstack-preparation-stage/`. The build also updates
`target/release/verstack`, the executable path in the checked-in backend unit.
This is a build of the shared working tree, including pre-existing main-thread
changes; it is not an isolated patch build. No git staging or commits were made.
## Main-thread activation
1. Finish or hold conflicting backend edits, and confirm the staged build still
represents the desired source. Rebuild if backend changes happened afterward.
2. Inspect `/api/activity` and the actual backend process/service. Wait for active
work to finish before restarting: shutdown interrupts active plugin groups;
Ghidra jobs may need computation repeated. Pausing automatic processing alone
does not stop explicitly requested imports from being admitted.
3. Check archive disk and tmpfs capacity. Higher concurrency does not implement
disk-pressure admission, and archive storage was recently nearly full.
4. Restart only the backend when ready. The checked-in unit is
`verstack-backend.service` under the user systemd manager; confirm that is the
actual running owner before using `systemctl --user restart verstack-backend`.
Leave the workbench and emulator/export services running.
5. Verify `/api/info` reports `preparation_workers: 3`, `analysis_workers: 4`,
and `/api/activity` reports `execution.preparation_limit: 3`. Check job logs
and scratch usage as the first concurrent preparations run.
The side conversation did not restart services or message/control other agents.
+80
View File
@@ -0,0 +1,80 @@
# Sound cue names
Godot script recovery revision 2 reads literal `SE_*` constants from
`spike_game_sound_ids.gd`. The Audio view displays the cue catalog, distinguishes
unresolved cues, and uses verified names for search, asset labels and editor tabs.
Stored WAV paths and comparison identities stay stable.
A catalog position is not a sound index. The naming adapter identifies bounded
AArch64 ADRP/ADD registration sequences with a literal object ID and a shared
call target. It then reads the matching executable's uniquely identified Radium
opcode-length table and follows every variant of that object's command stream.
Only unambiguous opcode `0x0b` keys that resolve to a section-8 PCM record produce
labels. Unknown commands, conflicting keys, unsupported executable layouts and
incomplete streams leave the cue unresolved. Multiple cues may name one sound.
Each link records the native call, string and command offsets, variant, object
ID and audio index. Browser enrichment requires both the source image path and
SHA-256 to match the decoded sound, so other releases and menu banks cannot
inherit labels accidentally. The original script and binary hashes remain in
the recovery evidence. This profile does not map all cue names or infer labels
from declaration order, speech recognition or neighboring records.
Run against every retained extracted snapshot using the local archive API:
```sh
tools/decoder-env/bin/python scripts/validate_sound_catalog.py
```
Results are in `data/validation/sound-catalog/summary.json`; each applicable ROM
has its recovered cue script and `godot-scripts-evidence.json`. This validation
reads originals and saves results separately without interrupting active jobs.
Validated metadata can be published to existing catalog entries with
`python3 scripts/publish_sound_catalog.py --apply` (see below). Rerunning
`godot-scripts` also stores the evidence in a new derived snapshot. Future
imports use the revised recovery stage automatically.
The September 13 validation covered six retained extracted releases. Pokémon
LE 0.81, Pro 0.81, LE 0.82 and LE 0.85 each recovered 760 cues and linked 25 names
to 25 audio records, with no failed scripts. King Kong Pro 0.97 and the retained
Pokémon LE 0.83 extraction contained no Godot script inputs. They are recorded
as missing catalog coverage. The browser regression uses new metadata with
real archived Pokémon WAV playback; it is not a live backend deployment check.
## Audio browsing (September 14)
Audio cards now show grayscale spectrograms computed from the retained 16-bit
PCM WAV. The preview samples 512-point Hann windows over the full timeline;
frequency is linear and brightness represents 80 to 0 dBFS. Stereo channels
contribute power separately. This is a visual overview, not a speech/music
classifier. Downloads and FFT computation run in a bounded pool of two to four browser
Web Workers. Visible cards can load concurrently; leaving the viewport cancels
queued work or terminates the worker and its download. Each download is limited
to 64 MiB and 30 seconds. Only the small rendered pixel buffer reaches the main
thread; completed previews are cached as small images. Unsupported or oversized audio keeps its
player with an explicit spectrogram-unavailable message.
Name, audio length, and file size support ascending/descending sorting across
the complete filtered result before pagination. Unknown durations sort last.
Duration uses verified decoded geometry, not compressed byte size or declared
codec frames. The category filter uses explicit cue-name tokens for dialogue,
system sounds, music, and SFX. Categories are estimates; missing or conflicting
cue evidence yields Unknown. There is no speech recognition or acoustic genre
classification in this revision.
`publish_sound_catalog.py --apply` publishes previously validated cue evidence
into the rebuildable catalog without rewriting snapshots or interrupting jobs.
Recovered script source/output hashes must match. Audio labels additionally
require the exact source bank SHA-256, source path and record index. The script
also updates name search; normal Godot recovery regenerates the same links.
Four retained Pokémon releases received 25 verified named sounds each (100
records). Inspection of the 0.81 executable found only those 25 catalog strings;
no additional verified mapping for the other 735 script cues was established.
For compatibility while older backend analysis processes are active, the UI
can load audio metadata pages, reconcile verified names by exact snapshot/path
from the artifact index, and sort/filter locally. Updated backends advertise
`audio_index_revision: 1` and perform that work server-side.
Checks: `node tests/audio-spectrum.cjs`, `node tests/ui-spectrogram-workers.mjs`, `node tests/ui-audio-index.mjs`,
`cargo test --lib sound_name_tests`, and `cargo test --test workspace`.
+47
View File
@@ -0,0 +1,47 @@
# VM export acceptance audit
Checked against the working tree and running services on 2026-09-14. The goal
remains active: native service-menu operation is not yet confirmed. A successful
export is not a claim that every game version is fully emulated.
| Requirement | Evidence and current result |
| --- | --- |
| Replace the emulator tab with Download VM | `workbench/stern-catalog/src/browser/emulator-view.tsx` opens `/emulator/exports/ui`. `tests/ui-vm-exports.mjs` passed against the running catalog, including standard ZIP range download and a complete quick ZIP download. |
| Package the selected game, SD runtime, kernel, initramfs and Mac supporting scripts | Published 0.83 `723fe5e4d9024f2ab195d28d50271d12` and 0.85 `e4359dddb21946689d3ded1dbab63648` include these artifacts. ZIP and image checks are recorded in `docs/emulation.md`. Standard launch requires host Python and QEMU; supported local IC profiles are prepared during export and need no Docker on launch. |
| Use the release's own SD before any donor | The current 0.83 ZIP manifest selects its own runtime ID and `own-sd`. `test_bundle_selection.py` also rejects substitution when an SD release's own system is missing. |
| Otherwise choose the closest same-title SD | The 0.85 standard export and actual quick build select the 0.83 SD. Numeric ordering, tie-breaking and title priority have policy tests. |
| Otherwise choose a same-generation SD | The policy excludes the wrong architecture and borrowed update runtimes. The live catalog selects King Kong's SD for other SPIKE 2 titles. This is selection evidence; new SPIKE 2 ZIP exports are disabled. |
| Quick export connects to the catalog and caches components | Actual downloaded client fetched game/runtime components and reused verified SHA-256 caches. The current downloaded client matched the source byte-for-byte; receipt `/tmp/verstack-quick-cleanup-validation.json`. |
| Quick export updates and switches versions | Actual 0.83 and 0.85 builds, game/libc comparisons, filesystem checks and switching without rebuild are recorded in `/tmp/verstack-universal-build-result.json` and `/tmp/verstack-universal-switch-result.json`. Those runs predate local IC provisioning; they prove build/switch behavior, not current native boot of both versions. Recipe changes invalidate images; incomplete builds cannot become active. |
| Explicit shared runtime per generation | Actual quick 0.83 build used the 0.81 SD override. Browser test selects that override, and policy tests reject a cross-generation override. `--default-runtime` returns to automatic selection. |
| Retain the supplied SPIKE 3 nodebus and local switch panel | Both vendored netbridge implementations and keyboard relays match the hashes in `SOURCE.json`. The selected game's node firmware version is advertised while retaining the board model. The current ZIP's 28 JavaScript input tests pass. Native menu navigation remains pending. |
| Preserve experimental SPIKE 2 work | Existing emulator code and proxy remain; `verstack-emulator` was confirmed running. King Kong's imported SD remains available. This phase has not established a downloadable ARM32 VM or repaired its earlier game-startup limitations. |
| Do not interrupt active catalog jobs | Export worker is separate and serialized with CPU/RAM limits. Only isolated validation VMs were stopped. Backend activation watcher remains live and waits for active sound decoding to finish before restarting the backend. Three preparation workers are not yet confirmed active. |
| Clean up build and validation scratch | Standard success/failure cleanup is tested. Quick failure now removes incomplete images and source extraction while preserving the previous active VM and component cache. All 20 export tests pass. The recent headless validation disk was removed after saving small logs; published ZIPs remain. |
## Native confirmation and remaining limits
The user reports that Pokémon runs on their Apple Silicon Mac and identifies
upstream commit `37d70e6`. Both recent 0.83 exports carry that commit, so the report
does not identify which ZIP was tested. Preserve both `5cc3a773...` and
`723fe5e4...`. Do not describe either one individually as native-confirmed until
the artifact is identified. Service / Enter, Back, + and still need native
confirmation; the question has already been asked.
The current 0.83 image passed the game's factory-record and cabinet-record
validators in isolated QEMU (both returned 1). That is narrower than full startup
or authentication. Only exact recognized game binaries receive local IC
provisioning; other selectable versions can export without being proven playable.
The original scope retains the supplied node model rather than implementing new
per-title physical playfield profiles.
The published `723fe5e4...` dashboard also passed all five Python rendering and
event-routing tests using the existing Pillow environment. Together with its
28 JavaScript input tests, these verify the packaged control UI and event path,
not the game's response on macOS. Logs remain in
`/tmp/verstack-packaged-controls-audit`; the extracted test source was removed.
The existing three-worker backend activation request remains pending at a safe
stopping point. Its live watcher is `verstack-preparation-activation.service`;
the required final evidence is `/api/info` reporting `preparation_workers: 3`
and `/api/activity` reporting `execution.preparation_limit: 3`.
+3
View File
@@ -0,0 +1,3 @@
__pycache__
**/__pycache__
*.pyc
+24
View File
@@ -0,0 +1,24 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
RUN dpkg --add-architecture arm64 && dpkg --add-architecture armhf && apt-get update && apt-get install -y --no-install-recommends \
python3 python3-aiohttp python3-pil b3sum bubblewrap busybox-static \
ca-certificates ffmpeg binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu \
qemu-user-static libegl-mesa0:arm64 libegl1:arm64 libgbm1:arm64 \
libgl1-mesa-dri:arm64 libglapi-mesa:arm64 libgles2:arm64 libglvnd0:arm64 \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends libc6-dev-arm64-cross \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends \
libavcodec-dev:arm64 libavutil-dev:arm64 libswscale-dev:arm64 \
libgbm-dev:arm64 libyuv-dev:arm64 \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends \
binutils-arm-linux-gnueabihf gcc-arm-linux-gnueabihf libc6-dev-armhf-cross \
libegl-mesa0:armhf libegl1:armhf libgbm1:armhf libgl1-mesa-dri:armhf \
libglapi-mesa:armhf libgles2:armhf libglvnd0:armhf \
libavcodec-dev:armhf libavutil-dev:armhf libswscale-dev:armhf \
libgbm-dev:armhf libyuv-dev:armhf \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . /app
CMD ["python3", "server.py"]
+119
View File
@@ -0,0 +1,119 @@
# Test emulation in Verstack
Open the **Emulator** play-circle button in the workbench activity bar, or open
`/emulator/` on the same Verstack host and port. Start **Stream & switch
diagnostic**, then click **Enable audio**. You should see a moving test pattern,
hear a quiet 440 Hz tone, and see table/button events in the diagnostic picture
and **Machine state**. The diagnostic runs the supplied software machine broker;
it does not run the Pokémon game.
Open a second browser tab to start a separate session. Each session has its own
machine state, controls, framebuffer, audio, and ephemeral owner credential.
Stop each session when finished. Closing a tab expires its session after 60
seconds; held controls release after three seconds without input heartbeat.
Reloading creates a new client; session resume is not implemented in this pilot.
The emulator worker runs with `apparmor=unconfined`, explicitly approved by the
user so bubblewrap can create its nested mount namespace. This setting is scoped
to that container; it does not disable host AppArmor. Real game compatibility
remains experimental.
The user also approved the worker-only `pivot_root` seccomp exception required
by bubblewrap. `seccomp-baseline.json` records the previous applied x86-64
filter; `seccomp.json` adds only that syscall and is used by `manage.sh`.
The **Release** selector lists materialized SPIKE 2 (ARM32) and SPIKE 3
(ARM64) runtimes. The launcher detects the executable architecture and selects
the matching QEMU, loader, compiler and Mesa libraries. A release may supply a
game, boot display and/or SPIKE menu; only available targets are offered.
System libraries and game files can come from different snapshots. Import any
full SD image of the matching generation, then use its extracted snapshot as
`--system-snapshot` for other game updates. There is no title/version allowlist.
The loader and dependency checks still apply: matching architecture alone does
not guarantee compatible library versions or hardware behavior. Unchanged files
are verified and hard-linked between runtime bundles to avoid duplicate copies.
These are experimental launch tests. The supplied Pokémon switch/node profile
is provisional and is not a universal physical table mapping. Binary patches and
donor debugging offsets are disabled across releases. A running process or
allocated framebuffer does not establish playable gameplay or audible game sound.
## Local setup
The current installation retains its original runtime and discovers additional
runtime manifests without rebuilding the UI. After importing and extracting an
SD image through Verstack, materialize its snapshot:
```sh
bash emulator/manage.sh build
bash emulator/manage.sh materialize --snapshot EXTRACTED_SD_SNAPSHOT
bash emulator/manage.sh start
npm --prefix workbench/stern-catalog run build
npm --prefix workbench run build:browser
systemctl --user restart verstack-workbench
```
For a game update that does not contain the system partition:
```sh
bash emulator/manage.sh materialize --snapshot GAME_SNAPSHOT --system-snapshot SD_SNAPSHOT
```
Use `--game-path PATH` if a snapshot contains multiple main executables. Source
paths and partition numbers are discovered from the manifest and ELF headers.
The system SD may be from another title of the same architecture; compatible
libraries remain required. Existing sessions retain their selected runtime.
Docker access is required; the script uses sudo when direct Docker access is
unavailable. Materialization downloads the selected system and game trees through Verstack's read-only API, verifies every BLAKE3 artifact,
and resumes by verifying existing files. Credentials are not copied into the
source tree or image. The emulator's game namespace has no network and uses
read-only game/system mounts. The worker receives namespace capabilities needed
by the donor's bubblewrap launcher; it is a local trusted-LAN pilot.
The worker binds **host loopback port 8095**. The Theia backend proxies
`/emulator` separately from archive operations. `VERSTACK_EMULATOR_PORT` changes
the proxy port. No machine-broker ports are published. Runtime files live in
ignored `data/emulator/runtime`; session scratch lives in a private 2 GiB
container tmpfs and is deleted on stop. Library SONAME aliases are reconstructed
in each private runtime view because this SD extraction omitted symlinks.
The worker restarts with Docker unless explicitly stopped. `start` starts an
existing container without replacing it. To load edits to the Python worker,
restart that container after stopping test sessions. Recreating it is needed
after changing container dependencies or resource limits.
## Pilot limits
- Two active sessions maximum (`VERSTACK_EMULATOR_MAX_SESSIONS` inside the
worker). Shared worker limits: 8 GiB RAM, six CPU cores, 512 processes and
2 GiB scratch. These are aggregate limits, not per-client resource guarantees.
- JPEG video at up to 12.5 fps, scaled to about 960×540, and 48 kHz stereo PCM
over a bounded same-origin HTTP stream. This is a simple test transport with
higher bandwidth and less precise A/V synchronization than WebRTC. Slow
streams disconnect rather than queue indefinitely. The client limits queued
audio to 250 ms. Real frame rate depends on guest execution and rendering.
- Each client has an unguessable credential required for control, state, logs,
and media. This isolates sessions but does not add user login or access
control to the existing LAN deployment. Use its existing trusted network.
- The browser must enable audio with a click. A stream reconnect currently
requires stopping and starting a session. Closing the Emulator tab discards
the local credential; saves are ephemeral.
- A runtime manifest means files were materialized; it does not mean a game
has booted. A process marked running does not establish playable gameplay.
## Verification
```sh
sudo docker exec verstack-emulator python3 -m unittest -v test_runtimes test_server
npm --prefix workbench/stern-catalog test
node tests/ui-emulator.mjs
```
The Python tests exercise two real brokers, credential isolation, capacity,
switch sequencing, media packet contents, disconnect release and process/scratch
cleanup. The browser test opens the workbench Emulator view and a second client,
checks rendered video and nonzero scheduled audio, sends a switch event, and
checks that stopping one session leaves the other streaming. These tests create
temporary diagnostic sessions; run with both worker slots free.
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
bundle_dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
exec python3 - "$bundle_dir" <<'PY'
import json,os,sys
from pathlib import Path
root=Path(sys.argv[1]).resolve()
if not (root/'active.json').exists():
raise SystemExit('Run Update.command first to build a game VM.')
active=json.loads((root/'active.json').read_text())
launcher=(root/active['directory']/'Launch.command').resolve()
if not launcher.is_relative_to(root/'images'):
raise SystemExit('Invalid active image path')
os.execv(str(launcher),[str(launcher)])
PY
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
set -eu
bundle_dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
if [ "$(uname -s)" != Darwin ] || [ "$(uname -m)" != arm64 ]; then
echo 'This bundle targets Apple Silicon macOS.' >&2
exit 1
fi
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else "Python 3.11+ is required: brew install python")'
if [ -x "$bundle_dir/.host-python/bin/python3" ]; then
export PATH="$bundle_dir/.host-python/bin:$PATH"
fi
if ! python3 -c 'from PIL import Image' >/dev/null 2>&1; then
echo 'Installing the dashboard image dependency into this bundle…'
python3 -m venv "$bundle_dir/.host-python"
"$bundle_dir/.host-python/bin/python3" -m pip install 'Pillow==12.3.0'
export PATH="$bundle_dir/.host-python/bin:$PATH"
fi
. "$bundle_dir/bundle-runtime.env"
export SPIKE3_EVIDENCE_ROOT="$bundle_dir/support"
export SPIKE3_NETBRIDGE_ELF="$bundle_dir/support/netbridge/netbridge.elf"
export SPIKE3_ENABLE_DASHBOARD=1
export SPIKE3_AUTOSTART=game
# Keep the supplied standalone switch model. No VPX table is required.
export SPIKE3_PHYSICS_AUTHORITY=synthetic
export SPIKE3_PLAYFIELD_PORT=0
export SPIKE3_VIDEO_BRIDGE=auto
if ! command -v qemu-system-aarch64 >/dev/null 2>&1; then
echo 'Install QEMU with: brew install qemu' >&2
exit 1
fi
# The dashboard controls are available even without optional browser streaming.
echo 'Cabinet controls: http://127.0.0.1:8090'
exec "$bundle_dir/emulation/scripts/run-macos.sh"
+62
View File
@@ -0,0 +1,62 @@
# Downloadable game VMs
The catalog's Download VM tab exports Apple Silicon macOS bundles based on
`~/pokemon_emulator`. `vendor/pokemon_emulator/SOURCE.json` records the upstream
commit and original source hashes. Local adaptations live alongside that source.
SPIKE 2's existing experimental emulator is preserved; this export builder
currently accepts SPIKE 3 ARM64 games.
Standard export packages the selected game and SD system into an ext4 VM with
its kernel, initramfs, Mac launcher, video bridge, and local switch dashboard.
Install Python 3.11+ and QEMU, unzip, and run `Launch.command`. The launcher
installs Pillow in a private environment if necessary. Controls are served on
localhost:8090, including Service / Enter, Back, +, and . Game-specific switch
mappings retain the supplied SPIKE 3 model. Exact-version local IC provisioning
remains hash-gated; unknown game binaries are not patched speculatively.
Supported exports prepare the synthetic EEPROM, local transport and TLS service
during the build. Their Mac launcher starts the supplied backend without Docker
or LLVM, using the same generated image and its private saved state.
Runtime selection uses the game's own SD first, then the closest same-title SD,
then an SD from the same machine generation. The manifest records the selected
runtime and reason. Experimental quick export includes a catalog client:
`Update.command` downloads verified game/runtime components and builds locally
with Docker; `Launch-universal.command` runs the selected image. `--list`,
`--game ID`, `--shared-runtime ID`, and `--default-runtime` select versions and
explicit same-generation runtime reuse. Components are cached by SHA-256.
The persistent export service is `verstack-exports.service` on localhost:8096,
proxied through the workbench. Builds have a separate serialized worker and
2-CPU/4-GiB Docker limits. Standard builds use temporary export scratch
(`VERSTACK_EXPORT_SCRATCH`, default `/tmp/verstack-vm-exports`), check free
space with reserves, and remove staging after success or failure. Only the final
ZIP and job logs remain in persistent storage. Restarting its HTTP service
preserves build workers. Standard cache keys cover the VM recipe and payload;
HTTP/scheduler/documentation edits do not invalidate existing standard downloads.
The quick client's failed local builds also remove their incomplete image and
extracted sources, retaining the previous active VM and verified downloads.
## Validation and current limitations
The standard Pokémon 0.83 ZIP was downloaded through the catalog with a matching
full-file SHA-256. The quick client downloaded actual runtime/game components
and reused the verified cache without downloading them again. Browser tests
cover standard and quick export controls. The downloaded quick client built 0.83 with a shared 0.81 SD and 0.85 with the
automatically selected nearest 0.83 SD. Both images passed filesystem and actual
game-byte checks. Version switching and the packaged Update.command reused
completed images. The quick-built guest rendered the Stern boot display in QEMU.
The user reports that Pokémon now runs on their Mac using upstream revision
`37d70e6`, shared by both recent 0.83 exports. The exact tested export and native
service-menu navigation remain unconfirmed; see docs/emulation.md for evidence.
A Mac test found audio with a blank display. Reproduction of that published image
showed missing kernel module dependency indexes: `virtio_gpu` failed to load,
while explicitly loaded sound modules worked. The image builder now runs depmod
on the final guest module tree, in addition to initramfs indexes. A repaired
copy boots with DRM, virtio_gpu, card0, and renderD128 present. The repaired image renders the Stern boot-display screen under QEMU TCG.
Native Mac gameplay remains unverified; this is not full playback validation.
Run `python3 -m unittest discover -s emulator -p 'test_bundle*.py'` for export
policy, staging, cache, and API tests. The vendored dashboard has its own Python
and JavaScript tests. Do not treat successful boot or manifests as proof of a
playable game. The overall implementation goal remains active.
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
bundle_dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
export PYTHONPATH="$bundle_dir/export-tools${PYTHONPATH:+:$PYTHONPATH}"
exec python3 -m bundles.universal --bundle "$bundle_dir" "$@"
+248
View File
@@ -0,0 +1,248 @@
"""VM export staging for the vendored Apple Silicon launcher.
Staging never edits source runtimes. The source tree is consumed by the upstream
prepare-rootfs-image script; finished exports contain its ext4 image and kernel.
"""
import json
import hashlib
from pathlib import Path
import shutil
import subprocess
from .runtime_selection import select_runtime
VENDOR = Path(__file__).parent / 'vendor/pokemon_emulator'
def runtime_inventory(root):
import runtimes
result = runtimes.inventory(root)
for row in result:
# Materialized updates record a distinct system_snapshot. Older SD
# manifests predate this field. Both have partition-root provenance.
own = row.get('system_snapshot', row.get('snapshot')) == row.get('snapshot')
row['is_sd'] = bool(row.get('system_root') and
(row.get('source', {}).get('url', '').endswith('.sdcard.raw.zip') or
(own and row['system_root'].startswith('partition-'))))
cache=Path(root)/'.export-catalog.json'
if cache.exists():
local_snapshots={r.get('snapshot') for r in result}
for row in json.loads(cache.read_text())['runtimes']:
if row['snapshot'] not in local_snapshots:
row['label']=runtimes.label(row['release'])
result.append(row)
return result
def plan(root, identity, shared_runtime_id=None):
rows = runtime_inventory(root)
matches = [r for r in rows if r['id'] == identity]
if len(matches) != 1:
raise ValueError('Choose an available game release')
game = matches[0]
selection = select_runtime(game, rows, shared_runtime_id=shared_runtime_id)
return game, selection
def clone_tree(source, destination):
shutil.copytree(source, destination, symlinks=True, dirs_exist_ok=True)
def restore_sonames(root, arch):
"""The archive extractor omitted SD symlinks; recover ELF SONAME links.
Inspect only selected SD libraries, not libraries from the build host.
"""
triplet = 'aarch64-linux-gnu' if arch == 'aarch64' else 'arm-linux-gnueabihf'
reader = shutil.which(triplet + '-readelf') or shutil.which('readelf')
if not reader:
raise RuntimeError('readelf is required to restore SD library links')
import re
for directory in ('lib', 'usr/lib'):
for path in sorted((root / directory).rglob('*')):
if not path.is_file() or path.is_symlink() or '.so' not in path.name:
continue
result = subprocess.run([reader, '-d', str(path)], capture_output=True, text=True)
match = re.search(r'\(SONAME\).*\[([^\]]+)\]', result.stdout)
if not match:
continue
name = match[1]
if name in ('.','..') or '/' in name or '\\' in name:
raise ValueError('Invalid library SONAME')
link = path.parent / name
if not link.exists() and not link.is_symlink():
link.symlink_to(path.name)
def restore_executables(root):
# The retained extractor materialized regular files with mode 0644.
# Restore executable ELF/script files in the private build tree only.
for path in root.rglob('*'):
if not path.is_file() or path.is_symlink():
continue
with path.open('rb') as stream:
header = stream.read(4)
if header.startswith(b'\x7fELF') or header.startswith(b'#!'):
path.chmod(path.stat().st_mode | 0o111)
def stage(root, identity, destination, *, shared_runtime_id=None):
game, selection = plan(root, identity, shared_runtime_id)
destination = Path(destination)
if destination.exists():
raise ValueError('Export staging destination must be new')
donor = selection['runtime']
destination.mkdir(parents=True)
clone_tree(VENDOR, destination)
source = destination / 'source-root'
clone_tree(Path(donor['directory']) / donor['system_root'], source)
games = source / 'games'
if games.exists() or games.is_symlink():
# SD system partitions may contain unrelated game links. These are
# copies inside the new stage, never the immutable archive/runtime.
if games.is_symlink(): games.unlink()
else: shutil.rmtree(games)
games.mkdir()
game_name = Path(game['game_root']).name
if game_name in ('.', '', 'games'):
game_name = 'title'
clone_tree(Path(game['directory']) / game['game_root'], games / game_name)
for item in (games / game_name).iterdir():
if item.name != game_name:
(games / item.name).symlink_to(game_name + '/' + item.name)
restore_sonames(source, game['architecture'])
restore_executables(source)
# This is a private copy of the selected SD, not a live session's state.
for name in ('data', 'dump'):
(source / name).mkdir(exist_ok=True)
metadata = dict(schema=1, target='macos-arm64', game_id=game['id'],
release=game['release'], architecture=game['architecture'],
generation=selection['generation'], runtime_id=donor['id'],
runtime_release=donor['release'], selection_reason=selection['reason'],
game_directory=game_name,
emulator_source=json.loads((VENDOR/'SOURCE.json').read_text())['commit'])
(destination/'bundle.json').write_text(json.dumps(metadata,indent=2)+'\n')
(source/'.rom-source.json').write_text(json.dumps(metadata)+'\n')
return metadata
def write_zip(source, output):
"""ZIP64 archive with executable modes and symlinks preserved for macOS."""
import os
import stat
import zipfile
source, output = Path(source), Path(output)
temporary = output.with_name(output.name + '.partial')
try:
with zipfile.ZipFile(temporary,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=1,allowZip64=True) as archive:
for path in sorted(source.rglob('*')):
relative = path.relative_to(source)
if relative.parts[0] in ('source-root','image-build','.host-python'):
continue # Selected system and game are in rootfs.ext4.
if path.is_symlink():
info=zipfile.ZipInfo(str(relative));info.create_system=3
info.external_attr=(stat.S_IFLNK | 0o777) << 16
archive.writestr(info,os.readlink(path))
elif path.is_file():
archive.write(path,str(relative))
temporary.replace(output)
finally:
temporary.unlink(missing_ok=True)
def node_firmware_version(game_directory):
"""Keep the existing node model, advertising the selected game's firmware."""
import re
versions=set()
for path in Path(game_directory).glob('*.hex'):
match=re.fullmatch(r'.*node.*-LPC[^/]+-(\d+)_(\d+)_(\d+)\.hex',path.name)
if match:
parts=tuple(map(int,match.groups()))
if any(part>255 for part in parts):
raise ValueError('Node firmware version cannot fit the existing wire protocol')
versions.add('.'.join(map(str,parts)))
if len(versions)>1:
raise ValueError('Selected game contains conflicting node firmware versions')
return next(iter(versions),'1.33.0')
def prepare_launcher(destination):
destination = Path(destination)
metadata = json.loads((destination/'bundle.json').read_text())
selected_game = destination/'source-root/games'/metadata['game_directory']
bridge = selected_game/'spike3/netbridge'
if not (bridge/'netbridge.elf').is_file():
raise ValueError('Selected SPIKE 3 game has no netbridge firmware')
clone_tree(bridge,destination/'support/netbridge')
playfield = selected_game/'assets/nuk/images/Test/pokemon_playfield_pro.png'
if playfield.is_file():
output=destination/'support/games/pokemon_pro/assets/nuk/images/Test'/playfield.name
output.parent.mkdir(parents=True,exist_ok=True)
shutil.copy2(playfield,output)
shutil.copy2(Path(__file__).with_name('Launch.command'),destination/'Launch.command')
# The donor's provisioning patches are guarded by exact binary hashes.
# Preserve that gate: other releases still get the existing nodebus model.
import importlib.util
provisioning = destination/'emulation/conagent-machine-emulator/provisioning.py'
spec = importlib.util.spec_from_file_location('bundle_provisioning', provisioning)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
game_hash = hashlib.sha256((selected_game/'game').read_bytes()).hexdigest()
try:
module.profile((selected_game/'game').read_bytes())
supported = True
except ValueError:
supported = False
prepared = (destination/'emulation/work/local-ic/manifest.json').is_file()
if supported and not prepared:
raise ValueError('Supported game is missing prepared local IC files')
node_version=node_firmware_version(selected_game)
(destination/'bundle-runtime.env').write_text(
'export SPIKE3_CONAGENT_BACKEND=${SPIKE3_CONAGENT_BACKEND:-' + ('1' if supported else '0') + '}\n'
+ ('export SPIKE3_LOCAL_IC_PREPARED=1\n' if prepared else '')
+ 'export SPIKE3_NODE_FIRMWARE_VERSION=${SPIKE3_NODE_FIRMWARE_VERSION:-'+node_version+'}\n')
metadata['game_sha256'] = game_hash
metadata['local_ic_supported'] = supported
metadata['node_firmware_version'] = node_version
(destination/'bundle.json').write_text(json.dumps(metadata,indent=2)+'\n')
(destination/'README.txt').write_text(
'Verstack game VM for Apple Silicon macOS\n\n'
'Install Python 3.11+ and QEMU (brew install python qemu), then double-click Launch.command.\n'
'First launch installs Pillow into a private Python environment if needed (internet required once).\n'
'Open http://127.0.0.1:8090 for cabinet and node-board switches.\n'
'The game runs in the QEMU window; no catalog connection is required.\n'
'This uses the existing SPIKE 3 switch model; game-specific mappings may differ.\n'
'The selected game and SD libraries are inside emulation/work/rootfs.ext4.\n'
'Save state is private to this image. Keep a backup before replacing it.\n'
'Optional browser video setup: python3 emulation/streaming/setup.py --work-dir emulation/work\n'
+ ('The local conagent backend is prepared and starts automatically; Docker is not needed to launch.\n'
if supported else 'Exact-version local IC provisioning is not available for this binary.\n'))
def build_image(destination, *, docker=('docker',), log=None):
"""Use the original image builder in a separate, bounded Linux container."""
destination=Path(destination).resolve()
metadata=json.loads((destination/'bundle.json').read_text())
if metadata['architecture'] != 'aarch64':
raise ValueError('ARM32 VM image support is still experimental and not wired into this builder')
repair=Path(__file__).with_name('repair-rootfs.sh').resolve()
command=[*docker,'run','--rm','--cpus','2','--memory','4g',
'--mount',f'type=bind,source={destination},target=/workspace',
'--mount',f'type=bind,source={repair},target=/repair-rootfs.sh,readonly',
'--mount',f'type=bind,source={Path(__file__).parent.resolve()},target=/export-tools/bundles,readonly',
'--env','SPIKE3_ROOTFS_SOURCE=/workspace/source-root',
'--env','SPIKE3_PATCH_PROFILE=none',
'--env','SPIKE3_ROOTFS_WORK_DIR=/workspace/image-build',
'--env','SPIKE3_ROOTFS_IMAGE_SIZE=6G',
'--entrypoint','bash','verstack-vm-builder:local','-ec',
'''PYTHONPATH=/export-tools python3 -c 'from pathlib import Path; from bundles.builder import restore_sonames,restore_executables; p=Path("/workspace/source-root"); restore_sonames(p,"aarch64"); restore_executables(p)'; '''
'bash /repair-rootfs.sh /workspace/source-root; '
'bash /workspace/emulation/scripts/prepare-rootfs-image.sh; '
'mkdir -p /workspace/emulation/work/macos-runtime; '
'mv /workspace/image-build/rootfs.ext4 /workspace/emulation/work/rootfs.ext4; '
'cp /opt/spike3-kernel/vmlinuz /opt/spike3-kernel/initrd.img '
'/workspace/emulation/work/macos-runtime/; '
'chmod 0644 /workspace/emulation/work/rootfs.ext4 /workspace/emulation/work/macos-runtime/*; '
'python3 /workspace/emulation/conagent-machine-emulator/prepare_export.py /workspace']
subprocess.run(command,check=True,stdout=log,stderr=subprocess.STDOUT)
prepare_launcher(destination)
+59
View File
@@ -0,0 +1,59 @@
"""Discover exported games and SD donors from the archive's extracted snapshots."""
import json
from pathlib import Path
import time
import subprocess
import os
import urllib.request
import materialize
from .jobs import atomic_json
def refresh(root, api):
root=Path(root);cache=root/'.export-catalog.json'
previous=json.loads(cache.read_text()) if cache.exists() else {'runtimes':[],'unavailable':[]}
known={r['snapshot']:r for r in previous['runtimes']}
with urllib.request.urlopen(api.rstrip('/')+'/api/library',timeout=120) as response:
snapshots=json.load(response)
rows=[];unavailable=[]
for summary in snapshots:
if summary['layer']!='extracted':continue
identity=summary['id']
if identity in known:rows.append(known[identity]);continue
try:
source=materialize.manifest(api,identity)
game,arch,system=materialize.discover(api,source)
rows.append(dict(id=identity,snapshot=identity,release=source['release'],architecture=arch,
is_sd=bool(system and system.startswith('partition-')),system_root=system,
game_root=str(Path(game).parent),targets={'game':'game'},
directory=str(root/identity),remote=True,api=api))
except ValueError as error:
unavailable.append(dict(id=identity,release=summary['release'],note=str(error)))
atomic_json(cache,dict(updated=time.time(),runtimes=rows,unavailable=unavailable))
return rows
def ensure_local(root,identity,shared_runtime_id=None):
from . import builder
game,selection=builder.plan(root,identity,shared_runtime_id)
donor=selection['runtime']
def prepare(row, system=None, game_path=None):
# Use the existing materialization toolchain, including BLAKE3, rather
# than depending on undeclared tools on the export service host.
app=Path(__file__).resolve().parents[1]
command=['sudo','-n','docker','run','--rm','--network','host','--cpus','2','--memory','2g',
'--user',f'{os.getuid()}:{os.getgid()}',
'--mount',f'type=bind,source={app},target=/app,readonly',
'--mount',f'type=bind,source={Path(root).resolve()},target=/runtime',
'verstack-emulator:local','python3','materialize.py','--api',row['api'],
'--snapshot',row['snapshot'],'--destination','/runtime']
if system:command+=['--system-snapshot',system]
if game_path:command+=['--game-path',game_path]
subprocess.run(command,check=True)
if donor.get('remote'):
prepare(donor)
if game.get('remote') and not (donor.get('remote') and donor['id']==game['id']):
prepare(game,system=donor['snapshot'] if donor['id']!=game['id'] else None,
game_path=game['game_root']+'/game' if game['game_root']!='.' else 'game')
return builder.plan(root,identity,shared_runtime_id)
+253
View File
@@ -0,0 +1,253 @@
"""Persistent export jobs and immutable component archives.
Workers hold OS locks, so restarting the HTTP service cannot duplicate an active
build. Only one export build uses CPU/disk at a time; catalog analysis is separate.
"""
import argparse
import fcntl
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time
import threading
import uuid
import zipfile
import tempfile
from . import builder
def atomic_json(path, value):
temporary=path.with_suffix('.next')
temporary.write_text(json.dumps(value,indent=2)+'\n')
os.replace(temporary,path)
def digest(path):
result=hashlib.sha256()
with Path(path).open('rb') as stream:
for chunk in iter(lambda:stream.read(1024*1024),b''):result.update(chunk)
return result.hexdigest()
def component(store, row, kind):
identity=row['id'] if kind=='game' else row['id']+':system'
key=hashlib.sha256(identity.encode()).hexdigest()
folder=Path(store)/'components';folder.mkdir(parents=True,exist_ok=True)
manifest=folder/(key+'.json')
if manifest.exists():
value=json.loads(manifest.read_text())
archive=folder/(value['sha256']+'.zip')
if archive.is_file() and archive.stat().st_size==value['bytes']:
return value
source=Path(row['directory'])/row['game_root' if kind=='game' else 'system_root']
temporary=folder/(key+'.partial')
try:
with zipfile.ZipFile(temporary,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=1,allowZip64=True) as archive:
for path in sorted(source.rglob('*')):
if path.is_file() and not path.is_symlink():
archive.write(path,str(path.relative_to(source)))
sha=digest(temporary);target=folder/(sha+'.zip');os.replace(temporary,target)
value=dict(sha256=sha,bytes=target.stat().st_size,url='/emulator/exports/components/'+sha)
atomic_json(manifest,value)
return value
finally:temporary.unlink(missing_ok=True)
def describe_plan(runtime_root, store, game_id, shared_runtime_id=None):
game, selection=builder.plan(runtime_root,game_id,shared_runtime_id)
donor=selection['runtime']
return dict(game_id=game_id,generation=selection['generation'],architecture=game['architecture'],
runtime_id=donor['id'],release=game['release'],runtime_release=donor['release'],
selection_reason=selection['reason'],game_directory=Path(game['game_root']).name,
game_component=component(store,game,'game'),runtime_component=component(store,donor,'runtime'))
def quick_bundle(destination, request):
destination.mkdir(parents=True,exist_ok=False)
shutil.copytree(Path(__file__).parent,destination/'export-tools/bundles',
ignore=shutil.ignore_patterns('__pycache__','*.pyc','web'))
atomic_json(destination/'connection.json',dict(catalog_url=request['catalog_url'],
game_id=request['game_id'],shared_runtime_id=request.get('shared_runtime_id')))
for name in ('Update.command','Launch-universal.command'):
shutil.copy2(Path(__file__).with_name(name),destination/name)
(destination/'README.txt').write_text(
'Experimental universal runtime for Apple Silicon Macs.\n'
'Run Update.command to fetch this game and its selected SD runtime and build a local VM.\n'
'Run Launch-universal.command to start the selected local VM and switch panel.\n'
'Requires Python 3, Docker Desktop, and QEMU (brew install qemu).\n'
'Update.command --list lists catalog games; --game ID changes versions.\n'
'Use --shared-runtime ID to try a cached SD runtime for the same SPIKE generation.\n'
'Use --default-runtime to restore automatic SD selection.\n')
def source_revision(mode='quick'):
revision=hashlib.sha256()
root=Path(__file__).parent
# Standard archives contain the VM recipe/launcher and vendored runtime.
# HTTP, job scheduling, and quick-client documentation do not change them.
paths=(list((root/'vendor').rglob('*')) +
[root/name for name in ('builder.py','runtime_selection.py','repair-rootfs.sh','Launch.command')]
if mode=='standard' else list(root.rglob('*')))
revision.update(('artifact-v2:'+mode).encode())
for path in sorted(paths):
if path.is_file() and '__pycache__' not in path.parts:
revision.update(str(path.relative_to(Path(__file__).parent)).encode())
revision.update(path.read_bytes())
return revision.hexdigest()
def create_job(store, runtime_root, request):
game, selection=builder.plan(runtime_root,request['game_id'],request.get('shared_runtime_id'))
if game['architecture']!='aarch64':
raise ValueError('SPIKE 2 VM export is not ready; its existing experimental emulator is retained')
if request.get('mode') not in ('standard','quick','components'):
raise ValueError('Choose standard or quick export')
revision=source_revision(request['mode'])
for record in (Path(store)/'jobs').glob('*/job.json'):
prior=json.loads(record.read_text())
if (prior.get('request')==request and prior.get('source_revision')==revision and
prior.get('runtime_id')==selection['runtime_id'] and prior['status']!='failed'):
if prior['status']!='complete' or (record.parent/'download.zip').is_file() or request['mode']=='components':
return prior
identity=uuid.uuid4().hex;directory=Path(store)/'jobs'/identity;directory.mkdir(parents=True)
value=dict(id=identity,status='queued',created=time.time(),request=request,
detail='Waiting for the export builder',game_label=game['label'],
runtime_id=selection['runtime_id'],source_revision=revision)
atomic_json(directory/'job.json',value)
spawn(store,runtime_root,identity)
return value
def spawn(store,runtime_root,identity):
directory=Path(store)/'jobs'/identity
with (directory/'worker.log').open('ab') as log:
process=subprocess.Popen([sys.executable,'-m','bundles.jobs','--store',str(Path(store).resolve()),
'--runtimes',str(Path(runtime_root).resolve()),'--job',identity],
cwd=Path(__file__).parent.parent,stdout=log,stderr=subprocess.STDOUT,
start_new_session=True)
threading.Thread(target=process.wait,daemon=True).start()
def remove_private_stage(stage):
"""Remove only our private build tree, including Docker-owned directories."""
if not stage.exists():return
try:
shutil.rmtree(stage)
except PermissionError:
subprocess.run(['sudo','-n','docker','run','--rm','--network','none',
'--mount',f'type=bind,source={stage.resolve()},target=/cleanup',
'--entrypoint','python3','verstack-vm-builder:local','-c',
'import pathlib,shutil; '
'[(p.unlink() if p.is_symlink() or not p.is_dir() else shutil.rmtree(p)) '
'for p in pathlib.Path("/cleanup").iterdir()]'],check=True)
stage.rmdir()
def standard_export(directory,runtime_root,request,update):
game,selection=builder.plan(runtime_root,request['game_id'],request.get('shared_runtime_id'))
inputs=[Path(game['directory'])/game['game_root'],
Path(selection['runtime']['directory'])/selection['runtime']['system_root']]
source_bytes=sum(p.stat().st_size for root in inputs for p in root.rglob('*')
if p.is_file() and not p.is_symlink())
scratch=Path(os.environ.get('VERSTACK_EXPORT_SCRATCH',
str(Path(tempfile.gettempdir())/'verstack-vm-exports'))).resolve()
scratch.mkdir(parents=True,exist_ok=True)
gib=1024**3
# Only the final archive belongs on persistent storage. Include room for
# injected kernel/Mesa files and leave capacity for concurrent imports.
archive_need=source_bytes+4*gib
scratch_need=source_bytes*3+12*gib
same_device=scratch.stat().st_dev==directory.stat().st_dev
if shutil.disk_usage(directory).free < archive_need+(scratch_need if same_device else 0):
raise ValueError('Insufficient archive space for this export and existing-job reserve')
if not same_device and shutil.disk_usage(scratch).free < scratch_need:
raise ValueError('Insufficient export scratch space; active catalog work takes priority')
with tempfile.TemporaryDirectory(prefix='build-',dir=scratch) as temporary:
stage=Path(temporary)/'bundle'
try:
update('building','Staging selected game and SD libraries in export scratch')
builder.stage(runtime_root,request['game_id'],stage,
shared_runtime_id=request.get('shared_runtime_id'))
update('building','Building ARM VM image')
with (directory/'build.log').open('a') as log:
builder.build_image(stage,docker=('sudo','-n','docker'),log=log)
update('packaging','Compressing downloadable ZIP')
builder.write_zip(stage,directory/'download.zip')
finally:
remove_private_stage(stage)
def work(store,runtime_root,identity):
directory=Path(store)/'jobs'/identity;record=directory/'job.json'
with (directory/'lock').open('w') as lock:
try:fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
except BlockingIOError:return
job=json.loads(record.read_text())
if job['status'] in ('complete','failed'):return
def update(status,detail,**extra):
job.update(status=status,detail=detail,updated=time.time(),**extra);atomic_json(record,job)
with (Path(store)/'builder.lock').open('w') as global_lock:
update('queued','Waiting for the export builder')
fcntl.flock(global_lock,fcntl.LOCK_EX)
try:
request=job['request'];stage=directory/'bundle'
if request['mode']!='quick':
update('building','Preparing selected archive release and SD runtime')
from .catalog import ensure_local
ensure_local(runtime_root,request['game_id'],request.get('shared_runtime_id'))
if request['mode']=='components':
update('building','Preparing cached game and SD runtime downloads')
result=describe_plan(runtime_root,store,request['game_id'],request.get('shared_runtime_id'))
update('complete','Components ready',plan=result);return
if request['mode']=='quick':
update('building','Preparing universal runtime launcher')
if stage.exists():shutil.rmtree(stage)
quick_bundle(stage,request)
update('packaging','Compressing downloadable ZIP')
builder.write_zip(stage,directory/'download.zip')
else:
standard_export(directory,runtime_root,request,update)
output=directory/'download.zip'
update('complete','Download ready',bytes=output.stat().st_size,sha256=digest(output))
except Exception as error:
update('failed','Export failed',error=str(error))
raise
def publish_prepared(store,runtime_root,prepared):
"""Publish an already built/validated VM without rebuilding the same image."""
prepared=Path(prepared)
metadata=json.loads((prepared/'bundle.json').read_text())
game,selection=builder.plan(runtime_root,metadata['game_id'])
if selection['runtime_id']!=metadata['runtime_id']:
raise ValueError('Prepared image no longer matches current SD selection')
for name in ('Launch.command','emulation/work/rootfs.ext4',
'emulation/work/macos-runtime/vmlinuz','emulation/work/macos-runtime/initrd.img'):
if not (prepared/name).is_file() or not (prepared/name).stat().st_size:
raise ValueError('Incomplete prepared VM: '+name)
identity=uuid.uuid4().hex;directory=Path(store)/'jobs'/identity;directory.mkdir(parents=True)
with (directory/'lock').open('w') as lock, (Path(store)/'builder.lock').open('w') as global_lock:
fcntl.flock(lock,fcntl.LOCK_EX);fcntl.flock(global_lock,fcntl.LOCK_EX)
job=dict(id=identity,status='packaging',created=time.time(),
request=dict(game_id=game['id'],mode='standard'),runtime_id=selection['runtime_id'],
source_revision=source_revision('standard'),detail='Packaging verified VM image',game_label=game['label'])
atomic_json(directory/'job.json',job)
output=directory/'download.zip'
try:
builder.write_zip(prepared,output)
job.update(status='complete',detail='Download ready',bytes=output.stat().st_size,sha256=digest(output))
except Exception as error:
job.update(status='failed',error=str(error));raise
finally:atomic_json(directory/'job.json',job)
return job
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--store',required=True)
parser.add_argument('--runtimes',required=True);parser.add_argument('--job',required=True)
args=parser.parse_args();work(args.store,args.runtimes,args.job)
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
source_root=${1:?selected SD source root}
# Buildroot's ARM64 loader searches lib64; these SD aliases were omitted.
for directory in "$source_root" "$source_root/usr"; do
if [ -d "$directory/lib" ] && [ ! -e "$directory/lib64" ] && [ ! -L "$directory/lib64" ]; then
ln -s lib "$directory/lib64"
fi
done
# Reconstruct omitted BusyBox applet links from that SD's BusyBox itself.
# This does not substitute a build-host shell or runtime library.
if [ -f "$source_root/bin/busybox" ]; then
qemu-aarch64-static -L "$source_root" "$source_root/lib/ld-linux-aarch64.so.1" \
--library-path "$source_root/lib:$source_root/usr/lib" "$source_root/bin/busybox" --list > /tmp/spike-busybox-applets
while IFS= read -r applet; do
case "$applet" in ''|*/*|..|.) exit 2;; esac
if [ ! -e "$source_root/bin/$applet" ] && [ ! -L "$source_root/bin/$applet" ]; then
ln -s busybox "$source_root/bin/$applet"
fi
done < /tmp/spike-busybox-applets
mkdir -p "$source_root/sbin"
if [ ! -e "$source_root/sbin/modprobe" ] && [ ! -L "$source_root/sbin/modprobe" ]; then
ln -s ../bin/busybox "$source_root/sbin/modprobe"
fi
fi
[ -e "$source_root/bin/sh" ] || { echo 'Selected runtime has no usable shell' >&2; exit 1; }
+69
View File
@@ -0,0 +1,69 @@
"""Deterministic SD donor selection; shared by standard and universal exports.
Candidates must describe original SD systems, never another update's borrowed
runtime. The caller supplies archive provenance and verified ELF architecture.
"""
import re
import unicodedata
def title(value):
return unicodedata.normalize('NFC', value.strip()).casefold()
def version(value):
match = re.fullmatch(r'v?(\d+(?:\.\d+)*)(?:[-+].*)?', value.strip())
if not match:
raise ValueError('Cannot compare release version: ' + value)
parts = tuple(map(int, match[1].split('.')))
return parts + (0,) * max(0, 4 - len(parts))
def family(record):
arch = record['architecture']
if arch not in ('aarch64', 'armhf'):
raise ValueError('Unsupported SPIKE architecture: ' + arch)
return 'spike3' if arch == 'aarch64' else 'spike2'
def select_runtime(game, candidates, *, shared_runtime_id=None):
"""Return donor + selection reason, without mutating either inventory.
The explicit experimental shared-runtime override is generation-scoped.
Without that override, own SD wins, followed by closest same-title SD,
followed by a deterministic compatible-family SD. A candidate must include
id, architecture, is_sd, system_root, and release(repository, version).
"""
generation = family(game)
compatible = [r for r in candidates if r.get('is_sd') and
r.get('system_root') and family(r) == generation]
by_id = {r['id']: r for r in compatible}
if len(by_id) != len(compatible):
raise ValueError('Duplicate SD runtime identity')
if shared_runtime_id is not None:
if shared_runtime_id not in by_id:
raise ValueError('Shared runtime must be an SD runtime of the same SPIKE generation')
donor, reason = by_id[shared_runtime_id], 'experimental-shared-generation'
elif game.get('is_sd'):
if game['id'] not in by_id:
raise ValueError('Selected SD release is missing its own system; cannot substitute a donor')
donor, reason = by_id[game['id']], 'own-sd'
else:
same_title = [r for r in compatible if title(r['release']['repository']) ==
title(game['release']['repository'])]
if same_title:
wanted = version(game['release']['version'])
def distance(row):
candidate = version(row['release']['version'])
width = max(len(wanted), len(candidate))
a = wanted + (0,) * (width - len(wanted))
b = candidate + (0,) * (width - len(candidate))
# Prefer the older donor when semantic component distances tie.
return tuple(abs(x-y) for x,y in zip(a,b)), b > a, row['id']
donor, reason = min(same_title, key=distance), 'closest-title-sd'
elif compatible:
donor, reason = min(compatible, key=lambda r: r['id']), 'generation-sd'
else:
raise ValueError('No SD runtime available for ' + generation)
return {'runtime': donor, 'reason': reason, 'generation': generation,
'game_id': game['id'], 'runtime_id': donor['id']}
+158
View File
@@ -0,0 +1,158 @@
"""Local VM export API. Run separately from archive analysis and emulation."""
import argparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import mimetypes
from pathlib import Path
import re
import threading
import urllib.parse
from . import builder, jobs
class ExportServer(ThreadingHTTPServer):
daemon_threads=True
def __init__(self,address,store,runtime_root,api=None):
super().__init__(address,Handler)
self.store=Path(store).resolve();self.store.mkdir(parents=True,exist_ok=True)
(self.store/'jobs').mkdir(exist_ok=True)
self.runtime_root=Path(runtime_root).resolve();self.create_lock=threading.Lock()
self.catalog_error=''
if api:
def discover():
from .catalog import refresh
import time
while True:
try:refresh(self.runtime_root,api);self.catalog_error=''
except Exception as error:self.catalog_error=str(error)
time.sleep(300)
threading.Thread(target=discover,daemon=True).start()
for path in (self.store/'jobs').glob('*/job.json'):
record=json.loads(path.read_text())
if record['status'] not in ('complete','failed'):
jobs.spawn(self.store,self.runtime_root,record['id'])
def catalog(self):
rows=builder.runtime_inventory(self.runtime_root);result=[]
for row in rows:
try:
game,selection=builder.plan(self.runtime_root,row['id'])
note='' if game['architecture']=='aarch64' else 'SPIKE 2 export is preliminary; the existing test emulator is retained.'
result.append(dict(id=row['id'],label=row['label'],exportable=row['architecture']=='aarch64',
architecture=row['architecture'],runtime_label=selection['runtime']['label'],note=note,
runtimes=[dict(id=r['id'],label=r['label']) for r in rows if r['is_sd'] and r['architecture']==row['architecture']]))
except ValueError as error:
result.append(dict(id=row['id'],label=row['label'],exportable=False,note=str(error),runtimes=[]))
cache=self.runtime_root/'.export-catalog.json'
if cache.exists():
from runtimes import label
for row in json.loads(cache.read_text()).get('unavailable',[]):
result.append(dict(id=row['id'],label=label(row['release']),exportable=False,note=row['note'],runtimes=[]))
return result
class Handler(BaseHTTPRequestHandler):
def json(self,value,status=200):
body=json.dumps(value).encode();self.send_response(status)
self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(body)))
self.send_header('Cache-Control','no-store');self.end_headers();self.wfile.write(body)
def file(self,path,download=False):
if not path.is_file():return self.json({'error':'Download is not ready'},404)
size=path.stat().st_size;start=0;end=size-1;status=200
value=self.headers.get('Range')
if value:
match=re.fullmatch(r'bytes=(\d+)-(\d*)',value)
if not match:return self.json({'error':'Unsupported byte range'},416)
start=int(match[1]);end=min(int(match[2]) if match[2] else end,end)
if start>end:return self.json({'error':'Range outside file'},416)
status=206
self.send_response(status);self.send_header('Content-Type',mimetypes.guess_type(path.name)[0] or 'application/octet-stream')
self.send_header('Content-Length',str(end-start+1));self.send_header('Accept-Ranges','bytes')
if status==206:self.send_header('Content-Range',f'bytes {start}-{end}/{size}')
if download:self.send_header('Content-Disposition','attachment; filename="verstack-vm.zip"')
self.end_headers()
with path.open('rb') as source:
source.seek(start);remaining=end-start+1
while remaining:
data=source.read(min(1024*1024,remaining))
if not data:break
self.wfile.write(data);remaining-=len(data)
def do_GET(self):
try:self.get()
except (BrokenPipeError,ConnectionResetError):pass
except (ValueError,KeyError) as error:self.json({'error':str(error)},400)
except Exception as error:self.json({'error':str(error)},500)
def get(self):
parsed=urllib.parse.urlsplit(self.path);path=parsed.path;query=urllib.parse.parse_qs(parsed.query)
prefix='/emulator/exports'
if path==prefix+'/ui':return self.file(Path(__file__).parent/'web/index.html')
if path==prefix+'/catalog':return self.json(self.server.catalog())
if path==prefix+'/plan':
game=query.get('game_id',[''])[0];shared=query.get('shared_runtime_id',[None])[0]
_,selection=builder.plan(self.server.runtime_root,game,shared)
request=dict(game_id=game,shared_runtime_id=shared,mode='components')
with self.server.create_lock:
matches=[]
for record in (self.server.store/'jobs').glob('*/job.json'):
job=json.loads(record.read_text())
if job['request']==request and job.get('runtime_id')==selection['runtime_id']:matches.append(job)
job=max(matches,key=lambda j:j['created']) if matches else jobs.create_job(self.server.store,self.server.runtime_root,request)
# A fresh Update.command may retry a failed preparation once.
# Ordinary polling must keep reporting the failure, rather
# than repeatedly launching failing workers in the background.
if job['status']=='failed' and query.get('retry',['0'])[0]=='1':
job=jobs.create_job(self.server.store,self.server.runtime_root,request)
if job['status']=='complete':return self.json(job['plan'])
if job['status']=='failed':return self.json({'error':job.get('error','Component build failed')},500)
return self.json({'status':'preparing','id':job['id']},202)
match=re.fullmatch(prefix+r'/components/([0-9a-f]{64})',path)
if match:return self.file(self.server.store/'components'/(match[1]+'.zip'))
match=re.fullmatch(prefix+r'/([0-9a-f]{32})(/download)?',path)
if match:
directory=self.server.store/'jobs'/match[1];record=directory/'job.json'
if not record.is_file():return self.json({'error':'Export not found'},404)
job=json.loads(record.read_text())
if match[2]:
if job['status']!='complete':return self.json({'error':'Export is not complete'},409)
return self.file(directory/'download.zip',True)
for name in ('build.log','worker.log'):
log=directory/name
if log.is_file():
with log.open('rb') as stream:
stream.seek(max(0,log.stat().st_size-8000));job['log']=stream.read().decode(errors='replace')
break
job.pop('request',None);return self.json(job)
return self.json({'error':'Not found'},404)
def do_POST(self):
try:
if self.path!='/emulator/exports':return self.json({'error':'Not found'},404)
origin=self.headers.get('Origin')
if origin and urllib.parse.urlsplit(origin).netloc!=self.headers.get('Host'):
return self.json({'error':'Cross-origin request rejected'},403)
if self.headers.get('X-Verstack-Client')!='1':return self.json({'error':'Missing client header'},403)
length=int(self.headers.get('Content-Length','0'))
if not 0<length<=8192:return self.json({'error':'Invalid request size'},400)
value=json.loads(self.rfile.read(length))
request={k:value[k] for k in ('game_id','mode','shared_runtime_id','catalog_url') if k in value}
if request.get('mode')=='quick':
parsed=urllib.parse.urlsplit(request.get('catalog_url',''))
if parsed.scheme not in ('http','https') or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ValueError('Quick export requires the catalog HTTP(S) address')
if request.get('mode')=='standard':request.pop('catalog_url',None)
if request.get('mode')=='components':raise ValueError('Use the component plan endpoint')
with self.server.create_lock:job=jobs.create_job(self.server.store,self.server.runtime_root,request)
return self.json({'id':job['id'],'status':job['status']},202)
except (ValueError,KeyError,TypeError) as error:self.json({'error':str(error)},400)
except Exception as error:self.json({'error':str(error)},500)
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--port',type=int,default=8096)
parser.add_argument('--store',default='data/emulator/exports');parser.add_argument('--runtimes',default='data/emulator/runtime')
parser.add_argument('--api',default='http://127.0.0.1:8080')
args=parser.parse_args();ExportServer(('127.0.0.1',args.port),args.store,args.runtimes,args.api).serve_forever()
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Experimental local cache client for catalog VM export components."""
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import urllib.parse
import urllib.request
import zipfile
MAX_COMPONENT_BYTES = 24 * 1024**3
def api_json(base, path, query):
url=base.rstrip('/')+path+'?'+urllib.parse.urlencode(query)
with urllib.request.urlopen(url,timeout=120) as response:
return json.load(response)
def fetch_component(base, component, cache):
"""Cache verified immutable bytes; interrupted downloads never become hits."""
expected=component['sha256'];size=component['bytes']
if not re.fullmatch('[0-9a-f]{64}',expected) or not isinstance(size,int) or not 0<size<=MAX_COMPONENT_BYTES:
raise ValueError('Invalid component descriptor')
base_url=urllib.parse.urlsplit(base)
url=urllib.parse.urljoin(base.rstrip('/')+'/',component['url'])
parsed=urllib.parse.urlsplit(url)
if parsed.scheme not in ('http','https') or (parsed.scheme,parsed.netloc)!=(base_url.scheme,base_url.netloc):
raise ValueError('Component URL must belong to the configured catalog')
cache=Path(cache);cache.mkdir(parents=True,exist_ok=True)
target=cache/(expected+'.zip')
def digest(path):
h=hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda:stream.read(1024*1024),b''):h.update(chunk)
return h.hexdigest()
if target.is_file() and target.stat().st_size==size and digest(target)==expected:
return target
temporary=target.with_suffix('.partial')
try:
h=hashlib.sha256();received=0
with urllib.request.urlopen(url,timeout=180) as response,temporary.open('wb') as output:
for chunk in iter(lambda:response.read(1024*1024),b''):
received+=len(chunk)
if received>size:raise ValueError('Component exceeds declared size')
h.update(chunk);output.write(chunk)
if received!=size or h.hexdigest()!=expected:raise ValueError('Component checksum mismatch')
os.replace(temporary,target)
finally:temporary.unlink(missing_ok=True)
return target
def unpack_component(archive, destination):
"""Components contain regular archive files; SD links are rebuilt later."""
destination=Path(destination)
destination.mkdir(parents=True,exist_ok=False)
with zipfile.ZipFile(archive) as source:
total=0;names=set()
for member in source.infolist():
path=PurePosixPath(member.filename)
if path.is_absolute() or '..' in path.parts or '\\' in member.filename or not path.parts:
raise ValueError('Unsafe component path')
if str(path) in names:raise ValueError('Duplicate component path')
names.add(str(path));total+=member.file_size
if total>MAX_COMPONENT_BYTES:raise ValueError('Expanded component exceeds limit')
kind=(member.external_attr>>16)&0o170000
if kind not in (0,0o100000,0o040000):raise ValueError('Component links and special files are not accepted')
target=destination.joinpath(*path.parts)
if member.is_dir():target.mkdir(parents=True,exist_ok=True);continue
target.parent.mkdir(parents=True,exist_ok=True)
with source.open(member) as stream,target.open('xb') as output:
for chunk in iter(lambda:stream.read(1024*1024),b''):output.write(chunk)
target.chmod(0o755 if member.external_attr>>16 & 0o111 else 0o644)
def resolve_and_fetch(base, game_id, cache, shared_runtime_id=None):
query={'game_id':game_id}
if shared_runtime_id:query['shared_runtime_id']=shared_runtime_id
import time
selected=api_json(base,'/emulator/exports/plan',{**query,'retry':'1'})
while selected.get('status')=='preparing':
print('Server is preparing cached components…',flush=True)
time.sleep(3)
selected=api_json(base,'/emulator/exports/plan',query)
if selected.get('error'):raise ValueError(selected['error'])
generation=selected['generation']
if generation not in ('spike2','spike3'):raise ValueError('Unsupported generation')
if selected['game_id']!=game_id:raise ValueError('Catalog returned another game')
if shared_runtime_id and selected['runtime_id']!=shared_runtime_id:
raise ValueError('Catalog did not honor selected shared runtime')
runtime=fetch_component(base,selected['runtime_component'],Path(cache)/generation/'runtimes')
game=fetch_component(base,selected['game_component'],Path(cache)/generation/'games')
return selected,runtime,game
def update_image(bundle, selected, runtime_archive, game_archive):
import shutil
import subprocess
from . import builder
from .jobs import source_revision
bundle=Path(bundle).resolve()
recipe=source_revision('standard')
fingerprint=hashlib.sha256(json.dumps({'plan':selected,'recipe':recipe},sort_keys=True).encode()).hexdigest()[:32]
destination=bundle/'images'/fingerprint
required=('Launch.command','emulation/work/rootfs.ext4',
'emulation/work/macos-runtime/vmlinuz','emulation/work/macos-runtime/initrd.img')
complete=destination/'build-complete.json'
if not complete.is_file() or not all((destination/p).is_file() and (destination/p).stat().st_size for p in required):
if destination.exists():shutil.rmtree(destination)
destination.mkdir(parents=True)
try:
shutil.copytree(builder.VENDOR,destination,dirs_exist_ok=True)
unpack_component(runtime_archive,destination/'source-root')
games=destination/'source-root/games'
if games.exists():shutil.rmtree(games)
games.mkdir()
name=selected['game_directory']
if name in ('','.','..') or '/' in name or '\\' in name:raise ValueError('Invalid game directory')
unpack_component(game_archive,games/name)
for item in (games/name).iterdir():
if item.name!=name:(games/item.name).symlink_to(name+'/'+item.name)
metadata={k:v for k,v in selected.items() if not k.endswith('_component')}
metadata.update(schema=1,target='macos-arm64')
(destination/'bundle.json').write_text(json.dumps(metadata,indent=2)+'\n')
if subprocess.run(['docker','image','inspect','verstack-vm-builder:local'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode:
subprocess.run(['docker','build','-t','verstack-vm-builder:local','-f',
str(builder.VENDOR/'emulation/docker/Dockerfile'),str(builder.VENDOR)],check=True)
builder.build_image(destination)
# The runnable image now contains these immutable source bytes.
shutil.rmtree(destination/'source-root')
complete.write_text(json.dumps({'recipe':recipe})+'\n')
except BaseException:
# Only this incomplete recipe directory belongs to this attempt.
# Keep the active image and verified component cache untouched.
shutil.rmtree(destination)
raise
active={'directory':str(destination.relative_to(bundle)), 'game_id':selected['game_id'],
'runtime_id':selected['runtime_id'],'generation':selected['generation']}
temporary=bundle/'active.next';temporary.write_text(json.dumps(active,indent=2)+'\n')
os.replace(temporary,bundle/'active.json')
return destination
def main():
import argparse
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--bundle',type=Path,required=True)
parser.add_argument('--game');parser.add_argument('--shared-runtime')
parser.add_argument('--default-runtime',action='store_true');parser.add_argument('--list',action='store_true')
parser.add_argument('--cache',type=Path)
args=parser.parse_args();bundle=args.bundle.resolve()
connection=json.loads((bundle/'connection.json').read_text())
base=connection['catalog_url']
if args.list:
for row in api_json(base,'/emulator/exports/catalog',{}):
print(row['id']+' '+row['label']+('' if row['exportable'] else ' [not available]'))
return
game=args.game or connection['game_id']
shared=None if args.default_runtime else args.shared_runtime or connection.get('shared_runtime_id')
cache=args.cache or Path.home()/'Library/Caches/VerstackEmulator'
print('Resolving game and SD runtime from '+base,flush=True)
selected,runtime,game_archive=resolve_and_fetch(base,game,cache,shared)
print('Building '+selected['release']['repository']+' '+selected['release']['version'],flush=True)
destination=update_image(bundle,selected,runtime,game_archive)
connection.update(game_id=game,shared_runtime_id=shared)
temporary=bundle/'connection.next';temporary.write_text(json.dumps(connection,indent=2)+'\n')
os.replace(temporary,bundle/'connection.json')
print('Ready: '+str(destination/'Launch.command'))
if __name__=='__main__':main()
+498
View File
@@ -0,0 +1,498 @@
{
"source": "~/pokemon_emulator",
"commit": "37d70e64286460938772e7de952dc009d889c198",
"files": [
{
"path": "emulation/scripts/build-buildroot-mesa.sh",
"sha256": "8c7002e205bbb619b91ba385b2c9d74a9cf3088beea118634788c321d1bac6e9"
},
{
"path": "emulation/scripts/capture-vnc.py",
"sha256": "1062b060d4bf6a4798bcca3ee188d4c53322271cd882f8ef36b6996b716572a0"
},
{
"path": "emulation/scripts/configure-vpx-controller-profile.py",
"sha256": "bf6653a3e04248776debb8d7a74f975f540c93be909f4f53ee3015dd95dd88cd"
},
{
"path": "emulation/scripts/gen-seckc-logo.py",
"sha256": "290b1d839b4e5cad411a7d37c55e249bc1eff3aede7370a51badd9b0c9826444"
},
{
"path": "emulation/scripts/launch-new-session.c",
"sha256": "9966f2db43a0fd73b3f393b29d3c3396bf07651739d66e72a01d1c0034753525"
},
{
"path": "emulation/scripts/macos-audio-rate.py",
"sha256": "42a7bccdc77c6f2f7451dacf2cf3cc58dde0e36fbd5a8e38f37d1879c311483f"
},
{
"path": "emulation/scripts/patch-boot-logo.sh",
"sha256": "051470291c8f142ba9cb0092d31bf51608dfcec4f208ff96bc5693ea7f71e0ee"
},
{
"path": "emulation/scripts/patch-game-country-lock.sh",
"sha256": "3bb10d57fa58ac10293adad21b14aa8d1b4ee8b1f131f83f9eccec7264c94ae9"
},
{
"path": "emulation/scripts/patch-game-factory-feature-flags.sh",
"sha256": "ad343b98c554bdc313439a9b0d5839b9c520b33c1f7297545c7a5a1696b33dc9"
},
{
"path": "emulation/scripts/patch-game-free-play.sh",
"sha256": "c8974a608dba6092fa50f312e62b39a8bbcc73bb3e921e5534653c6459e110e8"
},
{
"path": "emulation/scripts/patch-game-skip-node-runtime-update.sh",
"sha256": "30156eef2ecb3296872a4127dec16efda0df20e93b24c6a2991877349095b978"
},
{
"path": "emulation/scripts/patch-game-skip-video.sh",
"sha256": "d6276293a704e018ccd0775e1aaa8a9bcb93e558c6d27e1ba7c924b8cef84ca9"
},
{
"path": "emulation/scripts/playable-window-layout.swift",
"sha256": "60fbf34a5e917b2c8265ef82f8775aa45a0493da91dd0e11e963ae5d215a1b90"
},
{
"path": "emulation/scripts/prepare-rootfs-image.sh",
"sha256": "a6e24ad5e3d634dd624fb0c9420f564b6154d222b5b1ea5ad0830b699e86a3dc"
},
{
"path": "emulation/scripts/preserve-rootfs-saves.py",
"sha256": "96d6bee9ad7c1eb572d42f0998083b590b71060c94a74fe1e1dc37ae8e21642c"
},
{
"path": "emulation/scripts/run-macos.sh",
"sha256": "f38eeb68adb009ea7473a2a7e3ed0d2ea51b7c19eab2b476ffb85825fd75e0f6"
},
{
"path": "emulation/scripts/run-playable-macos.sh",
"sha256": "6c734658b1d3bbaf8035c791de5ea48b268b75d710c3129a984571256407c35b"
},
{
"path": "emulation/scripts/run-playable-manual-macos.sh",
"sha256": "86e72586d3dd05ad7acef7c4cfafa6ffd147ad6976e0c8faa7d964719519bf9e"
},
{
"path": "emulation/scripts/run-qemu.sh",
"sha256": "a65536a557c19d441a3237725ce66b63edd52f8583434da8b173124ba7547880"
},
{
"path": "emulation/scripts/sample-runtime-cpu.py",
"sha256": "bf9a555a40b0cd86f33e03603da91d246cc227bc0fd7f3fa8108e7d6b45956c1"
},
{
"path": "emulation/scripts/summarize-vpx-latency.py",
"sha256": "fa93f9bc04d6e3b03380eff8fe16fa9d8e017d0ec5de7ba8bfbcb872b63e6bb1"
},
{
"path": "emulation/scripts/terminate-macos-app.swift",
"sha256": "1c5baab36d49f81abc45c2abed50de76ef3fba518ed771e74120c2eae7c4a65a"
},
{
"path": "emulation/scripts/test_run_playable_vpx_stream.py",
"sha256": "a9fc2e8bf1107547f497577f12f6437d95bc93a442cf824e2493e0fdf2270ed9"
},
{
"path": "emulation/scripts/verify-game-pristine.sh",
"sha256": "13a4f689a585dd6c4df33a5966ebe3a2fb5ba4582bc883cc55b9cd080068a0ca"
},
{
"path": "emulation/scripts/vpx-controller-discovery.cpp",
"sha256": "a44f3e9651d5d8b11edafa8c010e51963b1265daf938fdf7e276b551d1023721"
},
{
"path": "emulation/scripts/vpx-focus-policy.c",
"sha256": "e0c765e2c9ccad9a5c33563552d76e5397eda98ee171423e84dc2607f2746810"
},
{
"path": "emulation/guest/emu-init",
"sha256": "9fdc662c9322124b101e2b03c2e11ba56c7ff33a3d8c07296ff71ac9f40c5520"
},
{
"path": "emulation/guest/guest-control-agent.sh",
"sha256": "235afa4e5c2c864d153027748b23d7f15295eab7c8ed0b104ae58e5faf730fd5"
},
{
"path": "emulation/guest/launch-target.sh",
"sha256": "83651860406c950c5ce996e24eec2ea7d70e5ab0ed72a71d40b615b8d5106265"
},
{
"path": "emulation/guest/runtime-trace-uprobe.sh",
"sha256": "49186e40f8926ff947335f473246ed42767c5f11426d35953a54583a619cd1ab"
},
{
"path": "emulation/guest/setup-fake-devices.sh",
"sha256": "e3ea7802e2959b891847afbcc0b6d89a55aecaaee55b43a61f22bf0f258e431d"
},
{
"path": "emulation/guest-config/asound.conf",
"sha256": "c9c255c36805b0e42a7e13c8b6762bea5aac6ddf227898580e77404c22ae7ac7"
},
{
"path": "emulation/guest-tools/display-probe.c",
"sha256": "38329f0ce803f383e74acfc66bae234d64ced40dbc23953c166ee239cff8c0f4"
},
{
"path": "emulation/guest-tools/fb-message.c",
"sha256": "374de1debc64dac4e19f3f1318a4ddade8c09e05c583dcd8a5313cf45a793608"
},
{
"path": "emulation/guest-tools/keyboard-switch-relay.c",
"sha256": "1149f29ac5e896462c7a988910ce138d1dec376b39c2a269b8b4d1447eb1c0c7"
},
{
"path": "emulation/stubs/audio_volume.c",
"sha256": "63eac344ca32b3814b9c5721e9bbb6035745a7d89d0a1a11e699a8a6c23cfe64"
},
{
"path": "emulation/stubs/eeprom_preload.c",
"sha256": "d4f18a2f92a1c15fa7797822ad1953f4ae3d084ec2892febdaf0a68dae85c59b"
},
{
"path": "emulation/stubs/h264_drm_shim.c",
"sha256": "7563631ca902309ac7804449e01a933c46d4e8ff782a632eee2a45f6d32f0642"
},
{
"path": "emulation/stubs/i2c_eeprom_0x57.c",
"sha256": "8059720de7b247ef602a779a7dc7d6ae19b1bd7e542651cc9db875413c52c318"
},
{
"path": "emulation/stubs/i2c_eeprom_0x57.h",
"sha256": "32e8b6223b4ec3a4c94e540007b0f360f127480f75a454c24c3f54d82dc78cae"
},
{
"path": "emulation/stubs/spike3emu_display_contract.h",
"sha256": "600bcdb459a75c3c63dc01f0a54232da9a2d5ba362f6c26d5e1dbb9272e18202"
},
{
"path": "emulation/stubs/spike3emu_stub.c",
"sha256": "d397c0aed37cab82410c8a33edc09103527b488079c442e1eb9bb3383d387a07"
},
{
"path": "emulation/stubs/spike3emu_stub.map",
"sha256": "29ce9c36fbd96768c4ac05b3939d5360b7a512b312a6dbf486c5b466528df68b"
},
{
"path": "emulation/stubs/spike3machine_client.c",
"sha256": "2a6080955c18c4c7527d7f3c8787597c0bbb3cf33ff6e04952806c972f794a21"
},
{
"path": "emulation/stubs/video_bridge_client.c",
"sha256": "e3d5b558d2f138a64f16152ed82f333751c6b1a2fecbd8add5ac410ae3f47179"
},
{
"path": "emulation/stubs/video_bridge_client.h",
"sha256": "e324f390dd87414a12478b5cb66de8e4e62cf6afc2fa5a453b3f8eee10c7f86f"
},
{
"path": "emulation/video-bridge/README.md",
"sha256": "2bde60972ae7bb61798900854fe87198737ffa11f99b7b5f54eb22ada245096b"
},
{
"path": "emulation/video-bridge/protocol.h",
"sha256": "0dd04558ee88b0ff78f63b690e1a557f7760027c0556fc99dd702b568a6b8b99"
},
{
"path": "emulation/video-bridge/smoke_test.c",
"sha256": "e7ec81acfb2077cabf03a83692b4e2ce1d89e0ea2f95e389a61d462bdfe5d094"
},
{
"path": "emulation/video-bridge/videotoolbox_bridge.m",
"sha256": "323e6322bef2aa5a936c9a23c499974a53b5e44ef9a417b0b7a14835fb0f216c"
},
{
"path": "emulation/dashboard/dashboard-input.js",
"sha256": "84d9ee35055db7f32a762dd0db7ff4d4b36664e218fbb63fa35255d1624c1679"
},
{
"path": "emulation/dashboard/display.js",
"sha256": "c452165d7ec184775d2245f2632880824b45ae90e504eeacdbca8b798651457a"
},
{
"path": "emulation/dashboard/server.py",
"sha256": "5ed8c8d8bd2f88c3661c566c658e9592bbc63cded8671a6df2b16d0244cf7c1c"
},
{
"path": "emulation/dashboard/test_display.mjs",
"sha256": "c3094271c91baad42ae8141038d0a0d92101a00b2b13bd4103de9f1732010283"
},
{
"path": "emulation/dashboard/test_display_server.py",
"sha256": "fadf2190a599e77b1f2fa01e6c7bcc8404d95d311a9cb6a1f20d3b0f61865f2f"
},
{
"path": "emulation/dashboard/test_input.mjs",
"sha256": "bab6af4c37f05af0fcec791beaabd33b1f6305a64a812087bec9124f86465c96"
},
{
"path": "emulation/dashboard/test_lamp_browser.py",
"sha256": "ed43cc0e0aadbfeb970f93f73dfc42c346a1967b11858449d574289e238828f5"
},
{
"path": "emulation/dashboard/test_server.py",
"sha256": "6b168c63d1f818a386fd78ab989accaa21e091eef4529c977ff06bb23f1f9bce"
},
{
"path": "emulation/dashboard/test_vpx_layout.py",
"sha256": "c6d77c396209604b63e46ea4116247479aec81ea60365c84b10cba1e9fa4457b"
},
{
"path": "emulation/dashboard/test_vpx_window.mjs",
"sha256": "382ad5321e2d4898a3447ae28ecefdb0ff055e4c1a18175f7c6d7004574103cd"
},
{
"path": "emulation/dashboard/vpx-window.js",
"sha256": "d150de089c008a9c501bfaafd419f542cc7bd83e719f9f25828bff6d233a5aff"
},
{
"path": "emulation/dashboard/vpx.html",
"sha256": "0536f8e9e050bbc960a1bfa260614d8dae383a85e290a4a44ddfdfb88de9dfbf"
},
{
"path": "emulation/streaming/README.md",
"sha256": "421b111547d9c76040aa66af6831b702d194229f7386b5c51ee9cbad32638866"
},
{
"path": "emulation/streaming/acceptance.cjs",
"sha256": "bc4a05c2d812502322fc2bcfe9b559493e76c5820534a077e54c478431d45101"
},
{
"path": "emulation/streaming/relay.py",
"sha256": "1958c7642393cf618b14168acedbba0f32ee430e147d31242c66150def163817"
},
{
"path": "emulation/streaming/requirements.txt",
"sha256": "986acfae1639116b0b5c16bdba2937546ff24dba1807ddc2f6f5a8d9a149249c"
},
{
"path": "emulation/streaming/runtime_config.py",
"sha256": "2f41bdc638d74a5db4ddd5d8b019abeb3e1afcaa47dec1bbbdce9426a17f7f64"
},
{
"path": "emulation/streaming/sck-capture.swift",
"sha256": "152ed4ffed7ea962b11af1636b6e895dbea81b3c45f7e0922085277814ec3b89"
},
{
"path": "emulation/streaming/sck-error-classification.swift",
"sha256": "e8d36987690705ab58bc81c70df4a4ae079915e226860fd4fd63b6f1ccf48271"
},
{
"path": "emulation/streaming/setup.py",
"sha256": "1d1b33b4d2bf7a92e9cb2b38fb1e1eb2a2c41edebb00afc4cd166a6284c11feb"
},
{
"path": "emulation/streaming/test_relay.py",
"sha256": "e950f34feef55954d8e015f6fc5cc449f54a71d3747acd65ad6ba977b61b525d"
},
{
"path": "emulation/streaming/test_sck_native.py",
"sha256": "d5a212a3e1a62eb1447fb6f7e86e5f4ca9dd822f8491f23824e6817eb6973b00"
},
{
"path": "emulation/cpu-spi/pokemon-pro-service-panel.json",
"sha256": "edfc6d44523e02ac01ec5f1eafe1aa88fcdca0774adb43f5a6fd8ac36ec6a963"
},
{
"path": "emulation/conagent-machine-emulator/README.md",
"sha256": "29495e9a276cda2537116ef31165524b0408f012e8c40e203721341ac5a0e76a"
},
{
"path": "emulation/conagent-machine-emulator/config.example.json",
"sha256": "eadc03c6485cb3b74270356e37f231831b5718e141b26ae4cf9972e5a7b100de"
},
{
"path": "emulation/conagent-machine-emulator/emulator.py",
"sha256": "538a3686c6d3d644c5a1954aec95f64b336b98a333a2eba55a4a310c9295dbd4"
},
{
"path": "emulation/conagent-machine-emulator/local_transport.c",
"sha256": "3ad5d4e43a25dc0e040a47ec0adac77d067f2e1089d3764c7a21412bf857f06f"
},
{
"path": "emulation/conagent-machine-emulator/prepare_guest.py",
"sha256": "e4d32d2d0f9cb8968f85cd6c9d2e52f22bd5fe7c4c885eb4e1959519fc75aa56"
},
{
"path": "emulation/conagent-machine-emulator/protocol.py",
"sha256": "7adf9097a451eee1ceb2c94450cb70c71dace55738311352348204bea7d37a70"
},
{
"path": "emulation/conagent-machine-emulator/provisioning.py",
"sha256": "5956f10d3bd718c715e145868fcba2dc50f599326d9e198e9091d8824cfd9fe8"
},
{
"path": "emulation/conagent-machine-emulator/service.py",
"sha256": "d6b1073ff9c08061711955dfa0a3c640040580df806b6f5f23818294c1093e80"
},
{
"path": "emulation/conagent-machine-emulator/settings.py",
"sha256": "9dc7ddf30d7c5db7d649ba87b1115e3210c53befbdcb8c41808cd61c98a9fa6a"
},
{
"path": "emulation/conagent-machine-emulator/store.py",
"sha256": "ee5c4a908c08c7fb930a629267032e519a102beb8a3dd970adc9740759b8dd6d"
},
{
"path": "emulation/conagent-machine-emulator/test_provisioning.py",
"sha256": "305733ff2bdab8dba19890d966338ecb08483f6ca54595a21e0878bda08fbaf7"
},
{
"path": "emulation/conagent-machine-emulator/test_service.py",
"sha256": "e4d9b619d2fd4d55ce2a341a49dd84c271b89797913f14996319c5e1f5fc65c6"
},
{
"path": "emulation/conagent-machine-emulator/wait_healthy.py",
"sha256": "9a8e9b802f24f6bc7c870895a9ba8b6506286e85e068ff79c214b375a2d21e72"
},
{
"path": "emulation/conagent-machine-emulator/web_ui.py",
"sha256": "237d547430ec737a3f4feab11662f5070e98919dede779f41ade8ec16fdf9d2b"
},
{
"path": "emulation/docker/Dockerfile",
"sha256": "57beb45d8eef4d2fb0e46a7f1493100a834903f2d60878993916de031b70837f"
},
{
"path": "emulation/docker/Dockerfile.mesa",
"sha256": "ecc4ff501ca792c2ed5b5e07a3adea886cc60b5a25c3b5bf480053b787b2d0aa"
},
{
"path": "emulation/docker/entrypoint.sh",
"sha256": "78ab8602958e7937a6a0c0373ca2d9f04b61c49770f41d85e69e7345cab52aa2"
},
{
"path": "emulation/docker/fetch-debian-arm64-kernel.sh",
"sha256": "86d366aaadc007f2479b117dd1bca3c2799d90eb9ca0a37f13880cfee1694ffb"
},
{
"path": "emulation/netbridge-emulator/README.md",
"sha256": "6c62b4509400431c23518fc58752ccc0da4c8812089f85c54f7bb5707c8751e6"
},
{
"path": "emulation/netbridge-emulator/keyboard_relay.py",
"sha256": "f0caf9d9082e0b6dfb9262aa5be6d76b03b74ebabd937e363b21bdd11e077f86"
},
{
"path": "emulation/netbridge-emulator/netbridge_emu.py",
"sha256": "fd93c55b55d6f68ccfae9b194fe6ec1e7bd24020f6ee8633227c7e2b624e9b8a"
},
{
"path": "emulation/netbridge-emulator/test_netbridge_emu.py",
"sha256": "41348d7e2762c7654f7b081af1a7ee207a8ef047382bea468eed3d8accc9cc96"
},
{
"path": "analysis/netbridge-emulator/README.md",
"sha256": "9c42c0f350a3f3a8b6e7d4ffe56ba4fe64cb9c966f172fb39ba87781eec82339"
},
{
"path": "analysis/netbridge-emulator/evidence/2026-08-18-magikarp-topper-qemu.md",
"sha256": "b325331e5f419cd4195fdccb9abf46f674aede29f458cc6f4192f41a9baee813"
},
{
"path": "analysis/netbridge-emulator/evidence/2026-09-05-main-live-acceptance.md",
"sha256": "b161a54de981728a821d19cf63f0f8a617d13a3571631fcbc0a4b229ca8498b0"
},
{
"path": "analysis/netbridge-emulator/evidence/2026-09-05-playfield-lamp-decoding.md",
"sha256": "0b4b6e6c783eb582d7e702347e21f84fca8f9da74309b0aee4f1c7596257c58d"
},
{
"path": "analysis/netbridge-emulator/evidence/2026-09-05-travel-map-diagnostic.md",
"sha256": "601e446f341abc8fbf74bbde3a44b1a6164beb2c328da3561dee431915b7e6f6"
},
{
"path": "analysis/netbridge-emulator/evidence/topper-protocol-v83.md",
"sha256": "e1bb230bbb8d8dde1a28203f07a809213252203876b5aa1cce609fce35a79012"
},
{
"path": "analysis/netbridge-emulator/fixtures/topper/controlled-first-catch.jsonl",
"sha256": "aad20f2acade91b5c1dc732a585203dff0b9e517ba0d188bf20d2dd80a0f11b2"
},
{
"path": "analysis/netbridge-emulator/fixtures/topper/guided-setup.jsonl",
"sha256": "8bdc6b3172a7aac01289dec00382d3ccf5326938409aa4d5fca72d5524d3c0c2"
},
{
"path": "analysis/netbridge-emulator/fixtures/topper/live-omitted-presence.jsonl",
"sha256": "15f4f2aa58f75155869bccc558b1b4d52fc3a5005c4a9549f3b7a8fe3ff49df7"
},
{
"path": "analysis/netbridge-emulator/fixtures/topper/live-transition-exceptions.jsonl",
"sha256": "a961b20684cad976159466bddb2a731f7bce83dadf6b3bd4f2fb67654b88eac8"
},
{
"path": "analysis/netbridge-emulator/fixtures/topper/magikarp-v83.json",
"sha256": "13145fa418f6533b67de593041ef25e96ba8533ce87bba28cf38e4b1e8eb2b61"
},
{
"path": "analysis/netbridge-emulator/fixtures/topper/recovered-post-award.jsonl",
"sha256": "3d684318581882b5c5270a739bcf675acc97668972b433eab20189c1d7c0e2bc"
},
{
"path": "analysis/netbridge-emulator/fixtures/topper/topology-v83.json",
"sha256": "08da0f0519fbea4463d17f3cb4c7d2fcf072cf050605461bfeaebc561d9a09d8"
},
{
"path": "analysis/netbridge-emulator/keyboard_relay.py",
"sha256": "f0caf9d9082e0b6dfb9262aa5be6d76b03b74ebabd937e363b21bdd11e077f86"
},
{
"path": "analysis/netbridge-emulator/netbridge_emu.py",
"sha256": "02062ca4367e23cf12d7f7bfd6961d775381dcd32909bd1b066315399000e085"
},
{
"path": "analysis/netbridge-emulator/test_magikarp_topology.py",
"sha256": "14407c2f7f995762bb2fafcf88bb9cc623c3e18c9ea1b14e912268c70e9b9642"
},
{
"path": "analysis/netbridge-emulator/test_netbridge_emu.py",
"sha256": "8533a178a7917e97303079cbc6d035903906883dd1c93fe3610fe78892e2b26d"
},
{
"path": "analysis/netbridge-emulator/test_playfield_pause.py",
"sha256": "945278395357bdb3b49964e6f98222b6218db398ff12b3d5d936df5d01cbc4e4"
},
{
"path": "analysis/netbridge-emulator/test_topper_protocol.py",
"sha256": "8f01ed588d213986aaba66237ecf132ef869bac1585f28133dd2c3471ef5671d"
},
{
"path": "analysis/netbridge-emulator/test_topper_topology.py",
"sha256": "a8a2016d39936a38d230aadf5244d7a56b1ec6aaf571e0610c6dce9a12c76e79"
},
{
"path": "analysis/netbridge-emulator/test_topper_trace.py",
"sha256": "434ccf6398868024565428eae8517c47eb7768b48fffdfc1a60710c6e41204ce"
},
{
"path": "analysis/netbridge-emulator/tools/DecompileTopper.java",
"sha256": "6ef9edbe3d511de0e7f9f85302ee87059a93ac43322f86eee3f3528185fc18b2"
},
{
"path": "analysis/netbridge-emulator/tools/extract_magikarp_topology.py",
"sha256": "55f67756cc3b47b6a457796f194efea5de8f1d19c07cd39bb2758e43ad0bf99b"
},
{
"path": "analysis/netbridge-emulator/tools/extract_topper_topology.py",
"sha256": "796da580f48f0d2cdb7eb9a9650547a42999bd2d6767b280244d0a276e39f753"
},
{
"path": "analysis/netbridge-emulator/tools/extract_topper_trace.py",
"sha256": "2180e2a27686c49b6a69d7fed77c0afc2e6e741394c5d67e4f7ae3ebab9a8d26"
},
{
"path": "analysis/netbridge-emulator/topper_protocol.py",
"sha256": "ba75af6c42e80912138cf8d8806e520f0dbed3c0616d493767eaf2f40ab29afe"
},
{
"path": "emulation/vpx/layout.json",
"sha256": "9ed9f7e71da08716ca3ff4125a04db70a5c7f5481ff386ececf0965a230e3fe9"
},
{
"path": "emulation/README.md",
"sha256": "4ca4c0d1b02f3253fee6f6dfccc62565b4bfa5c9035c2bdae3d95e248d6c6dc0"
}
]
}
@@ -0,0 +1,178 @@
# Netbridge Emulator
This is a clean-room host-side emulator for `/games/pokemon_pro/spike3/netbridge/netbridge.elf`.
It models the Raspberry Pi to RP2040 serial protocol recovered from Ghidra and stubs the lower hardware modules so the game has something bridge-shaped to talk to during QEMU or bench bring-up.
## Current Scope
- Pi-facing frame codec: `0x02` start, `0x03` end, `0x1b` escape with high-bit encoding.
- Host message checksum: additive two's-complement over command, sequence, and payload.
- Version command `0x20`: returns `0.98.4` plus synthetic ADC version.
- Reset/status/voltage commands `0x22`, `0x23`, `0x24`.
- Node discovery commands `0x25` and extended `0x40/0x00`.
- Firmware CRC command `0x30`: returns `0x357f5327`, computed over the recovered RP2040 logical image range.
- Direct nodebus command `0x41`: routes to an in-process stub node at address `0x0a`.
- Power-distribution command `0x15`: returns Action/Start/interlock GPIO state
and emits sequence-0 change reports without waiting for a host poll.
- Magikarp/topper-oriented node commands `0xf2`, `0xfc`, `0xfd`, `0xfe`, and `0xff`.
- Optional Magikarp topology: node `12` advertises the topper lamps and node
`13` advertises the head/tail servos with stable serial and calibration data.
- Native VPX controller service: loopback-only JSONL protocol
`spike3-playfield/v1` on `127.0.0.1:8091`.
Hardware modules that are not needed for early game progress are deliberately inert: LCD/DMD status reports success and WS2812/audio commands are accepted without side effects. The cabinet-input portion of power-distribution command `0x15` is modeled from the installed controller firmware; other power-distribution commands remain conservative synthetic responses.
## Run
Create a PTY and print the slave path:
```sh
python3 analysis/netbridge-emulator/netbridge_emu.py --pty
```
Serve an existing serial/PTY path:
```sh
python3 analysis/netbridge-emulator/netbridge_emu.py --serial /dev/ttyUSB0
```
Listen on a Unix socket for QEMU `-chardev socket`:
```sh
python3 analysis/netbridge-emulator/netbridge_emu.py \
--unix-socket emulation/work/netbridge.sock \
--physics-authority vpx \
--bridge-generation 1 \
--playfield-host 127.0.0.1 \
--playfield-port 8091 \
--qemu-monitor-socket emulation/work/monitor.sock \
--qemu-starts-paused
```
Advertise the optional Magikarp topper and publish its decoded RGB targets and
raw node diagnostics in the dashboard output-state feed:
```sh
python3 analysis/netbridge-emulator/netbridge_emu.py \
--unix-socket emulation/work/netbridge.sock --magikarp-topper
```
The topper mode proves discovery and serial/calibration reads, and captures
raw writes for nodes `12` and `13`. Its deterministic packed-output decoder
publishes all 74 Magikarp RGB targets on node `12`. The eight letter mappings
come from the native game's named descriptors and animation group, including
`M` at physical pixel 0 (G/R/B channels 0/1/2). Real initialization and recovered
post-award packets are replay fixtures. The September 5 controlled native
first-catch run validates **M**, with its exact award packet retained as a
fixture. The other seven letters remain statically mapped but not yet
live-validated; see [the acceptance record](evidence/2026-09-05-main-live-acceptance.md).
Head/tail/wave motion and transition duration units remain unproven, so no
mechanism motion or fade timing is invented. Malformed and unknown commands
preserve the last valid RGB targets and remain visible in bounded diagnostics.
See [the exact binary/packet evidence](evidence/topper-protocol-v83.md).
The old 92-pixel `ETOPPER` table belongs to logical board 4, a different
accessory. The Magikarp decoder uses only the hash-gated board-9/node-12
`fixtures/topper/magikarp-v83.json` topology: 8 letters, 24 back, 22 middle,
and 20 front pixels.
The playfield service starts alongside any PTY, serial, or Unix-socket UART
transport and prints this readiness record after binding:
```text
playfield-api ready host=127.0.0.1 port=8091 protocol=spike3-playfield/v1
```
Port `8091` is the default. Pass `--playfield-port 0` to keep the legacy UART
and dashboard behavior without a TCP listener. The host is deliberately
restricted to a loopback address.
`--physics-authority synthetic` retains the original convenience motion model:
trough coils, the auto-plunger, outlanes, and ejects move synthetic balls.
`--physics-authority vpx` disables all of those transitions from process start;
driver and lamp traffic is still observed and streamed to the native table.
## Native playfield protocol
The TCP service uses one JSON object per line. Every server message includes
`protocol`, a process-unique `bridge_session`, the launcher's monotonic
`bridge_generation`, a session-global `seq`, the model `revision`, and
`ts_mono_ns`. A client begins with:
```json
{"protocol":"spike3-playfield/v1","type":"hello","seq":1,"revision":0,"ts_mono_ns":0,"client":"vpx","physics_authority":"vpx"}
```
The server replies with a complete `snapshot` followed by
`status=synchronizing`. The snapshot deliberately has `connected=false`:
transport ownership is not yet safe-to-run readiness. The VPX client then
sends `release_transient`, its current `switch.set`/`cabinet.set` state, and a
`heartbeat`. TCP ordering makes that heartbeat a synchronization barrier. The
server responds with `status=ready` and sends HMP `cont` only when the QEMU UART
has reconnected and every preceding state record has been processed.
Snapshots contain `switches`, canonical `contacts` such as `8-SW-37`, exact
`lamps` such as `9-LP-65`, cumulative `coils` such as `8-DR-1`, and a
`driver_cursor`. Live game output is delivered as:
- `lamp.delta`: node `address`, starting index, byte `values`, and exact lamp
key/value pairs. Lamp batches may share one message.
- `driver.pulse`: node `address`, driver number, exact `key`, raw argument hex,
per-driver `pulse_count`, and global `driver_cursor`. Driver events are never
coalesced or dropped by the in-process queue.
- `status`: readiness, command acknowledgements, heartbeat replies, errors,
and QEMU monitor state.
The client can send:
- `switch.set` with `name` or an exact `address`, `active`, and optional
`transient`.
- `switch.pulse` with `name`/`address` and optional `duration_ms`.
- `cabinet.set` with `name`/`address` and `active`.
- `release_transient` and `heartbeat`.
Cabinet contacts, pulses, and ordinary playfield contacts default to transient.
Ball-location and held-mechanism contacts (the six trough switches, shooter,
right eject/hold, captive-ball, and position sensors) are retained by default.
If the VPX socket disappears, all transient contacts are released while those
stable contacts remain untouched. When a QEMU HMP socket is configured the
server sends `stop` on disconnect. The native launcher also starts QEMU with
`-S` in VPX-authority mode and pauses it before replacing a failed bridge.
`cont` is gated by the stable-state heartbeat described above. Monitor failures
are reported in status/output state and logs but never interrupt the UART
transport.
On a managed restart, `run-macos.sh` atomically moves the old output-state file
out of the live readiness path. The replacement restores its last lamp levels
and cumulative driver counters as snapshot baselines, publishes a new session
with `connected=false`, and never converts restored counters into
`driver.pulse` messages. Only retained trough/held-ball switch contacts are
loaded from the switch-state file (`--restore-stable-state` is passed only for
bridge generation 2 and later); cabinet buttons, targets, spinner edges, and
other transient contacts restart open. A cold launcher/QEMU start therefore
still begins from the normal six-ball trough defaults.
For the Docker/QEMU scaffold, the easiest path is:
```sh
SPIKE3_NETBRIDGE_EMU=1 SPIKE3_MAGIKARP_TOPPER=1 SPIKE3_AUTOSTART=game \
docker compose -f emulation/compose.yaml up --build
```
That mode starts this emulator on `emulation/work/netbridge.sock`, attaches it as a second QEMU PL011 serial port, symlinks guest `/dev/ttyAMA5` to `/dev/ttyAMA1`, and lets the preload shim pass `/dev/ttyAMA5` through as a real serial device. See `emulation/README.md` for the full runbook and current validation status.
## Test
```sh
python3 -m unittest discover -s analysis/netbridge-emulator
```
## Porting Notes
An ESP32, RP2040, or other microcontroller port only needs the same two protocol layers at first:
1. Host UART at 2,000,000 baud, 8N1, STX/ETX/ESC framing, additive checksum.
2. A nodebus backend that can answer discovery and direct `0x41` node requests with `payload || checksum || status`.
The original firmware is RP2040/Pico-SDK style and uses PIO for the node transmit side, so an exact physical replacement is easier on RP2040. An ESP32 replacement should still work as a protocol emulator if it can meet nodebus timing or if it only faces the Pi-side UART while node hardware is stubbed.
@@ -0,0 +1,173 @@
# Magikarp Topper QEMU Acceptance - 2026-08-18
> Historical acceptance record. The unmapped/raw-decoder status below describes
> August 18, not the current decoder. See the September 4 follow-up at the end.
## Build
- Tested commit: `a46b77256bd3f1e48181ef3b1ffd78c939de46cb`.
- Game image SHA-256:
`169b19ea6ef89ba4be1a8bb2f868697da597389ab023d8ed81f149b6353b459f`.
- Game image: `games/pokemon_pro/game`.
- Acceptance used a fresh APFS clone of `emulation/work/rootfs.ext4`; the
repository working image was not modified.
- Exact successful launch command:
```sh
SPIKE3_WORK_DIR=/tmp/s3tw.SsEZk3 \
SPIKE3_ROOTFS_IMAGE=/tmp/spike3-topper-final-acceptance.SsEZk3/rootfs.ext4 \
SPIKE3_MAGIKARP_TOPPER=1 \
SPIKE3_NETBRIDGE_TRACE=1 \
SPIKE3_MACOS_DISPLAY=none \
SPIKE3_MACOS_AUDIO=coreaudio \
SPIKE3_VIDEO_BRIDGE=1 \
SPIKE3_AUTOSTART=game \
SPIKE3_CAPTURE_TARGET_LOGS=1 \
emulation/scripts/run-macos.sh
```
`/tmp/s3tw.SsEZk3` was a short symlink to this worktree's
`emulation/work`. QEMU and the netbridge use Unix-domain sockets, whose path
limit is shorter than this worktree's absolute path.
## Node Acceptance
- Node 12 lamp board: **accepted**. Discovery returned address 12 and the game
completed 46 board-specific serial/config reads.
- Node 13 servo board: **accepted**. Discovery returned address 13 and the game
completed 47 board-specific serial/config reads.
- Node 14 was not advertised. The optional Magikarp topology is exactly nodes
12 and 13.
- Each board returned a distinct synthetic serial and a non-zero derived key:
```text
257:rx cmd=0x41 seq=0x01 payload=8c02fc7612
258:tx frame=02410178563412f0debc9aefbeadde214365874000be03
291:rx cmd=0x41 seq=0x01 payload=8d02fc7512
292:tx frame=024101112233445566778899aabbccddeeff010700be03
```
The serials derive to `0x51407546` for node 12 and `0x01000000` for
node 13. This avoids the all-zero identity seen in the first acceptance
attempt.
- Both boards returned valid runtime/config/calibration data.
`output-state.json` reported:
```json
{
"enabled": true,
"discovered": true,
"calibration_valid": {"12": true, "13": true},
"serial_key_reads": {"12": 46, "13": 47}
}
```
- The QEMU/netbridge logs contained no updater failure, unsupported-hardware
message, calibration failure, abort, panic, or node-12/node-13 failure loop.
The process remained live for more than seven minutes while the video bridge
repeatedly decoded 1920x1080 H.264/HEVC streams.
## Output Traffic
- Reproduction action: cold boot into the game's Guided Setup screen with the
optional topper enabled. No fabricated node traffic was injected.
- After the serial/config phase, the game issued sustained output traffic to
both optional boards. The first complete role-appropriate sequence was:
```text
18194:node-write address=13 args=40076000086000a00000
18197:node-write address=13 args=40066000086000a00000
18200:node-write address=13 args=40005d00085d00a00000
18315:node-write address=12 args=a4c00b00
18318:node-write address=12 args=b024c00b00
18321:node-write address=12 args=9044c003ff3f
18328:node-write address=13 args=4264040000ff0005
18333:node-write address=13 args=4264040000ff0005
18338:node-write address=13 args=4264040000ff0005
```
- The sequence repeated while the game stayed active. At the evidence snapshot,
node 12 had received 366 each of commands `0x90`, `0xa4`, and `0xb0`;
node 13 had received 105 `0x40` commands and 1,098 `0x42` commands.
- The generic output decoder classified the node-12 packets as lamp writes and
published the resulting raw channels:
```json
{
"12-LP-16": 192,
"12-LP-17": 3,
"12-LP-18": 255,
"12-LP-19": 63,
"12-LP-36": 11,
"12-LP-37": 0,
"12-LP-48": 192,
"12-LP-49": 11,
"12-LP-50": 0
}
```
- Node-13 `0x40` and `0x42` packets followed the existing driver-command
formats and were retained as raw servo-board traffic.
- A live dashboard launched with the repository virtualenv displayed
`discovered - mapping incomplete`, the raw node-12 lamp channels, both board
command counters, and the latest raw command for each board. This verifies
that post-read and post-write telemetry publication is live rather than one
request stale.
## Semantic Mappings
- Proven board roles: node 12 carries lamp-formatted output; node 13 carries
driver/servo-formatted output.
- Proven individual MAGIKARP letter mappings: **None**.
- Proven head/tail/wave mappings: **None**.
- Guided Setup output proves active board traffic but does not uniquely tie a
channel to a visible letter or mechanism. Therefore the published state
correctly remains `mapping_complete=false`; all eight letters and all three
mechanisms remain visibly marked as unmapped.
- The next evidence step is a repeatable real-machine or in-game topper
diagnostic/catch event that changes one output at a time. Those deltas can be
promoted into named mappings with fixtures and tests.
## Acceptance Environment Findings
- Controlled `SPIKE3_MACOS_AUDIO=none` runs with the topper disabled and enabled
both aborted before node discovery. Restoring the launcher's normal
CoreAudio/HDA path restored discovery and a stable game. The abort was caused
by the missing emulated HDA device, not the topper option.
- The launcher's dashboard child uses `python3`; on this shell, system Python
lacked Pillow. Restarting only the dashboard with `.venv/bin/python` restored
the UI. This did not affect QEMU, the game, or netbridge acceptance.
## Boundary
This validates the game-to-emulator contract under QEMU. It does not validate
nodebus voltage, timing, direction control, physical lamp placement, or
mechanism travel.
## Follow-up: semantic decoder recovery, 2026-09-04
The subsequent native-binary recovery corrected a mistaken topology premise:
the 92-pixel `ETOPPER` descriptor table is logical board 4, not Magikarp. The
actual Magikarp board is logical board 9/node 12, with 222 component channels
forming 74 RGB pixels. Its initializer is exactly the three node-12 packets
recorded above; each is a compressed zero target write, not the legacy raw
channel values shown in this historical report.
The packed-output decoder now independently reconstructs those RGB targets.
Native animation group `0x25b` resolves through the virtual/physical lamp tables
to named descriptors for all eight letters. In MAGIKARP order, their physical
pixels are `0,1,2,3,19,18,17,16`; M uses G/R/B channels `0,1,2`. This is a
direct static mapping, not a guessed delta or letter-count hook. A recovered
35-write post-award log excerpt produces a white M target on replay, with 30
accepted RGB writes, five explicit non-RGB writes, and zero malformed writes.
The excerpt does not establish a bounded first-catch acceptance interval.
The dashboard therefore shows mapped RGB letters and a diagnostic view of all
74 pixels. At the September 4 checkpoint, `live_validated=false` was retained
until a fresh controlled catch and corresponding displayed M were observed.
The [September 5 acceptance](2026-09-05-main-live-acceptance.md) now proves
that first-catch-to-M link and promotes only M to live-validated.
`mapping_complete=false`
remains correct because head/tail/wave packet targets and duration semantics
are still unproven. Full reproducible static offsets and fixture provenance
are in [the protocol evidence](topper-protocol-v83.md).
@@ -0,0 +1,184 @@
# Main live acceptance — 2026-09-04/05
Source under test: `main`, commit `7a7e3dc53e6b2cd98b70869ce25899766558bd21`.
Guest reports Pokémon Pro `V0.83.0`. This test does **not** upgrade the game image.
The M evidence-metadata/fixture/test updates are acceptance follow-ups;
the user subsequently approved committing and pushing them to main.
## Scope and safeguards
Restarted from the main checkout using the existing
`emulation/work/rootfs.ext4`, with no image preparation. The native launcher
does not invoke the preparer when the existing kernel/initrd are available.
`SPIKE3_PREPARE_MODE=never` was also present in the environment; the decisive
evidence is the launcher path, not that environment variable alone.
Local evidence directory (ignored, not included in Git):
`emulation/work/acceptance/main-PWJURx/`.
It contains screenshots, browser recordings, stopped-disk APFS clones,
NVRAM manifests and archived runtime logs. Runtime directory:
`/tmp/spike3-stream.jPiAam`.
All disk inspection used read-only `debugfs` against a clone made after
the managed QEMU process stopped. No two guests opened the disk concurrently.
These were managed VM stops, not guest filesystem unmounts.
The initial stopped disk and its baseline clone both hashed to
`68037085988b6fed1761d71f31a2e49add45226ed2fda8c5afcaa7b858d165e9`.
## Browser streams: passed bounded live check
Ran `emulation/streaming/acceptance.cjs` against the restarted main instance.
- SCK: 528 decoded frames; first frame about 592 ms after page load;
four distinct sampled images. Two decoder instances include the initial
Auto connection and the explicit SCK selection.
- VNC: connected; four distinct sampled images.
- No browser page errors. The runner switched Auto → SCK → VNC → Snapshot.
- A bounded `caffeinate` session kept the display awake during the SCK test.
The final trace-disabled restart was also checked: SCK decoded 535 frames,
first frame about 292 ms after page load; both SCK and VNC had four distinct
sampled images, and the browser error list was empty.
This is not an overnight soak or a proof of recovery after macOS display sleep.
The legacy snapshot endpoint intermittently returned HTTP 503 during the
acceptance work, while continuous VNC recording remained available.
## Preferences: passed two restarts without preparation
Changed **Master Volume Setting** from **30 to 31** in native Quick
Adjustments, accepted it, and exited service menus. Other adjustments were
left unchanged; an accidentally entered calibration dialog was exited
without writing calibration.
| Checkpoint | Native visible volume | Latest NVRAM generation | NVRAM SHA-256 | CRC |
|---|---:|---|---|---|
| Baseline | 30 | `00000002` | `654bbbbb487c619f3537004e4eaaa8f544d08c86417d592a013dda90883a9311` | `4ff2a267`, valid |
| After save and managed stop | 31 before stop | `00000003` | `102c356e6f0ab106700c5f5c913faa0669f1d65ee8aa368ea3f2c3d9e72c02c1` | `9828f0cb`, valid |
| Restart one | 31 | `00000004` after exit/stop | `2e190b877b304cbac53ad05cfe77896f6f16182007e49c30b2f1b94127f397ed` | `6f3b6bac`, valid |
| Restart two | 31, explicitly selected in native UI | — | — | — |
All 20 data/CRC pairs validated at each of those three extraction checkpoints.
NVM, LKRAM, PIN_NVRAM and SYS_NVRAM generations also advanced. The table is
not a claim that the particular volume field lives in the NVRAM blob rather
than one of the other persistence stores; the native UI provides that link.
The after-save stopped disk and clone both hashed to
`d40b0a6da8ef66d2da7d94d145132adb029d5bd836e7f6ef7cd03174ba19eaf0`.
Visible evidence: `master-volume-selected.jpg`, `master-volume-saved31.jpg`,
`reboot-one-volume.jpg`, and `reboot-two-volume31.jpg`.
Restored the original volume **30**, accepted it and exited service menus;
`volume-restored30.jpg` confirms the selected native setting. This verifies
the tested launch path persists the setting. It does not establish the cause
of an older reset, or test an image-preparation/update path.
The final trace-disabled restart also reloaded volume 30 successfully
(`final-volume30-reloaded.jpg`); service menus were then exited.
The final stopped-disk clone is `restored-rootfs.ext4`. All 20 data/CRC pairs
in `restored-nvram-manifest.json` are valid. NVM advanced to `00000005`,
SHA-256 `9f36b5968d33a7b589f725cb7a841ab5d6b26c5b6377fc6583031a74713cc859`,
CRC `dd6b5fed`. The smaller NVRAM store remained at generation `00000004`;
the stores are independent, not a single universal preference-generation counter.
## Catch: passed native first-catch → M
Evidence ID: `2026-09-05-main-live-acceptance:first-catch-M`.
The controlled run shows **FURFROU CAUGHT 6,750,000** and **RIVAL BATTLE
READY**, followed by the Magikarp animation and an M target that remains
lit during subsequent ordinary battle gameplay. All other letter targets
are black at the acceptance checkpoints.
The log below is the **second restart** instance, preserved under
`reboot-two-logs/netbridge-emulator.log`, not the earlier first-boot log.
- Before the final shot, replay through line **88231** yields all eight
letter targets black.
- Actual `battle_b` edges: **87375/87694** (active/released).
- Actual `left_ramp_made_1` edges: **88245/88393**.
- Native node-12 write **88336** sets M to `[63,63,63]`. The exact captured
packet is retained in `fixtures/topper/controlled-first-catch.jsonl`.
- `catch-after-qualifier.jpg` explicitly shows the caught award. Its JSON
checkpoint is line **89061**, M `[63,63,63]`.
- Independent full-window replay **8322099975**: **1,459 accepted**,
**150 unknown**, **0 malformed**, ending with only M lit.
- Extended replay through **173281** still has only M `[63,63,63]`, zero
malformed. `rival-battle-entry.jpg` shows normal Pikachu/Caterpie gameplay
about 98 seconds after the catch screenshot.
The single-packet fixture tests exact award decoding, not the whole causal
interval; the archived log and native screenshots establish that interval.
The curated topology marks **only M** live-validated with this evidence ID.
The binary-only topology extractor intentionally produces unvalidated
metadata; do not overwrite curated live evidence with a fresh static export.
Reproduction prerequisites were Start, Action/launch, six captive contacts,
`battle_b`, `left_ramp_made_1`, then six more captive contacts followed by
the winning `battle_b``left_ramp_made_1`. Captive contacts were
target/lower/upper repeated twice per group. This is a witnessed sequence,
not a claim of the shortest possible route.
Earlier broad shot sequences only produced Catch-ready scenes and transient
letter activity. A scene reading “CATCH!” or “READY TO CATCH” is not itself
evidence of a successful catch. The misleadingly named
`catch-confirmed-after-animation.jpg` still shows the Magikarp animation;
use the later battle screenshot for actual post-animation evidence.
Native callback analysis initially used an incorrectly aligned callback
record. The right-ramp/manual inference and subsequent left-orbit inference
must not be reused. The corrected join follows the input manager at ELF
`0x007c7660`: callback table base `0x32266e8`, callback at record `+0`,
descriptor ID at `+0x2a`; descriptor base `0x3227f28` with physical fields
at `+0x38`. That joins final catch bit 46 to descriptor 1052,
`left_ramp_made_1` (physical `9-SW-9`), with a conditional alternate at
descriptor 1043, `left_ramp_made_2` (`8-SW-12`). The first mapping is now
confirmed by the controlled native award above; the alternate was not tested.
## Travel: acceptance remains open
Travel requires the relevant native biome progression. Flipper pulses during
Discover were not counted as a Travel-selection test. Later this run showed
**BULBASAUR TRAINED! 300,000** and **FOREST BATTLE TOTAL 49,349,980**. A
subsequent Town scoop shot returned a plain world-map scene. Left and right
flipper pulses did not produce a visible destination selector there. This
does not yet prove whether the native Travel prerequisites were fully met
or the Travel presentation path is failing. Do not mark Travel fixed from
this run.
Read-only native predicate recovery at ELF `0x00553490` (Ghidra `0x00653490`) requires current-biome
discovery count `+0x94 > 0`, catch count `+0xe4 > comparison +0x134`, training
count `+0x184 > 0`, and its bit in Battle mask `+0x20c`. New-game initialization
clears the counters/comparison. Each relevant writer rechecks Travel via
ELF `0x005535b0`, setting the player's Travel-lit flag at `+0x1f8`. This does not
suggest extra random training shots. The Travel-lit presentation itself uses
`PK_WorldMap_LoopingBG_01`; actual selection is a separate `travel_select_scene`.
Next diagnostic: read the native qualification fields or capture the Town-to-
selection callback, distinguishing missing qualification from selection entry
or presentation failure.
Follow-up: the [native Travel/map diagnostic](2026-09-05-travel-map-diagnostic.md)
subsequently witnessed qualification, a visible selector, right-flipper release
cycling, and the native change into Desert. A bare world map persisted afterward;
display restoration remains open. This supersedes the selector ambiguity above,
not the other acceptance results.
The Magikarp mechanism calibration tech alert remains; RGB decoding is not
complete motion/mechanism emulation.
## Regression checks and handoff
Fresh deterministic checks: 125 netbridge, 89 emulation, 6 dashboard server,
15 streaming (plus one opt-in live test skipped), and 15 browser JavaScript
tests: **250 passed, 1 skipped**. The new packet/metadata checks were observed
failing before the fixture promotion and passing afterward. No decoder
algorithm or native binary patch was changed by this acceptance follow-up.
The final runtime restart uses the same image with `SPIKE3_NETBRIDGE_TRACE=0`
to stop unbounded verbose packet-log growth, while retaining the full acceptance
logs separately. The streaming worktree remains in place because runtime
dependencies are symlinked into it.
Independent read-only review approved the bounded follow-up with no findings,
rechecked the native screenshots/raw replay and all 125 netbridge tests, and
explicitly kept Travel and the other topper mappings outside the acceptance.
@@ -0,0 +1,142 @@
# Playfield lamp colors: packed-command decoding repair
## Result
The user's report that the board does not reliably identify the purple Catch
shots has a concrete backend cause. At checkout `52c4bcc`, the generic playfield lamp handler treated
packed SPIKE 3 LED commands as sequential brightness bytes. It can update the
wrong physical addresses and publish masks or transition bytes as light levels.
The dashboard then drew those incorrect values. The approved repair now decodes
node 8/9 packed commands with board-specific bounds and invalidates old saved
playfield lamp values. It does not change the game rules or dashboard RGB order.
This is independent of the world-map restoration investigation. Neither symptom
establishes a stopped game or a frozen browser stream.
## Reproduced with captured native packets
Checkout `52c4bcc7d40b3bb3efa4fd34d408c2eedaa0bd8c`.
Packets below occur in the third diagnostic's native write log under
`/tmp/spike3-travel3.7oymCI/logs/netbridge-emulator.log` (for example lines
523718, 523799 and 523895). These are node-command bytes after transport decoding.
An append-only checkpoint is retained locally as
`emulation/work/acceptance/travel-LuFpIR/lamp-native-log-checkpoint.log`.
| Node / command | Packed interpretation | Pre-fix generic handler publishes |
| --- | --- | --- |
| 9 / `9680220e8900a503` | Channels 17,18,19 = 137,0,165; transition 3 | Channels 22..27 = 34,14,137,0,165,3 |
| 9 / `8580220e` | Channels 17,18,19 = 255,255,255; transition 0 | Channels 5,6 = 34,14 |
| 8 / `942b03` | Channel 43 = 0; transition 3 | Channel 20 = 3 |
Each example was independently run through the existing pure packed parser and
a fresh in-memory `NetbridgeModel`. The packed interpretation is unique for
these examples. The faulty branch is `_apply_node_write` in
`analysis/netbridge-emulator/netbridge_emu.py`: it uses `opcode - 0x80` as the
start address and `args[2:]` as brightness values for all opcodes 0x80..0xbf.
That is not this packet format. The existing sequential-lamp test encodes the
same incorrect assumption; a passing test suite does not validate this path.
## Physical mapping and display boundary
Hash-verified native game:
`169b19ea6ef89ba4be1a8bb2f868697da597389ab023d8ed81f149b6353b459f`.
Descriptor records at ELF/file offsets `0x323a5f0`, `0x323a640`, `0x323a690`
identify logical board 8 (node 9), channels 17,18,19 as
`BERRY TARGET RIGHT-R`, `-G`, `-B`, respectively. Thus the first packet really
requests RGB `(137,0,165)`, a purple color, at the right Berry insert. This does
not independently identify it as the winning Catch shot at that exact instant.
The dashboard already maps that insert to `9-LP-17..19` and interprets this
triple in RGB order, matching these descriptors. The topper's GRB order must not
be copied onto the playfield. Passive browser inspection confirmed the overlay
is drawing the values received from `outputs.lamps`; no input or scene mutation
was used for that inspection. Local evidence is in ignored
`emulation/work/acceptance/travel-LuFpIR/lamp-browser-audit.json` and `.png`.
The dashboard polls every 500 ms and currently displays commanded targets,
without verified native transition interpolation. Those are additional fidelity
limits, but neither explains the proven wrong-address decoding above.
## Implemented repair
`decode_packed_rgb` shares the recovered grammar with the topper decoder but
accepts a board-specific upper channel bound: node 8 uses 87; node 9 uses 86.
The complete command must have one valid interpretation before any lamp changes.
Those bounds resolve native node-8 packets `944405` and `954405` to channel 68,
which were ambiguous under the topper's 222-channel topology. Sparse updates
publish exact keys and contiguous `lamp.delta` groups. Unsupported C0 and invalid
packed node-8/9 commands no longer fall back to sequential brightness bytes;
node-write tracing retains the raw packet for diagnosis. Other legacy nodes and
the topper's GRB mapping retain their existing behavior.
Snapshots now identify `lamp_state_format: spike3-packed-playfield-lamps/v1`.
Restore discards old node-8/9 lamp values, including plausible-looking colors at
valid addresses, and validates canonical keys and bounds for new-format values.
Unrelated valid legacy lamp, stable switch and coil state is preserved. Lamps
discarded during migration populate as the native game sends new commands.
## Verification
- Baseline: 125 netbridge tests passed despite the wrong lamp assumption.
- New captured-packet regressions and a real-browser check failed before the
repair. The browser showed the right Berry insert dark for purple/white commands
and the API exposed incorrect ghost keys.
- Replayed all 32,739 packed writes in the saved native log: node 8 accepted
17,690; node 9 accepted 15,049; zero undecodable packets and no C0 packets.
Accumulated model state matched decoded physical channels after every packet.
Observed ranges remained 0..87 and 0..86. This broad replay is a parser/model
consistency check; the three native descriptor-backed examples above supply
independent expected addresses and values.
- `emulation/dashboard/test_lamp_browser.py` now passes in real headless Chromium:
fresh model -> output JSON -> actual dashboard HTTP refresh -> visible right
Berry marker. Purple `(137,0,165)`, white, and implicit-zero/off all match;
no browser page errors. Purple and white are captured packets; off uses the
same mask with the implicit-zero mode. No synthetic input reaches the live VM.
Replay source SHA-256:
`278c7e2be97969d896ea8822fb90106a485740b7297bb4e5e60212e3d073c0fd`.
The read-only replay helper is retained locally as
`emulation/work/acceptance/travel-LuFpIR/replay-fixed-lamps.py`.
To run browser acceptance, make Playwright available to Node and set
`SPIKE3_TEST_CHROMIUM` to a Chromium executable, then run
`python3 -m unittest discover -s emulation/dashboard -p test_lamp_browser.py -v`.
Without that environment variable it explicitly skips the opt-in test.
## Live bridge reload
Applied source commit `ca67d11` to the running third diagnostic by terminating
only the identified netbridge child. The existing supervisor paused QEMU,
restored stable state into bridge generation 2, reconnected UART, and resumed
legacy port-0 operation. QEMU PID 53711 remained alive throughout. Native Ball 1
and score 209,924,590 were unchanged in the before/after screenshots; no cabinet
inputs were sent by this acceptance check. The topper's M/A/G targets continued
to update after reconnection. This was not a guest reboot or image preparation.
The prior snapshot contained 191 keys for each playfield node, including 207
out-of-range ghost keys in total. The post-reload snapshot contains 70 node-8
and 81 node-9 channels, all within bounds, with the corrected format marker.
Lamp updates advanced from 220,875 to 227,924; the existing coil counter was
retained and advanced from 1,404 to 1,426. Passive Chromium inspection found 113
lamp markers, 73 visible in the default view, including the active green left
orbit, blue left ramp, yellow right ramp and orange right orbit. Those are live
commanded colors, not a claim that this checkpoint was a purple Catch mode.
Local evidence: `lamps-before-repair.jpg`, `lamps-after-repair.jpg`,
`lamps-pre-restart-output-state.json`, `lamps-post-restart-output-state.json`,
and `lamp-browser-fixed-audit.json`/`.png` in the acceptance directory above.
The screenshot still shows the reported world-map issue. The existing
diagnostic runtime/verbose trace remains active; this bridge-only reload did
not change browser streaming helpers or the native observer.
Final verification: netbridge 130 tests; emulation 89; dashboard 7 including
real Chromium; streaming 16 with one platform skip; JavaScript display 15;
research 7. Total 263 passed, one skipped. Research validation passed with 52
evidence records. Expected negative-path transport/streaming diagnostics were
emitted by existing tests; no test failures occurred. Independent task review
approved the decoder and migration with no findings.
Address/color correctness does not by itself prove blink/fade timing or identify
all winning Catch shots. A fresh controlled Catch-instruction-to-winning-shot
acceptance remains separate from this captured-packet repair. The world-map
restoration issue is still unresolved.
@@ -0,0 +1,140 @@
# Native Travel and persistent world-map diagnostic
## Result and limits
Travel qualification, selector entry, visible destination selection, right-flipper
release cycling, and the native change into Desert were witnessed. The remaining
failure is display restoration: a bare world map can remain after catch or entry
into another biome. This is not a completed Travel acceptance or a production fix.
The user operated dashboard controls during these runs. Treat the traces as
witnessed sequences, not an isolated automated input test. In particular, the
keyboard harness started after the user's selector was already open, timed out,
and never sent its planned left/right presses. Both-flippers confirmation was
not independently validated in these runs.
## Binary and address basis
- Checkout: `52c4bcc7d40b3bb3efa4fd34d408c2eedaa0bd8c`.
- Repository game SHA-256:
`169b19ea6ef89ba4be1a8bb2f868697da597389ab023d8ed81f149b6353b459f`.
- Guest game SHA-256:
`e8add023b60be684509f25cd54972981cd2adf15b0ad09d1e99ba33c29a4f54a`.
The compared executable text is identical; differences begin in section-name
metadata at file byte 52,827,005. The observer rejects any other guest hash.
- All addresses below are **ELF/file offsets**. Add `0x100000` for the preserved
Ghidra project's image addresses. Runtime addresses additionally include ASLR.
The previous retirement of `UITravelSelect` at `0x586880` mixed these address
bases. ELF `0x586880` / Ghidra `0x686880` is the selector flipper handler; querying
Ghidra `0x586880` examines a different location. This correction does not resolve
the separately retired trough address.
## Witnessed native sequence
Second diagnostic, guest monotonic seconds:
| Time | Observation |
| --- | --- |
| 306.346721 | Current Forest player has discovery, catch, training and Battle prerequisites; eligibility returns true. |
| 312.395043 | Travel-lit background getter runs. Qualification itself can queue this announcement without a Town shot. |
| 317.093230 | Selector entry `0x5869a0`; scene 8 is selected. |
| 317.093767 | Selector starts at index 1 (Lake), starting biome 2 (Forest); scene node lookup is non-null. |
| 320.260720 | Right-flipper release advances the selection to index 2. |
| 321.143786 | Right-flipper release advances the selection to index 3. |
| 322.927261 | Selector exit callback runs. |
| 322.927285 | Exit invokes commit `0x586000`; this was not the explicit confirm or timeout callback. |
| 322.927287 | Native biome change `0x552cd0` receives biome 1 (Desert). |
| 322.928111 | Discover-map restoration `0x54d970` runs after returning to scene 0. |
Selection indices map to biome IDs `[2, 0, 3, 1]`: Forest, Lake/coast,
Mountain, Desert. Setup `0x586380` starts a 15-second selection timer and about
one second of initial input lockout. A single flipper cycles on **release**.
The first recording visibly contains `TRAVEL TO LAKE` and its countdown, then
the entering-Lake announcement. A later bare-map screenshot was not evidence
that the selector failed to render. The second run confirms the user's Desert
observation. Separate before/after screenshots retained the bare map and score
66,091,790 through a no-new-input observation interval; it was not merely the
brief Travel-lit announcement.
The observer's original `active` and `lockout` flipper fetches were invalid;
their large values are not timer evidence and were removed from the next probe.
## Restoration boundary
Fresh native analysis of `0x54d970` shows this order:
1. Select the biome by name with `discover_map_select.set_biome`.
2. Unconditionally request `set_world_map_visible(false)` through `0x54abe0`.
3. Update caught count and discovery progress.
The matching local GDC script was decoded using the official Godot tokenizer
buffer v100 layout. Its visibility setter directly hides the world background
and shows the biome group, progress bar and overlay when passed false. There is
no catch-count or progress guard inside that setter. The ordinary scene attach
and ready sequence is synchronous before the restoration call.
A third diagnostic observed a non-null node and false input at bool-helper
lookup `0x54ac38`, followed by a one-argument enqueue call at `0x54acf0` for the
same node. This occurred at an earlier discovery refresh, not the failed Desert
exit. It does not establish successful script execution at the failing boundary.
At `0x54acf0`, register x21 has become a Variant pointer: its byte at offset 0 is
the type tag (1), **not the boolean value**. The value is at `+8`; queue status
can be observed immediately afterward at `0x54acf4`. Do not interpret the
original prequeue `value=1` diagnostic as false being changed to true.
The user then reported another Desert entry followed by "it updated," clarifying
that the bonus gates changed the world map back to the Desert map. The
third trace distinguishes that sequence:
- At 357.085108, Travel commits Desert, then returns to the saved scene 3.
Unlike the second run, that exit does not invoke `0x54d970`.
- At 385.066839, subsequent discovery progression crosses the 25-percent
threshold and invokes `0x54d970` with return address `0x54e4b0` (call at
`0x54e4ac`); `0x54e4a8` is the preceding scene-switch return address. The node is again
non-null and the visibility-helper input is false.
- At 404.618092, further discovery switches to scene 1; the screenshot shows
Desert artwork, a DISCOVER bar and Voltorb. Score and topper M continued
updating. The view recovered without a binary or asset fix.
This supports a scene/progress-dependent restoration problem rather than a
stopped guest or frozen browser stream. It does not establish why the earlier
scene-0 restoration left a world map visible.
Native discovery update `0x54dfb0` makes no scene change below 25 percent;
crossing 25 percent selects the biome-map scene (0), and crossing 51 percent
selects the discovery scene (1). The generic restoration routine `0x54e960`
likewise makes no scene change below 25 percent. Biome change resets discovery
progress to zero through `0x54c500` / `0x54bb60`, without selecting a replacement
Godot scene there. This explains the third run's old-scene interval and later
bonus-gate refresh. It does not yet prove the origin of the visible world-map
background in every reported case.
The script also has test input handlers that toggle world/biome visibility on
Action and cycle biome on flippers. These handlers are not limited to keyboard
events: only the modifier rejection is Key-specific. Whether a native-injected
action overwrites restoration remains under investigation. The native injector
exists at ELF `0x6ad710`, but no executable cabinet caller has been established;
it may be dormant developer code. The browser VNC
client itself is view-only and dashboard controls use the cabinet transport.
## Local evidence and next discriminator
Raw evidence is intentionally ignored and not redistributed:
`emulation/work/acceptance/travel-LuFpIR/`. It includes hash-checked observer
scripts, native decompilations, reconstructed GDC tokens, two VNC recordings,
frame contact sheets, screenshots, and disposable diagnostic disk images.
Second runtime serial/input logs were under `/tmp/spike3-travel2.XZ45sV/logs/`;
third runtime under `/tmp/spike3-travel3.7oymCI/logs/`.
Next, correlate the failing exit with node lookup, the actual bool Variant,
queue status and any native Godot action injection. Compare against a later
25-percent discovery refresh, which calls the same restoration routine.
Do not patch Travel qualification, force scene visibility, or blame the stream
until that failing boundary is identified.
The rendering-only diagnostic separately found async DRM page flips rejected
with `EINVAL`, followed by a successful modeset fallback. That is evidence of
avoidable rendering work, not proof that it causes this map-state failure.
No production binary, asset, or rendering behavior was changed by these probes.
@@ -0,0 +1,198 @@
# Pokemon v0.83 topper protocol evidence
## Status, 2026-09-05
The decoder and dashboard expose the actual **74 Magikarp RGB targets**
independently of the legacy generic lamp decoder. All eight letter mappings
are proven through the native animation's group and named physical descriptors.
A recovered 35-command real post-award trace replays 30 RGB packets, zero
malformed packets, and five explicitly allowlisted non-RGB writes. Its `M`
target is white. A fresh bounded first-catch live gate now confirms **M**;
see [the main live acceptance](2026-09-05-main-live-acceptance.md).
The historical excerpt is not represented as a complete first-award fixture.
Mechanism targets remain unmapped pending packet/motion proof.
Read-only observation of the current live game subsequently confirmed the
dashboard's M target was white and rendered with the `lit` class. The first A
also became white during observation. No agent gameplay input was sent during
this observation, and the native catch animation was not witnessed; this
confirms current game→decoder→dashboard output but does not retrospectively
establish a causal first-catch interval. The subsequent controlled September 5
run does: native Furfrou caught award, final switch edge, exact node-12 packet,
and M retained in later gameplay. Only M is now `live_validated=true`.
## Correction: ETOPPER is a different accessory
The original plan accidentally selected the 92-pixel `ETOPPER` descriptor
table at file `0x32331b0`. Its logical-board field is **4**. The actual
Magikarp descriptors use logical board **9**, physical node **12**, beginning
at file `0x323d890`: 222 component records, each 80 bytes, through `0x3241df0`
exclusive. The historical `topology-v83.json` is preserved as board-4 data;
the decoder loads only `magikarp-v83.json`.
Actual groups are eight letters, 24 back pixels, 22 middle pixels and 20 front
pixels. This agrees exactly with the real initializer's 222-channel writes.
### Direct semantic mapping chain (all offsets below are ELF file offsets)
`FUN_005e0fd0` and `FUN_005e1270` animate group `0x25b` using the actual
Magikarp letter count from `FUN_006a89d0`. `FUN_0093e3c0` indexes the group
pointer table, while `FUN_0093d030` expands each virtual RGB member.
1. GOT at `0x3193ee0` → group table `0x31e0fb0`.
2. Entry `0x25b` at `0x31e2288` → terminated list at `0x31e3368`, containing
virtual RGB IDs `0x18f..0x196`, then zero.
3. GOT at `0x3191de0` → virtual RGB table `0x31dde68`, 16-byte records.
First member points to `0x31dfc00`, physical lamp IDs `0x425,0x426,0x427`.
4. GOT at `0x3195a30` → pointer `0x31c1de0` → lamp table `0x321a990`.
Its 40-byte records contain descriptor IDs at offset `0x1a`.
5. Those three lamp IDs resolve to descriptor IDs 1177,1178,1179 in the
80-byte descriptor table at `0x3227f40`. Their names are explicitly
`(M)AGIKARP-R/G/B`, logical board9, physical channels1/0/2.
| Letter | Pixel | G/R/B channels |
| --- | --- | --- |
| M | 0 | 0/1/2 |
| first A | 1 | 3/4/5 |
| G | 2 | 6/7/8 |
| I | 3 | 9/10/11 |
| K | 19 | 57/58/59 |
| second A | 18 | 54/55/56 |
| R | 17 | 51/52/53 |
| P | 16 | 48/49/50 |
The extractor verifies every step and exact parenthesized descriptor name;
no numeric-order assumption labels the letters. Both As retain distinct ordinal
records. The seven letters after M remain `live_validated=false` until their
matching controlled runs. Live validation is curated evidence metadata, not
something the binary-only extractor can infer from static descriptors.
Static servo descriptors immediately follow at `0x3241df0/0x3241e40/0x3241e90`:
`SERVO HEAD` driver7, `SERVO TAIL` driver6, `WAVE MOTOR` driver0 on logical
board10. These names alone do not establish packet target/duration fields.
## Static serializer recovery
Game SHA256 `169b19ea6ef89ba4be1a8bb2f868697da597389ab023d8ed81f149b6353b459f`.
Read-only Ghidra `PokemonCurrent/game`, using `tools/DecompileTopper.java`:
- analyzer `00a21ec0` (file `00921ec0`), arguments count/indices/values/times/state;
- serializer `00a253b0` (file `009253b0`), registers w0 node, w1 bank,
w2 count, x3 indices, x4 values, x5 transitions;
- secure transfer `00a22aa0` (file `00922aa0`).
The analyzer constrains each bank-local index to 0..95 and sorts/reduces input.
Caller `FUN_007bef40` explicitly divides/modulos physical channels by `0x60`.
Value mode (opcode low two bits): 0 implicit zero, 1 implicit 255,
2 one byte per component, 3 shared byte. Opcode bit 5 selects index list
or an additional component-mask presence byte. Bank-zero embeds transition bits
2..4 in the opcode. Nonzero banks set opcode bit 4 and put bank in bits 5..7
and transition mode in bits 2..4 of an additional flags byte. These overlap;
the reference decoder accepts only a unique complete interpretation, rejecting
ambiguous packets instead of guessing.
An index byte below 0x80 is a single component, or starts an ordered list
whose last index has bit 7 set. A byte with bit 7 set starts a compressed
component bitmask; bit 6 selects implicit 0xff versus zero mask bytes.
The following byte carries first mask-byte index in its high nibble, last
in its low nibble. Short masks include their first/last bytes explicitly,
with low header bits selecting explicit intermediate bytes. Long masks use
the low four header bits plus an additional byte as a 12-bit presence mask.
Mask bits are little endian, component order ascending. Long versus short is
determined by the bounds (more than eight mask bytes), not opcode bit5. The
analyzer omits the upper presence byte when it is `0xff` (all explicit), clears
opcode bit5, and the decoder reconstructs that implicit value. For long masks, the
analyzer initializes the upper presence byte to 0xff; unused high presence
bits remain one. The real `ba813bf0...` packet exposed this detail, now covered
by a regression test.
### September 4 live regression capture
Live runtime `/tmp/spike3-stream.jPiAam`, log
`logs/netbridge-emulator.log`, inclusive lines 1..113997 were frozen into the
derived trace `/tmp/magikarp-live113997.jsonl` (SHA256
`e52a60fbd740b4ad86c09f82853e72dbe517ec34a5667522ebd41a4e8255d735`).
The interval contains 8,349 topper writes. After the omitted-upper-presence fix,
it replays **7,636 accepted RGB writes, 713 unknown non-RGB writes, and zero
malformed writes**. Before the fix, 47 genuine RGB packets were rejected.
Two compact real-command fixtures preserve the newly exercised branches:
- `live-omitted-presence.jsonl`: exact log line24806, opcode `0x92`, bank1,
twelve-byte component mask with implicit upper presence `0xff`; physical
pixel34 is RGB `[86,188,217]`.
- `live-transition-exceptions.jsonl`: exact log line53540, opcode `0x9e`,
bank0, the same omitted presence encoding plus individual transition
exceptions.
The analyzer at `FUN_00a21ec0` initializes its presence bytes to `0xff00`,
selects the long layout from `(last_mask + 1 - first_mask) >= 9`, then at
`LAB_00a22538`/`LAB_00a22730` clears the extra-byte flag when the upper presence
byte remains `0xff`. `FUN_00a253b0` emits that byte only when the extra-byte
flag is `0x20`. This independently supports the live regression correction.
The browser's white RGB fill initially obscured its white letter text. Text
now selects black or white from RGB luminance; seven actual-JavaScript cases
cover white, black, primary colors, and malformed RGB inputs. Pixel labels use
the same contrast rule. No output target or animation timing is altered.
Transition mode 0 sends each transition; 4 is shared zero; 20 shared explicit;
8/24 use a bitmap selecting a second shared transition; 12/28 use a bitmap
selecting individual exceptions. Bit 4 means the common transition is explicit.
The transition bitmap precedes values, followed by common/exception times.
Every payload byte must be consumed before any state is committed.
`transition_raw` preserves component G/R/B byte values. Their conversion to
duration/ticks is not yet independently proven, so `transition_ticks` is null.
Pixels retain per-component sequence numbers and normalized R/G/B targets.
No fade interpolation uses wall time.
## Genuine initialization fixture
`fixtures/topper/guided-setup.jsonl` comes from main checkout
`emulation/work/rollback/20260901T015534Z/logs/netbridge-emulator.log`, inclusive
lines 18386..18392. Log SHA256:
`a95b7853ce8dd77faf2d7676e17ab87d1370076611663d787f0883a9c65f5a5a`.
| Complete command | Decoded component tuples (channel, value, raw transition) |
| --- | --- |
| `a4c00b00` | `(0..95, 0, 0)` |
| `b024c00b00` | `(96..191, 0, 0)` |
| `9044c003ff3f` | `(192..221, 0, 0)` |
The first two masks omit twelve 0xff bytes. The final mask explicitly sends
0xff and 0x3f with two implicit 0xff bytes. These startup writes cover all 74
actual Magikarp pixels.
## Recovered nonzero live traffic
`recovered-post-award.jsonl` preserves 35 node-12/13 writes, in their original
order, from the August 18 15:44:31 UTC tool output in session
`019ff92b-0fb2-7fb2-b702-5151451f5dfc` (rollout line16396). The original command
was `tail -300 emulation/work/logs/netbridge-emulator.log`; that log has since
been replaced. The extractor retained only complete decrypted writes, no
secure wrappers or serial material. It is a post-award excerpt, not a bounded
first-catch capture.
Real examples independently asserted and re-encoded in tests:
- `b70f10913f28`: channels15,16,17 target63, transition40.
- `ba813bf038e0383f3f3fffffff2802`: channels27..29 target63 transition40,
channels93..95 target255 transition2.
- `9244c003ff3f441f4f773688b754d3713482441f4fda64fa311638da64fa3116380d060f`:
channels192..221, per-component values, shared-zero transition;
pixel64 RGB is31,68,79; pixel73 RGB is6,13,15.
The replay's five explicit non-pixel exceptions are `14de000000` on node12,
`1400000000` on node13 and three `4264040000ff0005` on node13. No unknown
packet is interpreted as an RGB update. Truncating every prefix of the three
examples preserves all prior target state.
## Controlled capture
Run `tools/extract_topper_trace.py --log LOG --first-line N --last-line M
--source LABEL --output OUTPUT.jsonl`. Bounds are inclusive; record them around
each native action. The tool includes only decrypted node-12/13 writes and
refuses invalid hex or an empty interval. Record a native LCD award screenshot
inside the first-catch window, a comparable idle control, and separate service
actions for head/tail back, forward, center and wave backward/forward.
@@ -0,0 +1 @@
{"sequence":1,"node":12,"command_hex":"8aa107ff0fff0f0700003f3f3f0000000000000000000000000000000000000000001e","source":"2026-09-05-main-live-acceptance:first-catch-M","source_log_line":88336,"final_switch":"left_ramp_made_1","switch_active_log_line":88245,"native_award":"Furfrou caught 6750000"}
@@ -0,0 +1,3 @@
{"sequence":1,"node":12,"command_hex":"a4c00b00","source":"qemu-guided-setup-rollback-20260901"}
{"sequence":2,"node":12,"command_hex":"b024c00b00","source":"qemu-guided-setup-rollback-20260901"}
{"sequence":3,"node":12,"command_hex":"9044c003ff3f","source":"qemu-guided-setup-rollback-20260901"}
@@ -0,0 +1 @@
{"sequence":1,"node":12,"command_hex":"9224c70bc07ffc81e3c77ffcc07ffcbc56d9200e24a049b7632d72220f270d060f2a1331482153120815120815d361f21a0c1e632d72220f27220f27813b94482153120815321739a049b70d060f","source":"live-20260904-line24806"}
@@ -0,0 +1 @@
{"sequence":1,"node":12,"command_hex":"9e870bc001fcfe03c001fcc0ff03c081e3f80f3f3f3f3f3f3fffffff3f3f3f3f3f3fffffff3f3f3fffffff3f3f3f3f3f3fffffffffffff3f3f3f0c020202020202020202282828020202020202","source":"live-20260904-line53540"}
@@ -0,0 +1,35 @@
{"sequence":1,"node":12,"command_hex":"b224c60b247ffcc7f85b29688e41a3210f2656276336183e36183e52255eda64fb7e3a9136183eda64fb893f9d52255e0d060f210f260d060fdb64fc36183e9c48b45627630d060f7e3a91893f9d9c48b47e3a9152255e8e41a35b296836183e","source":"recovered-qemu-20260818-post-award"}
{"sequence":2,"node":12,"command_hex":"92448303ff7f1c3f210f26562763893f9d9c48b4210f2652255e52255e0d060f","source":"recovered-qemu-20260818-post-award"}
{"sequence":3,"node":12,"command_hex":"b224c60b367ffce3c0e3f84e245a843d982c1432612c6f2b13312b1331462051dd65fd893f9d2b1331dd65fd9946b04620512c1432d762f72b13318f41a4612c6f893f9d9946b08f41a4893f9d462051843d984e245a2b1331","source":"recovered-qemu-20260818-post-award"}
{"sequence":4,"node":12,"command_hex":"92448303ff7f1c072c1432612c6f9946b08f41a42c1432462051462051","source":"recovered-qemu-20260818-post-award"}
{"sequence":5,"node":12,"command_hex":"9580777002","source":"recovered-qemu-20260818-post-award"}
{"sequence":6,"node":12,"command_hex":"b224c80b37fcf9e3e8e3fd421e4c7b388d3719406b317c200f25100713200f253c1b44fe9343a9200f25fea94dc23c1b44371940dcfcd160f0200f25803b946b317c9343a9dcfca94dc2803b949343a93c1b447b388d421e4c200f25","source":"recovered-qemu-20260818-post-award"}
{"sequence":7,"node":12,"command_hex":"9244c203ffbe073719406b317ca94dc2803b94371940dcfc3c1b44dcfc3c1b44","source":"recovered-qemu-20260818-post-award"}
{"sequence":8,"node":12,"command_hex":"14de000000","source":"recovered-qemu-20260818-post-award"}
{"sequence":9,"node":12,"command_hex":"9ec007ff01ff0f00000000ff01ffffff0000000000000000003f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f000000000000000000281e1e1e000000000000000000000000000000000000","source":"recovered-qemu-20260818-post-award"}
{"sequence":10,"node":12,"command_hex":"9ec07bfeff3f00000000000000ffffff3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f28000000020202","source":"recovered-qemu-20260818-post-award"}
{"sequence":11,"node":12,"command_hex":"b224c00b0036183e703381441f4f773688170a1b160a19170a1b311638dd65fe9d48b4170a1bdd65feb754d33116380d060f441f4f0d060fda64fac85ce6170a1b7134827736880d060f9d48b4da64fab754d37134829d48b431163870338136183e170a1b","source":"recovered-qemu-20260818-post-award"}
{"sequence":12,"node":12,"command_hex":"9244c003ff3f441f4f773688b754d3713482441f4fda64fa311638da64fa3116380d060f","source":"recovered-qemu-20260818-post-award"}
{"sequence":13,"node":12,"command_hex":"b224c80b17fdfbe3f8e32b1331672f7652255e813b950f07121c0d200f071228122edcfca64cbf0f0712dcfcc45ae128122e52255ed762f7bf57db0f0712632d72813b95a64cbfd762f7c45ae1632d72a64cbf28122e672f762b13310f0712","source":"recovered-qemu-20260818-post-award"}
{"sequence":14,"node":12,"command_hex":"9244c003ff0752255e813b95c45ae1632d7252255ed762f728122ed762f728122e","source":"recovered-qemu-20260818-post-award"}
{"sequence":15,"node":12,"command_hex":"9a8017707038ffffff3f3f3f0228","source":"recovered-qemu-20260818-post-award"}
{"sequence":16,"node":12,"command_hex":"b224c00b16e3f8e3210f265d2a6b602c6e8c40a10d060f2310280d060f1f0e24d963f9af50c90d060fd963f9ce5eec1f0e24602c6ed361f3b352ce0d060f5427618c40a1af50c9d361f3ce5eec542761af50c91f0e245d2a6b210f260d060f","source":"recovered-qemu-20260818-post-award"}
{"sequence":17,"node":12,"command_hex":"9244c003ff07602c6e8c40a1ce5eec542761602c6ed361f31f0e24d361f31f0e24","source":"recovered-qemu-20260818-post-award"}
{"sequence":18,"node":13,"command_hex":"1400000000","source":"recovered-qemu-20260818-post-award"}
{"sequence":19,"node":12,"command_hex":"b224ce0b9f8fe33ffee3f8f1e31f180b1c5326606e327e9645ac2a1330170a1bd562f5b754d2d562f5d562f5170a1b6e327ecf5feea74cc04620519645acb754d2cf5feed562f5462051b754d2170a1b532660180b1c","source":"recovered-qemu-20260818-post-award"}
{"sequence":20,"node":12,"command_hex":"9244c003ff076e327e9645acd562f54620516e327ecf5fee170a1bcf5fee170a1b","source":"recovered-qemu-20260818-post-award"}
{"sequence":21,"node":13,"command_hex":"4264040000ff0005","source":"recovered-qemu-20260818-post-award"}
{"sequence":22,"node":13,"command_hex":"4264040000ff0005","source":"recovered-qemu-20260818-post-award"}
{"sequence":23,"node":13,"command_hex":"4264040000ff0005","source":"recovered-qemu-20260818-post-award"}
{"sequence":24,"node":12,"command_hex":"9a8012701c383f3f3fffffff2802","source":"recovered-qemu-20260818-post-award"}
{"sequence":25,"node":12,"command_hex":"b224ce0b9f8fe33ffee3f8f1e31f1007134921547c398fa049b8321739100713d05fefbf57dbd05fefdb64fb1007137c398fca5ce89946af391a41a049b8bf57dbca5ce8db64fb391a41bf57db100713492154100713","source":"recovered-qemu-20260818-post-award"}
{"sequence":26,"node":12,"command_hex":"9244c003ff077c398fa049b8db64fb391a417c398fca5ce8100713ca5ce8100713","source":"recovered-qemu-20260818-post-award"}
{"sequence":27,"node":12,"command_hex":"b224ce0b9f8fe33ffee3f8f1e31f0d060f401d4a8a3f9faa4ec33a1a430d060fca5de9c65be3ca5de9dd65fe0d060f8a3f9fc45ae18a3f9f2c1433aa4ec3c65be3c45ae1dd65fe2c1433c65be30d060f401d4a0d060f","source":"recovered-qemu-20260818-post-award"}
{"sequence":28,"node":12,"command_hex":"9244c003ff078a3f9faa4ec3dd65fe2c14338a3f9fc45ae10d060fc45ae10d060f","source":"recovered-qemu-20260818-post-award"}
{"sequence":29,"node":12,"command_hex":"b224cf0bebf88f033f0efcf1c78f0337193f9845aeb352cd431f4dc359e0cc5deac359e01409179845ae140917be57da7b388d210f26b352cd140917cc5deabe57da210f26cc5dea37193f","source":"recovered-qemu-20260818-post-award"}
{"sequence":30,"node":12,"command_hex":"924483033ffee3389845aeb352cd210f269845aebe57dabe57da140917","source":"recovered-qemu-20260818-post-award"}
{"sequence":31,"node":12,"command_hex":"9a8012801f38ffffff3f3f3f0228","source":"recovered-qemu-20260818-post-award"}
{"sequence":32,"node":12,"command_hex":"b224cf0bcbf88f033f7efcf18f032f1536a44bbdbb56d74c2358bb56d7d160f0bb56d7da64fa1c0c20a44bbd1c0c20b754d26c317c170a1bbb56d71c0c20d160f0b754d2da64fa170a1bd160f02f1536","source":"recovered-qemu-20260818-post-award"}
{"sequence":33,"node":12,"command_hex":"9244c203ffe338a44bbdbb56d7da64fa170a1ba44bbdb754d2b754d21c0c20","source":"recovered-qemu-20260818-post-award"}
{"sequence":34,"node":12,"command_hex":"b224cf0bcbf88f033f7efcf18f0326112cb151cbc359e0562763b252cdd662f6b252cdd461f325112bb151cb25112baf50c95b2a690e0611c359e025112bd662f6af50c9d461f30e0611d662f626112c","source":"recovered-qemu-20260818-post-award"}
{"sequence":35,"node":12,"command_hex":"9244c203ffe338b151cbc359e0d461f30e0611b151cbaf50c9af50c925112b","source":"recovered-qemu-20260818-post-award"}
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import socket
import time
from pathlib import Path
def publish(name: str, active: bool, state_file: Path, event_file: Path) -> None:
event = {"type": "switch", "name": name, "value": int(active), "source": "qemu-keyboard"}
with event_file.open("a", encoding="utf-8") as fp:
fp.write(json.dumps(event, sort_keys=True) + "\n")
try:
state = json.loads(state_file.read_text(encoding="utf-8"))
if not isinstance(state, dict):
state = {}
except (FileNotFoundError, json.JSONDecodeError, OSError):
state = {}
state[name] = active
temporary = state_file.with_suffix(".json.keyboard.tmp")
temporary.write_text(json.dumps(state, sort_keys=True), encoding="utf-8")
temporary.replace(state_file)
print(f"qemu-keyboard name={name} active={int(active)}", flush=True)
def run(socket_path: Path, state_file: Path, event_file: Path) -> None:
while True:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
client.connect(str(socket_path))
buffer = b""
while True:
chunk = client.recv(4096)
if not chunk:
break
buffer += chunk
while b"\n" in buffer:
raw_line, buffer = buffer.split(b"\n", 1)
parts = raw_line.decode("ascii", errors="ignore").split()
if len(parts) == 2 and parts[1] in ("0", "1"):
publish(parts[0], parts[1] == "1", state_file, event_file)
except OSError:
time.sleep(0.2)
finally:
client.close()
def main() -> None:
parser = argparse.ArgumentParser(description="Relay QEMU guest keyboard events into SPIKE switches")
parser.add_argument("--socket", required=True, type=Path)
parser.add_argument("--state-file", required=True, type=Path)
parser.add_argument("--event-file", required=True, type=Path)
args = parser.parse_args()
run(args.socket, args.state_file, args.event_file)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
from pathlib import Path
import hashlib
import json
import unittest
from tools.extract_magikarp_topology import EXPECTED_GAME_SHA256, extract_magikarp_topology, validate_magikarp_topology
class MagikarpTopologyTests(unittest.TestCase):
def test_checked_in_magikarp_fixture(self):
fixture = Path(__file__).resolve().parent / 'fixtures/topper/magikarp-v83.json'
topology = json.loads(fixture.read_text())
validate_magikarp_topology(topology)
self.assertEqual(topology['source_sha256'], EXPECTED_GAME_SHA256)
self.assertEqual(len(topology['pixels']), 74)
self.assertEqual([x['name'] for x in topology['letters']], list('MAGIKARP'))
def test_exact_game_maps_named_magikarp_board_and_letters(self):
game = Path(__file__).resolve().parents[2] / 'games/pokemon_pro/game'
if not game.exists():
self.skipTest('ignored exact game image not present')
if hashlib.sha256(game.read_bytes()).hexdigest() != EXPECTED_GAME_SHA256:
self.skipTest('local ignored game image differs from the pinned extraction fixture')
topology = extract_magikarp_topology(game)
validate_magikarp_topology(topology)
self.assertEqual(len(topology['pixels']), 74)
self.assertEqual(topology['logical_board'], 9)
self.assertEqual(topology['node_address'], 12)
self.assertEqual(topology['pixels'][0]['descriptors']['g'], '(M)AGIKARP-G')
self.assertEqual([x['pixel_indices'] for x in topology['letters']],
[[0],[1],[2],[3],[19],[18],[17],[16]])
self.assertEqual([x['name'] for x in topology['letters']], list('MAGIKARP'))
if __name__ == '__main__':
unittest.main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,357 @@
"""Same-session pause protocol, monitor acknowledgement and supervisor boundary."""
import copy
import json
from pathlib import Path
import socket
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
from netbridge_emu import NetbridgeModel, PlayfieldAPIServer
import test_netbridge_emu
class Monitor:
def __init__(self, path):
self.path = path
self.commands = []
self.state = "running"
self.refuse_stop = False
self.silent = False
self.stop = threading.Event()
self.listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.listener.bind(str(path))
self.listener.listen(8)
self.listener.settimeout(.1)
self.worker = threading.Thread(target=self.run, daemon=True)
self.worker.start()
def run(self):
while not self.stop.is_set():
try:
conn, _ = self.listener.accept()
except socket.timeout:
continue
except OSError:
return
with conn:
conn.settimeout(.1)
try:
conn.sendall(b"QEMU monitor\n(qemu) ")
buf = b""
while not self.stop.is_set():
try:
block = conn.recv(4096)
except socket.timeout:
continue
if not block:
break
buf += block
while b"\n" in buf:
wire, buf = buf.split(b"\n", 1)
command = wire.decode()
self.commands.append(command)
if self.silent:
continue
if command == "stop" and not self.refuse_stop:
self.state = "paused"
elif command == "cont":
self.state = "running"
reply = f"VM status: {self.state}\r\n" if command == "info status" else ""
conn.sendall(reply.encode() + b"(qemu) ")
except OSError:
pass
def close(self):
self.stop.set()
self.listener.close()
self.worker.join(1)
class PauseTests(unittest.TestCase):
open_playfield_client = test_netbridge_emu.NetbridgeEmulatorTests.open_playfield_client
send_playfield = staticmethod(test_netbridge_emu.NetbridgeEmulatorTests.send_playfield)
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.monitor = Monitor(Path(self.tmp.name) / "monitor.sock")
self.model = NetbridgeModel(image_crc=0, physics_authority="vpx",
output_state_file=Path(self.tmp.name) / "output-state.json")
self.server = PlayfieldAPIServer(self.model, port=0, qemu_monitor_socket=self.monitor.path)
self.server.start()
self.client, self.reader, _, _ = self.open_playfield_client(self.server)
def tearDown(self):
self.reader.close()
self.client.close()
self.server.stop()
self.monitor.close()
self.tmp.cleanup()
def request(self, kind, **fields):
self.send_playfield(self.client, {"type": kind, **fields})
return json.loads(self.reader.readline())
def pause(self):
reply = self.request("playfield.pause")
self.assertEqual(reply["status"], "paused")
self.assertTrue(reply["physics_pause_acknowledged"])
self.assertTrue(reply["connected"])
self.assertEqual(self.monitor.state, "paused")
return reply
def test_pause_resume_keeps_contacts_and_ordered_driver_edges(self):
self.request("switch.set", address="8-SW-5", active=True)
contacts = dict(self.model._physical_switch_state)
self.model._handle_node_write(8, bytes.fromhex("4001f006010000000000"))
# Driver already pending when pause is requested must precede its ACK.
self.send_playfield(self.client, {"type": "playfield.pause"})
driver = json.loads(self.reader.readline())
paused = json.loads(self.reader.readline())
self.assertEqual(driver["type"], "driver.pulse")
self.assertEqual(paused["status"], "paused")
self.assertLess(driver["seq"], paused["seq"])
self.assertEqual(paused["driver_cursor"], 1)
self.assertEqual(self.model._physical_switch_state, contacts)
commands = list(self.monitor.commands)
for _ in range(50):
self.assertTrue(self.request("heartbeat")["physics_pause_acknowledged"])
self.assertEqual(self.monitor.commands, commands)
started = time.monotonic()
pending = self.request("playfield.resume")
ready = json.loads(self.reader.readline())
self.assertEqual(pending["status"], "resume.pending")
self.assertTrue(pending["physics_resume_pending"])
self.assertEqual(ready["status"], "ready")
self.assertFalse(ready["physics_resume_pending"])
self.assertLess(time.monotonic() - started, .25)
self.assertEqual(ready["bridge_session"], paused["bridge_session"])
self.assertEqual(self.model._physical_switch_state, contacts)
self.model._handle_node_write(8, bytes.fromhex("4001f006010000000000"))
second = json.loads(self.reader.readline())
self.assertEqual(second["pulse_count"], 2)
self.assertLess(ready["seq"], second["seq"])
def test_paused_heartbeats_do_not_enqueue_cpu_state(self):
self.pause()
before = json.loads(self.model.output_state_file.read_text())["playfield_api"]["heartbeat_mono_ns"]
# An unacknowledging CPU channel must receive no heartbeat work.
class CPU:
def publish_state(self):
raise AssertionError("paused heartbeat queued CPU state")
self.server.cpu_spi_control = CPU()
try:
for _ in range(100):
self.assertTrue(self.request("heartbeat")["physics_paused"])
published = json.loads(self.model.output_state_file.read_text())["playfield_api"]
self.assertGreater(published["heartbeat_mono_ns"], before)
self.assertLess(time.monotonic_ns() - published["heartbeat_mono_ns"], 1_000_000_000)
self.assertTrue(published["physics_pause_acknowledged"])
finally:
self.server.cpu_spi_control = None
def test_repeated_pause_resume_does_not_release_or_restart(self):
self.request("switch.set", address="9-SW-1", active=True)
self.pause()
contacts = dict(self.model._physical_switch_state)
self.pause()
self.assertEqual(self.model._physical_switch_state, contacts)
self.assertEqual(self.request("playfield.resume")["status"], "resume.pending")
self.assertEqual(json.loads(self.reader.readline())["status"], "ready")
before = list(self.monitor.commands)
self.assertEqual(self.request("playfield.resume")["status"], "heartbeat")
self.assertEqual(self.monitor.commands, before)
self.assertEqual(self.model._physical_switch_state, contacts)
def test_failed_monitor_stop_cannot_certify_pause(self):
self.monitor.refuse_stop = True
reply = self.request("playfield.pause")
self.assertEqual(reply["status"], "pause.failed")
self.assertFalse(reply["physics_pause_acknowledged"])
self.assertFalse(reply["qemu_monitor"]["ok"])
self.assertEqual(self.monitor.state, "running")
def test_monitor_timeout_does_not_count_as_ack(self):
self.monitor.silent = True
ok, _ = self.server._qemu_monitor_command("stop", verify_state=True)
self.assertFalse(ok)
self.assertFalse(self.model.qemu_monitor_status["ok"])
self.monitor.silent = False
def test_resume_failure_stays_pending_and_is_bounded(self):
self.pause()
class CPU:
def publish_state(self): return True, "queued"
def wait_until_resume_state_sent(self, timeout):
self.timeout = timeout
return False, "undelivered"
cpu = CPU()
self.server.cpu_spi_control = cpu
try:
self.assertEqual(self.request("playfield.resume")["status"], "resume.pending")
failed = json.loads(self.reader.readline())
self.assertEqual(failed["status"], "sync.wait")
self.assertTrue(failed["physics_resume_pending"])
self.assertLessEqual(cpu.timeout, .5)
self.assertEqual(self.monitor.state, "paused")
finally:
self.server.cpu_spi_control = None
def test_reconnect_with_paused_intent_never_continues_qemu(self):
self.pause()
self.reader.close()
self.client.close()
deadline = time.monotonic() + 1
while self.model.playfield_connected and time.monotonic() < deadline:
time.sleep(.005)
self.assertFalse(self.model.physics_pause_acknowledged)
before = self.monitor.commands.count("cont")
self.client = socket.create_connection((self.server.host, self.server.bound_port), timeout=1)
self.reader = self.client.makefile("rb")
self.send_playfield(self.client, {"type": "hello", "physics_paused": True})
self.assertEqual(json.loads(self.reader.readline())["type"], "snapshot")
self.assertEqual(json.loads(self.reader.readline())["status"], "synchronizing")
self.request("release_transient")
self.request("switch.set", address="8-SW-5", active=True)
reply = self.request("heartbeat")
self.assertTrue(reply["physics_pause_acknowledged"])
self.assertTrue(reply["connected"])
self.assertEqual(self.monitor.commands.count("cont"), before)
class SupervisorTests(unittest.TestCase):
def test_only_certified_same_identity_pause_is_healthy(self):
source = (Path(__file__).resolve().parents[2] / "emulation/scripts/run-playable-macos.sh").read_text()
code = source.split("playfield_integrated() {\n python3 -c '\n", 1)[1].split("\n' \"$output_state_file\"", 1)[0]
document = {"bridge_session": "s", "bridge_generation": 3, "playfield_api": {
"protocol": "spike3-playfield/v1", "bridge_session": "s", "bridge_generation": 3,
"connected": True, "serial_connected": True, "physics_authority": "vpx",
"heartbeat_mono_ns": time.monotonic_ns(),
"physics_paused": True, "physics_pause_acknowledged": True,
"qemu_monitor": {"command": "stop", "ok": True}}}
def accepted(d, mode="certified-pause"):
with patch("builtins.open"), patch("json.load", return_value=d), patch("sys.argv", ["check", "state", mode, "s", "3"]):
try: exec(code, {})
except SystemExit as result: return result.code == 0
self.assertTrue(accepted(document))
self.assertFalse(accepted(document, "running"))
replacement = copy.deepcopy(document)
replacement["bridge_session"] = replacement["playfield_api"]["bridge_session"] = "replacement"
replacement["bridge_generation"] = replacement["playfield_api"]["bridge_generation"] = 4
self.assertFalse(accepted(replacement))
# No time-based exemption expires a healthy intentional pause after20s.
for key, value in [("connected", False), ("serial_connected", False),
("physics_pause_acknowledged", False), ("bridge_session", "other"),
("bridge_generation", 4), ("physics_resume_pending", True)]:
changed = copy.deepcopy(document)
changed["playfield_api"][key] = value
self.assertFalse(accepted(changed), key)
changed = copy.deepcopy(document)
changed["playfield_api"]["qemu_monitor"]["ok"] = False
self.assertFalse(accepted(changed))
for paused in (True, False):
stale = copy.deepcopy(document)
stale["playfield_api"].update(physics_paused=paused, heartbeat_mono_ns=time.monotonic_ns() - 6_000_000_000)
stale["playfield_api"]["qemu_monitor"] = {"command": "stop" if paused else "cont", "ok": True}
self.assertFalse(accepted(stale), f"stale paused={paused}")
stale["playfield_api"]["heartbeat_mono_ns"] = time.monotonic_ns()
self.assertTrue(accepted(stale), f"fresh paused={paused}")
class PublicationLockTests(unittest.TestCase):
def test_host_keyboard_ingestion_and_uart_poll_have_no_lock_cycle(self):
with tempfile.TemporaryDirectory() as directory:
event_file = Path(directory) / "events.jsonl"
output = Path(directory) / "output-state.json"
model = NetbridgeModel(image_crc=0, physics_authority="vpx", output_state_file=output)
# Match native startup: the event file begins after model creation.
model.event_file = event_file
event_file.write_text(json.dumps({"type": "switch", "name": "left_flipper",
"value": 1, "source": "qemu-keyboard"}) + "\n")
model.playfield_connected = True
state_owned = threading.Event()
host_attempted_state = threading.Event()
errors = []
real_lock = model._state_lock
class ObservedStateLock:
def __enter__(self):
if threading.current_thread().name == "test-host-event":
host_attempted_state.set()
real_lock.acquire()
return self
def __exit__(self, *args): real_lock.release()
model._state_lock = ObservedStateLock()
def uart_poll():
try:
with model._state_lock:
state_owned.set()
if not host_attempted_state.wait(1): raise AssertionError("host did not attempt model lock")
model.handle(0x25, 1, b"")
model.set_physics_pause(True, True)
except BaseException as exc: errors.append(exc)
def host_event():
try:
if not state_owned.wait(1): raise AssertionError("UART did not acquire model lock")
model._ingest_switch_events()
except BaseException as exc: errors.append(exc)
threads = [threading.Thread(target=uart_poll, daemon=True),
threading.Thread(target=host_event, name="test-host-event", daemon=True)]
for thread in threads: thread.start()
for thread in threads: thread.join(1)
self.assertFalse(any(thread.is_alive() for thread in threads), "host switch-sync/model lock cycle")
self.assertFalse(errors, errors)
self.assertEqual(model._event_offset, event_file.stat().st_size)
self.assertTrue(json.loads(output.read_text())["playfield_api"]["physics_pause_acknowledged"])
def test_cpu_snapshot_and_pause_publication_have_no_lock_cycle(self):
# The native failure requires a real output path; path=None bypasses
# publication and cannot exercise output-lock -> model-lock inversion.
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "output-state.json"
model = NetbridgeModel(image_crc=0, physics_authority="vpx", output_state_file=output)
state_owned = threading.Event()
output_owned = threading.Event()
errors = []
def publish_pause():
try:
with model._state_lock:
state_owned.set()
if not output_owned.wait(1): raise AssertionError("CPU publisher did not start")
model.set_physics_pause(True, True)
except BaseException as exc:
errors.append(exc)
def publish_cpu():
try:
if not state_owned.wait(1): raise AssertionError("pause publisher did not start")
# This is the observer's normal lock acquisition, held
# across its real nested publication to fix the interleave.
with model._output_state_lock:
output_owned.set()
model._cpu_spi_snapshot_changed({
"sequence": 10, "delivered_sequence": 9,
"state_hex": "ff", "guest_control": {"ok": True}})
except BaseException as exc:
errors.append(exc)
threads = [threading.Thread(target=fn, daemon=True) for fn in (publish_pause, publish_cpu)]
for thread in threads: thread.start()
for thread in threads: thread.join(1)
self.assertFalse(any(thread.is_alive() for thread in threads), "CPU observer/output/model publication lock cycle")
self.assertFalse(errors, errors)
state = json.loads(output.read_text())
self.assertTrue(state["playfield_api"]["physics_pause_acknowledged"])
self.assertEqual(state["cpu_spi"]["sequence"], 10)
self.assertEqual(state["cpu_spi"]["pending"], 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,143 @@
import json
from pathlib import Path
import unittest
from topper_protocol import TopperProtocolDecoder, load_topology, replay_fixture
class TopperProtocolTests(unittest.TestCase):
def setUp(self):
self.decoder = TopperProtocolDecoder(load_topology(), diagnostic_limit=3)
def test_initial_state_and_snapshot_isolation(self):
snapshot = self.decoder.snapshot()
self.assertEqual(len(snapshot['pixels']), 74)
self.assertEqual([len(v) for v in snapshot['pixel_groups'].values()], [8,24,22,20])
self.assertEqual([v['name'] for v in snapshot['letters']], list('MAGIKARP'))
self.assertTrue(all(v['mapped'] and not v['lit'] for v in snapshot['letters']))
snapshot['pixels'][0]['rgb'][0] = 100
self.assertEqual(self.decoder.snapshot()['pixels'][0]['rgb'], [0,0,0])
def test_real_initialization_masks_cover_exact_222_channels(self):
for command in ('a4c00b00', 'b024c00b00', '9044c003ff3f'):
self.assertTrue(self.decoder.apply(12, bytes.fromhex(command)))
pixels = self.decoder.snapshot()['pixels']
self.assertEqual([p['sequence'] for p in pixels], [1]*32 + [2]*32 + [3]*10)
self.assertTrue(all(p['rgb'] == [0,0,0] for p in pixels))
self.assertTrue(all(p['transition_raw'] == [0,0,0] for p in pixels[:74]))
def test_invalid_packets_are_atomic_and_diagnostics_bounded(self):
self.decoder.apply(12, bytes.fromhex('a4c00b00'))
before = self.decoder.snapshot()['pixels']
for command in ('', 'a4', 'a4c0', 'a4c00b', 'a4c00b0000', 'b044c00b00', 'ff'):
self.assertFalse(self.decoder.apply(12, bytes.fromhex(command)), command)
self.assertEqual(self.decoder.snapshot()['pixels'], before)
self.assertEqual(len(self.decoder.snapshot()['diagnostics']), 3)
self.assertGreater(self.decoder.snapshot()['counters']['malformed'], 0)
def test_buffer_types_and_type_errors(self):
for cls in (bytes, bytearray, memoryview):
self.assertTrue(self.decoder.apply(12, cls(bytes.fromhex('a4c00b00'))))
before = self.decoder.snapshot()
with self.assertRaises(TypeError):
self.decoder.apply(12, 'a4c00b00')
self.assertEqual(self.decoder.snapshot(), before)
def test_diagnostic_snapshot_cannot_mutate_decoder(self):
self.decoder.apply(13, b'\xff')
snapshot = self.decoder.snapshot()
snapshot['diagnostics'][0]['reason'] = 'tampered'
self.assertNotEqual(self.decoder.snapshot()['diagnostics'][0]['reason'], 'tampered')
def test_unknown_mechanism_retains_raw_without_inventing_name(self):
self.assertFalse(self.decoder.apply(13, bytes.fromhex('40076000086000a00000')))
snapshot = self.decoder.snapshot()
self.assertEqual(snapshot['diagnostics'][-1]['command_hex'], '40076000086000a00000')
self.assertTrue(all(not v['mapped'] and v['position'] is None
for v in snapshot['mechanisms'].values()))
def test_real_per_component_bank_two_values_are_grb(self):
command = '9244c003ff3f441f4f773688b754d3713482441f4fda64fa311638da64fa3116380d060f'
self.assertTrue(self.decoder.apply(12, bytes.fromhex(command)))
pixels = self.decoder.snapshot()['pixels']
self.assertEqual(pixels[64]['raw_grb'], [0x44,0x1f,0x4f])
self.assertEqual(pixels[64]['rgb'], [0x1f,0x44,0x4f])
self.assertEqual(pixels[73]['rgb'], [6,13,15])
self.assertEqual(len(pixels), 74)
rebuilt = bytes.fromhex('9244c003ff3f') + bytes(
component for pixel in pixels[64:74] for component in pixel['raw_grb'])
self.assertEqual(rebuilt.hex(), command)
def test_real_index_list_with_shared_value_and_transition(self):
self.assertTrue(self.decoder.apply(12, bytes.fromhex('b70f10913f28')))
pixel = self.decoder.snapshot()['pixels'][5]
self.assertEqual(pixel['rgb'], [63,63,63])
self.assertEqual(pixel['transition_raw'], [40,40,40])
def test_real_long_mask_with_two_transition_values(self):
self.assertTrue(self.decoder.apply(12, bytes.fromhex('ba813bf038e0383f3f3fffffff2802')))
pixels = self.decoder.snapshot()['pixels']
self.assertEqual(pixels[9]['rgb'], [63,63,63])
self.assertEqual(pixels[9]['transition_raw'], [40,40,40])
self.assertEqual(pixels[31]['rgb'], [255,255,255])
self.assertEqual(pixels[31]['transition_raw'], [2,2,2])
def test_real_long_mask_omits_all_explicit_upper_presence_byte(self):
# September 4 live trace: bank1, 12 mask bytes, upper presence=0xff
# is omitted, so opcode bit5 is clear despite the long mask range.
command = '9224c70bc07ffc81e3c77ffcc07ffcbc56d9200e24a049b7632d72220f270d060f2a1331482153120815120815d361f21a0c1e632d72220f27220f27813b94482153120815321739a049b70d060f'
self.assertTrue(self.decoder.apply(12, bytes.fromhex(command)))
self.assertEqual(self.decoder.snapshot()['pixels'][34]['rgb'], [86,188,217])
def test_real_long_mask_omitted_upper_presence_with_transition_exceptions(self):
command = '9e870bc001fcfe03c001fcc0ff03c081e3f80f3f3f3f3f3f3fffffff3f3f3f3f3f3fffffff3f3f3fffffff3f3f3f3f3f3fffffffffffff3f3f3f0c020202020202020202282828020202020202'
self.assertTrue(self.decoder.apply(12, bytes.fromhex(command)))
self.assertEqual(self.decoder.snapshot()['counters']['malformed'], 0)
def test_recovered_live_replay_is_deterministic_with_explicit_unknown_allowlist(self):
path = Path(__file__).with_name('fixtures') / 'topper' / 'recovered-post-award.jsonl'
snapshots = []
for _ in range(2):
decoder = TopperProtocolDecoder(load_topology())
replay_fixture(path, decoder)
snapshots.append(decoder.snapshot())
self.assertEqual(snapshots[0], snapshots[1])
self.assertEqual(snapshots[0]['counters'], dict(accepted=30, unknown=5, malformed=0))
self.assertEqual({d['command_hex'] for d in snapshots[0]['diagnostics']},
{'14de000000', '1400000000', '4264040000ff0005'})
self.assertEqual(snapshots[0]['pixels'][64]['rgb'], [81,177,203])
letter = snapshots[0]['letters'][0]
self.assertEqual(letter['pixel_indices'], [0])
self.assertTrue(letter['mapped'])
self.assertTrue(letter['lit'])
self.assertEqual(letter['rgb'], [255,255,255])
self.assertEqual(letter['evidence'], 'v83-native-letter-group-0x25b')
self.assertTrue(letter['live_validated'])
self.assertTrue(all(not item['live_validated']
for item in snapshots[0]['letters'][1:]))
def test_controlled_first_catch_packet_lights_only_live_validated_m(self):
path = Path(__file__).with_name('fixtures') / 'topper' / 'controlled-first-catch.jsonl'
self.assertTrue(path.exists(), 'retain the native first-catch award packet')
replay_fixture(path, self.decoder)
snapshot = self.decoder.snapshot()
self.assertEqual(snapshot['counters'], dict(accepted=1, unknown=0, malformed=0))
self.assertEqual([item['rgb'] for item in snapshot['letters']],
[[63,63,63]] + [[0,0,0]] * 7)
self.assertEqual([item['live_validated'] for item in snapshot['letters']],
[True] + [False] * 7)
self.assertEqual(snapshot['letters'][0]['live_evidence'],
'2026-09-05-main-live-acceptance:first-catch-M')
def test_real_packed_prefix_truncation_preserves_valid_state(self):
self.decoder.apply(12, bytes.fromhex('b70f10913f28'))
before = self.decoder.snapshot()['pixels']
for hex_value in ('b70f10913f28', 'ba813bf038e0383f3f3fffffff2802',
'9244c003ff3f441f4f773688b754d3713482441f4fda64fa311638da64fa3116380d060f'):
packet = bytes.fromhex(hex_value)
for length in range(len(packet)):
self.assertFalse(self.decoder.apply(12, packet[:length]), (hex_value,length))
self.assertEqual(self.decoder.snapshot()['pixels'], before)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
import json
import sys
import unittest
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT / "tools"))
from extract_topper_topology import EXPECTED_GAME_SHA256, validate_topper_topology
class TopperTopologyTests(unittest.TestCase):
def test_checked_in_fixture_freezes_the_92_pixel_topology(self):
fixture = ROOT / "fixtures/topper/topology-v83.json"
topology = json.loads(fixture.read_text(encoding="utf-8"))
validate_topper_topology(topology)
self.assertEqual(topology["source_sha256"], EXPECTED_GAME_SHA256)
self.assertEqual(len(topology["pixels"]), 92)
self.assertEqual(
Counter(pixel["group"] for pixel in topology["pixels"]),
{"left_dome": 12, "right_dome": 12, "center": 34, "front": 34},
)
self.assertEqual(topology["pixels"][0]["channels"], {"g": 0, "r": 1, "b": 2})
self.assertEqual(
topology["pixels"][-1]["channels"], {"g": 273, "r": 274, "b": 275}
)
self.assertEqual(
sorted(
channel
for pixel in topology["pixels"]
for channel in pixel["channels"].values()
),
list(range(276)),
)
self.assertEqual(topology["letters"], {})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,22 @@
import unittest
from tools.extract_topper_trace import iter_node_writes
class TraceTests(unittest.TestCase):
def test_selects_only_topper_and_inclusive_window(self):
lines = ['node-write address=12 args=abcd\n',
'node-write address=8 args=4000\n',
'node-write address=13 args=4001\n',
'node-write address=12 args=abcd\n']
self.assertEqual(list(iter_node_writes(lines, first_line=2, last_line=3)),
[{'sequence': 1, 'node': 13, 'command_hex': '4001'}])
def test_bad_hex_rejected(self):
for value in ('abc', 'xy'):
with self.assertRaises(ValueError):
list(iter_node_writes([f'node-write address=12 args={value}'],
first_line=1, last_line=1))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,34 @@
// Read-only serializer evidence extraction. Run with -noanalysis -readOnly.
// @category Spike3
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.DecompInterface;
import ghidra.program.model.listing.Function;
public class DecompileTopper extends GhidraScript {
public void run() throws Exception {
DecompInterface decompiler = new DecompInterface();
decompiler.openProgram(currentProgram);
for (String argument : getScriptArgs()) {
Function function = getFunctionContaining(toAddr(argument));
println("FUNCTION " + argument + " " + function);
if (function == null) {
for (var reference : getReferencesTo(toAddr(argument))) {
println("REFERENCE " + reference.getFromAddress());
Function owner = getFunctionContaining(reference.getFromAddress());
if (owner != null) {
var result = decompiler.decompileFunction(owner, 120, monitor);
if (result.decompileCompleted()) println(result.getDecompiledFunction().getC());
}
}
}
if (function != null) {
for (Function caller : function.getCallingFunctions(monitor)) {
println("CALLER " + caller.getEntryPoint() + " " + caller.getName());
}
var result = decompiler.decompileFunction(function, 120, monitor);
if (!result.decompileCompleted()) throw new Exception(result.getErrorMessage());
println(result.getDecompiledFunction().getC());
}
}
decompiler.dispose();
}
}
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Extract actual Magikarp board-9 topology, distinct from board-4 ETOPPER."""
import argparse
from collections import Counter
import hashlib
import json
from pathlib import Path
import struct
try:
from .extract_topper_topology import EXPECTED_GAME_SHA256
except ImportError:
from extract_topper_topology import EXPECTED_GAME_SHA256
DESCRIPTOR_BASE = 0x3227F40
MAGIKARP_OFFSET = 0x323D890
GROUP_COUNTS = {'letters': 8, 'back': 24, 'middle': 22, 'front': 20}
def extract_magikarp_topology(game):
data = Path(game).read_bytes()
digest = hashlib.sha256(data).hexdigest()
if digest != EXPECTED_GAME_SHA256:
raise ValueError('unexpected game SHA-256')
def number(offset, fmt='<Q'):
return struct.unpack_from(fmt, data, offset)[0]
def string(offset):
if not 0 <= offset < len(data):
raise ValueError('string pointer outside binary')
end = data.find(b'\0', offset)
if end < 0:
raise ValueError('unterminated descriptor')
return data[offset:end].decode('ascii')
pixels = {}
descriptor_channels = {}
for offset in range(MAGIKARP_OFFSET, MAGIKARP_OFFSET + 222 * 80, 80):
name = string(number(offset))
board = number(offset + 0x20, '<H')
channel = number(offset + 0x22, '<H')
if board != 9 or not 0 <= channel < 222:
raise ValueError('not a Magikarp board-9 component')
component = name[-1].lower()
if component not in 'rgb' or not name.endswith('-' + component.upper()):
raise ValueError('unexpected component suffix')
if 'MAGIKARP' in name.replace('(', '').replace(')', ''):
group = 'letters'
elif name.startswith('TOPPER BACK '):
group = 'back'
elif name.startswith('TOPPER MID '):
group = 'middle'
elif name.startswith('TOPPER FRONT '):
group = 'front'
else:
raise ValueError('unexpected Magikarp descriptor name')
pixel = pixels.setdefault(channel // 3, dict(index=channel // 3, group=group,
channels={}, descriptors={}))
if pixel['group'] != group or component in pixel['channels']:
raise ValueError('inconsistent RGB descriptor triple')
pixel['channels'][component] = channel
pixel['descriptors'][component] = name
descriptor_channels[(offset-DESCRIPTOR_BASE)//80] = channel
ordered = []
group_indices = Counter()
for index in sorted(pixels):
pixel = pixels[index]
pixel['channels'] = {c: pixel['channels'][c] for c in 'grb'}
pixel['descriptors'] = {c: pixel['descriptors'][c] for c in 'grb'}
pixel['group_index'] = group_indices[pixel['group']]
group_indices[pixel['group']] += 1
ordered.append(pixel)
# Follow the actual native MAGIKARP-count animation's group lookup.
group_table = number(0x3193EE0)
group_pointer = number(group_table + 0x25B * 8)
virtual_table = number(0x3191DE0)
lamp_table = number(number(0x3195A30))
letters = []
for index, name in enumerate('MAGIKARP'):
virtual_id = number(group_pointer + index * 2, '<H')
virtual_row = virtual_table + virtual_id * 16
if number(virtual_row+8, '<B') != 2:
raise ValueError('letter is not a single RGB virtual lamp')
component_pointer = number(virtual_row)
channels = []
descriptor_indices = []
for component in range(3):
lamp_id = number(component_pointer + component * 2, '<H')
descriptor_id = number(lamp_table + lamp_id * 40 + 0x1A, '<H')
channels.append(descriptor_channels[descriptor_id])
descriptor_indices.append(descriptor_id)
pixel_index = channels[0] // 3
if set(channels) != set(range(pixel_index*3, pixel_index*3+3)):
raise ValueError('letter does not resolve to one physical RGB pixel')
descriptor = ordered[pixel_index]['descriptors']['r']
if descriptor != 'MAGIKARP'[:index] + '(' + name + ')' + 'MAGIKARP'[index+1:] + '-R':
raise ValueError('letter group disagrees with physical descriptor')
letters.append(dict(name=name, ordinal=index, pixel_indices=[pixel_index],
descriptor_indices=descriptor_indices,
virtual_lamp=virtual_id, evidence='v83-native-letter-group-0x25b',
live_validated=False))
if number(group_pointer + 16, '<H') != 0:
raise ValueError('letter group must have exactly eight members')
result = dict(source_sha256=digest, logical_board=9, node_address=12,
descriptor_table=dict(offset=MAGIKARP_OFFSET, record_size=80, count=222),
component_order=list('grb'), pixels=ordered, letters=letters)
validate_magikarp_topology(result)
return result
def validate_magikarp_topology(document):
if document.get('source_sha256') != EXPECTED_GAME_SHA256:
raise ValueError('unexpected topology source hash')
if document.get('logical_board') != 9 or document.get('node_address') != 12:
raise ValueError('topology must describe Magikarp logical board9/node12')
pixels = document.get('pixels', [])
if len(pixels) != 74 or Counter(p['group'] for p in pixels) != GROUP_COUNTS:
raise ValueError('incorrect Magikarp pixel groups')
for index, pixel in enumerate(pixels):
if pixel['index'] != index or pixel['channels'] != dict(g=index*3, r=index*3+1, b=index*3+2):
raise ValueError('incorrect Magikarp physical component mapping')
for component, name in pixel['descriptors'].items():
if component not in 'grb' or not name.endswith('-' + component.upper()):
raise ValueError('invalid component descriptor')
letters = document.get('letters', [])
if [letter['name'] for letter in letters] != list('MAGIKARP'):
raise ValueError('incorrect letter group order')
if [letter['pixel_indices'] for letter in letters] != [[0],[1],[2],[3],[19],[18],[17],[16]]:
raise ValueError('incorrect native letter physical mapping')
if any(not letter.get('evidence') for letter in letters):
raise ValueError('letter mappings need evidence')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--game', required=True, type=Path)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
args.output.write_text(json.dumps(extract_magikarp_topology(args.game), indent=2) + '\n')
if __name__ == '__main__':
main()
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Extract the hash-gated Pokemon Pro Magikarp topper LED topology."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import struct
from collections import Counter
from pathlib import Path
EXPECTED_GAME_SHA256 = "169b19ea6ef89ba4be1a8bb2f868697da597389ab023d8ed81f149b6353b459f"
DESCRIPTOR_TABLE_OFFSET = 0x32331B0
RECORD_SIZE = 0x50
RECORD_COUNT = 276
COMPONENT_ORDER = ("g", "r", "b")
GROUPS = {
"L DOME": "left_dome",
"R DOME": "right_dome",
"CENTER": "center",
"FRONT": "front",
}
EXPECTED_GROUP_COUNTS = {"left_dome": 12, "right_dome": 12, "center": 34, "front": 34}
DESCRIPTOR_RE = re.compile(r"^ETOPPER (L DOME|R DOME|CENTER|FRONT) LED ([1-9][0-9]*)-([GRB])$")
def _read_c_string(data: bytes, pointer: int) -> str:
if pointer >= len(data):
raise ValueError(f"descriptor pointer {pointer:#x} is outside the game image")
end = data.find(b"\0", pointer)
if end == -1:
raise ValueError(f"descriptor string at {pointer:#x} is unterminated")
try:
return data[pointer:end].decode("ascii")
except UnicodeDecodeError as error:
raise ValueError(f"descriptor string at {pointer:#x} is not ASCII") from error
def _descriptor_parts(descriptor: str) -> tuple[str, int, str]:
match = DESCRIPTOR_RE.fullmatch(descriptor)
if match is None:
raise ValueError(f"unexpected topper descriptor: {descriptor!r}")
group_source, group_index, component = match.groups()
return GROUPS[group_source], int(group_index), component.lower()
def extract_topper_topology(game: Path) -> dict[str, object]:
"""Return the deterministic 92-pixel topology derived from *game*."""
data = game.read_bytes()
source_sha256 = hashlib.sha256(data).hexdigest()
if source_sha256 != EXPECTED_GAME_SHA256:
raise ValueError(
f"unexpected game SHA-256: {source_sha256}; expected {EXPECTED_GAME_SHA256}"
)
table_end = DESCRIPTOR_TABLE_OFFSET + RECORD_COUNT * RECORD_SIZE
if table_end > len(data):
raise ValueError("topper descriptor table is outside the game image")
pixels: dict[tuple[str, int], dict[str, object]] = {}
seen_channels: set[int] = set()
for record_number in range(RECORD_COUNT):
offset = DESCRIPTOR_TABLE_OFFSET + record_number * RECORD_SIZE
descriptor = _read_c_string(data, struct.unpack_from("<Q", data, offset)[0])
group, group_index, component = _descriptor_parts(descriptor)
channel = struct.unpack_from("<H", data, offset + 0x22)[0]
x, y, width, height = struct.unpack_from("<4H", data, offset + 0x2C)
if channel in seen_channels:
raise ValueError(f"duplicate logical component channel {channel}")
seen_channels.add(channel)
key = (group, group_index)
pixel = pixels.setdefault(
key,
{"channels": {}, "descriptors": {}, "x": x, "y": y, "width": width, "height": height},
)
channels = pixel["channels"]
descriptors = pixel["descriptors"]
assert isinstance(channels, dict) and isinstance(descriptors, dict)
if component in channels:
raise ValueError(f"duplicate {component} descriptor for {group} LED {group_index}")
channels[component] = channel
descriptors[component] = descriptor
if seen_channels != set(range(RECORD_COUNT)):
raise ValueError("topper logical component channels are missing or out of range")
ordered_pixels = []
group_order = tuple(EXPECTED_GROUP_COUNTS)
for group in group_order:
group_pixels = sorted(
(item for (item_group, _), item in pixels.items() if item_group == group),
key=lambda item: min(item["channels"].values()),
)
if len(group_pixels) != EXPECTED_GROUP_COUNTS[group]:
raise ValueError(f"unexpected {group} pixel count: {len(group_pixels)}")
for group_index, pixel in enumerate(group_pixels):
channels = pixel["channels"]
descriptors = pixel["descriptors"]
assert isinstance(channels, dict) and isinstance(descriptors, dict)
if set(channels) != set(COMPONENT_ORDER):
raise ValueError(f"missing RGB component in {group} pixel {group_index}")
channel_values = [channels[component] for component in COMPONENT_ORDER]
if channel_values != list(range(channel_values[0], channel_values[0] + 3)):
raise ValueError(f"non-contiguous RGB channel triple in {group} pixel {group_index}")
ordered_pixels.append(
{
"index": len(ordered_pixels),
"group": group,
"group_index": group_index,
"channels": {component: channels[component] for component in COMPONENT_ORDER},
"descriptors": {component: descriptors[component] for component in COMPONENT_ORDER},
"x": pixel["x"],
"y": pixel["y"],
}
)
document: dict[str, object] = {
"source_sha256": source_sha256,
"descriptor_table": {"offset": DESCRIPTOR_TABLE_OFFSET, "record_size": RECORD_SIZE},
"component_order": list(COMPONENT_ORDER),
"pixels": ordered_pixels,
"letters": {},
}
validate_topper_topology(document)
return document
def validate_topper_topology(document: dict[str, object]) -> None:
"""Reject topology documents that cease to be the known derived evidence."""
if document.get("source_sha256") != EXPECTED_GAME_SHA256:
raise ValueError("topology source hash does not match the approved game binary")
if document.get("descriptor_table") != {
"offset": DESCRIPTOR_TABLE_OFFSET,
"record_size": RECORD_SIZE,
}:
raise ValueError("topology descriptor table metadata is unexpected")
if document.get("component_order") != list(COMPONENT_ORDER):
raise ValueError("topology component order is unexpected")
if document.get("letters") != {}:
raise ValueError("topology letters must remain evidence-only and unmapped")
pixels = document.get("pixels")
if not isinstance(pixels, list) or len(pixels) != 92:
raise ValueError("topology must contain exactly 92 pixels")
if Counter(pixel.get("group") for pixel in pixels if isinstance(pixel, dict)) != EXPECTED_GROUP_COUNTS:
raise ValueError("topology group counts are unexpected")
all_channels: list[int] = []
for index, pixel in enumerate(pixels):
if not isinstance(pixel, dict) or pixel.get("index") != index:
raise ValueError("topology pixel indexes are not deterministic")
group = pixel.get("group")
if group not in EXPECTED_GROUP_COUNTS:
raise ValueError("topology contains an unknown pixel group")
channels = pixel.get("channels")
descriptors = pixel.get("descriptors")
if not isinstance(channels, dict) or not isinstance(descriptors, dict):
raise ValueError("topology pixel lacks channels or descriptors")
if list(channels) != list(COMPONENT_ORDER) or list(descriptors) != list(COMPONENT_ORDER):
raise ValueError("topology RGB components are unordered or incomplete")
values = [channels[component] for component in COMPONENT_ORDER]
if not all(isinstance(value, int) for value in values):
raise ValueError("topology channels must be integers")
if values != list(range(values[0], values[0] + 3)):
raise ValueError("topology RGB channels must be contiguous")
for component in COMPONENT_ORDER:
descriptor_group, descriptor_index, descriptor_component = _descriptor_parts(descriptors[component])
if descriptor_group != group or descriptor_index != pixel.get("group_index") + 1 or descriptor_component != component:
raise ValueError("topology descriptor does not match pixel metadata")
all_channels.extend(values)
if sorted(all_channels) != list(range(RECORD_COUNT)):
raise ValueError("topology channels are missing, duplicated, or out of range")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--game", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
topology = extract_topper_topology(args.game)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(topology, indent=2) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Extract bounded decrypted topper writes; never retain secure wrappers."""
import argparse
import json
import re
from pathlib import Path
from tempfile import NamedTemporaryFile
def iter_node_writes(lines, *, first_line, last_line):
if first_line < 1 or last_line < first_line:
raise ValueError('invalid inclusive line bounds')
sequence = 0
for number, line in enumerate(lines, 1):
if number > last_line:
break
if number < first_line:
continue
match = re.fullmatch(r'node-write address=(12|13) args=(\S+)\s*', line)
if not match:
continue
node, command = match.groups()
if not re.fullmatch(r'(?:[0-9a-fA-F]{2})+', command):
raise ValueError(f'invalid command hex at line {number}')
sequence += 1
yield dict(sequence=sequence, node=int(node), command_hex=command.lower())
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--log', type=Path, required=True)
parser.add_argument('--first-line', type=int, required=True)
parser.add_argument('--last-line', type=int, required=True)
parser.add_argument('--source', required=True)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
with args.log.open() as stream:
records = list(iter_node_writes(stream, first_line=args.first_line,
last_line=args.last_line))
if not records:
parser.error('capture interval contains no topper writes')
args.output.parent.mkdir(parents=True, exist_ok=True)
with NamedTemporaryFile(mode='w', dir=args.output.parent, delete=False) as stream:
temporary = Path(stream.name)
for record in records:
record['source'] = args.source
stream.write(json.dumps(record, separators=(',', ':')) + '\n')
temporary.replace(args.output)
print(f'Extracted {len(records)} topper writes')
if __name__ == '__main__':
main()
@@ -0,0 +1,220 @@
"""Deterministic inverse of Pokemon v0.83 packed topper LED serialization.
See evidence/topper-protocol-v83.md. Targets are commanded values, not
wall-clock interpolated output. Unproven semantic mappings stay unmapped.
"""
from collections import deque
from copy import deepcopy
import json
from pathlib import Path
from tools.extract_magikarp_topology import validate_magikarp_topology, GROUP_COUNTS
def load_topology(path=None):
path = path or Path(__file__).with_name('fixtures') / 'topper' / 'magikarp-v83.json'
document = json.loads(Path(path).read_text())
validate_magikarp_topology(document)
return document
class _Packet:
def __init__(self, data):
self.data, self.position = data, 0
def byte(self):
if self.position >= len(self.data):
raise ValueError('truncated packet')
value = self.data[self.position]
self.position += 1
return value
def _decode_rgb(command, *, extended, max_channel=221):
packet = _Packet(command)
opcode = packet.byte()
values_mode = opcode & 3
index_mode = bool(opcode & 0x20)
flags = packet.byte() if extended else opcode & 0x1c
bank = flags >> 5 if extended else 0
time_mode = flags & 0x1c
if extended and (flags & 3 or bank not in (1, 2)):
raise ValueError('invalid bank/transition flags')
if time_mode not in (0, 4, 8, 12, 20, 24, 28):
raise ValueError('unsupported transition mode')
first = packet.byte()
if first & 0x80:
bounds = packet.byte()
start, end = bounds >> 4, bounds & 15
if start > end or end > 11:
raise ValueError('invalid component mask range')
masks = []
fill = 255 if first & 0x40 else 0
if end - start >= 8:
if first & 0x30:
raise ValueError('invalid long component mask')
# Bit5 means the upper presence byte is transmitted, not that
# the range is long. The analyzer omits an all-explicit 0xff.
present = (first & 15) | (packet.byte() if index_mode else 255) << 4
# Analyzer initializes the upper presence byte to 0xff and
# clears only visited default masks; unused high bits stay one.
unused = 12 - (end - start + 1)
if present >> (end - start + 1) != (1 << unused) - 1:
raise ValueError('noncanonical unused mask presence bits')
masks = [packet.byte() if present & (1 << bit) else fill
for bit in range(end - start + 1)]
else:
if index_mode:
raise ValueError('short component mask has extra presence flag')
masks.append(packet.byte())
for bit in range(max(0, end - start - 1)):
masks.append(packet.byte() if first & (1 << bit) else fill)
if end != start:
masks.append(packet.byte())
if first & 0x3f & ~((1 << max(0, end-start-1))-1):
raise ValueError('unused component mask presence bits')
indices = [byte_index * 8 + bit
for byte_index, mask in enumerate(masks, start)
for bit in range(8) if mask & (1 << bit)]
else:
indices = [first]
if index_mode:
while True:
index = packet.byte()
indices.append(index & 0x7f)
if index & 0x80:
break
if not indices or indices != sorted(set(indices)) or indices[-1] > 95:
raise ValueError('invalid or unordered component indices')
channels = [bank * 96 + index for index in indices]
if channels[-1] > max_channel:
raise ValueError('component channel outside topology')
count = len(channels)
time_mask = 0
if time_mode & 8:
if not first & 0x80:
raise ValueError('transition masks require component masks')
for offset in range((count + 7) // 8):
time_mask |= packet.byte() << (offset * 8)
if time_mask >> count:
raise ValueError('transition mask exceeds component count')
if values_mode == 2:
values = [packet.byte() for _ in channels]
else:
value = packet.byte() if values_mode == 3 else (255 if values_mode else 0)
values = [value] * count
if time_mode == 0:
times = [packet.byte() for _ in channels]
else:
common = packet.byte() if time_mode & 16 else 0
if time_mode & 12 == 8:
alternate = packet.byte()
times = [alternate if time_mask & (1 << i) else common for i in range(count)]
elif time_mode & 12 == 12:
times = [packet.byte() if time_mask & (1 << i) else common for i in range(count)]
else:
times = [common] * count
if packet.position != len(command):
raise ValueError('trailing bytes')
return list(zip(channels, values, times))
def decode_packed_rgb(command, *, max_channel):
"""Decode one complete packed RGB command within a board's channel bounds."""
if not isinstance(command, (bytes, bytearray, memoryview)):
raise TypeError('command must be bytes, bytearray, or memoryview')
command = bytes(command)
if not command or not 0x80 <= command[0] <= 0xbf:
raise ValueError('unsupported packed RGB command')
candidates = []
reason = 'malformed packed RGB command'
for extended in ([False, True] if command[0] & 16 else [False]):
try:
updates = _decode_rgb(
command, extended=extended, max_channel=max_channel
)
if updates not in candidates:
candidates.append(updates)
except ValueError as error:
reason = str(error)
if len(candidates) == 1:
return candidates[0]
if len(candidates) > 1:
raise ValueError('ambiguous bank-zero versus extended encoding')
raise ValueError(reason)
class TopperProtocolDecoder:
def __init__(self, topology, *, diagnostic_limit=32):
validate_magikarp_topology(topology)
if not isinstance(diagnostic_limit, int) or diagnostic_limit < 1:
raise ValueError('diagnostic limit must be positive')
self._topology = deepcopy(topology)
self._sequence = 0
self._values = [0] * 222
self._times = [None] * 222
self._sequences = [None] * 222
self._diagnostics = deque(maxlen=diagnostic_limit)
self._counters = dict(accepted=0, unknown=0, malformed=0)
def apply(self, node_address, command):
if not isinstance(command, (bytes, bytearray, memoryview)):
raise TypeError('command must be bytes, bytearray, or memoryview')
command = bytes(command)
self._sequence += 1
reason = 'unknown node or command'
classification = 'unknown'
if node_address == 12 and command and 0x80 <= command[0] <= 0xbf:
classification = 'malformed'
try:
updates = decode_packed_rgb(command, max_channel=221)
for channel, value, transition in updates:
self._values[channel] = value
self._times[channel] = transition
self._sequences[channel] = self._sequence
self._counters['accepted'] += 1
return True
except ValueError as error:
reason = str(error)
elif not command:
classification, reason = 'malformed', 'empty command'
self._counters[classification] += 1
self._diagnostics.append(dict(sequence=self._sequence, node=node_address,
command_hex=command.hex(), reason=reason))
return False
def snapshot(self):
pixels = []
groups = {name: [] for name in GROUP_COUNTS}
for descriptor in self._topology['pixels']:
pixel = deepcopy(descriptor)
channels = [pixel['channels'][component] for component in ('g','r','b')]
raw = [self._values[channel] for channel in channels]
sequences = [self._sequences[channel] for channel in channels]
pixel.update(raw_grb=raw, rgb=[raw[1], raw[0], raw[2]],
transition_raw=[self._times[channel] for channel in channels],
transition_ticks=None, component_sequences=sequences,
sequence=max((s for s in sequences if s is not None), default=None))
pixels.append(pixel)
groups[pixel['group']].append(pixel['index'])
letters = []
for mapping in self._topology['letters']:
rgb = [max(pixels[index]['rgb'][component] for index in mapping['pixel_indices'])
for component in range(3)]
letters.append(dict(deepcopy(mapping), mapped=True, rgb=rgb,
level=max(rgb), lit=max(rgb) > 12))
mechanisms = {name: dict(mapped=False, position=None, target=None, evidence=None)
for name in ('head','tail','wave')}
return dict(pixels=pixels, pixel_groups=groups, letters=letters, mechanisms=mechanisms,
mapping_complete=False, sequence=self._sequence,
counters=dict(self._counters), diagnostics=deepcopy(list(self._diagnostics)))
def replay_fixture(path, decoder):
expected = 1
with Path(path).open() as stream:
for line in stream:
record = json.loads(line)
if record['sequence'] != expected:
raise ValueError('fixture sequence must be contiguous and start at one')
decoder.apply(record['node'], bytes.fromhex(record['command_hex']))
expected += 1
@@ -0,0 +1,520 @@
# Pokémon Pro VPX cabinet and SPIKE 3 emulator
For versioned ROM import, private LUKS key setup, game switching, and pristine image builds, see [the ROM library guide](roms/README.md). The repository-root layout below remains a legacy/bootstrap input. New image builds default to no game patches; legacy patches require `SPIKE3_PATCH_PROFILE=legacy-pokemon`.
This directory contains the emulator scaffold for an extracted SPIKE 3
Raspberry Pi root filesystem. It keeps the evidence rootfs untouched: all guest
edits, helper binaries, logs, and disk images are generated under
`emulation/work/`, which is excluded from Git.
The repository does not distribute the target rootfs, target binaries, or game
assets. Place an authorized extracted rootfs at the repository root before
starting the full Docker/QEMU emulator. The conagent backend can run by itself
without those files; see `analysis/conagent-machine-emulator/README.md`.
## Native playable cabinet
The maintained table is the manually edited Pokémon Pro table:
`pokemon-vpx/dist/Pokemon-Pro.vpx`.
There is no separate
manual or greybox table to build or maintain.
- Play: double-click `run-playable.command`.
- Edit: double-click `edit-table.command` (VPX Editor).
- Table geometry, controls, tools and acceptance records: [table guide](pokemon-vpx/README.md).
- ROM import and runtime upgrades: [ROM guide](roms/README.md).
- Audio output: `audio-settings.command`.
The older `run-playable-manual.command` is a compatibility alias for the same table.
Launchers never generate table geometry. Restore a missing table from version
control or backup. The `.vpx`, distribution `.vbs` and `script.vbs` must remain
synchronized after script changes; the launcher loads the sidecar.
VPX owns physics; the QEMU guest owns rules, scoring, display, audio, lamps and
mechanism commands. The native launcher checks the pinned host/plugin ABI and
supervises the bridge. Runtime copies are generated under `work/`.
Controls: Shift flippers; `1` Start; `5` coin; Return shooter launch; Left Alt
lockdown action; Z, / and Space nudges; 7/8/9/0 service back/down/up/enter.
### Folder guide
| Folder | Purpose |
| --- | --- |
| `pokemon-vpx/` | Canonical table, script, editing tools, models, textures and QA evidence |
| `vpx/` | Shared reference photos/manual and runtime layout manifest only |
| `vpx-plugin/`, `machine/`, `cpu-spi/`, `netbridge-emulator/` | Cabinet controller and hardware emulation |
| `scripts/`, `guest/`, `guest-tools/`, `stubs/` | Host launch and guest support |
| `roms/`, `library/` | Versioned ROM tooling and local runtime images |
| `work/`, `cache/` | Generated runtime state and caches |
| `archive/` | Verified recovery archive of retired table iterations |
See [cleanup record](archive/README.md) for the retained recovery archive and
preservation hashes. Historical QA and editable art remain with the maintained
table; they are evidence/source material, not alternative launch targets.
## Docker/browser quick start
For the verified software-rendered path, build the matching Mesa bundle once,
then launch:
```sh
emulation/scripts/build-buildroot-mesa.sh
SPIKE3_GRAPHICS_STACK=buildroot-software SPIKE3_ENABLE_LD_PRELOAD=1 \
docker compose -f emulation/compose.yaml up --build
```
This renders the target boot screen with real Mesa GBM/EGL/GLES. To run the
game with the Mac keyboard acting as a virtual cabinet, also set
`SPIKE3_AUTOSTART=game SPIKE3_NETBRIDGE_EMU=1`.
Open:
- Dashboard: <http://localhost:8090/>
- Display through noVNC: <http://localhost:6080/vnc.html?host=localhost&port=6080&autoconnect=true>
- Existing conagent backend emulator: <http://localhost:8088/>
The host conagent emulator is an optional diagnostic helper and is not part of
cabinet input. Native launch uses `SPIKE3_CONAGENT_BACKEND=auto`: if 8088 or
8345 is already occupied it prints a warning and continues without the helper.
Set it to `1` to require the helper (and fail on collision), or `0` to disable
it explicitly. Alternate ports use `SPIKE3_CONAGENT_API_PORT` and
`SPIKE3_CONAGENT_TIME_PORT`.
The default guest autostart is `boot-display`. After confirming that the
virtual DRM display appears in noVNC, restart with one of:
```sh
SPIKE3_AUTOSTART=spike-menu docker compose -f emulation/compose.yaml up
SPIKE3_AUTOSTART=game docker compose -f emulation/compose.yaml up
SPIKE3_AUTOSTART=game-headless docker compose -f emulation/compose.yaml up
SPIKE3_AUTOSTART=monitors docker compose -f emulation/compose.yaml up
```
## What It Builds
- A Debian-based Docker image with QEMU, noVNC, ffmpeg, qemu-user-static, and
cross-compilation tools.
- A Debian ARM64 kernel plus initramfs under `/opt/spike3-kernel/`; the
initramfs includes `virtio_gpu` and DRM modules for the QEMU display path.
- A generated ARM64 ext4 root image at `emulation/work/rootfs.ext4`.
- Guest helpers injected into the generated image under
`/usr/local/spike-emu/`.
- An optional AArch64 `LD_PRELOAD` stub at
`/usr/local/spike-emu/lib/libspike3emu_stub.so`.
- Overlay Mesa DRI fallback drivers under `/usr/local/spike-emu/mesa-dri`
inside the generated image, copied from the Docker image.
- Matching ARM64 Mesa/GLVND EGL runtime libraries under
`/usr/local/spike-emu/mesa-lib`, plus the Mesa EGL vendor descriptor.
- QEMU display exposed as VNC, then noVNC in the browser.
- A dashboard with the live QEMU display, a manual-derived clickable playfield,
keyboard map, switch events, and live coil/lamp-bus activity.
## Runtime Controls
Useful environment variables:
| Variable | Default | Purpose |
| --- | --- | --- |
| `SPIKE3_AUTOSTART` | `boot-display` | Guest target: `boot-display`, `spike-menu`, `game`, `game-headless`, `monitors`, or `shell`. |
| `SPIKE3_DISPLAY_WIDTH` | `1360` | QEMU virtio-gpu requested width. |
| `SPIKE3_DISPLAY_HEIGHT` | `768` | QEMU virtio-gpu requested height. |
| `SPIKE3_DISPLAY_BACKEND` | `auto` | Display backend: `auto`, `native`, or legacy `fbdev`. `auto` selects native when the GBM/EGL probe succeeds. |
| `SPIKE3_GRAPHICS_STACK` | `target` in Compose; `debian-llvmpipe` with `run-macos.sh` | Graphics userspace: original coherent target stack, version-matched `buildroot-software`, reproducibility-only `debian-legacy`, or Debian Mesa/LLVM `debian-llvmpipe`. |
| `SPIKE3_QEMU_MEM` | `4096` | Guest RAM in MB. |
| `SPIKE3_QEMU_SMP` | `8` on macOS, `4` in Docker | Guest CPU count. |
| `SPIKE3_VIDEO_THREADS` | `6` on macOS, `2` in Docker | FFmpeg H.264/HEVC decoder workers. The shim leaves two macOS vCPUs available for Godot, audio, and I/O. |
| `SPIKE3_VIDEO_BRIDGE` | `auto` on macOS | Build and use the macOS VideoToolbox bridge for hardware H.264/HEVC decode. Use `1` to require it or `0` to retain guest software decode. |
| `SPIKE3_MACOS_AUDIO` | `coreaudio` | Native launcher audio backend: `coreaudio` routes the guest HDA device to the Mac; `none` disables host playback. |
| `SPIKE3_ROOTFS_IMAGE_SIZE` | `6G` | Generated ext4 image size. |
| `SPIKE3_PREPARE_MODE` | `auto` | Rootfs preparation mode: `auto`, `always`, or `never`. |
| `SPIKE3_REBUILD_ROOTFS` | `0` | Set to `1` to force a rootfs rebuild even when the manifest matches. |
| `SPIKE3_BYPASS_COUNTRY_LOCK` | `1` | Skip the blocking country-warning renderer in the supplied Pokémon game binary. This is a local, signature-checked binary patch; it does not register the emulated cabinet or contact Stern services. Set to `0` to retain the factory behavior. |
| `SPIKE3_FORCE_FREE_PLAY` | `1` | Return enabled only for the game's `AD_FREE_PLAY` adjustment so an unprovisioned emulator does not require initialized coin pricing. Set to `0` to retain NVRAM pricing behavior. |
| `SPIKE3_KEEP_STAGE` | `0` | Set to `1` to retain the Linux-side staging tree (`SPIKE3_ROOTFS_STAGE`, default `/var/tmp/spike3-rootfs-stage`) for inspection inside the container. |
| `SPIKE3_SKIP_PREPARE` | unset | Compatibility alias for `SPIKE3_PREPARE_MODE=never`. |
| `SPIKE3_NO_QEMU` | unset | Set to `1` to run only dashboard/noVNC/backend helpers. |
| `SPIKE3_ENABLE_LD_PRELOAD` | `0` | Set to `1` to model missing cabinet hardware. Native mode keeps rendering real and only preserves extension pointers returned by `eglGetProcAddress`. |
| `SPIKE3_NETBRIDGE_EMU` | `0` | Set to `1` to start the Python netbridge emulator, attach it as a second QEMU PL011 serial port, and pass guest `/dev/ttyAMA5` through to it. |
| `SPIKE3_NETBRIDGE_SOCKET` | `/workspace/emulation/work/netbridge.sock` | Unix socket path used between QEMU and `analysis/netbridge-emulator/netbridge_emu.py` when `SPIKE3_NETBRIDGE_EMU=1`. |
| `SPIKE3_MAGIKARP_TOPPER` | `0` | Set to `1` with netbridge mode to advertise optional node `12` (lamps) and node `13` (head/tail servos), and expose their raw traffic in the dashboard. |
The dashboard accepts switch events through both `POST /api/event` and
`POST /api/switch/<name>`.
When netbridge mode is enabled, focus the dashboard page or the native QEMU
window and use:
| Mac key | Cabinet input |
| --- | --- |
| Left Shift / Right Shift | Left / right flipper |
| Enter | Start |
| Space | Action / launch |
| C | Left coin chute |
| 1 / 2 / 3 | Berry targets: left / center / right |
| 4 | Psyduck target |
| 5 / 6 / 7 | Lower-left bank: top / middle / bottom |
| 8 / 9 | Lower-right bank: top / bottom |
| X | Drain through the left outlane and return the ball to the trough |
| Q / W / R / T | Left orbit / left ramp / right ramp / right orbit |
| E / Y / U / I / O / P | BATTLE bank: B / A / T / T / L / E |
| A / S / D / F / G | Top pop / bottom pop / Town scoop (right eject) / spinner / Team Rocket |
The dashboard at <http://localhost:8090/> places a live QEMU screen above a
lower panel that defaults to the diagnostic **Debug map**. When the playable
launcher publishes a healthy independent VPX capture session, choose **Live
VPX** to view only the owned native player window. This stream is not QEMU VNC
and has no QEMU snapshot fallback; toggle back to Debug map or use its explicit
retry after a player restart. The live view keeps only cabinet controls active:
Left/Right Shift are flippers, Enter is Start, C is Coin, Space is Action, and L
is physical Launch. Leaving Live VPX, losing focus, cancelling a pointer, or
closing the page releases held controls. Debug-only shot keys remain available
only on the Debug map.
The schematic is derived from the Pokémon Pro switch, driver, and lamp locator
figures and the game's own monochrome playfield sketch. Blue switches can be
clicked directly, red driver markers flash when their exact coil output fires,
and every node 8/9 lamp channel drives its physical insert's live color and
intensity. Layer toggles keep the complete map readable. Mechanism sensors such
as the trough, flipper EOS, and Meowth travel limits are read-only; use **Reset
six-ball trough** if synthetic ball state needs recovery. Press and release
transitions are queued until the game reads the
corresponding node, so short taps are not lost between the emulated machine's
relatively slow polls.
The ball-device shim models the two essential held-state paths: an outlane hit
returns the in-play ball to the trough after a short roll delay, and the Town
scoop (`Right Eject`, `9-SW-1`) remains closed until the game fires its eject
coil (`9-DR-8`).
## macOS dashboard video
The standalone macOS dashboard can show continuous ScreenCaptureKit/H.264 or
view-only QEMU/noVNC video, with an explicit diagnostic Snapshot option. See the
[local browser streaming setup and verification guide](streaming/README.md).
This is separate from the native playable launcher described above; it does not
require preparing the guest image or change cabinet input routing.
## macOS hardware video decode
The native macOS launcher uses a narrow VideoToolbox bridge because stock QEMU
does not expose a macOS-backed `virtio-video` device. The existing FFmpeg shim
keeps the game's `h264_v4l2m2m` / `hevc_v4l2m2m` contract intact, but sends the
compressed packets over a dedicated `spike3.video` VirtIO-serial port. A small
host helper requires an Apple hardware decoder and returns NV12 frames to the
guest, where the existing DRM-prime compatibility path presents them to the
game.
`emulation/scripts/run-macos.sh` builds and starts the helper automatically.
If `SPIKE3_VIDEO_BRIDGE=auto` and the helper or named guest port is unavailable,
the shim logs the reason and retains the prior multithreaded guest decoder. Set
`SPIKE3_VIDEO_BRIDGE=1` when testing to make a missing host helper a launcher
error. Runtime evidence is written to:
- `emulation/work/logs/videotoolbox-bridge.log` on the host
- `/connectivity/dump/log/spike-emu/game.stdout.log` in the guest when
`SPIKE3_CAPTURE_TARGET_LOGS=1`
The host log reports `hardware=yes` only after VideoToolbox confirms that the
session is hardware accelerated. Protocol details and standalone H.264/HEVC
smoke-test instructions live in `emulation/video-bridge/README.md`.
The named controls use the physical addresses from the Pokémon Pro service
manual: flippers and most playfield targets are on node 8, Berry Right is on
node 9, cabinet Start/coin/action inputs are on node 1, and service inputs are
on local CPU node 0. Six trough switches begin active to represent the balls
installed in a real machine.
### Native Apple-silicon launch
Docker Desktop cannot expose Apple's Hypervisor Framework to the Linux
container, so its nested QEMU falls back to CPU-intensive TCG. After the Docker
workflow has prepared `emulation/work/rootfs.ext4` and built the local image,
run the guest directly on an Apple-silicon Mac instead:
```sh
docker compose -f emulation/compose.yaml down
emulation/scripts/run-macos.sh
```
The native launcher defaults to the already bundled Debian Mesa overlay with
multithreaded LLVM software rendering:
```sh
emulation/scripts/run-macos.sh
```
This does not add a host dependency and does not use the Apple GPU. It is a
faster CPU-rendered alternative to softpipe that spends additional guest CPU
cores on software rasterization. Set `SPIKE3_GRAPHICS_STACK=buildroot-software`
explicitly to restore the prior native-launcher default.
This uses the installed `qemu-system-aarch64` with `-accel hvf`, opens the game
in a native QEMU window, passes its keyboard events through to emulated SPIKE
switches, and also serves keyboard controls at <http://localhost:8090/>. The
script extracts the prepared kernel from the local Docker image on its first
run. The Cocoa window is the supported display path.
`SPIKE3_MACOS_DISPLAY=vnc` is useful for headless protocol diagnostics on local
VNC port 5900, but current host QEMU builds can reject page flips there.
## Base Reset
The extracted machine uses Buildroot 2024.08.1, glibc 2.39, Mesa 24.0.9, and a
Godot 4.4.1 custom build with a direct DRM/GBM/EGL display layer. The old
default mixed that target with Debian Bookworm Mesa 22.3 and then compensated
with increasingly broad EGL/GL interposition.
The reset has two independent probes:
1. `game-headless` runs Godot with `--headless --audio-driver Dummy` and tests
game initialization independently of graphics. Set `SPIKE3_ENABLE_LD_PRELOAD=1`
only when the native wrapper must pass its earlier hardware probes.
2. `buildroot-software` uses a complete Mesa 24.0.9 bundle built by the matching
Buildroot release with Gallium `swrast` and `virgl` enabled.
Build the coherent bundle once:
```sh
emulation/scripts/build-buildroot-mesa.sh
```
Generated libraries land under `emulation/work/buildroot-mesa/`, are excluded
from Git, and are included in the generated rootfs manifest.
## Netbridge Emulator Mode
Run the full game with the host-side netbridge emulator attached:
```sh
SPIKE3_NETBRIDGE_EMU=1 SPIKE3_GRAPHICS_STACK=buildroot-software \
SPIKE3_MAGIKARP_TOPPER=1 SPIKE3_ENABLE_LD_PRELOAD=1 SPIKE3_AUTOSTART=game \
docker compose -f emulation/compose.yaml up --build
```
On Apple silicon, the corresponding native launch is:
```sh
SPIKE3_MAGIKARP_TOPPER=1 emulation/scripts/run-macos.sh
```
What this does:
- Starts `analysis/netbridge-emulator/netbridge_emu.py --unix-socket /workspace/emulation/work/netbridge.sock --magikarp-topper` in the container when the topper option is enabled.
- Adds a QEMU PCI 16550 serial port backed by that socket.
- Adds `spike3.netbridge_serial=1` to the guest kernel command line.
- Symlinks guest `/dev/ttyAMA5` to the detected `/dev/ttyS0` UART.
- Sets `SPIKE3_REAL_NETBRIDGE_SERIAL=1` for target launches so the preload stub does not fake `/dev/ttyAMA5`.
Useful logs:
- Host netbridge emulator: `emulation/work/logs/netbridge-emulator.log`
- QEMU serial and OpenOCD messages: `emulation/work/logs/qemu-serial.log`
- Guest launch environment: `/connectivity/dump/log/spike-emu/launch-env.log`
- Preload serial-open behavior: `/connectivity/dump/log/spike-emu/ldpreload-stub.log`
### CPU-SPI service panel
Both the Docker and native launchers give the host netbridge the paired
`control.sock` guest-control channel and
`emulation/cpu-spi/pokemon-pro-service-panel.json`. The layout models these
four physical local-CPU service contacts:
| Service control | Physical contact | Active-low byte vector while pressed |
| --- | --- | --- |
| Enter / Select | `0-SW-8` | `fe ff ff ff ff ff ff ff` |
| Up | `0-SW-9` | `fd ff ff ff ff ff ff ff` |
| Down | `0-SW-10` | `fb ff ff ff ff ff ff ff` |
| Back | `0-SW-11` | `f7 ff ff ff ff ff ff ff` |
The idle vector is `ff 7f ff ff ff ff ff ff`: these contacts are active-low,
and the second-byte bit 7 default assertion remains present. Guest-control
agent or CPU-SPI shim changes require a new rootfs before live validation; the
launcher wiring alone does not update an existing image.
This order comes from the hardware descriptors in the current Pokemon 0.83
game: `SERVICE SELECT` is CPU bank 0 contact 8 and `SERVICE BACK` is contact
11. Commit `624bde3` recorded the reverse Enter/Back interpretation from an
older live build; it is retained as older-build evidence, not as the mapping
for the current binary.
After that one rootfs generation, runtime acceptance must observe the genuine
display entering the service menu with Enter, moving
`Volume 30 -> 31 -> 30`, and leaving with Back. Host netbridge logs by
themselves are not sufficient evidence.
## Logs and Artifacts
Host/container logs are written under `emulation/work/logs/`.
Rootfs cache state is written to:
- `emulation/work/rootfs.manifest`
- `emulation/work/rootfs-cache.status`
Rebuilds carry the existing disk's complete `/data/nv` store into the new image
and verify it before replacement. The source must be clean and provably offline;
the preparer never repairs it in place. Failed inspection, extraction, build, or
verification leaves the old image, checksum and manifest intact. A valid source
without `/data/nv` retains the workspace seeds; an existing store replaces the
entire seed subtree, including saves for other games and empty directories.
Verification compares file bytes/SHA256, type, mode, uid/gid and regular-file
size in the source, extracted/staged tree and new image. Symlinks, hardlinked
files, special objects, ACLs/xattrs, unsupported inode metadata or filesystem
features are refused. No native save formats or CRC sidecars are rewritten.
Successful rebuilds print a unique `rootfs.ext4.backup-*` directory containing
the previous image and any prior checksum/manifest. Keep these backups until
the updated image has passed your game acceptance checks. Caught promotion
errors roll back the old products. If storage also fails during rollback, the
error names the backup containing the remaining prior products; some products
may already have been restored at their original paths. Multi-file promotion
is not power-loss/SIGKILL atomic: after an interrupted promotion, inspect and
recover the complete prior product set from the backup before launching.
Existing-image rebuilds require native Linux storage and a trusted launcher
providing a read-only bind of the Linux host's `/proc` at
`/run/spike3-host-proc`, the host PID namespace, and root with
`CAP_SYS_PTRACE`/`CAP_SYS_ADMIN`. Missing or restricted inspection is refused.
The helper checks host writable opens and loop backing files, holds a whole-file
record lock throughout the transaction, rechecks source identity/content and
writers before promotion, and locks the replacement through promotion/rollback.
This assumes ordinary Linux QEMU image locking and a trusted host/launcher;
deliberate privileged lock bypass or a fabricated host `/proc` bind is outside
the guarantee. An unidentifiable loop backing file also causes refusal.
Docker-on-macOS shared folders (including OrbStack/Docker Desktop) are refused
for existing-image rebuilds: Linux inspection cannot establish whether macOS
QEMU has the image open, and the native launcher does not force image locking.
Stopping a container does not establish that Mac QEMU is stopped. There is no
offline-check bypass. First builds and unchanged cache hits still work; enabling
guarded shared-folder rebuilds requires separate platform work. The preservation
helper participates in the input manifest, so installing/changing it causes a
cache miss subject to these checks.
Disposable acceptance (only newly created native Linux temporary images are
written; the repository is read-only):
```sh
docker run --rm --privileged --pid=host \
--mount type=bind,src=/proc,dst=/run/spike3-host-proc,readonly \
--mount type=bind,src="$PWD",dst=/repo,readonly \
--entrypoint python3 spike3-cm4-emulator:local \
-m unittest discover -s /repo/emulation/tests -p test_rootfs_save_preservation.py -v
python3 -m unittest discover -s emulation/tests -q
```
The privileged fixture briefly attaches one disposable image to a read-only
loop device and detaches it to verify refusal. The host `/proc` bind must refer
to the Docker daemon's actual Linux host. These tests skip on macOS; they do not
constitute live game-update acceptance and never use the normal rootfs image.
Guest-side logs are intended to land under:
- `/connectivity/dump/log/spike-emu/launch.log`
- `/connectivity/dump/log/spike-emu/control-agent.log`
- `/connectivity/dump/log/spike-emu/ldpreload-stub.log`
Those files are inside `emulation/work/rootfs.ext4`; extract or mount the image
from the container when needed.
## Current Limitations
The Docker/browser lane remains an executable research scaffold. The native
macOS QEMU+VPX lane is the playable implementation, with acceptance boundaries
documented in `rootfs-triage-wiki/native-playable-table.md`.
- Verified: QEMU boots the generated rootfs, loads `virtio_gpu`, and provides
`/dev/dri/card0` plus `/dev/dri/renderD128`. The guest aliases the target's
expected `/dev/dri/card1` to that virtual device.
- Verified: the matching Buildroot Mesa 24.0.9 bundle initializes
`kms_swrast`, reports OpenGL ES 3.1 softpipe, imports GBM dma-bufs as real EGL
images, and produces complete FBOs. The Stern boot screen and Pokémon game
splash are visible in VNC at 1360x768.
- Verified: the default `debian-llvmpipe` stack under the native macOS HVF/Cocoa
launcher reports `OpenGL ES 3.2 Mesa 22.3.6` with `llvmpipe (LLVM 15.0.6,
128 bits)`. A QEMU framebuffer capture produced a complete 1920x1080 Pokémon
game frame. A single host sample showed QEMU at 534% CPU, so this is working
multithreaded software rendering, not hardware-accelerated graphics.
- Verified: dashboard and Mac keyboard events enter the real netbridge UART,
advertise the correct node activity, and change the active-low switch bank
consumed by the running game. Live traces confirmed left flipper at node
8/input 25 and Start at node 1/input 11, including ordered press/release for a
rapid tap. SPIKE 3 requests this ten-byte bank with node command `0xff`; the
model also supports the legacy `0x11` form.
- Verified: the macOS launcher exposes HDA Intel playback as ALSA card 0 and
routes it through QEMU's CoreAudio backend. The real game opens and prepares
the default PCM without the former null-device workaround.
- Verified: the software H.264/HEVC compatibility path uses six FFmpeg decoder
workers and libyuv's ARM NEON conversion for the game's 1920x1080 and
1940x1100 YUV420 assets, while retaining libswscale for uncommon formats.
- The native display path still needs the hardware shim for the disconnected
second connector and early GPIO/I2C/SPI/serial probes. In native mode the
shim passes graphics through; its only EGL/GLES compatibility behavior is
retaining extension entry points returned by `eglGetProcAddress`.
- The netbridge emulator supplies firmware identity, node discovery, switch
input banks, and enough output-command handling for the game to keep running,
but it is not yet a complete electrical model of every SPIKE node board.
- The optional Magikarp model proves nodes `12` and `13` can be discovered and
records their serial/calibration reads and raw writes. Its packed-output
decoder publishes the actual 74 RGB targets and all eight letter mappings
proven by the native descriptor/animation tables. Real initialization and
recovered post-award traffic replay successfully; a fresh bounded first-catch
live gate remains pending. Head/tail/wave targets and fade duration units
remain explicitly unproven. The historical 92-pixel `ETOPPER` table describes
a different accessory, not Magikarp. See the
[decoder evidence](../analysis/netbridge-emulator/evidence/topper-protocol-v83.md).
- Node writes with a zero response length are intentionally silent, matching
the RP2040 firmware and keeping registration traffic frame-aligned. The mock
identities cover the base Pro nodes and, only when opted in, the Magikarp
topper addresses probed by this game image.
- Godot reaches content startup, then reports that softpipe supports zero GL
program-binary formats. Some packaged shader binaries therefore need a
source-compilation/cache strategy instead of another fake GLES layer.
- Physical ball motion is intentionally outside the dashboard. Native VPX owns
it in playable mode and receives all 150 mapped lamp channels plus 13 mapped
drivers through the loopback controller bridge. The dashboard remains a
diagnostic view and does not become a gameplay authority.
- Docker Desktop on macOS runs QEMU without KVM, so startup and software
rendering are CPU-intensive.
## Manual Commands Inside the Container
Prepare the image only:
```sh
/workspace/emulation/scripts/prepare-rootfs-image.sh
```
Run QEMU directly:
```sh
SPIKE3_AUTOSTART=boot-display /workspace/emulation/scripts/run-qemu.sh
```
Fetch or refresh the ARM64 kernel inside the image/container:
```sh
fetch-debian-arm64-kernel
```
### Native audio sample rate
The macOS launcher reads the default output device's CoreAudio sample rate at
launch and supplies it explicitly to QEMU. This avoids using QEMU's implicit
44.1 kHz setting when Bluetooth output remains at 48 kHz.
`SPIKE3_MACOS_AUDIO_RATE=48000` provides an explicit override. Restart the
cabinet after changing output devices; this selection is not updated live.
Detection failure is reported and falls back to 48 kHz.
Double-click **`emulation/audio-settings.command`** to see the current output
device and rate, and save Automatic (recommended), 44.1, 48, 96 kHz, or a custom
rate. Settings are private to this checkout in `emulation/work/audio-settings.json`.
Automatic re-detects on every launch. `SPIKE3_MACOS_AUDIO_RATE` (including
`auto`) takes precedence over the saved selection.
For a read-only check: `python3 emulation/scripts/macos-audio-rate.py --status`.
For scripting: use `--set auto` or `--set 48000`. Configuration changes apply
on the next launch; this tool does not interrupt a running game. QEMU follows
default-device changes, but its configured rate remains fixed for the session.
Restart if a device switch changes the rate or causes incorrect pitch.
@@ -0,0 +1,69 @@
# Local Insider Connected service
The full-table launcher runs this service as a supervised dependency of the genuine game and genuine conagent. The game-to-conagent abstract Unix socket protocol is unchanged. A generated guest transport adapter routes HTTP calls to this local service, substitutes synthetic token-request credentials, and verifies TLS against a generated local certificate. QEMU restricts the guest network to the local API and time forwards.
## Full-table launch
From the repository root, use the existing manual-table launcher:
```sh
emulation/pokemon-vpx/run.command
```
The launcher prepares `emulation/work/local-ic/rootfs.ext4` from the existing stopped generated `emulation/work/rootfs.ext4`. It preserves subsequent settings in that local IC copy when helpers are refreshed. It does not regenerate the canonical VPX table or edit the rootfs evidence. Preparation requires Apple-silicon macOS, QEMU, e2fsprogs, LLVM/lld, OpenSSL and Docker; the EEPROM helper build uses the pinned Debian image recorded in `prepare_guest.py`. Existing graphics and VPX runtime prerequisites still apply.
The exact supported generated game is SHA-256 `e8add023b60be684509f25cd54972981cd2adf15b0ad09d1e99ba33c29a4f54a`. Unknown games fail preparation rather than receiving unchecked address patches. Two expected-digest literals in the generated copy are changed to accept a synthetic factory key. This preserves the factory validation algorithm and does not bypass game readiness or network authentication. Original game and EEPROM backups remain in the local IC directory. See [startup evidence](../../rootfs-triage-wiki/startup-readiness.md).
Open the service controls at `http://localhost:8345/`. Port 8088 serves the TLS API; port 8345 serves time and the local control UI. A port conflict is a launch error. Health checks verify both owned listeners before QEMU starts; a failed backend is restarted with its SQLite state preserved. Health means the mock is listening, not that the game is authenticated.
Useful overrides:
| Variable | Meaning |
|---|---|
| `SPIKE3_ROOTFS_IMAGE` | Existing stopped generated source image |
| `SPIKE3_LOCAL_IC_DIR` | Separate persistent local IC image, TLS, config and SQLite directory |
| `SPIKE3_CONAGENT_API_PORT` | Host TLS API port, default 8088 |
| `SPIKE3_CONAGENT_TIME_PORT` | Host time/control port, default 8345 |
| `SPIKE3_LOCAL_IC_PROVISION=0` | Restore the original generated game and factory record, preserving other saved settings |
| `SPIKE3_CONAGENT_BACKEND=0` | Offline unpatched-source launch with restricted networking |
A legitimately unconfigured EEPROM still opens Guided Setup. Save & Exit uses the real game handler and persists the completion record. Service Enter and Back are corrected in the host CPU-SPI mapping; no setup flag is forcibly set.
## Contracts and extensions
- `emulator.py`: HTTP/TLS listeners, local controls, request diagnostics and fault injection.
- `protocol.py`: backend routes. Unknown API and CDN requests return explicit unsupported errors.
- `service.py`: machine, guest/player, session and event behavior.
- `settings.py`: synthetic defaults and redaction.
- `store.py`: SQLite transactions for machine registration, properties, players, achievements and sessions, independent of game settings.
- `local_transport.c`: genuine conagent HTTP transport adaptation.
- `provisioning.py` / `prepare_guest.py`: exact-binary synthetic factory fixture and reversible generated image preparation.
The compatibility entrypoint `analysis/conagent-machine-emulator/emulator.py` delegates to this implementation. Guest play is the default; mock player authentication is disabled. The game may show its normal optional join screen; Start skips it without a mandatory login countdown. Authentication supplies the machine object required by the actual game parser. Startup achievement and audit responses use parser-compatible empty descriptor arrays; configuration uses an empty `data` object. The genuine game accepts a persistent positive session ID and uses it in event batches stored by the service. Custom achievement behavior and player account interoperability remain extension work. Retained representative player/data handlers and detailed session-update semantics are local prototypes, not claims of complete game-parser acceptance. The UI's extracted-call buttons exercise those prototypes, not the running game.
Confirmed live paths and parser addresses are recorded in the wiki. Token authentication, server time, game authentication and continuing heartbeat requests have been observed from the genuine conagent; the game has reached its own `COMMS_ENABLED` branch. HTTP 200 alone is not used as proof of that transition. Protected API calls check the actual issued token carried by conagent; the transport adapter does not replace Authorization headers.
## Fault scenarios
PATCH `/api/state` on the local control listener. Scenario keys are backend paths without trailing slashes. For example:
```sh
curl -X PATCH http://127.0.0.1:8345/api/state \
-H 'Content-Type: application/json' \
-d '{"scenarios":{"/api/v3/game/game_auth":{"omit_keys":["machine"]}}}'
```
Other scenario fields are `delay_ms`, `reject_status` (HTTP 400599), `malformed: true`, and `unavailable: true`. Clear scenarios with `{"scenarios":{}}`. Set `service.machine_enabled=false` to exercise the genuine disabled-machine error (403 with App-Status-Code 431). Scenarios do not delay health checks or persist across a service restart. Synthetic machine/session/property state does persist.
Requests are recorded with sensitive credentials and identifiers redacted in `emulation/work/logs/conagent-requests.jsonl`; service errors go to `conagent-backend.log`, and supervision/preparation diagnostics to `playable-qemu-launcher.log` and `local-ic-prepare.log`.
## Verification and rollback
```sh
python3 -m unittest discover -s emulation/conagent-machine-emulator -p 'test_*.py'
python3 -m unittest discover -s emulation/netbridge-emulator -p 'test_*.py'
```
Wire tests cover malformed/unsupported input, authentication rejection, missing fields, outages, delay isolation, persistence and listener collision. They are distinct from the runtime measurements and native gameplay evidence in the wiki.
Stop the full-table launcher before refreshing or rolling back its image. `SPIKE3_LOCAL_IC_PROVISION=0` restores only the original game and original factory-record range in the generated copy; it preserves later setup completion and other EEPROM values. Set `SPIKE3_CONAGENT_BACKEND=0` to launch the original generated source without the service. Keep the local IC directory to retain its settings and SQLite state. Do not overwrite the source image with the derived image.
@@ -0,0 +1,91 @@
{
"auth": {
"accept_any_credentials": false,
"api_password": "emu",
"api_token": "emu-api-token",
"api_username": "emu",
"device_refresh": "emu-device-refresh",
"device_token": "emu-device-token"
},
"bind_host": "127.0.0.1",
"api_port": 8088,
"time_port": 8345,
"public_host": "127.0.0.1",
"machine": {
"code_version": "0_83_0",
"game_model": "pro",
"game_title": "pokemon_pro",
"internal_ip_address": "192.0.2.33",
"mac_address": "02:00:00:00:00:03",
"machine_configuration_number": "pokemon_pro",
"machine_uuid": "00000000-0000-4000-8000-000000000001",
"model_number": "Pokemon Pro",
"model_version": "0.83.0",
"online": true,
"registered": true,
"serial_number": "SPIKE3-EMU-0001"
},
"service": {
"app_status_code": 0,
"force_http_status": 200,
"machine_enabled": true,
"player_auth_enabled": false,
"updates_available": false
},
"game_state": {
"active": false,
"audits": {
"AUD_AVERAGE_SCORES": 0,
"AUD_CONNECTIVITY_ACHIEVEMENTS_GIVEN": 0,
"AUD_CONNECTIVITY_NET_CONNECTION_SERVER_OFFLINE": 0,
"AUD_SCORE_HISTO_1": 0,
"AUD_TOTAL_HIGH_SCORE_AWARDS": 0
},
"ball": 1,
"current_player": 1,
"data_store": {},
"events": [],
"high_scores": [
{
"initials": "EMU",
"rank": 1,
"score": 50000000
},
{
"initials": "AAA",
"rank": 2,
"score": 25000000
},
{
"initials": "BBB",
"rank": 3,
"score": 10000000
},
{
"initials": "CCC",
"rank": 4,
"score": 5000000
}
],
"player_scores": {
"1": 0,
"2": 0,
"3": 0,
"4": 0
},
"session_uuid": ""
},
"urls": {
"stern_cdn_url": "http://127.0.0.1:8088/cdn/",
"stern_speed_test_url": "http://127.0.0.1:8088/speed-test"
},
"custom_responses": {
"/api/v3/game/example_custom_endpoint": {
"status": 200,
"body": {
"status": "OK",
"source": "custom response"
}
}
}
}
@@ -0,0 +1,497 @@
#!/usr/bin/env python3
"""
Host-side Stern SPIKE 3 conagent backend/machine emulator.
This is static-analysis support tooling. It does not execute target binaries.
It emulates the conagent-visible backend API surface documented in
rootfs-triage-wiki/conagent-re.md and exposes a local web UI for controlling
the simulated pinball machine state.
"""
from __future__ import annotations
import argparse
import copy
import json
import ssl
import sys
import threading
import time
import uuid
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from socketserver import ThreadingMixIn
from typing import Any
from urllib.parse import parse_qs, urlparse
from settings import DEFAULT_CONFIG, TIME_PATH, REDACT_KEYS, utc_now, deep_merge, redact, read_json_file, GAME_CALL_CATALOG
from service import EmulatorState
from protocol import ConagentProtocol
from web_ui import INDEX_HTML
class ReusableThreadingHTTPServer(ThreadingHTTPServer):
allow_reuse_address = True
daemon_threads = True
class EmulatorHandler(ConagentProtocol, BaseHTTPRequestHandler):
server_version = "Spike3ConagentEmulator/0.1"
def do_OPTIONS(self) -> None:
self.send_response(HTTPStatus.NO_CONTENT)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, Device-Token, Device-Refresh")
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
self.end_headers()
def do_GET(self) -> None:
self.route()
def do_POST(self) -> None:
self.route()
def do_PUT(self) -> None:
self.route()
def do_PATCH(self) -> None:
self.route()
def do_DELETE(self) -> None:
self.route()
@property
def state(self) -> EmulatorState:
return self.server.state # type: ignore[attr-defined]
def route(self) -> None:
path = urlparse(self.path).path.rstrip('/')
with self.state.lock:
scenario = copy.deepcopy(self.state.config.get('scenarios', {}).get(path, {}))
# Delay only the selected wire request, never the supervisor health check.
if path != '/healthz':
time.sleep(max(0, min(float(scenario.get('delay_ms', 0)), 300000)) / 1000)
self.scenario = scenario if path != '/healthz' else {}
# Serialize request state changes and commit before acknowledging them.
with self.state.lock:
self._route()
def _route(self) -> None:
parsed = urlparse(self.path)
path = parsed.path
body = None
start_status = 200
try:
body = self.read_body_json()
if path == "/healthz":
self.send_json({"service": "spike3-local-ic", "ready": True})
return
if path.startswith(("/api/v", "/ingest/")) and not self.state.config["auth"].get("accept_any_credentials", False):
expected = "Token " + str(self.state.config["auth"]["api_token"])
if self.headers.get("Authorization") != expected:
self.send_json({"error": "invalid_api_token"}, status=403, headers={"App-Status-Code": "0"})
self.log_access(path, body, 403)
return
if self.scenario.get('unavailable') or self.scenario.get('reject_status'):
status = int(self.scenario.get('reject_status', 503))
self.send_json({'error': 'configured_service_failure'}, status=status)
self.log_access(path, body, status)
return
if self.scenario.get('malformed'):
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', '1')
self.end_headers()
self.wfile.write(b'{')
self.log_access(path, body, 200)
return
if path in ("/", "/index.html"):
self.send_html(INDEX_HTML)
self.log_access(path, body, 200)
return
if path == "/speed-test":
self.send_text("ok\n", content_type="text/plain")
self.log_access(path, body, 200)
return
if path.startswith("/cdn/"):
self.send_json({"error": "unsupported_asset", "path": path}, status=501)
self.log_access(path, body, 501)
return
if path == "/api/state":
status, payload = self.handle_state(body)
elif path == "/api/requests":
status, payload = self.handle_requests()
elif path == "/api/reset":
status, payload = self.require_method("POST", lambda: self.state.reset())
elif path == "/api/machine/register":
status, payload = self.require_method("POST", lambda: self.state.register_machine(body))
elif path == "/api/machine/heartbeat":
status, payload = self.require_method("POST", self.state.heartbeat)
elif path == "/api/machine/auth-player":
status, payload = self.require_method_tuple("POST", lambda: self.state.player_auth((body or {}).get("user_uuid")))
elif path == "/api/game-calls":
status, payload = self.handle_game_calls()
elif path == "/api/game-calls/run":
status, payload = self.handle_game_call_run(body)
elif path == "/api/game-state/score":
status, payload = self.handle_game_score(body)
elif path.rstrip("/") == "/api-token-auth":
status, payload = self.handle_token_auth(body)
elif path.rstrip("/") == TIME_PATH.rstrip("/"):
status, payload = self.handle_time()
elif path.rstrip("/") == "/ingest/v1/game/game_events":
status, payload = self.require_method("POST", lambda: self.state.ingest_game_events(body))
elif path.startswith("/api/"):
status, payload = self.handle_conagent_api(path, parse_qs(parsed.query), body)
else:
status, payload = 404, {"error": "not_found", "path": path}
except ValueError:
status, payload = 400, {'error': 'invalid_json_request'}
except Exception as exc: # defensive server tooling
status, payload = 500, {"error": type(exc).__name__, "message": str(exc)}
for key in self.scenario.get('omit_keys', []):
if isinstance(payload, dict):
payload.pop(key, None)
headers = self.state.response_headers()
if status == 403 and isinstance(payload, dict) and payload.get("error") == "machine_not_enabled":
headers["App-Status-Code"] = "431"
self.state.persist()
self.send_json(payload, status=status, headers=headers)
self.log_access(path, body, status if "status" in locals() else start_status)
def handle_state(self, body: dict[str, Any] | None) -> tuple[int, dict[str, Any]]:
if self.command == "GET":
return 200, self.state.snapshot()
if self.command in {"PATCH", "PUT", "POST"}:
if not isinstance(body, dict):
return 400, {"error": "JSON object required"}
return 200, self.state.patch(body)
return 405, {"error": "method_not_allowed"}
def handle_requests(self) -> tuple[int, dict[str, Any]]:
if self.command == "GET":
with self.state.lock:
return 200, {"requests": copy.deepcopy(self.state.requests)}
if self.command == "DELETE":
with self.state.lock:
self.state.requests.clear()
return 200, {"ok": True}
return 405, {"error": "method_not_allowed"}
def handle_token_auth(self, body: dict[str, Any] | None) -> tuple[int, dict[str, Any]]:
if self.command != 'POST':
return 405, {'error': 'method_not_allowed'}
auth = self.state.config["auth"]
if not auth.get("accept_any_credentials", True):
if not body or body.get("username") != auth.get("api_username") or body.get("password") != auth.get("api_password"):
return 403, {"detail": "invalid credentials"}
return 200, {"token": auth.get("api_token", "emu-api-token")}
def handle_game_calls(self) -> tuple[int, dict[str, Any]]:
if self.command != "GET":
return 405, {"error": "method_not_allowed"}
return 200, {"calls": copy.deepcopy(GAME_CALL_CATALOG)}
def handle_game_score(self, body: dict[str, Any] | None) -> tuple[int, dict[str, Any]]:
if self.command != "POST":
return 405, {"error": "method_not_allowed"}
body = body or {}
game_state = self.state.add_score(int(body.get("player", 1)), int(body.get("delta", 0)))
return 200, {"status": "OK", "game_state": game_state}
def handle_game_call_run(self, body: dict[str, Any] | None) -> tuple[int, dict[str, Any]]:
if self.command != "POST":
return 405, {"error": "method_not_allowed"}
body = body or {}
call_id = str(body.get("id", ""))
overrides = body.get("overrides", {})
call = next((item for item in GAME_CALL_CATALOG if item["id"] == call_id), None)
if call is None:
return 404, {"error": "unknown_call", "id": call_id}
request = self.build_game_call_request(call_id, overrides if isinstance(overrides, dict) else {})
status, response = self.dispatch_game_call(request)
self.state.log_request({
"timestamp": utc_now(),
"client": "emulated-game",
"method": request["method"],
"path": request["path"],
"status": status,
"headers": {"source": "game-call-emulator"},
"body": redact(request.get("body") or {}),
})
return 200, {
"call": call,
"request": redact(request),
"response_status": status,
"response": redact(response),
"game_state": self.state.snapshot().get("game_state", {}),
}
def build_game_call_request(self, call_id: str, overrides: dict[str, Any]) -> dict[str, Any]:
machine = self.state.config["machine"]
auth = self.state.config["auth"]
game_state = self.state.config["game_state"]
default_user = next(iter(self.state.config.get("players", {})), "00000000-0000-4000-8000-00000000c0de")
session_uuid = game_state.get("session_uuid") or str(uuid.uuid4())
base: dict[str, Any]
if call_id == "token_auth":
base = {"method": "POST", "path": "/api-token-auth/", "body": {"username": auth.get("api_username"), "password": auth.get("api_password")}}
elif call_id == "server_time":
base = {"method": "GET", "path": TIME_PATH, "body": {}}
elif call_id == "player_auth":
base = {"method": "POST", "path": "/api/v3/game/player_auth", "body": {"machine_uuid": machine.get("machine_uuid"), "user_uuid": default_user}}
elif call_id == "retrieve_player_properties":
base = {"method": "POST", "path": "/api/v2/game/player_properties", "body": {"machine_uuid": machine.get("machine_uuid"), "user_uuids": [default_user]}}
elif call_id == "retrieve_player_achievements":
base = {"method": "POST", "path": "/api/v2/game/player_achievements", "body": {"machine_uuid": machine.get("machine_uuid"), "user_uuids": [default_user]}}
elif call_id == "session_start":
base = {
"method": "POST",
"path": "/api/v3/game/session_start",
"body": {
"machine_uuid": machine.get("machine_uuid"),
"players": [{"player_number": 1, "user_uuid": default_user}],
"game_mode": "standard",
},
}
elif call_id == "session_update":
base = {
"method": "POST",
"path": "/api/v3/game/session_update",
"body": {
"machine_uuid": machine.get("machine_uuid"),
"session_uuid": session_uuid,
"ball": game_state.get("ball", 1),
"player_scores": copy.deepcopy(game_state["player_scores"]),
},
}
elif call_id == "session_end":
base = {
"method": "POST",
"path": "/api/v3/game/session_end",
"body": {
"machine_uuid": machine.get("machine_uuid"),
"session_uuid": session_uuid,
"player_scores": copy.deepcopy(game_state["player_scores"]),
},
}
elif call_id == "high_score_event":
base = {
"method": "POST",
"path": "/api/v1/game/high_score_events",
"body": {
"machine_uuid": machine.get("machine_uuid"),
"session_uuid": session_uuid,
"high_scores": copy.deepcopy(game_state["high_scores"]),
"player_scores": copy.deepcopy(game_state["player_scores"]),
},
}
elif call_id == "audit_report":
base = {"method": "POST", "path": "/api/v2/game/machine_audits", "body": {"machine_uuid": machine.get("machine_uuid"), "audits": copy.deepcopy(game_state["audits"])}}
elif call_id == "game_configuration":
base = {"method": "GET", "path": "/api/v1/game/game_configuration", "body": {}}
elif call_id == "data_store":
base = {"method": "POST", "path": "/api/v1/game/data_store", "body": {"key": "emulator.score_snapshot", "value": copy.deepcopy(game_state["player_scores"])}}
elif call_id == "data_retrieve":
base = {"method": "POST", "path": "/api/v1/game/data_retrieve", "body": {"key": "emulator.score_snapshot"}}
else:
call = next(item for item in GAME_CALL_CATALOG if item["id"] == call_id)
base = {"method": call["method"], "path": call["path"], "body": {}}
if overrides:
deep_merge(base, overrides)
return base
def dispatch_game_call(self, request: dict[str, Any]) -> tuple[int, dict[str, Any]]:
old_command = self.command
self.command = request["method"]
try:
path = request["path"]
body = request.get("body") if isinstance(request.get("body"), dict) else {}
if path.rstrip("/") == "/api-token-auth":
return self.handle_token_auth(body)
if path.rstrip("/") == TIME_PATH.rstrip("/"):
return self.handle_time()
return self.handle_conagent_api(path, {}, body)
finally:
self.command = old_command
def handle_time(self) -> tuple[int, dict[str, Any]]:
return 200, {"datetime": utc_now(), "server_timezone": "UTC"}
def require_method(self, method: str, callback) -> tuple[int, dict[str, Any]]:
if self.command != method:
return 405, {"error": "method_not_allowed"}
return 200, callback()
def require_method_tuple(self, method: str, callback) -> tuple[int, dict[str, Any]]:
if self.command != method:
return 405, {"error": "method_not_allowed"}
return callback()
def read_body_json(self) -> dict[str, Any] | None:
length = int(self.headers.get("Content-Length", "0") or "0")
if length < 0 or length > 1024 * 1024:
self.close_connection = True
raise ValueError('Invalid request size')
if length <= 0:
return None
raw = self.rfile.read(length)
if not raw:
return None
try:
parsed = json.loads(raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
raise ValueError('Invalid JSON') from None
if not isinstance(parsed, dict):
raise ValueError('JSON object required')
return parsed
def send_json(self, payload: Any, status: int = 200, headers: dict[str, str] | None = None) -> None:
data = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
for key, value in (headers or {}).items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(data)
def send_html(self, html: str) -> None:
data = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def send_text(self, text: str, content_type: str = "text/plain") -> None:
data = text.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_access(self, path: str, body: dict[str, Any] | None, status: int) -> None:
interesting_headers = {}
for key in ("User-Agent", "Authorization", "Device-Token", "Device-Refresh", "Message-Number"):
value = self.headers.get(key)
if value:
interesting_headers[key] = "<present>" if key.lower().replace('-', '_') in REDACT_KEYS else value
self.state.log_request({
"timestamp": utc_now(),
"client": self.client_address[0] if self.client_address else "",
"method": self.command,
"path": path,
"status": status,
"headers": interesting_headers,
"body": redact(body or {}),
})
def log_message(self, fmt: str, *args: Any) -> None:
# Keep stdout focused on startup info; full request data is in the web UI.
return
def make_server(host: str, port: int, state: EmulatorState) -> ReusableThreadingHTTPServer:
server = ReusableThreadingHTTPServer((host, port), EmulatorHandler)
server.state = state # type: ignore[attr-defined]
return server
def serve(server: ThreadingMixIn, label: str) -> None:
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
def load_config(args: argparse.Namespace) -> dict[str, Any]:
config: dict[str, Any] = {}
if args.config:
config = read_json_file(args.config)
for key in ("bind_host", "public_host"):
value = getattr(args, key)
if value:
config[key] = value
for key in ("api_port", "time_port"):
value = getattr(args, key)
if value is not None:
config[key] = value
return config
def write_default_config(path: str) -> None:
with open(path, "w", encoding="utf-8") as fp:
json.dump(DEFAULT_CONFIG, fp, indent=2, sort_keys=True)
fp.write("\n")
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="SPIKE 3 conagent backend/machine emulator")
parser.add_argument("--database", help="SQLite persistent synthetic service state")
parser.add_argument("--request-log", help="Redacted JSONL request trace")
parser.add_argument("--config", help="JSON config file to load")
parser.add_argument("--write-default-config", help="write a default JSON config and exit")
parser.add_argument("--bind-host", help="bind host, default from config or 127.0.0.1")
parser.add_argument("--public-host", help="host name/IP shown in generated UI URLs")
parser.add_argument("--api-port", type=int, help="web UI and conagent HTTPS/API port")
parser.add_argument("--time-port", type=int, help="plain HTTP server-time port, conagent default is 8345")
parser.add_argument("--api-tls-cert", help="certificate PEM for HTTPS API mode")
parser.add_argument("--api-tls-key", help="private key PEM for HTTPS API mode")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
if args.write_default_config:
write_default_config(args.write_default_config)
print(f"wrote {args.write_default_config}")
return 0
config = load_config(args)
state = EmulatorState.from_config(config, args.database)
state.request_log = args.request_log
host = state.config["bind_host"]
api_port = int(state.config["api_port"])
time_port = int(state.config["time_port"])
tls_enabled = bool(args.api_tls_cert or args.api_tls_key)
state.config["_api_tls_enabled"] = tls_enabled
if tls_enabled and not (args.api_tls_cert and args.api_tls_key):
raise SystemExit("--api-tls-cert and --api-tls-key must be supplied together")
api_server = make_server(host, api_port, state)
if tls_enabled:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(args.api_tls_cert, args.api_tls_key)
api_server.socket = context.wrap_socket(api_server.socket, server_side=True)
servers: list[tuple[ReusableThreadingHTTPServer, str]] = [(api_server, "api/ui")]
if time_port != api_port:
servers.append((make_server(host, time_port, state), "time"))
print(f"web UI: {state.api_base_url()}/")
print(f"server time URL: {state.time_url()}")
print("press Ctrl-C to stop")
threads = []
for server, label in servers:
thread = threading.Thread(target=serve, args=(server, label), daemon=True)
thread.start()
threads.append(thread)
try:
while all(thread.is_alive() for thread in threads):
time.sleep(0.5)
except KeyboardInterrupt:
for server, _label in servers:
server.shutdown()
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,61 @@
/* Local-only transport adapter for the genuine conagent. No target ABI changes.
* Built as a freestanding shared object against the target's existing libc/curl.
*/
typedef __SIZE_TYPE__ size_t;
extern void *dlsym(void *, const char *);
extern char *getenv(const char *);
extern char *strstr(const char *, const char *);
extern char *strchr(const char *, int);
extern int snprintf(char *, size_t, const char *, ...);
extern int strcmp(const char *, const char *);
extern int strncmp(const char *, const char *, size_t);
extern int write(int, const void *, size_t);
#define NEXT ((void *)-1L)
#define VA_START(a,b) __builtin_va_start(a,b)
#define VA_ARG(a,t) __builtin_va_arg(a,t)
#define VA_END(a) __builtin_va_end(a)
/* CURLOPT_URL is copied by libcurl. All non-URL options retain their ABI. */
int curl_easy_setopt(void *handle, int option, ...) {
int (*real)(void *, int, ...) = dlsym(NEXT, "curl_easy_setopt");
__builtin_va_list args; VA_START(args, option); int result;
if (option == 10002) {
const char *url = VA_ARG(args, const char *);
const char *scheme = url ? strstr(url, "://") : 0;
const char *path = scheme ? strchr(scheme + 3, '/') : 0;
if (!path) path = "/";
char local[8192];
const char *origin = strstr(path, "wibbly_wobbly_timey_wimey") ?
"http://10.0.2.100:8345" : "https://10.0.2.100";
int count = snprintf(local, sizeof(local), "%s%s", origin, path);
result = count < 0 || (size_t)count >= sizeof(local) ? 3 : real(handle, option, local);
/* Ignore ambient proxy settings and trust only the generated local CA. */
if (!result) result = real(handle, 10004, ""); /* CURLOPT_PROXY */
if (!result) result = real(handle, 10065, "/usr/local/spike-emu/ic/ca.pem");
if (!result) result = real(handle, 64, 1L); /* SSL_VERIFYPEER */
if (!result) result = real(handle, 81, 2L); /* SSL_VERIFYHOST */
} else if (option >= 30000 && option < 40000) {
result = real(handle, option, VA_ARG(args, long long));
} else if (option >= 10000 && option < 30000) {
result = real(handle, option, VA_ARG(args, void *));
} else {
result = real(handle, option, VA_ARG(args, long));
}
VA_END(args); return result;
}
/* Substitute credentials only at the HTTP boundary; the game/agent wire
* protocol and their authentication state machines remain genuine. */
int curl_easy_perform(void *handle) {
int (*info)(void *, int, ...) = dlsym(NEXT, "curl_easy_getinfo");
int (*set)(void *, int, ...) = dlsym(NEXT, "curl_easy_setopt");
int (*perform)(void *) = dlsym(NEXT, "curl_easy_perform");
char *url = 0;
if (!info || !set || !perform) return 2;
if (!info(handle, 0x100001, &url) && url && strstr(url, "/api-token-auth")) {
static const char credentials[] = "{\"username\":\"emu\",\"password\":\"emu\"}";
int result = set(handle, 60, (long)(sizeof(credentials) - 1));
if (!result) result = set(handle, 10015, credentials);
if (result) return result;
}
return perform(handle);
}
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Provision a newly built export inside its Linux builder, before publication.
The immutable source game is only read. Exact binary guards and installation
readback apply to the private generated filesystem. Nothing compiles on launch.
"""
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
from provisioning import patch_game, profile, provision_eeprom
def run(*args):
return subprocess.run([str(a) for a in args], check=True, capture_output=True)
def prepare(root):
root = Path(root).resolve()
metadata = json.loads((root / 'bundle.json').read_text())
name = metadata['game_directory']
if not name or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-' for c in name):
raise ValueError('Invalid guest game directory')
original = (root / 'source-root/games' / name / 'game').read_bytes()
try:
profile(original)
except ValueError:
print('No exact local IC provisioning profile for this game')
return
image = root / 'emulation/work/rootfs.ext4'
output = root / 'emulation/work/local-ic'
# A refresh must never regenerate credentials under an existing saved VM.
output.mkdir(exist_ok=False)
tls = output / 'tls'
tls.mkdir()
run('openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '3650',
'-subj', '/CN=SPIKE3 local IC', '-addext',
'subjectAltName=IP:10.0.2.100,IP:127.0.0.1,DNS:localhost',
'-keyout', tls / 'key.pem', '-out', tls / 'ca.pem')
(tls / 'key.pem').chmod(0o600)
source = root / 'emulation'
with tempfile.TemporaryDirectory(prefix='export-ic-') as temporary:
temporary = Path(temporary)
transport = temporary / 'local_transport.so'
eeprom_helper = temporary / 'eeprom_preload.so'
run('aarch64-linux-gnu-gcc', '-shared', '-fPIC', '-nostdlib',
'-Wl,--hash-style=gnu', source / 'conagent-machine-emulator/local_transport.c',
'-o', transport)
run('aarch64-linux-gnu-gcc', '-O2', '-Wall', '-Wextra', '-shared', '-fPIC',
source / 'stubs/eeprom_preload.c', source / 'stubs/i2c_eeprom_0x57.c',
'-o', eeprom_helper, '-ldl', '-pthread')
game = temporary / 'game'
game.write_bytes(patch_game(original))
saved = output / 'game.before-local-ic'
saved.write_bytes(original)
saved.chmod(0o600)
eeprom = temporary / 'eeprom.bin'
target_eeprom = '/connectivity/dump/spike-emu/eeprom-0x57.bin'
run('debugfs', '-R', f'dump {target_eeprom} {eeprom}', image)
before = eeprom.read_bytes() if eeprom.exists() else bytes([255]) * 32768
(output / 'eeprom.before-local-ic').write_bytes(before)
eeprom.write_bytes(provision_eeprom(original, before))
for directory in ('/usr/local/spike-emu/ic', '/connectivity',
'/connectivity/dump', '/connectivity/dump/spike-emu'):
run('debugfs', '-w', '-R', f'mkdir {directory}', image)
installs = [(game, f'/games/{name}/game', '0100755'),
(eeprom, target_eeprom, '0100600'),
(transport, '/usr/local/spike-emu/ic/local_transport.so', '0100755'),
(eeprom_helper, '/usr/local/spike-emu/lib/eeprom_preload.so', '0100755'),
(tls / 'ca.pem', '/usr/local/spike-emu/ic/ca.pem', '0100644')]
hashes = {}
for index, (payload, target, mode) in enumerate(installs):
# debugfs can return success for failed commands: verify every byte.
run('debugfs', '-w', '-R', f'rm {target}', image)
run('debugfs', '-w', '-R', f'write {payload} {target}', image)
run('debugfs', '-w', '-R', f'set_inode_field {target} mode {mode}', image)
verify = temporary / f'verify-{index}'
run('debugfs', '-R', f'dump {target} {verify}', image)
if not verify.exists() or verify.read_bytes() != payload.read_bytes():
raise ValueError(f'Export local IC verification failed: {target}')
hashes[target] = hashlib.sha256(verify.read_bytes()).hexdigest()
checked = subprocess.run(['e2fsck', '-fn', str(image)], capture_output=True)
if checked.returncode:
raise ValueError('Provisioned export failed filesystem check: ' + checked.stderr.decode())
(output / 'config.json').write_text(json.dumps({
'bind_host': '127.0.0.1', 'public_host': '10.0.2.100',
'auth': {'accept_any_credentials': False},
'service': {'player_auth_enabled': False},
'urls': {'stern_cdn_url': 'https://10.0.2.100/cdn/',
'stern_speed_test_url': 'https://10.0.2.100/speed-test'}}, indent=2) + '\n')
(output / 'manifest.json').write_text(json.dumps({
'schema': 1, 'prepared_export': True, 'game_directory': name,
'original_game_sha256': hashlib.sha256(original).hexdigest(),
'installed': hashes}, indent=2) + '\n')
# The builder runs as root; the exporting host user must be able to archive
# private key/rollback files without making those files world-readable.
owner = root.stat()
for path in [output, *output.rglob('*')]:
os.chown(path, owner.st_uid, owner.st_gid)
print('Prepared local conagent transport, synthetic EEPROM and TLS service')
if __name__ == '__main__':
prepare(Path(sys.argv[1]))
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Prepare a reversible local-IC copy of an existing, stopped generated guest.
Never edits the source image or canonical VPX. The generated copy receives a
hash-checked synthetic factory-key comparison and EEPROM record; other saved
settings are preserved. Existing output images retain settings during refresh.
"""
import argparse
import fcntl
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
from provisioning import patch_game, provision_eeprom, SETUP_RECORD_GAMES
ROOT = Path(__file__).resolve().parents[2]
DEBIAN = 'debian:bookworm@sha256:6ebd97fa83deb272194a2cf015b3d26a4d538e9ad3a7a79d544c8af5b0a01443'
def run(args, **kwargs):
return subprocess.run([str(x) for x in args], check=True, **kwargs)
def digest(path):
with open(path, 'rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def tool(name, fallback):
found = (fallback if Path(fallback).is_file() else None) or shutil.which(name)
if not found:
raise SystemExit(f'{name} is required to prepare the generated local IC guest')
return found
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--source-image', required=True, type=Path)
ap.add_argument('--directory', required=True, type=Path)
ap.add_argument('--no-synthetic-provisioning', action='store_true')
ap.add_argument('--api-port', type=int, default=8088)
ap.add_argument('--time-port', type=int, default=8345)
args = ap.parse_args()
source = args.source_image.resolve()
output = args.directory.resolve()
output.mkdir(parents=True, exist_ok=True)
image = output/'rootfs.ext4'
if image == source or not source.is_file():
raise SystemExit('Source must be an existing image distinct from the generated local IC copy')
with open(output/'prepare.lock', 'a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
manifest_path = output/'manifest.json'
manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else {}
if manifest.get('source_image', str(source)) != str(source):
raise SystemExit('Local IC image belongs to another source; choose a separate directory to preserve its settings')
sources = [ROOT/'emulation/guest/emu-init', ROOT/'emulation/guest/launch-target.sh',
ROOT/'emulation/stubs/eeprom_preload.c', ROOT/'emulation/stubs/i2c_eeprom_0x57.c',
ROOT/'emulation/stubs/i2c_eeprom_0x57.h', ROOT/'emulation/stubs/audio_volume.c',
Path(__file__).with_name('local_transport.c')]
hashes = {str(p.relative_to(ROOT)):digest(p) for p in sources}
hashes['preparer'] = digest(Path(__file__))
hashes['provisioning'] = digest(Path(__file__).with_name('provisioning.py'))
hashes['synthetic_provisioning'] = not args.no_synthetic_provisioning
tls = output/'tls'
tls.mkdir(exist_ok=True)
if not (tls/'ca.pem').exists() or not (tls/'key.pem').exists():
with tempfile.TemporaryDirectory(dir=output) as staging:
staging = Path(staging)
run(['openssl','req','-x509','-newkey','rsa:2048','-nodes','-days','365',
'-subj','/CN=SPIKE3 local IC', '-addext','subjectAltName=IP:10.0.2.100,IP:127.0.0.1,DNS:localhost',
'-keyout',staging/'key.pem','-out',staging/'ca.pem'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
os.chmod(staging/'key.pem',0o600)
for name in ('ca.pem','key.pem'): os.replace(staging/name,tls/name)
run(['openssl','x509','-checkend','0','-noout','-in',tls/'ca.pem'],stdout=subprocess.DEVNULL)
hashes['local_ca'] = digest(tls/'ca.pem')
if not image.exists() or manifest.get('helpers') != hashes:
debugfs = tool('debugfs','/opt/homebrew/opt/e2fsprogs/sbin/debugfs')
e2fsck = tool('e2fsck','/opt/homebrew/opt/e2fsprogs/sbin/e2fsck')
inspected = image if image.exists() else source
opened = subprocess.run(['lsof','-t',str(inspected)],capture_output=True,text=True)
if opened.stdout.strip(): raise SystemExit('Stop the source/local IC guest before copying or refreshing its helpers')
compiler = tool('clang','/opt/homebrew/opt/llvm/bin/clang')
linker = tool('ld.lld','/opt/homebrew/opt/lld/bin/ld.lld')
run([compiler,'--target=aarch64-linux-gnu',f'--ld-path={linker}','-shared','-fPIC','-nostdlib',
'-Wl,--hash-style=gnu',sources[-1],'-o',output/'local_transport.so'])
run(['docker','run','--rm','--platform','linux/arm64',
'-v',f'{ROOT}/emulation/stubs:/src:ro','-v',f'{output}:/out',DEBIAN,
'sh','-ec','apt-get update >/out/compiler.log 2>&1; '
'apt-get install -y --no-install-recommends gcc libc6-dev libasound2-dev >>/out/compiler.log 2>&1; '
'gcc -O2 -Wall -Wextra -shared -fPIC /src/eeprom_preload.c /src/i2c_eeprom_0x57.c '
'-o /out/eeprom_preload.so -ldl -pthread; '
'gcc -O2 -Wall -Wextra -shared -fPIC /src/audio_volume.c '
'-o /out/libspike3audio.so -lasound -ldl -lm -pthread'])
if not image.exists():
partial=output/'rootfs.partial'
if sys.platform == 'darwin': run(['cp','-c',source,partial])
else: shutil.copyfile(source,partial)
os.replace(partial,image)
checked = subprocess.run([e2fsck,'-fy',str(image)],capture_output=True,text=True)
(output/'filesystem-check.log').write_text(checked.stdout+checked.stderr)
if checked.returncode not in (0,1): raise SystemExit('Generated guest filesystem check failed; see filesystem-check.log')
# debugfs operates only after journal replay. Check every installed
# byte by exporting it again; debugfs itself may return zero on errors.
with tempfile.TemporaryDirectory(prefix='spike3-ic-') as staging:
staging=Path(staging)
for directory in ['/usr/local/spike-emu/ic','/connectivity/dump/spike-emu']:
run([debugfs,'-w','-R',f'mkdir {directory}',image],capture_output=True)
game=staging/'game'
run([debugfs,'-R',f'dump /games/pokemon_pro/game {game}',image],capture_output=True)
original_game=output/'game.before-local-ic'
if not original_game.exists():
shutil.copyfile(game,original_game)
os.chmod(original_game,0o600)
original=original_game.read_bytes()
patched=patch_game(original) # Full-file guard, including refresh/rollback.
if game.read_bytes() not in (original,patched):
raise SystemExit('Generated game changed outside local IC; refusing to overwrite it')
game.write_bytes(original if args.no_synthetic_provisioning else patched)
eeprom=staging/'eeprom'
run([debugfs,'-R',f'dump /connectivity/dump/spike-emu/eeprom-0x57.bin {eeprom}',image],capture_output=True)
if not eeprom.exists(): eeprom.write_bytes(bytes([255])*32768)
before=output/'eeprom.before-local-ic'
if not before.exists():
shutil.copyfile(eeprom,before)
os.chmod(before,0o600)
if args.no_synthetic_provisioning:
saved=bytearray(eeprom.read_bytes())
record_start = 0x3c if hashlib.sha256(original).hexdigest() in SETUP_RECORD_GAMES else 0x40
saved[record_start:0x100]=before.read_bytes()[record_start:0x100]
eeprom.write_bytes(saved)
else:
eeprom.write_bytes(provision_eeprom(original,eeprom.read_bytes()))
manifest['game_sha256']=digest(game)
installs = [(game,'/games/pokemon_pro/game'),
(eeprom,'/connectivity/dump/spike-emu/eeprom-0x57.bin'),
(sources[0],'/usr/local/spike-emu/bin/emu-init'),
(sources[1],'/usr/local/spike-emu/bin/launch-target.sh'),
(output/'local_transport.so','/usr/local/spike-emu/ic/local_transport.so'),
(output/'eeprom_preload.so','/usr/local/spike-emu/lib/eeprom_preload.so'),
(output/'libspike3audio.so','/usr/local/spike-emu/lib/libspike3audio.so'),
(tls/'ca.pem','/usr/local/spike-emu/ic/ca.pem')]
for index,(original,target) in enumerate(installs):
payload=staging/f'payload{index}';shutil.copyfile(original,payload)
run([debugfs,'-w','-R',f'rm {target}',image],capture_output=True)
run([debugfs,'-w','-R',f'write {payload} {target}',image],capture_output=True)
mode = '0100600' if target.endswith('.bin') else ('0100644' if target.endswith('.pem') else '0100755')
run([debugfs,'-w','-R',f'set_inode_field {target} mode {mode}',image],capture_output=True)
verify=staging/f'verify{index}'
run([debugfs,'-R',f'dump {target} {verify}',image],capture_output=True)
if not verify.exists() or digest(verify)!=digest(original):
raise SystemExit(f'Generated helper verification failed: {target}')
manifest.update(source_image=str(source), helpers=hashes)
manifest_path.write_text(json.dumps(manifest,indent=2)+'\n')
config = output/'config.json'
if not config.exists():
config.write_text(json.dumps({'bind_host':'127.0.0.1','public_host':'10.0.2.100',
'auth':{'accept_any_credentials':False},'service':{'player_auth_enabled':False},
'urls':{'stern_cdn_url':'https://10.0.2.100/cdn/',
'stern_speed_test_url':'https://10.0.2.100/speed-test'}},indent=2)+'\n')
print(image)
if __name__ == '__main__': main()
@@ -0,0 +1,104 @@
"""Backend wire handlers. See the wiki for each contract's validation status."""
from __future__ import annotations
import copy
from typing import Any
from settings import utc_now
class ConagentProtocol:
def handle_conagent_api(
self,
path: str,
query: dict[str, list[str]],
body: dict[str, Any] | None,
) -> tuple[int, dict[str, Any]]:
service = self.state.config["service"]
custom = self.state.config.get("custom_responses", {}).get(path)
if isinstance(custom, dict):
return int(custom.get("status", 200)), custom.get("body", custom)
forced = int(service.get("force_http_status", 200))
if forced != 200:
return forced, {"status": "FORCED_HTTP_STATUS", "path": path}
normalized = path.rstrip("/")
if normalized == "/api/v1/ping":
return 200, {"result": "pong", "server_time": utc_now(), "online": self.state.config["machine"].get("online", True)}
if normalized == "/api/v1/game/version_upgrades_available":
upgrades = []
if service.get("updates_available", False):
upgrades.append({
"version": "0_84_0-emulator",
"url": self.state.api_base_url() + "/cdn/update/0_84_0-emulator.pkg",
"sha256": "0" * 64,
})
return 200, {"upgrades_available": bool(upgrades), "upgrades": upgrades}
if normalized == "/api/v2/game/heartbeat":
return 200, self.state.heartbeat()
if normalized == "/api/v3/game/game_register":
return 200, self.state.register_machine(body)
if normalized == "/api/v3/game/game_auth":
if not service.get('machine_enabled', True):
return 403, {'error': 'machine_not_enabled'}
return 200, self.state.game_auth_payload()
if normalized == "/api/v3/game/player_auth":
user_uuid = (body or {}).get("user_uuid") or query.get("user_uuid", [None])[0]
return self.state.player_auth(user_uuid)
if normalized == "/api/v3/game/game_achievement_descriptors":
# 00922650 requires this key; custom descriptor schemas are future work.
return 200, {"achievement_descriptors": [], "descriptor_version": "local-1"}
if normalized == "/api/v3/game/game_audit_descriptors":
# 006e07b0 accepts an array, including an empty descriptor set.
return 200, {"audit_descriptors": []}
if normalized == "/api/v2/game/game_property_descriptors":
return 200, {"property_descriptors": []}
if normalized == "/api/v2/game/player_properties":
user_uuids = (body or {}).get("user_uuids") or []
players = []
for user_uuid in user_uuids:
player = self.state.config.get("players", {}).get(user_uuid, {"user_uuid": user_uuid, "active": True})
players.append({"user_uuid": user_uuid, "properties": {"display_name": player.get("display_name", "Player"), "initials": player.get("initials", "PLY")}})
return 200, {"users": players, "player_properties": players}
if normalized == "/api/v2/game/player_achievements":
user_uuids = (body or {}).get("user_uuids") or []
return 200, {
"users": [
{
"user_uuid": user_uuid,
"achievements": [achievement["id"] for achievement in self.state.config.get("achievements", [])[:1]],
}
for user_uuid in user_uuids
]
}
if normalized == "/api/v3/game/session_start":
return 200, self.state.start_session(body)
if normalized == "/api/v3/game/session_update":
return 200, self.state.update_session(body)
if normalized == "/api/v3/game/session_end":
return 200, self.state.end_session(body)
if normalized == "/api/v1/game/high_score_events":
return 200, self.state.receive_event("high_score_events", body)
if normalized == "/api/v2/game/machine_audits":
self.state.update_score_audits()
return 200, {
"status": "OK",
"accepted": True,
"audits": copy.deepcopy(self.state.config["game_state"]["audits"]),
}
if normalized == "/api/v1/game/game_configuration":
return 200, {"data": {}}
if normalized == "/api/v1/game/data_store":
key = str((body or {}).get("key", "default"))
self.state.config["game_state"]["data_store"][key] = copy.deepcopy((body or {}).get("value"))
return 200, {"status": "OK", "stored": key}
if normalized == "/api/v1/game/data_retrieve":
key = str((body or {}).get("key", "default"))
return 200, {"status": "OK", "key": key, "value": copy.deepcopy(self.state.config["game_state"]["data_store"].get(key))}
if normalized == "/api/v1/game/payment_events":
return 200, self.state.receive_event("payment_events", body)
if normalized == "/api/v1/game/alert_events":
return 200, self.state.receive_event("alert_events", body)
return 501, {"error": "unsupported_endpoint", "method": self.command, "path": path}
@@ -0,0 +1,100 @@
"""Synthetic factory key for the exact generated SPIKE 3 assessment guest.
This is not production provisioning. Two literal comparisons accept only the
synthetic key's digest instead of the factory key's digest. All EEPROM decoding,
key derivation, bounds checks and network readiness/authentication remain real.
Pristine 0.83 and 0.85 profiles also write a local cabinet completion record, allowing the
genuine input gates to run without feature-getter or warning-renderer patches.
Only a generated image may receive these results. No original image is edited.
"""
import hashlib
import struct
import subprocess
import zlib
SUPPORTED_GAME = 'e8add023b60be684509f25cd54972981cd2adf15b0ad09d1e99ba33c29a4f54a'
SUPPORTED_083_SD = '1e5becb85840d0cc7a1d334b7cc43bdb83e51c7c7f8243bead8db679e9ffb22d'
MAC = bytes.fromhex('525400123456')
# No production secrets: a deterministic fixture for a local service only.
FACTORY_KEY = b''.join(hashlib.sha256(f'SPIKE3 LOCAL IC SYNTHETIC KEY {i}'.encode()).digest() for i in range(6))
FACTORY_CRC = zlib.crc32(hashlib.sha1(FACTORY_KEY).digest())
PATCHES = ((0x7e3f48, 2, bytes.fromhex('02249552a25bbe72')),
(0x7e4300, 1, bytes.fromhex('01249552a15bbe72')))
SUPPORTED_085 = 'ee75236ecf4ee3d42a50872439454753fde9e488e09bea9007432c34d67f0118'
SUPPORTED_085_LE = '5f8bd90b23c35d15252315b2003fb99d0c5950bca2017a085db49f7b52862293'
SETUP_RECORD_GAMES = (SUPPORTED_083_SD, SUPPORTED_085, SUPPORTED_085_LE)
def profile(game):
sha = hashlib.sha256(game).hexdigest()
if sha in (SUPPORTED_GAME, SUPPORTED_083_SD):
return PATCHES, (0x026ee0f0, 0x026fef10, 0x026fef20)
if sha == SUPPORTED_085:
# Same two SHA1/CRC comparison paths, verified in 0.85 disassembly.
return ((0x8b43a8, 2, PATCHES[0][2]), (0x8b4760, 1, PATCHES[1][2])), (0x0271d260, 0x0272f5b0, 0x0272f5c0)
if sha == SUPPORTED_085_LE:
# LE layout: both comparison paths and all three codec constants were
# checked against this exact binary, independently of the Pro layout.
return ((0x7a3348, 2, PATCHES[0][2]), (0x7a3700, 1, PATCHES[1][2])), (0x02716800, 0x02727020, 0x02727030)
raise ValueError('Unknown guest game: synthetic provisioning requires a documented exact SHA-256')
def file_bytes(game, ghidra_address, length):
"""Resolve the exact ELF load mapping, not an unchecked VA/file assumption."""
wanted = ghidra_address-0x100000
phoff = struct.unpack_from('<Q',game,32)[0]
size,count = struct.unpack_from('<HH',game,54)
for index in range(count):
kind,flags,offset,address,physical,filesz,memsz,align = struct.unpack_from('<IIQQQQQQ',game,phoff+index*size)
if kind == 1 and address <= wanted and wanted+length <= address+filesz:
return game[offset+wanted-address:offset+wanted-address+length]
raise ValueError('Factory codec constant is outside ELF load segments')
def eeprom_mask(game, serial=0):
# QEMU virt has no Pi serial-number property: the genuine reader yields 0.
identity=struct.pack('<QQ',serial,0)
salt=file_bytes(game,profile(game)[1][0],16)
digest=hashlib.sha1(bytes(a^b for a,b in zip(identity,salt))).digest()
state,sequence=struct.unpack('<QQ',digest[:16])
limit=(1<<64)-1
increment=((sequence<<1)|1)&limit
state=(state+increment)&limit
result=bytearray()
for _ in range(32768):
state=(increment+state*0x5851f42d4c957f2d)&limit
rotation=state>>59
value=((state^(state>>18))>>27)&0xffffffff
result.append(((value>>rotation)|(value<<((32-rotation)&31)))&255)
return result
def patch_game(game):
patches, _ = profile(game)
result=bytearray(game)
for offset,register,expected in patches:
if game[offset:offset+8]!=expected: raise ValueError('Factory comparison signature mismatch')
result[offset:offset+8]=struct.pack('<II',0x52800000|((FACTORY_CRC&0xffff)<<5)|register,
0x72a00000|((FACTORY_CRC>>16)<<5)|register)
return bytes(result)
def provision_eeprom(game,eeprom):
_, addresses = profile(game)
if len(eeprom)!=32768: raise ValueError('Expected 32768-byte EEPROM backing file')
salt=file_bytes(game,addresses[1],16)
iv=file_bytes(game,addresses[2],16)
key=hashlib.sha256(MAC+salt+bytes(16)).digest()[4:28]
encrypted=subprocess.run(['openssl','enc','-aes-192-ofb','-K',key.hex(),'-iv',iv.hex(),'-nopad'],
input=FACTORY_KEY,capture_output=True,check=True).stdout
if len(encrypted)!=192: raise ValueError('Factory encryption produced the wrong length')
mask=eeprom_mask(game)
result=bytearray(eeprom)
result[0x40:0x100]=bytes(a^b for a,b in zip(encrypted,mask[0x40:0x100]))
if hashlib.sha256(game).hexdigest() in SETUP_RECORD_GAMES:
# Local cabinet fixture: genuine 0.85 Pro reader at 0x62a0e0 validates
# this marker and complemented 16-bit byte sum at EEPROM 0x3c.
# Pristine 0.83 reader/writer: 0x79dc90 / 0x79dc30.
# LE 0.85 reader is at VA 0x8929d0; writer 0x892970 and checksum
# helper 0x6f0690 confirm the same marker and byte-sum encoding.
# Its absence sets feature 3, suppressing Start and service inputs.
marker = bytes((0x5a, 0xa5))
record = marker + struct.pack('<H', (~sum(marker)) & 0xffff)
result[0x3c:0x40] = bytes(a ^ b for a, b in zip(record, mask[0x3c:0x40]))
return bytes(result)
@@ -0,0 +1,365 @@
"""Synthetic machine, session, and player behavior."""
from __future__ import annotations
import copy
import json
import sys
import threading
import uuid
from dataclasses import dataclass, field
from typing import Any
from settings import DEFAULT_CONFIG, TIME_PATH, MAX_LOGS, utc_now, deep_merge, redact
from store import StateStore
@dataclass
class EmulatorState:
config: dict[str, Any]
lock: threading.RLock = field(default_factory=threading.RLock)
requests: list[dict[str, Any]] = field(default_factory=list)
message_number: int = 1
store: StateStore | None = None
request_log: str | None = None
@classmethod
def from_config(cls, config: dict[str, Any], database: str | None = None) -> "EmulatorState":
merged = copy.deepcopy(DEFAULT_CONFIG)
deep_merge(merged, config)
state = cls(config=merged)
if database:
state.store = StateStore(database)
saved = state.store.load()
if saved:
deep_merge(state.config, saved['config'])
state.message_number = saved['message_number']
return state
def persist(self):
if self.store:
self.store.save(self.config, self.message_number)
def snapshot(self) -> dict[str, Any]:
with self.lock:
snap = copy.deepcopy(self.config)
snap["message_number"] = self.message_number
snap["urls"]["api_base_url"] = self.api_base_url()
snap["urls"]["time_url"] = self.time_url()
return redact(snap)
def patch(self, patch: dict[str, Any]) -> dict[str, Any]:
with self.lock:
allowed = {"machine", "urls", "auth", "service", "players", "achievements", "game_state", "custom_responses", "scenarios", "properties"}
clean = {key: value for key, value in patch.items() if key in allowed}
if "scenarios" in clean:
scenarios = clean.pop("scenarios")
if not isinstance(scenarios, dict) or any(not isinstance(v, dict) for v in scenarios.values()):
raise ValueError("scenarios must map paths to objects")
for scenario in scenarios.values():
delay = scenario.get("delay_ms", 0)
if not isinstance(delay, (int, float)) or not 0 <= delay <= 300000:
raise ValueError("delay_ms must be between 0 and 300000")
rejected = scenario.get("reject_status", 503)
if not isinstance(rejected, int) or not 400 <= rejected <= 599:
raise ValueError("reject_status must be an HTTP failure status")
self.config["scenarios"] = copy.deepcopy(scenarios)
deep_merge(self.config, clean)
return self.snapshot()
def reset(self) -> dict[str, Any]:
with self.lock:
public_host = self.config.get("public_host", DEFAULT_CONFIG["public_host"])
bind_host = self.config.get("bind_host", DEFAULT_CONFIG["bind_host"])
api_port = self.config.get("api_port", DEFAULT_CONFIG["api_port"])
time_port = self.config.get("time_port", DEFAULT_CONFIG["time_port"])
self.config = copy.deepcopy(DEFAULT_CONFIG)
self.config["public_host"] = public_host
self.config["bind_host"] = bind_host
self.config["api_port"] = api_port
self.config["time_port"] = time_port
self.requests.clear()
self.message_number = 1
return self.snapshot()
def next_message_number(self) -> int:
with self.lock:
self.message_number += 1
return self.message_number
def log_request(self, entry: dict[str, Any]) -> None:
with self.lock:
entry = redact(entry)
self.requests.append(entry)
del self.requests[:-MAX_LOGS]
if self.request_log:
with open(self.request_log, 'a', encoding='utf-8') as stream:
stream.write(json.dumps(entry, sort_keys=True) + '\n')
def api_base_url(self) -> str:
scheme = "https" if self.config.get("_api_tls_enabled") else "http"
return f"{scheme}://{self.config['public_host']}:{self.config['api_port']}"
def time_url(self) -> str:
return f"http://{self.config['public_host']}:{self.config['time_port']}{TIME_PATH}"
def response_headers(self) -> dict[str, str]:
return {
"Message-Number": str(self.next_message_number()),
"App-Status-Code": str(self.config.get("service", {}).get("app_status_code", 0)),
"Device-Token": str(self.config['auth']['device_token']),
"Device-Refresh": str(self.config['auth']['device_refresh']),
"Cache-Control": "no-store",
}
def register_machine(self, body: dict[str, Any] | None = None) -> dict[str, Any]:
with self.lock:
machine = self.config["machine"]
if body:
for key in (
"serial_number",
"machine_configuration_number",
"model_number",
"model_version",
"code_version",
"game_title",
"game_model",
"mac_address",
"internal_ip_address",
):
if body.get(key):
machine[key] = body[key]
if not machine.get("machine_uuid"):
machine["machine_uuid"] = str(uuid.uuid4())
machine["registered"] = True
machine["last_registration"] = utc_now()
return self.game_auth_payload()
def heartbeat(self) -> dict[str, Any]:
with self.lock:
self.config["machine"]["last_heartbeat"] = utc_now()
return {
"ok": True,
"online": self.config["machine"].get("online", True),
"machine_uuid": self.config["machine"].get("machine_uuid"),
"server_time": utc_now(),
}
def player_auth(self, user_uuid: str | None) -> tuple[int, dict[str, Any]]:
with self.lock:
if not self.config["service"].get("player_auth_enabled", True):
return 403, {"authenticated": False, "status": "USER_NOT_ACTIVE"}
if not user_uuid:
user_uuid = "00000000-0000-4000-8000-00000000c0de"
player = self.config["players"].get(user_uuid)
if player is None:
player = {
"user_uuid": user_uuid,
"initials": "NEW",
"display_name": "Unregistered Emulator Player",
"active": True,
}
self.config["players"][user_uuid] = player
self.config["machine"]["last_player_uuid"] = user_uuid
if not player.get("active", True):
return 403, {"authenticated": False, "status": "USER_NOT_ACTIVE", "user_uuid": user_uuid}
return 200, {"authenticated": True, "status": "OK", "user_uuid": user_uuid, "player": player}
def game_auth_payload(self) -> dict[str, Any]:
urls = self.config["urls"]
auth = self.config["auth"]
machine = self.config["machine"]
service = self.config["service"]
status = "OK" if service.get("machine_enabled", True) else "MACHINE_NOT_ENABLED"
return {
"success": service.get("machine_enabled", True),
"authorized": service.get("machine_enabled", True),
"message": "Local synthetic machine authenticated",
"machine": self.machine_properties(),
"stern_cdn_auth_key": "local-cdn-key",
"stern_media_url": urls.get("stern_cdn_url"),
"stern_media_auth_key": "local-media-key",
"status": status,
"machine_enabled": service.get("machine_enabled", True),
"machine_uuid": machine.get("machine_uuid"),
"serial_number": machine.get("serial_number"),
"stern_cdn_url": urls.get("stern_cdn_url"),
"stern_speed_test_url": urls.get("stern_speed_test_url"),
"device_token": auth.get("device_token"),
"device_refresh": auth.get("device_refresh"),
"server_time": utc_now(),
}
def machine_properties(self) -> dict[str, Any]:
"""Game parser 006cea50 and its nested model/location readers."""
machine = self.config["machine"]
properties = {
"uid": machine["machine_uuid"],
"game_model": {"number": machine.get("game_number", "AG"),
"title": {"descriptor_version": ""}},
"location": {},
"registered_to": "Local Emulator",
"claimed": True,
"enabled": self.config["service"].get("machine_enabled", True),
"online": machine.get("online", True),
"archived": False,
"is_at_verified_location": False,
"enable_cloudwatch_logging": False,
}
deep_merge(properties, self.config.get("properties", {}))
return properties
def receive_event(self, kind: str, body: dict[str, Any] | None) -> dict[str, Any]:
"""Persist an observed game event without fabricating scores or awards."""
with self.lock:
events = self.config["game_state"].setdefault("received_events", [])
events.append({"kind": kind, "received_at": utc_now(), "payload": copy.deepcopy(body or {})})
del events[:-500]
return {"success": True, "accepted": True}
def ingest_game_events(self, body: dict[str, Any] | None) -> dict[str, Any]:
"""Observed 00926c60 request; retain real event batches for future rules."""
body = body or {}
metadata = body.get("metadata")
if not isinstance(metadata, dict) or not isinstance(body.get("events"), list):
raise ValueError("Game events require metadata and an events array")
session = str(uuid.UUID(str(metadata.get("session_uuid", ""))))
sequence = body.get("opaque_seq")
if not isinstance(sequence, int) or sequence < 0:
raise ValueError("Game events require a nonnegative opaque_seq")
inserted = self.store.ingest(session, sequence, body) if self.store else True
if inserted:
self.receive_event("game_events", body)
return {"success": True, "accepted": True, "opaque_seq": sequence}
def add_score(self, player: int, delta: int) -> dict[str, Any]:
if player < 1 or player > 4:
raise ValueError("player must be 1..4")
with self.lock:
scores = self.config["game_state"]["player_scores"]
key = str(player)
scores[key] = max(0, int(scores.get(key, 0)) + int(delta))
self.config["game_state"]["current_player"] = player
event = {
"timestamp": utc_now(),
"type": "score",
"player": player,
"delta": int(delta),
"score": scores[key],
}
self.config["game_state"]["events"].append(event)
del self.config["game_state"]["events"][:-100]
self.update_score_audits()
return copy.deepcopy(self.config["game_state"])
def update_score_audits(self) -> None:
game_state = self.config["game_state"]
scores = [int(score) for score in game_state["player_scores"].values()]
played_scores = [score for score in scores if score > 0]
if played_scores:
game_state["audits"]["AUD_AVERAGE_SCORES"] = sum(played_scores) // len(played_scores)
buckets = [
(0, 999999),
(1000000, 1999999),
(2000000, 4999999),
(5000000, 9999999),
(10000000, 14999999),
(15000000, 19999999),
(20000000, 24999999),
(25000000, 29999999),
(30000000, 34999999),
(35000000, 39999999),
(40000000, 49999999),
(50000000, 59999999),
(60000000, 79999999),
(80000000, 99999999),
(100000000, 199999999),
(200000000, 299999999),
(300000000, sys.maxsize),
]
for idx, (low, high) in enumerate(buckets, start=1):
game_state["audits"][f"AUD_SCORE_HISTO_{idx}"] = sum(1 for score in played_scores if low <= score <= high)
def ensure_session(self) -> str:
game_state = self.config["game_state"]
if not game_state.get("session_uuid"):
game_state["session_uuid"] = str(uuid.uuid4())
return game_state["session_uuid"]
def session_payload(self) -> dict[str, Any]:
game_state = self.config["game_state"]
machine = self.config["machine"]
return {
"session_uuid": self.ensure_session(),
"session_id": game_state.get("session_id", 0),
"descriptor_version": "local-1",
"machine_uuid": machine.get("machine_uuid"),
"ball": game_state.get("ball", 1),
"current_player": game_state.get("current_player", 1),
"player_scores": copy.deepcopy(game_state["player_scores"]),
"active": game_state.get("active", False),
}
def start_session(self, body: dict[str, Any] | None = None) -> dict[str, Any]:
with self.lock:
game_state = self.config["game_state"]
requested = (body or {}).get("session_uuid")
session_uuid = str(uuid.UUID(str(requested))) if requested else str(uuid.uuid4())
sessions = game_state.setdefault("sessions", {})
if session_uuid not in sessions:
session_id = int(game_state.get("next_session_id", 1))
game_state["next_session_id"] = session_id + 1
sessions[session_uuid] = {"session_id": session_id, "request": copy.deepcopy(body or {}), "started_at": utc_now()}
game_state["active"] = True
game_state["session_uuid"] = session_uuid
game_state["session_id"] = sessions[session_uuid]["session_id"]
game_state["ball"] = 1
game_state["started_at"] = utc_now()
game_state["ended_at"] = ""
game_state["events"].append({"timestamp": utc_now(), "type": "session_start", "session_uuid": game_state["session_uuid"]})
del game_state["events"][:-100]
return {"status": "OK", **self.session_payload()}
def update_session(self, body: dict[str, Any] | None = None) -> dict[str, Any]:
with self.lock:
game_state = self.config["game_state"]
if not game_state.get("active"):
self.start_session()
if body and isinstance(body.get("player_scores"), dict):
for key, value in body["player_scores"].items():
if str(key) in game_state["player_scores"]:
game_state["player_scores"][str(key)] = max(0, int(value))
game_state["updated_at"] = utc_now()
game_state["events"].append({"timestamp": utc_now(), "type": "session_update", "session_uuid": game_state["session_uuid"]})
del game_state["events"][:-100]
self.update_score_audits()
return {"status": "OK", **self.session_payload()}
def end_session(self, body: dict[str, Any] | None = None) -> dict[str, Any]:
with self.lock:
if body:
self.update_session(body)
game_state = self.config["game_state"]
game_state["active"] = False
game_state["ended_at"] = utc_now()
session = game_state.get("sessions", {}).get(game_state.get("session_uuid"))
if session is not None:
session["ended_at"] = game_state["ended_at"]
session["final_request"] = copy.deepcopy(body or {})
self.update_high_scores()
game_state["events"].append({"timestamp": utc_now(), "type": "session_end", "session_uuid": game_state["session_uuid"]})
del game_state["events"][:-100]
return {"status": "OK", **self.session_payload(), "high_scores": copy.deepcopy(game_state["high_scores"])}
def update_high_scores(self) -> None:
game_state = self.config["game_state"]
initials_by_player = {"1": "P1", "2": "P2", "3": "P3", "4": "P4"}
rows = copy.deepcopy(game_state["high_scores"])
for player, score in game_state["player_scores"].items():
rows.append({"rank": 0, "initials": initials_by_player.get(player, "PLY"), "score": int(score)})
rows = sorted(rows, key=lambda row: int(row.get("score", 0)), reverse=True)[:4]
for idx, row in enumerate(rows, start=1):
row["rank"] = idx
game_state["high_scores"] = rows
game_state["audits"]["AUD_TOTAL_HIGH_SCORE_AWARDS"] = sum(
1 for score in game_state["player_scores"].values() if any(int(score) >= int(row["score"]) for row in rows)
)
@@ -0,0 +1,277 @@
"""Configuration and shared protocol utilities."""
from __future__ import annotations
import datetime as dt
import json
from typing import Any
TIME_PATH = "/b8Ag3XU6TH/wibbly_wobbly_timey_wimey/"
MAX_LOGS = 500
REDACT_KEYS = {
"password",
"token",
"api_token",
"device_token",
"device_refresh",
"authorization",
"secret",
"secret_key",
"stern_cdn_auth_key",
"stern_media_auth_key",
"access_key",
"account_number_64",
"serial_number",
"mac_address",
}
DEFAULT_CONFIG: dict[str, Any] = {
"bind_host": "127.0.0.1",
"api_port": 8088,
"time_port": 8345,
"public_host": "127.0.0.1",
"machine": {
"serial_number": "SPIKE3-EMU-0001",
"machine_uuid": "00000000-0000-4000-8000-000000000001",
"machine_configuration_number": "pokemon_pro",
"model_number": "Pokemon Pro",
"model_version": "0.83.0",
"code_version": "0_83_0",
"game_title": "pokemon_pro",
"game_number": "AG",
"game_model": "pro",
"mac_address": "02:00:00:00:00:03",
"internal_ip_address": "192.0.2.33",
"registered": True,
"online": True,
},
"urls": {
"stern_cdn_url": "http://127.0.0.1:8088/cdn/",
"stern_speed_test_url": "http://127.0.0.1:8088/speed-test",
},
"auth": {
"api_username": "emu",
"api_password": "emu",
"api_token": "emu-api-token",
"device_token": "emu-device-token",
"device_refresh": "emu-device-refresh",
"accept_any_credentials": False,
},
"properties": {},
"service": {
"machine_enabled": True,
"player_auth_enabled": False,
"updates_available": False,
"force_http_status": 200,
"app_status_code": 0,
},
"players": {
"00000000-0000-4000-8000-00000000c0de": {
"user_uuid": "00000000-0000-4000-8000-00000000c0de",
"initials": "EMU",
"display_name": "Emulated Player",
"active": True,
}
},
"achievements": [
{
"id": "emu-first-connect",
"name": "First Connect",
"description": "Emulator descriptor returned by the local backend.",
"points": 10,
},
{
"id": "emu-playfield-ready",
"name": "Playfield Ready",
"description": "Synthetic achievement for conagent response testing.",
"points": 25,
},
],
"game_state": {
"active": False,
"session_uuid": "",
"ball": 1,
"current_player": 1,
"player_scores": {
"1": 0,
"2": 0,
"3": 0,
"4": 0,
},
"high_scores": [
{"rank": 1, "initials": "EMU", "score": 50000000},
{"rank": 2, "initials": "AAA", "score": 25000000},
{"rank": 3, "initials": "BBB", "score": 10000000},
{"rank": 4, "initials": "CCC", "score": 5000000},
],
"audits": {
"AUD_SCORE_HISTO_1": 0,
"AUD_AVERAGE_SCORES": 0,
"AUD_TOTAL_HIGH_SCORE_AWARDS": 0,
"AUD_CONNECTIVITY_ACHIEVEMENTS_GIVEN": 0,
"AUD_CONNECTIVITY_NET_CONNECTION_SERVER_OFFLINE": 0,
},
"events": [],
"data_store": {},
},
"custom_responses": {},
}
GAME_CALL_CATALOG: list[dict[str, Any]] = [
{
"id": "token_auth",
"label": "API token auth",
"method": "POST",
"path": "/api-token-auth/",
"evidence": "conagent strings: https://%s/api-token-auth/ and username/password JSON",
},
{
"id": "ping",
"label": "Ping backend",
"method": "GET",
"path": "/api/v1/ping",
"evidence": "conagent string /api/v1/ping",
},
{
"id": "game_auth",
"label": "Game auth",
"method": "POST",
"path": "/api/v3/game/game_auth",
"evidence": "conagent string /api/v3/game/game_auth",
},
{
"id": "game_register",
"label": "Machine registration",
"method": "POST",
"path": "/api/v3/game/game_register",
"evidence": "conagent string /api/v3/game/game_register",
},
{
"id": "achievement_descriptors",
"label": "Achievement descriptors",
"method": "GET",
"path": "/api/v3/game/game_achievement_descriptors",
"evidence": "game string: Requesting achievement descriptors; conagent endpoint string",
},
{
"id": "server_time",
"label": "Server time",
"method": "GET",
"path": TIME_PATH,
"evidence": "game string QERT_TIME_QUERY; conagent plain HTTP time URL",
},
{
"id": "player_auth",
"label": "Authenticate player",
"method": "POST",
"path": "/api/v3/game/player_auth",
"evidence": "game string PLAYER_AUTHENTICATE and user_uuid",
},
{
"id": "retrieve_player_properties",
"label": "Retrieve player properties",
"method": "POST",
"path": "/api/v2/game/player_properties",
"evidence": "game strings QET_RETRIEVE_PLAYER_PROPERTIES and /api/v2/game/player_properties",
},
{
"id": "retrieve_player_achievements",
"label": "Retrieve player achievements",
"method": "POST",
"path": "/api/v2/game/player_achievements",
"evidence": "game string QET_RETRIEVE_PLAYER_ACHIEVEMENTS and /api/v2/game/player_achievements",
},
{
"id": "session_start",
"label": "Game session begin",
"method": "POST",
"path": "/api/v3/game/session_start",
"evidence": "game strings GameSessionBegin and /api/v3/game/session_start",
},
{
"id": "session_update",
"label": "Game session update",
"method": "POST",
"path": "/api/v3/game/session_update",
"evidence": "game strings SESSION_UPDATE and /api/v3/game/session_update",
},
{
"id": "session_end",
"label": "Game session end",
"method": "POST",
"path": "/api/v3/game/session_end",
"evidence": "game strings SESSION_END and /api/v3/game/session_end",
},
{
"id": "high_score_event",
"label": "High score event",
"method": "POST",
"path": "/api/v1/game/high_score_events",
"evidence": "game string /api/v1/game/high_score_events and high score labels",
},
{
"id": "audit_report",
"label": "Machine audits",
"method": "POST",
"path": "/api/v2/game/machine_audits",
"evidence": "game strings QET_AUDIT_REPORT and /api/v2/game/machine_audits",
},
{
"id": "game_configuration",
"label": "Game configuration",
"method": "GET",
"path": "/api/v1/game/game_configuration",
"evidence": "game strings QET_QUERY_GAME_CONFIGURATION and /api/v1/game/game_configuration",
},
{
"id": "data_store",
"label": "Data store",
"method": "POST",
"path": "/api/v1/game/data_store",
"evidence": "game strings QET_DATA_STORE and /api/v1/game/data_store",
},
{
"id": "data_retrieve",
"label": "Data retrieve",
"method": "POST",
"path": "/api/v1/game/data_retrieve",
"evidence": "game strings QET_DATA_RETRIEVE and /api/v1/game/data_retrieve",
},
]
def utc_now() -> str:
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def deep_merge(dst: dict[str, Any], src: dict[str, Any]) -> dict[str, Any]:
for key, value in src.items():
if isinstance(value, dict) and isinstance(dst.get(key), dict):
deep_merge(dst[key], value)
else:
dst[key] = value
return dst
def redact(value: Any) -> Any:
if isinstance(value, dict):
out = {}
for key, item in value.items():
if key.lower().replace('-', '_') in REDACT_KEYS:
out[key] = "<redacted>"
else:
out[key] = redact(item)
return out
if isinstance(value, list):
return [redact(item) for item in value]
return value
def read_json_file(path: str) -> dict[str, Any]:
with open(path, "r", encoding="utf-8") as fp:
data = json.load(fp)
if not isinstance(data, dict):
raise ValueError(f"{path} must contain a JSON object")
return data
@@ -0,0 +1,41 @@
"""Transactional persistence for mock service state, separate from game NVRAM."""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
PERSISTENT_KEYS = ('machine', 'players', 'achievements', 'game_state', 'properties')
class StateStore:
def __init__(self, path: str):
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self.connection = sqlite3.connect(self.path, check_same_thread=False)
self.connection.execute('PRAGMA journal_mode=WAL')
self.connection.execute('CREATE TABLE IF NOT EXISTS state (id INTEGER PRIMARY KEY CHECK(id=1), schema INTEGER NOT NULL, payload TEXT NOT NULL)')
self.connection.execute('CREATE TABLE IF NOT EXISTS game_events (session TEXT NOT NULL, sequence INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(session,sequence))')
self.connection.commit()
def load(self):
row = self.connection.execute('SELECT schema,payload FROM state WHERE id=1').fetchone()
if row is None:
return None
if row[0] != 1:
raise ValueError('Unsupported IC state schema')
return json.loads(row[1])
def save(self, config, message_number):
payload = {'config': {key: config[key] for key in PERSISTENT_KEYS if key in config},
'message_number': message_number}
with self.connection:
self.connection.execute('INSERT INTO state VALUES(1,1,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload',
(json.dumps(payload, sort_keys=True),))
def close(self):
self.connection.close()
def ingest(self, session, sequence, payload):
with self.connection:
cursor = self.connection.execute('INSERT OR IGNORE INTO game_events VALUES(?,?,?)',
(session, sequence, json.dumps(payload, sort_keys=True)))
return cursor.rowcount == 1
@@ -0,0 +1,75 @@
"""Exact-binary provisioning checks; skip only when evidence is unavailable."""
import hashlib
import os
from pathlib import Path
import struct
import subprocess
import unittest
import zlib
from provisioning import (SUPPORTED_GAME, SUPPORTED_083_SD, SETUP_RECORD_GAMES, FACTORY_KEY, FACTORY_CRC, MAC,
eeprom_mask, file_bytes, patch_game, provision_eeprom, profile)
GAME = Path(__file__).resolve().parents[1]/'work/startup-ic/guest-game'
class ProvisioningTests(unittest.TestCase):
def test_unknown_binary_is_rejected(self):
with self.assertRaises(ValueError): patch_game(b'unknown')
with self.assertRaises(ValueError): provision_eeprom(b'unknown',bytes(32768))
def test_exact_patch_and_round_trip(self):
path = Path(os.environ.get('SPIKE3_PROVISIONING_TEST_GAME', str(GAME)))
if not path.exists(): self.skipTest('Exact generated evidence game is not available')
game=path.read_bytes()
self.assertIn(hashlib.sha256(game).hexdigest(), (SUPPORTED_GAME, SUPPORTED_083_SD))
patched=patch_game(game)
changed={i for i,(a,b) in enumerate(zip(game,patched)) if a!=b}
self.assertTrue(changed)
self.assertTrue(changed<=set(range(0x7e3f48,0x7e3f50))|set(range(0x7e4300,0x7e4308)))
# Decode the two AArch64 constants to verify they retain comparison
# instructions and select the synthetic key digest, not unconditional success.
for offset in (0x7e3f48,0x7e4300):
low,high=struct.unpack_from('<II',patched,offset)
self.assertEqual(((low>>5)&65535)|(((high>>5)&65535)<<16),FACTORY_CRC)
original=bytes(i%256 for i in range(32768))
encoded=provision_eeprom(game,original)
start=60 if hashlib.sha256(game).hexdigest() in SETUP_RECORD_GAMES else 64
self.assertEqual(encoded[:start],original[:start])
if start==60:
marker=bytes(a^b for a,b in zip(encoded[60:64],eeprom_mask(game)[60:64]))
self.assertEqual(marker,bytes.fromhex('5aa500ff'))
self.assertEqual(encoded[256:],original[256:])
mask=eeprom_mask(game)
ciphertext=bytes(a^b for a,b in zip(encoded[64:256],mask[64:256]))
key=hashlib.sha256(MAC+file_bytes(game,0x026fef10,16)+bytes(16)).digest()[4:28]
iv=file_bytes(game,0x026fef20,16)
plaintext=subprocess.run(['openssl','enc','-d','-aes-192-ofb','-K',key.hex(),'-iv',iv.hex(),'-nopad'],input=ciphertext,capture_output=True,check=True).stdout
self.assertEqual(plaintext,FACTORY_KEY)
self.assertEqual(zlib.crc32(hashlib.sha1(plaintext).digest()),FACTORY_CRC)
corrupted=bytes([plaintext[0]^1])+plaintext[1:]
self.assertNotEqual(zlib.crc32(hashlib.sha1(corrupted).digest()),FACTORY_CRC)
def test_085_exact_patch_and_encrypted_record(self):
default=Path(__file__).resolve().parents[1]/'library/releases/pokemon-pro-0.85.0-update/packages/pokemon_pro/files/pokemon_pro/game'
path=Path(os.environ.get('SPIKE3_PROVISIONING_TEST_GAME_085',str(default)))
if not path.exists(): self.skipTest('0.85 release not imported')
game=path.read_bytes();patches,addresses=profile(game)
patched=patch_game(game)
changed={i for i,(a,b) in enumerate(zip(game,patched)) if a!=b}
allowed={i for offset,_,_ in patches for i in range(offset,offset+8)}
self.assertTrue(changed);self.assertTrue(changed<=allowed)
for offset,_,_ in patches:
low,high=struct.unpack_from('<II',patched,offset)
self.assertEqual(((low>>5)&65535)|(((high>>5)&65535)<<16),FACTORY_CRC)
original=bytes(i%256 for i in range(32768));encoded=provision_eeprom(game,original)
self.assertEqual(encoded[:60],original[:60]);self.assertEqual(encoded[256:],original[256:])
decoded_marker=bytes(a^b for a,b in zip(encoded[60:64],eeprom_mask(game)[60:64]))
self.assertEqual(decoded_marker[:2],bytes((0x5a,0xa5)))
self.assertEqual(struct.unpack('<H',decoded_marker[2:])[0],(~sum(decoded_marker[:2]))&65535)
self.assertEqual(provision_eeprom(game,encoded),encoded)
mask=eeprom_mask(game);ciphertext=bytes(a^b for a,b in zip(encoded[64:256],mask[64:256]))
key=hashlib.sha256(MAC+file_bytes(game,addresses[1],16)+bytes(16)).digest()[4:28]
iv=file_bytes(game,addresses[2],16)
decoded=subprocess.run(['openssl','enc','-d','-aes-192-ofb','-K',key.hex(),'-iv',iv.hex(),'-nopad'],input=ciphertext,capture_output=True,check=True).stdout
self.assertEqual(decoded,FACTORY_KEY)
if __name__=='__main__': unittest.main()
@@ -0,0 +1,173 @@
"""Wire failure and durability tests; these do not claim game parser acceptance."""
import concurrent.futures
import http.client
import json
from pathlib import Path
import tempfile
import threading
import time
import unittest
from emulator import make_server
from service import EmulatorState
from settings import TIME_PATH, redact
class ServiceTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.database = str(Path(self.directory.name) / "state.sqlite")
self.state = EmulatorState.from_config({"auth": {"accept_any_credentials": False}}, self.database)
self.server = make_server("127.0.0.1", 0, self.state)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join()
self.state.store.close()
self.directory.cleanup()
def request(self, path, body=None, method="GET"):
conn = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=3)
data = json.dumps(body) if isinstance(body, dict) else body
conn.request(method, path, body=data, headers={"Content-Type": "application/json", "Authorization": "Token emu-api-token"})
response = conn.getresponse()
result = (response.status, dict(response.getheaders()), response.read())
conn.close()
return result
def test_authentication_rejection_and_success(self):
self.assertEqual(self.request("/api-token-auth/", {"username":"emu", "password":"wrong"}, "POST")[0], 403)
status, headers, body = self.request("/api-token-auth/", {"username":"emu", "password":"emu"}, "POST")
self.assertEqual(status, 200)
self.assertEqual(json.loads(body)["token"], "emu-api-token")
self.assertEqual(headers["Device-Token"], "emu-device-token")
def test_guest_default_and_unknown_endpoint(self):
self.assertFalse(self.state.config["service"]["player_auth_enabled"])
status, _, body = self.request("/api/v999/unknown")
self.assertEqual(status, 501)
self.assertEqual(json.loads(body)["error"], "unsupported_endpoint")
self.assertEqual(self.request("/cdn/missing")[0], 501)
def test_startup_descriptor_contracts(self):
self.assertEqual(json.loads(self.request("/api/v3/game/game_achievement_descriptors/")[2]),
{"achievement_descriptors": [], "descriptor_version": "local-1"})
self.assertEqual(json.loads(self.request("/api/v3/game/game_audit_descriptors/")[2]),
{"audit_descriptors": []})
self.assertEqual(json.loads(self.request("/api/v1/game/game_configuration/")[2]), {"data": {}})
def test_observed_game_events_are_persisted_without_synthetic_scores(self):
event={"standard_scores":[{"name":"SYN", "score":"12345"}]}
self.assertEqual(self.request("/api/v1/game/high_score_events/",event,"POST")[0],200)
payment={"events":[{"event":"players_are_set", "num_players":"2"}]}
self.assertEqual(self.request("/api/v1/game/payment_events/",payment,"POST")[0],200)
second=EmulatorState.from_config({},self.database)
try:
recorded=second.config["game_state"]["received_events"][-2]
self.assertEqual(recorded["payload"],event)
self.assertEqual(second.config["game_state"]["received_events"][-1]["payload"],payment)
self.assertEqual(second.config["game_state"]["player_scores"]["1"],0)
finally:second.store.close()
def test_real_event_batch_is_durable_and_idempotent(self):
body={"metadata":{"session_uuid":"00000000-0000-4000-8000-00000000beef","ball_number":1},
"opaque_seq":2,"events":[{"evt":"SYS_BALL_END","score":53990}]}
path="/ingest/v1/game/game_events/"
for _ in range(2):self.assertEqual(self.request(path,body,"POST")[0],200)
second=EmulatorState.from_config({},self.database)
try:
rows=second.store.connection.execute("SELECT payload FROM game_events").fetchall()
self.assertEqual(len(rows),1)
self.assertEqual(json.loads(rows[0][0]),body)
finally:second.store.close()
self.assertEqual(self.request(path,{"events":[]},"POST")[0],400)
def test_session_id_matches_requested_uuid_and_survives_restart(self):
body={"session_uuid":"00000000-0000-4000-8000-00000000abcd"}
path="/api/v3/game/session_start/"
first=json.loads(self.request(path,body,"POST")[2])
second=json.loads(self.request(path,body,"POST")[2])
self.assertGreater(first["session_id"],0)
self.assertEqual(first["session_uuid"],body["session_uuid"])
self.assertEqual(first["session_id"],second["session_id"])
restored=EmulatorState.from_config({},self.database)
try:self.assertEqual(restored.start_session(body)["session_id"],first["session_id"])
finally:restored.store.close()
def test_backend_requires_the_issued_api_token(self):
conn=http.client.HTTPConnection("127.0.0.1",self.server.server_port)
conn.request("GET","/api/v3/game/game_auth/",headers={"Authorization":"Token wrong"})
response=conn.getresponse()
self.assertEqual(response.status,403)
self.assertEqual(json.loads(response.read())["error"],"invalid_api_token")
conn.close()
def test_time_and_heartbeat(self):
self.assertEqual(self.request(TIME_PATH)[0], 200)
self.assertEqual(self.request("/api/v2/game/heartbeat/")[0], 200)
self.assertTrue(self.state.config["machine"]["last_heartbeat"])
def test_faults_do_not_record_successful_heartbeat(self):
path = "/api/v2/game/heartbeat"
for scenario, status in [({"unavailable":True},503), ({"reject_status":403},403), ({"malformed":True},200)]:
self.state.config["scenarios"] = {path:scenario}
response, _, body = self.request(path)
self.assertEqual(response, status)
self.assertFalse(self.state.config["machine"].get("last_heartbeat"))
if scenario.get("malformed"):
with self.assertRaises(json.JSONDecodeError): json.loads(body)
def test_missing_property_scenario(self):
path = "/api/v3/game/game_auth"
self.state.config["scenarios"] = {path:{"omit_keys":["machine_uuid", "stern_cdn_url"]}}
payload = json.loads(self.request(path)[2])
self.assertNotIn("machine_uuid", payload)
self.assertNotIn("stern_cdn_url", payload)
def test_health_remains_available_during_request_delay(self):
path = "/api/v2/game/heartbeat"
self.state.config["scenarios"] = {path:{"delay_ms":400}}
with concurrent.futures.ThreadPoolExecutor() as pool:
future = pool.submit(self.request, path)
time.sleep(.05)
start = time.monotonic()
self.assertTrue(json.loads(self.request("/healthz")[2])["ready"])
self.assertLess(time.monotonic()-start, .25)
self.assertEqual(future.result()[0], 200)
def test_state_survives_service_recreation(self):
self.request("/api/v3/game/game_register", {"serial_number":"synthetic-test"}, "POST")
self.request("/api/v1/game/data_store", {"key":"extension", "value":{"score":42}}, "POST")
self.request("/api/v3/game/session_start", {}, "POST")
second = EmulatorState.from_config({}, self.database)
try:
self.assertEqual(second.config["machine"]["serial_number"], "synthetic-test")
self.assertEqual(second.config["game_state"]["data_store"]["extension"], {"score":42})
self.assertEqual(second.config["game_state"]["session_uuid"], self.state.config["game_state"]["session_uuid"])
finally: second.store.close()
def test_invalid_json_and_port_collision(self):
for body in ("{", "[]", "null"):
self.assertEqual(self.request("/api-token-auth/", body, "POST")[0], 400)
with self.assertRaises(OSError): make_server("127.0.0.1", self.server.server_port, self.state)
def test_scenarios_can_be_cleared_and_machine_properties_match_parser(self):
self.state.patch({"scenarios":{"/api/v2/game/heartbeat":{"unavailable":True}}})
self.assertEqual(self.request("/api/v2/game/heartbeat")[0],503)
self.state.patch({"scenarios":{}})
self.assertEqual(self.request("/api/v2/game/heartbeat")[0],200)
data=json.loads(self.request("/api/v3/game/game_auth")[2])
self.assertTrue(data["success"] and data["authorized"])
self.assertEqual(data["machine"]["game_model"]["number"],"AG")
self.assertTrue(data["machine"]["enabled"])
self.state.config["service"]["machine_enabled"]=False
status,headers,body=self.request("/api/v3/game/game_auth")
self.assertEqual((status,headers["App-Status-Code"]),(403,"431"))
self.assertNotIn("success",json.loads(body))
def test_header_redaction(self):
values = {"Device-Token":"secret", "Device-Refresh":"secret", "Authorization":"secret", "nested":{"password":"secret"}}
self.assertNotIn("secret", json.dumps(redact(values)))
if __name__ == "__main__": unittest.main()
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Require both owned backend listeners before starting the guest."""
import argparse
import json
import os
import ssl
import time
import urllib.request
p = argparse.ArgumentParser()
p.add_argument('--pid',type=int,required=True)
p.add_argument('--api-port',type=int,required=True)
p.add_argument('--time-port',type=int,required=True)
p.add_argument('--ca',required=True)
a = p.parse_args()
context = ssl.create_default_context(cafile=a.ca)
end = time.monotonic()+10
while time.monotonic()<end:
try:
os.kill(a.pid,0)
except ProcessLookupError:
raise SystemExit('Local IC process exited before becoming healthy (check ports and backend log)')
try:
for scheme,port in [('https',a.api_port),('http',a.time_port)]:
with urllib.request.urlopen(f'{scheme}://127.0.0.1:{port}/healthz',context=context,timeout=.5) as response:
data=json.load(response)
if data != {'service':'spike3-local-ic','ready':True}: raise ValueError('Unexpected health response')
break
except (OSError,ValueError): time.sleep(.1)
else: raise SystemExit('Local IC listeners failed to become healthy; see backend log')
@@ -0,0 +1,440 @@
"""Local diagnostic UI."""
INDEX_HTML = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SPIKE 3 Machine Emulator</title>
<style>
:root {
color-scheme: light dark;
--bg: #f6f7f9;
--panel: #ffffff;
--text: #17202a;
--muted: #596575;
--line: #d9dee7;
--accent: #146c94;
--accent-2: #2d7d46;
--warn: #a45f00;
--bad: #a33a3a;
--shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111418;
--panel: #181d23;
--text: #edf1f7;
--muted: #a9b3c2;
--line: #313945;
--accent: #5ba3c7;
--accent-2: #72ba7d;
--warn: #d99b44;
--bad: #dc7272;
--shadow: none;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
border-bottom: 1px solid var(--line);
background: var(--panel);
position: sticky;
top: 0;
z-index: 2;
}
h1, h2 { margin: 0; font-weight: 650; letter-spacing: 0; }
h1 { font-size: 20px; }
h2 { font-size: 16px; }
main {
display: grid;
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
gap: 16px;
padding: 16px;
max-width: 1440px;
margin: 0 auto;
}
section {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
padding: 14px;
}
.stack { display: grid; gap: 16px; align-content: start; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.title-row { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; }
label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 12px;
font-weight: 600;
}
input, textarea, select {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: var(--text);
padding: 8px 9px;
font: inherit;
}
textarea { min-height: 110px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
button {
border: 1px solid var(--line);
background: transparent;
color: var(--text);
border-radius: 6px;
padding: 8px 10px;
cursor: pointer;
font: inherit;
font-weight: 600;
}
button.primary { background: var(--accent); border-color: var(--accent); color: white; }
button.good { background: var(--accent-2); border-color: var(--accent-2); color: white; }
button.bad { border-color: var(--bad); color: var(--bad); }
.grid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.status {
display: inline-flex;
align-items: center;
gap: 7px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 9px;
color: var(--muted);
white-space: nowrap;
}
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--bad); }
.dot.on { background: var(--accent-2); }
.muted { color: var(--muted); }
.kv {
display: grid;
grid-template-columns: 145px minmax(0, 1fr);
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid var(--line);
word-break: break-word;
}
.kv:last-child { border-bottom: 0; }
.logs {
height: 520px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
}
.log {
display: grid;
grid-template-columns: 82px 70px minmax(0, 1fr) 56px;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
.log:last-child { border-bottom: 0; }
.pill {
display: inline-block;
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px 8px;
margin: 2px 4px 2px 0;
color: var(--muted);
font-size: 12px;
}
.call-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.call-list button {
text-align: left;
min-height: 46px;
}
.result {
min-height: 132px;
max-height: 260px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
@media (max-width: 900px) {
main { grid-template-columns: 1fr; }
.grid2, .grid3, .call-list { grid-template-columns: 1fr; }
.log { grid-template-columns: 72px 54px minmax(0, 1fr); }
.log .status-code { display: none; }
}
</style>
</head>
<body>
<header>
<div>
<h1>SPIKE 3 Machine Emulator</h1>
<div class="muted">Backend API and server-time target for conagent testing</div>
</div>
<div class="row">
<span class="status"><span id="onlineDot" class="dot"></span><span id="onlineLabel">loading</span></span>
<button id="refreshBtn">Refresh</button>
</div>
</header>
<main>
<div class="stack">
<section>
<div class="title-row"><h2>Machine</h2><button class="primary" id="saveMachineBtn">Save</button></div>
<div class="grid2">
<label>Serial number<input id="serial_number"></label>
<label>Machine UUID<input id="machine_uuid"></label>
<label>Configuration<input id="machine_configuration_number"></label>
<label>Code version<input id="code_version"></label>
<label>Model number<input id="model_number"></label>
<label>Model version<input id="model_version"></label>
<label>Game title<input id="game_title"></label>
<label>Game model<input id="game_model"></label>
<label>MAC address<input id="mac_address"></label>
<label>Internal IP<input id="internal_ip_address"></label>
</div>
<div class="row" style="margin-top: 12px">
<button class="good" id="toggleOnlineBtn">Toggle Online</button>
<button id="registerBtn">Register</button>
<button id="heartbeatBtn">Heartbeat</button>
</div>
</section>
<section>
<div class="title-row"><h2>URLs</h2><button class="primary" id="saveUrlsBtn">Save</button></div>
<div class="grid2">
<label>API base URL<input id="api_base_url" readonly></label>
<label>Time URL<input id="time_url" readonly></label>
<label>CDN URL<input id="stern_cdn_url"></label>
<label>Speed test URL<input id="stern_speed_test_url"></label>
</div>
</section>
<section>
<div class="title-row"><h2>Service Behavior</h2><button class="primary" id="saveServiceBtn">Save</button></div>
<div class="grid3">
<label>Machine enabled<select id="machine_enabled"><option value="true">true</option><option value="false">false</option></select></label>
<label>Player auth enabled<select id="player_auth_enabled"><option value="true">true</option><option value="false">false</option></select></label>
<label>Updates available<select id="updates_available"><option value="false">false</option><option value="true">true</option></select></label>
<label>HTTP status<input id="force_http_status" type="number" min="100" max="599"></label>
<label>App status code<input id="app_status_code" type="number"></label>
</div>
</section>
<section>
<div class="title-row"><h2>Player Auth</h2><button id="authPlayerBtn">Authenticate</button></div>
<label>User UUID<input id="auth_user_uuid"></label>
<div id="playerResult" class="muted" style="margin-top: 10px"></div>
</section>
<section>
<div class="title-row"><h2>Score Driver</h2><button id="addScoreBtn">Add Score</button></div>
<div class="grid2">
<label>Player<select id="score_player"><option value="1">1</option><option value="2">2</option><option value="3">3</option><option value="4">4</option></select></label>
<label>Points<input id="score_delta" type="number" value="1000000"></label>
</div>
<div class="row" style="margin-top: 12px">
<button id="startSessionBtn">Start Session</button>
<button id="updateSessionBtn">Update Session</button>
<button id="endSessionBtn">End Session</button>
</div>
</section>
</div>
<div class="stack">
<section>
<div class="title-row">
<h2>Live State</h2>
<button class="bad" id="resetBtn">Reset Defaults</button>
</div>
<div id="stateSummary"></div>
</section>
<section>
<div class="title-row"><h2>Achievements</h2><button class="primary" id="saveAchievementsBtn">Save</button></div>
<textarea id="achievements"></textarea>
</section>
<section>
<div class="title-row">
<h2>Extracted Game Calls</h2>
<button id="refreshCallsBtn">Refresh</button>
</div>
<div id="callList" class="call-list"></div>
<div id="callResult" class="result" style="margin-top: 12px"></div>
</section>
<section>
<div class="title-row">
<h2>Requests</h2>
<div class="row"><button id="clearLogsBtn">Clear</button><button id="refreshLogsBtn">Refresh</button></div>
</div>
<div id="logs" class="logs"></div>
</section>
</div>
</main>
<script>
let state = null;
let callCatalog = [];
const $ = (id) => document.getElementById(id);
async function api(path, options = {}) {
const response = await fetch(path, {
headers: {'Content-Type': 'application/json'},
...options
});
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return await response.json();
}
function boolValue(id) {
return $(id).value === 'true';
}
function renderState() {
const machine = state.machine;
const urls = state.urls;
const service = state.service;
$('onlineDot').className = `dot ${machine.online ? 'on' : ''}`;
$('onlineLabel').textContent = machine.online ? 'mock availability: online' : 'mock availability: offline';
for (const key of ['serial_number','machine_uuid','machine_configuration_number','code_version','model_number','model_version','game_title','game_model','mac_address','internal_ip_address']) {
$(key).value = machine[key] ?? '';
}
$('api_base_url').value = urls.api_base_url ?? '';
$('time_url').value = urls.time_url ?? '';
$('stern_cdn_url').value = urls.stern_cdn_url ?? '';
$('stern_speed_test_url').value = urls.stern_speed_test_url ?? '';
for (const key of ['machine_enabled','player_auth_enabled','updates_available']) {
$(key).value = String(Boolean(service[key]));
}
$('force_http_status').value = service.force_http_status ?? 200;
$('app_status_code').value = service.app_status_code ?? 0;
$('auth_user_uuid').value = Object.keys(state.players || {})[0] || '00000000-0000-4000-8000-00000000c0de';
$('achievements').value = JSON.stringify(state.achievements || [], null, 2);
const items = [
['Registered', String(machine.registered)],
['Last heartbeat', machine.last_heartbeat || 'none'],
['Last player', machine.last_player_uuid || 'none'],
['Session', state.game_state?.active ? state.game_state.session_uuid : 'inactive'],
['Ball', String(state.game_state?.ball ?? 1)],
['P1 score', Number(state.game_state?.player_scores?.['1'] ?? 0).toLocaleString()],
['P2 score', Number(state.game_state?.player_scores?.['2'] ?? 0).toLocaleString()],
['P3 score', Number(state.game_state?.player_scores?.['3'] ?? 0).toLocaleString()],
['P4 score', Number(state.game_state?.player_scores?.['4'] ?? 0).toLocaleString()],
['Message number', String(state.message_number)],
['API token', state.auth.api_token ? 'configured' : 'missing'],
['Device token', state.auth.device_token ? 'configured' : 'missing'],
];
$('stateSummary').innerHTML = items.map(([k, v]) => `<div class="kv"><strong>${k}</strong><span>${v}</span></div>`).join('');
}
function renderLogs(logs) {
const box = $('logs');
box.innerHTML = '';
for (const entry of logs.slice().reverse()) {
const row = document.createElement('div');
row.className = 'log';
const ts = (entry.timestamp || '').split('T')[1]?.replace('Z','') || '';
row.innerHTML = `<span>${ts}</span><strong>${entry.method || ''}</strong><span>${entry.path || ''}</span><span class="status-code">${entry.status ?? ''}</span>`;
row.title = JSON.stringify(entry, null, 2);
box.appendChild(row);
}
}
function renderCalls() {
const box = $('callList');
box.innerHTML = '';
for (const call of callCatalog) {
const button = document.createElement('button');
button.innerHTML = `<strong>${call.label}</strong><br><span class="muted">${call.method} ${call.path}</span>`;
button.title = call.evidence || '';
button.onclick = async () => {
const result = await api('/api/game-calls/run', {method: 'POST', body: JSON.stringify({id: call.id})});
$('callResult').textContent = JSON.stringify(result, null, 2);
await refresh();
};
box.appendChild(button);
}
}
async function refresh() {
state = await api('/api/state');
callCatalog = (await api('/api/game-calls')).calls;
renderState();
renderCalls();
renderLogs((await api('/api/requests')).requests);
}
async function patchState(patch) {
state = await api('/api/state', {method: 'PATCH', body: JSON.stringify(patch)});
renderState();
}
$('refreshBtn').onclick = refresh;
$('refreshCallsBtn').onclick = refresh;
$('refreshLogsBtn').onclick = async () => renderLogs((await api('/api/requests')).requests);
$('clearLogsBtn').onclick = async () => { await api('/api/requests', {method: 'DELETE'}); await refresh(); };
$('resetBtn').onclick = async () => { if (confirm('Reset emulator state to defaults?')) { state = await api('/api/reset', {method: 'POST', body: '{}'}); renderState(); } };
$('toggleOnlineBtn').onclick = async () => patchState({machine: {online: !state.machine.online}});
$('registerBtn').onclick = async () => { await api('/api/machine/register', {method: 'POST', body: '{}'}); await refresh(); };
$('heartbeatBtn').onclick = async () => { await api('/api/machine/heartbeat', {method: 'POST', body: '{}'}); await refresh(); };
$('authPlayerBtn').onclick = async () => {
const result = await api('/api/machine/auth-player', {method: 'POST', body: JSON.stringify({user_uuid: $('auth_user_uuid').value})});
$('playerResult').textContent = JSON.stringify(result);
await refresh();
};
$('addScoreBtn').onclick = async () => {
const result = await api('/api/game-state/score', {method: 'POST', body: JSON.stringify({player: Number($('score_player').value), delta: Number($('score_delta').value || 0)})});
$('callResult').textContent = JSON.stringify(result, null, 2);
await refresh();
};
$('startSessionBtn').onclick = async () => {
const result = await api('/api/game-calls/run', {method: 'POST', body: JSON.stringify({id: 'session_start'})});
$('callResult').textContent = JSON.stringify(result, null, 2);
await refresh();
};
$('updateSessionBtn').onclick = async () => {
const result = await api('/api/game-calls/run', {method: 'POST', body: JSON.stringify({id: 'session_update'})});
$('callResult').textContent = JSON.stringify(result, null, 2);
await refresh();
};
$('endSessionBtn').onclick = async () => {
const result = await api('/api/game-calls/run', {method: 'POST', body: JSON.stringify({id: 'session_end'})});
$('callResult').textContent = JSON.stringify(result, null, 2);
await refresh();
};
$('saveMachineBtn').onclick = async () => {
const machine = {};
for (const key of ['serial_number','machine_uuid','machine_configuration_number','code_version','model_number','model_version','game_title','game_model','mac_address','internal_ip_address']) machine[key] = $(key).value;
await patchState({machine});
};
$('saveUrlsBtn').onclick = async () => patchState({urls: {stern_cdn_url: $('stern_cdn_url').value, stern_speed_test_url: $('stern_speed_test_url').value}});
$('saveServiceBtn').onclick = async () => patchState({service: {
machine_enabled: boolValue('machine_enabled'),
player_auth_enabled: boolValue('player_auth_enabled'),
updates_available: boolValue('updates_available'),
force_http_status: Number($('force_http_status').value || 200),
app_status_code: Number($('app_status_code').value || 0)
}});
$('saveAchievementsBtn').onclick = async () => patchState({achievements: JSON.parse($('achievements').value)});
refresh().catch((err) => alert(err.message));
</script>
</body>
</html>"""
@@ -0,0 +1,13 @@
{
"byte_count": 8,
"active_low": true,
"default_asserted": [
{"byte": 1, "bit": 7}
],
"switches": [
{"name": "service_enter", "contact": "0-SW-8", "byte": 0, "bit": 0},
{"name": "service_up", "contact": "0-SW-9", "byte": 0, "bit": 1},
{"name": "service_down", "contact": "0-SW-10", "byte": 0, "bit": 2},
{"name": "service_back", "contact": "0-SW-11", "byte": 0, "bit": 3}
]
}
@@ -0,0 +1,181 @@
const LIVE_KEYS = Object.freeze({
ShiftLeft: 'left_flipper', ShiftRight: 'right_flipper', Enter: 'start',
Space: 'action_button', KeyC: 'coin', KeyL: 'launch_ball',
});
export function createDashboardEventTransport({fetch, refresh, onError = error => console.error(error),
sessionId = globalThis.crypto.randomUUID()}) {
let sequence = 0;
let pending = Promise.resolve();
function deliver(name, value, refreshAfter, teardown, eventSequence) {
const request = fetch('/api/event', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({type: 'switch', name, value, client_id: sessionId, sequence: eventSequence}),
keepalive: teardown,
}).then(response => {
if (!response.ok) throw new Error(`Dashboard event failed (${response.status})`);
if (refreshAfter && !teardown) {
try { void Promise.resolve(refresh()).catch(onError); } catch (error) { onError(error); }
}
});
return request.then(() => true).catch(error => { onError(error); return false; });
}
return function postEvent(name, value, refreshAfter = true, teardown = false) {
const eventSequence = ++sequence;
if (teardown) return deliver(name, value, refreshAfter, true, eventSequence);
pending = pending.then(() => deliver(name, value, refreshAfter, false, eventSequence));
return pending;
};
}
export function installDashboardInput({window, document, modeSelector, postEvent,
debugKeys = {}, setButton = () => {}, setKeycap = () => {}, onError = error => console.error(error)}) {
const held = new Map();
const counts = new Map();
const pendingReleases = new Map();
const cleanups = [];
const listen = (target, type, fn) => { target.addEventListener(type, fn); cleanups.push(() => target.removeEventListener(type, fn)); };
const keys = () => modeSelector.value === 'live' ? LIVE_KEYS : debugKeys;
const send = (name,value,refreshAfter,teardown) => {
try {
const sent = Promise.resolve(postEvent(name,value,refreshAfter,teardown));
if (value === 0 && !teardown) {
const releaseIdentity = {};
pendingReleases.set(name,releaseIdentity);
void sent.then(ok => {
if (ok !== false && pendingReleases.get(name) === releaseIdentity) pendingReleases.delete(name);
}).catch(onError);
} else void sent.catch(onError);
} catch (error) { onError(error); }
};
function activate(token, name, code='') {
if (held.has(token)) return;
held.set(token,{name,code});
if (code) setKeycap(code,true);
const count=counts.get(name)||0; counts.set(name,count+1);
if (count===0) { setButton(name,true); send(name,1,true,false); }
}
function release(token, teardown=false) {
const item=held.get(token); if (!item) return;
held.delete(token); if (item.code) setKeycap(item.code,false);
const count=(counts.get(item.name)||1)-1;
if (count>0) counts.set(item.name,count);
else {
counts.delete(item.name); setButton(item.name,false);
if (!teardown) send(item.name,0,true,false);
return item.name;
}
return '';
}
function releaseAll(teardown=false) {
const teardownNames = teardown ? new Set(pendingReleases.keys()) : null;
for (const token of [...held.keys()]) {
const name = release(token,teardown);
if (name && teardown) teardownNames.add(name);
}
if (teardown) {
for (const name of teardownNames) send(name,0,true,true);
pendingReleases.clear();
}
}
listen(window, 'keydown', event => {
const target=event.target;
if (target?.isContentEditable || ['INPUT','TEXTAREA','SELECT'].includes(target?.tagName?.toUpperCase())) return;
const name=keys()[event.code]; const token=`key:${event.code}`;
if (!name || event.repeat || held.has(token)) return;
event.preventDefault(); activate(token,name,event.code);
});
listen(window, 'keyup', event => {
const token=`key:${event.code}`; const item=held.get(token); if (!item) return;
event.preventDefault(); release(token);
});
listen(window, 'blur', () => releaseAll()); listen(window, 'pagehide', () => releaseAll(true));
listen(modeSelector, 'change', () => releaseAll());
for (const button of document.querySelectorAll('[data-cabinet]')) {
const press = event => { const token=`pointer:${event.pointerId}`; if (held.has(token)) return;
button.setPointerCapture?.(event.pointerId); activate(token,button.dataset.cabinet); };
const releasePointer = event => release(`pointer:${event.pointerId}`);
listen(button,'pointerdown',press); for (const type of ['pointerup','pointercancel','lostpointercapture']) listen(button,type,releasePointer);
}
return {releaseAll, destroy(){ releaseAll(); for (const cleanup of cleanups.splice(0)) cleanup(); }};
}
export function createDebugSequenceRunner({postEvent, pause, now = () => performance.now(), maxLiveGapMs = 30000}) {
let generation=0;
let active=null;
let segment=null;
let segmentCounter=0;
function result(item, status, extra={}) {
return {status, segmentId:item?.id ?? null, checkpointReference:item?.checkpointReference ?? '',
nativeInventory:'unknown', ...extra, ...(item?.invalid || {})};
}
function invalidate(item, reason, extra={}) {
if (item && !item.invalid) item.invalid={status:'invalid',reason,checkpointRequired:true,...extra};
}
function check(item) {
const gapMs=now()-item.lastActivity;
if (gapMs>maxLiveGapMs || gapMs<0) invalidate(item,'live-idle-gap',{gapMs,maxLiveGapMs});
return !item.invalid;
}
async function deliver(item,name,value,teardown=false) {
try {
if (await postEvent(name,value,false,teardown) === false) invalidate(item,'delivery-failed');
} catch (error) { invalidate(item,'delivery-failed',{error:String(error)}); }
if (check(item)) item.lastActivity=now();
}
function release(entry, teardown=false) {
if (!entry) return Promise.resolve();
// Page teardown may flush a normal release still queued behind its press.
if (entry.release && !teardown) return entry.release;
const pending=deliver(entry.segment,entry.name,0,teardown).then(()=>{
if (active===entry) active=null;
});
entry.release=pending;
return pending;
}
async function cancel(teardown=false) {
generation++;
const item=segment;
await release(active,teardown);
return result(item,'canceled');
}
return {
async beginSegment(checkpointReference) {
const stopping=cancel();
const expected=generation;
segment=null;
await stopping;
if (expected!==generation) return result(null,'canceled');
if (typeof checkpointReference!=='string' || !checkpointReference.trim()) return result(null,'checkpoint-required');
segment={id:++segmentCounter,checkpointReference:checkpointReference.trim(),lastActivity:now()};
return result(segment,'ready');
},
async run(names) {
const stopping=cancel();
const expected=generation;
const item=segment;
await stopping;
if (expected!==generation) return result(item,'canceled');
if (!item) return result(null,'checkpoint-required');
if (!check(item)) return result(item,'invalid');
try {
for (const name of names) {
if (expected!==generation || !check(item)) break;
const entry={name,segment:item}; active=entry;
await deliver(item,name,1);
if (expected===generation && check(item)) { await pause(180); check(item); }
await release(entry);
if (expected!==generation || !check(item)) break;
await pause(320);
if (!check(item)) break;
}
} catch (error) {
invalidate(item,'sequence-failed',{error:String(error)});
if (active?.segment===item) await release(active);
}
return result(item,expected===generation?'completed':'canceled');
},
cancel,
};
}
@@ -0,0 +1,623 @@
const DEFAULT_DEPENDENCIES = {
get document() { return globalThis.document; },
fetch: (...args) => globalThis.fetch(...args),
get WebSocket() { return globalThis.WebSocket; },
get VideoDecoder() { return globalThis.VideoDecoder; },
get EncodedVideoChunk() { return globalThis.EncodedVideoChunk; },
atob: (value) => globalThis.atob(value),
importModule: (url) => import(url),
now: () => Date.now(),
setTimeout: (...args) => globalThis.setTimeout(...args),
clearTimeout: (...args) => globalThis.clearTimeout(...args),
};
const MAX_PENDING_FRAMES = 8;
const MAX_DECODE_QUEUE = 8;
const CAPTURE_RETRY_DELAYS = [500, 1000, 2000];
const PRESENTATION_DEADLINE_MS = 5000;
export function installPageRestoreReload(pageTarget) {
let requested = false;
pageTarget.addEventListener('pageshow', event => {
if (event.persisted && !requested) {
requested = true;
pageTarget.location.reload();
}
});
}
export function mountPageDisplay({pageTarget, mount}) {
let destroyed = false;
let instance = null;
const destroy = () => {
destroyed = true;
instance?.destroy();
instance = null;
pageTarget.removeEventListener('pagehide', destroy);
};
pageTarget.addEventListener('pagehide', destroy);
void mount(() => !destroyed).then(mounted => {
if (destroyed) mounted.destroy();
else instance = mounted;
});
return {destroy};
}
function captureFailure(record) {
if (!record || typeof record !== 'object' || Array.isArray(record)) return null;
const permission = record.state === 'permission-required'
|| (record.domain === 'SCStreamErrorDomain' && record.code === -3801);
const retryable = record.state === 'error' && record.domain === 'SCStreamErrorDomain'
&& Number.isInteger(record.code) && record.code === -3805 && record.retryable === true;
return {
permission,
retryable: !permission && retryable,
message: typeof record.message === 'string' && record.message
? record.message
: permission ? 'Screen recording permission required; check macOS settings and retry'
: retryable ? 'Capture application interrupted (SCStreamErrorDomain/-3805)' : record.state,
};
}
function closeFailure(event) {
if (event?.code !== 1011) return null;
try {
const record = JSON.parse(event.reason);
if (!record || typeof record !== 'object' || Array.isArray(record)
|| typeof record.state !== 'string' || typeof record.retryable !== 'boolean') return null;
return captureFailure(record);
} catch { return null; }
}
export function mountToggleDisplay({selector, liveElement, debugElement, debugElements = [], layoutElement, pageTarget,
mount, setTimeout = (...args) => globalThis.setTimeout(...args),
clearTimeout = (...args) => globalThis.clearTimeout(...args)}) {
let instance = null;
let generation = 0;
let destroyed = false;
let appliedLive = null;
let restoreTimer = null;
function stop() { generation++; instance?.destroy(); instance=null; }
async function update() {
const live = selector.value === 'live' && !destroyed;
appliedLive = live;
liveElement.hidden = !live; debugElement.hidden = live;
layoutElement?.classList.toggle('live-view', live);
for (const element of debugElements) element.hidden = live;
stop();
if (!live) return;
const expected = generation;
const mounted = await mount(() => !destroyed && selector.value === 'live' && expected === generation);
if (destroyed || selector.value !== 'live' || expected !== generation) mounted.destroy();
else instance = mounted;
}
const onChange = () => void update();
const onPageHide = () => {
destroyed = true;
clearTimeout(restoreTimer);
restoreTimer = null;
stop();
};
const onPageShow = event => {
if (event.persisted || destroyed) return;
clearTimeout(restoreTimer);
// History navigation restores form values after pageshow, without change.
const timer = setTimeout(() => {
if (destroyed || restoreTimer !== timer) return;
restoreTimer = null;
if ((selector.value === 'live') !== appliedLive) void update();
}, 0);
restoreTimer = timer;
};
selector.addEventListener('change',onChange); pageTarget.addEventListener('pagehide',onPageHide);
pageTarget.addEventListener('pageshow', onPageShow);
void update();
return {destroy(){ onPageHide(); selector.removeEventListener('change',onChange);
pageTarget.removeEventListener('pagehide',onPageHide);
pageTarget.removeEventListener('pageshow',onPageShow); }};
}
export async function mountDisplay({
container,
selector,
statusElement,
configUrl = '/api/display',
dependencies = {},
ownership = () => true,
}) {
const platform = {...DEFAULT_DEPENDENCIES, ...dependencies};
const fetchDisplayConfig = platform.fetch;
const decodeBase64 = platform.atob;
const importModule = platform.importModule;
const now = platform.now;
const setTimeout = platform.setTimeout;
const clearTimeout = platform.clearTimeout;
async function fetchConfig() {
const response = await fetchDisplayConfig(configUrl);
if (!response.ok) throw new Error(`Display configuration failed (${response.status})`);
return response.json();
}
let config = {};
let generation = 0;
let teardown = () => {};
let destroyed = false;
let activeMode = null;
let retryCount = 0;
let retryTimer = null;
let lastCaptureFailure = '';
const alive = () => !destroyed && ownership();
const current = (expected, mode = activeMode) => alive()
&& expected === generation && mode === activeMode && selector.value === mode;
function setStatus(message) {
statusElement.textContent = message;
}
function stopProvider() {
generation += 1;
clearTimeout(retryTimer);
retryTimer = null;
teardown();
teardown = () => {};
if (ownership()) container.replaceChildren();
}
function failureText(providerName, error) {
const detail = error?.message || String(error || 'unavailable');
return `${providerName} failed: ${detail}`;
}
function showFailure(mode, message) {
if (!alive() || selector.value !== mode) return;
stopProvider();
const failureGeneration = generation;
setStatus(message);
const retry = platform.document.createElement('button');
retry.type = 'button';
retry.textContent = 'Retry display';
retry.addEventListener('click', () => {
if (!current(failureGeneration, mode) || retry.disabled) return;
retry.disabled = true;
retryCount = 0;
lastCaptureFailure = '';
void retryWithFreshConfig(mode, failureGeneration, false);
});
container.replaceChildren(retry);
}
async function retryWithFreshConfig(mode, failureGeneration, automatic) {
if (!current(failureGeneration, mode)) return;
try {
const refreshedConfig = await fetchConfig();
if (!current(failureGeneration, mode)) return;
config = refreshedConfig;
const failure = captureFailure(config.providers?.sck?.status);
if (automatic && failure?.permission) {
showFailure(mode, failureText('ScreenCaptureKit', failure));
return;
}
await start(mode);
} catch (error) {
if (current(failureGeneration, mode)) {
showFailure(mode, [lastCaptureFailure, failureText('Display configuration', error)].filter(Boolean).join('; '));
}
}
}
function providerFailed(mode, providerKind, failedGeneration, error, classification = null) {
if (!current(failedGeneration, mode)) return;
const message = failureText(providerKind === 'sck' ? 'ScreenCaptureKit' : 'VNC', error);
if (providerKind === 'sck') lastCaptureFailure = message;
if (providerKind === 'sck' && classification?.retryable && retryCount < CAPTURE_RETRY_DELAYS.length) {
const delay = CAPTURE_RETRY_DELAYS[retryCount++];
stopProvider();
const retryGeneration = generation;
setStatus(`${message}; retrying in ${delay / 1000}s (${retryCount}/3)`);
retryTimer = setTimeout(() => {
if (!current(retryGeneration, mode)) return;
retryTimer = null;
void retryWithFreshConfig(mode, retryGeneration, true);
}, delay);
retryTimer?.unref?.();
return;
}
if (mode === 'auto' && providerKind === 'sck' && !classification?.permission) {
void fallbackToVnc(mode, message);
return;
}
showFailure(mode, providerKind === 'vnc' && lastCaptureFailure
? `${lastCaptureFailure}; ${message}` : message);
}
async function fallbackToVnc(mode, message) {
stopProvider();
const pending = startVnc(mode);
const expected = generation;
try {
if (!await pending && current(expected, mode)) showFailure(mode, message);
} catch (error) {
if (current(expected, mode)) showFailure(mode, `${message}; ${failureText('VNC', error)}`);
}
}
function startSck(mode) {
const provider = config.providers?.sck;
const webCodecsAvailable = (
typeof platform.VideoDecoder === 'function'
&& typeof platform.VideoDecoder.isConfigSupported === 'function'
&& typeof platform.EncodedVideoChunk === 'function'
&& typeof platform.WebSocket === 'function'
&& typeof platform.atob === 'function'
);
if (!provider?.available || !webCodecsAvailable) return false;
const currentGeneration = ++generation;
const canvas = platform.document.createElement('canvas');
const context = canvas.getContext('2d');
container.replaceChildren(canvas);
const socket = new platform.WebSocket(provider.url);
socket.binaryType = 'arraybuffer';
let decoder = null;
let configSequence = 0;
let waitingForKeyframe = true;
const pendingFrames = [];
let activeDecoderConfig = null;
let activeConfigFingerprint = '';
let requestedConfigFingerprint = '';
let presentedFrames = 0;
let receivedFrames = 0;
let lastReceivedTimestamp = null;
let lastPresentedTimestamp = null;
let pendingProgress = null;
let progressTimer = null;
let firstFrameDeadline = null;
canvas.dataset.sckReceivedFrames = '0';
canvas.dataset.sckPresentedFrames = '0';
function watchPresentation() {
if (!current(currentGeneration, mode)) return;
if (lastPresentedTimestamp !== null && lastReceivedTimestamp > lastPresentedTimestamp) {
pendingProgress ||= {timestamp: lastReceivedTimestamp, since: now()};
}
if (platform.document.hidden) {
clearTimeout(progressTimer);
progressTimer = null;
return;
}
if (!pendingProgress) return;
if (progressTimer !== null) return;
const delay = Math.max(0, PRESENTATION_DEADLINE_MS - (now() - pendingProgress.since));
const timer = setTimeout(() => {
if (!current(currentGeneration, mode) || progressTimer !== timer) return;
progressTimer = null;
if (platform.document.hidden) return;
providerFailed(mode, 'sck', currentGeneration,
'Video presentation stalled while newer frames were received', {retryable: true});
}, delay);
progressTimer = timer;
timer?.unref?.();
}
function armFirstFrameDeadline() {
clearTimeout(firstFrameDeadline);
const sequence = configSequence;
const timer = setTimeout(() => {
if (!current(currentGeneration, mode) || sequence !== configSequence || firstFrameDeadline !== timer) return;
firstFrameDeadline = null;
providerFailed(mode, 'sck', currentGeneration,
configUrl === '/api/vpx-display'
? 'No VPX video frame received; check the player window and screen recording permission'
: 'No video frame received; wake the Mac display or use VNC');
}, 10000);
firstFrameDeadline = timer;
timer?.unref?.();
}
platform.document.addEventListener('visibilitychange', watchPresentation);
armFirstFrameDeadline();
teardown = () => {
clearTimeout(firstFrameDeadline);
clearTimeout(progressTimer);
progressTimer = null;
platform.document.removeEventListener('visibilitychange', watchPresentation);
if (decoder && decoder.state !== 'closed') decoder.close();
socket.close();
};
socket.onopen = () => {
if (current(currentGeneration, mode)) setStatus('ScreenCaptureKit connected');
};
socket.onerror = (event) => {
providerFailed(mode, 'sck', currentGeneration, event?.message || 'connection error');
};
socket.onclose = (event) => {
const classification = closeFailure(event);
providerFailed(
mode,
'sck',
currentGeneration,
classification || event?.reason || `connection closed (${event?.code || 'unknown'})`,
classification,
);
};
function decodeFrame(data) {
if (!current(currentGeneration, mode)) return;
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
if (bytes.byteLength < 9 || (bytes[0] !== 1 && bytes[0] !== 2)) return;
const keyframe = bytes[0] === 1;
const overloaded = decoder.decodeQueueSize >= MAX_DECODE_QUEUE;
if (overloaded) waitingForKeyframe = true;
if (waitingForKeyframe && !keyframe) return;
try {
if (overloaded) {
decoder.reset();
decoder.configure(activeDecoderConfig);
}
waitingForKeyframe = false;
const timestamp = Number(new DataView(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength,
).getBigUint64(1));
decoder.decode(new platform.EncodedVideoChunk({
type: keyframe ? 'key' : 'delta',
timestamp,
data: bytes.slice(9),
}));
} catch (error) {
providerFailed(mode, 'sck', currentGeneration, error);
}
}
async function configure(message) {
const fingerprint = [
message.codec,
message.description,
message.width,
message.height,
].join(':');
if (decoder?.state === 'configured' && fingerprint === activeConfigFingerprint) {
return;
}
if (fingerprint === requestedConfigFingerprint) return;
requestedConfigFingerprint = fingerprint;
const sequence = ++configSequence;
// A replacement decoder may start a new timestamp origin. Its first
// output and subsequent progress must not inherit the previous timeline.
clearTimeout(progressTimer);
progressTimer = null;
pendingProgress = null;
lastReceivedTimestamp = null;
lastPresentedTimestamp = null;
delete canvas.dataset.sckLastReceivedTimestamp;
delete canvas.dataset.sckLastPresentedTimestamp;
armFirstFrameDeadline();
const descriptionText = decodeBase64(message.description);
const description = Uint8Array.from(
descriptionText,
(character) => character.charCodeAt(0),
);
const decoderConfig = {codec: message.codec, description};
const support = await platform.VideoDecoder.isConfigSupported(decoderConfig);
if (!current(currentGeneration, mode) || sequence !== configSequence) return;
if (!support.supported) throw new Error(`Unsupported AVC codec: ${message.codec}`);
if (decoder && decoder.state !== 'closed') decoder.close();
decoder = new platform.VideoDecoder({
output(frame) {
try {
if (current(currentGeneration, mode) && sequence === configSequence) {
clearTimeout(firstFrameDeadline);
firstFrameDeadline = null;
context.drawImage(frame, 0, 0, canvas.width, canvas.height);
lastPresentedTimestamp = frame.timestamp;
canvas.dataset.sckPresentedFrames = String(++presentedFrames);
canvas.dataset.sckLastPresentedTimestamp = String(frame.timestamp);
if (pendingProgress && frame.timestamp >= pendingProgress.timestamp) {
pendingProgress = null;
clearTimeout(progressTimer);
progressTimer = null;
}
watchPresentation();
}
} finally {
frame.close();
}
},
error(error) {
if (sequence === configSequence) providerFailed(mode, 'sck', currentGeneration, error);
},
});
activeDecoderConfig = support.config || decoderConfig;
activeConfigFingerprint = fingerprint;
decoder.configure(activeDecoderConfig);
canvas.width = message.width;
canvas.height = message.height;
waitingForKeyframe = true;
while (pendingFrames.length) decodeFrame(pendingFrames.shift());
}
socket.onmessage = (event) => {
if (!current(currentGeneration, mode)) return;
if (typeof event.data === 'string') {
let message;
try {
message = JSON.parse(event.data);
} catch (error) {
providerFailed(mode, 'sck', currentGeneration, error);
return;
}
if (message?.type === 'config') {
const pending = configure(message);
const sequence = configSequence;
void pending.catch((error) => {
if (sequence === configSequence) providerFailed(mode, 'sck', currentGeneration, error);
});
}
if (message?.type === 'status') {
const classification = captureFailure(message);
if (message.state === 'error' || classification?.permission) {
providerFailed(mode, 'sck', currentGeneration, classification, classification);
return;
}
setStatus(message.message || message.state || 'ScreenCaptureKit status');
}
return;
}
const bytes = new Uint8Array(event.data);
if (bytes.byteLength < 9 || (bytes[0] !== 1 && bytes[0] !== 2)) return;
lastReceivedTimestamp = Number(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(1));
canvas.dataset.sckReceivedFrames = String(++receivedFrames);
canvas.dataset.sckLastReceivedTimestamp = String(lastReceivedTimestamp);
// decodeQueueSize excludes work already accepted by the codec. Watch
// actual timestamp progress too; receipt/status traffic cannot reset it.
watchPresentation();
if (!decoder || decoder.state !== 'configured') {
if (pendingFrames.length >= MAX_PENDING_FRAMES) {
pendingFrames.length = 0;
waitingForKeyframe = true;
}
pendingFrames.push(event.data);
return;
}
decodeFrame(event.data);
};
return true;
}
async function startVnc(mode) {
const provider = config.providers?.vnc;
if (!provider?.available) return false;
const currentGeneration = ++generation;
let module;
try {
module = await importModule(provider.module);
} catch (error) {
if (!current(currentGeneration, mode)) return true;
throw error;
}
if (!current(currentGeneration, mode)) return true;
const RFB = module.default || module.RFB;
const rfb = new RFB(container, provider.url, {
credentials: {password: provider.password},
});
rfb.viewOnly = true;
rfb.scaleViewport = true;
rfb.resizeSession = false;
teardown = () => rfb.disconnect();
rfb.addEventListener('connect', () => {
if (current(currentGeneration, mode)) setStatus('VNC connected');
});
for (const eventName of ['disconnect', 'securityfailure', 'credentialsrequired']) {
rfb.addEventListener(eventName, (event) => {
providerFailed(mode, 'vnc', currentGeneration, event?.detail?.reason || eventName);
});
}
setStatus('VNC connecting');
return true;
}
function startSnapshot() {
if (!config.diagnostic) return false;
const currentGeneration = ++generation;
const image = platform.document.createElement('img');
image.alt = 'QEMU display diagnostic snapshot';
const refresh = platform.document.createElement('button');
refresh.type = 'button';
refresh.textContent = 'Refresh snapshot';
const refreshImage = () => {
const separator = config.diagnostic.includes('?') ? '&' : '?';
image.src = `${config.diagnostic}${separator}t=${now()}`;
};
image.onload = () => {
if (current(currentGeneration)) setStatus('Snapshot diagnostic');
};
image.onerror = () => {
if (current(currentGeneration)) setStatus('Snapshot failed to load');
};
refresh.addEventListener('click', refreshImage);
container.replaceChildren(image, refresh);
teardown = () => {
image.onload = null;
image.onerror = null;
refresh.removeEventListener('click', refreshImage);
};
setStatus('Snapshot diagnostic (manual refresh)');
refreshImage();
return true;
}
async function start(mode) {
if (!alive() || selector.value !== mode) return;
stopProvider();
activeMode = mode;
if (mode === 'snapshot') {
if (!startSnapshot()) {
showFailure('snapshot', failureText('Snapshot', 'diagnostic unavailable'));
}
return;
}
if (mode === 'sck') {
if (!startSck('sck')) {
const failure = captureFailure(config.providers?.sck?.status);
const message = failureText(
'ScreenCaptureKit',
failure?.permission ? failure : config.providers?.sck?.reason || 'unavailable',
);
showFailure('sck', [lastCaptureFailure, message].filter(Boolean).join('; '));
}
return;
}
if (mode === 'vnc') {
await fallbackToVnc(mode, failureText('VNC', config.providers?.vnc?.reason || 'unavailable'));
return;
}
if (mode === 'auto' && startSck('auto')) return;
if (mode === 'auto') {
const failure = captureFailure(config.providers?.sck?.status);
if (failure?.permission) showFailure(mode, failureText('ScreenCaptureKit', failure));
else await fallbackToVnc(mode, lastCaptureFailure
|| failureText('Browser display', 'no streaming provider available'));
}
}
const requestedMode = selector.value || (configUrl === '/api/vpx-display' ? 'sck' : 'auto');
let initialPending = true;
let selectionChanged = false;
const onSelectionChange = () => {
selectionChanged = true;
if (!initialPending) void start(selector.value);
};
const destroy = () => {
destroyed = true;
selector.removeEventListener('change', onSelectionChange);
stopProvider();
};
selector.addEventListener('change', onSelectionChange);
try {
config = await fetchConfig();
} catch (error) {
initialPending = false;
if (!alive()) {
selector.removeEventListener('change', onSelectionChange);
return {destroy() {}};
}
activeMode = selector.value || requestedMode;
selector.value = activeMode;
showFailure(activeMode, failureText('Display configuration', error));
return {destroy};
}
initialPending = false;
if (!alive()) {
selector.removeEventListener('change', onSelectionChange);
return {destroy() {}};
}
const initialMode = selectionChanged || selector.value && selector.value !== requestedMode
? selector.value : ['auto', 'sck', 'vnc', 'snapshot'].includes(config.mode)
? config.mode
: requestedMode;
selector.value = initialMode;
await start(initialMode);
return {destroy};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,185 @@
import json
import os
from pathlib import Path
import socket
import tempfile
import threading
import unittest
import urllib.error
import urllib.request
import time
from server import DashboardServer
class DisplayServerTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.work = Path(self.tmp.name) / 'isolated-work'
self.work.mkdir()
self.server = DashboardServer(('127.0.0.1', 0), Path(self.tmp.name), self.work)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.url = f'http://127.0.0.1:{self.server.server_port}'
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join()
self.tmp.cleanup()
def get(self, path):
with urllib.request.urlopen(self.url + path, timeout=2) as response:
return response.read()
def get_response(self, path):
return urllib.request.urlopen(self.url + path, timeout=2)
def test_provider_uses_selected_work_directory_and_listener(self):
with socket.socket() as listener:
listener.bind(('127.0.0.1', 0))
listener.listen()
port = listener.getsockname()[1]
(self.work / 'browser-stream.json').write_text(json.dumps({
'mode': 'auto', 'providers': {'sck': {'port': port}, 'vnc': {'port': port}}}))
(self.work / 'sck-stream-status.json').write_text(json.dumps({'pid':os.getpid(),'state':'ready'}))
result = json.loads(self.get('/api/display'))
self.assertTrue(result['providers']['sck']['available'])
self.assertFalse(result['providers']['vnc']['available'])
(self.work / 'novnc' / 'core').mkdir(parents=True)
(self.work / 'novnc' / 'core' / 'rfb.js').write_text('export default {};')
result = json.loads(self.get('/api/display'))
self.assertTrue(result['providers']['vnc']['available'])
def test_asset_traversal_and_symlink_escape_are_rejected(self):
root = self.work / 'novnc'
root.mkdir()
outside = self.work / 'private.js'
outside.write_text('private')
(root / 'link.js').symlink_to(outside)
for path in ('/assets/novnc/%2e%2e/private.js', '/assets/novnc/link.js'):
with self.assertRaises(urllib.error.HTTPError) as error:
self.get(path)
self.assertEqual(error.exception.code, 404)
def test_dashboard_mounts_stream_module_without_jpeg_polling(self):
body = self.get('/').decode()
self.assertIn('installPageRestoreReload(window)', body)
self.assertIn('mountPageDisplay({pageTarget:window', body)
self.assertIn('mountDisplay', body)
self.assertIn('id="display-provider"', body)
self.assertNotIn('setInterval(refreshScreen', body)
self.assertNotIn('src="/api/screen.jpg"', body)
self.assertIn('id="playfield-view"', body)
self.assertIn('value="debug" selected', body)
self.assertIn("configUrl:'/api/vpx-display'", body)
self.assertIn('data-cabinet="launch_ball"', body)
self.assertIn('Debug shot shortcuts are disabled in Live VPX', body)
self.assertIn('[hidden] { display:none!important; }', body)
self.assertIn('data-debug-only', body)
self.assertIn('#vpx-display canvas { width:100%; height:100%; object-fit:contain;', body)
self.assertIn('.cabinet-layout.live-view { grid-template-columns:minmax(0,1fr); }', body)
self.assertIn('@media(min-width:1100px) and (min-height:500px)', body)
self.assertIn('body:has(.cabinet-layout.live-view)', body)
def test_dashboard_links_to_standalone_vpx_window(self):
body = self.get('/').decode()
self.assertIn('href="/vpx"', body)
self.assertIn('target="_blank"', body)
self.assertIn('id="open-vpx-window"', body)
self.assertIn("installVpxPopoutLink", body)
def test_standalone_vpx_page_is_live_only_and_self_contained(self):
body = self.get('/vpx').decode()
self.assertIn('id="vpx-cabinet"', body)
self.assertIn('id="vpx-display"', body)
self.assertIn('id="vpx-provider"', body)
self.assertIn('value="live"', body)
self.assertIn("configUrl: '/api/vpx-display'", self.get('/assets/vpx-window.js').decode())
self.assertEqual(body.count('data-cabinet='), 6)
for name in ('left_flipper', 'right_flipper', 'start', 'coin', 'action_button', 'launch_ball'):
self.assertIn(f'data-cabinet="{name}"', body)
self.assertNotIn('qemu-display', body)
self.assertNotIn('/api/status', body)
self.assertNotIn('/api/logs', body)
self.assertNotIn('/api/screen.jpg', body)
def test_standalone_assets_are_exactly_allowlisted_with_correct_mime_types(self):
expected = {
'/vpx': 'text/html; charset=utf-8',
'/assets/vpx-window.js': 'text/javascript',
'/assets/display.js': 'text/javascript',
'/assets/dashboard-input.js': 'text/javascript',
}
for path, content_type in expected.items():
with self.subTest(path=path), self.get_response(path) as response:
self.assertEqual(response.headers.get_content_type(), content_type.split(';')[0])
for path in ('/assets/vpx.html', '/assets/vpx-window.js/extra', '/vpx-window.js'):
with self.subTest(path=path), self.assertRaises(urllib.error.HTTPError) as error:
self.get(path)
self.assertEqual(error.exception.code, 404)
def test_display_credentials_are_not_served_to_rebound_hosts(self):
request = urllib.request.Request(self.url + '/api/display', headers={'Host':'untrusted.invalid'})
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(request, timeout=2)
self.assertEqual(error.exception.code, 403)
def write_vpx(self, **overrides):
session = 'session-a'
config = dict(session_id=session, port=8093, target_pid=os.getpid(),
pid=os.getpid(), width=720, height=1280, fps=30, state='ready')
status = dict(session_id=session, target_pid=os.getpid(), pid=os.getpid(),
state='ready', updated_at=time.time())
config.update(overrides.pop('config', {}))
status.update(overrides.pop('status', {}))
(self.work / 'vpx-stream.json').write_text(json.dumps(config))
(self.work / 'vpx-stream-status.json').write_text(json.dumps(status))
def test_vpx_display_requires_matching_live_fresh_session(self):
self.write_vpx()
result = json.loads(self.get('/api/vpx-display'))
self.assertEqual(result['mode'], 'sck')
self.assertEqual(result['providers']['sck']['url'], 'ws://127.0.0.1:8093')
self.assertTrue(result['providers']['sck']['available'])
self.assertEqual(set(result['providers']), {'sck'})
self.assertNotIn('diagnostic', result)
for state in ('ready', 'starting', 'streaming', 'error', 'permission-required'):
with self.subTest(valid_status_state=state):
self.write_vpx(status={'state': state})
provider = json.loads(self.get('/api/vpx-display'))['providers']['sck']
self.assertTrue(provider['available'])
cases = (
({'status': {'session_id': 'other'}}, 'session'),
({'status': {'state': 'stopped'}}, 'stopped'),
({'status': {'updated_at': time.time() - 7}}, 'heartbeat'),
({'status': {'updated_at': float('nan')}}, 'heartbeat'),
({'config': {'port': True}}, 'port'),
({'config': {'target_pid': 0}}, 'PID'),
({'status': {'pid': -1}}, 'PID'),
({'config': {'session_id': ''}}, 'session'),
({'config': {'state': 'unknown'}}, 'state'),
({'status': {'state': 'unknown'}}, 'state'),
({'status': {'state': None}}, 'state'),
)
for changes, reason in cases:
with self.subTest(reason=reason):
self.write_vpx(**changes)
provider = json.loads(self.get('/api/vpx-display'))['providers']['sck']
self.assertFalse(provider['available'])
self.assertIn(reason.lower(), provider['reason'].lower())
def test_vpx_display_missing_and_rebound_host_are_unavailable(self):
result = json.loads(self.get('/api/vpx-display'))
self.assertFalse(result['providers']['sck']['available'])
self.assertIn('metadata', result['providers']['sck']['reason'].lower())
request = urllib.request.Request(self.url + '/api/vpx-display', headers={'Host':'untrusted.invalid'})
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(request, timeout=2)
self.assertEqual(error.exception.code, 403)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,318 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {installDashboardInput, createDashboardEventTransport, createDebugSequenceRunner} from './dashboard-input.js';
class Target {
constructor() { this.listeners = new Map(); this.dataset = {}; this.classList = {toggle() {}}; }
addEventListener(type, fn) { const list=this.listeners.get(type)||[]; list.push(fn); this.listeners.set(type,list); }
removeEventListener(type, fn) { this.listeners.set(type,(this.listeners.get(type)||[]).filter(x=>x!==fn)); }
fire(type, extra={}) { for (const fn of this.listeners.get(type)||[]) fn({type,preventDefault(){},...extra}); }
}
function harness(mode='live') {
const window = new Target(); const modeSelector = new Target(); modeSelector.value=mode;
const button = new Target(); button.dataset.cabinet='launch_ball';
const document = {querySelectorAll: (selector) => selector === '[data-cabinet]' ? [button] : [], querySelector:()=>null};
const events=[];
const input=installDashboardInput({window,document,modeSelector,postEvent:(name,value)=>events.push([name,value])});
return {window,modeSelector,button,document,events,input};
}
test('live keys send cabinet controls only and suppress repeats', () => {
const h=harness();
h.window.fire('keydown',{code:'ShiftLeft'}); h.window.fire('keydown',{code:'ShiftLeft',repeat:true});
h.window.fire('keydown',{code:'KeyQ'}); h.window.fire('keyup',{code:'ShiftLeft'});
assert.deepEqual(h.events,[['left_flipper',1],['left_flipper',0]]);
h.input.destroy();
});
test('typing in editable controls never activates cabinet or debug shortcuts',()=>{
for (const target of [{tagName:'INPUT'}, {tagName:'TEXTAREA'}, {tagName:'SELECT'},
{tagName:'SPAN',isContentEditable:true}]) {
const h=harness(); let prevented=false;
h.window.fire('keydown',{code:'Space',target,preventDefault(){prevented=true;}});
assert.deepEqual(h.events,[]); assert.equal(prevented,false);
h.input.destroy();
}
const h=harness('debug'); h.input.destroy();
const input=installDashboardInput({window:h.window,document:h.document,modeSelector:h.modeSelector,
postEvent:(n,v)=>h.events.push([n,v]),debugKeys:{KeyQ:'spinner'}});
h.window.fire('keydown',{code:'KeyQ',target:{tagName:'INPUT'}});
assert.deepEqual(h.events,[]); input.destroy();
});
test('keyup releases a cabinet key held before focus moved into an editor',()=>{
const h=harness();
h.window.fire('keydown',{code:'ShiftLeft'});
h.window.fire('keyup',{code:'ShiftLeft',target:{tagName:'INPUT'}});
assert.deepEqual(h.events,[['left_flipper',1],['left_flipper',0]]);
h.input.destroy();
});
test('blur and mode change release held controls', () => {
const h=harness(); h.window.fire('keydown',{code:'Space'}); h.window.fire('blur');
h.window.fire('keydown',{code:'KeyC'}); h.modeSelector.value='debug'; h.modeSelector.fire('change');
assert.deepEqual(h.events,[['action_button',1],['action_button',0],['coin',1],['coin',0]]);
h.input.destroy();
});
test('physical cabinet button releases on cancel and lost capture', () => {
const h=harness();
h.button.fire('pointerdown',{pointerId:1}); h.button.fire('pointercancel',{pointerId:1});
h.button.fire('pointerdown',{pointerId:2}); h.button.fire('lostpointercapture',{pointerId:2});
assert.deepEqual(h.events,[['launch_ball',1],['launch_ball',0],['launch_ball',1],['launch_ball',0]]);
h.input.destroy();
});
test('logical refcounts combine keyboard and pointers into one edge pair', () => {
const h=harness(); h.button.dataset.cabinet='left_flipper';
h.window.fire('keydown',{code:'ShiftLeft'});
h.button.fire('pointerdown',{pointerId:1}); h.button.fire('pointerdown',{pointerId:2});
h.window.fire('keyup',{code:'ShiftLeft'}); h.button.fire('pointerup',{pointerId:1});
assert.deepEqual(h.events,[['left_flipper',1]]);
h.button.fire('pointerup',{pointerId:2});
assert.deepEqual(h.events,[['left_flipper',1],['left_flipper',0]]);
h.input.destroy();
});
test('releaseAll emits one release per logical input and clears every keycap', () => {
const h=harness(); h.input.destroy(); h.button.dataset.cabinet='left_flipper';
const keycaps=[]; h.events.length=0;
const input=installDashboardInput({window:h.window,document:h.document,modeSelector:h.modeSelector,
postEvent:(n,v)=>h.events.push([n,v]),setKeycap:(c,a)=>keycaps.push([c,a])});
h.window.fire('keydown',{code:'ShiftLeft'}); h.button.fire('pointerdown',{pointerId:4});
h.window.fire('blur');
assert.deepEqual(h.events,[['left_flipper',1],['left_flipper',0]]);
assert.deepEqual(keycaps,[['ShiftLeft',true],['ShiftLeft',false]]);
input.destroy();
});
test('debug sequence cancellation balances the outstanding press and stops', async () => {
const events=[]; let resume;
const runner=createDebugSequenceRunner({postEvent:async(n,v)=>events.push([n,v]),
pause:()=>new Promise(resolve=>{resume=resolve;})});
await runner.beginSegment('baseline evidence');
const running=runner.run(['left_orbit','spinner']);
while (!resume) await Promise.resolve();
await runner.cancel(); resume(); await running;
assert.deepEqual(events,[['left_orbit',1],['left_orbit',0]]);
});
function sequenceHarness({delivery, sleep} = {}) {
let time=0;
const events=[];
const runner=createDebugSequenceRunner({now:()=>time,
postEvent:async(n,v,_refresh,teardown)=>{events.push([n,v,!!teardown]); return delivery?.(n,v);},
pause:async(ms)=>{time+=ms; await sleep?.(ms);}});
return {runner,events,advance(ms){time+=ms;}};
}
test('debug sequences require an explicit evidence reference and never claim inventory acceptance', async()=>{
const h=sequenceHarness();
assert.equal((await h.runner.run(['spinner'])).status,'checkpoint-required');
assert.equal((await h.runner.beginSegment(' ')).status,'checkpoint-required');
assert.deepEqual(h.events,[]);
await h.runner.beginSegment('evidence/baseline.json');
const result=await h.runner.run(['spinner']);
assert.equal(result.status,'completed');
assert.equal(result.nativeInventory,'unknown');
assert.equal(result.checkpointReference,'evidence/baseline.json');
});
test('idle between runs latches invalid until an explicit new segment', async()=>{
const h=sequenceHarness(); await h.runner.beginSegment('baseline');
await h.runner.run(['spinner']); h.advance(30001);
const expired=await h.runner.run(['left_orbit']);
assert.equal(expired.status,'invalid'); assert.equal(expired.reason,'live-idle-gap');
assert.equal((await h.runner.run(['left_orbit'])).status,'invalid');
assert.equal(h.events.length,2);
await h.runner.beginSegment('fresh baseline');
const next=await h.runner.run(['left_orbit']);
assert.equal(next.status,'completed'); assert.notEqual(next.segmentId,expired.segmentId);
});
for (const delay of ['hold','interval','delivery']) test(`overdue ${delay} stops further presses and balances release`,async()=>{
const h=sequenceHarness({delivery:(_n,v)=>{if(delay==='delivery' && v)h.advance(30001);},
sleep:ms=>{if(ms===(delay==='hold'?180:delay==='interval'?320:0))h.advance(30001);}});
await h.runner.beginSegment('baseline');
const result=await h.runner.run(['spinner','left_orbit']);
assert.equal(result.status,'invalid');
assert.deepEqual(h.events.map(([n,v])=>[n,v]),[['spinner',1],['spinner',0]]);
});
for (const failure of ['false','throw']) test(`delivery ${failure} invalidates and still attempts release`,async()=>{
const h=sequenceHarness({delivery:(_n,v)=>{if(v){if(failure==='throw')throw Error('failed');return false;}}});
await h.runner.beginSegment('baseline');
assert.equal((await h.runner.run(['spinner','left_orbit'])).status,'invalid');
assert.deepEqual(h.events.map(([n,v])=>[n,v]),[['spinner',1],['spinner',0]]);
assert.equal((await h.runner.run(['left_orbit'])).status,'invalid');
});
test('new checkpoint fences an older pending run and records its cancellation',async()=>{
let unblock;
const h=sequenceHarness({sleep:()=>new Promise(resolve=>{unblock=resolve;})});
await h.runner.beginSegment('old baseline');
const old=h.runner.run(['spinner','left_orbit']);
while(!unblock)await Promise.resolve();
await h.runner.beginSegment('new baseline'); unblock();
assert.equal((await old).status,'canceled');
assert.deepEqual(h.events.map(([n,v])=>[n,v]),[['spinner',1],['spinner',0]]);
});
test('teardown cancellation releases outstanding press with keepalive',async()=>{
let unblock;
const h=sequenceHarness({sleep:()=>new Promise(resolve=>{unblock=resolve;})});
await h.runner.beginSegment('baseline'); const running=h.runner.run(['spinner','left_orbit']);
while(!unblock)await Promise.resolve();
await h.runner.cancel(true); unblock();
assert.equal((await running).status,'canceled');
assert.deepEqual(h.events,[['spinner',1,false],['spinner',0,true]]);
});
test('an empty helper cannot report completion after an idle gap',async()=>{
const h=sequenceHarness(); await h.runner.beginSegment('baseline'); h.advance(30001);
assert.equal((await h.runner.run([])).status,'invalid');
});
test('concurrent replacement runs fence all but newest before first press',async()=>{
const h=sequenceHarness(); await h.runner.beginSegment('baseline');
const first=h.runner.run(['spinner']); const second=h.runner.run(['left_orbit']);
assert.equal((await first).status,'canceled'); assert.equal((await second).status,'completed');
assert.deepEqual(h.events.map(([n,v])=>[n,v]),[['left_orbit',1],['left_orbit',0]]);
});
test('checkpoint replacement during a pending press prevents any older next press',async()=>{
let finishPress;
const h=sequenceHarness({delivery:(_n,v)=>v?new Promise(resolve=>{finishPress=resolve;}):true});
await h.runner.beginSegment('old'); const first=h.runner.run(['spinner','left_orbit']);
while(!finishPress)await Promise.resolve();
await h.runner.beginSegment('new'); finishPress(true);
assert.equal((await first).status,'canceled');
assert.deepEqual(h.events.map(([n,v])=>[n,v]),[['spinner',1],['spinner',0]]);
});
test('teardown flushes a normal release still pending behind a press',async()=>{
let finishRelease; let resume;
const calls=[];
const runner=createDebugSequenceRunner({postEvent:(n,v,_r,teardown)=>{
calls.push([n,v,!!teardown]);
return v===0 && !teardown?new Promise(resolve=>{finishRelease=resolve;}):Promise.resolve(true);
},pause:()=>new Promise(resolve=>{resume=resolve;})});
await runner.beginSegment('baseline'); const run=runner.run(['spinner','left_orbit']);
while(!resume)await Promise.resolve();
const cancel=runner.cancel(); await runner.cancel(true);
finishRelease(true); resume(); await cancel; await run;
assert.deepEqual(calls,[['spinner',1,false],['spinner',0,false],['spinner',0,true]]);
});
test('dashboard transport serializes events and marks teardown delivery keepalive', async () => {
const calls=[]; const completions=[];
const fetch=(url,options)=>{ calls.push({url,options}); return new Promise(resolve=>completions.push(resolve)); };
const postEvent=createDashboardEventTransport({fetch,refresh:async()=>{},sessionId:'test-session'});
const press=postEvent('left_flipper',1,false);
const release=postEvent('left_flipper',0,false,true);
await Promise.resolve();
assert.equal(calls.length,2,'teardown release dispatches synchronously past the pending press');
const pressCall=calls.find(call=>JSON.parse(call.options.body).value===1);
const releaseCall=calls.find(call=>JSON.parse(call.options.body).value===0);
assert.deepEqual(JSON.parse(pressCall.options.body),
{type:'switch',name:'left_flipper',value:1,client_id:'test-session',sequence:1});
assert.equal(pressCall.options.keepalive,false);
assert.deepEqual(JSON.parse(releaseCall.options.body),
{type:'switch',name:'left_flipper',value:0,client_id:'test-session',sequence:2});
assert.equal(releaseCall.options.keepalive,true);
for (const complete of completions.splice(0)) complete({ok:true});
await Promise.all([press,release]);
});
test('pagehide queues an outstanding release with teardown transport and handles failure', async () => {
const deliveries=[]; const errors=[];
const postEvent=(name,value,_refresh,teardown)=>{
deliveries.push([name,value,teardown]);
return Promise.reject(new Error('navigation interrupted'));
};
const h=harness(); h.input.destroy();
const input=installDashboardInput({window:h.window,document:h.document,modeSelector:h.modeSelector,
postEvent,onError:error=>errors.push(error.message)});
h.window.fire('keydown',{code:'ShiftLeft'});
h.window.fire('pagehide');
await Promise.resolve(); await Promise.resolve();
assert.deepEqual(deliveries,[['left_flipper',1,false],['left_flipper',0,true]]);
assert.deepEqual(errors,['navigation interrupted','navigation interrupted']);
input.destroy();
});
test('blur then pagehide synchronously flushes its unresolved normal release only', async () => {
const deliveries=[]; let settleRelease;
const postEvent=(name,value,_refresh,teardown)=>{
deliveries.push([name,value,teardown]);
if (value===0 && !teardown) return new Promise(resolve=>{settleRelease=resolve;});
return Promise.resolve(true);
};
const h=harness(); h.input.destroy();
const input=installDashboardInput({window:h.window,document:h.document,modeSelector:h.modeSelector,postEvent});
h.window.fire('keydown',{code:'ShiftLeft'});
h.window.fire('blur');
h.window.fire('pagehide');
assert.deepEqual(deliveries,[
['left_flipper',1,false],['left_flipper',0,false],['left_flipper',0,true],
]);
settleRelease(true); await Promise.resolve();
input.destroy();
});
test('transport reports HTTP failure without an unhandled rejection', async () => {
const errors=[];
const postEvent=createDashboardEventTransport({
fetch:async()=>({ok:false,status:500}),refresh:async()=>{},sessionId:'test-session',
onError:error=>errors.push(error.message),
});
assert.equal(await postEvent('start',1),false);
assert.deepEqual(errors,['Dashboard event failed (500)']);
});
test('pending dashboard refresh does not delay the next input edge', async () => {
const calls=[]; let finishRefresh;
const postEvent=createDashboardEventTransport({
fetch:async(_url,options)=>{calls.push(JSON.parse(options.body)); return {ok:true};},
refresh:()=>new Promise(resolve=>{finishRefresh=resolve;}),sessionId:'test-session',
});
await postEvent('left_flipper',1);
await postEvent('left_flipper',0,false);
assert.deepEqual(calls.map(call=>call.value),[1,0]);
finishRefresh();
});
test('an older release completion cannot hide a newer pending release from pagehide', async () => {
const deliveries=[]; const releases=[];
const postEvent=(name,value,_refresh,teardown)=>{
deliveries.push([name,value,teardown]);
if (value===0 && !teardown) return new Promise(resolve=>releases.push(resolve));
return Promise.resolve(true);
};
const h=harness(); h.input.destroy();
const input=installDashboardInput({window:h.window,document:h.document,modeSelector:h.modeSelector,postEvent});
h.window.fire('keydown',{code:'ShiftLeft'}); h.window.fire('keyup',{code:'ShiftLeft'});
h.window.fire('keydown',{code:'ShiftLeft'}); h.window.fire('keyup',{code:'ShiftLeft'});
releases[0](true); await Promise.resolve(); await Promise.resolve();
h.window.fire('pagehide');
assert.deepEqual(deliveries.map(event=>event.slice(1)),[
[1,false],[0,false],[1,false],[0,false],[0,true],
]);
releases[1](true); input.destroy();
});
test('pagehide deduplicates a held input and its older pending release', async () => {
const deliveries=[];
const postEvent=(name,value,_refresh,teardown)=>{
deliveries.push([name,value,teardown]);
return value===0 && !teardown ? new Promise(()=>{}) : Promise.resolve(true);
};
const h=harness(); h.input.destroy();
const input=installDashboardInput({window:h.window,document:h.document,modeSelector:h.modeSelector,postEvent});
h.window.fire('keydown',{code:'ShiftLeft'}); h.window.fire('keyup',{code:'ShiftLeft'});
h.window.fire('keydown',{code:'ShiftLeft'});
h.window.fire('pagehide');
assert.equal(deliveries.filter(([_name,value,teardown])=>value===0 && teardown).length,1);
input.destroy();
});
@@ -0,0 +1,92 @@
"""Opt-in real Chromium check: native command -> bridge -> HTTP -> lamp marker.
Set SPIKE3_TEST_CHROMIUM and make Playwright available to Node (e.g. NODE_PATH).
Uses a temporary output directory and ephemeral localhost port; no live game input.
"""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import unittest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "analysis" / "netbridge-emulator"))
from netbridge_emu import NetbridgeModel
SPEC = importlib.util.spec_from_file_location("lamp_browser_dashboard", Path(__file__).with_name("server.py"))
dashboard = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(dashboard)
PROBE = r"""
const {chromium} = require('playwright');
(async () => {
const browser = await chromium.launch({headless: true, executablePath: process.env.SPIKE3_TEST_CHROMIUM});
try {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(process.argv[1]);
const marker = page.locator('[data-lamp="9-LP-17..19"]');
await page.waitForFunction(() => typeof paintLamps === 'function' && document.querySelector('[data-lamp]'));
// refresh() is the production fetch/render path; no mocked API or lamp painter.
await page.evaluate(() => refresh());
const result = await marker.evaluate(element => ({
color: element.style.backgroundColor,
lit: element.classList.contains('lit'),
visible: element.getBoundingClientRect().width > 0,
}));
result.errors = errors;
result.lamps = await page.evaluate(async () => (await (await fetch('/api/status')).json()).outputs.lamps);
console.log(JSON.stringify(result));
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
"""
@unittest.skipUnless(os.environ.get("SPIKE3_TEST_CHROMIUM"), "set SPIKE3_TEST_CHROMIUM for real browser acceptance")
class PackedLampBrowserTests(unittest.TestCase):
def test_native_berry_right_rgb_reaches_visible_marker(self):
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
model = NetbridgeModel(image_crc=0, output_state_file=work / "output-state.json")
server = dashboard.DashboardServer(("127.0.0.1", 0), ROOT, work)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
# Captured native purple and white commands; the zero command is
# the same captured mask with the implicit-zero value mode.
for packet, values, color, lit in (
("9680220e8900a503", [137, 0, 165], "rgb(137, 0, 165)", True),
("8580220e", [255, 255, 255], "rgb(255, 255, 255)", True),
("8480220e", [0, 0, 0], "", False),
):
with self.subTest(packet=packet):
model._handle_node_write(9, bytes.fromhex(packet))
model._publish_output_state()
result = subprocess.run(
["node", "-e", PROBE, f"http://127.0.0.1:{server.server_port}/"],
check=True, capture_output=True, text=True, timeout=30,
)
state = json.loads(result.stdout)
self.assertEqual(state["errors"], [])
self.assertTrue(state["visible"])
self.assertEqual(state["color"], color)
self.assertEqual(state["lit"], lit)
self.assertEqual(state["lamps"], {
f"9-LP-{channel}": value for channel, value in zip((17, 18, 19), values)
})
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,211 @@
import importlib.util
import json
import socket
import tempfile
import threading
import unittest
import urllib.request
import urllib.error
from pathlib import Path
SERVER_PATH = Path(__file__).with_name("server.py")
SPEC = importlib.util.spec_from_file_location("spike_dashboard", SERVER_PATH)
dashboard = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(dashboard)
class DashboardRenderingTests(unittest.TestCase):
def test_root_contains_virtual_magikarp_topper(self):
with tempfile.TemporaryDirectory() as directory:
workspace = Path(directory)
(workspace / "emulation" / "work" / "logs").mkdir(parents=True)
server = dashboard.DashboardServer(("127.0.0.1", 0), workspace)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
host, port = server.server_address
with urllib.request.urlopen(
f"http://{host}:{port}/",
timeout=2,
) as response:
body = response.read().decode()
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
self.assertIn('id="magikarp-topper"', body)
for name in ('service_enter', 'service_back', 'service_up', 'service_down'):
self.assertIn(f'data-cabinet="{name}"', body)
self.assertIn('id="topper-letters"', body)
self.assertIn("function paintTopper(topper)", body)
self.assertIn('<details id="topper-pixels">', body)
for group in ('letters', 'back', 'middle', 'front'):
self.assertIn(f'data-topper-group="{group}"', body)
self.assertIn('function paintTopperPixels(topper)', body)
self.assertIn('letter.rgb', body)
self.assertIn('transition_raw', body)
self.assertIn("element.style.color = lit ? topperTextColor(letter.rgb) : '';", body)
self.assertIn('function topperTextColor(rgb)', body)
self.assertIn('createDashboardEventTransport', body)
self.assertNotIn('async function postEvent(name, value', body)
self.assertIn('id="debug-checkpoint-reference"', body)
self.assertIn('id="debug-begin-segment"', body)
shortcuts = body.split('id="debug-shortcuts"', 1)[1].split('</aside>', 1)[0]
self.assertIn('id="debug-checkpoint-reference"', shortcuts)
self.assertIn('id="helper-status"', shortcuts)
self.assertIn('Sequence completed; native inventory unknown', body)
self.assertIn('checkpoint-required', body)
self.assertNotIn('helperStatus.textContent = `Done:', body)
self.assertIn("window.addEventListener('pagehide',", body)
self.assertIn('debugSequenceRunner.cancel(true)', body)
class DashboardEventRoutingTests(unittest.TestCase):
def post(self, url, payload, headers=None):
request = urllib.request.Request(url, data=json.dumps(payload).encode(),
headers={'Content-Type': 'application/json', **(headers or {})}, method='POST')
return urllib.request.urlopen(request, timeout=2)
def test_api_queues_switch_event_without_connecting_to_guest_control_socket(self):
with tempfile.TemporaryDirectory() as directory:
workspace = Path(directory)
work = workspace / "emulation" / "work"
work.mkdir(parents=True)
server = dashboard.DashboardServer(("127.0.0.1", 0), workspace)
control_listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
control_listener.bind(str(server.control_sock))
control_listener.listen(1)
control_listener.settimeout(0.1)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
host, port = server.server_address
request = urllib.request.Request(
f"http://{host}:{port}/api/switch/service_up",
data=json.dumps({"value": 1}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=2) as response:
result = json.loads(response.read())
leaked_payload = b""
connection = None
try:
connection, _ = control_listener.accept()
connection.settimeout(0.1)
leaked_payload = connection.recv(4096)
except TimeoutError:
pass
finally:
if connection is not None:
connection.close()
self.assertIsNone(
connection,
"dashboard must not connect to control.sock",
)
self.assertEqual(
b"",
leaked_payload,
"dashboard must not send raw JSON to control.sock",
)
expected_event = {
"name": "service_up",
"source": "dashboard",
"type": "switch",
"value": 1,
}
self.assertEqual(
{
"ok": True,
"queued_for_emulator": True,
"sent_to_guest": False,
"error": "",
"event": expected_event,
},
result,
)
self.assertEqual(
[expected_event],
[
json.loads(line)
for line in server.event_log.read_text().splitlines()
],
)
self.assertEqual(
{"service_up": True},
json.loads(server.switch_state_file.read_text()),
)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
control_listener.close()
def test_post_rejects_remote_host_cross_origin_and_oversized_body_and_assigns_source(self):
with tempfile.TemporaryDirectory() as directory:
workspace = Path(directory)
(workspace / 'emulation' / 'work').mkdir(parents=True)
server = dashboard.DashboardServer(('127.0.0.1', 0), workspace)
thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start()
url = f'http://127.0.0.1:{server.server_port}/api/switch/start'
try:
with self.post(url, {'value': 1, 'source': 'vpx'}) as response:
self.assertEqual(json.loads(response.read())['event']['source'], 'dashboard')
for headers in ({'Host': 'evil.invalid'}, {'Origin': 'http://evil.invalid'}):
with self.assertRaises(urllib.error.HTTPError) as error:
self.post(url, {'value': 1}, headers)
self.assertEqual(error.exception.code, 403)
with self.assertRaises(urllib.error.HTTPError) as error:
self.post(url, {'value': 'x' * 20000})
self.assertEqual(error.exception.code, 413)
request = urllib.request.Request(url, data=b'[]',
headers={'Content-Type':'application/json'}, method='POST')
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(request, timeout=2)
self.assertEqual(error.exception.code, 400)
finally:
server.shutdown(); server.server_close(); thread.join(timeout=2)
def test_late_sequenced_press_cannot_reassert_after_teardown_release(self):
with tempfile.TemporaryDirectory() as directory:
workspace = Path(directory)
(workspace / 'emulation' / 'work').mkdir(parents=True)
server = dashboard.DashboardServer(('127.0.0.1', 0), workspace)
release = server.send_event({'type':'switch','name':'left_flipper','value':0,
'client_id':'page-1','sequence':2})
press = server.send_event({'type':'switch','name':'left_flipper','value':1,
'client_id':'page-1','sequence':1})
self.assertTrue(release['queued_for_emulator'])
self.assertTrue(press['ignored_stale'])
self.assertFalse(press['queued_for_emulator'])
self.assertEqual({'left_flipper':False}, server.read_switch_state())
events = [json.loads(line) for line in server.event_log.read_text().splitlines()]
self.assertEqual([0], [event['value'] for event in events])
server.server_close()
def test_sequence_order_is_independent_for_each_held_input(self):
with tempfile.TemporaryDirectory() as directory:
workspace = Path(directory)
(workspace / 'emulation' / 'work').mkdir(parents=True)
server = dashboard.DashboardServer(('127.0.0.1', 0), workspace)
for payload in (
{'name':'right_flipper','value':0,'sequence':4},
{'name':'left_flipper','value':0,'sequence':3},
{'name':'left_flipper','value':1,'sequence':1},
{'name':'right_flipper','value':1,'sequence':2},
):
server.send_event({'type':'switch','client_id':'page-2',**payload})
self.assertEqual({'left_flipper':False,'right_flipper':False},
server.read_switch_state())
events = [json.loads(line) for line in server.event_log.read_text().splitlines()]
self.assertEqual([('right_flipper',0),('left_flipper',0)],
[(event['name'],event['value']) for event in events])
server.server_close()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,160 @@
import os
from pathlib import Path
import subprocess
import tempfile
import threading
import unittest
from server import DashboardServer
@unittest.skipUnless(os.environ.get('VPX_LAYOUT_BROWSER') == '1', 'opt-in real browser regression')
class VpxLayoutTests(unittest.TestCase):
def browser_environment(self):
node = os.environ.get('NODE', 'node')
chromium_path = os.environ.get('SPIKE3_TEST_CHROMIUM')
if not chromium_path or not Path(chromium_path).is_file():
self.skipTest('set SPIKE3_TEST_CHROMIUM to an installed Chromium executable')
dependency = subprocess.run([node, '-e', "require.resolve('playwright')"],
text=True, capture_output=True)
if dependency.returncode:
self.skipTest('Playwright unavailable to Node; expose it through NODE_PATH')
return node
def test_wide_live_dashboard_places_game_and_playfield_side_by_side(self):
node = self.browser_environment()
script = r'''const {chromium}=require('playwright'); const assert=require('node:assert/strict');
(async()=>{ const browser=await chromium.launch({headless:true,executablePath:process.env.SPIKE3_TEST_CHROMIUM}); try {
const page=await browser.newPage();
for (const [width,height] of [[1784,1456],[1920,1080],[2560,1080]]) {
await page.setViewportSize({width,height}); await page.goto(process.argv[1]);
await page.selectOption('#playfield-view','live'); await page.waitForTimeout(100);
await page.evaluate(()=>{
const add=(id,w,h)=>{const host=document.getElementById(id); host.replaceChildren(); const canvas=document.createElement('canvas'); canvas.width=w; canvas.height=h; host.append(canvas);};
add('qemu-display',1360,768); add('vpx-display',720,1280);
});
const state=await page.evaluate(()=>{
const rect=id=>{const r=document.getElementById(id).getBoundingClientRect(); return {left:r.left,top:r.top,right:r.right,bottom:r.bottom,width:r.width,height:r.height};};
const qemu=rect('qemu-display'),vpx=rect('vpx-display');
const inside=r=>r.left>=0&&r.top>=0&&r.right<=innerWidth&&r.bottom<=innerHeight;
return {qemu,vpx,inside:inside(qemu)&&inside(vpx),sideBySide:qemu.right<=vpx.left,
controls:[...document.querySelectorAll('[data-cabinet]')].every(e=>inside(e.getBoundingClientRect())),
overflow:document.documentElement.scrollWidth<=innerWidth&&document.documentElement.scrollHeight<=innerHeight,
fits:[...document.querySelectorAll('#qemu-display canvas,#vpx-display canvas')].map(e=>getComputedStyle(e).objectFit)};
});
assert(state.qemu.width>0&&state.qemu.height>0&&state.vpx.width>0&&state.vpx.height>0);
assert.equal(state.inside,true); assert.equal(state.sideBySide,true); assert.equal(state.controls,true); assert.equal(state.overflow,true);
assert.deepEqual(state.fits,['contain','contain']);
}
} finally {await browser.close();} })().catch(e=>{console.error(e);process.exitCode=1;});'''
self.run_browser_probe(node, script, '/')
def test_main_dashboard_restores_debug_portrait_then_refits_wide_live(self):
node = self.browser_environment()
script = r'''const {chromium}=require('playwright'); const assert=require('node:assert/strict');
(async()=>{ const browser=await chromium.launch({headless:true,executablePath:process.env.SPIKE3_TEST_CHROMIUM}); try {
const page=await browser.newPage();
const inject=()=>page.evaluate(()=>{
const add=(id,w,h)=>{const host=document.getElementById(id);host.replaceChildren();const canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;host.append(canvas);};
add('qemu-display',1360,768); add('vpx-display',720,1280);
});
const wide=async()=>{
await inject(); const state=await page.evaluate(()=>{
const q=document.getElementById('qemu-display').getBoundingClientRect(),v=document.getElementById('vpx-display').getBoundingClientRect();
return {sideBySide:q.right<=v.left,inside:q.bottom<=innerHeight&&v.bottom<=innerHeight,
overflow:getComputedStyle(document.body).overflow,fits:[...document.querySelectorAll('#qemu-display canvas,#vpx-display canvas')].map(e=>getComputedStyle(e).objectFit)};
});
assert.equal(state.sideBySide,true); assert.equal(state.inside,true); assert.equal(state.overflow,'hidden'); assert.deepEqual(state.fits,['contain','contain']);
};
await page.setViewportSize({width:1920,height:1080}); await page.goto(process.argv[1]);
await page.selectOption('#playfield-view','live'); await page.waitForTimeout(100); await wide();
await page.setViewportSize({width:720,height:1280}); await page.selectOption('#playfield-view','debug'); await page.waitForTimeout(50);
const debug=await page.evaluate(()=>{
const q=document.querySelector('.qemu-wrap').getBoundingClientRect(),map=document.getElementById('debug-playfield').getBoundingClientRect();
return {bodyOverflow:getComputedStyle(document.body).overflow,stacked:map.top>=q.bottom,
debugVisible:!document.getElementById('debug-playfield').hidden,vpxHidden:document.getElementById('vpx-display').hidden,
width:document.querySelector('.cabinet-main').getBoundingClientRect().width};
});
assert.notEqual(debug.bodyOverflow,'hidden'); assert.equal(debug.stacked,true); assert.equal(debug.debugVisible,true); assert.equal(debug.vpxHidden,true); assert(debug.width<=720);
await page.selectOption('#playfield-view','live'); await page.waitForTimeout(100); await inject();
const portraitLive=await page.evaluate(()=>{
const q=document.querySelector('.qemu-wrap').getBoundingClientRect(),v=document.getElementById('vpx-display').getBoundingClientRect();
return {bodyOverflow:getComputedStyle(document.body).overflow,stacked:v.top>=q.bottom,visible:!document.getElementById('vpx-display').hidden};
});
assert.notEqual(portraitLive.bodyOverflow,'hidden'); assert.equal(portraitLive.stacked,true); assert.equal(portraitLive.visible,true);
await page.setViewportSize({width:2560,height:1080}); await page.waitForTimeout(50); await wide();
} finally {await browser.close();} })().catch(e=>{console.error(e);process.exitCode=1;});'''
self.run_browser_probe(node, script, '/')
def run_browser_probe(self, node, script, path):
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp) / 'work'
work.mkdir()
server = DashboardServer(('127.0.0.1', 0), Path(tmp), work)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url = f'http://127.0.0.1:{server.server_port}{path}'
try:
result = subprocess.run([node, '-e', script, url], text=True,
capture_output=True, timeout=90)
finally:
server.shutdown(); server.server_close(); thread.join()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_viewports_resize_and_fullscreen_keep_display_and_controls_visible(self):
node = self.browser_environment()
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp) / 'work'
work.mkdir()
server = DashboardServer(('127.0.0.1', 0), Path(tmp), work)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url = f'http://127.0.0.1:{server.server_port}/vpx'
script = r'''const {chromium} = require('playwright');
const assert = require('node:assert/strict');
(async () => {
const browser = await chromium.launch({headless:true, executablePath:process.env.SPIKE3_TEST_CHROMIUM});
try {
const page = await browser.newPage();
async function injectCanvas() {
await page.evaluate(() => {
const display=document.getElementById('vpx-display'); display.replaceChildren();
const canvas=document.createElement('canvas'); canvas.width=720; canvas.height=1280; display.append(canvas);
});
}
async function check() {
const result=await page.evaluate(() => {
const viewport={width:innerWidth,height:innerHeight};
const inside=element => { const r=element.getBoundingClientRect(); return r.left>=0 && r.top>=0 && r.right<=viewport.width && r.bottom<=viewport.height; };
const canvas=document.querySelector('#vpx-display canvas');
const display=document.getElementById('vpx-display'); const rectangle=display.getBoundingClientRect();
return {display:inside(display), displayPositive:rectangle.width>0 && rectangle.height>0,
controls:[...document.querySelectorAll('[data-cabinet]')].every(inside),
overflow:document.documentElement.scrollWidth<=innerWidth && document.documentElement.scrollHeight<=innerHeight,
fit:getComputedStyle(canvas).objectFit};
});
assert.deepEqual(result,{display:true,displayPositive:true,controls:true,overflow:true,fit:'contain'});
}
for (const [width,height] of [[1784,1456],[1280,720],[720,1280],[390,844]]) {
await page.setViewportSize({width,height}); await page.goto(process.argv[1]); await injectCanvas(); await check();
}
await page.setViewportSize({width:960,height:540}); await check();
if (await page.evaluate(() => document.fullscreenEnabled)) {
await page.click('#vpx-fullscreen');
await page.waitForFunction(() => document.fullscreenElement === document.getElementById('vpx-cabinet'));
await check();
} else console.log('FULLSCREEN_SKIPPED: browser reports fullscreen unavailable');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode=1; });'''
try:
result = subprocess.run([node, '-e', script, url], text=True,
capture_output=True, timeout=90)
finally:
server.shutdown()
server.server_close()
thread.join()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {installVpxPopoutLink, requestCabinetFullscreen, setupVpxWindow} from './vpx-window.js';
import {installDashboardInput as installRealDashboardInput} from './dashboard-input.js';
class Target {
constructor() { this.listeners = new Map(); this.textContent = ''; }
addEventListener(type, fn) { this.listeners.set(type, fn); }
removeEventListener(type, fn) { if (this.listeners.get(type) === fn) this.listeners.delete(type); }
dispatch(type, event = {}) { this.listeners.get(type)?.({button: 0, ...event}); }
}
test('ordinary link click opens an isolated popup and prevents duplicate navigation', () => {
const link = new Target(); let prevented = 0;
const popup = {opener: {}};
const cleanup = installVpxPopoutLink({link, open: (url, target, features) => {
assert.equal(url, '/vpx'); assert.equal(target, '_blank');
assert.match(features, /popup/); assert.match(features, /width=720/); assert.match(features, /height=1280/);
return popup;
}});
link.dispatch('click', {preventDefault: () => prevented++});
assert.equal(prevented, 1); assert.equal(popup.opener, null);
cleanup(); assert.equal(link.listeners.size, 0);
});
test('modified link clicks retain normal browser semantics', () => {
const link = new Target(); let opened = 0; let prevented = 0;
installVpxPopoutLink({link, open: () => { opened++; }});
link.dispatch('click', {metaKey: true, preventDefault: () => prevented++});
assert.equal(opened, 0); assert.equal(prevented, 0);
});
test('blocked popup leaves ordinary link navigation available', () => {
const link = new Target(); let prevented = 0;
installVpxPopoutLink({link, open: () => null});
link.dispatch('click', {preventDefault: () => prevented++});
assert.equal(prevented, 0);
});
test('fullscreen reports unavailable and rejected requests visibly', async () => {
const status = new Target();
assert.equal(await requestCabinetFullscreen({cabinet: {}, status}), false);
assert.match(status.textContent, /unavailable/i);
const cabinet = {requestFullscreen: async () => { throw new Error('denied'); }};
assert.equal(await requestCabinetFullscreen({cabinet, status}), false);
assert.match(status.textContent, /denied/i);
});
test('fullscreen targets the cabinet so controls remain included', async () => {
const status = new Target(); let requested = 0;
const cabinet = {requestFullscreen: async () => { requested++; }};
assert.equal(await requestCabinetFullscreen({cabinet, status}), true);
assert.equal(requested, 1); assert.equal(status.textContent, '');
});
test('page teardown closes only this page display and input resources', async () => {
const page = new Target(); const fullscreenButton = new Target();
let reloads = 0;
page.location = {reload: () => reloads++};
let displayDestroyed = 0; let inputDestroyed = 0;
const document = {getElementById: id => ({
'vpx-cabinet': {}, 'vpx-display': {}, 'vpx-provider': {value: 'sck'},
'vpx-display-status': new Target(), 'vpx-input-mode': {value: 'live'},
'fullscreen-status': new Target(), 'vpx-fullscreen': fullscreenButton,
})[id], querySelectorAll: () => []};
const controller = await setupVpxWindow({window: page, document,
mountDisplay: async () => ({destroy: () => displayDestroyed++}),
installDashboardInput: () => ({destroy: () => inputDestroyed++}),
createDashboardEventTransport: () => () => {}});
page.dispatch('pagehide');
assert.equal(displayDestroyed, 1); assert.equal(inputDestroyed, 1);
page.dispatch('pageshow', {persisted: false});
assert.equal(reloads, 0);
page.dispatch('pageshow', {persisted: true});
page.dispatch('pageshow', {persisted: true});
assert.equal(reloads, 1);
controller.destroy();
assert.equal(displayDestroyed, 1); assert.equal(inputDestroyed, 1);
});
test('pagehide during deferred display mount prevents ownership and input installation', async () => {
const page = new Target(); const fullscreenButton = new Target();
let reloads = 0;
page.location = {reload: () => reloads++};
let finishMount; let ownership; let displayDestroyed = 0; let inputInstalled = 0;
const document = {getElementById: id => ({
'vpx-cabinet': {}, 'vpx-display': {}, 'vpx-provider': {value: 'sck'},
'vpx-display-status': new Target(), 'vpx-input-mode': {value: 'live'},
'fullscreen-status': new Target(), 'vpx-fullscreen': fullscreenButton,
})[id], querySelectorAll: () => []};
const setup = setupVpxWindow({window: page, document,
mountDisplay: options => { ownership = options.ownership; return new Promise(resolve => { finishMount = resolve; }); },
installDashboardInput: () => { inputInstalled++; return {destroy() {}}; },
createDashboardEventTransport: () => () => {}});
page.dispatch('pagehide');
page.dispatch('pageshow', {persisted: true});
assert.equal(reloads, 1, 'restore listener is installed before awaiting mount');
assert.equal(ownership(), false);
finishMount({destroy: () => displayDestroyed++});
const controller = await setup;
assert.equal(displayDestroyed, 1); assert.equal(inputInstalled, 0);
controller.destroy(); assert.equal(displayDestroyed, 1);
});
test('pagehide flushes held and pending releases with teardown transport before input destroy', async () => {
const page = new EventTarget(); const fullscreenButton = new Target(); const calls = [];
const never = new Promise(() => {});
const document = {getElementById: id => ({
'vpx-cabinet': {}, 'vpx-display': {}, 'vpx-provider': {value: 'sck'},
'vpx-display-status': new Target(), 'vpx-input-mode': Object.assign(new EventTarget(), {value: 'live'}),
'fullscreen-status': new Target(), 'vpx-fullscreen': fullscreenButton,
})[id], querySelectorAll: () => []};
const postEvent = (name, value, refresh, teardown) => {
calls.push({name, value, teardown});
return value === 0 && !teardown ? never : Promise.resolve(true);
};
await setupVpxWindow({window: page, document,
mountDisplay: async () => ({destroy() {}}), installDashboardInput: installRealDashboardInput,
createDashboardEventTransport: () => postEvent});
const key = (type, code) => {
const event = new Event(type, {cancelable: true});
Object.defineProperties(event, {code: {value: code}, repeat: {value: false}});
page.dispatchEvent(event);
};
key('keydown', 'ShiftLeft');
key('keydown', 'ShiftRight'); key('keyup', 'ShiftRight');
page.dispatchEvent(new Event('pagehide'));
assert.deepEqual(calls.filter(call => call.teardown), [
{name: 'right_flipper', value: 0, teardown: true},
{name: 'left_flipper', value: 0, teardown: true},
]);
});
@@ -0,0 +1,73 @@
import {installPageRestoreReload} from './display.js';
export function installVpxPopoutLink({link, open = (...args) => globalThis.window.open(...args)}) {
const click = event => {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey ||
event.shiftKey || event.altKey) return;
const popup = open(link.href || '/vpx', '_blank', 'popup,width=720,height=1280,resizable=yes');
if (!popup) return;
popup.opener = null;
event.preventDefault();
};
link.addEventListener('click', click);
return () => link.removeEventListener('click', click);
}
export async function requestCabinetFullscreen({cabinet, status}) {
if (typeof cabinet.requestFullscreen !== 'function') {
status.textContent = 'Fullscreen is unavailable in this browser.';
return false;
}
try {
await cabinet.requestFullscreen();
status.textContent = '';
return true;
} catch (error) {
status.textContent = `Fullscreen failed: ${error?.message || error}`;
return false;
}
}
export async function setupVpxWindow({window, document, mountDisplay,
installDashboardInput, createDashboardEventTransport}) {
installPageRestoreReload(window);
const cabinet = document.getElementById('vpx-cabinet');
const fullscreenButton = document.getElementById('vpx-fullscreen');
const fullscreenStatus = document.getElementById('fullscreen-status');
let destroyed = false;
let display = null;
let input = null;
const fullscreen = () => void requestCabinetFullscreen({cabinet, status: fullscreenStatus});
const onPageHide = () => {
input?.releaseAll?.(true);
destroy();
};
const destroy = () => {
if (destroyed) return;
destroyed = true;
fullscreenButton.removeEventListener('click', fullscreen);
input?.destroy();
display?.destroy();
window.removeEventListener('pagehide', onPageHide);
};
fullscreenButton.addEventListener('click', fullscreen);
window.addEventListener('pagehide', onPageHide);
display = await mountDisplay({
container: document.getElementById('vpx-display'),
selector: document.getElementById('vpx-provider'),
statusElement: document.getElementById('vpx-display-status'),
configUrl: '/api/vpx-display',
ownership: () => !destroyed,
});
if (destroyed) {
display.destroy();
return {destroy};
}
const postEvent = createDashboardEventTransport({fetch: (...args) => window.fetch(...args), refresh: () => {}});
input = installDashboardInput({
window, document, modeSelector: document.getElementById('vpx-input-mode'), postEvent,
setButton: (name, active) => document.querySelectorAll(`[data-cabinet="${name}"]`)
.forEach(button => button.classList.toggle('active', active)),
});
return {destroy};
}
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pokémon Pro — VPX window</title>
<style>
:root { color-scheme:dark; background:#05080a; color:#eef3f7; font-family:ui-sans-serif,system-ui,sans-serif; }
* { box-sizing:border-box; }
html,body { width:100%; height:100%; margin:0; overflow:hidden; }
#vpx-cabinet { width:100%; height:100vh; height:100dvh; min-height:0; display:grid;
grid-template-rows:auto minmax(0,1fr) auto; background:#05080a; }
.toolbar,.cabinet-buttons { display:flex; flex-wrap:wrap; align-items:center; gap:8px; padding:8px 12px; background:#111c23; }
.toolbar { border-bottom:1px solid #263640; }
.toolbar strong { margin-right:auto; }
#vpx-display { min-width:0; min-height:0; display:grid; place-items:center; overflow:hidden; }
#vpx-display canvas { width:100%; height:100%; min-width:0; min-height:0; object-fit:contain; display:block; }
.cabinet-buttons { border-top:1px solid #263640; }
button { min-height:32px; padding:5px 10px; border:1px solid #50616f; border-radius:6px; background:#1d2832; color:#f7fbff; cursor:pointer; }
button.active { background:#356d98; border-color:#8fd0ff; box-shadow:0 0 12px #4acbff88; }
.status { color:#b9c9d2; font-size:12px; }
.error { color:#ff9ca5; font-size:12px; }
#vpx-provider,#vpx-input-mode { position:fixed; width:1px; height:1px; clip-path:inset(50%); overflow:hidden; }
</style>
</head>
<body>
<main id="vpx-cabinet">
<header class="toolbar">
<strong>Pokémon Pro — Live VPX</strong>
<span id="vpx-display-status" class="status" role="status">Connecting…</span>
<button id="vpx-fullscreen" type="button">Fullscreen</button>
<span id="fullscreen-status" class="error" role="alert"></span>
<select id="vpx-provider" aria-label="VPX video provider"><option value="sck">Native window capture</option></select>
<select id="vpx-input-mode" aria-label="Input mode"><option value="live" selected>Live</option></select>
</header>
<section id="vpx-display" aria-label="Live VPX stream"></section>
<nav class="cabinet-buttons" aria-label="Physical cabinet controls">
<button type="button" data-cabinet="left_flipper">Left flipper</button>
<button type="button" data-cabinet="right_flipper">Right flipper</button>
<button type="button" data-cabinet="start">Start</button>
<button type="button" data-cabinet="coin">Coin</button>
<button type="button" data-cabinet="action_button">Action</button>
<button type="button" data-cabinet="launch_ball">Physical Launch</button>
</nav>
</main>
<script type="module">
import {mountDisplay} from '/assets/display.js';
import {installDashboardInput, createDashboardEventTransport} from '/assets/dashboard-input.js';
import {setupVpxWindow} from '/assets/vpx-window.js';
setupVpxWindow({window, document, mountDisplay, installDashboardInput, createDashboardEventTransport});
</script>
</body>
</html>
@@ -0,0 +1,67 @@
FROM debian:bookworm
ARG FETCH_ARM64_KERNEL=1
ENV DEBIAN_FRONTEND=noninteractive
RUN dpkg --add-architecture arm64 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
binfmt-support \
busybox-static \
ca-certificates \
cpio \
curl \
dosfstools \
e2fsprogs \
fdisk \
ffmpeg \
file \
gcc-aarch64-linux-gnu \
git \
initramfs-tools \
jq \
kmod \
libegl-mesa0:arm64 \
libegl1:arm64 \
libasound2-dev:arm64 \
libavcodec-dev:arm64 \
libavutil-dev:arm64 \
libgbm1:arm64 \
libgbm-dev:arm64 \
libgl1-mesa-dri:arm64 \
libglapi-mesa:arm64 \
libgles2:arm64 \
libswscale-dev:arm64 \
libyuv-dev:arm64 \
libglvnd0:arm64 \
libglx-mesa0:arm64 \
mesa-vulkan-drivers:arm64 \
libc6-dev-arm64-cross \
make \
netcat-openbsd \
novnc \
procps \
psmisc \
python3 \
python3-pil \
python3-websockify \
qemu-system-arm \
qemu-user-static \
qemu-utils \
rsync \
socat \
util-linux \
wget \
xz-utils \
zstd \
&& rm -rf /var/lib/apt/lists/*
COPY emulation/docker/fetch-debian-arm64-kernel.sh /usr/local/bin/fetch-debian-arm64-kernel
COPY emulation/docker/entrypoint.sh /opt/spike3/entrypoint.sh
RUN chmod +x /usr/local/bin/fetch-debian-arm64-kernel /opt/spike3/entrypoint.sh \
&& if [ "$FETCH_ARM64_KERNEL" = "1" ]; then fetch-debian-arm64-kernel; fi
EXPOSE 6080 8090 8088 8345
ENTRYPOINT ["/opt/spike3/entrypoint.sh"]
@@ -0,0 +1,48 @@
FROM debian:bookworm AS builder
ARG BUILDROOT_VERSION=2024.08.1
ENV DEBIAN_FRONTEND=noninteractive
ENV FORCE_UNSAFE_CONFIGURE=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bc \
bison \
build-essential \
ca-certificates \
cpio \
file \
flex \
git \
libncurses-dev \
patch \
perl \
python3 \
rsync \
unzip \
wget \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt
RUN wget -q "https://buildroot.org/downloads/buildroot-${BUILDROOT_VERSION}.tar.gz" \
&& tar -xzf "buildroot-${BUILDROOT_VERSION}.tar.gz" \
&& rm "buildroot-${BUILDROOT_VERSION}.tar.gz"
WORKDIR /opt/buildroot-${BUILDROOT_VERSION}
COPY emulation/buildroot/spike3_mesa_defconfig configs/spike3_mesa_defconfig
RUN make spike3_mesa_defconfig \
&& make -j"$(nproc)"
RUN mkdir -p /opt/spike3-mesa-bundle/rootfs/usr/lib \
&& cp -a output/target/usr/lib/libEGL.so* /opt/spike3-mesa-bundle/rootfs/usr/lib/ \
&& cp -a output/target/usr/lib/libGLES*.so* /opt/spike3-mesa-bundle/rootfs/usr/lib/ \
&& cp -a output/target/usr/lib/libgbm.so* /opt/spike3-mesa-bundle/rootfs/usr/lib/ \
&& cp -a output/target/usr/lib/libglapi.so* /opt/spike3-mesa-bundle/rootfs/usr/lib/ \
&& cp -a output/target/usr/lib/libdrm.so* /opt/spike3-mesa-bundle/rootfs/usr/lib/ \
&& cp -a output/target/usr/lib/libexpat.so* /opt/spike3-mesa-bundle/rootfs/usr/lib/ \
&& cp -a output/target/usr/lib/libz.so* /opt/spike3-mesa-bundle/rootfs/usr/lib/ \
&& cp -a output/target/usr/lib/dri /opt/spike3-mesa-bundle/rootfs/usr/lib/
FROM scratch
COPY --from=builder /opt/spike3-mesa-bundle/ /
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
set -euo pipefail
workspace=${SPIKE3_WORKSPACE:-/workspace}
work_dir="$workspace/emulation/work"
log_dir="$work_dir/logs"
mkdir -p "$log_dir"
log() {
printf '[entrypoint] %s\n' "$*"
}
start_novnc() {
log "starting noVNC on :6080 for QEMU VNC :5900"
web_root=/usr/share/novnc
if [ ! -d "$web_root" ]; then
web_root=/usr/share/novnc/utils
fi
local served_root="$work_dir/novnc-web"
mkdir -p "$served_root"
find "$web_root" -mindepth 1 -maxdepth 1 -exec ln -sfn {} "$served_root"/ \;
cat > "$served_root/index.html" <<'EOF'
<!doctype html>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=/vnc.html?host=localhost&port=6080&autoconnect=true">
<title>SPIKE 3 Emulator Display</title>
<a href="/vnc.html?host=localhost&port=6080&autoconnect=true">Open display</a>
EOF
websockify --web "$served_root" 0.0.0.0:6080 127.0.0.1:5900 >"$log_dir/novnc.log" 2>&1 &
}
start_conagent_backend() {
local script="$workspace/analysis/conagent-machine-emulator/emulator.py"
if [ ! -f "$script" ]; then
log "conagent backend emulator not found at $script"
return
fi
log "starting conagent backend emulator on :8088 and :8345"
python3 "$script" \
--config "$workspace/analysis/conagent-machine-emulator/config.example.json" \
--bind-host 0.0.0.0 \
--public-host localhost \
>"$log_dir/conagent-backend.log" 2>&1 &
}
start_dashboard() {
log "starting dashboard on :8090"
python3 "$workspace/emulation/dashboard/server.py" \
--workspace "$workspace" \
--host 0.0.0.0 \
--port 8090 \
>"$log_dir/dashboard.log" 2>&1 &
}
start_netbridge_emulator() {
if [ "${SPIKE3_NETBRIDGE_EMU:-0}" != "1" ]; then
return
fi
export SPIKE3_NETBRIDGE_SOCKET=${SPIKE3_NETBRIDGE_SOCKET:-$work_dir/netbridge.sock}
local topper_args=()
if [[ "${SPIKE3_MAGIKARP_TOPPER:-0}" == "1" ]]; then
topper_args+=(--magikarp-topper)
fi
rm -f "$SPIKE3_NETBRIDGE_SOCKET"
log "starting netbridge emulator on $SPIKE3_NETBRIDGE_SOCKET"
python3 "$workspace/analysis/netbridge-emulator/netbridge_emu.py" \
--unix-socket "$SPIKE3_NETBRIDGE_SOCKET" \
--state-file "$work_dir/switch-state.json" \
--event-file "$work_dir/dashboard-events.jsonl" \
--output-state-file "$work_dir/output-state.json" \
--guest-control-socket "$work_dir/control.sock" \
--cpu-spi-layout "$workspace/emulation/cpu-spi/pokemon-pro-service-panel.json" \
"${topper_args[@]}" \
>"$log_dir/netbridge-emulator.log" 2>&1 &
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -S "$SPIKE3_NETBRIDGE_SOCKET" ] && return
sleep 0.1
done
log "netbridge emulator socket was not created yet; QEMU may fail to connect"
}
prepare_image() {
if [ "${SPIKE3_SKIP_PREPARE:-0}" = "1" ]; then
export SPIKE3_PREPARE_MODE=never
fi
export SPIKE3_PREPARE_MODE=${SPIKE3_PREPARE_MODE:-auto}
"$workspace/emulation/scripts/prepare-rootfs-image.sh"
}
start_qemu() {
if [ "${SPIKE3_NO_QEMU:-0}" = "1" ]; then
log "SPIKE3_NO_QEMU=1, not starting QEMU"
tail -f "$log_dir"/*.log
fi
log "starting QEMU, autostart=${SPIKE3_AUTOSTART:-boot-display}"
"$workspace/emulation/scripts/run-qemu.sh" >"$log_dir/qemu-serial.log" 2>&1 &
}
trap 'jobs -p | xargs -r kill; wait || true' TERM INT EXIT
prepare_image
start_novnc
start_conagent_backend
start_dashboard
start_netbridge_emulator
start_qemu
wait -n
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -euo pipefail
out_dir=${SPIKE3_KERNEL_DIR:-/opt/spike3-kernel}
tmp_dir=$(mktemp -d)
cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT
mkdir -p "$out_dir"
dpkg --add-architecture arm64
apt-get update
cd "$tmp_dir"
download_kernel_package() {
local meta="$1"
local dep
dep=$(apt-cache depends "$meta" \
| awk '/Depends: linux-image-[0-9].*-arm64/ { print $2; exit }')
if [ -z "$dep" ]; then
return 1
fi
apt-get download "${dep%:arm64}:arm64"
}
if ! download_kernel_package linux-image-arm64:arm64; then
download_kernel_package linux-image-cloud-arm64:arm64
fi
for deb in ./*.deb; do
dpkg-deb -x "$deb" extracted
done
kernel=$(find extracted -path '*/boot/vmlinuz-*' -type f | sort | tail -n 1)
if [ -z "$kernel" ]; then
echo "No ARM64 vmlinuz found in downloaded kernel package" >&2
exit 1
fi
cp "$kernel" "$out_dir/vmlinuz"
if [ -d extracted/lib/modules ]; then
mkdir -p "$out_dir/modules/lib"
rsync -a extracted/lib/modules "$out_dir/modules/lib/"
fi
version=$(basename "$(find "$out_dir/modules/lib/modules" -maxdepth 1 -mindepth 1 -type d | sort | tail -n 1)")
# Build an ARM64 initramfs explicitly. mkinitramfs on an x86 build host
# otherwise embeds x86 shell/tools alongside the ARM kernel.
apt-get download busybox-static:arm64
mkdir -p busybox-arm64 initramfs/{bin,sbin,dev,proc,sys,newroot,lib/modules}
dpkg-deb -x ./busybox-static_*_arm64.deb busybox-arm64
install -m 0755 busybox-arm64/bin/busybox initramfs/bin/busybox
for applet in sh mount mkdir modprobe sleep switch_root cat; do
ln -s busybox "initramfs/bin/$applet"
done
ln -s ../bin/busybox initramfs/sbin/modprobe
rsync -a "$out_dir/modules/lib/modules/$version" initramfs/lib/modules/
depmod -b "$tmp_dir/initramfs" "$version"
cat > initramfs/init <<'INIT'
#!/bin/sh
export PATH=/bin
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev
modprobe virtio_pci
modprobe virtio_blk
modprobe crc32c_generic
modprobe ext4
for attempt in 1 2 3 4 5 6 7 8 9 10; do
[ -b /dev/vda ] && break
sleep 1
done
mount -t ext4 -o rw /dev/vda /newroot || exec sh
exec switch_root /newroot /usr/local/spike-emu/bin/emu-init
INIT
chmod 0755 initramfs/init
(cd initramfs && find . -print0 | cpio --null -o --format=newc 2>/dev/null | gzip -1) > "$out_dir/initrd.img"
{
echo "kernel_source=$(basename "$kernel")"
echo "kernel_version=${version:-unknown}"
echo "fetched_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
sha256sum "$out_dir/vmlinuz"
if [ -f "$out_dir/initrd.img" ]; then
sha256sum "$out_dir/initrd.img"
fi
} > "$out_dir/manifest.txt"
echo "Installed ARM64 kernel at $out_dir/vmlinuz"
@@ -0,0 +1,9 @@
pcm.!default {
type plug
slave.pcm "hw:0,0"
}
ctl.!default {
type hw
card 0
}
@@ -0,0 +1,318 @@
#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define EGL_PLATFORM_GBM_KHR 0x31d7
#define EGL_PLATFORM_SURFACELESS_MESA 0x31dd
#define EGL_OPENGL_ES_API 0x30a0
#define EGL_NONE 0x3038
#define EGL_RED_SIZE 0x3024
#define EGL_GREEN_SIZE 0x3023
#define EGL_BLUE_SIZE 0x3022
#define EGL_ALPHA_SIZE 0x3021
#define EGL_DEPTH_SIZE 0x3025
#define EGL_SURFACE_TYPE 0x3033
#define EGL_RENDERABLE_TYPE 0x3040
#define EGL_PBUFFER_BIT 0x0001
#define EGL_OPENGL_ES2_BIT 0x0004
#define EGL_CONTEXT_CLIENT_VERSION 0x3098
#define EGL_WIDTH 0x3057
#define EGL_HEIGHT 0x3056
#define GL_VENDOR 0x1f00
#define GL_RENDERER 0x1f01
#define GL_VERSION 0x1f02
static const char *state_path = "/run/spike-emu/display-backend.env";
static void write_state(const char *requested, const char *resolved, const char *reason) {
FILE *fp = fopen(state_path, "w");
if (!fp) {
fprintf(stderr, "display-probe: failed to write %s: %s\n", state_path, strerror(errno));
return;
}
fprintf(fp, "SPIKE3_DISPLAY_BACKEND_REQUESTED=%s\n", requested);
fprintf(fp, "SPIKE3_DISPLAY_BACKEND_RESOLVED=%s\n", resolved);
fprintf(fp, "SPIKE3_DISPLAY_BACKEND_REASON=%s\n", reason);
fclose(fp);
}
static int open_drm_card(void) {
int fd = open("/dev/dri/card1", O_RDWR | O_CLOEXEC);
if (fd >= 0) {
fprintf(stderr, "display-probe: opened /dev/dri/card1 fd=%d\n", fd);
return fd;
}
fprintf(stderr, "display-probe: open /dev/dri/card1 failed: %s\n", strerror(errno));
fd = open("/dev/dri/card0", O_RDWR | O_CLOEXEC);
if (fd >= 0) {
fprintf(stderr, "display-probe: opened /dev/dri/card0 fd=%d\n", fd);
} else {
fprintf(stderr, "display-probe: open /dev/dri/card0 failed: %s\n", strerror(errno));
}
return fd;
}
static bool probe_native(void) {
void *gbm_lib = dlopen("libgbm.so.1", RTLD_NOW | RTLD_LOCAL);
void *egl_lib = dlopen("libEGL.so.1", RTLD_NOW | RTLD_LOCAL);
if (!gbm_lib || !egl_lib) {
fprintf(stderr, "display-probe: dlopen failed gbm=%p egl=%p error=%s\n",
gbm_lib, egl_lib, dlerror());
return false;
}
void *(*gbm_create_device)(int) = dlsym(gbm_lib, "gbm_create_device");
void (*gbm_device_destroy)(void *) = dlsym(gbm_lib, "gbm_device_destroy");
void *(*egl_get_proc_address)(const char *) = dlsym(egl_lib, "eglGetProcAddress");
const char *(*egl_query_string)(void *, int) = dlsym(egl_lib, "eglQueryString");
int (*egl_initialize)(void *, int *, int *) = dlsym(egl_lib, "eglInitialize");
uint32_t (*egl_get_error)(void) = dlsym(egl_lib, "eglGetError");
void *(*egl_get_platform_display_ext)(uint32_t, void *, const intptr_t *) = NULL;
intptr_t no_attribs[] = { EGL_NONE };
if (!gbm_create_device || !egl_get_proc_address || !egl_initialize || !egl_get_error) {
fprintf(stderr, "display-probe: missing required symbols\n");
return false;
}
egl_get_platform_display_ext = egl_get_proc_address("eglGetPlatformDisplayEXT");
if (!egl_get_platform_display_ext) {
egl_get_platform_display_ext = dlsym(egl_lib, "eglGetPlatformDisplayEXT");
}
if (!egl_get_platform_display_ext) {
fprintf(stderr, "display-probe: eglGetPlatformDisplayEXT unavailable\n");
return false;
}
if (egl_query_string) {
fprintf(stderr, "display-probe: EGL client extensions=%s\n",
egl_query_string(NULL, 0x3055) ? egl_query_string(NULL, 0x3055) : "(null)");
}
int fd = open_drm_card();
if (fd < 0) {
return false;
}
void *gbm = gbm_create_device(fd);
fprintf(stderr, "display-probe: gbm_create_device ret=%p errno=%d\n", gbm, errno);
if (!gbm) {
close(fd);
return false;
}
void *display = egl_get_platform_display_ext(EGL_PLATFORM_GBM_KHR, gbm, no_attribs);
uint32_t err = egl_get_error();
fprintf(stderr, "display-probe: eglGetPlatformDisplayEXT(GBM) ret=%p egl_error=0x%x errno=%d\n",
display, err, errno);
if (!display) {
if (gbm_device_destroy) {
gbm_device_destroy(gbm);
}
close(fd);
return false;
}
int major = 0;
int minor = 0;
int ok = egl_initialize(display, &major, &minor);
err = egl_get_error();
fprintf(stderr, "display-probe: eglInitialize ret=%d version=%d.%d egl_error=0x%x errno=%d\n",
ok, major, minor, err, errno);
if (gbm_device_destroy) {
gbm_device_destroy(gbm);
}
close(fd);
return ok == 1;
}
static bool probe_surfaceless(void) {
void *egl_lib = dlopen("libEGL.so.1", RTLD_NOW | RTLD_LOCAL);
void *gles_lib = dlopen("libGLESv2.so.2", RTLD_NOW | RTLD_LOCAL);
if (!egl_lib || !gles_lib) {
fprintf(stderr, "display-probe: surfaceless dlopen failed egl=%p gles=%p error=%s\n",
egl_lib, gles_lib, dlerror());
return false;
}
void *(*egl_get_proc_address)(const char *) = dlsym(egl_lib, "eglGetProcAddress");
const char *(*egl_query_string)(void *, int) = dlsym(egl_lib, "eglQueryString");
void *(*egl_get_platform_display_ext)(uint32_t, void *, const intptr_t *) = NULL;
void *(*egl_get_platform_display)(uint32_t, void *, const intptr_t *) = dlsym(egl_lib, "eglGetPlatformDisplay");
void *(*egl_get_display)(void *) = dlsym(egl_lib, "eglGetDisplay");
int (*egl_initialize)(void *, int *, int *) = dlsym(egl_lib, "eglInitialize");
int (*egl_bind_api)(uint32_t) = dlsym(egl_lib, "eglBindAPI");
int (*egl_choose_config)(void *, const int *, void *, int, int *) = dlsym(egl_lib, "eglChooseConfig");
void *(*egl_create_context)(void *, void *, void *, const int *) = dlsym(egl_lib, "eglCreateContext");
void *(*egl_create_pbuffer_surface)(void *, void *, const int *) = dlsym(egl_lib, "eglCreatePbufferSurface");
int (*egl_make_current)(void *, void *, void *, void *) = dlsym(egl_lib, "eglMakeCurrent");
int (*egl_destroy_surface)(void *, void *) = dlsym(egl_lib, "eglDestroySurface");
int (*egl_destroy_context)(void *, void *) = dlsym(egl_lib, "eglDestroyContext");
int (*egl_terminate)(void *) = dlsym(egl_lib, "eglTerminate");
uint32_t (*egl_get_error)(void) = dlsym(egl_lib, "eglGetError");
const unsigned char *(*gl_get_string)(uint32_t) = dlsym(gles_lib, "glGetString");
intptr_t no_attribs[] = { EGL_NONE };
setenv("EGL_PLATFORM", "surfaceless", 1);
setenv("MESA_LOADER_DRIVER_OVERRIDE", "swrast", 0);
if (egl_get_proc_address) {
egl_get_platform_display_ext = egl_get_proc_address("eglGetPlatformDisplayEXT");
}
if (!egl_get_platform_display_ext) {
egl_get_platform_display_ext = dlsym(egl_lib, "eglGetPlatformDisplayEXT");
}
if (!egl_initialize || !egl_choose_config || !egl_create_context ||
!egl_create_pbuffer_surface || !egl_make_current || !egl_get_error || !gl_get_string) {
fprintf(stderr, "display-probe: surfaceless missing required symbols\n");
return false;
}
if (egl_query_string) {
fprintf(stderr, "display-probe: surfaceless EGL client extensions=%s\n",
egl_query_string(NULL, 0x3055) ? egl_query_string(NULL, 0x3055) : "(null)");
}
void *display = NULL;
if (egl_get_platform_display_ext) {
display = egl_get_platform_display_ext(EGL_PLATFORM_SURFACELESS_MESA, NULL, no_attribs);
fprintf(stderr, "display-probe: surfaceless eglGetPlatformDisplayEXT ret=%p egl_error=0x%x\n",
display, egl_get_error());
}
if (!display && egl_get_platform_display) {
display = egl_get_platform_display(EGL_PLATFORM_SURFACELESS_MESA, NULL, no_attribs);
fprintf(stderr, "display-probe: surfaceless eglGetPlatformDisplay ret=%p egl_error=0x%x\n",
display, egl_get_error());
}
if (!display && egl_get_display) {
display = egl_get_display(NULL);
fprintf(stderr, "display-probe: surfaceless eglGetDisplay ret=%p egl_error=0x%x\n",
display, egl_get_error());
}
if (!display) {
return false;
}
int major = 0;
int minor = 0;
int ok = egl_initialize(display, &major, &minor);
fprintf(stderr, "display-probe: surfaceless eglInitialize ret=%d version=%d.%d egl_error=0x%x\n",
ok, major, minor, egl_get_error());
if (ok != 1) {
return false;
}
if (egl_bind_api) {
ok = egl_bind_api(EGL_OPENGL_ES_API);
fprintf(stderr, "display-probe: surfaceless eglBindAPI ret=%d egl_error=0x%x\n",
ok, egl_get_error());
if (ok != 1) {
return false;
}
}
int config_attribs[] = {
EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
EGL_RED_SIZE, 8,
EGL_GREEN_SIZE, 8,
EGL_BLUE_SIZE, 8,
EGL_ALPHA_SIZE, 8,
EGL_DEPTH_SIZE, 24,
EGL_NONE
};
void *config = NULL;
int num_config = 0;
ok = egl_choose_config(display, config_attribs, &config, 1, &num_config);
fprintf(stderr, "display-probe: surfaceless eglChooseConfig ret=%d num=%d config=%p egl_error=0x%x\n",
ok, num_config, config, egl_get_error());
if (ok != 1 || num_config < 1 || !config) {
return false;
}
int context_attribs[] = {
EGL_CONTEXT_CLIENT_VERSION, 2,
EGL_NONE
};
void *context = egl_create_context(display, config, NULL, context_attribs);
fprintf(stderr, "display-probe: surfaceless eglCreateContext ret=%p egl_error=0x%x\n",
context, egl_get_error());
if (!context) {
return false;
}
int pbuffer_attribs[] = {
EGL_WIDTH, 64,
EGL_HEIGHT, 64,
EGL_NONE
};
void *surface = egl_create_pbuffer_surface(display, config, pbuffer_attribs);
fprintf(stderr, "display-probe: surfaceless eglCreatePbufferSurface ret=%p egl_error=0x%x\n",
surface, egl_get_error());
if (!surface) {
if (egl_destroy_context) {
egl_destroy_context(display, context);
}
return false;
}
ok = egl_make_current(display, surface, surface, context);
fprintf(stderr, "display-probe: surfaceless eglMakeCurrent ret=%d egl_error=0x%x\n",
ok, egl_get_error());
if (ok == 1) {
fprintf(stderr, "display-probe: surfaceless GL_VENDOR=%s\n",
gl_get_string(GL_VENDOR) ? (const char *)gl_get_string(GL_VENDOR) : "(null)");
fprintf(stderr, "display-probe: surfaceless GL_RENDERER=%s\n",
gl_get_string(GL_RENDERER) ? (const char *)gl_get_string(GL_RENDERER) : "(null)");
fprintf(stderr, "display-probe: surfaceless GL_VERSION=%s\n",
gl_get_string(GL_VERSION) ? (const char *)gl_get_string(GL_VERSION) : "(null)");
}
if (egl_destroy_surface) {
egl_destroy_surface(display, surface);
}
if (egl_destroy_context) {
egl_destroy_context(display, context);
}
if (egl_terminate) {
egl_terminate(display);
}
return ok == 1;
}
int main(int argc, char **argv) {
const char *requested = argc > 1 ? argv[1] : "auto";
if (strcmp(requested, "fbdev") == 0) {
write_state(requested, "fbdev", "requested");
fprintf(stderr, "display-probe: resolved fbdev because it was requested\n");
return 0;
}
bool native_ok = probe_native();
bool surfaceless_ok = false;
if (native_ok &&
(strcmp(requested, "native") == 0 || strcmp(requested, "auto") == 0)) {
write_state(requested, "native", "native-gbm-egl-ok");
fprintf(stderr, "display-probe: resolved native\n");
return 0;
}
surfaceless_ok = probe_surfaceless();
if (strcmp(requested, "native") == 0) {
write_state(requested, "native", "native-requested-probe-failed");
fprintf(stderr, "display-probe: native requested despite probe failure\n");
return 1;
}
write_state(requested, "fbdev",
surfaceless_ok ? "native-gbm-failed-surfaceless-egl-ok" : "native-gbm-and-surfaceless-egl-failed");
fprintf(stderr, "display-probe: resolved fbdev after native probe failure surfaceless_ok=%d\n",
surfaceless_ok ? 1 : 0);
return 0;
}
@@ -0,0 +1,179 @@
#include <ctype.h>
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static uint32_t env_u32(const char *name, uint32_t fallback) {
const char *value = getenv(name);
if (!value || !*value) {
return fallback;
}
char *end = NULL;
unsigned long parsed = strtoul(value, &end, 10);
if (!end || *end != '\0' || parsed == 0 || parsed > 16384) {
return fallback;
}
return (uint32_t)parsed;
}
static const uint8_t *glyph_rows(char ch) {
static const uint8_t blank[7] = {0, 0, 0, 0, 0, 0, 0};
static const uint8_t unknown[7] = {14, 17, 1, 2, 4, 0, 4};
static const uint8_t glyphs[][7] = {
['0'] = {14, 17, 19, 21, 25, 17, 14},
['1'] = {4, 12, 4, 4, 4, 4, 14},
['2'] = {14, 17, 1, 2, 4, 8, 31},
['3'] = {30, 1, 1, 14, 1, 1, 30},
['4'] = {2, 6, 10, 18, 31, 2, 2},
['5'] = {31, 16, 30, 1, 1, 17, 14},
['6'] = {6, 8, 16, 30, 17, 17, 14},
['7'] = {31, 1, 2, 4, 8, 8, 8},
['8'] = {14, 17, 17, 14, 17, 17, 14},
['9'] = {14, 17, 17, 15, 1, 2, 12},
['A'] = {14, 17, 17, 31, 17, 17, 17},
['B'] = {30, 17, 17, 30, 17, 17, 30},
['C'] = {14, 17, 16, 16, 16, 17, 14},
['D'] = {30, 17, 17, 17, 17, 17, 30},
['E'] = {31, 16, 16, 30, 16, 16, 31},
['F'] = {31, 16, 16, 30, 16, 16, 16},
['G'] = {14, 17, 16, 23, 17, 17, 15},
['H'] = {17, 17, 17, 31, 17, 17, 17},
['I'] = {14, 4, 4, 4, 4, 4, 14},
['J'] = {7, 2, 2, 2, 18, 18, 12},
['K'] = {17, 18, 20, 24, 20, 18, 17},
['L'] = {16, 16, 16, 16, 16, 16, 31},
['M'] = {17, 27, 21, 21, 17, 17, 17},
['N'] = {17, 25, 21, 19, 17, 17, 17},
['O'] = {14, 17, 17, 17, 17, 17, 14},
['P'] = {30, 17, 17, 30, 16, 16, 16},
['Q'] = {14, 17, 17, 17, 21, 18, 13},
['R'] = {30, 17, 17, 30, 20, 18, 17},
['S'] = {15, 16, 16, 14, 1, 1, 30},
['T'] = {31, 4, 4, 4, 4, 4, 4},
['U'] = {17, 17, 17, 17, 17, 17, 14},
['V'] = {17, 17, 17, 17, 17, 10, 4},
['W'] = {17, 17, 17, 21, 21, 21, 10},
['X'] = {17, 17, 10, 4, 10, 17, 17},
['Y'] = {17, 17, 10, 4, 4, 4, 4},
['Z'] = {31, 1, 2, 4, 8, 16, 31},
[' '] = {0, 0, 0, 0, 0, 0, 0},
['-'] = {0, 0, 0, 31, 0, 0, 0},
['_'] = {0, 0, 0, 0, 0, 0, 31},
[':'] = {0, 4, 4, 0, 4, 4, 0},
['.'] = {0, 0, 0, 0, 0, 12, 12},
['/'] = {1, 1, 2, 4, 8, 16, 16},
['('] = {2, 4, 8, 8, 8, 4, 2},
[')'] = {8, 4, 2, 2, 2, 4, 8},
['='] = {0, 0, 31, 0, 31, 0, 0},
};
unsigned char idx = (unsigned char)ch;
if (idx == ' ') {
return glyphs[(unsigned char)' '];
}
if (idx >= sizeof(glyphs) / sizeof(glyphs[0])) {
return unknown;
}
const uint8_t *rows = glyphs[idx];
for (int i = 0; i < 7; i++) {
if (rows[i]) {
return rows;
}
}
return idx == 0 ? blank : unknown;
}
static void fill_rect(uint32_t *fb, uint32_t width, uint32_t height,
uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint32_t color) {
for (uint32_t yy = y; yy < y + h && yy < height; yy++) {
for (uint32_t xx = x; xx < x + w && xx < width; xx++) {
fb[yy * width + xx] = color;
}
}
}
static void draw_char(uint32_t *fb, uint32_t width, uint32_t height,
uint32_t x, uint32_t y, char ch, uint32_t color, uint32_t scale) {
ch = (char)toupper((unsigned char)ch);
const uint8_t *rows = glyph_rows(ch);
for (uint32_t row = 0; row < 7; row++) {
for (uint32_t col = 0; col < 5; col++) {
if (rows[row] & (1u << (4 - col))) {
fill_rect(fb, width, height, x + col * scale, y + row * scale, scale, scale, color);
}
}
}
}
static void draw_text(uint32_t *fb, uint32_t width, uint32_t height,
uint32_t x, uint32_t y, const char *text, uint32_t color, uint32_t scale) {
uint32_t cursor = x;
uint32_t step = 6 * scale;
for (const char *p = text; p && *p; p++) {
if (*p == '\n' || cursor + 5 * scale >= width - 24) {
y += 9 * scale;
cursor = x;
if (*p == '\n') {
continue;
}
}
draw_char(fb, width, height, cursor, y, *p, color, scale);
cursor += step;
if (y + 8 * scale >= height) {
return;
}
}
}
int main(int argc, char **argv) {
uint32_t width = env_u32("SPIKE3_DISPLAY_WIDTH", 1360);
uint32_t height = env_u32("SPIKE3_DISPLAY_HEIGHT", 768);
uint32_t *fb = calloc((size_t)width * height, sizeof(*fb));
if (!fb) {
fprintf(stderr, "fb-message: allocation failed\n");
return 1;
}
for (uint32_t y = 0; y < height; y++) {
for (uint32_t x = 0; x < width; x++) {
uint8_t shade = (uint8_t)(16 + (y * 32 / (height ? height : 1)));
fb[y * width + x] = ((uint32_t)shade << 16) | ((uint32_t)(shade + 4) << 8) | (shade + 10);
}
}
fill_rect(fb, width, height, 0, 0, width, 86, 0x0032589a);
fill_rect(fb, width, height, 0, 86, width, 6, 0x00ffcc00);
draw_text(fb, width, height, 36, 24, "SPIKE 3 EMULATOR", 0x00ffffff, 5);
uint32_t y = 128;
if (argc <= 1) {
draw_text(fb, width, height, 44, y, "TARGET EXITED", 0x00ffdf80, 4);
} else {
draw_text(fb, width, height, 44, y, argv[1], 0x00ffdf80, 4);
}
y += 74;
for (int i = 2; i < argc && y < height - 40; i++, y += 42) {
draw_text(fb, width, height, 58, y, argv[i], 0x00e8f0ff, 3);
}
int fd = open("/dev/fb0", O_RDWR | O_CLOEXEC);
if (fd < 0) {
fprintf(stderr, "fb-message: open /dev/fb0 failed: %s\n", strerror(errno));
free(fb);
return 1;
}
size_t bytes = (size_t)width * height * sizeof(*fb);
ssize_t wrote = pwrite(fd, fb, bytes, 0);
if (wrote < 0 || (size_t)wrote != bytes) {
fprintf(stderr, "fb-message: framebuffer write failed: %s\n", wrote < 0 ? strerror(errno) : "short write");
close(fd);
free(fb);
return 1;
}
close(fd);
free(fb);
return 0;
}
@@ -0,0 +1,163 @@
#include <errno.h>
#include <dirent.h>
#include <fcntl.h>
#include <linux/input.h>
#include <poll.h>
#include <stdio.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
struct key_mapping {
unsigned short code;
const char *name;
};
static const struct key_mapping mappings[] = {
/*
* Keep the QEMU Cocoa window's keyboard contract identical to the VPX
* playfield contract. The backbox can temporarily gain focus during
* Guided Setup or manual window placement; these events are relayed into
* the same cabinet model instead of becoming stale diagnostic switches.
*/
{KEY_LEFTSHIFT, "left_flipper"},
{KEY_RIGHTSHIFT, "right_flipper"},
{KEY_Z, "nudge_left"},
{KEY_SLASH, "nudge_right"},
{KEY_SPACE, "nudge_center"},
{KEY_1, "start"},
{KEY_5, "coin"},
{KEY_ENTER, "launch_ball"},
{KEY_LEFTALT, "action_button"},
{KEY_7, "service_back"},
{KEY_8, "service_down"},
{KEY_9, "service_up"},
{KEY_0, "service_enter"},
/* Retain C as a compatibility alias for older native test notes. */
{KEY_C, "coin"},
{KEY_X, "left_outlane"},
{KEY_Q, "left_orbit"}, {KEY_W, "left_ramp_enter"},
{KEY_E, "battle_b"}, {KEY_Y, "battle_a"}, {KEY_U, "battle_t1"},
{KEY_I, "battle_t2"}, {KEY_O, "battle_l"}, {KEY_P, "battle_e"},
{KEY_R, "right_ramp_made"},
{KEY_T, "right_orbit"}, {KEY_A, "top_pop"},
{KEY_S, "bottom_pop"}, {KEY_D, "right_eject"},
{KEY_F, "spinner"}, {KEY_G, "rocket_enter"},
};
static int open_keyboard(void) {
char path[64];
char name[128];
for (int index = 0; index < 16; ++index) {
snprintf(path, sizeof(path), "/dev/input/event%d", index);
int fd = open(path, O_RDONLY | O_NONBLOCK);
if (fd < 0) {
continue;
}
memset(name, 0, sizeof(name));
if (ioctl(fd, EVIOCGNAME(sizeof(name) - 1), name) >= 0 &&
(strstr(name, "Keyboard") || strstr(name, "keyboard"))) {
fprintf(stderr, "keyboard-switch-relay: using %s (%s)\n", path, name);
return fd;
}
close(fd);
}
return -1;
}
static int open_relay(void) {
DIR *directory = opendir("/sys/class/virtio-ports");
if (!directory) {
return -1;
}
struct dirent *entry;
char sysfs_path[512];
char port_name[128];
char device_path[512];
while ((entry = readdir(directory)) != NULL) {
if (strncmp(entry->d_name, "vport", 5) != 0) {
continue;
}
snprintf(sysfs_path, sizeof(sysfs_path),
"/sys/class/virtio-ports/%s/name", entry->d_name);
FILE *name_file = fopen(sysfs_path, "r");
if (!name_file) {
continue;
}
port_name[0] = '\0';
if (!fgets(port_name, sizeof(port_name), name_file)) {
fclose(name_file);
continue;
}
fclose(name_file);
port_name[strcspn(port_name, "\r\n")] = '\0';
if (strcmp(port_name, "spike3.keyboard") != 0) {
continue;
}
snprintf(device_path, sizeof(device_path), "/dev/%s", entry->d_name);
int fd = open(device_path, O_WRONLY | O_NONBLOCK);
closedir(directory);
if (fd >= 0) {
fprintf(stderr, "keyboard-switch-relay: using relay %s\n", device_path);
}
return fd;
}
closedir(directory);
/* Keep compatibility with root filesystems that do run udev. */
return open("/dev/virtio-ports/spike3.keyboard", O_WRONLY | O_NONBLOCK);
}
static const char *switch_name(unsigned short code) {
for (size_t index = 0; index < sizeof(mappings) / sizeof(mappings[0]); ++index) {
if (mappings[index].code == code) {
return mappings[index].name;
}
}
return NULL;
}
int main(void) {
int keyboard_fd = -1;
int relay_fd = -1;
struct input_event event;
for (;;) {
if (keyboard_fd < 0) {
keyboard_fd = open_keyboard();
}
if (relay_fd < 0) {
relay_fd = open_relay();
}
if (keyboard_fd < 0 || relay_fd < 0) {
sleep(1);
continue;
}
struct pollfd poll_fd = {.fd = keyboard_fd, .events = POLLIN};
int result = poll(&poll_fd, 1, 1000);
if (result < 0 && errno != EINTR) {
close(keyboard_fd);
keyboard_fd = -1;
continue;
}
if (result <= 0 || !(poll_fd.revents & POLLIN)) {
continue;
}
ssize_t count = read(keyboard_fd, &event, sizeof(event));
if (count != (ssize_t)sizeof(event) || event.type != EV_KEY || event.value == 2) {
continue;
}
const char *name = switch_name(event.code);
if (!name) {
continue;
}
char line[96];
int length = snprintf(line, sizeof(line), "%s %d\n", name, event.value ? 1 : 0);
if (write(relay_fd, line, (size_t)length) != length) {
close(relay_fd);
relay_fd = -1;
}
}
}
+149
View File
@@ -0,0 +1,149 @@
#!/bin/sh
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/spike-emu/bin
export PATH
export SPIKE3_EMU=1
export MESA_SHADER_CACHE_DIR=/tmp/cache/mesa
mountpoint() {
grep -qs " $1 " /proc/mounts
}
mount_fs() {
type="$1"
src="$2"
dst="$3"
opts="$4"
mkdir -p "$dst"
if ! mountpoint "$dst"; then
mount -t "$type" -o "$opts" "$src" "$dst" 2>/dev/null || true
fi
}
cmdline_value() {
key="$1"
tr ' ' '\n' < /proc/cmdline | sed -n "s/^$key=//p" | tail -n 1
}
mount_fs proc proc /proc defaults
mount_fs sysfs sysfs /sys defaults
mount_fs devtmpfs devtmpfs /dev mode=0755
mount_fs devpts devpts /dev/pts gid=5,mode=620,ptmxmode=0666
mount_fs tmpfs tmpfs /run mode=0755,nosuid,nodev
mount_fs tmpfs tmpfs /tmp mode=1777
mount_fs tmpfs tmpfs /dev/shm mode=1777,nosuid,nodev
mkdir -p /tmp/cache/mesa /connectivity/dump/log/spike-emu /connectivity/dump/cores
mkdir -p /connectivity/dump/spike-emu
mkdir -p /connectivity/alsa /connectivity/bluetooth /connectivity/bluealsa
mkdir -p /var/lib
ln -sfn /connectivity/alsa /var/lib/alsa
ln -sfn /connectivity/bluetooth /var/lib/bluetooth
ln -sfn /connectivity/bluealsa /var/lib/bluealsa
# Restricted QEMU forwarding exposes only the local mock service. This is a
# generated-emulator option, never a change to the evidence network config.
if [ "$(cmdline_value spike3.local_ic)" = 1 ]; then
modprobe virtio_net || echo "Local IC: failed to load virtio_net" >&2
ip link set lo up
ip link set eth0 up
ip addr replace 10.0.2.15/24 dev eth0
ip route replace default via 10.0.2.2 dev eth0
fi
hostname spike3-emu 2>/dev/null || true
syslogd 2>/dev/null || true
klogd 2>/dev/null || true
hw_log=/connectivity/dump/log/spike-emu/hardware.log
{
echo "hardware setup starting"
for module in drm drm_kms_helper virtio virtio_ring virtio_pci virtio_gpu virtio_input evdev 8250 8250_pci snd_hda_intel; do
if modprobe "$module" 2>/dev/null; then
echo "modprobe $module: ok"
else
echo "modprobe $module: failed"
fi
done
module_root="/lib/modules/$(uname -r)/kernel"
for module_path in \
"$module_root/drivers/char/virtio_console.ko" \
"$module_root/drivers/virtio/virtio_input.ko" \
"$module_root/drivers/input/evdev.ko" \
"$module_root/sound/soundcore.ko" \
"$module_root/sound/core/snd.ko" \
"$module_root/sound/core/snd-timer.ko" \
"$module_root/sound/core/snd-pcm.ko" \
"$module_root/sound/core/snd-hwdep.ko" \
"$module_root/sound/hda/snd-hda-core.ko" \
"$module_root/sound/pci/hda/snd-hda-codec.ko" \
"$module_root/drivers/leds/trigger/ledtrig-audio.ko" \
"$module_root/sound/pci/hda/snd-hda-codec-generic.ko" \
"$module_root/sound/hda/snd-intel-dspcfg.ko" \
"$module_root/sound/pci/hda/snd-hda-intel.ko"; do
module_name="$(basename "$module_path" .ko | tr '-' '_')"
if [ -f "$module_path" ] && insmod "$module_path" 2>/dev/null; then
echo "insmod $module_path: ok"
elif grep -q "^$module_name " /proc/modules 2>/dev/null; then
echo "insmod $module_path: already loaded"
else
echo "insmod $module_path: failed"
fi
done
} >>"$hw_log" 2>&1
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -e /dev/dri/card0 ] && break
sleep 0.2
done
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -e /dev/snd/pcmC0D0p ] && break
sleep 0.2
done
if [ -e /dev/dri/card0 ] && [ ! -e /dev/dri/card1 ]; then
ln -s card0 /dev/dri/card1 2>/dev/null || true
fi
{
echo "--- /dev/dri ---"
ls -l /dev/dri 2>&1 || true
echo "--- /sys/class/drm ---"
ls -l /sys/class/drm 2>&1 || true
echo "--- lsmod drm/virtio ---"
lsmod 2>/dev/null | grep -E 'drm|virtio' || true
echo "--- /dev/snd ---"
ls -l /dev/snd 2>&1 || true
echo "--- ALSA cards/PCM ---"
cat /proc/asound/cards 2>&1 || true
cat /proc/asound/pcm 2>&1 || true
} >>"$hw_log" 2>&1
/usr/local/spike-emu/bin/setup-fake-devices.sh
/usr/local/spike-emu/bin/guest-control-agent.sh &
/usr/local/spike-emu/bin/keyboard-switch-relay \
>>/connectivity/dump/log/spike-emu/keyboard-relay.log 2>&1 &
autostart="$(cmdline_value spike3.autostart)"
[ -n "$autostart" ] || autostart=boot-display
echo "SPIKE3 emulator init complete; autostart=$autostart" >/dev/console
(
/usr/local/spike-emu/bin/launch-target.sh "$autostart" 2>&1
) | tee /connectivity/dump/log/spike-emu/launch.log >/dev/console &
# Explicit local debugger console; absent from normal launches. The host must
# also add a private virtserialport named spike3.diagnostic.
if [ "$(cmdline_value spike3.startup_diagnostics)" = 1 ]; then
(
while true; do
for namefile in /sys/class/virtio-ports/*/name; do
[ "$(cat "$namefile")" = "spike3.diagnostic" ] || continue
port=/dev/$(basename "$(dirname "$namefile")")
( exec 3<>"$port"; /bin/sh <&3 >&3 2>&1 )
done
sleep 1
done
) &
fi
while true; do
sleep 3600
done
@@ -0,0 +1,100 @@
#!/bin/sh
log=/connectivity/dump/log/spike-emu/control-agent.log
CPU_SPI_STATE_FILE=/run/spike-emu/cpu-spi-state.hex
CPU_SPI_SAFE_DEFAULT=ff7fffffffffffff
find_control_port() {
if [ -c /dev/virtio-ports/spike3.control ]; then
printf '%s\n' /dev/virtio-ports/spike3.control
return 0
fi
for name_path in /sys/class/virtio-ports/*/name; do
[ -r "$name_path" ] || continue
IFS= read -r name < "$name_path" || continue
port_name=$name
[ "$name" = "spike3.control" ] || continue
[ "$port_name" = spike3.control ] || continue
port_id=$(basename "$(dirname "$name_path")")
[ -c "/dev/$port_id" ] || continue
printf '%s\n' "/dev/$port_id"
return 0
done
return 1
}
write_cpu_spi_state() {
state=$1
[ "${#state}" -eq 16 ] || return 1
case "$state" in
*[!0-9A-Fa-f]*) return 1 ;;
esac
tmp="${CPU_SPI_STATE_FILE}.tmp.$$"
umask 077
if ! printf '%s' "$state" > "$tmp"; then
rm -f "$tmp"
return 1
fi
if ! mv "$tmp" "$CPU_SPI_STATE_FILE"; then
rm -f "$tmp"
return 1
fi
}
mkdir -p "$(dirname "$log")" /run/spike-emu
# The same active-low safe state is written by the original VPX agent as:
# write_cpu_spi_state ff 7f ff ff ff ff ff ff
if ! write_cpu_spi_state "$CPU_SPI_SAFE_DEFAULT"; then
printf '%s\n' "failed to initialize CPU-SPI state" >> "$log"
exit 1
fi
echo "control agent starting" >> "$log"
echo "spike3-control-agent: starting" > /dev/console 2>/dev/null || true
while true; do
port="$(find_control_port 2>/dev/null || true)"
if [ -n "$port" ]; then
if ! exec 3<>"$port"; then
printf '%s unable to open %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$port" >> "$log"
sleep 1
continue
fi
echo "using virtio serial port $port" >> "$log"
echo "spike3-control-agent: connected $port" > /dev/console 2>/dev/null || true
while IFS=' ' read -r protocol seq_token revision_token ts_token state_token extra <&3; do
[ "$protocol" = "cpu-spi-state/v1" ] || continue
[ -z "$extra" ] || continue
seq=${seq_token#seq=}
revision=${revision_token#revision=}
ts_mono_ns=${ts_token#ts_mono_ns=}
state=${state_token#state=}
[ "$seq_token" != "$seq" ] || continue
[ "$revision_token" != "$revision" ] || continue
[ "$ts_token" != "$ts_mono_ns" ] || continue
[ "$state_token" != "$state" ] || continue
case "$seq:$revision:$ts_mono_ns" in
*[!0-9:]*) continue ;;
*::*|:*|*:) continue ;;
esac
if write_cpu_spi_state "$state"; then
printf 'cpu-spi-ack/v1 seq=%s revision=%s\n' "$seq" "$revision" >&3
printf '%s cpu-spi-state seq=%s revision=%s state=%s\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$seq" "$revision" "$state" \
>> /run/spike-emu/events.log
printf '%s cpu-spi-state seq=%s revision=%s state=%s\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$seq" "$revision" "$state" \
>> "$log"
else
printf '%s invalid CPU-SPI state\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$log"
fi
done
exec 3>&-
exec 3<&-
echo "spike3-control-agent: disconnected $port" > /dev/console 2>/dev/null || true
fi
sleep 1
done
+268
View File
@@ -0,0 +1,268 @@
#!/bin/sh
target="${1:-boot-display}"
log_dir=/connectivity/dump/log/spike-emu
mkdir -p "$log_dir"
mkdir -p /run/spike-emu
export PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/spike-emu/bin
export MESA_SHADER_CACHE_DIR=/tmp/cache/mesa
export SPIKE3_EMU=1
cmdline_graphics_stack="$(sed -n 's/.*spike3.graphics_stack=\([^ ]*\).*/\1/p' /proc/cmdline | tail -n 1)"
cmdline_enable_ld_preload="$(sed -n 's/.*spike3.enable_ld_preload=\([^ ]*\).*/\1/p' /proc/cmdline | tail -n 1)"
export SPIKE3_GRAPHICS_STACK="${SPIKE3_GRAPHICS_STACK:-${cmdline_graphics_stack:-target}}"
export SPIKE3_ENABLE_LD_PRELOAD="${SPIKE3_ENABLE_LD_PRELOAD:-${cmdline_enable_ld_preload:-0}}"
export SPIKE3_DISPLAY_WIDTH="${SPIKE3_DISPLAY_WIDTH:-$(sed -n 's/.*spike3.width=\([^ ]*\).*/\1/p' /proc/cmdline | tail -n 1)}"
export SPIKE3_DISPLAY_HEIGHT="${SPIKE3_DISPLAY_HEIGHT:-$(sed -n 's/.*spike3.height=\([^ ]*\).*/\1/p' /proc/cmdline | tail -n 1)}"
cmdline_display_backend="$(sed -n 's/.*spike3.display_backend=\([^ ]*\).*/\1/p' /proc/cmdline | tail -n 1)"
export SPIKE3_DISPLAY_BACKEND="${SPIKE3_DISPLAY_BACKEND:-$cmdline_display_backend}"
cmdline_netbridge_serial="$(sed -n 's/.*spike3.netbridge_serial=\([^ ]*\).*/\1/p' /proc/cmdline | tail -n 1)"
if [ "$cmdline_netbridge_serial" = "1" ]; then
export SPIKE3_REAL_NETBRIDGE_SERIAL=1
fi
[ -n "$SPIKE3_DISPLAY_WIDTH" ] || SPIKE3_DISPLAY_WIDTH=1360
[ -n "$SPIKE3_DISPLAY_HEIGHT" ] || SPIKE3_DISPLAY_HEIGHT=768
case "$SPIKE3_GRAPHICS_STACK" in
target)
# Preserve the machine's coherent Buildroot 2024.08.1 / Mesa 24.0.9
# userspace. It only supports the original VC4/V3D hardware, but remains
# the reference stack and must not be silently mixed with Debian Mesa.
export LIBGL_DRIVERS_PATH=${LIBGL_DRIVERS_PATH:-/usr/lib/dri}
;;
buildroot-software)
bundle=/usr/local/spike-emu/buildroot-mesa
if [ ! -d "$bundle/lib/dri" ]; then
echo "Buildroot Mesa bundle is missing: $bundle/lib/dri" >&2
exit 3
fi
export LD_LIBRARY_PATH="$bundle/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export LIBGL_DRIVERS_PATH="$bundle/lib/dri"
export GBM_ALWAYS_SOFTWARE=1
export LIBGL_ALWAYS_SOFTWARE=1
export MESA_LOADER_DRIVER_OVERRIDE=${MESA_LOADER_DRIVER_OVERRIDE:-kms_swrast}
export GALLIUM_DRIVER=${GALLIUM_DRIVER:-softpipe}
;;
debian-legacy)
# Retained only to reproduce the old experiment. This is intentionally no
# longer the default because it mixes Mesa 22.3 with the target ABI.
export LIBGL_DRIVERS_PATH=/usr/local/spike-emu/mesa-dri:/usr/lib/dri:/usr/lib64/dri
export LD_LIBRARY_PATH=/usr/local/spike-emu/mesa-lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}
export GBM_ALWAYS_SOFTWARE=1
export LIBGL_ALWAYS_SOFTWARE=1
export MESA_LOADER_DRIVER_OVERRIDE=${MESA_LOADER_DRIVER_OVERRIDE:-kms_swrast}
export GALLIUM_DRIVER=${GALLIUM_DRIVER:-softpipe}
;;
debian-llvmpipe)
# Diagnostic software-rendering path using the already bundled Debian Mesa
# overlay. llvmpipe uses LLVM and multiple guest CPU threads, unlike the
# coherent Buildroot bundle's deliberately minimal softpipe renderer.
export LIBGL_DRIVERS_PATH=/usr/local/spike-emu/mesa-dri:/usr/lib/dri:/usr/lib64/dri
export LD_LIBRARY_PATH=/usr/local/spike-emu/mesa-lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}
export GBM_ALWAYS_SOFTWARE=1
export LIBGL_ALWAYS_SOFTWARE=1
export MESA_LOADER_DRIVER_OVERRIDE=${MESA_LOADER_DRIVER_OVERRIDE:-kms_swrast}
export GALLIUM_DRIVER=${GALLIUM_DRIVER:-llvmpipe}
;;
*)
echo "Unknown SPIKE3_GRAPHICS_STACK=$SPIKE3_GRAPHICS_STACK" >&2
exit 2
;;
esac
export EGL_LOG_LEVEL=${EGL_LOG_LEVEL:-debug}
export LIBGL_DEBUG=${LIBGL_DEBUG:-verbose}
export MESA_DEBUG=${MESA_DEBUG:-1}
export SPIKE3_STUB_CONSOLE=${SPIKE3_STUB_CONSOLE:-1}
cmdline_stub_file_log="$(sed -n 's/.*spike3.stub_file_log=\([^ ]*\).*/\1/p' /proc/cmdline | tail -n 1)"
export SPIKE3_STUB_FILE_LOG=${SPIKE3_STUB_FILE_LOG:-${cmdline_stub_file_log:-0}}
cmdline_capture_target_logs="$(sed -n 's/.*spike3.capture_target_logs=\([^ ]*\).*/\1/p' /proc/cmdline | tail -n 1)"
export SPIKE3_CAPTURE_TARGET_LOGS=${SPIKE3_CAPTURE_TARGET_LOGS:-${cmdline_capture_target_logs:-0}}
cmdline_video_threads="$(sed -n 's/.*spike3.video_threads=\([^ ]*\).*/\1/p' /proc/cmdline | tail -n 1)"
export SPIKE3_VIDEO_THREADS=${SPIKE3_VIDEO_THREADS:-${cmdline_video_threads:-}}
cmdline_video_bridge="$(sed -n 's/.*spike3.video_bridge=\([^ ]*\).*/\1/p' /proc/cmdline | tail -n 1)"
export SPIKE3_VIDEO_BRIDGE=${SPIKE3_VIDEO_BRIDGE:-${cmdline_video_bridge:-0}}
export EGL_PLATFORM=${EGL_PLATFORM:-surfaceless}
export SPIKE3_DISPLAY_BACKEND_REQUESTED=${SPIKE3_DISPLAY_BACKEND:-auto}
export SPIKE3_DISABLE_HW_VIDEO=${SPIKE3_DISABLE_HW_VIDEO:-1}
if [ "$target" = "game-headless" ]; then
SPIKE3_DISPLAY_BACKEND_RESOLVED=headless
elif [ -x /usr/local/spike-emu/bin/display-probe ]; then
/usr/local/spike-emu/bin/display-probe "$SPIKE3_DISPLAY_BACKEND_REQUESTED" \
> "$log_dir/display-probe.log" 2>&1 || true
if [ -f /run/spike-emu/display-backend.env ]; then
. /run/spike-emu/display-backend.env
fi
else
SPIKE3_DISPLAY_BACKEND_RESOLVED=${SPIKE3_DISPLAY_BACKEND_REQUESTED}
fi
SPIKE3_DISPLAY_BACKEND_RESOLVED=${SPIKE3_DISPLAY_BACKEND_RESOLVED:-auto}
if [ "$SPIKE3_DISPLAY_BACKEND_RESOLVED" = "auto" ]; then
SPIKE3_DISPLAY_BACKEND_RESOLVED=native
fi
export SPIKE3_DISPLAY_BACKEND_RESOLVED
export SPIKE3_DISPLAY_BACKEND="$SPIKE3_DISPLAY_BACKEND_RESOLVED"
if [ "$SPIKE3_DISPLAY_BACKEND_RESOLVED" = "fbdev" ]; then
export MESA_LOADER_DRIVER_OVERRIDE=swrast
if [ "$SPIKE3_GRAPHICS_STACK" = "debian-llvmpipe" ]; then
export GALLIUM_DRIVER=llvmpipe
else
export GALLIUM_DRIVER=softpipe
fi
export EGL_PLATFORM=surfaceless
fi
{
echo "launch target=$target"
echo "SPIKE3_DISPLAY_BACKEND_REQUESTED=${SPIKE3_DISPLAY_BACKEND_REQUESTED:-}"
echo "SPIKE3_DISPLAY_BACKEND_RESOLVED=${SPIKE3_DISPLAY_BACKEND_RESOLVED:-}"
echo "SPIKE3_GRAPHICS_STACK=${SPIKE3_GRAPHICS_STACK:-}"
echo "SPIKE3_DISPLAY_WIDTH=${SPIKE3_DISPLAY_WIDTH:-}"
echo "SPIKE3_DISPLAY_HEIGHT=${SPIKE3_DISPLAY_HEIGHT:-}"
echo "LIBGL_DRIVERS_PATH=${LIBGL_DRIVERS_PATH:-}"
echo "LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}"
echo "MESA_LOADER_DRIVER_OVERRIDE=${MESA_LOADER_DRIVER_OVERRIDE:-}"
echo "GALLIUM_DRIVER=${GALLIUM_DRIVER:-}"
echo "VK_ICD_FILENAMES=${VK_ICD_FILENAMES:-}"
echo "__EGL_VENDOR_LIBRARY_FILENAMES=${__EGL_VENDOR_LIBRARY_FILENAMES:-}"
echo "GBM_ALWAYS_SOFTWARE=${GBM_ALWAYS_SOFTWARE:-}"
echo "LIBGL_ALWAYS_SOFTWARE=${LIBGL_ALWAYS_SOFTWARE:-}"
echo "SPIKE3_STUB_CONSOLE=${SPIKE3_STUB_CONSOLE:-}"
echo "SPIKE3_STUB_FILE_LOG=${SPIKE3_STUB_FILE_LOG:-}"
echo "SPIKE3_CAPTURE_TARGET_LOGS=${SPIKE3_CAPTURE_TARGET_LOGS:-}"
echo "SPIKE3_VIDEO_THREADS=${SPIKE3_VIDEO_THREADS:-auto}"
echo "SPIKE3_VIDEO_BRIDGE=${SPIKE3_VIDEO_BRIDGE:-0}"
echo "SPIKE3_DISABLE_HW_VIDEO=${SPIKE3_DISABLE_HW_VIDEO:-}"
echo "SPIKE3_REAL_NETBRIDGE_SERIAL=${SPIKE3_REAL_NETBRIDGE_SERIAL:-}"
echo "EGL_PLATFORM=${EGL_PLATFORM:-}"
ls -l /dev/dri 2>&1 || true
} >> "$log_dir/launch-env.log"
if [ "${SPIKE3_ENABLE_LD_PRELOAD:-0}" = "1" ] && [ -f /usr/local/spike-emu/lib/libspike3emu_stub.so ]; then
export LD_PRELOAD=/usr/local/spike-emu/lib/libspike3emu_stub.so
if [ -f /usr/local/spike-emu/lib/libspike3audio.so ]; then
export LD_PRELOAD=/usr/local/spike-emu/lib/libspike3audio.so:$LD_PRELOAD
fi
if [ -f /usr/local/spike-emu/lib/eeprom_preload.so ]; then
export LD_PRELOAD=/usr/local/spike-emu/lib/eeprom_preload.so:$LD_PRELOAD
fi
fi
show_status_screen() {
title="$1"
shift
if [ -x /usr/local/spike-emu/bin/fb-message ]; then
/usr/local/spike-emu/bin/fb-message "$title" "$@" >> "$log_dir/fb-message.log" 2>&1 || true
fi
}
classify_failure() {
target_name="$1"
rc="$2"
target_log="$3"
reason="TARGET EXITED"
detail1="TARGET=$target_name RC=$rc"
detail2="SEE /CONNECTIVITY/DUMP/LOG/SPIKE-EMU"
detail3="LOG=$(basename "$target_log")"
if grep -q "snd_pcm_prepare\\|Unknown PCM default" "$target_log" 2>/dev/null; then
reason="AUDIO DEFAULT PCM FAILURE"
detail1="ALSA DEFAULT PCM IS NOT USABLE"
detail2="TARGET ABORTED IN SND_PCM_PREPARE"
elif grep -q "Failed to initialize ShaderLibrary\\|Shader failed to compile" "$target_log" 2>/dev/null; then
reason="SHADER LIBRARY INIT FAILURE"
detail1="TARGET REACHED GODOT SHADER SETUP"
detail2="CHECK SOURCE VS PROGRAM BINARY PATH"
elif [ "$rc" -ge 128 ]; then
reason="TARGET CRASHED"
detail1="TARGET=$target_name SIGNAL_RC=$rc"
detail2="DISPLAY PATH IS PAST BLACK SCREEN"
elif grep -q "failed to init bridge thread\\|failed to initialize bridge\\|OpenOCD init failed" "$target_log" 2>/dev/null; then
reason="BRIDGE INIT FAILURE"
detail1="OPENOCD NETBRIDGE HARDWARE MISSING"
detail2="DISPLAY PATH IS PAST BLACK SCREEN"
fi
echo "target $target_name exited rc=$rc reason=$reason" >> "$log_dir/launch.log"
show_status_screen "$reason" "$detail1" "$detail2" "$detail3"
}
run_target() {
target_name="$1"
shift
target_log="$log_dir/${target_name}.stdout.log"
echo "target command: $*" >> "$log_dir/launch.log"
if [ "$SPIKE3_CAPTURE_TARGET_LOGS" = "1" ]; then
: > "$target_log"
"$@" >> "$target_log" 2>&1 &
target_pid=$!
tail -n +1 -f "$target_log" &
tail_pid=$!
wait "$target_pid"
rc=$?
kill "$tail_pid" 2>/dev/null || true
wait "$tail_pid" 2>/dev/null || true
else
target_log=/dev/null
"$@" >/dev/null 2>&1 &
target_pid=$!
wait "$target_pid"
rc=$?
fi
classify_failure "$target_name" "$rc" "$target_log"
exit "$rc"
}
start_local_conagent() {
[ -x /games/conagent ] || return 0
# The production boot always starts conagent beside the game. It does not
# need the graphics/hardware preload used by the game process itself.
(
unset LD_PRELOAD
if [ "$(sed -n 's/.*spike3.local_ic=\([^ ]*\).*/\1/p' /proc/cmdline | tail -n 1)" = 1 ]; then
export LD_PRELOAD=/usr/local/spike-emu/ic/local_transport.so
fi
exec /games/conagent
) >> "$log_dir/conagent.stdout.log" 2>&1 &
conagent_pid=$!
echo "local conagent pid=$conagent_pid" >> "$log_dir/launch.log"
}
case "$target" in
shell)
exec /bin/sh
;;
boot-display)
echo "launching boot_display" >> "$log_dir/launch.log"
run_target boot-display /games/spike3/bin/boot_display \
-background_image /games/spike3/bin/SternLogo_1360x768.png \
-font /games/spike3/bin/VeraMono.ttf \
-font_height 48 \
-message "SPIKE 3 EMU"
;;
spike-menu)
pkill boot_display 2>/dev/null || true
echo "launching SPIKE menu" >> "$log_dir/launch.log"
run_target spike-menu /games/spike3/spike_menu/game
;;
game)
pkill boot_display 2>/dev/null || true
start_local_conagent
echo "launching main game" >> "$log_dir/launch.log"
run_target game /games/game
;;
game-headless)
pkill boot_display 2>/dev/null || true
echo "launching main game with Godot headless display and dummy audio preload=${LD_PRELOAD:-disabled}" >> "$log_dir/launch.log"
run_target game-headless /games/game --headless --audio-driver Dummy
;;
monitors)
echo "launching conagent and game monitors" >> "$log_dir/launch.log"
/etc/init.d/conagent_monitor start >> "$log_dir/conagent-monitor.log" 2>&1 &
run_target monitors /etc/init.d/game_monitor start
;;
*)
echo "unknown target: $target" >&2
exit 2
;;
esac
@@ -0,0 +1,272 @@
#!/bin/sh
set -eu
table=${1:?usage: runtime-trace-uprobe.sh UPROBES.tsv GAME TARGET_PID RAW_OUTPUT}
game=${2:?usage: runtime-trace-uprobe.sh UPROBES.tsv GAME TARGET_PID RAW_OUTPUT}
target_pid=${3:?usage: runtime-trace-uprobe.sh UPROBES.tsv GAME TARGET_PID RAW_OUTPUT}
raw_output=${4:?usage: runtime-trace-uprobe.sh UPROBES.tsv GAME TARGET_PID RAW_OUTPUT}
tracefs=${SPIKE3_TRACEFS_ROOT:-/sys/kernel/tracing}
proc_root=${SPIKE3_PROC_ROOT:-/proc}
console=${SPIKE3_TRACE_CONSOLE:-/dev/console}
group=spike3_rules
mounted_tracefs=0
created_names=
refresh_pid=
tab=$(printf '\t')
cleanup() {
if [ -n "$refresh_pid" ]; then
kill "$refresh_pid" 2>/dev/null || true
wait "$refresh_pid" 2>/dev/null || true
fi
for created_name in $created_names; do
enable="$tracefs/events/$group/$created_name/enable"
[ ! -e "$enable" ] || printf '0\n' > "$enable" 2>/dev/null || true
printf '%s\n' "-:$group/$created_name" >> "$tracefs/uprobe_events" 2>/dev/null || true
done
if [ "$mounted_tracefs" = 1 ]; then
umount "$tracefs" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM HUP
case "$target_pid" in
''|*[!0-9]*) echo "invalid target PID: $target_pid" >&2; exit 1 ;;
esac
[ -r "$table" ] || { echo "uprobe table is not readable: $table" >&2; exit 1; }
[ -r "$game" ] || { echo "runtime target is not readable: $game" >&2; exit 1; }
if command -v sha256sum >/dev/null 2>&1; then
actual_hash=$(sha256sum "$game" | awk '{print $1}')
else
actual_hash=$(shasum -a 256 "$game" | awk '{print $1}')
fi
expected_hash=$(sed -n '1s/^# binary_sha256[[:space:]]*//p' "$table")
if [ -z "$expected_hash" ] || [ "$actual_hash" != "$expected_hash" ]; then
echo "runtime target SHA-256 $actual_hash does not match uprobe table $expected_hash" >&2
exit 1
fi
target_exe="$proc_root/$target_pid/exe"
[ -e "$target_exe" ] || { echo "target PID $target_pid does not exist" >&2; exit 1; }
resolved_target=$(readlink -f "$target_exe")
resolved_game=$(readlink -f "$game")
if [ "$resolved_target" != "$resolved_game" ]; then
echo "target PID $target_pid executable $resolved_target is not $resolved_game" >&2
exit 1
fi
is_hex_number() {
case "$1" in
0x*) ;;
*) return 1 ;;
esac
case "${1#0x}" in
''|*[!0-9a-fA-F]*) return 1 ;;
esac
}
is_hex_bytes() {
case "$1" in
''|*[!0-9a-fA-F]*) return 1 ;;
esac
}
is_supported_register() {
case "$1" in
x[0-9]|x[12][0-9]|x30) ;;
*) return 1 ;;
esac
}
is_register_list() (
remaining_registers=$1
while :; do
case "$remaining_registers" in
*,*)
register=${remaining_registers%%,*}
remaining_registers=${remaining_registers#*,}
is_supported_register "$register" || return 1
[ -n "$remaining_registers" ] || return 1
;;
*)
is_supported_register "$remaining_registers"
return
;;
esac
done
)
is_equality_gate() (
case "$1" in
*=*) ;;
*) return 1 ;;
esac
gate_register=${1%%=*}
gate_value=${1#*=}
case "$gate_value" in
*'='*) return 1 ;;
esac
is_supported_register "$gate_register" && is_hex_number "$gate_value"
)
build_task_filter() {
current_filter=
for task_path in "$proc_root/$target_pid"/task/[0-9]*; do
[ -d "$task_path" ] || continue
task_id=${task_path##*/}
case "$task_id" in ''|*[!0-9]*) continue ;; esac
if [ -z "$current_filter" ]; then
current_filter="common_pid == $task_id"
else
current_filter="$current_filter || common_pid == $task_id"
fi
done
[ -n "$current_filter" ] || current_filter="common_pid == $target_pid"
printf '%s\n' "$current_filter"
}
write_event_filter() {
filter_name=$1
filter_condition=$2
filter_tasks=$(build_task_filter)
if [ "$filter_condition" = - ]; then
printf '%s\n' "$filter_tasks" > "$tracefs/events/$group/$filter_name/filter"
else
condition_register=${filter_condition%%=*}
condition_value=${filter_condition#*=}
printf '(%s) && %s == %s\n' "$filter_tasks" "$condition_register" "$condition_value" > "$tracefs/events/$group/$filter_name/filter"
fi
}
# Validate the complete table and every reviewed instruction byte before
# touching tracefs. The host validator supplied these file offsets.
row_count=0
ungated_count=0
while IFS="$tab" read -r name file_offset expected_bytes event confidence registers condition elf_address extra; do
case "$name" in ''|'# '*) continue ;; esac
row_count=$((row_count + 1))
[ -z "${extra:-}" ] || { echo "unexpected uprobe table field in $name" >&2; exit 1; }
case "$name" in *[!A-Za-z0-9_.:-]*) echo "invalid hook name: $name" >&2; exit 1 ;; esac
case "$event" in ''|*[!A-Za-z0-9_.:-]*) echo "invalid event name: $event" >&2; exit 1 ;; esac
is_hex_number "$file_offset" || { echo "invalid file offset: $file_offset" >&2; exit 1; }
is_hex_number "$elf_address" || { echo "invalid ELF address: $elf_address" >&2; exit 1; }
is_hex_bytes "$expected_bytes" || { echo "invalid expected bytes: $name" >&2; exit 1; }
[ $(( ${#expected_bytes} % 2 )) -eq 0 ] || { echo "odd expected bytes: $name" >&2; exit 1; }
case "$condition" in
-) ungated_count=$((ungated_count + 1)) ;;
*) is_equality_gate "$condition" || {
echo "unsupported uprobe equality gate in $name: $condition" >&2
exit 1
} ;;
esac
is_register_list "$registers" || {
echo "unsupported uprobe register in $name: $registers" >&2
exit 1
}
byte_count=$(( ${#expected_bytes} / 2 ))
actual_bytes=$(dd if="$game" bs=1 skip="$((file_offset))" count="$byte_count" 2>/dev/null | od -An -v -tx1 | tr -d ' \n')
normalized_expected_bytes=$(printf '%s\n' "$expected_bytes" | tr 'A-F' 'a-f')
if [ "$actual_bytes" != "$normalized_expected_bytes" ]; then
echo "runtime target bytes for $name are $actual_bytes, expected $expected_bytes" >&2
exit 1
fi
done < "$table"
[ "$row_count" -gt 0 ] || { echo "uprobe table contains no hooks" >&2; exit 1; }
if [ "$ungated_count" -gt 0 ] && [ "$row_count" -ne 1 ]; then
echo "an ungated diagnostic uprobe table must contain exactly one hook" >&2
exit 1
fi
if [ "${SPIKE3_SKIP_TRACEFS_MOUNT:-0}" != 1 ]; then
if ! grep -qs " $tracefs tracefs " "$proc_root/mounts"; then
mkdir -p "$tracefs"
mount -t tracefs tracefs "$tracefs"
mounted_tracefs=1
fi
fi
[ -w "$tracefs/uprobe_events" ] || { echo "tracefs uprobe_events is unavailable" >&2; exit 1; }
if [ -e "$tracefs/events/$group" ]; then
echo "tracefs event group $group already exists" >&2
exit 1
fi
# The confidence column is consumed to preserve the reviewed table shape.
# shellcheck disable=SC2034
while IFS="$tab" read -r name file_offset expected_bytes event confidence registers condition elf_address extra; do
case "$name" in ''|'# '*) continue ;; esac
fetches=
old_ifs=$IFS
IFS=,
for register in $registers; do
fetches="$fetches $register=%$register:u64"
done
IFS=$old_ifs
printf 'p:%s/%s %s:%s%s\n' "$group" "$name" "$resolved_game" "$file_offset" "$fetches" >> "$tracefs/uprobe_events"
created_names="$created_names $name"
event_dir="$tracefs/events/$group/$name"
if [ "${SPIKE3_FAKE_TRACEFS:-0}" = 1 ]; then
mkdir -p "$event_dir"
: > "$event_dir/filter"
: > "$event_dir/enable"
fi
[ -w "$event_dir/filter" ] && [ -w "$event_dir/enable" ] || {
echo "tracefs did not create event $group/$name" >&2
exit 1
}
write_event_filter "$name" "$condition"
printf '1\n' > "$event_dir/enable"
done < "$table"
refresh_seconds=${SPIKE3_TASK_REFRESH_SECONDS:-1}
case "$refresh_seconds" in
''|*[!0-9]*|0) echo "SPIKE3_TASK_REFRESH_SECONDS must be a positive integer" >&2; exit 1 ;;
esac
(
while [ -d "$proc_root/$target_pid/task" ]; do
sleep "$refresh_seconds"
for refresh_name in $created_names; do
refresh_condition=$(awk -F '\t' -v wanted="$refresh_name" '$1 == wanted {print $7; exit}' "$table")
[ -n "$refresh_condition" ] || continue
write_event_filter "$refresh_name" "$refresh_condition" 2>/dev/null || exit 0
done
done
) &
refresh_pid=$!
mkdir -p "$(dirname "$raw_output")"
: > "$raw_output"
echo "SPIKE3UPROBE_READY pid=$target_pid group=$group count=$row_count" >> "$console"
while IFS= read -r trace_line; do
printf '%s\n' "$trace_line" >> "$raw_output"
for probe_name in $created_names; do
case "$trace_line" in
*": $probe_name: "*) ;;
*) continue ;;
esac
metadata=$(awk -F '\t' -v wanted="$probe_name" '$1 == wanted {print $4, $8; exit}' "$table")
event=${metadata%% *}
elf_address=${metadata#* }
trace_time=$(printf '%s\n' "$trace_line" | sed -n 's/.* \([0-9][0-9]*\)\.\([0-9][0-9]*\): [A-Za-z0-9_.:-]*:.*/\1 \2/p')
[ -n "$trace_time" ] || continue
trace_seconds=${trace_time%% *}
trace_fraction=${trace_time#* }
trace_fraction=$(printf '%-9s' "$trace_fraction" | tr ' ' 0 | cut -c1-9)
trace_ns=$((trace_seconds * 1000000000 + 1$trace_fraction - 1000000000))
trace_pid=$(printf '%s\n' "$trace_line" | sed -n 's/^.*-\([0-9][0-9]*\) \[[^]]*\].*/\1/p')
[ -n "$trace_pid" ] || trace_pid=$target_pid
register_fields=
for token in $trace_line; do
case "$token" in
x[0-9]=0x[0-9a-fA-F]*|x[12][0-9]=0x[0-9a-fA-F]*|x30=0x[0-9a-fA-F]*|x[0-9]=[0-9]*|x[12][0-9]=[0-9]*|x30=[0-9]*)
register_fields="$register_fields $token"
;;
esac
done
echo "SPIKE3UPROBE name=$probe_name event=$event elf=$elf_address trace_ns=$trace_ns pid=$trace_pid tgid=$target_pid$register_fields" >> "$console"
break
done
done < "$tracefs/trace_pipe"
@@ -0,0 +1,55 @@
#!/bin/sh
mkdir -p /dev/input /dev/serial/by-path /run/spike-emu \
/connectivity/dump/log/spike-emu /connectivity/dump/spike-emu
touch /run/spike-emu/events.log
for chip in 0 1 2 3; do
[ -e "/dev/gpiochip$chip" ] || touch "/dev/gpiochip$chip"
done
for bus in 0 1 10 11; do
[ -e "/dev/i2c-$bus" ] || touch "/dev/i2c-$bus"
done
for ev in 0 1 2 3; do
[ -e "/dev/input/event$ev" ] || touch "/dev/input/event$ev"
done
[ -e /dev/ttyAMA0 ] || touch /dev/ttyAMA0
if tr ' ' '\n' < /proc/cmdline | grep -qx 'spike3.netbridge_serial=1'; then
rm -f /dev/ttyAMA5
netbridge_uart=
for _ in 1 2 3 4 5 6 7 8 9 10; do
for candidate in /dev/ttyS0 /dev/ttyAMA1; do
if [ -c "$candidate" ]; then
netbridge_uart=$candidate
break 2
fi
done
sleep 0.1
done
if [ -n "$netbridge_uart" ]; then
ln -s "$netbridge_uart" /dev/ttyAMA5
echo "netbridge UART: /dev/ttyAMA5 -> $netbridge_uart" >> /connectivity/dump/log/spike-emu/hardware.log
else
echo "netbridge UART missing: expected /dev/ttyS0 or /dev/ttyAMA1" >> /connectivity/dump/log/spike-emu/hardware.log
fi
else
[ -e /dev/ttyAMA1 ] || touch /dev/ttyAMA1
fi
cat > /run/spike-emu/asound.conf <<'EOF'
pcm.!default {
type null
}
ctl.!default {
type hw
card 0
}
EOF
export ALSA_CONFIG_PATH=/run/spike-emu/asound.conf
echo "fake device placeholders prepared" >> /connectivity/dump/log/spike-emu/hardware.log
@@ -0,0 +1,66 @@
# Netbridge Emulator
This is a clean-room host-side emulator for `/games/pokemon_pro/spike3/netbridge/netbridge.elf`.
It models the Raspberry Pi to RP2040 serial protocol recovered from Ghidra and stubs the lower hardware modules so the game has something bridge-shaped to talk to during QEMU or bench bring-up.
## Current Scope
- Pi-facing frame codec: `0x02` start, `0x03` end, `0x1b` escape with high-bit encoding.
- Command `0x25` mirrors firmware `NODEBUS_Poll`: it reports the highest
responding installed node address, not the identity of a changed switch.
Pokemon discovery descends through `0x0a`, `0x09`, `0x08`, `0x04`, and
`0x01`; omitting the backbox/topper at `0x0a` produces `NODES NOT FOUND`.
A complete pass returns one zero before the next pass is rearmed.
- Host message checksum: additive two's-complement over command, sequence, and payload.
- Version command `0x20`: returns `0.98.4` plus synthetic ADC version.
- Reset/status/voltage commands `0x22`, `0x23`, `0x24`.
- Node discovery commands `0x25` and extended `0x40/0x00`.
- Firmware CRC command `0x30`: returns `0x357f5327`, computed over the recovered RP2040 logical image range.
- Direct nodebus command `0x41`: routes to an in-process stub node at address `0x0a`.
- Magikarp/topper-oriented node commands `0xf2`, `0xfc`, `0xfd`, `0xfe`, and `0xff`.
Hardware modules that are not needed for early game progress are deliberately inert: LCD/DMD status reports success, WS2812/audio commands are accepted without side effects, and power distribution commands get conservative synthetic responses.
## Run
Create a PTY and print the slave path:
```sh
python3 emulation/netbridge-emulator/netbridge_emu.py --pty
```
Serve an existing serial/PTY path:
```sh
python3 emulation/netbridge-emulator/netbridge_emu.py --serial /dev/ttyUSB0
```
Listen on a Unix socket for QEMU `-chardev socket`:
```sh
python3 emulation/netbridge-emulator/netbridge_emu.py --unix-socket emulation/work/netbridge.sock
```
For the Docker/QEMU scaffold, the easiest path is:
```sh
SPIKE3_NETBRIDGE_EMU=1 SPIKE3_AUTOSTART=game docker compose -f emulation/compose.yaml up --build
```
That mode starts this emulator on `emulation/work/netbridge.sock`, attaches it as a second QEMU PL011 serial port, symlinks guest `/dev/ttyAMA5` to `/dev/ttyAMA1`, and lets the preload shim pass `/dev/ttyAMA5` through as a real serial device. See `emulation/README.md` for the full runbook and current validation status.
## Test
```sh
python3 -m unittest discover -s emulation/netbridge-emulator
```
## Porting Notes
An ESP32, RP2040, or other microcontroller port only needs the same two protocol layers at first:
1. Host UART at 2,000,000 baud, 8N1, STX/ETX/ESC framing, additive checksum.
2. A nodebus backend that can answer discovery and direct `0x41` node requests with `payload || checksum || status`.
The original firmware is RP2040/Pico-SDK style and uses PIO for the node transmit side, so an exact physical replacement is easier on RP2040. An ESP32 replacement should still work as a protocol emulator if it can meet nodebus timing or if it only faces the Pi-side UART while node hardware is stubbed.
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import socket
import time
from pathlib import Path
def publish(name: str, active: bool, state_file: Path, event_file: Path) -> None:
event = {"type": "switch", "name": name, "value": int(active), "source": "qemu-keyboard"}
with event_file.open("a", encoding="utf-8") as fp:
fp.write(json.dumps(event, sort_keys=True) + "\n")
try:
state = json.loads(state_file.read_text(encoding="utf-8"))
if not isinstance(state, dict):
state = {}
except (FileNotFoundError, json.JSONDecodeError, OSError):
state = {}
state[name] = active
temporary = state_file.with_suffix(".json.keyboard.tmp")
temporary.write_text(json.dumps(state, sort_keys=True), encoding="utf-8")
temporary.replace(state_file)
print(f"qemu-keyboard name={name} active={int(active)}", flush=True)
def run(socket_path: Path, state_file: Path, event_file: Path) -> None:
while True:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
client.connect(str(socket_path))
buffer = b""
while True:
chunk = client.recv(4096)
if not chunk:
break
buffer += chunk
while b"\n" in buffer:
raw_line, buffer = buffer.split(b"\n", 1)
parts = raw_line.decode("ascii", errors="ignore").split()
if len(parts) == 2 and parts[1] in ("0", "1"):
publish(parts[0], parts[1] == "1", state_file, event_file)
except OSError:
time.sleep(0.2)
finally:
client.close()
def main() -> None:
parser = argparse.ArgumentParser(description="Relay QEMU guest keyboard events into SPIKE switches")
parser.add_argument("--socket", required=True, type=Path)
parser.add_argument("--state-file", required=True, type=Path)
parser.add_argument("--event-file", required=True, type=Path)
args = parser.parse_args()
run(args.socket, args.state_file, args.event_file)
if __name__ == "__main__":
main()
@@ -0,0 +1,863 @@
#!/usr/bin/env python3
"""
Host-side emulator for the SPIKE 3 RP2040 netbridge.
This models the Pi-facing UART protocol recovered from
games/pokemon_pro/spike3/netbridge/netbridge.elf. It is intentionally a
development stub: hardware-facing modules return stable synthetic state, while
nodebus requests are routed to a small in-process node model.
"""
from __future__ import annotations
import argparse
import binascii
import errno
import os
import selectors
import socket
import struct
import sys
import termios
import time
import json
from dataclasses import dataclass, field
from pathlib import Path
STX = 0x02
ETX = 0x03
ESC = 0x1B
DEFAULT_NETBRIDGE_ELF = (
Path(__file__).resolve().parents[2]
/ "games/pokemon_pro/spike3/netbridge/netbridge.elf"
)
# Mac keyboard layout recovered from the switch descriptor table in the
# installed Pokemon game binary. Raw names such as ``switch_8_17`` are also
# accepted so additional playfield switches can be exercised without a patch.
SWITCH_MAP: dict[str, tuple[int, int]] = {
"left_flipper": (8, 25),
"right_flipper": (8, 24),
# Pokemon 0.83 consumes the printed 1-SW-11 Start contact as bank bit 11.
"start": (1, 11),
"coin": (1, 16),
"action_button": (1, 2),
"slam_tilt": (1, 22),
"service_enter": (0, 8),
"service_back": (0, 11),
"service_up": (0, 9),
"service_down": (0, 10),
"shooter_lane": (8, 28),
"trough_1": (8, 37),
"trough_2": (8, 36),
"trough_3": (8, 35),
"trough_4": (8, 34),
"trough_5": (8, 33),
"trough_6": (8, 32),
"target_1": (8, 9), # Berry left
"target_2": (8, 10), # Berry center
"target_3": (9, 0), # Berry right
"target_4": (8, 5), # Psyduck
"target_5": (8, 0), # Lower-left top (SW-1)
"target_6": (8, 1), # Lower-left middle
"target_7": (8, 2), # Lower-left bottom
"target_8": (8, 3), # Lower-right top
"target_9": (8, 4), # Lower-right bottom
}
DEFAULT_ACTIVE_SWITCHES = (
"trough_1",
"trough_2",
"trough_3",
"trough_4",
"trough_5",
"trough_6",
)
def _escape_byte(value: int) -> bytes:
value &= 0xFF
if value in (STX, ETX, ESC):
return bytes((ESC, value | 0x80))
return bytes((value,))
def encode_frame(command: int, sequence: int, payload: bytes = b"") -> bytes:
body = bytes((command & 0xFF, sequence & 0xFF)) + payload
checksum = (-sum(body)) & 0xFF
out = bytearray([STX])
for value in body + bytes((checksum,)):
out.extend(_escape_byte(value))
out.append(ETX)
return bytes(out)
class FrameDecoder:
def __init__(self) -> None:
self.in_frame = False
self.escaped = False
self.buf = bytearray()
def feed(self, data: bytes) -> list[tuple[int, int, bytes]]:
frames: list[tuple[int, int, bytes]] = []
for value in data:
if value == STX:
self.in_frame = True
self.escaped = False
self.buf.clear()
continue
if not self.in_frame:
continue
if value == ESC:
self.escaped = True
continue
if value == ETX:
if len(self.buf) >= 3 and (sum(self.buf) & 0xFF) == 0:
frames.append((self.buf[0], self.buf[1], bytes(self.buf[2:-1])))
self.in_frame = False
self.escaped = False
self.buf.clear()
continue
if self.escaped:
value &= 0x7F
self.escaped = False
if len(self.buf) < 0x200:
self.buf.append(value)
else:
self.in_frame = False
self.escaped = False
self.buf.clear()
return frames
def crc32_netbridge_image(path: Path = DEFAULT_NETBRIDGE_ELF) -> int:
"""Return the CRC used by firmware command 0x30.
The firmware computes CRC32 over RP2040 memory from __logical_binary_start
for 0x12a40 bytes. In this ELF the executable LOAD segment begins at file
offset 0x1000 and virtual address 0x10000000, so logical start maps to
file offset 0x1100.
"""
data = path.read_bytes()
blob = data[0x1100 : 0x1100 + 0x12A40]
if len(blob) != 0x12A40:
raise ValueError(f"{path} is too small for recovered netbridge CRC range")
return binascii.crc32(blob) & 0xFFFFFFFF
@dataclass
class NodeDevice:
address: int = 0x0A
firmware_version: tuple[int, int, int] = (1, 33, 0)
ticks_per_second: int = 10000
firmware_revision: int = 1
# LPC1313FHN33 is one of the supported MCU identities in the installed
# host's node capability table. A zero ID leaves the node classified as
# unknown, so its configured inputs are never serviced.
chip_id: int = 0x2C40102B
flash_capacity: int = 0x8000
serial_key_material: bytes = bytes.fromhex("78563412f0debc9aefbeadde21436587")
runtime_data: bytearray = field(default_factory=lambda: bytearray(256))
def respond(self, args: bytes, expected_len: int) -> bytes:
command = args[0] if args else 0x00
if command == 0xF2:
# Runtime data is addressed in eight-byte reads and variable-size
# writes. Keep it for the life of the emulated node so the game's
# post-update verification reads back the configuration it wrote.
offset = args[1] if len(args) > 1 else 0
if len(args) > 2:
end = min(len(self.runtime_data), offset + len(args) - 2)
self.runtime_data[offset:end] = args[2 : 2 + end - offset]
payload = bytes(
self.runtime_data[offset : offset + max(0, expected_len - 2)]
)
elif command == 0xF8:
# Board/bootloader identity: runtime state followed by flash
# capacity in erase blocks. A zero capacity makes the host reject
# the board as too small and repeatedly enter its runtime updater.
payload = struct.pack("<H", self.flash_capacity)[: max(0, expected_len - 2)]
elif command == 0xFC:
payload = self.serial_key_material[: max(0, expected_len - 2)]
elif command == 0xFD:
payload = self.serial_key_material[: max(0, expected_len - 2)]
elif command == 0xFE:
major, minor, patch = self.firmware_version
# GetVersion is a structured timing/capability response, not a
# board-name string. Bytes 8-9 are the node timer frequency used
# by the game to calculate debounce, coil, and LED timing.
# Byte 10 is the runtime revision, compared by game 0090b070
# against bundled firmware metadata (see startup-readiness.md).
identity = (
bytes(
(
self.address & 0x7F,
major & 0xFF,
minor & 0xFF,
patch & 0xFF,
)
)
+ struct.pack("<I", self.chip_id)
+ struct.pack("<H", self.ticks_per_second)
+ bytes((self.firmware_revision,))
)
payload = identity[: max(0, expected_len - 2)]
elif command == 0xFA and len(args) > 1 and args[1] == 1:
# Older GetVersion clients retrieve the revision separately.
payload = bytes((self.firmware_revision,))
elif command == 0xFF:
payload = bytes(max(0, expected_len - 2))
else:
payload = bytes(max(0, expected_len - 2))
payload = payload.ljust(max(0, expected_len - 2), b"\x00")
checksum = (-sum(payload)) & 0xFF
status = 0
return payload + bytes((checksum, status))
@dataclass
class NetbridgeModel:
image_crc: int
adc_version: int = 0xFF
reset_flags: int = 0
bridge_status: int = 0
vbb_mv: int = 48000
# LCD_WriteImage() reports 0x05 while a frame is queued. LCD_Update then
# completes the transfer and leaves the ready/idle bit (0x02) set.
lcd_status: int = 0x02
dmd_status: int = 0
nodes: dict[int, NodeDevice] = field(default_factory=dict)
# Physical nodebus boards installed in Pokemon Pro. Node 0x0a is the
# backbox/topper board; excluding it leaves the locator visibly stopped at
# "1 4 8 9" and the game reports NODES NOT FOUND.
discovery_addresses: tuple[int, ...] = (0x01, 0x04, 0x08, 0x09, 0x0A)
last_frames: list[tuple[int, int, bytes]] = field(default_factory=list)
discovered_nodes: set[int] = field(default_factory=set)
discovery_zero_pending: bool = False
resume_discovery_after_input: bool = False
state_file: Path | None = None
event_file: Path | None = None
output_state_file: Path | None = None
input_states: dict[int, bytearray] = field(default_factory=dict)
pending_activity: list[int] = field(default_factory=list)
traffic_enabled: set[tuple[int, int]] = field(default_factory=set)
awaiting_input_read: set[int] = field(default_factory=set)
queued_switch_events: list[tuple[str, bool]] = field(default_factory=list)
_last_switch_state: dict[str, bool] = field(default_factory=dict)
_event_offset: int = 0
output_frame_count: int = 0
# The installed descriptors debounce cabinet/playfield contacts for 30 ms.
# Stretch synthetic taps beyond that wall-clock interval because a burst
# of node reads can otherwise consume every retained sample in <30 ms.
minimum_switch_hold_seconds: float = 0.075
# Keep a tap active until the host has consumed at least one GetInputState
# reply. GetStatus (0xff) is board health, not an input-bank sample.
# SPIKE game code debounces cabinet/playfield contacts across multiple
# input-bank samples. A dashboard click or a quick Mac key tap can queue
# key-up immediately, so retain the press for enough polls to register.
minimum_switch_active_reads: int = 5
# Do not let a missing/changed host acknowledgement block every later
# control edge. This is deliberately longer than the installed 30 ms
# debounce and the normal 75 ms synthetic hold.
maximum_switch_hold_seconds: float = 0.250
_last_switch_change_at: dict[str, float] = field(default_factory=dict)
_switch_active_reads: dict[str, int] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.nodes:
# Model only boards installed in Pokemon Pro. The shared SPIKE
# image probes many optional addresses, but answering those probes
# invents hardware and makes the game attempt to update it.
for address in (0x00, 0x01, 0x04, 0x08, 0x09, 0x0A):
self.nodes[address] = NodeDevice(address=address)
# Pokemon Pro's node 4 is the QR Reader board. Its bundled node
# runtime is built for LPC812/LPC1124, not the LPC1313 used by the
# cabinet and core-driver boards. Advertising LPC1313 here makes
# the host enter a firmware update it can never satisfy.
self.nodes[0x04].chip_id = 0x00008120 # LPC812M101JDH16
self.nodes[0x04].flash_capacity = 0x4000
self.nodes[0x04].firmware_revision = 3
for address in self.nodes:
self.input_states[address] = bytearray(b"\xff" * 8)
for name in DEFAULT_ACTIVE_SWITCHES:
node, index = SWITCH_MAP[name]
byte_index, bit_index = divmod(index, 8)
self.input_states[node][byte_index] &= ~(1 << bit_index)
self._last_switch_state[name] = True
self._publish_initial_switch_state()
if self.event_file:
try:
self._event_offset = self.event_file.stat().st_size
except OSError:
self._event_offset = 0
def _publish_initial_switch_state(self) -> None:
if not self.state_file:
return
try:
state = json.loads(self.state_file.read_text(encoding="utf-8"))
if not isinstance(state, dict):
state = {}
except (FileNotFoundError, json.JSONDecodeError, OSError):
state = {}
for name in DEFAULT_ACTIVE_SWITCHES:
state[name] = True
try:
temporary = self.state_file.with_suffix(".json.tmp")
temporary.write_text(json.dumps(state, sort_keys=True), encoding="utf-8")
temporary.replace(self.state_file)
except OSError:
pass
def handle(self, command: int, sequence: int, payload: bytes) -> bytes | None:
self.last_frames.append((command, sequence, payload))
if len(self.last_frames) > 64:
self.last_frames = self.last_frames[-64:]
if command == 0x20:
return encode_frame(command, sequence, bytes((0, 98, 4, self.adc_version)))
if command == 0x22:
return encode_frame(command, sequence, bytes((self.reset_flags & 0xFF,)))
if command == 0x23:
status = self.bridge_status
self.bridge_status = 0
return encode_frame(command, sequence, struct.pack("<I", status))
if command == 0x24:
return encode_frame(command, sequence, struct.pack("<I", self.vbb_mv))
if command == 0x25:
self._sync_switch_events()
if not self.event_file:
self._sync_switch_state()
# Stable startup locator: enumerate the installed boards once.
# Runtime configured-traffic arbitration is modeled separately;
# do not feed a switch edge into the locator path.
# NODEBUS_Poll resolves arbitration to the highest responding
# address. The host consumes/configures that board before polling
# again, so locator discovery descends instead of following the
# profile tuple's ascending storage order.
found = max(
(address for address in self.discovery_addresses if address not in self.discovered_nodes),
default=0,
)
if found:
self.discovered_nodes.add(found)
if self.discovered_nodes.issuperset(self.discovery_addresses):
self.discovery_zero_pending = True
elif self.discovery_zero_pending:
# A complete NODEBUS_Poll pass is terminated by one zero.
# Rearm only after emitting that terminator so a later locator
# cannot be mistaken for queued switch traffic.
self.discovery_zero_pending = False
self.discovered_nodes.clear()
elif self.pending_activity:
found = self.pending_activity.pop(0)
self.awaiting_input_read.add(found)
return encode_frame(command, sequence, bytes((found & 0xFF,)))
if command == 0x27:
if payload[:1] == b"\x02":
self.lcd_status = 0x05
return encode_frame(command, sequence, bytes((self.lcd_status,)))
if payload[:1] in (b"", b"\x00"):
if self.lcd_status == 0x05:
self.lcd_status = 0x02
return encode_frame(command, sequence, bytes((self.lcd_status & 0xFF,)))
return None
if command == 0x28:
if not payload:
return encode_frame(command, sequence, bytes((self.dmd_status & 0xFF,)))
return None
if command == 0x2F:
self._record_output_frame(payload)
return None
if command in (0x29, 0x2A, 0x2B, 0x2C):
return None
if command == 0x30:
return encode_frame(command, sequence, struct.pack("<I", self.image_crc))
if command == 0x40:
return self._handle_extended(sequence, payload)
if command == 0x41:
return self._handle_node_request(sequence, payload)
if 0x10 <= command <= 0x1F:
return encode_frame(command, sequence, bytes((0xFF,)))
return None
def _handle_extended(self, sequence: int, payload: bytes) -> bytes | None:
if not payload:
return None
subcmd = payload[0]
if subcmd == 0x00:
found = min(self.nodes) if self.nodes else 0
return encode_frame(0x40, sequence, bytes((found & 0xFF,)))
if subcmd == 0x03:
return encode_frame(0x40, sequence, bytes((0, 98, 4)))
if subcmd == 0x05:
status = self.bridge_status
self.bridge_status = 0
return encode_frame(0x40, sequence, struct.pack("<I", status))
if subcmd == 0x0A:
return encode_frame(0x40, sequence, b"\x00\x00")
if subcmd == 0x0D and len(payload) == 7:
seed_slot = (payload[2] & 0x3F) >> 4
mode = payload[2] & 0x0F
value = struct.unpack_from("<I", payload, 3)[0]
mixed = self._mix_hash(value ^ (seed_slot * 0x9E3779B9))
if mode in (1, 2, 3, 4):
return encode_frame(0x40, sequence, struct.pack("<I", mixed))
self.bridge_status |= 0x20
return None
def _handle_node_request(self, sequence: int, payload: bytes) -> bytes | None:
if len(payload) < 4:
self.bridge_status |= 0x04
return encode_frame(0x41, sequence, bytes((self.bridge_status & 0xFF,)))
wire_address = payload[0]
secure_node_request = (wire_address & 0xC0) == 0xC0
address = wire_address & (0x3F if secure_node_request else 0x7F)
wire_arg_count = payload[1]
args_end = 2 + wire_arg_count
args = payload[2:args_end]
expected_len = payload[args_end] if args_end < len(payload) else 1
if secure_node_request and args:
# SPIKE 3 adds one encrypted checksum byte to the node arguments
# and two checksum/status bytes to non-empty replies. The XOR
# token is built from the node's low nibble and the wrapped
# argument count; its complement is used on alternating bytes.
# Once node setup completes, each byte is additionally XORed with
# the board's 16-byte serial key. Try that keyed form first, then
# the zero-key startup form used by early captures.
token = ((address & 0x0F) << 4) | (wire_arg_count & 0x0F)
transport_decrypted = bytes(
value ^ (token if index % 2 == 0 else token ^ 0xFF)
for index, value in enumerate(args)
)
node_key = self.nodes.get(address)
keyed_decrypted = bytes(
value ^ node_key.serial_key_material[index & 0x0F]
for index, value in enumerate(transport_decrypted)
) if node_key is not None else b""
candidates = (keyed_decrypted, transport_decrypted)
decrypted = next(
(
candidate
for candidate in candidates
if candidate
and not (sum(bytes((wire_address, wire_arg_count)) + candidate) & 0xFF)
),
b"",
)
if not decrypted:
self.bridge_status |= 0x04
else:
args = decrypted[:-1]
node = self.nodes.get(address)
if expected_len == 0:
# NODE writes (SetTraffic, LED/coil configuration, etc.) are sent
# without a requested reply. The RP2040 firmware returns directly
# in this case; emitting even an empty 0x41 frame leaves the host's
# request/response stream out of phase.
if node is None:
self.bridge_status |= 0x04
else:
self._handle_node_write(address, args)
return None
if node is None:
self.bridge_status |= 0x04
# HOST_Update in the installed RP2040 firmware pre-fills the node
# receive area with 0x55, asks for expected_len - 1 bus bytes, then
# writes bridgeStatus into the final byte. Match that timeout
# response exactly for an absent optional board.
response = bytes((0x55,)) * max(0, expected_len - 1) + bytes((0x04,))
else:
# GetInputState is command 0x11 on both legacy SPIKE and the
# installed SPIKE 3 host. GetStatus (0xff) also returns ten bytes,
# but those eight payload bytes are board-health words and must
# remain zero unless a real node fault is being modeled.
secure_input_read = (
secure_node_request
and wire_arg_count == 3
and args[:1] == b"\x11"
and expected_len == 12
)
if secure_input_read or (args[:1] == b"\x11" and expected_len == 10):
# The installed game directly polls input banks during normal
# play instead of issuing command 0x25 before every read.
# Drain one queued transition for this node so a quick key tap
# is visible across two consecutive bank reads.
if address not in self.awaiting_input_read:
self._sync_switch_events(address)
input_payload = bytes(self.input_states[address])
if secure_input_read:
# Firmware newer than 1.23 wraps GetInputState. The request
# carries the encrypted command, session key, and request
# checksum. Use the unwrapped session key here;
# the host decrypts eight response bytes by XORing them
# with successive one-bit rotations of that key.
key = args[1]
encrypted = bytes(
value ^ self._rotate_left_byte(key, index)
for index, value in enumerate(input_payload)
)
response = self._node_response(encrypted, expected_len)
else:
response = self._node_response(input_payload, expected_len)
for name, active in self._last_switch_state.items():
mapping = self._switch_mapping(name)
if active and mapping is not None and mapping[0] == address:
if self._switch_active_reads.get(name, 0) == 0:
print(
f"input-read name={name} node={address} index={mapping[1]} "
f"secure={int(secure_input_read)} "
f"key={f'0x{args[1]:02x}' if secure_input_read else 'none'}",
flush=True,
)
self._switch_active_reads[name] = self._switch_active_reads.get(name, 0) + 1
self.awaiting_input_read.discard(address)
self.pending_activity = [node for node in self.pending_activity if node != address]
if any(
active
and (mapping := self._switch_mapping(name)) is not None
and mapping[0] == address
and not self._switch_active_debounce_satisfied(name)
for name, active in self._last_switch_state.items()
):
# Real node firmware keeps asserting traffic while a
# contact is changing. Re-advertise the bank until a quick
# synthetic tap has survived the game's debounce window.
self.pending_activity.append(address)
else:
response = node.respond(args, expected_len)
if len(response) != expected_len:
response = response[:expected_len].ljust(expected_len, b"\x00")
return encode_frame(0x41, sequence, response)
def _handle_node_write(self, address: int, args: bytes) -> None:
if args[:1] == b"\xf2" and len(args) > 2:
node = self.nodes.get(address)
if node is not None:
offset = args[1]
end = min(len(node.runtime_data), offset + len(args) - 2)
node.runtime_data[offset:end] = args[2 : 2 + end - offset]
return
if args[:1] != b"\xf0" or len(args) < 2:
return
if args[1] == 0x11 and address in self.nodes:
# SetTraffic(GetInputState) enables switch-change notifications;
# it does not begin a locator pass. Ingest pending host keys but
# preserve the locator's discovered set so input traffic cannot
# make an in-progress board scan appear incomplete.
if address == 0:
self._sync_switch_events()
# SetTraffic configures input notifications and is repeated
# during normal operation. Discovery already starts empty at
# boot/reset; clearing it here traps the host in locator loops
# and starves the pending switch bank.
else:
traffic = (address, 0x11)
if traffic not in self.traffic_enabled:
self.traffic_enabled.add(traffic)
if address not in self.pending_activity:
# First registration publishes the current bank,
# including the initially occupied trough switches.
self.pending_activity.append(address)
@staticmethod
def _node_response(payload: bytes, expected_len: int) -> bytes:
data_len = max(0, expected_len - 2)
payload = payload[:data_len].ljust(data_len, b"\x00")
return payload + bytes(((-sum(payload)) & 0xFF, 0))
@staticmethod
def _rotate_left_byte(value: int, amount: int) -> int:
amount &= 7
return ((value << amount) | (value >> ((8 - amount) & 7))) & 0xFF
def _sync_switch_state(self) -> None:
if not self.state_file:
return
try:
state = json.loads(self.state_file.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return
if not isinstance(state, dict):
return
for name, raw_value in state.items():
self._apply_switch(name, bool(raw_value))
def _sync_switch_events(self, requested_node: int | None = None) -> None:
if self.event_file:
try:
with self.event_file.open("r", encoding="utf-8") as fp:
fp.seek(self._event_offset)
for line in fp:
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("type") == "switch" and event.get("name"):
self.queued_switch_events.append(
(str(event["name"]), bool(event.get("value", 0)))
)
self._event_offset = fp.tell()
except OSError:
pass
# Apply one transition per host poll or direct bank read. This
# preserves a quick key tap as two distinct input states even when the
# game bypasses command 0x25 and polls a node directly.
if not self.queued_switch_events:
return
if requested_node is None:
if self.pending_activity or self.awaiting_input_read:
return
name, active = self.queued_switch_events.pop(0)
if not self._switch_transition_ready(name, active):
self.queued_switch_events.insert(0, (name, active))
return
self._apply_switch(name, active)
return
for index, (name, active) in enumerate(self.queued_switch_events):
mapping = self._switch_mapping(name)
if mapping is not None and mapping[0] == requested_node:
if not self._switch_transition_ready(name, active):
return
self.queued_switch_events.pop(index)
self._apply_switch(name, active)
return
def queue_switch(self, name: str, active: bool) -> bool:
"""Queue a host control edge for delivery through node input traffic.
Dashboard/VPX controls share the in-process machine broker with this
model, so they do not use the legacy JSONL event file. Retaining the
edge in this queue lets the normal polling path apply its minimum hold
time and read-count debounce rules.
"""
if self._switch_mapping(name) is None:
return False
target = bool(active)
current_target = next(
(state for queued_name, state in reversed(self.queued_switch_events) if queued_name == name),
self._last_switch_state.get(name, False),
)
if current_target != target:
self.queued_switch_events.append((name, target))
return True
def _switch_transition_ready(self, name: str, active: bool) -> bool:
if active or not self._last_switch_state.get(name, False):
return True
return self._switch_active_debounce_satisfied(name)
def _switch_active_debounce_satisfied(self, name: str) -> bool:
changed_at = self._last_switch_change_at.get(name, 0.0)
elapsed = time.monotonic() - changed_at
if elapsed >= self.maximum_switch_hold_seconds:
return True
return (
self._switch_active_reads.get(name, 0) >= self.minimum_switch_active_reads
and elapsed >= self.minimum_switch_hold_seconds
)
def set_switch_immediate(self, name: str, active: bool, advertise: bool = False) -> bool:
if self._switch_mapping(name) is None:
return False
self.queued_switch_events = [
edge for edge in self.queued_switch_events if edge[0] != name
]
self._apply_switch(name, bool(active), advertise=advertise)
return True
def release_switch(self, name: str) -> bool:
if self._switch_mapping(name) is None:
return False
if self._last_switch_state.get(name, False):
self._apply_switch(name, False, advertise=True)
elif any(queued_name == name and active for queued_name, active in self.queued_switch_events):
self.queue_switch(name, False)
return True
def force_release_switch(self, name: str) -> bool:
if self._switch_mapping(name) is None:
return False
self.queued_switch_events = [
edge for edge in self.queued_switch_events if edge[0] != name
]
self._apply_switch(name, False, advertise=True)
return True
def _apply_switch(self, name: str, active: bool, advertise: bool = True) -> None:
mapping = self._switch_mapping(name)
if mapping is None:
return
node, index = mapping
if node not in self.input_states or not 0 <= index < 64:
return
if self._last_switch_state.get(name, False) == active:
return
self._last_switch_state[name] = active
self._last_switch_change_at[name] = time.monotonic()
if active:
self._switch_active_reads[name] = 0
byte_index, bit_index = divmod(index, 8)
mask = 1 << bit_index
if active:
self.input_states[node][byte_index] &= ~mask
else:
self.input_states[node][byte_index] |= mask
if advertise and node not in self.pending_activity:
self.pending_activity.append(node)
print(f"switch name={name} node={node} index={index} active={int(active)}", flush=True)
@staticmethod
def _switch_mapping(name: str) -> tuple[int, int] | None:
mapping = SWITCH_MAP.get(name)
if mapping is None and name.startswith("switch_"):
parts = name.split("_")
if len(parts) == 3 and parts[1].isdigit() and parts[2].isdigit():
mapping = (int(parts[1]), int(parts[2]))
return mapping
def _record_output_frame(self, payload: bytes) -> None:
self.output_frame_count += 1
if not self.output_state_file:
return
# LED traffic is continuous. Publish the first frame and then a
# sampled summary rather than turning filesystem I/O into a bottleneck.
if self.output_frame_count != 1 and self.output_frame_count % 64:
return
state = {
"led_frames": self.output_frame_count,
"last_led_bytes": len(payload),
"last_led_payload": payload.hex(),
}
try:
temporary = self.output_state_file.with_suffix(".json.tmp")
temporary.write_text(json.dumps(state, sort_keys=True), encoding="utf-8")
temporary.replace(self.output_state_file)
except OSError:
pass
@staticmethod
def _mix_hash(value: int) -> int:
value &= 0xFFFFFFFF
value = (value * 0xFFFF8001 - 1) & 0xFFFFFFFF
value = ((value ^ (value >> 10)) * 9) & 0xFFFFFFFF
value = (((value >> 6) ^ value) * 0xFFFFF801 - 1) & 0xFFFFFFFF
return ((value >> 16) ^ value) & 0xFFFFFFFF
def configure_serial(fd: int, baud: int = 2_000_000) -> None:
attrs = termios.tcgetattr(fd)
attrs[0] = 0
attrs[1] = 0
attrs[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
attrs[3] = 0
attrs[6][termios.VMIN] = 0
attrs[6][termios.VTIME] = 1
baud_const = getattr(termios, f"B{baud}", termios.B115200)
attrs[4] = baud_const
attrs[5] = baud_const
termios.tcsetattr(fd, termios.TCSANOW, attrs)
def serve_fd(fd: int, model: NetbridgeModel, trace_frames: bool = False) -> None:
decoder = FrameDecoder()
sel = selectors.DefaultSelector()
os.set_blocking(fd, False)
sel.register(fd, selectors.EVENT_READ)
while True:
for key, _ in sel.select(timeout=0.5):
try:
data = os.read(key.fd, 4096)
except OSError as exc:
if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
continue
raise
if not data:
return
for command, sequence, payload in decoder.feed(data):
if trace_frames:
print(
f"rx cmd=0x{command:02x} seq=0x{sequence:02x} "
f"payload={payload.hex() or '-'}",
flush=True,
)
response = model.handle(command, sequence, payload)
if response:
if trace_frames:
print(f"tx frame={response.hex()}", flush=True)
os.write(fd, response)
elif trace_frames:
print(f"tx none cmd=0x{command:02x}", flush=True)
def serve_unix_socket(path: str, model: NetbridgeModel, trace_frames: bool = False) -> None:
try:
os.unlink(path)
except FileNotFoundError:
pass
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(path)
server.listen(1)
print(path, flush=True)
while True:
conn, _ = server.accept()
try:
serve_fd(conn.fileno(), model, trace_frames)
finally:
conn.close()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pty", action="store_true", help="create and serve a pseudoterminal")
parser.add_argument("--serial", help="serve an existing serial device or PTY path")
parser.add_argument("--unix-socket", help="listen on a Unix socket for QEMU chardev socket")
parser.add_argument("--netbridge-elf", type=Path, default=DEFAULT_NETBRIDGE_ELF)
parser.add_argument("--node-address", type=lambda x: int(x, 0), default=0x01)
parser.add_argument("--state-file", type=Path, help="JSON switch state written by the dashboard")
parser.add_argument("--event-file", type=Path, help="JSONL switch transition queue written by the dashboard")
parser.add_argument("--output-state-file", type=Path, help="sampled JSON summary of output/LED traffic")
parser.add_argument("--trace-frames", action="store_true", help="log every decoded UART request and response")
args = parser.parse_args(argv)
model = NetbridgeModel(
image_crc=crc32_netbridge_image(args.netbridge_elf),
state_file=args.state_file,
event_file=args.event_file,
output_state_file=args.output_state_file,
)
requested_node_address = args.node_address & 0x7F
if requested_node_address not in model.nodes:
model.nodes[requested_node_address] = NodeDevice(address=requested_node_address)
model.input_states[requested_node_address] = bytearray(b"\xff" * 8)
if args.pty:
master, slave = os.openpty()
print(os.ttyname(slave), flush=True)
serve_fd(master, model, args.trace_frames)
return 0
if args.serial:
fd = os.open(args.serial, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
configure_serial(fd)
serve_fd(fd, model, args.trace_frames)
return 0
if args.unix_socket:
serve_unix_socket(args.unix_socket, model, args.trace_frames)
return 0
parser.error("choose --pty, --serial PATH, or --unix-socket PATH")
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,351 @@
#!/usr/bin/env python3
import struct
import json
import tempfile
import unittest
from pathlib import Path
from netbridge_emu import FrameDecoder, NetbridgeModel, crc32_netbridge_image, encode_frame
class NetbridgeEmulatorTests(unittest.TestCase):
def round_trip(self, frame):
decoded = FrameDecoder().feed(frame)
self.assertEqual(len(decoded), 1)
return decoded[0]
def test_frame_codec_escapes_control_bytes(self):
frame = encode_frame(0x02, 0x03, b"\x1b\x41")
self.assertIn(b"\x1b\x82", frame)
self.assertIn(b"\x1b\x83", frame)
self.assertIn(b"\x1b\x9b", frame)
self.assertEqual(self.round_trip(frame), (0x02, 0x03, b"\x1b\x41"))
def test_version_command(self):
model = NetbridgeModel(image_crc=0x12345678)
response = model.handle(0x20, 7, b"")
self.assertEqual(self.round_trip(response), (0x20, 7, b"\x00\x62\x04\xff"))
def test_in_memory_switch_queue_works_without_legacy_event_file(self):
model = NetbridgeModel(image_crc=0)
self.assertIsNone(model.event_file)
self.assertTrue(model.queue_switch("start", True))
model._sync_switch_events()
node, index = (1, 11)
byte_index, bit_index = divmod(index, 8)
self.assertEqual(model.input_states[node][byte_index] & (1 << bit_index), 0)
def test_crc_command_uses_recovered_default(self):
model = NetbridgeModel(image_crc=crc32_netbridge_image())
response = model.handle(0x30, 1, b"")
_, _, payload = self.round_trip(response)
self.assertEqual(struct.unpack("<I", payload)[0], 0x357F5327)
def test_node_serial_key_command(self):
model = NetbridgeModel(image_crc=0)
request_payload = bytes([0x81, 0x02, 0xFC, 0x81, 0x12])
response = model.handle(0x41, 9, request_payload)
_, _, payload = self.round_trip(response)
self.assertEqual(len(payload), 0x12)
self.assertEqual(payload[:16], model.nodes[0x01].serial_key_material)
def test_board_id_reports_nonzero_flash_capacity(self):
model = NetbridgeModel(image_crc=0)
_, _, payload = self.round_trip(
model.handle(0x41, 1, bytes((0x04, 0x01, 0xF8, 0x04)))
)
self.assertEqual(payload[:2], bytes((0x00, 0x40)))
self.assertEqual(sum(payload[:-1]) & 0xFF, 0)
self.assertEqual(payload[-1], 0)
def test_node_version_contains_timer_frequency(self):
model = NetbridgeModel(image_crc=0)
request_payload = bytes((0x08, 0x02, 0xFE, 0x7A, 0x0D))
_, _, payload = self.round_trip(model.handle(0x41, 1, request_payload))
self.assertEqual(payload[:4], bytes((0x08, 1, 33, 0)))
self.assertEqual(payload[4:8], struct.pack("<I", model.nodes[8].chip_id))
self.assertEqual(payload[8:10], struct.pack("<H", 10000))
def test_firmware_revision_matches_installed_runtime_metadata(self):
# Independent contract: GDB captured bundled firmware expectations at
# game SHA e8add023..., Ghidra 0090b2ec. Byte 10 was formerly 0x27.
model = NetbridgeModel(image_crc=0)
for address, revision in ((1, 1), (4, 3), (8, 1), (9, 1)):
with self.subTest(address=address):
payload = model.nodes[address].respond(b"\xfe", 13)
self.assertEqual(payload[10], revision)
self.assertEqual(sum(payload[:-1]) & 0xff, 0)
self.assertEqual(model.nodes[address].respond(b"\xfa\x01", 3)[0], revision)
def test_node_write_with_zero_response_length_is_silent(self):
model = NetbridgeModel(image_crc=0)
set_traffic = bytes((0x08, 0x03, 0xF0, 0x11, 0x74, 0x00))
self.assertIsNone(model.handle(0x41, 0, set_traffic))
def test_runtime_data_written_by_game_is_read_back(self):
model = NetbridgeModel(image_crc=0)
runtime_write = bytes((0x04, 0x04, 0xF2, 0x10, 0xAA, 0xBB, 0x00))
self.assertIsNone(model.handle(0x41, 0, runtime_write))
runtime_read = bytes((0x04, 0x02, 0xF2, 0x10, 0x0A))
_, _, payload = self.round_trip(model.handle(0x41, 1, runtime_read))
self.assertEqual(payload[:2], b"\xaa\xbb")
self.assertEqual(len(payload), 10)
def test_broadcast_input_traffic_does_not_restart_completed_discovery(self):
model = NetbridgeModel(image_crc=0)
model.discovered_nodes.update(model.discovery_addresses)
broadcast_set_traffic = bytes((0x00, 0x03, 0xF0, 0x11, 0x7C, 0x00))
self.assertIsNone(model.handle(0x41, 0, broadcast_set_traffic))
_, _, first = self.round_trip(model.handle(0x25, 2, b""))
self.assertEqual(first, b"\x00")
def test_locator_reports_installed_nodes_in_arbitration_order(self):
model = NetbridgeModel(image_crc=0)
found = []
for sequence in range(6):
_, _, payload = self.round_trip(model.handle(0x25, sequence, b""))
found.append(payload[0])
self.assertEqual(found, [10, 9, 8, 4, 1, 0])
_, _, restarted = self.round_trip(model.handle(0x25, 6, b""))
self.assertEqual(restarted, b"\x0a")
def test_per_node_input_traffic_publishes_that_bank(self):
model = NetbridgeModel(image_crc=0)
model.discovered_nodes.update(model.discovery_addresses)
node_eight_input_traffic = bytes((0x08, 0x03, 0xF0, 0x11, 0x74, 0x00))
self.assertIsNone(model.handle(0x41, 0, node_eight_input_traffic))
_, _, activity = self.round_trip(model.handle(0x25, 2, b""))
self.assertEqual(activity, b"\x08")
def test_pending_switch_activity_survives_repeated_set_traffic(self):
with tempfile.TemporaryDirectory() as directory:
event_file = Path(directory) / "events.jsonl"
event_file.touch()
model = NetbridgeModel(image_crc=0, event_file=event_file)
model.discovered_nodes.update(model.discovery_addresses)
with event_file.open("a", encoding="utf-8") as events:
events.write(json.dumps({"type": "switch", "name": "start", "value": 1}) + "\n")
broadcast_set_traffic = bytes((0x00, 0x03, 0xF0, 0x11, 0x7C, 0x00))
self.assertIsNone(model.handle(0x41, 0, broadcast_set_traffic))
_, _, activity = self.round_trip(model.handle(0x25, 2, b""))
self.assertEqual(activity, b"\x01")
self.assertEqual(model.discovered_nodes, set(model.discovery_addresses))
def test_quick_switch_tap_is_delivered_as_two_input_states(self):
with tempfile.TemporaryDirectory() as directory:
event_file = Path(directory) / "events.jsonl"
event_file.touch()
model = NetbridgeModel(
image_crc=0,
event_file=event_file,
minimum_switch_hold_seconds=0,
minimum_switch_active_reads=0,
)
model.discovered_nodes.update(model.nodes)
with event_file.open("a", encoding="utf-8") as events:
events.write(json.dumps({"type": "switch", "name": "start", "value": 1}) + "\n")
events.write(json.dumps({"type": "switch", "name": "start", "value": 0}) + "\n")
model.pending_activity.clear()
_, _, activity = self.round_trip(model.handle(0x25, 1, b""))
self.assertEqual(activity, b"\x01")
model.awaiting_input_read.add(1)
_, _, pressed = self.round_trip(
model.handle(0x41, 2, bytes((0x01, 0x01, 0x11, 0x0A)))
)
self.assertEqual(pressed[1] & (1 << 3), 0)
model._last_switch_change_at["start"] -= 1
_, _, released = self.round_trip(
model.handle(0x41, 4, bytes((0x01, 0x01, 0x11, 0x0A)))
)
self.assertNotEqual(released[1] & (1 << 3), 0)
def test_activity_polling_repeats_press_through_default_debounce(self):
with tempfile.TemporaryDirectory() as directory:
event_file = Path(directory) / "events.jsonl"
event_file.touch()
model = NetbridgeModel(image_crc=0, event_file=event_file)
model.discovered_nodes.update(model.discovery_addresses)
with event_file.open("a", encoding="utf-8") as events:
events.write(json.dumps({"type": "switch", "name": "start", "value": 1}) + "\n")
events.write(json.dumps({"type": "switch", "name": "start", "value": 0}) + "\n")
model.pending_activity.clear()
request = bytes((0x01, 0x01, 0x11, 0x0A))
for sequence in range(1, 6):
if sequence == 1:
_, _, activity = self.round_trip(model.handle(0x25, sequence, b""))
self.assertEqual(activity, b"\x01")
_, _, pressed = self.round_trip(model.handle(0x41, sequence, request))
self.assertEqual(pressed[1] & (1 << 3), 0)
model._last_switch_change_at["start"] -= 1
_, _, final_pressed = self.round_trip(model.handle(0x41, 6, request))
self.assertNotEqual(final_pressed[1] & (1 << 3), 0)
_, _, released = self.round_trip(model.handle(0x41, 7, request))
self.assertNotEqual(released[1] & (1 << 3), 0)
def test_direct_input_polls_drain_queued_switch_tap(self):
with tempfile.TemporaryDirectory() as directory:
event_file = Path(directory) / "events.jsonl"
event_file.touch()
model = NetbridgeModel(
image_crc=0,
event_file=event_file,
minimum_switch_hold_seconds=0,
minimum_switch_active_reads=0,
)
with event_file.open("a", encoding="utf-8") as events:
events.write(json.dumps({"type": "switch", "name": "start", "value": 1}) + "\n")
events.write(json.dumps({"type": "switch", "name": "start", "value": 0}) + "\n")
request = bytes((0x01, 0x01, 0x11, 0x0A))
_, _, pressed = self.round_trip(model.handle(0x41, 1, request))
self.assertEqual(pressed[1] & (1 << 3), 0)
_, _, released = self.round_trip(model.handle(0x41, 2, request))
self.assertNotEqual(released[1] & (1 << 3), 0)
def test_direct_input_poll_holds_press_for_debounce(self):
with tempfile.TemporaryDirectory() as directory:
event_file = Path(directory) / "events.jsonl"
event_file.touch()
model = NetbridgeModel(
image_crc=0,
event_file=event_file,
minimum_switch_hold_seconds=60,
minimum_switch_active_reads=5,
)
with event_file.open("a", encoding="utf-8") as events:
events.write(json.dumps({"type": "switch", "name": "start", "value": 1}) + "\n")
events.write(json.dumps({"type": "switch", "name": "start", "value": 0}) + "\n")
request = bytes((0x01, 0x01, 0x11, 0x0A))
_, _, pressed = self.round_trip(model.handle(0x41, 1, request))
_, _, still_pressed = self.round_trip(model.handle(0x41, 2, request))
self.assertEqual(pressed[1] & (1 << 3), 0)
self.assertEqual(still_pressed[1] & (1 << 3), 0)
model.minimum_switch_hold_seconds = 0
model.minimum_switch_active_reads = 0
_, _, released = self.round_trip(model.handle(0x41, 3, request))
self.assertNotEqual(released[1] & (1 << 3), 0)
def test_default_tap_stays_active_across_game_debounce_reads(self):
with tempfile.TemporaryDirectory() as directory:
event_file = Path(directory) / "events.jsonl"
event_file.touch()
model = NetbridgeModel(image_crc=0, event_file=event_file)
with event_file.open("a", encoding="utf-8") as events:
events.write(json.dumps({"type": "switch", "name": "start", "value": 1}) + "\n")
events.write(json.dumps({"type": "switch", "name": "start", "value": 0}) + "\n")
request = bytes((0x01, 0x01, 0x11, 0x0A))
for sequence in range(1, 6):
_, _, sample = self.round_trip(model.handle(0x41, sequence, request))
self.assertEqual(sample[1] & (1 << 3), 0)
model._last_switch_change_at["start"] -= 1
_, _, released = self.round_trip(model.handle(0x41, 6, request))
self.assertNotEqual(released[1] & (1 << 3), 0)
def test_get_status_does_not_expose_switch_bank(self):
model = NetbridgeModel(image_crc=0)
model._apply_switch("start", True)
_, _, status = self.round_trip(
model.handle(0x41, 1, bytes((0x01, 0x01, 0xFF, 0x0A)))
)
self.assertEqual(status[:8], bytes(8))
def test_secure_input_command_returns_rotating_xor_bank(self):
model = NetbridgeModel(image_crc=0)
model._apply_switch("coin", True)
# Captured from the installed game's secure node-1 input read. The
# wrapped bytes 02 87 d3 decrypt to command 11, key 6b, checksum c0.
key = 0x6B
request = bytes.fromhex("c1030287d30c")
_, _, encrypted = self.round_trip(model.handle(0x41, 1, request))
decrypted = bytes(
value ^ model._rotate_left_byte(key, index)
for index, value in enumerate(encrypted[:8])
)
self.assertEqual(decrypted, bytes(model.input_states[1]))
self.assertEqual(len(encrypted), 12)
def test_live_wrapped_secure_input_command_returns_switch_bank(self):
model = NetbridgeModel(image_crc=0)
model._apply_switch("start", True)
# Live Pokemon 0.83 capture after node-key setup. Removing both the
# transport token and serial key decodes 7a cc 92 to 11 76 b5.
key = 0x76
request = bytes.fromhex("c1037acc920c")
_, _, encrypted = self.round_trip(model.handle(0x41, 1, request))
decrypted = bytes(
value ^ model._rotate_left_byte(key, index)
for index, value in enumerate(encrypted[:8])
)
self.assertEqual(decrypted, bytes(model.input_states[1]))
self.assertEqual(len(encrypted), 12)
def test_long_secure_write_wraps_sixteen_byte_node_key(self):
model = NetbridgeModel(image_crc=0)
# Long configuration traffic may span more than one 16-byte node-key
# period. An invalid checksum is acceptable here; it must not crash.
request = bytes((0xC1, 0x20)) + bytes(range(0x20)) + b"\x00"
self.assertIsNone(model.handle(0x41, 1, request))
def test_input_command_returns_switch_bank(self):
model = NetbridgeModel(image_crc=0)
model._apply_switch("left_flipper", True)
request = bytes((0x08, 0x02, 0x11, 0x67, 0x0A))
_, _, payload = self.round_trip(model.handle(0x41, 1, request))
self.assertEqual(payload[3] & (1 << 1), 0)
self.assertEqual(len(payload), 10)
def test_get_status_returns_board_health_not_switches(self):
model = NetbridgeModel(image_crc=0)
model._apply_switch("left_flipper", True)
request = bytes((0x08, 0x02, 0xFF, 0x79, 0x0A))
_, _, payload = self.round_trip(model.handle(0x41, 1, request))
self.assertEqual(payload[:8], bytes(8))
self.assertEqual(len(payload), 10)
def test_six_balls_begin_in_the_trough(self):
model = NetbridgeModel(image_crc=0)
request = bytes((0x08, 0x02, 0x11, 0x67, 0x0A))
_, _, payload = self.round_trip(model.handle(0x41, 1, request))
self.assertEqual(payload[4], 0xC0)
def test_optional_shared_image_nodes_are_not_invented(self):
model = NetbridgeModel(image_crc=0)
self.assertNotIn(0x02, model.nodes)
self.assertNotIn(0x0C, model.nodes)
self.assertNotIn(0x0D, model.nodes)
self.assertNotIn(0x0E, model.nodes)
def test_qr_reader_uses_a_compatible_lpc812_identity(self):
model = NetbridgeModel(image_crc=0)
self.assertEqual(model.nodes[0x04].chip_id, 0x00008120)
self.assertEqual(model.nodes[0x04].flash_capacity, 0x4000)
self.assertEqual(model.nodes[0x08].flash_capacity, 0x8000)
def test_led_output_summary_is_published(self):
with tempfile.TemporaryDirectory() as directory:
output_file = Path(directory) / "outputs.json"
model = NetbridgeModel(image_crc=0, output_state_file=output_file)
self.assertIsNone(model.handle(0x2F, 0, b"\x01\x02\x03"))
state = json.loads(output_file.read_text(encoding="utf-8"))
self.assertEqual(state["led_frames"], 1)
self.assertEqual(state["last_led_payload"], "010203")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
workspace=${SPIKE3_WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}
image=${SPIKE3_MESA_BUILD_IMAGE:-spike3-buildroot-mesa:24.0.9}
output="$workspace/emulation/work/buildroot-mesa"
docker build \
-f "$workspace/emulation/docker/Dockerfile.mesa" \
-t "$image" \
"$workspace"
container=$(docker create "$image" /unused)
cleanup() {
docker rm -f "$container" >/dev/null 2>&1 || true
}
trap cleanup EXIT
rm -rf "$output"
mkdir -p "$output"
docker cp "$container:/rootfs" "$output/rootfs"
test -e "$output/rootfs/usr/lib/libEGL.so.1"
test -e "$output/rootfs/usr/lib/libGLESv2.so.2"
test -e "$output/rootfs/usr/lib/libgbm.so.1"
test -e "$output/rootfs/usr/lib/dri/kms_swrast_dri.so"
test -e "$output/rootfs/usr/lib/dri/swrast_dri.so"
printf 'Buildroot Mesa bundle ready: %s\n' "$output/rootfs/usr/lib"
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
from __future__ import annotations
import socket
import struct
import sys
from pathlib import Path
def recv_exact(sock: socket.socket, size: int) -> bytes:
chunks = []
remaining = size
while remaining:
chunk = sock.recv(remaining)
if not chunk:
raise RuntimeError("short read from VNC server")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def main() -> int:
host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 5900
out = Path(sys.argv[3]) if len(sys.argv) > 3 else Path("/workspace/emulation/work/vnc.ppm")
with socket.create_connection((host, port), timeout=10) as sock:
version = recv_exact(sock, 12)
sock.sendall(version)
if version.startswith(b"RFB 003.003"):
security = struct.unpack(">I", recv_exact(sock, 4))[0]
if security != 1:
raise RuntimeError(f"unsupported VNC security type {security}")
else:
count = recv_exact(sock, 1)[0]
security_types = recv_exact(sock, count)
if 1 not in security_types:
raise RuntimeError(f"VNC server does not offer no-auth security: {security_types!r}")
sock.sendall(b"\x01")
result = struct.unpack(">I", recv_exact(sock, 4))[0]
if result != 0:
raise RuntimeError(f"VNC security negotiation failed: {result}")
sock.sendall(b"\x01")
width, height = struct.unpack(">HH", recv_exact(sock, 4))
recv_exact(sock, 16)
name_len = struct.unpack(">I", recv_exact(sock, 4))[0]
recv_exact(sock, name_len)
pixel_format = struct.pack(
">BBBBHHHBBBxxx",
32,
24,
0,
1,
255,
255,
255,
16,
8,
0,
)
sock.sendall(b"\x00xxx" + pixel_format)
sock.sendall(struct.pack(">BBH", 2, 0, 1) + struct.pack(">i", 0))
sock.sendall(struct.pack(">BBHHHH", 3, 0, 0, 0, width, height))
while True:
message_type = recv_exact(sock, 1)[0]
if message_type == 0:
recv_exact(sock, 1)
rect_count = struct.unpack(">H", recv_exact(sock, 2))[0]
rgb = bytearray(width * height * 3)
for _ in range(rect_count):
x, y, w, h, encoding = struct.unpack(">HHHHi", recv_exact(sock, 12))
if encoding != 0:
raise RuntimeError(f"unsupported VNC rectangle encoding {encoding}")
raw = recv_exact(sock, w * h * 4)
for row in range(h):
for col in range(w):
src = (row * w + col) * 4
dst = ((y + row) * width + (x + col)) * 3
rgb[dst:dst + 3] = raw[src:src + 3]
break
if message_type == 2:
continue
raise RuntimeError(f"unexpected VNC message type {message_type}")
out.parent.mkdir(parents=True, exist_ok=True)
with out.open("wb") as fp:
fp.write(f"P6\n{width} {height}\n255\n".encode())
fp.write(rgb)
print(f"wrote {out} {width}x{height}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Install deterministic keyboard/gamepad mappings into the private VPX INI."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
KEYBOARD = {
# Cabinet keyboard map. Z, / and Space are the stock VPX nudge keys and are
# reserved for nudging: aliasing them onto the flippers and launch is what
# previously consumed every nudge direction and left nudging dead.
"Mapping.LeftFlipper": "Key;225",
"Mapping.RightFlipper": "Key;229",
"Mapping.LeftNudge": "Key;29",
"Mapping.RightNudge": "Key;56",
"Mapping.CenterNudge": "Key;44",
# The lockdown-bar action button is the only cabinet button Pokemon Pro
# exposes (action_button, 1-SW-2). The Spike3 controller plugin routes the
# Lockbar action straight to it.
"Mapping.Lockbar": "Key;226",
# Shooter-lane launch is deliberately not the LaunchBall action. The plugin
# claims LaunchBall, maps it to the same action_button contact as Lockbar,
# and clears enableVPXProcessing while the bridge is connected, so a
# LaunchBall binding is an alias of the action button and never reaches the
# table script. Custom1 passes through, so Return works the donor plunger.
"Mapping.LaunchBall": "",
"Mapping.Custom1": "Key;40",
"Mapping.Credit1": "Key;34",
"Mapping.Start": "Key;30",
"Mapping.Service1": "Key;36",
"Mapping.Service2": "Key;37",
"Mapping.Service3": "Key;38",
"Mapping.Service4": "Key;39",
}
PLAYER = {
# Pin the private cabinet to VPX's 10.8 box-model keyboard nudge with full
# strength. Blank values in a newly generated INI are legal but obscure
# which default won; explicit values make the three stock keys repeatable.
"KeyboardNudgeMode": "1",
"KeyboardNudgeStrength": "1",
"NudgeStrength": "1",
}
SERVICE_MAPPINGS = frozenset(
{
"Mapping.Service1",
"Mapping.Service2",
"Mapping.Service3",
"Mapping.Service4",
}
)
def button(device: dict[str, Any], name: str, *, threshold: str | None = None) -> str | None:
value = device.get(name)
if not isinstance(value, int):
return None
mapping = f"{device['setting_id']};{value}"
return f"{mapping};o;{threshold}" if threshold is not None else mapping
def mappings(devices: list[dict[str, Any]]) -> tuple[dict[str, str], dict[str, str]]:
actions: dict[str, list[str]] = {key: [value] for key, value in KEYBOARD.items()}
properties: dict[str, str] = {}
plungers: list[str] = []
for device in devices:
setting_id = device.get("setting_id")
if not isinstance(setting_id, str) or not setting_id.startswith("SDLJoy_"):
raise ValueError("invalid SDL controller setting_id")
properties[f"Device.{setting_id}.Name"] = str(device.get("name", "SDL Gamepad"))
properties[f"Device.{setting_id}.Type"] = "2"
properties[f"Device.{setting_id}.NoAutoLayout"] = "1"
routes = {
"Mapping.LeftFlipper": button(device, "left_shoulder"),
"Mapping.RightFlipper": button(device, "right_shoulder"),
"Mapping.Lockbar": button(device, "south"),
"Mapping.Credit1": button(device, "view"),
"Mapping.Start": button(device, "menu"),
"Mapping.Service1": button(device, "dpad_left"),
"Mapping.Service2": button(device, "dpad_down"),
"Mapping.Service3": button(device, "dpad_up"),
"Mapping.Service4": button(device, "dpad_right"),
}
for action, route in routes.items():
if route:
actions[action].append(route)
# Retain familiar face-button aliases as well as the requested
# Menu/View layout.
for action, name in (("Mapping.Credit1", "north"), ("Mapping.Start", "east")):
route = button(device, name)
if route:
actions[action].append(route)
for route in (
button(device, "right_trigger", threshold="-0.300000"),
button(device, "right_stick_button"),
):
if route:
actions["Mapping.Custom1"].append(route)
right_stick_y = device.get("right_stick_y")
if isinstance(right_stick_y, int):
plungers.append(f"{setting_id};{right_stick_y};P;0.000000;1.000000;1.000000")
for index, plunger in enumerate(plungers):
properties[f"Mapping.Plunger{index}.Position"] = plunger
properties[f"Mapping.Plunger{index}.Velocity"] = ""
properties["PlungerSensorCount"] = str(len(plungers))
properties["Devices"] = ";".join(["Key", *(d["setting_id"] for d in devices)])
return {
key: " | ".join(entry for entry in value if entry)
for key, value in actions.items()
}, properties
def update_ini(
path: Path,
values: dict[str, str],
*,
default_only: frozenset[str] = frozenset(),
section: str = "Input",
) -> None:
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
start: int | None = None
end = len(lines)
for index, line in enumerate(lines):
stripped = line.strip()
if stripped.lower() == f"[{section.lower()}]":
start = index
continue
if start is not None and index > start and stripped.startswith("["):
end = index
break
if start is None:
if lines and lines[-1]:
lines.append("")
start = len(lines)
lines.append(f"[{section}]")
end = len(lines)
found: set[str] = set()
for index in range(start + 1, end):
stripped = lines[index].strip()
if stripped.startswith(";") or "=" not in stripped:
continue
key = stripped.split("=", 1)[0].strip()
if key in values:
# Service-panel bindings are operator preferences. Install the
# keyboard/D-pad cabinet defaults only once, then leave both
# custom bindings and explicit empty/disabled bindings intact.
if key not in default_only:
lines[index] = f"{key} = {values[key]}"
found.add(key)
lines[end:end] = [f"{key} = {value}" for key, value in values.items() if key not in found]
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text("\n".join(lines) + "\n", encoding="utf-8")
temporary.replace(path)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("discovery", type=Path)
parser.add_argument("profile", type=Path)
args = parser.parse_args()
devices = json.loads(args.discovery.read_text(encoding="utf-8"))
if not isinstance(devices, list):
raise ValueError("controller discovery must contain a JSON array")
actions, properties = mappings(devices)
update_ini(
args.profile,
{**actions, **properties},
default_only=SERVICE_MAPPINGS,
)
update_ini(args.profile, PLAYER, section="Player")
# This is a workspace-private VPX profile created specifically for the
# integrated cabinet. VPX defaults newly discovered experimental plugins
# to disabled, so enable only the ABI-attested controller copied into this
# private app. The user's ordinary VPX preferences and other plugins remain
# untouched.
update_ini(
args.profile,
{"Enable": "1"},
section="Plugin.Spike3Controller",
)
print(f"Configured private VPX profile for {len(devices)} SDL gamepad(s)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Generate SecKC boot-splash logos matching Stern's SternLogo_<res>.png geometry.
These are original placeholder graphics for the SecKC SPIKE 3 talk (a chrome
"silverball" over a green terminal-styled SecKC wordmark on black). Drop real
SecKC branding in later by replacing the render_wordmark()/draw art or the
emitted PNGs. Output feeds emulation/scripts/patch-boot-logo.sh, which swaps
them over the staged SternLogo_<res>.png files at image-prepare time.
Usage:
python3 emulation/scripts/gen-seckc-logo.py [--out DIR]
Requires Pillow. Emits SternLogo_<W>x<H>.png for each Stern geometry plus a
1920x1080 SternLogo.png, so a straight file-for-file swap works.
"""
from __future__ import annotations
import argparse
import math
import os
from PIL import Image, ImageDraw, ImageFont
# (width, height) for every SternLogo boot geometry boot_display may load.
GEOMETRIES = [(1920, 1080), (1360, 768), (1024, 600), (800, 480)]
BG = (0, 0, 0)
GREEN = (57, 255, 20) # terminal / phosphor green
GREEN_DIM = (28, 130, 12)
WHITE = (235, 235, 235)
FONT_CANDIDATES_BOLD = [
"/System/Library/Fonts/Supplemental/Impact.ttf",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/System/Library/Fonts/HelveticaNeue.ttc",
]
FONT_CANDIDATES_MONO = [
"/System/Library/Fonts/Menlo.ttc",
"/System/Library/Fonts/Supplemental/Courier New Bold.ttf",
"/System/Library/Fonts/SFNSMono.ttf",
]
WORDMARK = "SecKC"
SUBTITLE = "// hack the silverball"
def load_font(candidates: list[str], size: int) -> ImageFont.FreeTypeFont:
for path in candidates:
if os.path.exists(path):
try:
return ImageFont.truetype(path, size)
except OSError:
continue
return ImageFont.load_default()
def draw_chrome_ball(img: Image.Image, cx: int, cy: int, r: int) -> None:
"""Radial-gradient steel ball: bright highlight up-left fading to near-black."""
hx, hy = cx - r * 0.35, cy - r * 0.4 # highlight center
box = (cx - r, cy - r, cx + r, cy + r)
ball = Image.new("RGB", (2 * r, 2 * r), BG)
bd = ImageDraw.Draw(ball)
for yy in range(2 * r):
for xx in range(2 * r):
dx, dy = xx - r, yy - r
if dx * dx + dy * dy > r * r:
continue
# distance from the highlight point, normalised
d = math.hypot((cx - r + xx) - hx, (cy - r + yy) - hy)
t = max(0.0, min(1.0, d / (r * 1.7)))
shade = int(255 * (1.0 - t) ** 1.6)
bd.point((xx, yy), fill=(shade, shade, min(255, shade + 6)))
mask = Image.new("L", (2 * r, 2 * r), 0)
ImageDraw.Draw(mask).ellipse((0, 0, 2 * r - 1, 2 * r - 1), fill=255)
img.paste(ball, (int(box[0]), int(box[1])), mask)
# thin white rim
ImageDraw.Draw(img).ellipse(box, outline=WHITE, width=max(2, r // 40))
def rounded_pill(draw: ImageDraw.ImageDraw, box, radius, outline, width) -> None:
draw.rounded_rectangle(box, radius=radius, outline=outline, width=width)
def render(width: int, height: int) -> Image.Image:
img = Image.new("RGB", (width, height), BG)
draw = ImageDraw.Draw(img)
cx = width // 2
# chrome ball sits above the wordmark, like Stern's ball
ball_r = int(height * 0.13)
ball_cy = int(height * 0.30)
draw_chrome_ball(img, cx, ball_cy, ball_r)
# wordmark pill
wm_font = load_font(FONT_CANDIDATES_BOLD, int(height * 0.20))
tb = draw.textbbox((0, 0), WORDMARK, font=wm_font)
tw, th = tb[2] - tb[0], tb[3] - tb[1]
pad_x, pad_y = int(width * 0.05), int(height * 0.05)
pill_w, pill_h = tw + 2 * pad_x, th + 2 * pad_y
pill_x0 = cx - pill_w // 2
pill_y0 = int(height * 0.44)
pill_box = (pill_x0, pill_y0, pill_x0 + pill_w, pill_y0 + pill_h)
rounded_pill(draw, pill_box, radius=pill_h // 2, outline=GREEN, width=max(3, height // 180))
# wordmark text (correct for font bbox offset)
tx = cx - tw // 2 - tb[0]
ty = pill_y0 + pad_y - tb[1]
draw.text((tx, ty), WORDMARK, font=wm_font, fill=GREEN)
# subtitle in mono, under the pill
sub_font = load_font(FONT_CANDIDATES_MONO, int(height * 0.055))
sb = draw.textbbox((0, 0), SUBTITLE, font=sub_font)
sw = sb[2] - sb[0]
draw.text((cx - sw // 2 - sb[0], pill_box[3] + int(height * 0.04) - sb[1]),
SUBTITLE, font=sub_font, fill=GREEN_DIM)
return img
def render_wide(width: int, height: int) -> Image.Image:
"""Wide, transparent-background banner for the Godot SPIKE-menu boot logo
(Spike3_logo.png, 1978x309 RGBA, composited as spike3_logo_layer)."""
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# chrome ball at the left
ball_r = int(height * 0.34)
ball_cx = int(width * 0.14)
ball_cy = height // 2
ball = Image.new("RGBA", (width, height), (0, 0, 0, 0))
bd = ImageDraw.Draw(ball)
hx, hy = ball_cx - ball_r * 0.35, ball_cy - ball_r * 0.4
for yy in range(ball_cy - ball_r, ball_cy + ball_r):
for xx in range(ball_cx - ball_r, ball_cx + ball_r):
dx, dy = xx - ball_cx, yy - ball_cy
if dx * dx + dy * dy > ball_r * ball_r:
continue
d = math.hypot(xx - hx, yy - hy)
t = max(0.0, min(1.0, d / (ball_r * 1.7)))
shade = int(255 * (1.0 - t) ** 1.6)
bd.point((xx, yy), fill=(shade, shade, min(255, shade + 6), 255))
img.alpha_composite(ball)
draw.ellipse((ball_cx - ball_r, ball_cy - ball_r, ball_cx + ball_r, ball_cy + ball_r),
outline=WHITE + (255,), width=max(2, ball_r // 40))
# wordmark
wm_font = load_font(FONT_CANDIDATES_BOLD, int(height * 0.62))
tb = draw.textbbox((0, 0), WORDMARK, font=wm_font)
tw = tb[2] - tb[0]
tx = ball_cx + ball_r + int(width * 0.03) - tb[0]
ty = (height - (tb[3] - tb[1])) // 2 - tb[1]
draw.text((tx, ty), WORDMARK, font=wm_font, fill=GREEN + (255,))
# subtitle to the right of the wordmark, baseline-aligned low
sub_font = load_font(FONT_CANDIDATES_MONO, int(height * 0.16))
sb = draw.textbbox((0, 0), SUBTITLE, font=sub_font)
sx = tx + tw + int(width * 0.03)
if sx + (sb[2] - sb[0]) < width:
draw.text((sx, int(height * 0.60) - sb[1]), SUBTITLE, font=sub_font, fill=GREEN_DIM + (255,))
return img
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__),
"..", "assets", "seckc-boot-logo"))
args = ap.parse_args()
out = os.path.abspath(args.out)
os.makedirs(out, exist_ok=True)
for w, h in GEOMETRIES:
img = render(w, h)
img.save(os.path.join(out, f"SternLogo_{w}x{h}.png"))
print(f"wrote SternLogo_{w}x{h}.png")
# default (no-suffix) name Stern ships at 1920x1080
render(1920, 1080).save(os.path.join(out, "SternLogo.png"))
print("wrote SternLogo.png (1920x1080)")
# Godot SPIKE-menu boot logo (transparent RGBA banner)
render_wide(1978, 309).save(os.path.join(out, "Spike3_logo.png"))
print("wrote Spike3_logo.png (1978x309 RGBA)")
print(f"output dir: {out}")
if __name__ == "__main__":
main()
@@ -0,0 +1,52 @@
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char **argv)
{
static const int reset_signals[] = {SIGHUP, SIGINT, SIGQUIT, SIGTERM};
struct sigaction action;
sigset_t empty_mask;
if (argc < 2) {
fprintf(stderr, "usage: launch-new-session PROGRAM [ARG ...]\n");
return 64;
}
/*
* A Terminal Ctrl-C is delivered to its foreground process group. Put
* each long-running cabinet component in a separate session so only the
* shell supervisor receives that interrupt and can coordinate a native
* VPX shutdown plus QEMU cleanup. execvp preserves this process ID for
* liveness checks, AppKit termination, and wait(2).
*/
if (setsid() < 0) {
const int error = errno;
fprintf(stderr, "launch-new-session: setsid: %s\n", strerror(error));
return 71;
}
memset(&action, 0, sizeof(action));
action.sa_handler = SIG_DFL;
sigemptyset(&action.sa_mask);
for (size_t index = 0; index < sizeof(reset_signals) / sizeof(reset_signals[0]); ++index) {
if (sigaction(reset_signals[index], &action, NULL) < 0) {
const int error = errno;
fprintf(stderr, "launch-new-session: sigaction: %s\n", strerror(error));
return 71;
}
}
sigemptyset(&empty_mask);
if (sigprocmask(SIG_SETMASK, &empty_mask, NULL) < 0) {
const int error = errno;
fprintf(stderr, "launch-new-session: sigprocmask: %s\n", strerror(error));
return 71;
}
execvp(argv[1], &argv[1]);
const int error = errno;
fprintf(stderr, "launch-new-session: exec %s: %s\n", argv[1], strerror(error));
return error == ENOENT ? 127 : 126;
}
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Read the default CoreAudio output rate without changing device properties."""
import argparse
import json
import os
from pathlib import Path
import ctypes
import math
import sys
class Address(ctypes.Structure):
_fields_ = [('selector', ctypes.c_uint32), ('scope', ctypes.c_uint32),
('element', ctypes.c_uint32)]
def fourcc(value):
return int.from_bytes(value.encode('ascii'), 'big')
def output_details():
audio = ctypes.CDLL('/System/Library/Frameworks/CoreAudio.framework/CoreAudio')
get = audio.AudioObjectGetPropertyData
get.argtypes = [ctypes.c_uint32, ctypes.POINTER(Address), ctypes.c_uint32,
ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32), ctypes.c_void_p]
get.restype = ctypes.c_int32
def read(device, selector, result):
address = Address(fourcc(selector), fourcc('glob'), 0)
size = ctypes.c_uint32(ctypes.sizeof(result))
status = get(device, ctypes.byref(address), 0, None, ctypes.byref(size), ctypes.byref(result))
if status:
raise RuntimeError(f'CoreAudio property {selector}: {status}')
return result.value
device = read(1, 'dOut', ctypes.c_uint32())
if not device:
raise RuntimeError('No default output device')
rate = read(device, 'nsrt', ctypes.c_double())
if not math.isfinite(rate) or not 8000 <= rate <= 192000 or rate != int(rate):
raise RuntimeError(f'Unsupported output sample rate: {rate}')
name = 'Default output device'
reference = read(device, 'lnam', ctypes.c_void_p())
if reference:
cf = ctypes.CDLL('/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation')
cf.CFStringGetCString.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_long, ctypes.c_uint32]
cf.CFStringGetCString.restype = ctypes.c_bool
cf.CFRelease.argtypes = [ctypes.c_void_p]
try:
buffer = ctypes.create_string_buffer(1024)
if cf.CFStringGetCString(reference, buffer, len(buffer), 0x08000100):
name = buffer.value.decode('utf-8')
finally:
cf.CFRelease(reference)
return name, int(rate)
def output_rate():
return output_details()[1]
SETTINGS = Path(__file__).resolve().parents[1] / 'work/audio-settings.json'
def validate_rate(value):
if str(value) == 'auto':
return 'auto'
if isinstance(value, bool) or not str(value).isdigit() or not 8000 <= int(value) <= 192000:
raise ValueError('Choose auto or an integer rate between 8000 and 192000 Hz')
return int(value)
def preference(path=SETTINGS, environ=None):
environ = os.environ if environ is None else environ
override = environ.get('SPIKE3_MACOS_AUDIO_RATE')
if override:
return validate_rate(override)
if path.exists():
data = json.loads(path.read_text())
if not isinstance(data, dict):
raise ValueError('Audio settings must be a JSON object')
return validate_rate(data.get('rate', 'auto'))
return 'auto'
def save_preference(value, path=SETTINGS):
value = validate_rate(value)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix('.tmp')
temporary.write_text(json.dumps({'rate': value}, indent=2) + '\n')
temporary.replace(path)
def detected_rate():
try:
return output_details()
except (OSError, RuntimeError) as error:
print(f'Cannot detect CoreAudio rate ({error}); using 48000 Hz. '
'Open Audio Settings to choose a rate.', file=sys.stderr)
return "Detection unavailable", 48000
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--status', action='store_true', help='Show detected and selected rates')
parser.add_argument('--configure', action='store_true', help='Choose and save a rate interactively')
parser.add_argument('--set', dest='setting', help='Save auto or a sample rate in Hz')
args = parser.parse_args()
try:
if args.setting is not None:
save_preference(args.setting)
choice = preference()
device_name, detected = detected_rate()
selected = detected if choice == 'auto' else choice
if args.status or args.configure or args.setting is not None:
print(f'Mac output: {device_name}')
print(f'Detected Mac output rate: {detected} Hz')
print(f'Selection: {"Automatic" if choice == "auto" else str(choice) + " Hz"}')
print(f'Next game launch: {selected} Hz')
if args.configure:
print('\nAutomatic is recommended. Connect/select your headphones before launching.')
print('1. Automatic 2. 44,100 Hz 3. 48,000 Hz 4. 96,000 Hz')
answer = input('Choose 14, enter a rate in Hz, or press Return to keep: ').strip()
if answer:
value = {'1': 'auto', '2': 44100, '3': 48000, '4': 96000}.get(answer, answer)
save_preference(value)
print('Saved audio selection.')
print('Changes apply on the next launch. Restart after changing output devices if pitch changes.')
if os.environ.get('SPIKE3_MACOS_AUDIO_RATE'):
print('SPIKE3_MACOS_AUDIO_RATE overrides saved settings for this shell.')
else:
print(selected)
except (ValueError, OSError) as error:
parser.exit(2, f'Audio settings error: {error}\n')
if __name__ == '__main__':
main()

Some files were not shown because too many files have changed in this diff Show More