romdevtools 0.22.1 → 0.24.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 (54) hide show
  1. package/AGENTS.md +169 -494
  2. package/CHANGELOG.md +103 -0
  3. package/examples/genesis/templates/platformer.c +5 -1
  4. package/examples/genesis/templates/two_plane_parallax.c +166 -0
  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 +225 -2
  9. package/src/host/framebuffer.js +37 -0
  10. package/src/http/skill-doc.js +1 -1
  11. package/src/mcp/tools/audio.js +2 -2
  12. package/src/mcp/tools/frame.js +13 -34
  13. package/src/mcp/tools/index.js +2 -2
  14. package/src/mcp/tools/input-layout.js +10 -0
  15. package/src/mcp/tools/input.js +26 -2
  16. package/src/mcp/tools/metasprite-tools.js +1 -1
  17. package/src/mcp/tools/platform-tools.js +18 -11
  18. package/src/mcp/tools/playtest.js +17 -2
  19. package/src/mcp/tools/project.js +9 -1
  20. package/src/mcp/tools/rendering-context.js +1 -1
  21. package/src/mcp/tools/symbols.js +130 -39
  22. package/src/mcp/tools/tile-inspect.js +1 -1
  23. package/src/mcp/tools/toolchain.js +3 -2
  24. package/src/mcp/tools/watch-memory.js +58 -6
  25. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +155 -6
  26. package/src/platforms/atari2600/MENTAL_MODEL.md +37 -0
  27. package/src/platforms/atari7800/MENTAL_MODEL.md +36 -0
  28. package/src/platforms/c64/MENTAL_MODEL.md +83 -6
  29. package/src/platforms/gb/MENTAL_MODEL.md +74 -0
  30. package/src/platforms/gb/lib/c/SDCC_GOTCHAS.md +91 -0
  31. package/src/platforms/gba/MENTAL_MODEL.md +57 -3
  32. package/src/platforms/gba/lib/arm-archives/libc.a +0 -0
  33. package/src/platforms/gba/lib/arm-archives/libgcc.a +0 -0
  34. package/src/platforms/gba/lib/arm-archives/libnosys.a +0 -0
  35. package/src/platforms/gbc/MENTAL_MODEL.md +34 -0
  36. package/src/platforms/gbc/lib/c/SDCC_GOTCHAS.md +91 -0
  37. package/src/platforms/genesis/MENTAL_MODEL.md +180 -0
  38. package/src/platforms/genesis/TROUBLESHOOTING.md +32 -0
  39. package/src/platforms/genesis/lib/c/libc.a +0 -0
  40. package/src/platforms/genesis/lib/c/libgcc.a +0 -0
  41. package/src/platforms/genesis/lib/c/libm.a +0 -0
  42. package/src/platforms/gg/MENTAL_MODEL.md +24 -0
  43. package/src/platforms/gg/lib/c/gg_crt0.s +30 -0
  44. package/src/platforms/lynx/MENTAL_MODEL.md +33 -7
  45. package/src/platforms/msx/MENTAL_MODEL.md +27 -0
  46. package/src/platforms/nes/MENTAL_MODEL.md +35 -0
  47. package/src/platforms/sms/MENTAL_MODEL.md +51 -0
  48. package/src/platforms/sms/lib/c/sms_crt0.s +40 -0
  49. package/src/platforms/snes/MENTAL_MODEL.md +21 -0
  50. package/src/platforms/snes/TROUBLESHOOTING.md +43 -0
  51. package/src/playtest/playtest.js +48 -0
  52. package/src/toolchains/sdcc/preflight-lint.js +164 -8
  53. package/examples/msx/catch_game/_verify.mjs +0 -93
  54. package/examples/pce/catch_game/_verify.mjs +0 -75
@@ -22,6 +22,32 @@ read that platform's `platform({op:'doc', platform, name:'mental_model'})`.
22
22
  The cheat DB is bundled (`romdev_game_codes`). Do **not** scan the user's disk for
23
23
  `.cht` files — if it's not in the bundled DB, treat it as absent and RE it.
24
24
 
25
+ **Reading a `cheats({op:'lookup'})` hit as a RAM/code map.** Each decoded part carries an
26
+ `address`, a `value`, a `kind` (`ram` = a labeled variable; `code` = a labeled ROM
27
+ patch site, has a `compare`), and a `device` (`game-genie`/`pro-action-replay`/
28
+ `gameshark`/`action-replay`/`raw`). So "which byte holds magic?" → one `lookup` call.
29
+ Filter a long list with `filter:"health"` or `kind:"ram"`. A match is by No-Intro
30
+ **name/filename, NOT a verified CRC** — it's PROBABLE (a different region/revision can
31
+ move addresses), so confirm before patching. The cheapest confirmation is to apply it
32
+ and watch: `cheats({op:'apply', path, desc})` → `frame({op:'screenshot'})`.
33
+
34
+ `cheats({op:'apply'})` is non-destructive (volatile core state, the RetroArch way — the ROM
35
+ file is never touched; `host({op:'reset'})`/`state({op:'load'})`/`cheats({op:'clear'})` removes it). It takes a
36
+ matched `desc`, a raw `code`, or `loadMedia({cheats:[…]})` to seed codes BEFORE frame 0.
37
+ `appliedAs` reports how it went in (`ram` poke / `rom` read-intercept / `raw` device
38
+ code). DB coverage is 13/14 (every tier-1 system except C64); GBA cheats are
39
+ encrypted, so apply-only (no labeled-address map — see `mapNote`).
40
+
41
+ **Creating a NEW code — `cheats({op:'make', platform, address, value, compare?})`.** The inverse of
42
+ decoding: turn a byte you found (via §1 or `breakpoint`) into a shareable, verified code,
43
+ for ANY ROM incl. your own homebrew/WIP. A RAM cheat needs just `address`+`value`; a ROM
44
+ patch adds `compare` (the byte currently there). It encodes for the platform's native
45
+ device(s) and labels each (NES/Genesis → Game Genie; SNES → Pro Action Replay **and**
46
+ Game Genie; GB/GBC → Game Genie + GameShark; SMS/GG → Action Replay) plus the raw
47
+ `ADDR:VAL`; each carries `verified:true` (round-trips against the full DB). Systems with no
48
+ letter-code device (Atari 2600/7800, Lynx, GBA, C64, PC Engine, MSX) get a verified raw
49
+ code. Works on all 14. Nothing is ever written to a ROM file.
50
+
25
51
  ---
26
52
 
27
53
  ## 1. To find the RAM address of a value (score / timer / stat / HP / record-id)
@@ -50,12 +76,20 @@ usually a struct/entity array, each island one record.
50
76
  The #1 trap: visible names/labels are often **pre-rendered tile GRAPHICS**, not
51
77
  font-rendered from an ASCII string. Patching the ASCII string then does nothing.
52
78
 
53
- 1. `text({op:'learn'})` on the on-screen text. If it reports
54
- `likelyPreRenderedGraphic:true` (unique sequential tiles, no font reuse),
55
- **stop** the text is a bitmap. Editing it means changing tile pixels, not a
56
- string. Do not patch any ASCII string you found; it isn't the source.
57
- 2. If it IS font-rendered, find the string with `text({op:'find'})` /
58
- `text({op:'encode'})` and patch that.
79
+ 1. `text({op:'learn'})` on the on-screen text it infers the game's char→tile-ID map
80
+ (games use their own encoding: Excitebike A=$0A, Mario ASCII-offset, FF sparse). Two
81
+ modes: ROM mode `knownStrings:[{text, offset}]` when you found the bytes; **LIVE mode
82
+ `fromScreen:[{text, row, col}]`** reads the tile IDs straight off the live BG map at a
83
+ tile position (`background({view:'map'})` shows where the text sits) — this breaks the
84
+ chicken-and-egg of needing the offset you're still hunting. Live mode works on every
85
+ tilemap platform (NES/SNES/Genesis/GB/GBC/SMS/GG/C64); atari2600/7800, lynx, gba have
86
+ no text nametable → ROM mode only. If `learn` reports `likelyPreRenderedGraphic:true`
87
+ (unique sequential tiles, no font reuse), **stop** — the text is a bitmap. Editing it
88
+ means changing tile pixels, not a string. Do not patch any ASCII string you found.
89
+ 2. If it IS font-rendered: `text({op:'find', romPath, text, fontMap})` locates the string
90
+ (returns `fileOffset`, `prgFileOffset`, and a bank-aware `cpuAddress`+`bank` to feed
91
+ `disasm({target:'rom'})`; flags a likely length-prefix byte to avoid the overrun trap),
92
+ then `text({op:'encode', text, fontMap})` → bytes for `romPatch({op:'write'})`.
59
93
  3. To find where a graphic/text was sourced from: on **Genesis**, `watch({on:'dma', precision:'sampled'})`
60
94
  — drive to the screen that shows the graphic, and it reports the ROM offset(s)
61
95
  the tiles were DMA'd from (decoded from the VDP DMA registers). Edit the tile
@@ -103,6 +137,15 @@ from a SOURCE struct rather than written in place. Don't conclude "the address
103
137
  is wrong." Find the source: `memory({op:'search'})` the live value to locate the struct
104
138
  the copy reads from, then `breakpoint({on:'write'})` on THAT.
105
139
 
140
+ **Precision — exact vs sampled.** The default `breakpoint({on:'write'})` is a core-level write
141
+ watchpoint: it returns the EXACT writing instruction's PC, captured inside the CPU write
142
+ path — correct even for NMI/IRQ-driven writes (the common case where a frame-sampled PC
143
+ is just the idle loop). On a banked mapper it reports the `bank` (NES/GB/SMS-GG) so you
144
+ can pass `{startAddress, bank}` to `disasm({target:'rom'})`. The lighter
145
+ `breakpoint({on:'write', precision:'sampled'})` (a.k.a. `watch({on:'mem'})`) steps until the byte changes
146
+ and returns a frame-boundary PC — a lead, not a guarantee under interrupts; use it for the
147
+ value timeline or when you just want the change history, and cross-check the value trace.
148
+
106
149
  ---
107
150
 
108
151
  ## 5b. To READ a register at an instruction — execution breakpoints (all 14)
@@ -254,6 +297,101 @@ and `input({op:'set'})` state it too.)
254
297
 
255
298
  ---
256
299
 
300
+ ## 7. Authoring & verifying the byte patch
301
+
302
+ Once you know WHAT to change, the write loop is a handful of calls — no custom scripts:
303
+
304
+ - **`assembleSnippet({cpu, origin, code})`** — assemble a tiny asm chunk to raw bytes (no
305
+ header/linker/segments). CPUs: `6502 / 65c02 / 65816 / 68k / z80 / sm83 / gb / gbc /
306
+ huc6280`. **Z80 gotcha:** the sdas dialect requires `#` on immediates (`ld a,#5`, not
307
+ `ld a,5`).
308
+ - **`romPatch({op:'write', path, offset, hex, expect})`** — the splicer THE other hack tools
309
+ compose through. **Always pass `expect`** (the current bytes) — it refuses the write if
310
+ they don't match, catching a hex/dec slip or a patch authored against region A applied to
311
+ region B. `allowExpand` for size-changing edits.
312
+ - **`romPatch({op:'diff', platform, a, b})`** — mapper-aware ROM diff: reports CPU addresses
313
+ (NROM-128 mirrors, SNES LoROM `XX:XXXX`), per-region tallies (PRG vs CHR vs header), and
314
+ `tile:N` annotations on CHR changes for direct sprite-hack identification. Use it to
315
+ confirm a patch landed where you meant.
316
+ - **`disasm({target:'references', path, platform, address})`** — find every instruction that
317
+ references a target address, classified `call/jump/branch/read/write/use/ref` (walks the
318
+ vector table too). The fast "who touches this?" for a STATIC image. Limitation: direct
319
+ addressing only — indirect/computed jumps aren't detected (use the runtime `watch`/
320
+ `breakpoint` tools in §5/§5d for those).
321
+ - **`cart({op:'extract', path, outputDir})`** — split a ROM into standard parts (NES header/
322
+ prg/chr; SNES copier_header+rom+internal header; Genesis vectors/header/body; GB boot/
323
+ header/body) + a `manifest.json` (mapper, mirroring…). **`cart({op:'wrap'})`** is the inverse:
324
+ emits `wrapperSource` + `linkerConfig` ready for `build({output:'rom'})`.
325
+
326
+ Verify-before-patch: `memory({op:'write', region:'system_ram', offset, hex})` on the LIVE emulator and
327
+ watch the screen react — cheaper than shipping a wrong ROM patch.
328
+
329
+ ## 7b. Whole-ROM rebuildable disassembly — `disasm({target:'project'})`
330
+
331
+ For a STRUCTURAL hack (new logic, not a byte poke), turn the whole ROM into a
332
+ re-buildable project in one call: `disasm({target:'project', path, outputDir})`. It splits
333
+ the ROM into regions (per-16KB bank for banked NES, per-32KB for SNES LoROM, slot0+slotX
334
+ for GB, one flat region for SMS/Genesis/C64/Atari), disassembles each through the CPU's
335
+ native objdump, then **reassembles + verifies byte-exact** against the original; any line
336
+ that won't reproduce faithfully heals to a `.byte`/`db` of its real bytes, so the emitted
337
+ `.asm` ALWAYS rebuilds (`roundTrip.allByteExact`). `readablePercent` per region tells you
338
+ how much came back as real instructions vs. data. Alongside the `.asm` it writes the
339
+ turnkey **rebuild glue**: data blobs (NES CHR-ROM → `chr.bin`; stripped Genesis/GBA/Lynx/MSX
340
+ cartridge header → `*.bin`), a `BUILD.md` with the exact steps, and — where a one-call
341
+ rebuild exists — a `rebuild.json` of the precise `build({...})` args. So the loop is
342
+ `disasm({target:'project'})` → edit a `.asm` → rebuild → `romPatch({op:'diff'})` to confirm.
343
+
344
+ **Two rebuild tiers** (the disasm emits each CPU's native-reassembler syntax — ca65 for
345
+ 6502/65816, GNU `as` for m68k/arm/z80/gbz80 — which only some `build()` toolchains consume):
346
+ - **One-call `build()` rebuild, byte-identical** — **NES, C64, Atari 7800, Lynx**. Feed
347
+ `rebuild.json` straight to `build`. (Lynx: `build()` yields the headerless image; prepend
348
+ the shipped `lnx_header.bin` for the full `.lnx`.)
349
+ - **Native-recipe rebuild (`buildCall:null`), byte-identical, steps in `BUILD.md`** — **SMS,
350
+ GG, MSX, GB, GBC, Genesis, GBA, Atari 2600**. Their `build()` toolchains (SDCC/RGBDS/asar/
351
+ dasm/vasm) can't reassemble ca65/GNU-as syntax, so `BUILD.md` gives the proven native
352
+ `as`/`ld`/`objcopy` chain.
353
+ - **PC Engine** is the one not-yet-byte-exact case (the region trims real padding / doesn't
354
+ strip a copier header) — `BUILD.md` flags it.
355
+
356
+ **Rebuilding a commercial NES (NROM CHR-ROM) game — `build({inesHeader})`:** the most common
357
+ NES RE rebuild. `build({output:'rom', platform:'nes', inesHeader:{prgBanks, chrBanks, mapper,
358
+ mirroring}, sourcesPaths:{…the PRG…}, binaryIncludePaths:{"chr.bin":…}})` auto-emits the
359
+ 16-byte iNES header + CHARS-segment wiring + flat NROM `.cfg` — no hand-derived header bytes.
360
+ `disasm({target:'project'})` puts exactly this call in `rebuild.json`. (For homebrew C that
361
+ ships fixed tile art, `linkerConfig:"chr-rom"` is the segment-split equivalent.)
362
+
363
+ **Readability caveats** (the bytes are ALWAYS correct; only instruction-vs-`.byte` coverage
364
+ varies): SNES and large Genesis ROMs come back byte-exact but DATA-ONLY (flat whole-ROM
365
+ disasm of a mostly-data image heals to `.byte` — meaningful coverage needs recursive
366
+ entry-point following, a known follow-up). GBA reads LOW because GBA C compiles mostly to
367
+ Thumb reached via an ARM crt0 stub, so an ARM-mode disasm decodes Thumb spans as `.byte`.
368
+ Banked-NES is the strongest case (~100% instructions); GB/GBC, SMS/GG, C64, Atari are also
369
+ near-100%.
370
+
371
+ ## 8. Graphics swaps — PNG ↔ tiles round-trip
372
+
373
+ For sprite/tile edits (not text), don't hand-roll the tile-format math:
374
+
375
+ - **`tiles({op:'png', source:'path', platform, path, bank, paletteFromEmulator, paletteIndex})`** —
376
+ a source ROM's tiles → PNG. `bank:N` (NES 4 KB CHR bank) replaces magic file-offset math;
377
+ `paletteFromEmulator:true`+`paletteIndex` colors the export with the LIVE palette (vs
378
+ grayscale) so the art is recognizable to edit.
379
+ - **`importArt({from:'rom', sourceRom, sourcePlatform, sourceBank, sourceTileX/Y/W/H, targetPlatform,
380
+ outputPng, intent, paletteIndex})`** — one-call lift of a tile region from a source ROM into
381
+ the target platform's format (extract+crop+quantize). `intent:"homebrew"` reads the live
382
+ source palette; `intent:"rom-hack"` preserves source bytes verbatim.
383
+ - **`encodeArt({stage:'tiles', platform, pngBase64})`** → target-platform tile bytes.
384
+ - **`romPatch({op:'spliceCHR', path, platform, pngBase64, tileIndex, expect, bank, paletteHint})`** —
385
+ PNG → tile bytes → splice into CHR at tile slot N (auto-locates iNES CHR base; `expect`
386
+ checks the existing tile bytes; `paletteHint:["#RRGGBB",…]` gives explicit RGB→index
387
+ mapping). Composes the `encodeArt`+`romPatch({op:'write'})` step in one call.
388
+ - **`background({view:'rendered'})`** — at the current state, the set of tile IDs actually drawn
389
+ (BG nametable + OAM). Sample at title/gameplay/menu and diff the sets to map tile IDs to
390
+ assets without scanning sheets by eye. (`romPatch({op:'findFree'})` locates $FF/$00 runs for asm
391
+ overlays, longest-first.)
392
+
393
+ ---
394
+
257
395
  ## Quick reference
258
396
 
259
397
  | Goal | Tool |
@@ -276,4 +414,15 @@ and `input({op:'set'})` state it too.)
276
414
  | Where did a VRAM graphic come from (Genesis) | `watch({on:'dma', precision:'sampled'})` (ROM offset of the DMA source) |
277
415
  | Drive a menu fast | `input({op:'navigate'})` (advances on screen change) |
278
416
  | Free RAM map for a known game | `cheats({op:'lookup'})` / `cheats({op:'search'})` |
417
+ | Apply a cheat live (non-destructive) | `cheats({op:'apply'})` (verify a label / fun) |
418
+ | Create a shareable code from a byte | `cheats({op:'make'})` (verified, all 14) |
419
+ | Read on-screen text's tile map | `text({op:'learn', fromScreen})` (live, no offset needed) |
420
+ | Find / encode a font-rendered string | `text({op:'find'})` → `text({op:'encode'})` |
421
+ | Assemble asm → raw patch bytes | `assembleSnippet({cpu, origin, code})` |
422
+ | Mapper-aware diff of two ROMs | `romPatch({op:'diff'})` (CPU addrs, CHR `tile:N`) |
423
+ | Who references this address (static) | `disasm({target:'references'})` (direct modes only) |
424
+ | Split / rebuild a ROM into parts | `cart({op:'extract'})` / `cart({op:'wrap'})` |
425
+ | Swap a sprite/tile (PNG round-trip) | `tiles({op:'png'})` → edit → `romPatch({op:'spliceCHR'})` |
426
+ | Lift art from another game's ROM | `importArt({from:'rom'})` |
427
+ | Tile IDs actually being drawn now | `background({view:'rendered'})` |
279
428
  | Safe patch | `romPatch({op:'write'})`/`romPatch({op:'writeMany'})` with `expect` |
@@ -192,3 +192,40 @@ When you call `build({output:'rom', platform:"atari2600", source: ...})`:
192
192
  2. The result is `.a26` — loadable in stella (`loadMedia`).
193
193
 
194
194
  There's no linker — dasm produces a complete cart in one pass.
195
+
196
+ ## MCP debug & inspection tooling
197
+
198
+ The 2600 runs on the **stella2014 (patched)** core. Because the 2600 has
199
+ no framebuffer and no standard sound chip, its inspectors look different
200
+ from the tilemap consoles — they decode the live TIA snapshot instead.
201
+
202
+ What you can read:
203
+
204
+ - **`palette({source:'live'})`** — the NTSC 128-color palette as a PNG,
205
+ with the *current* TIA background luma+hue extracted from the live
206
+ snapshot so you can see what color the beam is painting right now. This
207
+ is the same 128-entry `HHHHLLLL` palette the 7800 uses.
208
+ - **`sprites({op:'inspect'})`** — there is **no OAM** on the 2600, so this
209
+ returns the state of the 5 TIA graphics objects (P0, P1, M0, M1, Ball)
210
+ plus a current-scanline PNG showing how the TIA is composing that line.
211
+ - **`cpu({op:'read'})`** — the 6502 register file (A / X / Y / P / SP / PC)
212
+ pulled from the M6502 core's internal regs.
213
+ - **`background({view:'renderState'})`** — decodes the 32-byte TIA snapshot
214
+ into the playfield pattern, per-object enables/positions, and the color
215
+ registers.
216
+ - **`disasm({target:'rom'})`** and **`disasm({target:'references'})`** —
217
+ both anchor to the top of the bank (`$F000-$FFFF`) and label the vector
218
+ table (NMI / RESET / IRQ at `$FFFA`).
219
+
220
+ Memory regions for **`memory({op:'read'})`**:
221
+
222
+ | Region | Size | What it is |
223
+ | --- | --- | --- |
224
+ | `system_ram` | 128 bytes | the RIOT RAM — that's the *entire* console RAM |
225
+ | `a26_tia_regs` | 32 bytes | the live TIA register snapshot |
226
+ | `a26_cpu_regs` | 7 bytes | the 6502 register snapshot |
227
+
228
+ **No `audioDebug` inspector.** The 2600's sound comes from the two TIA
229
+ audio voices (`AUDC/AUDF/AUDV` at `$15-$1A`), not a standard PSG/FM chip,
230
+ so there's no `audioDebug` decode — read the audio state directly out of
231
+ the `a26_tia_regs` snapshot instead.
@@ -322,3 +322,39 @@ When you call `build({output:'rom', platform:"atari7800", language:"c"})`:
322
322
  3. ld65 links + atari7800.cfg → flat `.a78` ROM.
323
323
 
324
324
  Loadable via prosystem (`loadMedia`).
325
+
326
+ ## MCP debug & inspection tooling
327
+
328
+ The 7800 runs on the **prosystem (patched)** core. Like the 2600 it has
329
+ no framebuffer and no tile/sprite-attribute tables, so its inspectors
330
+ decode MARIA's display-list machinery rather than a tilemap.
331
+
332
+ What you can read:
333
+
334
+ - **`palette({source:'live'})`** — a 256-color master palette PNG, with
335
+ the live MARIA palette block at `$20-$3F` decoded into the 8 palettes ×
336
+ 3 colors each, plus the backdrop. This is the Atari NTSC palette shared
337
+ with the 2600.
338
+ - **`sprites({op:'inspect'})`** — there is **no OAM** on the 7800. Instead
339
+ this returns the MARIA control registers and the **DPP** display-list-
340
+ list pointer, leaving the agent to walk the DLL → DL hierarchy itself
341
+ (the same structure described under "MARIA: the unusual one" above).
342
+ - **`cpu({op:'read'})`** — the 6502 ("Sally") register file (A / X / Y /
343
+ P / SP / PC) read from prosystem's `sally` globals.
344
+ - **`background({view:'renderState'})`** — the MARIA CTRL bits, DPP,
345
+ CHARBASE, and the current `dlistPtr`.
346
+ - **`disasm({target:'rom'})`** and **`disasm({target:'references'})`** —
347
+ both default to the top 16 KB (`$C000-$FFFF`), where the reset vector
348
+ lands.
349
+
350
+ Memory regions for **`memory({op:'read'})`**:
351
+
352
+ | Region | Size | What it is |
353
+ | --- | --- | --- |
354
+ | `system_ram` | 64 KB | the *entire* 6502 address space — MARIA regs, RAM, and ROM are all visible through this one region |
355
+ | `a78_cpu_regs` | — | the 6502 register snapshot |
356
+
357
+ **No `audioDebug` inspector.** The 7800's standard audio is the same TIA
358
+ chip carried over from the 2600 (`$15-$1A`), not a decodable PSG/FM chip,
359
+ so there's no `audioDebug` decode. (Some carts add a POKEY, but it's
360
+ non-standard — don't assume it's present.)
@@ -41,6 +41,12 @@ Default after boot = `$37` (all three set) — BASIC + KERNAL ROMs +
41
41
  I/O regs. Set CHAREN=0 to read the character-set bitmaps from ROM;
42
42
  set HIRAM=0 to swap KERNAL out for RAM (useful for low-memory tricks).
43
43
 
44
+ > **cc65 zero-page starts at $02 (same cc65 trap as NES/Atari/Lynx).** cc65
45
+ > reserves `$00-$01` (here it's also the 6510 I/O port), so your first
46
+ > `.res 1` in `ZEROPAGE` lands at **$02**, not $00. Don't hand-write asm that
47
+ > assumes a ZP var is at $00. Confirm with `symbols({op:'map'})` after
48
+ > `build({output:'romWithDebug'})`.
49
+
44
50
  ## VIC-II — character cells, NOT tiles
45
51
 
46
52
  The C64's video chip displays 25 rows × 40 cols of 8×8 character
@@ -107,12 +113,35 @@ which is what the KERNAL's IRQ uses to update key state every
107
113
 
108
114
  ### Driving input over MCP
109
115
 
110
- The C64 joystick has **one** fire button. Over MCP press it with
111
- `input({op:'set', b: true})` or the spatial `input({op:'set', south: true})` both clear
112
- `$DC00` bit 4 (verified live against vice). `input({op:'set', a: true})` is a **no-op**
113
- (no second button not in `input({op:'layout'})`'s `physicalButtons`). So drive fire
114
- with `b`/`south`, plus the d-pad. ⚠ A cc65 `.prg` only starts after BASIC
115
- auto-`RUN`s it — step ~70+ frames past load before input/reads register.
116
+ **Joystick.** One fire button. Press it with `input({op:'set', b: true})` (or
117
+ spatial `south`) both clear `$DC00` bit 4 (verified live). `a` is a **no-op**
118
+ (no second button). Drive fire with `b`/`south` + the d-pad. The joystick reads
119
+ **port 2** by default; switch with `input({op:'joyport', joyport:1})` /
120
+ `input({op:'joyport'})` to read it.
121
+
122
+ **Keyboard (the C64-specific part — many games NEED it).** Unlike consoles, most
123
+ C64 games (and cracktros) gate gameplay behind a KEYBOARD setup screen — **F1**
124
+ to pick 1 player, RUN/STOP, SPACE/RETURN — that the joystick can't reach. So if a
125
+ joystick gets you to a title/intro but not into play, you almost certainly need a
126
+ key:
127
+ - `input({op:'pressKey', key:'f1'})` — press one C64 key (held + auto-released).
128
+ Keys: `f1/f3/f5/f7`, `return`, `space`, `run/stop`, `a-z`, `0-9`, `ctrl`, `cbm`,
129
+ `home`, `down`, `right`, `lshift`, `rshift`.
130
+ - `input({op:'typeText', text:'LOAD"*",8,1\rRUN\r'})` — type a string (`\r` =
131
+ RETURN). For BASIC commands / filenames.
132
+ - `input({op:'layout', platform:'c64'})` lists the keyboard keys + the joyport.
133
+
134
+ A typical C64 RE startup: load → step to the title → `pressKey f1` (1 player) →
135
+ `set {b:true}` (fire to start) → step → you're in gameplay → `state({op:'save'})`.
136
+
137
+ **Controller-alone (playtest):** a human in `playtest` needs no keyboard — the
138
+ pad's spare buttons map to the C64 keys (X=Space, L2=Run/Stop, R2=Return,
139
+ right-stick=F1/F3/F5/F7, top face=F1; d-pad+Fire=joystick). The same mapping
140
+ applies to the agent's `setInput`, so e.g. `input({op:'set', c64_f1:true})` also
141
+ presses F1. `playtest({op:'open'})` returns `c64Controls` to relay to the user.
142
+
143
+ ⚠ A cc65 `.prg` only starts after BASIC auto-`RUN`s it — step ~70+ frames past
144
+ load before input/reads register.
116
145
 
117
146
  ## SID — three voices of fame
118
147
 
@@ -131,6 +160,54 @@ the `sid_play.s` starter snippet.
131
160
  waveform, freq→note, pulse-width, ADSR — plus the filter cutoff/resonance/mode.
132
161
  Handy for verifying a `sid_play` routine is actually gating notes.
133
162
 
163
+ ## MCP debug & inspection tooling
164
+
165
+ romdev runs the C64 on a patched VICE (`vice_x64`) core that exposes deep live
166
+ state. The inspectors all read the **running** machine — use them to confirm what
167
+ your code actually did to the hardware, not what you think it did.
168
+
169
+ **Live visual / state inspectors:**
170
+
171
+ - `palette({source:'live'})` — the 16-color hardware-fixed C64 palette as a PNG,
172
+ plus the current border / background / extra-background color indices decoded
173
+ straight from the VIC-II registers.
174
+ - `sprites({op:'inspect'})` — all 8 MOBs decoded into the generic sprite shape:
175
+ X/Y, color, multicolor flag, expand-X / expand-Y, priority, AND the screen-RAM
176
+ sprite-data pointers at `$07F8` so you can locate each sprite's pixel block in
177
+ the VIC bank.
178
+ - `cpu({op:'read'})` — the 6510 registers (A/X/Y/P/SP/PC) read from a live,
179
+ `#define`-aliased register file, plus the `$0001` I/O port decoded into its
180
+ LORAM / HIRAM / CHAREN bits (so you can see which ROMs are banked in).
181
+ - `background({view:'renderState'})` — the VIC-II registers decoded into
182
+ mode / scroll / colors / sprites, the VIC bank resolved from CIA2 `$DD00`, and
183
+ the **absolute** screen-RAM + character-base addresses (no manual bank math).
184
+ - `audioDebug({op:'inspect', chip:'sid'})` — the SID voice/filter decode covered
185
+ in the SID section above.
186
+
187
+ **`c64_*` memory regions** (via `memory({op:'read'})`) — exact, named windows
188
+ onto the hardware, decoded live:
189
+
190
+ | region | size | notes |
191
+ | --------------- | ----- | ----------------------------------------------- |
192
+ | `system_ram` | 64 KB | full RAM |
193
+ | `c64_color_ram` | 1 KB | the nibble color RAM |
194
+ | `c64_vic_regs` | 64 B | VIC-II registers |
195
+ | `c64_sid_regs` | 29 B | SID registers (read via `sid_peek`) |
196
+ | `c64_cia1_regs` | 16 B | CIA1, from the `c_cia[]` array |
197
+ | `c64_cia2_regs` | 16 B | CIA2, from the `c_cia[]` array |
198
+ | `c64_cpu_regs` | 7 B | 6510 register file |
199
+
200
+ **Disassembly:** `disasm({target:'rom'})` and `disasm({target:'references'})`
201
+ accept `.prg` files (they understand the 2-byte little-endian load-address
202
+ header), and apply the C64 register-annotation table so VIC-II / SID / CIA
203
+ register accesses come back named rather than as bare addresses.
204
+
205
+ **Starter snippets** cover `vic_init` / `sprite_table` / `sid_play` /
206
+ `read_joystick` / `basic_stub`.
207
+
208
+ Disk-image loading and disk SAVE/restore tooling have their own sections below
209
+ ("Disk images" and "Disk SAVES").
210
+
134
211
  ## Cartridge / load file format
135
212
 
136
213
  The .prg format is dead simple:
@@ -14,6 +14,62 @@ one call fuses a framebuffer pixel scan with the live LCDC and returns
14
14
  `verified:null` = step a frame first. Zero image tokens, frame-0-guarded — use it
15
15
  as the first move when a change "did nothing."
16
16
 
17
+ ## Toolchains
18
+
19
+ Default is **C** via SDCC's sm83 port (the same SDCC that powers SMS/GG/MSX/
20
+ Coleco). For hand-tuned asm, pass `language:"asm"` to route through RGBDS. The C
21
+ path uses `__sfr __at 0xFFNN` to bind GB I/O regs; the helper headers under
22
+ `src/platforms/gb/lib/c/gb_hardware.h` define LCDC/STAT/SCY/SCX/LY/BGP/OBP0/OBP1/
23
+ etc. for both DMG and CGB. ⚠ SDCC 4.4.0 codegen quirk: `for (;;) { switch + write
24
+ to __sfr }` crashes the register allocator — use `do { ... } while (1)` and
25
+ table-lookup writes instead. (See the GB/GBC SDCC_GOTCHAS for the full set of
26
+ sm83 codegen footguns.)
27
+
28
+ ## MCP debug & inspection tooling
29
+
30
+ The bundled gambatte core is patched to expose deep live state — sprites,
31
+ palettes, tiles, background/LCDC, CPU, and raw memory regions. This applies to
32
+ **both `gb` and `gbc`** builds (one shared gambatte core).
33
+
34
+ **Live inspectors (decode hardware state, no manual byte-twiddling):**
35
+
36
+ - **`sprites({op:'inspect'})`** — decodes all 40 OAM slots and renders a
37
+ sprite-sheet PNG with sprite-priority + horizontal/vertical flip applied.
38
+ - **`palette({source:'live'})`** — DMG path decodes the BGP / OBP0 / OBP1 bytes
39
+ into 4 shades each; CGB path decodes the 64-byte BCPS/OCPS palette RAM into
40
+ 8 palettes × 4 colors in BGR555.
41
+ - **`tiles({op:'png'})`** — renders all 384 tiles from $8000-$97FF.
42
+ - **`cpu({op:'read'})`** — SM83 register file: A/F/BC/DE/HL + flags + IME/halt.
43
+ - **`audioDebug({op:'inspect', chip:'gb'})`** — DMG APU decode: 2 pulse + wave
44
+ + noise channels, with timer→freq→note conversion, sweep, duty, and panning,
45
+ read straight from the live `NR*` registers.
46
+ - **`background({view:'renderState'})`** — LCDC bit-by-bit, scroll (SCX/SCY),
47
+ LY/LYC, window state, plus CGB extras: current VRAM bank, KEY1, and the
48
+ BCPS/OCPS palette index.
49
+
50
+ **Raw memory regions** via `memory({op:'read', region:...})`:
51
+
52
+ | Region | Contents |
53
+ |---|---|
54
+ | `gb_vram` | VRAM ($8000-$9FFF) — tile data + BG maps (CGB: the active bank) |
55
+ | `gb_oam` | OAM ($FE00-$FE9F) — 40 sprites × 4 bytes |
56
+ | `gb_io` | I/O register page ($FF00-$FF7F) — LCDC, BGP, JOYP, CGB regs, etc. |
57
+ | `gb_hram` | HRAM ($FF80-$FFFE) — fast scratch |
58
+ | `gb_bgpdata` | CGB BG palette RAM (64 bytes) |
59
+ | `gb_objpdata` | CGB OBJ palette RAM (64 bytes) |
60
+ | `gb_cpu_regs` | SM83 register snapshot |
61
+
62
+ ⚠ **Gotcha: gambatte exposes `gb_vram`, NOT the generic `video_ram` region.**
63
+ Other platforms' cores expose video memory under `video_ram`; on GB/GBC you must
64
+ ask for `gb_vram` (and the other `gb_*` names above). A `video_ram` read here
65
+ returns nothing.
66
+
67
+ **Disassembly:** `disasm({target:'rom'})` + `disasm({target:'references'})` +
68
+ `disasm({target:'project'})` route through the native binutils z80 `objdump` in
69
+ its `gbz80` machine (WASM, `-m gbz80`) — full CB-prefix coverage plus the
70
+ SM83-specific opcodes (`ld (hl+),a`, `ldh`, `reti`, `ld hl,sp+e8`). One z80-elf
71
+ binutils serves both plain Z80 (SMS/GG/MSX) and the GB CPU.
72
+
17
73
  ## Five silent-failure footguns to know before you start (R26 + R27)
18
74
 
19
75
  If your ROM compiles cleanly but doesn't render — or sprites land in
@@ -72,6 +128,21 @@ check these first. All five have shipped fixes in the bundled runtime
72
128
  If you use the bundled `gb_crt0.s` you're good; if you bring your
73
129
  own, make sure gsinit zeros `_DATA`.
74
130
 
131
+ 6. **Don't poke a hardcoded `$C0xx` WRAM pointer for game state — it
132
+ overlaps your statics.** SDCC links the C runtime's data + BSS (every
133
+ `static` global: your PRNG seed, your grids, your scores) at the BOTTOM
134
+ of WRAM starting `$C000`. A `volatile uint8_t *board = (uint8_t*)0xC000;`
135
+ then scribbles right over `static uint32_t rng = ...;` et al. Symptom
136
+ looks exactly like an SDCC *codegen* bug — e.g. a 32-bit xorshift PRNG
137
+ that "degenerates" so every roll is identical (it's not miscompiling;
138
+ its seed is being clobbered). **Use a `static` array and let the linker
139
+ place it** (`static uint8_t board[78]; board[i]=p;`), or, if you must
140
+ hardcode, put scratch at `$C200`+ and confirm with the linker map
141
+ (`build({includeSymbols:true})` → check `s__DATA`/`s__BSS`; your scratch
142
+ must start above the end of `_BSS`). Full write-up + the
143
+ "is-it-really-a-miscompile" repro in
144
+ `lib/c/SDCC_GOTCHAS.md` § "sm83 codegen traps in plain game logic".
145
+
75
146
  ## Memory map you actually care about
76
147
 
77
148
  ```
@@ -81,6 +152,9 @@ $8000-$97FF VRAM tile data — 384 tiles × 16 bytes (CGB: dual-banked, 768 tot
81
152
  $9800-$9FFF VRAM BG maps + CGB attribute map (in bank 1)
82
153
  $A000-$BFFF Cart RAM (mappers only; not present in 32 KB ROM-only carts)
83
154
  $C000-$DFFF WRAM (8 KB) — your variables, your stack
155
+ ⚠ statics start at $C000 (rng/grids/scores live here): NEVER
156
+ hardcode a $C0xx pointer for game state — use a `static`
157
+ array; for fixed scratch use $C200+ (see footgun #6).
84
158
  $FE00-$FE9F OAM (40 sprites × 4 bytes) — written via DMA
85
159
  $FF00 JOYP — joypad I/O
86
160
  $FF40-$FF4B I/O registers — LCDC, BGP, OBP0, OBP1, SCY, SCX, etc.
@@ -188,3 +188,94 @@ build({
188
188
  },
189
189
  })
190
190
  ```
191
+
192
+ ## sm83 codegen traps in plain game logic (WRAM integer/array code)
193
+
194
+ Every footgun above is about VRAM / OAM-DMA / the cart header — the stuff
195
+ that makes sprites vanish. This section is the opposite: **plain WRAM game
196
+ logic** — PRNGs, collision grids, score math. Two such "miscompiles" were
197
+ reported from a real GBC Columns build session and chased to ground here.
198
+ **Verdict: neither was an sm83 codegen bug.** They are documented so you
199
+ don't burn hours blaming the compiler for what is actually a memory-layout
200
+ or static-init trap.
201
+
202
+ ### NOT a bug: 32-bit math / `uint32_t` shifts ≥ 16
203
+
204
+ Reported: *"`static uint32_t rng=0x1357; rng ^= rng<<13; rng ^= rng>>17;
205
+ rng ^= rng<<5;` degenerates — every `1+xorshift()%6` roll comes out the
206
+ same (near-monochrome)."*
207
+
208
+ **Reproduced on sm83: it does NOT degenerate.** A ROM that seeds the PRNG,
209
+ calls `xorshift()` 20×, and writes `1 + (result % 6)` to WRAM reads back a
210
+ fully-varied `5,5,5,1,5,5,4,1,3,2,1,...` — the exact sequence a reference
211
+ implementation produces. Full 32-bit fidelity was confirmed byte-for-byte
212
+ across several seeds (`0xDEADBEEF`, `0x00000001`, …). The `<<13` / `>>17` /
213
+ `<<5` shifts (including the ≥16-bit right shift) and `% 6` are all correct.
214
+ **Do not rewrite a working 32-bit xorshift into 16-bit to "dodge" this.**
215
+ 32-bit ops are bigger/slower than 16-bit on an 8-bit CPU, so prefer 16-bit
216
+ PRNGs for *speed* — but not for correctness; both are correct.
217
+
218
+ ### The REAL trap behind "monochrome RNG": writing game state to a fixed
219
+ `0xC0xx` WRAM address that overlaps your statics
220
+
221
+ This is what actually produces the reported symptom. SDCC links the C
222
+ runtime's `_DATA` / `_INITIALIZED` segment (every value-initialised
223
+ `static`, e.g. `static uint32_t rng = 0x1357;`) **at the very bottom of
224
+ WRAM, starting `$C000`**, with `_BSS` (zero-init statics like
225
+ `static uint8_t grid[78];`) right after it. If your code also pokes a
226
+ **hardcoded** `$C000`-area pointer for game state —
227
+
228
+ ```c
229
+ volatile uint8_t *board = (volatile uint8_t *)0xC000; /* DON'T */
230
+ board[i] = piece; /* clobbers `rng` and friends! */
231
+ ```
232
+
233
+ — you are scribbling directly over your own statics. Then `xorshift()`
234
+ reads a trashed `rng`, the PRNG collapses, and every roll looks the same.
235
+ It presents *exactly* like a compiler bug; it is not.
236
+
237
+ **Fixes (any one):**
238
+ - **Best — let the linker place it.** Use a `static` array and take its
239
+ address; never hardcode a WRAM pointer:
240
+ `static uint8_t board[6*13]; ... board[i] = piece;`
241
+ - If you *must* use a fixed address, put it well clear of the runtime data:
242
+ `$C200`+ is safe for small projects (statics here end far below `$C100`;
243
+ `shadow_oam` is pinned at `$C100`). Confirm with the linker map — build
244
+ with `includeSymbols:true` and look at `s__DATA` / `s__BSS` (e.g.
245
+ `s__DATA = $C000`, `s__BSS = $C006`): your scratch RAM must start ABOVE
246
+ the end of `_BSS`.
247
+ - **Diagnose it in seconds:** read `system_ram` offset 0 right after boot
248
+ and compare against your initialised statics' expected bytes. If a
249
+ `static uint32_t x = 0x1357;` doesn't read back `57 13 00 00` at its map
250
+ address, something is overwriting it.
251
+
252
+ ### NOT a bug: short `for` loop with an indexed `static` array read
253
+
254
+ Reported: *"`for(i=0;i<3;i++){ if(grid[r*6+col]) return 1; }` reads the
255
+ wrong cells (pieces lock mid-air / floating gaps); unrolling the 3
256
+ iterations fixed it."*
257
+
258
+ **Reproduced on sm83: the looped form reads the CORRECT cells.** A ROM that
259
+ seeds `grid[]` with a sparse occupied/empty pattern and runs `collides()`
260
+ both looped and hand-unrolled, for 8 straddling `(col,topy)` inputs, gets
261
+ **identical, correct** results from both forms (`1,0,1,0,1,1,1,1`). The
262
+ `grid[r*6+col]` index math and the 3-iteration loop are fine. If your real
263
+ collision check "floats," look first at the WRAM-collision trap above (a
264
+ clobbered `grid[]`), at off-by-one row/col limits, or at signed/unsigned
265
+ mix-ups — not at loop codegen. **Don't pre-emptively unroll loops as a
266
+ compiler workaround; with the stack-overflow fix in place, sm83 loops with
267
+ indexed array reads are reliable.**
268
+
269
+ ### z80 (SMS/GG) ONLY — fixed: value-initialised statics booted as 0
270
+
271
+ Investigating the above on the **z80** port (SMS/GG share the SDCC family)
272
+ surfaced a real bug — but a **crt0** bug, not codegen. The bundled
273
+ `sms_crt0.s` / `gg_crt0.s` placed `_INITIALIZER` (the ROM image of
274
+ value-initialised statics) *after* the `_DATA` RAM block in the area list,
275
+ so sdld put it in RAM; the gsinit `ldir` then copied uninitialised RAM onto
276
+ itself and **every `static uint8_t x = 5;` booted as 0** (and BSS wasn't
277
+ zeroed either). On z80 *this* is what made the xorshift PRNG monochrome
278
+ (seed `rng` booted 0 → stayed 0). Fixed 2026-06-08 by ROM-placing
279
+ `_INITIALIZER` + adding a `_DATA` zero loop, mirroring this sm83 crt0 (which
280
+ was already correct — hence sm83 was never affected). If you bring your own
281
+ z80 crt0, model gsinit on `gb_crt0.s`.
@@ -125,9 +125,9 @@ maxmod (separate library, not bundled here).
125
125
 
126
126
  **Debugging sound:** `audioDebug({op:'inspect', chip:"gba"})` decodes the live APU —
127
127
  per-channel freq→note/duty/volume for the 4 tone channels plus the 2 Direct
128
- Sound FIFO states. Pair with `sprites({op:'inspect'})`/`palette({source:'live'})`/
129
- `background({view:'renderState'})`/`cpu({op:'read'})` (ARM7) and `breakpoint({on:'write'})` for the rest of the
130
- live-debug loop.
128
+ Sound FIFO states. See "MCP debug & inspection tooling" below for the rest of
129
+ the live-debug loop (sprites / palette / background / cpu / breakpoint + the
130
+ memory regions and disasm pipeline).
131
131
 
132
132
  **For scaffold-level sfx**, the libtonc runtime ships a minimal
133
133
  `gba_sfx.h` / `gba_sfx.c` pair (3 functions: `sfx_init`, `sfx_tone`,
@@ -136,6 +136,60 @@ as the NES/GB scaffold sound API, so cross-platform game ports feel
136
136
  the same. All 5 GBA genre scaffolds (shmup/platformer/puzzle/sports/
137
137
  racing) use it.
138
138
 
139
+ ## MCP debug & inspection tooling
140
+
141
+ GBA runs on mGBA (patched). These inspectors read the *live* core state —
142
+ reach for them when a sprite, palette, or BG renders wrong and the source
143
+ alone doesn't explain it. (The audio inspector is also summarized under
144
+ "Sound" above.)
145
+
146
+ - **`sprites({op:'inspect'})`** — decodes all **128 OAM sprites** into a
147
+ generic shape: attr0/1/2 unpacked to shape + size, **9-bit signed X**,
148
+ the affine and hidden flags, and tile / palette / priority.
149
+ - **`palette({source:'live'})`** — reads the palette as **15-bit BGR555**:
150
+ 256 BG entries + 256 OBJ entries. Pass `area:'bg'` or `area:'sprite'` to
151
+ pick the half.
152
+ - **`cpu({op:'read'})`** — ARM7TDMI dump: the 16 general regs **r0-r15**,
153
+ `cpsr` + `spsr`, the processor mode, the ARM/THUMB state bit, and an
154
+ **`execPc`** field that is r15 adjusted back for the pipeline prefetch
155
+ (r15 reads ahead of the executing instruction, so raw r15 is misleading —
156
+ use `execPc` for "where am I really").
157
+ - **`audioDebug({op:'inspect', chip:'gba'})`** — the 4 DMG-compatible PSG
158
+ channels (per-channel freq→note / duty / volume) plus the **2 Direct Sound
159
+ DMA FIFO** states, and master / bias. See "Sound" above.
160
+ - **`background({view:'renderState'})`** — decodes DISPCNT: the BG mode, and
161
+ per-BG enable / priority / char-base / map-base / color-mode, the
162
+ forced-blank bit, and OBJ enable. Use it to confirm REG_DISPCNT and the
163
+ REG_BGxCNT bases match where you uploaded tiles + maps.
164
+
165
+ ### Memory regions (`memory({op:'read', region:…})`)
166
+
167
+ | Region | Address / size | Contents |
168
+ |-----------------|------------------------------------|-------------------------------------------|
169
+ | `gba_cpu_regs` | — | ARM7TDMI register snapshot |
170
+ | `gba_io_regs` | $04000000-$040003FE (1 KB) | the I/O page — **video AND audio** MMIO |
171
+ | `gba_palette` | $05000000-$050003FF (1 KB) | 256 BG + 256 OBJ BGR555 entries |
172
+ | `gba_oam` | $07000000-$070003FF (1 KB) | 128 sprite attribute entries (8 B each) |
173
+ | `system_ram` | $02000000 EWRAM / $03000000 IWRAM | main + on-chip work RAM |
174
+ | `video_ram` | $06000000-$06017FFF (96 KB) | BG + sprite tile data + framebuffer |
175
+ | `save_ram` | $0E000000-$0E00FFFF (64 KB) | battery-backed SRAM |
176
+
177
+ Pair `sprites` / `palette` / `background` / `cpu` with
178
+ `breakpoint({on:'write'})` for the full live-debug loop.
179
+
180
+ ### Disassembly (`disasm({target:…})`)
181
+
182
+ `disasm({target:'rom'})`, `disasm({target:'references'})`, and
183
+ `disasm({target:'project'})` run the native binutils
184
+ **`arm-none-eabi-objdump`** (WASM) — **ARM mode by default**, pass
185
+ `thumb:true` for Thumb code. The byte-exact project reassembles through
186
+ `arm-none-eabi-as` / `ld` / `objcopy`.
187
+
188
+ **Gotcha (until ARM/Thumb mode-tracking lands):** GBA C compiles mostly to
189
+ **Thumb** reached via an **ARM crt0 stub**, so an ARM-mode disasm of a full
190
+ ROM decodes the Thumb spans as `.byte` — still byte-exact, just less readable.
191
+ Disasm the Thumb spans with `thumb:true` to get real mnemonics.
192
+
139
193
  ## Frame heartbeat
140
194
 
141
195
  ```c