Skip to content

GameQ architecture

Source-code links on this page require an invitation to the private GameQ repository.

On narrow screens, scroll sideways inside wide diagrams to see the full flow.

GameQ turns the COLDCARD Q into a Game Boy handheld. It reuses Coinkite’s hardware support, runs Peanut-GB as native C, and uses a modified Pokémon Red ROM as its resident room launcher. A small MicroPython shell owns cartridge loading, SD saves, input scheduling, dialogs and recovery.

The accepted 0.1.7GQ baseline is on main. The operator confirmed walking, CAPS fast-forward and save/reload on the Q. Full power-off save loading was explicitly confirmed on 0.1.6GQ. The latest report does not repeat that exact check or provide 0.1.7GQ timing measurements. See the hardware record for artifact hashes, evidence and open checks.

This guide explains implementation boundaries. Start with getting started to install, playing for controls, or development troubleshooting for the failures that shaped this design.

Component Responsibility Source
Immutable bootloader Hardware startup, firmware authentication and custom-firmware warning Pinned Coldcard dependency; never included in GameQ’s DFU
Startup overlay Select GameQ or recovery after LCD, PSRAM and keyboard initialization patch-coldcard.py
Boot and session shell ROM transfers, pacing, input, save ownership and diagnostics gameq_boot.py
Library shell Catalog, shelf dispatch, native dialogs and room restoration gameq_library.py
Native binding MicroPython API, hardware access and exception cleanup module.c
Emulator adapter Peanut-GB callbacks, verified ROM cache and LCD rows gameq_core.c
Resident room Actual Red engine, tiles, sprites, collision and mailbox requests hub overlays and hub guide
Recovery application Inspect and stage a Q DFU, authenticate, then hand off to the bootloader gameq_recovery.py

Only one Game Boy emulator instance runs at a time. Opening a native settings dialog pauses the room. Launching a cartridge replaces it; returning starts a fresh room and restores a small position checkpoint.

Wide diagrams scroll sideways on smaller screens.

%%{init: {"flowchart": {"useMaxWidth": false}}}%%
flowchart TD
    Boot[Immutable bootloader] --> Hardware[Initialize Q hardware]
    Hardware --> Marker{Recovery requested?}
    Marker -->|Yes| Recovery[GameQ updater]
    Recovery -->|Install and reboot| Boot
    Recovery -->|Cancel| Room[Resident room]
    Marker -->|No| Room
    Room -->|Select shelf| Load[Verify SD cartridge]
    Load --> Game[Play game]
    Game -->|Cancel| Save[Resolve save and exit]
    Save --> Room

Normal startup bypasses wallet settings, PIN login and Bitcoin menus. Recovery is selected before importing gameq_boot by an SD file at GameQ/system/recovery. An empty marker applies to every version; a for-version: marker applies to its named installed version. See build and recovery instructions.

From 0.1.5GQ, recovery uses a dedicated updater instead of freezing unused wallet menus into the image. The updater checks DFU framing, Q target, lengths, CRC and transfer readback. It retains configured main-PIN authentication and the bootloader’s signature check. A device without a PIN uses the bootloader’s blank-device authentication state. Recovery Cancel returns to the room in the same event loop. Updates through 0.1.7GQ succeeded on hardware; Cancel still needs explicit physical acceptance. This escape cannot recover every fault that occurs before the marker check.

The room is the complete 1 MiB hub ROM, losslessly compressed inside signed application flash. It requires no SD ROM copy. The implementation and host tests support no-SD room startup; that explicit hardware check remains open. Cartridge ROMs and saves live on SD.

The Q exposes 8 MiB of mapped PSRAM. GameQ uses only the lower-half region exposed by the stock Python wrapper. The upper half belongs to the stock RAM disk and recovery metadata. A mapped address is not permission to reuse it.

%%{init: {"flowchart": {"useMaxWidth": false}}}%%
flowchart TD
    subgraph Flash[Application flash]
        Runtime[Runtime and updater]
        Hub[Compressed hub]
    end
    subgraph PSRAM[External PSRAM]
        ROM[Current ROM: 2 MiB]
        Scratch[Unused scratch: 2 MiB]
        Reserved[Stock-owned: 4 MiB]
    end
    subgraph SRAM[Internal SRAM]
        Cache[Verified ROM cache]
        Core[Emulator and LCD rows]
        Heap[Objects and save RAM]
    end
    Hub -->|Decode| ROM
    SD[SD cartridge] -->|Load| ROM
    ROM -->|Verified copies| Cache
    Cache --> Core
    Heap <--> Core
Region or budget Accepted 0.1.7GQ value Meaning
Application payload 1,220,608 bytes; 221,184 bytes headroom Frozen runtime, emulator, resident hub and updater
0x90000000–0x901fffff 2 MiB ROM maximum One room or cartridge; Red uses 1 MiB
0x90200000–0x903fffff 2 MiB unused scratch Reserved lower half, not a general allocator
0x90400000–0x907fffff 4 MiB stock-owned GameQ leaves it alone
Native core static SRAM 222,148 bytes Includes 176 KiB of ROM bank storage and emulator/display state
Linked MicroPython heap 0x20038dc0–0x20097ff8; 389,688 bytes Before live Python allocations, including Red’s 32 KiB save buffer
Stack reservation 16 KiB Separate from the linked heap span

These are linked address/size budgets, not measured peak free memory. check-memory-budget.py requires at least 256 KiB of linked heap and verifies that the core does not overlap it. The build exports memory-budget.json, the ELF/map and ARM stack estimates.

The reservation is valid because normal GameQ startup does not run the wallet, PSBT, NFC or virtual-disk workflows that also use PSRAM. Recovery stages firmware before any game ROM is loaded. Cartridge RAM stays in internal SRAM: ordinary C byte and halfword stores to PSRAM have not been qualified for a mutable heap. That distinction is a gate for DOOM integration.

Source ownership evidence: bootloader PSRAM definition, Python wrapper, RAM-disk driver and Q linker layout.

The loader fills a 16 KiB internal buffer, handling short nonempty SD reads until the bank is complete. Native code writes aligned 32-bit words to PSRAM, with a barrier and immediate readback. A fingerprint is recorded only for a successfully written complete bank.

After the whole image has been written, the loader rereads the source. Its bank fingerprint must match the first pass before external-memory verification starts. This separates a changed source from a PSRAM readback mismatch and catches later writes that alias earlier banks.

%%{init: {"flowchart": {"useMaxWidth": false}}}%%
flowchart TD
    Source[Read first source bank] --> Write[Write aligned PSRAM words]
    Write --> Hash[Record bank fingerprint]
    Hash --> Again[Read source bank again]
    Again --> Same{Source unchanged?}
    Same -->|No| Reject[Reject and record error]
    Same -->|Yes| Scan[Scan the whole PSRAM bank]
    Scan --> Match{Whole scan matches?}
    Match -->|No: at most 3 scans| Scan
    Match -->|No: limit reached| Reject
    Match -->|Yes| Admit[Admit verified bank]
    Admit --> Cache[Verify each SRAM cache copy]

Each verification scan compares all four bytes from a single external word read. At most three complete scans are allowed per bank. Failed fragments are never combined into a pass and verification never repairs memory by rewriting it. Emulator startup requires every bank to be admitted.

Bank zero remains in internal SRAM. Ten additional 16 KiB slots cache switchable banks with least-recently-used replacement. On a miss, a full aligned copy must match its recorded fingerprint before the slot becomes visible. Three failed copies stop the core. Cache hits touch only internal SRAM. FNV fingerprints detect corruption; they are not cryptographic guarantees.

The resident ROM uses the same two-pass transfer, with sequential decompression instead of SD reads. It checks the compressed asset SHA-256, each decoded bank’s fingerprint, the full decoded ROM SHA-256 and a clean stream end. The roughly 34 KiB decoder workspace and 16 KiB output bank are released before emulator startup and save-buffer allocation.

Sources: ROM word helpers, verifier, builtin decoder. Tests: ROM verification, builtin asset, core/cache and boot ownership.

The PSRAM timing helper, introduced in 0.1.6GQ, is scoped to the pinned Q’s 120 MHz clock. It requires the inactivity timeout enabled with LPTR=16 and a known prescaler no slower than divide-by-four. It drains mapped writes, aborts prefetch, waits for an idle controller and then selects 30 MHz, with eight chip-select-high clocks, about 267 ns. Polling is bounded and the prior interrupt mask is restored. An unsupported state or failed transition stops loading and requires a reboot.

Verification and cache copies pause for 4 microseconds after each 64-byte burst, allowing chip select to release for refresh. The inactivity timeout releases chip select; it does not make a busy controller safe to reconfigure.

ESP-PSRAM64H specifies at least 50 ns CE-high, no more than 8 microseconds CE-low, at least 20 ns hold after the final rising clock and 45–55% duty. In Mode 0, ST’s controller provides one serial clock of final hold: about 33 ns at 30 MHz, versus 17 ns at 60 MHz. The even divider preserves 50% duty. These source-derived margins motivated the change. Successful hardware runs do not isolate the electrical cause of earlier corruption.

References: ESP-PSRAM64H Table 10-5, STM32 RM0432 sections 19.4.10–19.4.19, controller model tests and compiled verifier tests.

The ST7788 panel is 320×240 RGB565 on SPI1. The stock driver requests 60 MHz from the 120 MHz MCU. Game Boy output remains 160×144 source pixels:

View Panel output Reason
Cartridge 266×240 at (27, 0) Preserve the original aspect ratio to within one output pixel
Room 320×240 Fill the launcher screen
Diagnostic capture 160×144 grayscale Inspect emulation independently of scaling and SPI

Four amber shades replace the original green palette. The renderer masks Peanut’s palette flags before selecting a shade. RGB565 output does not add Game Boy Color emulation; the pinned core is DMG-only. Audio is disabled. See the screenshot gallery for capture provenance.

Send changed rows, retain a complete picture

Section titled “Send changed rows, retain a complete picture”

The 0.1.7GQ renderer reuses the existing grayscale capture and an 18-byte row validity bitmap. A selected frame compares normalized shades row by row. An unchanged valid row needs no RGB conversion or SPI transfer. A changed row expands to one or two physical rows; consecutive changed rows share a window, while a gap opens a new one.

Validity means “the complete row was successfully sent to the LCD.” Updating a capture alone is insufficient. A row becomes valid only after transfer success. Startup, width changes, Python screen clearing and native faults invalidate all rows. The MicroPython frame binding also catches command/data exceptions, invalidates the display, releases chip select and rethrows the same exception.

Full redraw Pixel bytes Wire-time floor at requested 60 MHz
Cartridge, 266×240 127,680 17.02 ms
Room, 320×240 153,600 20.48 ms

CPU work, commands and scheduling add time. Dirty-row updates transfer less. A 54,000-frame host comparison against 0.1.6GQ matched reconstructed LCD panels, captures, emulated state, saves and ROM-copy traces. At one render per four frames, the new-game replay sent 64.02% fewer wire bytes overall and 40.93% fewer during walking, including window-command overhead. The wide replay used Red, not the room ROM. These are transfer counts, not physical FPS. See hardware-test.md for the complete validation summary.

render_state() reports selected frames, source rows seen, rows sent, pixel bytes and window commands. These uint32 counters reset at cartridge startup, can wrap in long sessions and do not measure elapsed time.

Emulation and display cadence are separate

Section titled “Emulation and display cadence are separate”

Normal mode targets 59.73 emulated frames per second and adapts display cadence to measured cost, up to one LCD render per four emulated frames. This is a target, not a guarantee. CAPS removes pacing waits and still selects one render per four frames. It promises no fixed speed multiplier.

Input, emulation and dirty-save tracking continue on every emulated frame. Autosaving follows wall time. Each frame yields to the keyboard/power tasks, including in fast-forward. Mode changes and SD stalls reset pacing debt.

Controls come from the keyboard’s 6×10 pressed-state matrix, not a text character queue. This preserves held and simultaneous buttons. The physical SHIFT/CAPS key toggles on its down edge, once per press, and is not passed to the Game Boy. The stock SYM+SHIFT caps chord is unrelated.

The dedicated NFC_ACTIVE LED on PE4 follows fast-forward; cleanup turns it off on exit/fault. GameQ does not start the NFC service or change RF settings. The LED behavior is implemented and host-tested but still awaits explicit hardware confirmation. See gameq_led.py and LED tests.

The Q GPU shares the LCD SPI pins. The session takes SPI ownership and mutes stock status-bar drawing while native rendering is active. Cleanup restores the display hook. A background text draw must not interleave with a native row.

Pokémon Red uses MBC3 plus RAM and battery (0x13), with 32 KiB cartridge RAM and no RTC. Python owns that bytearray; native code attaches it and registers a MicroPython GC root. The binding re-derives the pointer each frame. Stop or fault releases the native root, and the library releases its old session references before allocating another cartridge. The room never reads or writes a persistent Pokémon save.

The game’s SAVE menu writes emulated cartridge RAM. GameQ then persists dirty RAM after two quiet seconds, with a two-minute fallback for continuously changing RAM. Cancel and a normal held-Power shutdown flush immediately. A failed write remains pending for retry.

Save replacement writes and syncs .sav.tmp, preserves the previous .sav as .sav.bak, then promotes the temporary image. Loading prefers a complete .tmp, then .sav, then .bak. Wrong-length main files move to .bad; I/O errors stop loading instead of starting with blank RAM. These checks validate length and transfer order, not every game’s internal checksum. Emulator faults write a separate .crash.sav and preserve the regular save.

%%{init: {"flowchart": {"useMaxWidth": false}}}%%
flowchart TD
    Hold[Power held during play] --> Flush{Save succeeds?}
    Flush -->|Yes| Off[Shutdown]
    Flush -->|No| Pause[Pause with RAM retained]
    Pause --> Choice{Choose next action}
    Choice -->|Enter| Flush
    Choice -->|Cancel| Resume[Release keys and resume]
    Choice -->|X| Confirm{Confirm discard?}
    Confirm -->|Enter| Off
    Confirm -->|Cancel| Choice

The keyboard callback does not draw. In 0.1.7GQ it records failure on the active SaveKeeper; the game coroutine owns the paused retry/return/discard UI. Repeated holds cannot enqueue multiple dialogs or transfer a stale failure to a later cartridge. Physical testing of this failure path and arbitrary FAT/SD power loss remains open. Normal save/reload passed on 0.1.7GQ; full-reboot save loading passed on 0.1.6GQ.

Sources and regression coverage: SaveKeeper and session flow, library ownership, boot/save tests and library tests.

The room is built from pinned pret/pokered plus overlays, rather than a Python imitation of its visuals. A 16-byte GQH1 mailbox at Game Boy WRAM $DEF0 requests shelf launch, settings, DOOM information or startup placement. The ROM publishes the command after its argument and coordinates. Linker assertions reserve the mailbox away from Red’s box data and stack.

The bridge accepts only action acknowledgements and bounded startup replies; it offers no arbitrary WRAM write API. Settings resume the paused room. A game launch saves three bytes of player x/y/facing; returning reloads the room and restores those coordinates after collision validation. This is a session-only position checkpoint, not an emulator save state or persistent hub save.

The shell sorts and snapshots up to eight regular .gb or .gbc files in /GameQ/roms. It excludes the legacy SD hub, directories and dotfiles such as macOS ._pokemon-red.gb. Shelf assignments stay fixed until a rescan. The filename suffix does not prove CGB compatibility. Held dialog keys are drained before play resumes so Cancel cannot immediately reopen the desk.

See hub.md for the complete mailbox ABI, map and build process; gameq_hub.h, hub tests and mailbox tests for executable contracts. The DOOM cabinet remains informational. A native port and CGB support are separate runtime roadmap work.

The build uses pinned dependencies and a container source snapshot. It rebuilds the hub inside that snapshot, binds ROM/asset/source manifests with SHA-256, and exports an application-only Q DFU at 0x08020000, signed with developer key 0. The immutable bootloader and its custom-firmware warning remain intact. Build instructions cover toolchains, signing and reproducibility.

The native module uses the Q board’s USER_C_MODULES path, SRC_USERMOD and the pinned MicroPython’s three-argument MP_REGISTER_MODULE. Build checks verify the module table, linked symbols, frozen shell, signature and memory budget. See build-firmware.sh.

Host tests isolate control flow and reproduce complete ROM execution. ARM emulation tests compiled code and exception paths. Neither models electrical PSRAM/SPI behavior. The hardware record is the acceptance source of truth; the troubleshooting guide records what each observation established and what remains uncertain.