romdevtools 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENTS.md +145 -498
  2. package/CHANGELOG.md +114 -3
  3. package/examples/atari7800/templates/sports.c +6 -2
  4. package/examples/sms/templates/shmup.c +5 -2
  5. package/package.json +2 -2
  6. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  7. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  8. package/src/host/LibretroHost.js +250 -1
  9. package/src/http/skill-doc.js +1 -1
  10. package/src/mcp/tools/index.js +4 -46
  11. package/src/mcp/tools/input-layout.js +10 -0
  12. package/src/mcp/tools/input.js +31 -2
  13. package/src/mcp/tools/playtest.js +17 -2
  14. package/src/mcp/tools/project.js +39 -6
  15. package/src/mcp/tools/record.js +9 -3
  16. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +155 -6
  17. package/src/platforms/atari2600/MENTAL_MODEL.md +37 -0
  18. package/src/platforms/atari7800/MENTAL_MODEL.md +36 -0
  19. package/src/platforms/c64/MENTAL_MODEL.md +103 -6
  20. package/src/platforms/gb/MENTAL_MODEL.md +56 -0
  21. package/src/platforms/gba/MENTAL_MODEL.md +57 -3
  22. package/src/platforms/gba/lib/arm-archives/libc.a +0 -0
  23. package/src/platforms/gba/lib/arm-archives/libgcc.a +0 -0
  24. package/src/platforms/gba/lib/arm-archives/libnosys.a +0 -0
  25. package/src/platforms/gbc/MENTAL_MODEL.md +21 -0
  26. package/src/platforms/genesis/MENTAL_MODEL.md +19 -0
  27. package/src/platforms/genesis/lib/c/libc.a +0 -0
  28. package/src/platforms/genesis/lib/c/libgcc.a +0 -0
  29. package/src/platforms/genesis/lib/c/libm.a +0 -0
  30. package/src/platforms/gg/MENTAL_MODEL.md +24 -0
  31. package/src/platforms/lynx/MENTAL_MODEL.md +33 -7
  32. package/src/platforms/msx/MENTAL_MODEL.md +27 -0
  33. package/src/platforms/nes/MENTAL_MODEL.md +35 -0
  34. package/src/platforms/sms/MENTAL_MODEL.md +51 -0
  35. package/src/platforms/snes/MENTAL_MODEL.md +21 -0
  36. package/src/platforms/snes/TROUBLESHOOTING.md +43 -0
  37. package/src/playtest/playtest.js +48 -0
  38. package/examples/msx/catch_game/_verify.mjs +0 -93
  39. package/examples/pce/catch_game/_verify.mjs +0 -75
  40. package/src/mcp/tool-manifest.js +0 -92
package/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # romdev — Agent guide
2
2
 
3
- You are reading this because romdev is connected. This is the orientation. Read it once; you won't need to re-read it during a session.
3
+ This is romdev's GENERIC orientation read it once. The platform-specific detail (memory maps, footguns, debug tooling) lives in each platform's docs, which you fetch on demand with `platform({op:'doc'})` as you work; this doc tells you when.
4
4
 
5
5
  ## What this server does
6
6
 
@@ -18,6 +18,16 @@ Internalize this above all else: **you never need — and must never install —
18
18
 
19
19
  This rule is about **compilers and emulators only** — NOT about content tools. ImageMagick, GIMP, Aseprite/LibreSprite, Audacity, Tiled, a tracker (FamiStudio/Deflemask), Python for a quick art script — all fine to use, and fine for the user to install. They produce **raw source art/audio** (a PNG, a sprite sheet, a `.wav`, a `.tmx`); romdev then **imports and packs** that into platform-native data. Use them freely when they help; just don't reach for a *compiler or emulator*.
20
20
 
21
+ ## The second rule: READ YOUR TARGET PLATFORM'S DOCS BEFORE YOU WRITE CODE FOR IT
22
+
23
+ This doc is deliberately GENERIC — it can't hold 14 platforms' worth of detail without bloating every session. The knowledge that actually saves you — the memory map, the input/control quirks, the render-enable order, the codegen traps, the SDK's footguns — lives in each platform's docs, read on demand:
24
+
25
+ - **`platform({op:'doc', platform, name:'mental_model'})`** — read this for EVERY system you're about to build or RE on, BEFORE you write code. It's a couple hundred tokens and most "why won't this work" dead-ends are a documented footgun you'd have seen there (a C64 game that needs a keyboard key to start; an SDCC WRAM-layout trap; a platform's render-enable order; gambatte exposing `gb_vram` not `video_ram`).
26
+ - **`platform({op:'doc', platform, name:'troubleshooting'})`** — the symptom→fix list; read it the moment something's broken.
27
+ - **`platform({op:'doc', platform:'romhacking', name:'playbook'})`** — read FIRST if you're doing a romhack/RE (the cross-platform decision tree).
28
+
29
+ Skipping this is the #1 avoidable time-sink. If you find yourself flailing on platform behavior and you haven't read that platform's `mental_model`, stop and read it — the answer is almost always there.
30
+
21
31
  ### romdev also packs assets in-server — reach for these first
22
32
 
23
33
  Asset conversion is bundled too, so you often don't need the host tools at all. First-class tools: `encodeArt({stage:'tiles'})`, `encodeArt({stage:'tilemap'})`, `encodeArt({stage:'quantize'})`, `palette({source:'platformMaster'})`, `palette({source:'lospec'})`, `encodeArt({stage:'validate'})`, the loaders `importArt({from:'texturepacker'})` / `importArt({from:'aseprite'})` / `importArt({from:'gif'})` / `importArt({from:'tiled'})`, and helpers like `sprites({op:'capture'})` / `importArt({from:'rom'})`. The canonical quantize→tile→pack path lives here. Typical flow: paint pixels in a host editor (or generate a PNG), then `encodeArt({stage:'quantize'})` → `encodeArt({stage:'tiles'})` to get platform-native tiles. (You can do the whole thing in-server too when the art is procedural.)
@@ -138,10 +148,12 @@ worry about ground truth:
138
148
 
139
149
  1. **`scaffold({op:'project', platform, template, name, path})`** — drops a
140
150
  complete, self-contained project tree on disk (main.c + the
141
- runtime files it needs + your `vendor/` library source for
142
- reference + README + .gitignore). Build with `build({output:'run'})` against
143
- the project's files; the bundled examples ARE the reference
144
- implementation.
151
+ runtime files it needs + the vendored library source for
152
+ reference + README + .gitignore). The response lists only the files you EDIT
153
+ (`files`) + a `vendorFileCount`; pass `verbose:true` for the full manifest.
154
+ Build the whole dir in one call with `build({output:'project', path,
155
+ outputPath})` (toolchain/crt0/linker inferred — no manifest); the bundled
156
+ examples ARE the reference implementation.
145
157
  2. **`scaffold({op:'game', platform, genre})`** — same but picks a known-good
146
158
  genre scaffold (shmup / platformer / puzzle / sports / racing).
147
159
  3. **`scaffold({op:'snippets', platform, mode})`** (mode `list`/`get`/`getAll`)
@@ -152,11 +164,13 @@ worry about ground truth:
152
164
  without round-tripping bytes through your context — preferred
153
165
  when you're scaffolding into a project dir.
154
166
 
155
- For most workflows, path A is all you need. Read MENTAL_MODEL.md +
156
- TROUBLESHOOTING.md when stuck. **When a tool call FAILS, read the error
157
- message and `issues[]` first see "When a call fails" below; the error
158
- usually names the fix.** File a feedback round if the bundled examples
159
- are wrong.
167
+ Reminder (it's the second rule up top): **read your platform's
168
+ `platform({op:'doc', platform, name:'mental_model'})` BEFORE you write code for
169
+ it** that's where the footguns that would otherwise burn your session live.
170
+
171
+ For most workflows, path A is all you need. **When a tool call FAILS, read the
172
+ error message and `issues[]` first — see "When a call fails" below; the error
173
+ usually names the fix.** File a feedback round if the bundled examples are wrong.
160
174
 
161
175
  ### Path B — Debug when the bundled code disagrees with behavior
162
176
 
@@ -317,18 +331,19 @@ Different platforms have different levels of MCP-exposed debugging — different
317
331
  > `as`/`ld`/`objcopy`. The per-platform notes below cover the platform-SPECIFIC
318
332
  > inspectors + chips (PC Engine + MSX: generic shapes only so far).
319
333
 
320
- - **SNES** (snes9x patched): `sprites({op:'inspect'})`, `palette({source:'live'})`, `cpu({op:'read', cpu:'main'|'spc700'})`, getDspState (full per-voice + master mixer), `memory({op:'read'})` regions for OAM/CGRAM/ARAM/FillRAM. Audio + video both deeply introspectable.
321
- - **NES** (fceumm patched): `sprites({op:'inspect'})`, `palette({source:'live'})`, `cpu({op:'read'})` (6502), `background({view:'renderState'})` (PPUCTRL/PPUMASK decoded active CHR bank + file offset), `memory({op:'read'})` regions for OAM/Palette/Nametables/CHR/CPU_REGS/PPU_REGS/APU_REGS.
322
- - **Genesis** (gpgx patched): `sprites({op:'inspect'})`, `palette({source:'live'})`, `cpu({op:'read', cpu:'main'})` for 68K, getYm2612State (limited internal struct), getPsgState, `memory({op:'read'})` regions for CRAM/VSRAM/VDP_REGS/Z80_RAM/M68K/YM2612/PSG/VRAM.
323
- - **SMS / Game Gear** (gpgx patched): `sprites({op:'inspect'})` (SAT decode + sprite-sheet PNG), `palette({source:'live'})` (6-bit BGR for SMS, 12-bit BGR for GG), `tiles({op:'png'})` (4bpp interleaved, 16KB VRAM as 512-tile sheet), `cpu({op:'read'})` (Z80 — A/F/BC/DE/HL/IX/IY/shadows + flags + interrupt state), `audioDebug({op:'inspect', chip:'psg'})` (SN76489 — 3 tone + 1 noise; same gpgx region as Genesis), `background({view:'renderState'})` (VDP regs → name table / BG-tile / sprite-tile / SAT addresses + scroll + display state), `memory({op:'read'})` regions for sms_vram, sms_cram, sms_vdp_regs, sms_z80_regs (gg_vram, gg_cram for Game Gear's 64-byte palette). `disasm({target:'rom'})` + `disasm({target:'references'})` + `disasm({target:'project'})` run through the native binutils z80 `objdump` (WASM, `-m z80`) with full prefix coverage (CB/ED/DD/FD/DDCB/FDCB) and the same auto-label / register-annotation / file-offset / untilReturn pipeline as NES/SNES.
324
- - **Game Boy / Game Boy Color** (gambatte patched): `sprites({op:'inspect'})` (40-sprite OAM decode + sprite-sheet PNG with sprite-priority + h/v flip), `palette({source:'live'})` (DMG: BGP/OBP0/OBP1 byte decode → 4 shades each; GBC: 64-byte BCPS/OCPS palette RAM → 8 palettes × 4 colors BGR555), `tiles({op:'png'})` (384 tiles from $8000-$97FF), `cpu({op:'read'})` (SM83 — A/F/BC/DE/HL + flags + IME/halt), `audioDebug({op:'inspect', chip:'gb'})` (DMG APU — 2 pulse + wave + noise with timer→freq→note, sweep, duty, panning), `background({view:'renderState'})` (LCDC bit-by-bit, scroll, LY/LYC, window, GBC extras: VRAM bank / KEY1 / BCPS/OCPS index), `memory({op:'read'})` regions for gb_vram, gb_oam, gb_io, gb_hram, gb_bgpdata, gb_objpdata, gb_cpu_regs. `disasm({target:'rom'})` + `disasm({target:'references'})` + `disasm({target:'project'})` route through the native binutils z80 `objdump` in its `gbz80` machine (WASM, `-m gbz80`) — full CB-prefix coverage + SM83-specific opcodes (`ld (hl+),a`, `ldh`, `reti`, `ld hl,sp+e8`). One z80-elf binutils serves both plain Z80 (SMS/GG/MSX) and the GB CPU.
325
- - **Toolchains:** default is **C** via SDCC's sm83 port (same SDCC that powers SMS/GG/MSX/Coleco). For hand-tuned asm, pass `language:"asm"` to route through RGBDS. The C path uses `__sfr __at 0xFFNN` to bind GB I/O regs; helper headers under `src/platforms/gb/lib/c/gb_hardware.h` define LCDC/STAT/SCY/SCX/LY/BGP/OBP0/OBP1/etc. for both DMG and CGB. The SDCC 4.4.0 codegen quirk (`for (;;) { switch + write to __sfr }` crashes the register allocator) applies — use `do { ... } while (1)` and table-lookup writes instead.
326
- - **Atari 2600** (stella2014 patched): `palette({source:'live'})` (NTSC 128-color palette PNG; current background luma+hue extracted from TIA snapshot), `sprites({op:'inspect'})` (no OAM returns the 5 graphics objects state P0/P1/M0/M1/Ball + a current-scanline PNG showing TIA composition), `cpu({op:'read'})` (6502 — A/X/Y/P/SP/PC from the M6502 internal regs), `background({view:'renderState'})` (decodes the 32-byte TIA snapshot into playfield/sprite/colors), `memory({op:'read'})` regions for `system_ram` (128 bytes of RIOT RAM), `a26_tia_regs` (32-byte TIA snapshot), `a26_cpu_regs` (7-byte 6502 snapshot). `disasm({target:'rom'})` + `disasm({target:'references'})` anchor to the top of the bank ($F000-$FFFF) with vector-table labels (NMI/RESET/IRQ at $FFFA).
327
- - **Atari 7800** (prosystem patched): `palette({source:'live'})` (256-color master PNG; MARIA palette block at $20-$3F decoded into 8 palettes × 3 colors + backdrop), `sprites({op:'inspect'})` (no OAM returns the MARIA control regs + the DPP display-list-list pointer for the agent to walk), `cpu({op:'read'})` (6502 — A/X/Y/P/SP/PC from prosystem's sally globals), `background({view:'renderState'})` (MARIA CTRL bits + DPP + CHARBASE + dlistPtr), `memory({op:'read'})` regions for `system_ram` (the entire 64KB 6502 address space — MARIA regs, RAM, ROM all visible) + `a78_cpu_regs`. `disasm({target:'rom'})` + `disasm({target:'references'})` default to the top 16KB ($C000-$FFFF) where the reset vector lands.
328
- - **Commodore 64** (vice patched): `palette({source:'live'})` (the 16-color hardware-fixed palette PNG + current border/background/extra-bg indices decoded from VIC-II regs), `sprites({op:'inspect'})` (8 MOBs decoded into the generic shape with X/Y/color/multicolor/expand-X/expand-Y/priority + the screen-RAM sprite-data pointers at $07F8 so the agent can locate sprite pixel blocks), `cpu({op:'read'})` (6510 — A/X/Y/P/SP/PC from a `#define`-aliased live register file + the I/O port at $0001 decoded into LORAM/HIRAM/CHAREN), `audioDebug({op:'inspect', chip:'sid'})` (6581/8580 — 3 voices {waveform, freq→note, pulse-width, ADSR} + filter cutoff/resonance/mode), `background({view:'renderState'})` (VIC-II regs decoded into mode/scroll/colors/sprites, VIC bank from CIA2 $DD00, absolute screen + char base addresses), `memory({op:'read'})` regions for `system_ram` (64 KB RAM), `c64_color_ram` (1 KB), `c64_vic_regs` (64 B), `c64_sid_regs` (29 B via sid_peek), `c64_cia1_regs`/`c64_cia2_regs` (16 B each from `c_cia[]`), `c64_cpu_regs` (7 B). `disasm({target:'rom'})` + `disasm({target:'references'})` accept `.prg` files (2-byte load-address header) and the C64 register annotation table for VIC-II / SID / CIA registers. Starter snippets cover vic_init / sprite_table / sid_play / read_joystick / basic_stub. **Disk images:** `loadMedia({platform:'c64', path:'game.d64'})` loads & autostarts real `.d64`/`.t64`/`.tap`/`.crt`/`.g64` games (drive 8, warp autostart — give it a few hundred frames; `status.mediaKind` reports disk/tape/cartridge/program); `cart({op:'packDisk', prgPath})` wraps a built `.prg` into a distributable autostart `.d64` (the format the Commodore 64 Ultimate hardware + the homebrew scene load), and `cart({op:'extract', path:'x.d64'})` lists/pulls its files. **Disk SAVES** (the C64 save medium is the floppy, not battery SRAM): a game's OWN KERNAL `SAVE` writes into the live disk (true-drive GCR write-back), so just run the game, let it save, then `state({op:'exportDisk', path})` to capture a `.d64` that includes the saved file (re-loadable to resume). `state({op:'importDisk', path})` pushes a `.d64` back into the running drive and `state({op:'putDiskFile', path, name})` injects one PRG file — for injecting a save made elsewhere. (On-disk filenames are PETSCII; the .d64 reader decodes them.)
329
- - **Game Boy Advance** (mgba patched): `sprites({op:'inspect'})` (128 OAM sprites → generic shape with shape/size, 9-bit signed X, affine/hidden, tile/palette/priority), `palette({source:'live'})` (256 BG + 256 OBJ 15-bit BGR555, `area:'bg'|'sprite'`), `cpu({op:'read'})` (ARM7TDMI — 16 gprs r0-r15 + cpsr/spsr + mode + ARM/THUMB, plus `execPc` adjusted for pipeline prefetch), `audioDebug({op:'inspect', chip:'gba'})` (4 DMG PSG channels + 2 Direct Sound DMA FIFOs, master/bias), `background({view:'renderState'})` (DISPCNT bg-mode + per-BG enable/priority/char-base/map-base/color-mode, forced-blank, OBJ enable), `memory({op:'read'})` regions for `gba_cpu_regs`, `gba_io_regs` (the IO page — video AND audio regs), `gba_palette`, `gba_oam`, plus system_ram/video_ram/save_ram. `disasm({target:'rom'})` + `disasm({target:'references'})` + `disasm({target:'project'})` run through the native binutils `arm-none-eabi-objdump` (WASM) — ARM by default, `thumb:true` for Thumb code; the byte-exact project reassembles through `arm-none-eabi-as`/`ld`/`objcopy`. (Note: GBA C compiles mostly to Thumb reached via an ARM crt0 stub, so an ARM-mode disasm of a full ROM decodes the Thumb spans as `.byte` — still byte-exact, just less readable until ARM/Thumb mode-tracking lands.)
330
- - **Atari Lynx** (handy patched): `palette({source:'live'})` (16-entry 12-bit Mikey palette → RGB), `cpu({op:'read'})` (65C02 A/X/Y/P/SP/PC + flags), `audioDebug({op:'inspect', chip:'mikey'})` (4 channels volume, timer→freq→note, 12-bit LFSR state), `background({view:'renderState'})` (DISPCTL DMA-enable/flip/color-mode + display base address), `memory({op:'read'})` regions for `lynx_cpu_regs`, `lynx_hw_regs` (the $FC00-$FDFF Suzy+Mikey window — sprite engine regs, LCD control, audio, palette), plus system_ram. **`sprites({op:'inspect'})` is a special case:** the Lynx has NO fixed OAM — sprites are SCB (Sprite Control Block) linked lists in RAM walked by Suzy, so `sprites({op:'inspect'})` returns the SCB list head (SCBNEXT $FC10/$FC11) and instructions to walk the chain over system_ram rather than a sprite table.
331
- - **MSX, ColecoVision**: standard system_ram + save_ram + video_ram. Deeper introspection not yet added — extend by patching their cores following the snes9x/gpgx/fceumm/vice pattern (see scripts/patches/).
334
+ The deep per-platform inspectors + the exact memory-region names, core quirks, and any platform-specific traps live in **each platform's `MENTAL_MODEL.md`** (read via `platform({op:'doc', platform, name:'mental_model'})`) read it for the system you're on. Symptom doc:
335
+ - **NES** blank/black screen, wrong sprites/colors, or need live PPU regs / CIRAM-attribute / MMC1-banked CHR state.
336
+ - **SNES** garbage/flashing sprites, or live OAM/CGRAM/SPC700/S-DSP state (PPU regs read via the FillRAM shadowno core patch needed).
337
+ - **Genesis** missing/wrong sprites, palette/scroll, or live SAT/CRAM/VSRAM/VDP/Z80 state (mind the gpgx VRAM byte-swap).
338
+ - **GB / GBC** wrong sprites/palette/tiles/BG or live SM83/APU/LCDC state; gambatte exposes `gb_vram` (NOT `video_ram`) + `gb_oam`/`gb_io`/`gb_hram`. (GB MENTAL_MODEL also holds the SDCC toolchain notes; GBC adds the CGB palette deltas.)
339
+ - **SMS / GG** sprite/tile/palette/BG issues or live Z80 + VRAM/CRAM/VDP regions (SMS holds the shared gpgx detail; GG = 12-bit-vs-6-bit palette + `gg_vram`/`gg_cram` deltas).
340
+ - **GBA** — sprite/palette/BG wrong, ARM7 `execPc` (pipeline-adjusted PC), the `gba_*` regions, or ARM-vs-Thumb objdump (Thumb decodes as `.byte`).
341
+ - **Atari 2600** — blank screen / missing sprite / TIA-or-palette state, or `audioDebug` "not supported" (no OAM, no standard sound chip).
342
+ - **Atari 7800** display-list garbage, MARIA palette/DPP, `sprites({op:'inspect'})` returns no OAM, or no `audioDebug`.
343
+ - **C64** VIC/sprites/SID/banking misbehaving: live palette/sprites/cpu/renderState inspectors, `c64_*` regions, `.prg` disasm, disk load/save+export (and its keyboard/joyport input see "Driving input over MCP").
344
+ - **Atari Lynx** — `sprites({op:'inspect'})` returns an SCB list head (no fixed OAM), or you need the Mikey palette/audio, 65C02 regs, or the `lynx_hw_regs` $FC00-$FDFF window.
345
+ - **MSX** VDP/PSG inspection or AY8910 `audioDebug`. (ColecoVision is bring-up-only: standard `system_ram`/`save_ram`/`video_ram`, no custom inspectors — extend by patching its core per the snes9x/gpgx pattern.)
346
+ - **PC Engine** — generic shapes + the core's native regions only so far (no custom-inspector treatment yet).
332
347
 
333
348
  Starter snippets per platform live under `src/platforms/<platform>/lib/`. Discover via `scaffold({op:'snippets', platform})` (default `mode:'list'`), fetch one via `scaffold({op:'snippets', platform, mode:'get', name})`. SNES + NES + Genesis + SMS + Game Boy + Atari 2600 + Atari 7800 have substantial snippet libraries; others are minimal.
334
349
 
@@ -396,7 +411,7 @@ When `build({output:'run'})` is too coarse, the long-form workflow:
396
411
  2. `loadMediaBytes({ platform, base64 })` → load without disk I/O
397
412
  3. `frame({op:'step', frames: N})` or `runUntil({ condition })` → advance time
398
413
  4. `frame({op:'screenshot'})` for vibes, `tiles({op:'pixels'})`/`tiles({op:'fingerprints'})` for byte-precise work, `memory({op:'read'})` for game state
399
- 5. `input({op:'set'})` / `input({op:'press'})` / `input({op:'sequence'})` to drive the game
414
+ 5. `input({op:'set'})` / `input({op:'press'})` / `input({op:'sequence'})` to drive the game — some platforms have extra control/input modes worth reading in their `MENTAL_MODEL.md` (e.g. C64 needs keyboard keys like F1/RUN-STOP to start many games; `input({op:'pressKey'/'typeText'/'joyport'})`)
400
415
  6. `state({op:'save'}, "checkpoint")` / `state({op:'load'}, "checkpoint")` for try/undo
401
416
 
402
417
  ## Diagnosing behavior over time (game-feel, not just "is it alive")
@@ -440,463 +455,79 @@ romdev errors are written FOR you — they name what went wrong AND how to recov
440
455
 
441
456
  ## ROM hacking workflow
442
457
 
443
- The full byte-patch loop is six MCP calls, no custom scripts:
444
-
445
- ```js
446
- cart({op:'identify', path }) // 1. what is it?
447
- disasm({target:'rom', path, startAddress, untilReturn:true })
448
- // 2. find the target
449
- // (auto-tagged reset/nmi/irq labels,
450
- // HW register names, file-offset
451
- // comments — for NES, BOTH .nes and
452
- // prg.bin offsets emitted —
453
- // mapper-aware addresses)
454
- assembleSnippet({ cpu, origin, code: "lda #$00\nrts" })
455
- // 3. encode replacement bytes
456
- memory({op:'write', region:"system_ram", offset:0xRAM, hex })
457
- // 4. VERIFY first write the value
458
- // on the live emulator, watch for
459
- // the expected behavior. Cheaper than
460
- // a wrong patch.
461
- romPatch({op:'write', path, offset, hex, expect: "<current bytes>" })
462
- // 5. patch with safety check
463
- // refuses if existing bytes differ
464
- romPatch({op:'diff', platform, a: original, b: patched }) // 6. verify the patch landed
465
- loadMedia({ platform, path: patched }) frame({op:'screenshot'}) // 7. run it
466
- ```
467
-
468
- **Driving input through a watched run.** A `watch`/`breakpoint` with NO
469
- `pressDuring` INHERITS whatever `input({op:'set'})` last held same as
470
- `frame({op:'step'})`. But if you pass `pressDuring`, that schedule OWNS the pad
471
- for the whole run and a prior `input({op:'set'})` is ignored. So to hold a button
472
- *through* a watched window, put it in `pressDuring` not a preceding `set`.
473
-
474
- **Finding which CODE wrote a byte.** Static disasm reading is the slow part —
475
- multiple `cmp #$XX` instructions look identical. Don't guess. Two tools, in order
476
- of precision:
477
-
478
- - **`breakpoint({on:'write', address, maxFrames, pressDuring})` — the precise one (NES).**
479
- Arms a core-level WRITE WATCHPOINT and returns the EXACT writing instruction's
480
- PC, captured inside the CPU write path correct even for NMI/IRQ-driven writes
481
- (the common NES case, where a frame-sampled PC is just the idle loop). This is
482
- the right tool when you need the actual writer.
483
- ```js
484
- breakpoint({ on:'write', address: 0x00CD, maxFrames: 300, pressDuring:[{ frame:30, button:"A" }] })
485
- { found:true, pc:"$AF85", value:"0x81", hits:19 }
486
- disasm({ target:'rom', path, startAddress: 0xAF85 }) // the real store instruction
487
- ```
488
- Supported on **all 14 tier-1 systems** — NES, GB/GBC, Genesis, SMS/GG, SNES,
489
- Atari 2600/7800, C64, Lynx (65C02), PC Engine (HuC6280), MSX (Z80), and GBA
490
- (ARM7) — every bundled CPU family. On a banked mapper a `$8000-$BFFF` pc may be
491
- in a switchable bank; `breakpoint({on:'write'})` reports the `bank` (NES/GB/SMS-GG) so you can
492
- pass it to `disasm({target:'rom'})`.
493
- - **`watch({on:'mem'})` / `breakpoint({on:'write',precision:'sampled'})` — cross-platform, frame-sampled.** Step until
494
- the byte changes; the returned `pc` is a frame-boundary sample (a lead, not a
495
- guarantee under interrupts — cross-check the value trace). Use on non-NES, or
496
- for the value timeline.
497
- - **`memory({op:'snapshot'})` + `memory({op:'diff'})` — "which bytes did THIS event touch?"** When
498
- you don't yet know the address: `memory({op:'snapshot'})` before the event, trigger it
499
- (`input({op:'press'})`/`frame({op:'step'})`), then `memory({op:'diff'})` — you get just the changed offsets
500
- with before/after, no eyeballing two RAM dumps. The fast way to find an area-id
501
- / phase / flag byte a transition writes. (`state({op:'diff'})` is the coarse
502
- whole-machine "did anything change?" version.)
503
-
504
- ```js
505
- breakpoint({ on:'write', precision:'sampled', region:"system_ram", offset:0x03B6, maxFrames:300,
506
- pressDuring:[{ frame:30, button:"A" }] })
507
- → { pc: "$E3AF" (frame-sampled), changes:[{ before:31, after:32 }] }
508
- ```
509
-
510
- **Execution breakpoints (all 14 platforms) — read the register at the instruction.**
511
- When the answer isn't a flat table but a value computed in a register, stop the
512
- CPU *at the instruction* and read it:
513
- - **`breakpoint({on:'pc', address, maxFrames, pressDuring})`** — runs until the CPU PC
514
- reaches `address`, then FREEZES the CPU exactly there. Then `cpu({op:'read'})` reads
515
- the full register file at that precise moment. The canonical RE move: break at a
516
- decoder's `move.b (a0),d0`, read `A0` → the source pointer, `memory({op:'readCart'})`/
517
- `memory({op:'read'})` at it. Turns "infer for hours" into ~3 calls.
518
- - **`breakpoint({on:'read', address, ...})`** — the read-side mirror of `breakpoint({on:'write'})`: the
519
- EXACT instruction PC that READ an address (who *consumes* a value).
520
- - **`frame({op:'stepInstruction'})`** — CPU-level single-step; pair with `cpu({op:'read'})` to watch
521
- registers change one instruction at a time.
522
- - These work on all 14 platforms (every bundled CPU family) — including `breakpoint({on:'write'})`
523
- (as of 0.6.0 PC Engine gained its write watchpoint, so no platform is the exception
524
- anymore).
525
-
526
- ```js
527
- breakpoint({ on:'write', address:0xFF2000 }) → { pc:"$49E", ... } // get a real instruction PC
528
- breakpoint({ on:'pc', address:0x49E }) → { hit:true, pc:"$49E" } // CPU frozen here
529
- cpu({ op:'read', platform:"genesis", cpu:"main" }) // → registers.A0 = the pointer
530
- ```
531
-
532
- All in the `assets` category except `disasm({target:'rom'})` (in `debug`); the breakpoint
533
- trio (`breakpoint({on:'pc'})`/`breakpoint({on:'read'})`/`frame({op:'stepInstruction'})`) is in `advanced`.
534
-
535
- ### Before you hunt — check the cheat database (`cheats({op:'lookup'})`)
536
-
537
- For a KNOWN commercial ROM, the fastest way to find the byte is to not hunt at
538
- all: the bundled cheat database is a free, crowd-sourced **map of labeled RAM
539
- addresses and code sites**. Call `cheats({op:'lookup', path})` FIRST — for a matched
540
- game it returns that game's cheats with the address decoded out of each one:
541
-
542
- ```js
543
- cheats({ op:'lookup', path: "Rygar (USA).nes" })
544
- // → { matched:true, confidence:"name", game:"Rygar (USA)", crc32:"...",
545
- // entries:[
546
- // { desc:"Infinite Magic Attack", code:"00CD:FF",
547
- // parts:[{ address:"$00CD", value:"0xFF", kind:"ram" }] }, // ← labeled RAM var
548
- // { desc:"Infinite Health", code:"SXUZXTSA",
549
- // parts:[{ address:"$8E20", value:"0xA5", compare:"0x85", kind:"code" }] }, // ← code site
550
- // ...] }
551
- ```
552
-
553
- So "which byte holds magic?" is answered in one call: `$00CD`. A RAM cheat
554
- (`kind:"ram"`) is a **labeled variable**; a ROM cheat (`kind:"code"`, has a
555
- `compare`) is a **labeled patch site** — point `disasm({target:'rom'})` at its address
556
- to read the routine. Filter a long list with `filter:"health"` or `kind:"ram"`.
557
-
558
- **Device types are labeled — it's not all "Game Genie."** Each decoded part
559
- carries a `device` so you know exactly what you're looking at:
560
- `game-genie` (NES/Genesis/SNES/GB ROM patches), `pro-action-replay` (SNES — the
561
- most common SNES device, RAM pokes like `7E0DBF63`), `gameshark` (GB RAM),
562
- `action-replay` (SMS/GG), or `raw` (`ADDR:VAL`). A few formats (e.g. the SMS/GG
563
- Game Genie variant) are labeled with their device but left address-undecoded
564
- rather than guessing — honest over wrong.
565
-
566
- **Trust it like you trust disasm — verify, don't assume.** A match is by
567
- No-Intro name / filename, NOT a verified CRC, so it's a PROBABLE match: very
568
- likely right, but a different region/revision can use different addresses. The
569
- `note` says so explicitly. Confirm a label before patching — the cheapest
570
- confirmation is to apply it and watch:
571
-
572
- ```js
573
- cheats({ op:'apply', path:"Rygar (USA).nes", desc:"Infinite Magic Attack" }) // enable it live
574
- frame({ op:'screenshot' }) // see the effect → label confirmed
575
- // or apply a RAW code from anywhere:
576
- cheats({ op:'apply', code:"00CD:FF" }) // RAM poke → appliedAs:"ram"
577
- cheats({ op:'apply', code:"SXIOPO" }) // Game Genie (core decodes it)
578
- cheats({ op:'apply', code:"C06C:0C:26" }) // raw ROM patch → auto-re-encoded to a read-intercept (appliedAs:"rom", reencodedFrom)
579
- cheats({ op:'clear' }) // remove all
580
- ```
581
-
582
- **`appliedAs` tells you how it went in** — `"ram"` (per-frame poke), `"rom"` (in-core
583
- read-intercept), `"raw"` (core-decoded device code), or `"rom-unencodable"` (a ROM
584
- address that couldn't be made into a working ROM patch — likely a no-op; add a COMPARE
585
- byte). A raw `ADDR:VAL:COMPARE` on a ROM address would otherwise silently no-op as a RAM
586
- poke, so `cheats({op:'apply'})` transparently re-encodes it to the platform's ROM-patch device (NES/
587
- Genesis/GB Game Genie, SNES Game Genie — NOT Pro Action Replay, which is RAM). **Boot-time
588
- cheats:** pass `loadMedia({ cheats:[…] })` to apply codes BEFORE frame 0 (iterating on a
589
- boot-seeded value), and use `host({op:'reset', hard:true})` for a true power-cycle — plain `host({op:'reset'})`
590
- is the RESET button and leaves work RAM (and boot-seeded state) intact.
591
-
592
- `cheats({op:'apply'})` is also just **fun** — play any matched game with infinite lives,
593
- invincibility, etc. It is **NON-DESTRUCTIVE**, exactly like RetroArch: the cheat
594
- lives in volatile core state (a per-frame RAM write, or an in-core read-intercept
595
- for ROM cheats), the ROM file on disk is NEVER touched, and `host({op:'reset'})` / `state({op:'load'})`
596
- / `cheats({op:'clear'})` removes it. **`cheats({op:'lookup'})` DB coverage (13/14):** NES, GB/GBC,
597
- SNES, Genesis, SMS/GG, Atari 2600/7800, **Lynx**, **GBA**, **PC Engine**, **MSX** —
598
- every tier-1 system except **C64** (the cheat database ships no C64 entries, so
599
- there's nothing to look up; `cheats({op:'make'})` still works on C64). The DB is its own
600
- package (`romdev_game_codes`), lazy-loaded per platform; `cheats({op:'search', platform,
601
- query})` fuzzy-finds a game by name. One caveat: **GBA** DB cheats are
602
- Code Breaker / GameShark (encrypted), so they're **apply-only** — the `code`
603
- applies live, but the address isn't descrambled into a labeled map the way the
604
- other systems are (the response says so via `mapNote`). **`cheats({op:'apply'})` /
605
- `cheats({op:'make'})` work on all 14.** Unmatched ROMs (homebrew, your own WIP, an
606
- unlisted dump) return `matched:false` with a clear reason — the tool never
607
- guesses.
608
-
609
- ### Creating NEW cheat codes (`cheats({op:'make'})`)
610
-
611
- The inverse of decoding: turn a byte you found into a shareable code — for ANY
612
- ROM, **including your own homebrew/WIP** where no DB entry exists. This closes
613
- the loop with the byte-hunting tools:
614
-
615
- ```js
616
- breakpoint({ on:'write', precision:'sampled', region:"system_ram", offset:0xCD }) // 1. find the byte (or use cheats({op:'lookup'}))
617
- cheats({ op:'make', platform:"nes", address:0x00CD, value:0xFF })
618
- // → { raw:"CD:FF", note:"RAM cheat...", ... } // 2. RAM poke → raw code
619
- // For a ROM/Game-Genie patch, read the current byte and pass it as `compare`:
620
- memory({ op:'read', region:"prg_rom", offset:0x8E20 }) // (current byte = 0x85)
621
- cheats({ op:'make', platform:"nes", address:0x8E20, value:0xA5, compare:0x85 })
622
- // → { gameGenie:"SZZAETSA", verified:true, raw:"8E20:A5:85", ... }
623
- cheats({ op:'apply', code:"SZZAETSA" }) → frame({ op:'screenshot' }) // 3. confirm it works
624
- ```
625
-
626
- `cheats({op:'make'})` encodes for the platform's NATIVE device(s) and **labels each one**
627
- — NES/Genesis → Game Genie; SNES → Pro Action Replay **and** Game Genie; GB/GBC
628
- → Game Genie (ROM) + GameShark (RAM); SMS/GG → Action Replay — plus the raw
629
- `ADDR:VAL` always. Each generated code carries `verified:true` (decoded back and
630
- confirmed; the encoders round-trip 100% against the full DB — NES/Genesis/GB/GBC
631
- Game Genie, SNES Game Genie + PAR, GB GameShark). Force a specific device with
632
- `device:`. A RAM cheat needs just `address`+`value`; a ROM patch adds `compare`
633
- (the byte currently there). Nothing is ever written to a ROM file.
634
- **`cheats({op:'make'})` works on all 14 tier-1 systems** — the systems with no native
635
- letter-code device (Atari 2600/7800, Lynx, GBA, C64, PC Engine, MSX) get a
636
- verified raw `ADDR:VAL` code that `cheats({op:'apply'})` passes straight to the core.
637
-
638
- ```js
639
- cheats({ op:'make', platform:"snes", address:0x7E0DBF, value:0x63 })
640
- // → { codes:[ {device:"pro-action-replay", code:"7E0DBF63", verified:true},
641
- // {device:"game-genie", code:"17D8-9EE8", verified:true} ],
642
- // raw:"7E0DBF:63", ... }
643
- ```
644
-
645
- ### Editing in-game TEXT (font maps)
646
-
647
- Games store text as their own tile-index encoding (Excitebike: A=$0A; Mario:
648
- ASCII-offset; FF: sparse). Three tools automate the round-trip instead of
649
- hand-deriving the table:
650
-
651
- - **`text({op:'learn'})`** — infer the char→tile-ID map. TWO modes:
652
- - ROM mode: `knownStrings:[{text, offset}]` when you found the text's bytes.
653
- - **LIVE mode: `fromScreen:[{text, row, col}]`** — the text is on screen RIGHT
654
- NOW; reads the tile IDs straight from the live BG map at a tile position. This
655
- breaks the chicken-and-egg (you'd otherwise need the ROM offset you're
656
- hunting). Works on every tilemap platform (NES/SNES/Genesis/GB/GBC/SMS/GG/C64);
657
- `background({view:'map'})` shows you where the text sits. (atari2600/7800, lynx,
658
- gba have no text-tile nametable → use ROM mode.)
659
- - **`text({op:'find', romPath, text, fontMap})`** — locate the string in the
660
- ROM. Returns `fileOffset` (.nes), `prgFileOffset` (prg.bin), and a bank-aware
661
- `cpuAddress` + `bank` (NES/GB/GBC in-bank address, Genesis flat; SNES is
662
- mapper-dependent → use the offsets) — feed `{startAddress, bank}` to
663
- `disasm({target:'rom'})`. Flags a likely length-prefix byte to avoid the classic
664
- overrun.
665
- - **`text({op:'encode'})`** — text + map → bytes, ready for `romPatch({op:'write'})`.
666
-
667
- ```js
668
- text({ op:'learn', fromScreen:[{ text:"START", row:13, col:11 }] }) // read tiles off the live screen
669
- text({ op:'find', romPath, text:"MOUNTAIN", fontMap }) // → offsets + bank + context
670
- text({ op:'encode', text:"NEW TEXT ", fontMap }) → romPatch({ op:'write', ... }) // rewrite it
671
- ```
672
-
673
- **Tools for hacking, by category:**
674
-
675
- - `romPatch({op:'write', path, offset, hex, expect, allowExpand})` — generic byte
676
- splicer with safety check. THE primitive — every other hack tool
677
- composes through it. `expect` refuses the write if existing bytes don't
678
- match, catching the silent corruption when a patch authored against
679
- region A is applied to region B.
680
- - `assembleSnippet({cpu, origin, code})` — assemble a tiny chunk of asm
681
- to raw bytes. No header, no linker config, no segments. Supports
682
- `6502 / 65c02 / 65816 / 68k / z80 / sm83 / gb / gbc / huc6280`.
683
- Z80 NOTE: sdas dialect requires `#` on immediates (`ld a,#5`, not
684
- `ld a,5`).
685
- - `romPatch({op:'diff', platform, a, b})` — mapper-aware ROM diff. Reports CPU
686
- addresses (NROM-128 mirrors correctly, SNES LoROM banks as `XX:XXXX`),
687
- per-region tallies (PRG vs CHR vs header), and `tile: N` annotations
688
- on CHR changes for direct sprite-hack identification.
689
- - `romPatch({op:'findFree', path, minLength, fillBytes})` — locate runs of $FF
690
- or $00 for asm overlays. Sorted longest-first.
691
- - `disasm({target:'references', path, platform, address})` — find every instruction
692
- that references a target address. Classifies refs as
693
- `call/jump/branch/read/write/use/ref`. Walks the vector table too.
694
- Limitation: only direct addressing modes; indirect/computed jumps
695
- not detected.
696
- - `romPatch({op:'spliceCHR', path, platform, pngBase64, tileIndex, expect, bank, paletteHint})` —
697
- composition: PNG → tile bytes → splice into CHR at tile slot N.
698
- Auto-locates iNES CHR base. `expect` checks the existing tile bytes.
699
- `bank: N` (NES) replaces magic file offsets; `paletteHint:["#RRGGBB",...]`
700
- gives explicit RGB→palette-index mapping (skips the default quantization
701
- that requires PNGs with exactly 4 distinct grayscale levels).
702
- - `cheats({op:'lookup', path, filter, kind})` — match a KNOWN ROM to the bundled
703
- cheat DB and return THIS game's labeled RAM addresses + code sites
704
- (decoded from each cheat). The free "which byte holds X?" map. Probable
705
- match (name/filename, not CRC) — verify before patching.
706
- - `cheats({op:'apply', code | desc+path, index, enabled})` /
707
- `cheats({op:'clear'})` — apply a cheat to the loaded game LIVE and
708
- non-destructively (the RetroArch way: volatile core state, ROM file
709
- never touched). Use a raw `code` or a matched `desc`. Doubles as the
710
- cheapest way to VERIFY a `cheats({op:'lookup'})` label (apply → screenshot), and
711
- as a fun-bonus (play with infinite lives, etc.).
712
- - `cheats({op:'make', platform, address, value, compare?, style})` — CREATE a new
713
- cheat code from an address+value (the inverse of decoding). Returns a
714
- Game Genie letter code + the raw ADDR:VAL, with a `verified` round-trip
715
- check. Works on any ROM incl. homebrew/WIP. Pair with `breakpoint({on:'write',precision:'sampled'})`/
716
- `cheats({op:'lookup'})` (find the byte) → `cheats({op:'make'})` (encode) → `cheats({op:'apply'})` (confirm).
717
- - `watch({on:'mem', region, offset, length, frames, pressDuring})` /
718
- `breakpoint({on:'write', precision:'sampled', region, offset, maxFrames, pressDuring})` — frame-level
719
- memory-write trace. Reports every change with PC, so you can map a
720
- RAM byte back to the writing code path. Cross-platform. The "find
721
- the byte" half of hacking, mechanized. (Reach for this when a ROM
722
- ISN'T in the cheat DB, or to find a byte no cheat covers.)
723
- - `background({view:'rendered'})` — at the current emulator state, walk the
724
- BG nametable + OAM and return the set of tile IDs actually being
725
- drawn. Sample at known game states (title / gameplay / menu) and diff
726
- the sets to map tile IDs to game assets without scanning sheets by eye.
727
- - `cart({op:'extract', path, outputDir})` — split ROM into standard parts
728
- (NES: header.bin/prg.bin/chr.bin; SNES: copier_header + rom + internal
729
- header; Genesis: vectors/header/body; GB: boot/header/body) plus a
730
- manifest.json with mapper, mirroring, etc.
731
- - `cart({op:'wrap', platform, ...})` — counterpart to `cart({op:'extract'})`.
732
- Emits `wrapperSource` (.s) + `linkerConfig` (cc65 ld65 cfg) ready
733
- for `build({output:'rom'})`. Per-platform templates.
734
- - `disasm({target:'rom'})` — see "Disassembler" section below for the full
735
- annotation set.
736
-
737
- For graphics swaps specifically:
738
- - `tiles({op:'png', source:'path', platform, path, bank, paletteFromEmulator, paletteIndex})`
739
- from a source game → PNG of its tiles. `bank: N` (NES 4 KB CHR bank
740
- index) replaces magic file-offset math. `paletteFromEmulator: true`
741
- + `paletteIndex` colors the export with the live game palette
742
- (instead of grayscale) — much easier to recognize art and edit in a
743
- pixel tool.
744
- - `importArt({from:'rom', sourceRom, sourcePlatform, sourceBank,
745
- sourceTileX/Y/W/H, targetPlatform, outputPng, intent, paletteIndex})`
746
- — one-call lift of a tile region from a source game's ROM into the
747
- target platform's tile format. Combines extract + crop + quantize +
748
- optional manifest. Under `intent:"homebrew"` reads the live source
749
- palette automatically (same `paletteFromEmulator` semantics as
750
- `tiles({op:'png',source:'path'})`); under `intent:"rom-hack"` preserves source
751
- bytes verbatim. Output PNG + manifest feed straight into
752
- `importArt({from:'texturepacker'})`.
753
- - `encodeArt({stage:'tiles', platform, pngBase64})` → target-platform tile bytes
754
- - `romPatch({op:'spliceCHR'})` to write them into the CHR region of your target ROM
755
- (handles the `encodeArt({stage:'tiles'})` + `romPatch({op:'write'})` composition in one call)
458
+ **The full RE/romhack workflow is the playbook read it FIRST:**
459
+ `platform({op:'doc', platform:'romhacking', name:'playbook'})`. It's the cross-platform
460
+ decision tree that wires the primitives below together (with the trap each one exists
461
+ to avoid), plus the per-asset round-trips (text, compressed assets, graphics) and a
462
+ Quick-reference table. Don't reconstruct the flow from this summary — it's only here so
463
+ you know the capability exists and where the detail lives.
464
+
465
+ Key primitives (all bundled, all 14 tier-1 systems unless noted; full detail in the
466
+ playbook):
467
+
468
+ - **Find a value's RAM address** — the Cheat-Engine loop `memory({op:'search'})` →
469
+ `memory({op:'searchNext', compare})`, NOT a full-RAM diff. (`memory({op:'snapshot'})`+`memory({op:'diff'})`
470
+ answers the different question "which bytes did THIS one event touch?".)
471
+ - **Free RAM/code map for a known game** — `cheats({op:'lookup', path})` decodes each cheat
472
+ into labeled addresses (`kind:ram`=variable, `kind:code`=patch site); `cheats({op:'apply'})`
473
+ confirms a label live + non-destructively; `cheats({op:'make'})` mints a verified shareable
474
+ code from a byte you found. Probable (name) match — verify before patching.
475
+ - **Find the instruction that wrote/read a byte** — `breakpoint({on:'write', address})` returns
476
+ the EXACT writer (core-level watchpoint, correct under NMI/IRQ; reports `bank`);
477
+ `precision:'sampled'` is the lighter frame-sampled lead. `breakpoint({on:'read'})` is the
478
+ read-side mirror. `found:false` the region is bulk-copied/DMA'd from a SOURCE struct.
479
+ - **Read a register AT an instruction** — `breakpoint({on:'pc', address})` freezes the CPU
480
+ `cpu({op:'read'})` for the live register file (e.g. a decoder's source pointer);
481
+ `frame({op:'stepInstruction'})` single-steps. The "infer for hours → read it in 3 calls" move.
482
+ - **Discover the unknown routine** — `watch({on:'range'|'pc'})` logs every PC touching a
483
+ region; `watch({on:'dma'})` (Genesis) traces a graphic back to its ROM source offset.
484
+ - **Confirm bytes / classify** — `memory({op:'readCart'})` reads the running program image
485
+ (un-banked: file offset = CPU address); `memory({op:'classify'})` tells a real table from
486
+ ASCII/code before you trust it.
487
+ - **Edit on-screen text** `text({op:'learn'})` infers the font map (incl. LIVE
488
+ `fromScreen` mode — no offset needed) and flags pre-rendered-graphic text (don't patch
489
+ the "string"); `text({op:'find'})`/`text({op:'encode'})` do the string round-trip.
490
+ - **Compressed assets** drive the ROM's OWN codec: `cpu({op:'decompress'})`/`cpu({op:'call'})`
491
+ to expand, then the re-inject trio `romPatch({op:'makeStored'})` (verbatim-expand block) →
492
+ `romPatch({op:'findFree'})` → `romPatch({op:'relocate'})`, with `romPatch({op:'findPointer'})` for the
493
+ loader pointer. Don't reimplement the compressor.
494
+ - **Author/verify the patch** `assembleSnippet({cpu, origin, code})` bytes;
495
+ `romPatch({op:'write'})` (always pass `expect`); `romPatch({op:'diff'})` mapper-aware verify;
496
+ `disasm({target:'references'})` (static "who touches this?"); `cart({op:'extract'|'wrap'})`.
497
+ - **Graphics swaps** `tiles({op:'png'})`/`importArt({from:'rom'})` edit
498
+ `romPatch({op:'spliceCHR'})`; `background({view:'rendered'})` for the tile IDs drawn now.
499
+
500
+ Category placement: most live in `assets`; `disasm({target:'rom'})` is in `debug`; the
501
+ breakpoint trio (`pc`/`read`/`stepInstruction`) is in `advanced`.
756
502
 
757
503
  ## Disassembler
758
504
 
759
- `disasm({target:'rom'})` ships with every annotation enabled by default:
505
+ `disasm({target:'rom'|'references'|'project'})` covers **all 14 platforms** every CPU
506
+ family disassembles through a native binutils disassembler compiled to WASM (no
507
+ hand-rolled JS decoders): 6502/65816 via cc65's `da65`, Z80 (SMS/GG/MSX) + SM83
508
+ (GB/GBC) via one z80-elf `objdump` (`-m z80` / `-m gbz80`), m68k (Genesis) via
509
+ `m68k-elf-objdump`, ARM/Thumb (GBA) via `arm-none-eabi-objdump`. The annotations
510
+ (vector labels / hardware-register names / file-offset comments / `untilReturn`) are
511
+ post-processing layered on top.
760
512
 
761
513
  ```js
762
514
  disasm({target:'rom', path, platform:"nes", startAddress:0xC184,
763
515
  length:64, untilReturn:true})
764
- //
765
- // reset: sei ; C184 78 x @0x194 (prg @0x184)
766
- // cld ; C185 D8 . @0x195 (prg @0x185)
767
- // lda #$00 ; C186 A9 00 .. @0x196 (prg @0x186)
768
- // sta $2000 ; C188 8D 00 20 .. @0x198 (prg @0x188) PPUCTRL
769
- // ldx #$FF ; C18B A2 FF .. @0x19B (prg @0x18B)
770
- // ...
516
+ // reset: sei ; C184 78 @0x194 (prg @0x184)
517
+ // lda #$00 ; C186 A9 00 @0x196 (prg @0x186)
518
+ // sta $2000 ; C188 8D 00 20 @0x198 (prg @0x188) PPUCTRL
771
519
  ```
772
520
 
773
- What you get:
774
- - **Vector labels** (`reset:`, `nmi:`, `irq:`) auto-tagged from the iNES /
775
- SNES / Genesis vector tables. For SMS/GG, fixed Z80 vectors are tagged:
776
- `reset:` at $0000, `rst08`/`rst10`/`rst18`/`rst20`/`rst28`/`rst30:` at
777
- their RST addresses, `irq:` at $0038 (SMS vblank handler), `nmi:` at
778
- $0066 (pause button). For GB/GBC, the SM83 vectors get the same
779
- treatment plus the dedicated IRQ vectors: `vblank:` at $0040,
780
- `lcd_stat:` at $0048, `timer:` at $0050, `serial:` at $0058,
781
- `joypad:` at $0060, and `entry:` at $0100. `autoLabelVectors:false`
782
- to turn off.
783
- - **Hardware register names** (`; PPUCTRL`, `; PPUMASK`, `; SND_CHN`,
784
- `; VRAM`, `; LCDC`, `; VDP_CTRL`, `; IO_PORT_A` etc) on any operand
785
- that hits a known platform register. NES + SNES + Genesis + GB + SMS/GG
786
- tables built in. `annotateRegisters:false`.
787
- - **File-offset comments** (`; @0xNNNN`) on every disassembled line —
788
- mapper-aware, so $C184 on NROM-128 correctly reports `@0x194`. Direct
789
- input to `romPatch({op:'write'})`'s `offset`. For NES iNES files, the header-stripped
790
- PRG offset is ALSO reported (`@0x194 (prg @0x184)`) so you can patch
791
- either the `.nes` file or `prg.bin` from `cart({op:'extract'})` without doing
792
- the -16 math. `annotateFileOffsets:false` to turn off.
793
- - **Mapper-aware addressing**: NROM-128 mirror at $C000, MMC1/MMC3/UxROM
794
- top bank fixed at $C000, SMS sega-mapper slot-0/1/2 1:1 file mapping,
795
- GB/GBC slot 0 fixed + slot 1 banked (pass `bank` to target a non-
796
- default ROM bank). No more manual `startAddress: 49152` because the
797
- disassembler understood the mapping.
798
- - **`endAddress` alternative to `length`** — disassemble "from X to Y"
799
- without computing byte count yourself.
800
- - **`untilReturn: true`** — truncates at the first `rts/rti/rtl/bare jmp`
801
- (6502) or `ret/reti/retn/bare jp` (Z80) or `ret/reti/bare jp/jp hl`
802
- (SM83). Combine with an auto-tagged `reset:` label to grab exactly
803
- one routine.
804
- - **`dataRanges: [{start, length}]`** — mark address ranges as `.byte`
805
- tables instead of bizarre disassembled "code." Useful for embedded
806
- sprite tables, music data, lookup tables.
807
- - **`outputPath`** — writes raw asm to disk instead of returning a
808
- 188KB JSON wad. Returns `{outputPath, asmBytes, asmLines}` for log/inspection.
809
-
810
- Every CPU family disassembles through a native binutils disassembler compiled to
811
- WASM: 6502/65816 via cc65's `da65`; Z80 (SMS/GG/MSX) + SM83 (GB/GBC) via one
812
- z80-elf `objdump` (`-m z80` / `-m gbz80`); m68k (Genesis) via `m68k-elf-objdump`;
813
- ARM/Thumb (GBA) via `arm-none-eabi-objdump`. No hand-rolled JS decoders. The
814
- auto-label / register-annotation / file-offset / untilReturn handling is
815
- post-processing layered on the objdump output.
816
-
817
- ### Whole-ROM, rebuildable projects — `disasm({target:'project'})`
818
-
819
- `disasm({target:'rom'})` gives you one routine as text. `disasm({target:'project'})` turns an
820
- **entire ROM into a complete, re-buildable project in one call**, across **all 14
821
- systems** (NES, SNES, GB/GBC, SMS/GG, Genesis, **GBA**, C64, Atari 2600/7800,
822
- **Lynx** — 65C02, **PC Engine** — HuC6280, and **MSX** — Z80; byte-exact on 13,
823
- PC Engine the one current exception — see caveats).
824
- Each region disassembles through the CPU's native objdump and reassembles through
825
- the matching native `as`/`ld`/`objcopy`, so the round-trip is guaranteed byte-for-byte:
826
-
827
- ```js
828
- disasm({ target:'project', path: "game.nes", outputDir: "./game-disasm" })
829
- // → { ok, platform, regions:[{file, startAddress, roundTripOk, readablePercent}],
830
- // roundTrip:{ allByteExact, failed:[] }, readablePercentAvg,
831
- // rebuild:{ blobs:[{file,bytes}], buildCall:{...}|null, verifiable, buildDoc:"BUILD.md", notes } }
832
- // Writes the .asm regions + chr.bin/header blobs + BUILD.md + rebuild.json to outputDir.
833
- ```
521
+ `disasm({target:'project'})` is the **RE-rebuild workhorse**: one call turns a whole ROM
522
+ into a byte-exact, re-buildable disassembly (per-region `.asm` + rebuild glue), faithful
523
+ where it can be and falling back to `.byte` where it can't — so it *always* reassembles to
524
+ the original image. That's the path for any structural hack (new logic / text / graphics).
834
525
 
835
- It splits the ROM into regions (per-16KB bank for banked NES, per-32KB bank for
836
- SNES LoROM, slot0+slotX for GB, one flat region for SMS/Genesis/C64/Atari),
837
- disassembles each, then **reassembles it and verifies the result is byte-exact
838
- against the original**. Any line that doesn't reassemble faithfully falls back
839
- to `.byte`/`db` data recovered from the address comments — so the emitted `.asm`
840
- files ALWAYS rebuild to the original bytes (`roundTrip.allByteExact`). The
841
- `readablePercent` per region tells you how much came back as real instructions
842
- vs. data. Each `.asm` carries a provenance + round-trip header and is ready to
843
- edit and rebuild with the platform's native toolchain.
844
-
845
- **It also writes the REBUILD GLUE** so the project is turnkey, not just byte-exact
846
- region files. Alongside the `.asm` files you get: any non-code DATA blobs the
847
- rebuild needs (NES CHR-ROM → `chr.bin`; the stripped Genesis/GBA/Lynx/MSX
848
- cartridge header → `*.bin`), a **`BUILD.md`** with the exact rebuild steps, and —
849
- where a one-call rebuild exists — a **`rebuild.json`** holding the precise
850
- `build({...})` args (absolute paths). The response carries the same under
851
- `rebuild: { blobs, buildCall, verifiable, buildDoc, notes }`. So the RE loop is:
852
- `disasm({target:'project'})` → edit a `.asm` → rebuild → `diffRoms` to confirm.
853
-
854
- Two rebuild tiers (honest — the disasm emits each CPU's native-reassembler
855
- syntax, which only some platforms' `build()` toolchains can consume):
856
- - **One-call `build()` rebuild, byte-identical** — **NES, C64, Atari 7800, Lynx**.
857
- Feed `rebuild.json` straight to `build`. (NES uses the new `inesHeader` option
858
- — see below. Lynx: build() yields the headerless image; prepend the shipped
859
- `lnx_header.bin` for the full `.lnx`.)
860
- - **Native-recipe rebuild (`buildCall:null`), byte-identical, steps in `BUILD.md`**
861
- — **SMS, GG, MSX, GB, GBC, Genesis, GBA, Atari 2600**. Their `build()`
862
- toolchains (SDCC/RGBDS/asar/dasm/vasm) can't reassemble the disasm's ca65/GNU-as
863
- syntax, so `BUILD.md` gives the proven native `as`/`ld`/`objcopy` chain.
864
- - **PC Engine** is the one not-yet-byte-exact case (the region trims real padding /
865
- doesn't strip a copier header) — `BUILD.md` says so.
866
-
867
- **Rebuilding a commercial NES (NROM CHR-ROM) game — `build({inesHeader})`:** the
868
- most common NES RE rebuild. `build({output:'rom', platform:'nes', inesHeader:{
869
- prgBanks, chrBanks, mapper, mirroring}, sourcesPaths:{...the PRG...},
870
- binaryIncludePaths:{ "chr.bin":... }})` auto-emits the 16-byte iNES header + the
871
- CHARS segment wiring + the flat NROM `.cfg` — no hand-derived header bytes, no
872
- glue `.s`/`.cfg`. (`disasm({target:'project'})` puts exactly this call in
873
- `rebuild.json`.) For homebrew C that ships fixed tile art, `linkerConfig:"chr-rom"`
874
- is the segment-split equivalent. See the NES MENTAL_MODEL.md "Rebuilding a CHR-ROM
875
- NROM image" section.
876
-
877
- Reassembler per CPU family (all bundled WASM, no installs): **cc65** ca65/ld65
878
- for 6502 + 65816; native binutils **`as`/`ld`/`objcopy`** for the GNU CPUs —
879
- `m68k-elf` (Genesis), `arm-none-eabi` (GBA), and one `z80-elf` for both Z80
880
- (SMS/GG/MSX) and gbz80 (GB/GBC). objdump and `as` share GNU syntax, so objdump's
881
- output feeds straight back into `as` with no translation; any line the assembler
882
- won't reproduce exactly is healed to a `.byte` of its real bytes.
883
-
884
- Caveats worth knowing up front:
885
- - **SNES and large Genesis ROMs come back byte-exact but DATA-ONLY**
886
- (low `readablePercent`). Flat whole-ROM disassembly of a mostly-data image
887
- heals down to `.byte`; meaningful instruction coverage there needs recursive
888
- entry-point following, a known follow-up. The bytes are always correct.
889
- - **GBA** rebuilds byte-exact but reads LOW: GBA C compiles mostly to Thumb,
890
- reached via an ARM crt0 stub, so an ARM-mode disasm decodes the Thumb spans as
891
- `.byte`. ARM/Thumb mode-tracking is the readability follow-up; the bytes are
892
- always correct. (The 192-byte GBA header is emitted as a clean data region.)
893
- - Banked-NES is the strongest case — per-bank regions come back ~100%
894
- instructions. GB/GBC, SMS/GG, C64, and Atari are also near-100%.
895
- - **PC Engine** is the one platform that does NOT round-trip byte-exact yet: the
896
- region trims real trailing $FF padding and doesn't strip a 512-byte copier
897
- header, so the emitted region is a lossy view of the `.pce`. `BUILD.md` flags
898
- this; a `planRegions` fix is the follow-up.
899
- - Platform is sniffed from the file extension; pass `platform:` to override.
526
+ The tool's own params document the flags (`untilReturn` / `dataRanges` / `endAddress` /
527
+ `bank` / `thumb` / `outputPath`; auto vector labels + register-name + file-offset
528
+ annotations, all on by default; NES file offsets report both `.nes` and PRG). The
529
+ ROM-hacking playbook (`platform({op:'doc', platform:'romhacking', name:'playbook'})`) has
530
+ the end-to-end workflow read those rather than re-deriving the detail here.
900
531
 
901
532
  ## CHR/tile tools — file vs emulator source
902
533
 
@@ -1134,34 +765,50 @@ If a platform genuinely lacks a tilemap (Atari 2600 races the beam; 7800 uses di
1134
765
 
1135
766
  ## Known toolchain landmines
1136
767
 
1137
- A few platform-tool quirks worth knowing up front:
1138
-
1139
- - **asar (SNES) silent fails** on certain idioms: `$ - label` size expressions crash with a heap-pointer exit code (use `end_label - start_label` instead). Some opcode + operand arithmetic like `STA SYMBOL + N` where SYMBOL is `=`-defined also crashes silently — our preflight catches the common cases. When `ok: false, issues: []`, the wrapper now synthesizes a fallback issue with a hint.
1140
- - **asar bank-border-crossed** can happen if your `org` + `dw` runs past $00FFFF. Native vectors are at $FFE4-$FFEE; emulation vectors at $FFF4-$FFFF. Use `scaffold({op:'snippets', platform: "snes", mode: "get", name: "lorom_header.asm"})` for the layout.
1141
- - **cc65 (NES, C64, etc.) zero page** starts at $02. cc65 reserves $00-$01 for its runtime. Your first `.res 1` lands at $02, not $00. Use `symbols({op:'map'})` after `build({output:'romWithDebug'})` to confirm.
1142
- - **NES pattern table cap = 256 tiles per nametable**. The tilemap index is 8-bit, so per-frame BG can use at most 256 unique tiles per pattern table. Auto-converting a busy illustration usually overflows. `encodeArt({stage:'tilemap'})` warns; the only workaround is mid-frame CHR bank switching (MMC3-class mapper).
1143
- - **NES + GB/GBC turnkey** (R9/R10 self-contained + sound, 2026-05-25): use `scaffold({op:'project', platform, template, name, path})` to scaffold a project. The pipeline copies every file the template depends on — `{nes,gb}_runtime.{h,c}`, `gb_hardware.h`, custom `crt0.s`, linker `.cfg`, `patch-header.js` (GB) — into the project directory alongside `main.c`. **No auto-injection at build time.** Iterate the whole dir in one call with `build({output:'run', path, platform})` (or supply `sources` / `sourcesPaths` / `includes` / `includePaths` / `crt0` / `crt0Path` / `linkerConfig` / `codeLoc` explicitly when the files aren't on disk). Take the project elsewhere with stock cc65/sdcc and it builds the same way. The runtime APIs include sprites, BG, input, AND **sound** — `sound_init` / `sound_play_tone(channel, period, vol, length)` / `sound_play_noise` / `sound_off`. NES drives pulse1+pulse2+triangle+noise via $4000-$400F + $4015; GB drives the 4-channel APU via NR10-NR52. SFX-grade, fire-and-forget — for full music tracks, drop in famitone2 (NES) or your own driver. Templates: `default` (palette cycle), `hello_sprite` (sprite + d-pad + **beep on A press**), `tile_engine` (multi-room tile map). Docs: [`src/platforms/nes/MENTAL_MODEL.md`](src/platforms/nes/MENTAL_MODEL.md) + [`TROUBLESHOOTING.md`](src/platforms/nes/TROUBLESHOOTING.md); [`src/platforms/gb/MENTAL_MODEL.md`](src/platforms/gb/MENTAL_MODEL.md) + [`TROUBLESHOOTING.md`](src/platforms/gb/TROUBLESHOOTING.md). **NES sprite staging:** the bundled `nes_runtime` now handles the OAM-DMA race for you (oam_clear just resets the index; ppu_wait_nmi hides unused slots after you stage), so the old "must stage before ppu_wait_nmi" flicker trap is gone — the standard `oam_clear → oam_spr → ppu_wait_nmi` loop renders cleanly. **GB ROM header:** both asm and C builds auto-run `rgbfix` inside `build({output:'rom'/'run'})`, so the Nintendo logo + checksums + CGB flag are correct out of the box — no manual header-patch step needed (use `romPatch({op:'gbHeader'})` only to fix up an external ROM).
1144
- - **Game Boy / GBC silent-failure footguns** (R54 cleanup, full detail in `platform({op:'doc', platform:"gb"|"gbc", name:"mental_model"})`):
1145
- - **The bundled `gb_crt0.s` is now actually linked.** Pre-r54 a fundamental bug in `buildZ80C` was shipping the raw .s text to sdld as if pre-assembled sdld silently rejected it and fell back to SDCC's stock sm83 crt0 (no GB cart boot, no IRQ vectors). Map showed no `init` symbol, $0000 was $FF, $0100 was $FF. Every GB ROM ran on stock crt0 invisibly. Fixed by auto-detecting .s source vs .rel object and running it through sdasgb first. Post-fix: `init` at $0150, entry $0100 = `00 c3 50 01` (nop; jp $0150), reset vector $0000 = $C9. **This was the root cause for #14 audio AND part of why every previous "runtime should work OOTB" round still felt friction-heavy.**
1146
- - **GB/GBC C builds now auto-fix the header at build time** (rgbfix runs inside `build({output:'rom'})`): Nintendo logo at $0104, header checksum at $014D, global checksum, and the CGB flag at $0143 ($00 for `.gb`, $C0 for `.gbc`). You no longer need to patch the header manually — the ROM `build({output:'rom'})` hands back boots on real hardware as-is. `romPatch({op:'gbHeader', path})` still exists if you want to override title / cart type / RAM size / etc. on an existing file.
1147
- - **`shadow_oam` is pinned at $C100** in the bundled `gb_runtime.c` via `__at(0xC100)`. OAM DMA reads ONLY the high byte and copies 160 bytes from `$XX00` — a plain `uint8_t my_oam[160]` may land at $C017 and DMA garbage. If you roll your own OAM buffer, pick an address with `0x00` low byte (e.g. $C200) and pass it directly to `oam_dma_copy`.
1148
- - **Call `enable_vblank_irq()` once at boot.** Without it, `wait_vblank()` busy-polls `LY` which updates only at WASM `frame({op:'step'})` quantum boundaries → game loop runs at ~1/30 intended speed on the emulator. After enable, `wait_vblank()` compiles to `HALT` + vblank IRQ wake (~10 cycles per frame).
1149
- - **Use `memcpy_vram(dst, src, n)` for VRAM bulk writes**, NOT raw `(uint8_t*)0x8000` casts — SDCC sm83 may elide the latter as dead code. The bundled `gb_hardware.h` declares every $FFxx register as `volatile`-typed so direct writes like `BGP = 0xE4;` are fine; the hazard is only on cast-through-pointer block copies.
1150
- - **`background({view:'map', platform:"gb"})` now renders a 256×256 PNG of the BG plane.** Pass `which: 1` for $9C00 map base, `window: true` to render the Window map instead. Returns `mapBase` + `mode` + `scy/scx` so you can see where the visible 160×144 region falls.
1151
- - **`memory({op:'read', region:"video_ram"})` doesn't work on GB** — gambatte exposes VRAM as `gb_vram` (not the generic libretro id). r54 errors now suggest this directly. Also: `gb_oam`, `gb_io`, `gb_hram`, `gb_bgpdata`, `gb_objpdata`, `gb_cpu_regs`. `tiles({op:'png'})` / `background({view:'map'})` / `sprites({op:'inspect'})` abstract over this.
1152
- - **SMS / Game Gear VDP footguns** (R53 cleanup, full detail in `platform({op:'doc', platform:"gg"|"sms", name:"mental_model"})`):
1153
- - **8 sprites per scanline** is a hard VDP limit. Extra sprites on the same Y row silently drop — symptom: "first 8 letters of CATCH THE COIN render, rest vanish." Split text across multiple Y rows OR draw it via the BG name table (no per-line limit).
1154
- - **GG OAM coords are hardware-space, NOT visible-space.** The libretro screenshot returns the 160×144 visible region but OAM bytes are still 256×192 hw-coord. Visible region = OAM x∈[48,207], y∈[24,167]. `sprites({op:'inspect'})` reports hardware coords too.
1155
- - **SAT $D0 is the renderer terminator.** R53 fixed `sms_sprite_init` / `gg_sprite_init` so they no longer fill Y with $D0 (they use $E0 now — off-screen but not the terminator). You only hit the trap if you write $D0 yourself; if sprites past a given slot are missing in `sprites({op:'inspect'})`, that's still the diagnosis.
1156
- - **R6 = 0xFB → sprite tiles at $0000**, not $2000 (older comments lied — fixed). Bit 2 SET = $2000, CLEAR = $0000. Trust `sprites({op:'inspect'})`' `spriteTileDataBase` field over comments.
1157
- - **SNES CHR/tilemap can overlap in VRAM** if you put them carelessly. CHR starts at word $0000; if your CHR is 16KB the tilemap can't be at word $2000. Put tilemap at word $4000 or later when your CHR is big.
1158
- - **SNES audio is a separate ROM build** — the Sony SPC700 coprocessor handles all sound; the main 65816 can only upload a driver + samples then send commands. Workflow: write your SPC driver in `arch spc700` .asm, `build({output:'rom', platform:"spc700", source})` to flat raw bytes, then `.incbin` the result into your main 65816 .asm + write the $BBAA handshake at $2140-$2143 to upload it. `encodeAudio({target:'brr', pcmPath, outputPath})` encodes 16-bit PCM into the SNES BRR format the SPC needs. See `src/platforms/snes/lib/audio_pipeline.asm` for the protocol overview, and the SPC driver bundled into any SNES game project scaffolded with a sound genre.
1159
- - **All SDCC-built platforms (GB, GBC, SMS, GG, MSX, ColecoVision)** share a few SDCC-sm83 / -z80 quirks. The detailed reference is [`src/platforms/gb/lib/c/SDCC_GOTCHAS.md`](src/platforms/gb/lib/c/SDCC_GOTCHAS.md).
1160
- **2026-05-25: The "for-loop + function-call crash family" (`dbuf_append_str NULL` assertion) is FIXED.** It was emscripten's default 64 KB stack overflowing the static `sm83_regs[]` table at runtime — not a SDCC codegen bug. Fixed by adding `-s STACK_SIZE=8388608` to `scripts/_lib.sh`. Patterns #1..#10 / #37 / #38 / #39 from previous agent notes all compile cleanly now. You don't need `unroll.h`, you don't need to split files into ≤200-line TUs, you don't need array-of-structs refactors. Write the natural code.
1161
- **C89-only.** SDCC sm83 is C89. No inline `for (int i = 0; ...)`, no mid-block declarations, no compound literals. SDCC's syntax-error line is usually wrong (points at the FIRST decl after non-decl code); use the linter's line numbers instead.
1162
- **Pre-flight linter:** `build({output:'rom'})` runs a syntax scan before invoking SDCC. C89 violations show up in `issues[]` with `stage: "lint"` and a `ref:` pointing at the right GOTCHAS section. Pass `lint:"strict"` to fail the build on any lint hit; default is advisory. **The linter reports EVERY mid-block decl in a block**, ordinal-tagged (`#2`, `#3` etc.) so a subtle earlier decl doesn't silence the obvious later one (R53 fix). If a flagged line doesn't look like a decl to you, double-check: typedef'd names ending in `_t`, plus `struct`/`union`/`enum` declarations, all count.
1163
- **Multi-TU still helps iteration speed** (`sourcesPaths: {"main.c":..., "render.c":...}`): smaller TUs rebuild faster, easier to navigate. When a multi-TU build fails, the response includes `failedTU` + `compiledOK` so you know exactly which file to bisect.
1164
- SMS/GG: `scaffold({op:'project', platform:"sms"|"gg"})` ships `sms_crt0.s` / `gg_crt0.s` into the project automatically — these crt0s give a proper cartridge reset vector + IM 1 + stack setup before calling `main()`. SDCC's stock z80 crt0 traps `rst $08` and any VDP-touching code hangs at PC=$0007, so the bundled crt0 is mandatory for real-hardware boot. GB/GBC: see the NES + GB/GBC self-contained-project bullet above.
768
+ Two cross-cutting notes apply broadly; the rest is platform-specific and lives in
769
+ each platform's docs (read them for the system you're on — see below).
770
+
771
+ - **C compilers run a pre-flight lint before the real compile.** When a build
772
+ fails (or even when it succeeds with warnings), read the structured `issues[]`
773
+ entries with `stage:"lint"` name the exact file:line and carry a `ref:` into
774
+ the relevant GOTCHAS section. Pass `lint:"strict"` to FAIL the build on any
775
+ lint hit (default is advisory). Don't trust the raw compiler `log` line numbers
776
+ they're often off-by-one; the lint line is the right one.
777
+ - **All SDCC platforms (GB, GBC, SMS, GG, MSX, ColecoVision) are C89.** No inline
778
+ `for (int i = )`, no mid-block declarations, no compound literals/designated
779
+ initializers hoist every declaration to the top of its block. The lint catches
780
+ these; the canonical reference (plus the WRAM-layout traps that masquerade as
781
+ "miscompiles") is [`src/platforms/gb/lib/c/SDCC_GOTCHAS.md`](src/platforms/gb/lib/c/SDCC_GOTCHAS.md).
782
+
783
+ **Platform-specific toolchain traps live in each platform's
784
+ `MENTAL_MODEL.md` / `TROUBLESHOOTING.md` (read via
785
+ `platform({op:'doc', platform, name:'mental_model'|'troubleshooting'})`) read
786
+ them for YOUR platform before you build.** By symptom:
787
+
788
+ - **SNES asm `ok:false` with empty/cryptic `issues[]`** asar silent-fail idioms
789
+ (`$ - label` size expr, `STA SYMBOL+N` on a `=`-constant, bank-border crossed) +
790
+ **CHR/tilemap VRAM overlap** (garbage BG tiles) snes `TROUBLESHOOTING`.
791
+ - **SNES has no sound** audio is a SEPARATE SPC700 build (`platform:"spc700"`
792
+ `.incbin` $2140-$2143 handshake; `encodeAudio({target:'brr'})` for samples)
793
+ snes `MENTAL_MODEL` "Sound" + `TROUBLESHOOTING`.
794
+ - **Hand-asm clobbers the C runtime / a ZP var isn't where you put it** cc65
795
+ zero-page starts at **$02** ($00-$01 reserved); first `.res 1` = $02. All cc65
796
+ platforms (NES, C64, Atari, Lynx) → nes/c64 `MENTAL_MODEL`.
797
+ - **Busy BG art renders garbage / `encodeArt({stage:'tilemap'})` warns** → NES
798
+ 256-unique-tiles-per-pattern-table cap → nes `MENTAL_MODEL`.
799
+ - **GB/GBC: white screen, sprites garbage, VRAM stays empty, or "works until a
800
+ button is held then corrupts"** → header/CGB-flag auto-fix, `shadow_oam`
801
+ page-alignment, `memcpy_vram` (raw VRAM stores get elided), OAM-DMA-from-HRAM,
802
+ crt0 BSS-zeroing, and `gb_vram` (NOT `video_ram`) → gb `MENTAL_MODEL` footguns +
803
+ `SDCC_GOTCHAS`.
804
+ - **SMS/GG: sprites past slot N vanish / text cut off / sprites invisible** →
805
+ 8-sprites-per-scanline limit, SAT `$D0` terminator, R6 sprite-tile-base
806
+ ($2000 vs $0000), GG OAM hardware-vs-visible coords → sms/gg `MENTAL_MODEL`.
807
+
808
+ Turnkey NES/GB/GBC projects (`scaffold({op:'project'})`) copy every runtime file
809
+ the template needs (`*_runtime.{h,c}`, `gb_hardware.h`, crt0, linker cfg) into the
810
+ project dir and auto-fix the cart header at build — iterate the whole dir with
811
+ `build({output:'run', path, platform})`. Details in those platforms' MENTAL_MODELs.
1165
812
 
1166
813
  ## Session continuity — REUSE YOUR SESSION
1167
814