Files
pokemon_pinball_wiki/docker-qemu-emulation.md

228 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Docker/QEMU Emulation Scaffold
## Scope
Workspace-local implementation:
- `emulation/`
Purpose:
- Build a Docker-packaged QEMU environment for booting a generated copy of the extracted AArch64 rootfs.
- Expose the guest display through QEMU VNC and noVNC in a browser.
- Provide a browser dashboard for logs and early virtual switch events.
- Keep target rootfs evidence files unmodified.
Static-analysis boundary: this page documents the emulator scaffold and evidence-based design. Creating and booting `emulation/work/rootfs.ext4` executes a generated guest copy, not the extracted evidence files directly.
## Current Status
This page primarily documents the older Docker/VNC analysis scaffold. That
path still provides a useful browser framebuffer and diagnostics, but it is not
the gameplay path. The current playable implementation runs QEMU natively with
HVF, Cocoa, and CoreAudio and joins it to a native VPX table; see
`native-playable-table.md`.
The native path now produces a genuine 1920x1080 Pokémon game frame with the
llvmpipe compatibility stack. The generated guest and netbridge are connected
end to end: VPX contacts enter the same node model consumed by the real game,
and real lamp/driver writes return to VPX through the supervised playfield
protocol. This does not make the emulator an exact electrical model of every
SPIKE board.
| Layer | Status | Evidence / remaining issue |
| --- | --- | --- |
| Generated guest boot | Working | QEMU reaches `SPIKE3 emulator init complete`; see `emulation/work/logs/qemu-serial.log`. |
| Browser access | Working | noVNC and the dashboard respond, and `capture-vnc.py` records nonblack 1360x768 frames. |
| DRM device discovery | Working | `virtio_gpu` creates `/dev/dri/card0`, `/dev/dri/renderD128`, and `fb0`; the guest provides the target-compatible `card1` alias. |
| Mesa EGL/GLES initialization | Working with compatibility overlay | The native probe reaches EGL and OpenGL ES 3.1 with Mesa softpipe; fbdev mode reaches a real surfaceless display and pbuffer. |
| Native target display | Working software-rendered baseline | HVF/Cocoa plus Debian llvmpipe produced a complete 1920x1080 game frame. This is CPU rendering, not Apple-GPU acceleration. |
| Native audio | Working baseline | QEMU HDA/CoreAudio carries the real game audio; VPX supplies only mechanical table sounds. |
| Netbridge/nodebus | Working playable subset | The real game consumes VPX cabinet/playfield contacts and publishes mapped lamp/driver output. The model is not a complete electrical reproduction of every node. |
| Switch dashboard | Diagnostic only | The browser remains optional; native VPX is the authoritative gameplay source when `physics_authority=vpx`. |
| Video decode | Working compatibility path | The host VideoToolbox bridge is preferred on macOS, with the existing guest software decoder retained as fallback. |
### Remaining fidelity boundary
The extracted software assumes Raspberry Pi graphics and I/O while QEMU
exposes generic VirtIO devices. The working native baseline therefore retains
a narrow compatibility overlay and CPU renderer. Docker/VNC can still hit the
historical EGLImage/FBO fallback path even though Cocoa displays the real game.
Several machine subsystems remain behavioral models rather than electrical
replicas: the RP2040 netbridge, SPIKE nodes, TAS/MAX amplifiers, cabinet buses,
and video decode devices. VPX supplies the missing physical playfield, but its
ramp elevations, forces, and materials remain evidence-tagged estimates where
the manual has no direct measurement.
## Evidence Basis
The main game and SPIKE menu use a direct DRM/KMS display path rather than a desktop windowing path.
Confirmed string/import evidence:
- `/games/pokemon_pro/game` imports `libEGL.so.1`, `libGLESv2.so.2`, `libdrm.so.2`, and `libgbm.so.1`; see `analysis/static-triage/game.objdump-p.txt`.
- `/games/pokemon_pro/game` strings include `/sys/class/drm`, `/dev/dri/card1`, `/dev/dri/card0`, `drmModeSetCrtc fail`, `drmModePageFlip`, `gbm_bo_create`, `eglGetPlatformDisplayEXT`, and `egl resolution: %d x %d`; see `analysis/static-triage/game.strings`.
- `/games/pokemon_pro/spike3/spike_menu/game` has matching GBM/EGL/DRM strings at offsets including `0x382cd0` (`/sys/class/drm`), `0x383030` (`/dev/dri/card1`), `0x383058` (`/dev/dri/card0`), and `0x383730` (`drmModeSetCrtc fail`); see `analysis/static-triage/spike_menu_game.strings`.
- `/etc/init.d/S12lvds_panel` launches `/games/spike3/bin/boot_display` early, and `/etc/init.d/game_monitor` later stops `boot_display` before launching `/games/game`.
Implementation consequence: both paths use QEMU `virtio-gpu`, not X11
forwarding. Docker exposes VNC/noVNC for analysis; playable macOS uses native
Cocoa directly.
## Implemented Files
Primary files:
| Path | Purpose |
| --- | --- |
| `emulation/compose.yaml` | Docker Compose entry point and port mapping. |
| `emulation/docker/Dockerfile` | Debian-based image with QEMU, noVNC, ffmpeg, qemu-user-static, and AArch64 cross tools. |
| `emulation/docker/fetch-debian-arm64-kernel.sh` | Installs a Debian ARM64 kernel and initramfs into `/opt/spike3-kernel/`; the initramfs seeds DRM/`virtio_gpu` modules. |
| `emulation/scripts/prepare-rootfs-image.sh` | Copies target-root entries into `emulation/work/rootfs-stage`, injects helpers, and builds `emulation/work/rootfs.ext4`. |
| `emulation/scripts/run-qemu.sh` | Boots QEMU `virt` with `virtio-blk`, `virtio-gpu`, VNC, serial logging, user networking, and a virtio-serial control socket. |
| `emulation/guest/emu-init` | Guest init used by QEMU with `init=/usr/local/spike-emu/bin/emu-init`. |
| `emulation/dashboard/server.py` | Browser dashboard on port `8090` for links, logs, and virtual switch events. |
| `emulation/stubs/spike3emu_stub.c` | Optional AArch64 `LD_PRELOAD` stub for inert fake device opens/ioctls on GPIO, I2C, serial, input, and `/dev/mem` paths. |
| `analysis/netbridge-emulator/netbridge_emu.py` | Optional host-side netbridge emulator used when `SPIKE3_NETBRIDGE_EMU=1`. |
Generated runtime files:
| Path | Purpose |
| --- | --- |
| `emulation/work/rootfs.ext4` | Generated guest root disk; not checked in as evidence. |
| `emulation/work/logs/` | Host/container logs for QEMU, dashboard, noVNC, and conagent backend. |
| `emulation/work/control.sock` | QEMU virtio-serial socket used by the dashboard event bridge. |
| `emulation/work/netbridge.sock` | Optional QEMU socket backend for the Python netbridge emulator. |
## Usage
Start the default boot-display target:
```sh
docker compose -f emulation/compose.yaml up --build
```
Open:
- Dashboard: `http://localhost:8090/`
- Display: `http://localhost:6080/vnc.html?host=localhost&port=6080&autoconnect=true`
- Conagent backend emulator: `http://localhost:8088/`
Try later targets after the virtual display is visible:
```sh
SPIKE3_AUTOSTART=spike-menu docker compose -f emulation/compose.yaml up
SPIKE3_AUTOSTART=game docker compose -f emulation/compose.yaml up
SPIKE3_AUTOSTART=monitors docker compose -f emulation/compose.yaml up
```
Run the main game with the Python netbridge emulator attached:
```sh
SPIKE3_NETBRIDGE_EMU=1 SPIKE3_AUTOSTART=game docker compose -f emulation/compose.yaml up --build
```
This starts `analysis/netbridge-emulator/netbridge_emu.py` on `emulation/work/netbridge.sock`, attaches it as a second QEMU PL011 serial port, maps guest `/dev/ttyAMA5` to that serial path, and tells the preload shim not to fake `/dev/ttyAMA5`.
The guest init accepts these autostart targets:
- `boot-display`
- `spike-menu`
- `game`
- `monitors`
- `shell`
## Current Emulator Behavior
The generated guest image:
- Mounts `/proc`, `/sys`, `/dev`, `/dev/pts`, `/run`, and `/tmp`.
- Creates writable `/connectivity`, `/connectivity/dump`, `/data`, and `/tmp/cache/mesa`.
- Preserves `/games/game -> /games/pokemon_pro/game`.
- Injects `/usr/local/spike-emu/bin/launch-target.sh` to start `boot_display`, SPIKE menu, game, or monitors.
- Injects an optional `LD_PRELOAD` hardware stub at `/usr/local/spike-emu/lib/libspike3emu_stub.so` when the Docker image has `aarch64-linux-gnu-gcc`.
- Injects overlay Mesa DRI fallback drivers under `/usr/local/spike-emu/mesa-dri` when the Docker image has ARM64 `virtio_gpu`, `kms_swrast`, `swrast`, and `zink` drivers.
- Injects matching ARM64 Debian Mesa/GLVND EGL libraries under `/usr/local/spike-emu/mesa-lib` plus `/usr/share/glvnd/egl_vendor.d/50_mesa.json`, so the fallback DRI drivers are loaded by the same Mesa userspace stack instead of the target rootfs `libEGL`. The overlay intentionally does not shadow the target rootfs `libstdc++.so.6`; Debian bookworm's copy lacks `GLIBCXX_3.4.31`/`GLIBCXX_3.4.32`, which `/games/game` requires.
- Injects ARM64 Mesa Vulkan/lavapipe support when present: `mesa-vulkan-drivers:arm64`, `libvulkan.so*`, `libvulkan_lvp.so`, and `/usr/share/vulkan/icd.d/lvp_icd.aarch64.json`.
- Injects `/usr/local/spike-emu/bin/display-probe`, an AArch64 helper compiled from `emulation/guest-tools/display-probe.c`, to test the guest GBM/EGL path before target launch.
- Supports `SPIKE3_DISPLAY_BACKEND=auto|native|fbdev`. `auto` records the probe result under `/run/spike-emu/display-backend.env` and currently selects `fbdev` presentation by default; `native` is still available for forced comparison.
- Supports optional `SPIKE3_NETBRIDGE_EMU=1`. In that mode the container starts the Python netbridge emulator, QEMU adds a second PL011 serial backed by `emulation/work/netbridge.sock`, guest setup symlinks `/dev/ttyAMA5` to `/dev/ttyAMA1`, and the preload shim passes `/dev/ttyAMA5` through instead of opening `/dev/null`.
- In `fbdev` fallback mode, `emulation/stubs/spike3emu_stub.c` uses real Mesa surfaceless/pbuffer rendering when possible, supplies fake-but-consistent EGL/GBM/EGLImage objects where needed, treats final DRM framebuffer/page-flip calls as successful, and writes visible frames to `/dev/fb0`.
- In fallback mode, the shim now also interposes enough GLES object setup for boot-display and SPIKE menu to get past their framebuffer-completeness path: synthetic GLES identity/limits, shader/program calls, framebuffer/renderbuffer/texture calls, and `/sys/class/gpio/*` fake opens/writes.
- Injects a generated `/etc/asound.conf` that maps ALSA `default` to the null PCM, clearing the prior missing-default-PCM abort inside the emulator copy.
- Injects `/usr/local/spike-emu/bin/fb-message`, compiled from `emulation/guest-tools/fb-message.c`, so target exits can paint a useful diagnostic screen to `/dev/fb0`.
- Starts a guest control agent that logs virtio-serial dashboard events to `/run/spike-emu/events.log` and `/connectivity/dump/log/spike-emu/control-agent.log`.
- Creates a cache manifest at `emulation/work/rootfs.manifest` and cache status at `emulation/work/rootfs-cache.status`. `SPIKE3_PREPARE_MODE=auto` reuses `emulation/work/rootfs.ext4` when inputs match.
The dashboard:
- Links to noVNC and the existing conagent backend emulator.
- Reads host logs from `emulation/work/logs/`.
- Reports rootfs cache status and requested display backend from `/api/status`.
- Sends virtual switch/button events as JSON to `emulation/work/control.sock` when QEMU is running. Verified APIs are `POST /api/event` and `POST /api/switch/<name>`.
## Verification Status
Commands run from the workspace root:
```sh
docker compose -f emulation/compose.yaml build spike3-emu
docker compose -f emulation/compose.yaml run --rm --entrypoint /workspace/emulation/scripts/prepare-rootfs-image.sh spike3-emu
SPIKE3_PREPARE_MODE=never SPIKE3_AUTOSTART=boot-display docker compose -f emulation/compose.yaml up --force-recreate
```
Confirmed results:
- Docker image contains `/opt/spike3-kernel/vmlinuz` and `/opt/spike3-kernel/initrd.img`; the initramfs includes `drm.ko`, `drm_kms_helper.ko`, `virtio_dma_buf.ko`, and `virtio-gpu.ko`.
- QEMU boots the generated `emulation/work/rootfs.ext4` and reaches `SPIKE3 emulator init complete; autostart=boot-display` in `emulation/work/logs/qemu-serial.log`.
- QEMU serial log confirms `virtio-gpu-pci detected`, `Initialized virtio_gpu`, and `fb0: virtio_gpudrmfb frame buffer device`.
- Guest hardware log confirms `/dev/dri/card0`, `/dev/dri/renderD128`, `/sys/class/drm/card0`, `/sys/class/drm/card0-Virtual-1`, and loaded `virtio_gpu`/`drm` modules.
- noVNC responds at `http://localhost:6080/vnc.html`; the dashboard responds at `http://localhost:8090/api/status`.
- `POST /api/switch/start` returns `sent_to_guest: true` and appends a switch event to `emulation/work/dashboard-events.jsonl`.
- The generated `/dev/dri/card1 -> card0` symlink fixes the first `/dev/dri/card1` open failure for `boot_display` and SPIKE menu.
- QEMU `virtio-gpu-pci,max_outputs=2` exposes two DRM connectors. The second connector is disconnected by QEMU VNC, so the LD_PRELOAD shim now snapshots the first connected connector and presents connector id `39` as connected with mode `1360x768`. This removes SPIKE menu's repeated `failed to get DRM connector for display_number 1` failure.
- Adding `libegl1:arm64`, `libegl-mesa0:arm64`, `libgles2:arm64`, and related ARM64 Mesa/GLVND packages to the Docker image removes the prior Mesa loader fatal `did not find extension DRI_Mesa version 1`.
- Adding `mesa-vulkan-drivers:arm64` to the Docker image makes the ARM64 lavapipe ICD and `libvulkan_lvp.so` available to the generated rootfs overlay.
- `emulation/scripts/capture-vnc.py` captures QEMU VNC frames to PPM; earlier captures before the `fbdev` fallback were all black (`1360x768`, RGB extrema all zero).
- Cache verification on 2026-07-07: after one rebuild, a second `docker compose -f emulation/compose.yaml run --rm --entrypoint /workspace/emulation/scripts/prepare-rootfs-image.sh spike3-emu` logged `rootfs cache hit; reusing /workspace/emulation/work/rootfs.ext4`. Host `stat` showed `emulation/work/rootfs.ext4` mtime unchanged at `1783433532`.
- `boot-display` verification on 2026-07-07: `docker compose -f emulation/compose.yaml up --force-recreate` reached `SPIKE3 emulator init complete; autostart=boot-display`; container-side `capture-vnc.py` wrote `emulation/work/boot-display-fbdev.ppm` with RGB extrema `[(0, 255), (0, 255), (0, 227)]`.
- `spike-menu` verification on 2026-07-07: `docker compose -f emulation/compose.yaml run --name spike3-menu-test --rm --service-ports -e SPIKE3_AUTOSTART=spike-menu spike3-emu` wrote `emulation/work/spike-menu-fbdev.ppm` with RGB extrema `[(0, 255), (0, 255), (0, 203)]`.
- `game` verification on 2026-07-07: `docker compose -f emulation/compose.yaml run --name spike3-game-test --rm --service-ports -e SPIKE3_AUTOSTART=game spike3-emu` wrote `emulation/work/game-fbdev.ppm` with RGB extrema `[(0, 255), (0, 255), (0, 203)]`.
- Diagnostic-screen verification on 2026-07-07: after adding `fb-message` and the generated ALSA null default, `docker compose -f emulation/compose.yaml run --name spike3-game-diag --rm --service-ports -e SPIKE3_AUTOSTART=game spike3-emu` moved past the prior `Unknown PCM default` / `snd_pcm_prepare` abort. It then hit shader/video failures and a segfault; `capture-vnc.py` wrote `emulation/work/game-diagnostic.ppm`, converted to `emulation/work/game-diagnostic.png`, showing `SHADER LIBRARY INIT FAILURE` and `LOG=GAME.STDOUT.LOG`.
- Fallback GLES/framebuffer verification on 2026-07-07: after adding synthetic GLES framebuffer/renderbuffer/texture wrappers, `boot-display` no longer logs `failed framebuffer check for created target buffer`, `failed to create framebuffer`, or `create_window RETRY`. `capture-vnc.py` wrote `emulation/work/boot-display-fbo-wrap.ppm` with RGB extrema `[(0, 255), (0, 255), (0, 42)]`.
- SPIKE menu verification on 2026-07-07: after adding `/sys/class/gpio/*` to the fake path set, the prior repeated `invalid GPIO 25` / `failed to set GPIO 25` spam disappeared. `capture-vnc.py` wrote `emulation/work/spike-menu-gpio-wrap.ppm` with RGB extrema `[(0, 255), (0, 255), (0, 134)]`. The deterministic blockers observed afterward are hardware-facing: `OpenOCD init failed`, `failed to initialize bridge`, TAS/MAX amp register verify failures, and missing real node hardware.
- Main game verification on 2026-07-07: `capture-vnc.py` wrote `emulation/work/game-gl-limits.ppm` with RGB extrema `[(0, 255), (0, 255), (0, 255)]`. The old framebuffer retry is not the blocker, but Godot/Radium still reports `Too many varyings used in shader (1 used, maximum supported is 0)`, followed by HEVC/V4L2 setup errors and `Segmentation fault`.
- Rootfs cache recheck on 2026-07-07 after the final shim rebuild: a second one-shot prepare logged `rootfs cache hit; reusing /workspace/emulation/work/rootfs.ext4`.
- Mesa dependency verification on 2026-07-07: adding missing ARM64 loader dependencies (`libX11-xcb`, `libwayland-client`, `libxcb-dri2`, `libxcb-present`, `libxcb-randr`, `libxcb-sync`, `libxcb-xfixes`, and `libxshmfence`) changed `display-probe` from `EGL_BAD_PARAMETER` to a successful native GBM/EGL initialization. The probe log reports `EGL_MESA_platform_gbm`, `EGL_MESA_platform_surfaceless`, successful `eglInitialize`, and Mesa DRI loading from `/usr/local/spike-emu/mesa-dri/kms_swrast_dri.so`.
- Native path verification on 2026-07-07: forcing `SPIKE3_DISPLAY_BACKEND=native` reaches `OpenGL ES 3.1 Mesa 22.3.6` / `renderer: softpipe`, but `/games/game` repeatedly logs `failed to make image from buffer object` and `failed to create framebuffer`; native is therefore not the default auto presentation path.
- Native llvmpipe verification on 2026-07-14: `SPIKE3_GRAPHICS_STACK=debian-llvmpipe SPIKE3_CAPTURE_TARGET_LOGS=1 emulation/scripts/run-macos.sh` booted the ARM64 guest with HVF and the Cocoa display. The target log reports `OpenGL API OpenGL ES 3.2 Mesa 22.3.6 - Compatibility - Using Device: Mesa/X.org - llvmpipe (LLVM 15.0.6, 128 bits)`. QEMU monitor `screendump` produced `emulation/work/llvmpipe-test.ppm` (1920x1080), converted to `emulation/work/llvmpipe-test.png`, and the capture contains a complete Pokémon game frame. A host `ps` snapshot 22 seconds after launch reported QEMU at 534% CPU and 6.1% memory. This confirms functional multithreaded CPU rendering; it is not Apple-GPU acceleration. Repeated `failed to queue page flip: Invalid argument` messages remain in the serial log but did not prevent the captured frame. Because live animation was smooth, `run-macos.sh` now selects `debian-llvmpipe` by default; `SPIKE3_GRAPHICS_STACK=buildroot-software` retains the former behavior when explicitly requested.
- fbdev/surfaceless verification on 2026-07-07: forcing `SPIKE3_DISPLAY_BACKEND=fbdev` uses the real Mesa surfaceless display and an implicit pbuffer (`eglMakeCurrent ... draw=<pbuffer> read=<pbuffer>`). This clears the `eglChooseConfig num=0` retry path, but the target-created framebuffer remains incomplete. The serial log shows `failed framebuffer check for created target buffer`, `failed to create framebuffer`, and repeated `GL_INVALID_FRAMEBUFFER_OPERATION` from `glClear`, `glDrawArrays`, and `glDrawElements`. The capture `emulation/work/game-fbdev-implicit-pbuffer.ppm` is nonblank but still only shows the fallback color bands because no valid target swap/copy occurs.
- Netbridge wiring verification on 2026-07-07: `python3 -m unittest discover -s analysis/netbridge-emulator`, Python bytecode compilation, and `bash -n` over the changed Docker/QEMU shell scripts all pass. The full `SPIKE3_NETBRIDGE_EMU=1` Docker boot has not yet been run, so guest acceptance of the emulated `/dev/ttyAMA5` path remains an open validation item.
Historical native-display blocker (superseded by the later Mesa dependency and fbdev work above):
- `/games/spike3/bin/boot_display` is reached and the old connector/EGL-loader fatals are cleared, but its EGL display creation still fails before it can allocate framebuffer BOs.
- The current shim trace confirms `LD_PRELOAD` is loaded and `gbm_create_device(fd=3)` succeeds on the `kms_swrast`/`softpipe` default path, returning a non-null GBM device pointer. The subsequent `eglGetPlatformDisplayEXT(EGL_PLATFORM_GBM_KHR, gbm_device, NULL)` returns `NULL` with `eglGetError() == 0x300c` (`EGL_BAD_PARAMETER`).
- Shim fallbacks also return `NULL` with `EGL_BAD_PARAMETER` for `EGL_PLATFORM_SURFACELESS_MESA`, core `eglGetPlatformDisplay`, and `eglGetDisplay(NULL)` even with `EGL_PLATFORM=surfaceless`.
- Forcing `MESA_LOADER_DRIVER_OVERRIDE=zink` and `GALLIUM_DRIVER=zink` with `VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.aarch64.json` makes `gbm_create_device` return `NULL`, so that is not the default path to leave enabled.
- After EGL display creation fails, `boot_display` later reports shader compile errors and then loops on `failed to lock front buffer: gbm_buffer[0] is null (frame=0)`. This appears to be an uninitialized app framebuffer array after the failed window/context setup, not a direct `gbm_surface_lock_front_buffer` failure.
- The implemented `fbdev` fallback bypasses this native EGL/GBM failure enough to produce nonblank QEMU VNC frames and let analysis proceed to the next deterministic target failure.
- With fallback enabled, the next deterministic front-end blocker is no longer black VNC, missing ALSA default PCM, or zero GL varying limits. `spike-menu` and `game` proceed through real Mesa initialization and hardware-facing bridge/audio failures. The current main-game display blocker is the EGLImage/FBO attachment path: the target-created framebuffer is incomplete, producing `GL_INVALID_FRAMEBUFFER_OPERATION` and preventing a real game frame from being copied to `/dev/fb0`.
## Limitations and Next Steps
Confirmed current limitations:
- The dashboard event path is only a guest-visible event log. It does not yet inject events into the games SPIKE nodebus model.
- The optional `LD_PRELOAD` stub is intentionally broad and inert. It can move early hardware probing forward, but any deterministic game failure still needs specific static/dynamic analysis before adding a targeted shim.
- QEMU `virtio-gpu` is not Raspberry Pi VC4/V3D. It is the right first display target because the binary asks for standard DRM/GBM/EGL surfaces, but shader/extension mismatches may still require a display interposer or patch.
- Docker Desktop on macOS runs this under QEMU TCG without KVM acceleration, so startup may be slow.
Next implementation targets:
1. Fix or fake the EGLImage/FBO attachment path used for the target-created framebuffer under fbdev/surfaceless Mesa. The current evidence is `failed framebuffer check for created target buffer`, `failed to create framebuffer`, and Mesa `GL_INVALID_FRAMEBUFFER_OPERATION` errors after `eglMakeCurrent` succeeds with a real pbuffer.
2. Run `SPIKE3_NETBRIDGE_EMU=1 SPIKE3_AUTOSTART=game docker compose -f emulation/compose.yaml up --build` and verify whether the bridge thread accepts the emulated `/dev/ttyAMA5` path. If OpenOCD still blocks startup before UART use, stub or bypass the OpenOCD programming step while preserving the UART protocol model.
3. Investigate HEVC/V4L2 decode initialization errors after display setup is stable.
4. Map dashboard switch events into a first SPIKE nodebus emulator, reusing recovered node command evidence from `main-game-behavior.md` and `topper-serial-key-emulator.md`.