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 map
Section titled “Component map”| 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.
Boot, recovery and session flow
Section titled “Boot, recovery and session flow”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.
Memory ownership
Section titled “Memory ownership”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.
ROM admission and the SRAM cache
Section titled “ROM admission and the SRAM cache”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.
External-memory timing
Section titled “External-memory timing”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.
Display, pacing and input
Section titled “Display, pacing and input”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.
Save ownership and shutdown
Section titled “Save ownership and shutdown”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.
Room protocol and catalog
Section titled “Room protocol and catalog”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.
Build and validation boundaries
Section titled “Build and validation boundaries”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.