romdevtools 0.27.0 → 0.28.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 (144) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +309 -0
  3. package/README.md +1 -1
  4. package/examples/README.md +1 -1
  5. package/examples/atari2600/templates/platformer.asm +18 -9
  6. package/examples/atari2600/templates/racing.asm +25 -4
  7. package/examples/atari2600/templates/shmup.asm +30 -5
  8. package/examples/atari2600/templates/sports.asm +41 -9
  9. package/examples/atari7800/templates/hello_sprite.c +8 -4
  10. package/examples/atari7800/templates/platformer.c +12 -8
  11. package/examples/atari7800/templates/puzzle.c +7 -4
  12. package/examples/atari7800/templates/racing.c +5 -2
  13. package/examples/atari7800/templates/shmup.c +8 -4
  14. package/examples/atari7800/templates/sports.c +6 -3
  15. package/examples/c64/templates/platformer.c +28 -24
  16. package/examples/c64/templates/puzzle.c +77 -16
  17. package/examples/c64/templates/racing.c +9 -0
  18. package/examples/c64/templates/shmup.c +13 -1
  19. package/examples/c64/templates/sports.c +9 -4
  20. package/examples/gb/templates/platformer.c +6 -2
  21. package/examples/gb/templates/puzzle.c +279 -101
  22. package/examples/gb/templates/racing.c +13 -1
  23. package/examples/gb/templates/shmup.c +13 -1
  24. package/examples/gb/templates/sports.c +9 -3
  25. package/examples/gba/templates/platformer.c +7 -13
  26. package/examples/gba/templates/puzzle.c +93 -15
  27. package/examples/gba/templates/racing.c +13 -1
  28. package/examples/gba/templates/shmup.c +13 -1
  29. package/examples/gba/templates/sports.c +17 -5
  30. package/examples/gbc/templates/platformer.c +6 -2
  31. package/examples/gbc/templates/puzzle.c +878 -178
  32. package/examples/gbc/templates/racing.c +13 -1
  33. package/examples/gbc/templates/shmup.c +13 -1
  34. package/examples/gbc/templates/sports.c +9 -3
  35. package/examples/genesis/templates/puzzle.c +76 -15
  36. package/examples/genesis/templates/racing.c +13 -1
  37. package/examples/genesis/templates/shmup_2p.c +13 -1
  38. package/examples/gg/templates/platformer.c +4 -0
  39. package/examples/gg/templates/puzzle.c +80 -14
  40. package/examples/gg/templates/racing.c +17 -1
  41. package/examples/gg/templates/shmup.c +17 -1
  42. package/examples/gg/templates/sports.c +4 -0
  43. package/examples/lynx/templates/platformer.c +25 -6
  44. package/examples/lynx/templates/puzzle.c +77 -14
  45. package/examples/lynx/templates/shmup.c +13 -1
  46. package/examples/lynx/templates/sports.c +5 -2
  47. package/examples/msx/platformer/main.c +2 -0
  48. package/examples/msx/puzzle/main.c +78 -15
  49. package/examples/msx/racing/main.c +1 -0
  50. package/examples/msx/shmup/main.c +1 -0
  51. package/examples/msx/sports/main.c +3 -2
  52. package/examples/nes/templates/platformer.c +11 -3
  53. package/examples/nes/templates/puzzle.c +81 -21
  54. package/examples/nes/templates/racing.c +15 -1
  55. package/examples/nes/templates/shmup.c +1 -0
  56. package/examples/nes/templates/sports.c +1 -0
  57. package/examples/pce/platformer/main.c +3 -1
  58. package/examples/pce/puzzle/main.c +78 -12
  59. package/examples/pce/racing/main.c +1 -0
  60. package/examples/pce/shmup/main.c +5 -4
  61. package/examples/pce/sports/main.c +4 -3
  62. package/examples/sms/templates/platformer.c +4 -0
  63. package/examples/sms/templates/puzzle.c +80 -14
  64. package/examples/sms/templates/racing.c +17 -1
  65. package/examples/sms/templates/shmup.c +17 -1
  66. package/examples/sms/templates/shmup_2p.c +17 -1
  67. package/examples/sms/templates/sports.c +4 -0
  68. package/examples/snes/templates/platformer.c +32 -15
  69. package/examples/snes/templates/puzzle.c +84 -16
  70. package/examples/snes/templates/racing.c +20 -1
  71. package/examples/snes/templates/shmup.c +20 -2
  72. package/examples/snes/templates/sports.c +7 -0
  73. package/package.json +12 -12
  74. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  75. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  76. package/src/cores/wasm/fceumm_libretro.js +1 -1
  77. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  78. package/src/cores/wasm/gambatte_libretro.js +1 -1
  79. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  80. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  81. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  82. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  83. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  84. package/src/cores/wasm/handy_libretro.js +1 -1
  85. package/src/cores/wasm/handy_libretro.wasm +0 -0
  86. package/src/cores/wasm/mgba_libretro.js +1 -1
  87. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  88. package/src/cores/wasm/prosystem_libretro.js +1 -1
  89. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  90. package/src/cores/wasm/snes9x_libretro.js +1 -1
  91. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  92. package/src/cores/wasm/stella2014_libretro.js +1 -1
  93. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  94. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  95. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  96. package/src/host/LibretroHost.js +245 -10
  97. package/src/mcp/server.js +6 -0
  98. package/src/mcp/tools/disasm-rebuild.js +315 -65
  99. package/src/mcp/tools/disasm.js +149 -28
  100. package/src/mcp/tools/find-references.js +216 -51
  101. package/src/mcp/tools/frame.js +11 -4
  102. package/src/mcp/tools/index.js +15 -1
  103. package/src/mcp/tools/input.js +26 -3
  104. package/src/mcp/tools/memory.js +208 -39
  105. package/src/mcp/tools/playtest.js +56 -4
  106. package/src/mcp/tools/project.js +35 -9
  107. package/src/mcp/tools/toolchain.js +43 -10
  108. package/src/mcp/tools/watch-memory.js +141 -24
  109. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
  110. package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
  111. package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
  112. package/src/platforms/atari7800/MENTAL_MODEL.md +27 -6
  113. package/src/platforms/gb/MENTAL_MODEL.md +16 -1
  114. package/src/platforms/gb/TROUBLESHOOTING.md +42 -0
  115. package/src/platforms/gb/lib/c/patch-header.js +7 -4
  116. package/src/platforms/gbc/MENTAL_MODEL.md +12 -0
  117. package/src/platforms/gbc/TROUBLESHOOTING.md +21 -0
  118. package/src/platforms/gbc/lib/c/font.h +43 -0
  119. package/src/platforms/gbc/lib/c/patch-header.js +7 -4
  120. package/src/platforms/genesis/MENTAL_MODEL.md +40 -6
  121. package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
  122. package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
  123. package/src/platforms/gg/TROUBLESHOOTING.md +13 -17
  124. package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
  125. package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
  126. package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
  127. package/src/platforms/msx/MENTAL_MODEL.md +6 -0
  128. package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
  129. package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
  130. package/src/platforms/msx/lib/c/msx_hw.h +2 -0
  131. package/src/platforms/msx/lib/c/msx_vdp.c +45 -0
  132. package/src/platforms/nes/MENTAL_MODEL.md +10 -3
  133. package/src/platforms/nes/lib/c/nes_runtime.c +41 -0
  134. package/src/platforms/nes/lib/c/nes_runtime.h +2 -0
  135. package/src/platforms/pce/MENTAL_MODEL.md +9 -0
  136. package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
  137. package/src/platforms/pce/lib/c/pce_hw.h +2 -1
  138. package/src/platforms/pce/lib/c/pce_sound.c +22 -0
  139. package/src/platforms/sms/MENTAL_MODEL.md +5 -0
  140. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  141. package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
  142. package/src/platforms/snes/MENTAL_MODEL.md +5 -0
  143. package/src/playtest/playtest.js +73 -3
  144. package/src/toolchains/index.js +37 -8
@@ -59,15 +59,34 @@ thousands of bytes and you'll drown).
59
59
  on-screen value. `region` defaults to `system_ram`.
60
60
  2. Change the value in-game (take damage, score a point), then
61
61
  `memory({op:'searchNext', compare:'eq', value})` — or `compare:'gt'|'lt'|'changed'|'unchanged'|
62
- 'inc'|'dec'` when you don't know the new value. Repeat until a handful remain.
62
+ 'inc'|'dec'` when you don't know the new value. The relative compares work as the
63
+ FIRST narrow too (baselines are recorded at seed). Repeat until a handful remain.
63
64
  3. Confirm: `memory({op:'write'})` the candidate and watch the screen react.
64
65
 
65
66
  This is the Cheat-Engine/RetroArch loop. It is THE bread-and-butter primitive.
66
67
 
68
+ **Stored ≠ displayed.** When a correct-looking seed returns 0, the byte usually isn't the
69
+ raw number: seed `as:'bcd'` for packed-BCD scores (2 decimal digits per byte — very common
70
+ on NES), or `as:'digits'` for one byte per ON-SCREEN digit at any constant tile base (HUD
71
+ tile-index buffers; the matched base is reported per candidate, and `searchNext` keeps
72
+ comparing in the same representation). For displayed−1 lives or ÷10 scores, just seed the
73
+ transformed number. If an INPUT drives the value (position, velocity, charge), skip the
74
+ loop entirely: `memory({op:'diffRuns', portsA:[{right:true}]})` isolates it in one call.
75
+
67
76
  `memory({op:'snapshot'})` + `memory({op:'diff'})` is for "which bytes did THIS one event touch?",
68
77
  not for value hunting. `memory({op:'diff'})` defaults to a **clustered summary** (ranges +
69
78
  stride) so it won't flood you — a reported stride (e.g. "islands at 0x80") is
70
- usually a struct/entity array, each island one record.
79
+ usually a struct/entity array, each island one record. Small clusters (≤8 bytes) carry
80
+ `before`/`after` hex inline, and `minDelta:N` drops |after−before| < N so RNG/counter
81
+ wiggle disappears from the report.
82
+
83
+ **"Which byte does this INPUT drive?" → `memory({op:'diffRuns'})`** — runs the same start
84
+ state twice (savestate restore in between) under two different held inputs (`portsA` vs
85
+ `portsB`, default released) for `frames` each, and returns only the bytes that DIVERGE
86
+ between the runs, with run-A/run-B values on small clusters. One call replaces the whole
87
+ save → hold → step → dump → restore → hold-other → dump → diff loop; the frame counter and
88
+ all input-independent churn cancel out automatically. (The emulator is left at the end of
89
+ run B.)
71
90
 
72
91
  ---
73
92
 
@@ -140,7 +159,12 @@ the copy reads from, then `breakpoint({on:'write'})` on THAT.
140
159
  **Precision — exact vs sampled.** The default `breakpoint({on:'write'})` is a core-level write
141
160
  watchpoint: it returns the EXACT writing instruction's PC, captured inside the CPU write
142
161
  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
162
+ is just the idle loop). On ALL 14 platforms, every hit (write/read/pc) also carries
163
+ **`registersAtHit`** — the full register file frozen AT the hit instant — and the CPU
164
+ stays FROZEN until the hit is cleared. Use registersAtHit instead of a follow-up
165
+ `cpu({op:'read'})`: pre-0.28.0 the live registers kept running after a hit (on gpgx they
166
+ drifted hundreds of instructions — address registers read that way were someone else's
167
+ values). On a banked mapper it reports the `bank` (NES/GB/SMS-GG) so you
144
168
  can pass `{startAddress, bank}` to `disasm({target:'rom'})`. The lighter
145
169
  `breakpoint({on:'write', precision:'sampled'})` (a.k.a. `watch({on:'mem'})`) steps until the byte changes
146
170
  and returns a frame-boundary PC — a lead, not a guarantee under interrupts; use it for the
@@ -197,6 +221,16 @@ pushes a sentinel return, and runs until it returns. Most of these formats have
197
221
  you can usually craft a replacement by hand. (sandbox:false leaves the dest buffer
198
222
  live for `memory({op:'read'})`; sandbox:true restores the game untouched.)
199
223
 
224
+ **Pass `pure:true` — on every platform.** A non-pure call that spans frames runs the
225
+ game's OWN frame logic concurrently (VBlank handlers via RAM vectors, music
226
+ drivers) — which can overwrite the dest buffer mid-call and hand you poisoned
227
+ "ground truth" (a real session spent hours diffing a CORRECT reimplementation
228
+ against it). With `pure:true` the game's handlers CANNOT run: Genesis/SMS/GG step
229
+ only the CPU (`pureMode:'cpu-only'`); everywhere else interrupt DELIVERY is
230
+ suppressed for the duration (`'irq-blocked'` — pending lines stay pending, video
231
+ advances harmlessly); the 2600 has no interrupts (`'no-interrupts'`). Non-pure
232
+ results carry a ⚠ caveat whenever frame logic ran.
233
+
200
234
  ## 5e. Re-inject an edited asset — the round-trip (don't reimplement the compressor)
201
235
 
202
236
  Once you can SEE the decompressed bytes (5c) and you've edited them, put them BACK
@@ -315,9 +349,14 @@ Once you know WHAT to change, the write loop is a handful of calls — no custom
315
349
  confirm a patch landed where you meant.
316
350
  - **`disasm({target:'references', path, platform, address})`** — find every instruction that
317
351
  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).
352
+ vector table too). The fast "who touches this?" for a STATIC image. EVERY banked format
353
+ is scanned PER BANK NES mappers (refs carry `prgBank`), and SNES multi-bank LoROM,
354
+ GB/GBC MBC, SMS/GG Sega-mapper, MSX megaROM, Atari 2600 F8/F6/F4, Atari 7800 SuperGame,
355
+ and >32KB HuCards (refs carry `romBank`) — so a hit in bank 12 of a 128KB cart shows up,
356
+ not just the first bank. Zero-page direct + indexed operands match, and `#$nn` immediates
357
+ are excluded (values, not addresses). Limitation: direct addressing only —
358
+ indirect/computed jumps aren't detected (use the runtime `watch`/`breakpoint` tools in
359
+ §5/§5d for those).
321
360
  - **`cart({op:'extract', path, outputDir})`** — split a ROM into standard parts (NES header/
322
361
  prg/chr; SNES copier_header+rom+internal header; Genesis vectors/header/body; GB boot/
323
362
  header/body) + a `manifest.json` (mapper, mirroring…). **`cart({op:'wrap'})`** is the inverse:
@@ -330,8 +369,9 @@ watch the screen react — cheaper than shipping a wrong ROM patch.
330
369
 
331
370
  For a STRUCTURAL hack (new logic, not a byte poke), turn the whole ROM into a
332
371
  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
372
+ the ROM into regions (per-bank on EVERY banked format: 16KB banks for NES/GB/SMS-GG/MSX/
373
+ 7800-SuperGame, 32KB for SNES LoROM, 4KB for banked 2600, 8KB pages for >32KB HuCards;
374
+ one flat region for Genesis/C64/Lynx/GBA and small carts), disassembles each through the CPU's
335
375
  native objdump, then **reassembles + verifies byte-exact** against the original; any line
336
376
  that won't reproduce faithfully heals to a `.byte`/`db` of its real bytes, so the emitted
337
377
  `.asm` ALWAYS rebuilds (`roundTrip.allByteExact`). `readablePercent` per region tells you
@@ -343,22 +383,27 @@ rebuild exists — a `rebuild.json` of the precise `build({...})` args. So the l
343
383
 
344
384
  **Two rebuild tiers** (the disasm emits each CPU's native-reassembler syntax — ca65 for
345
385
  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`.)
386
+ - **One-call `build()` rebuild, byte-identical** — **NES (NROM *and* banked mappers), C64,
387
+ Atari 7800 (flat *and* SuperGame banked), Lynx, PC Engine (flat *and* banked HuCards)**.
388
+ Feed `rebuild.json` straight to `build`. Banked projects ship a HEADER segment with the
389
+ original header bytes (16 iNES / 128 .a78 / 512 copier), per-bank segment wrappers, and a
390
+ generated multi-bank `.cfg` referenced via `linkerConfigPath` (so the cfg never streams
391
+ through context). (Lynx: `build()` yields the headerless image; prepend the shipped
392
+ `lnx_header.bin` for the full `.lnx`.)
349
393
  - **Native-recipe rebuild (`buildCall:null`), byte-identical, steps in `BUILD.md`** — **SMS,
350
394
  GG, MSX, GB, GBC, Genesis, GBA, Atari 2600**. Their `build()` toolchains (SDCC/RGBDS/asar/
351
395
  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.
396
+ `as`/`ld`/`objcopy` chain — per-bank on banked carts (Sega-mapper SMS/GG, MSX megaROMs,
397
+ banked 2600 get per-bank wrappers + cfg blobs and a bank-by-bank recipe).
355
398
 
356
399
  **Rebuilding a commercial NES (NROM CHR-ROM) game — `build({inesHeader})`:** the most common
357
400
  NES RE rebuild. `build({output:'rom', platform:'nes', inesHeader:{prgBanks, chrBanks, mapper,
358
401
  mirroring}, sourcesPaths:{…the PRG…}, binaryIncludePaths:{"chr.bin":…}})` auto-emits the
359
402
  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.)
403
+ `disasm({target:'project'})` puts exactly this call in `rebuild.json` for NROM; banked
404
+ mappers get the per-bank segment + multi-bank `.cfg` form instead (see the one-call tier
405
+ above). (For homebrew C that ships fixed tile art, `linkerConfig:"chr-rom"` is the
406
+ segment-split equivalent.)
362
407
 
363
408
  **Readability caveats** (the bytes are ALWAYS correct; only instruction-vs-`.byte` coverage
364
409
  varies): SNES and large Genesis ROMs come back byte-exact but DATA-ONLY (flat whole-ROM
@@ -398,6 +443,7 @@ For sprite/tile edits (not text), don't hand-roll the tile-format math:
398
443
  |---|---|
399
444
  | Find a value's address | `memory({op:'search'})` → `memory({op:'searchNext'})` (NOT full-RAM diff) |
400
445
  | Which bytes did one event touch | `memory({op:'snapshot'})` → `memory({op:'diff'})` (summary) |
446
+ | Which byte does an INPUT drive | `memory({op:'diffRuns', portsA, portsB?})` (A/B divergence, one call) |
401
447
  | Is on-screen text a string or a bitmap | `text({op:'learn'})` (reports pre-rendered graphic) |
402
448
  | Is a "table" really ASCII/code | `memory({op:'classify'})` |
403
449
  | Confirm a patch is in the running ROM | `memory({op:'readCart'})` |
@@ -406,7 +452,8 @@ For sprite/tile edits (not text), don't hand-roll the tile-format math:
406
452
  | Which instruction READ a byte | `breakpoint({on:'read', address})` (read-side `breakpoint({on:'write'})`) |
407
453
  | Single-step the CPU | `frame({op:'stepInstruction'})` (+ `cpu({op:'read'})` to watch regs) |
408
454
  | Set a CPU register | `cpu({op:'setReg', regId, value})` |
409
- | Decompress a compressed asset | `cpu({op:'decompress'})` / `cpu({op:'call'})` (run the ROM's own codec) |
455
+ | Decompress a compressed asset | `cpu({op:'decompress'})` / `cpu({op:'call', pure:true})` (run the ROM's own codec, interference-free) |
456
+ | Where does this on-screen graphic come from | `watch({on:'copy', start, end})` (all 14 — writer PC per VRAM write; Genesis DMA also via `watch({on:'dma'})`) |
410
457
  | Re-inject edited bytes the game accepts | `romPatch({op:'makeStored'})` (verbatim-expand block) → `romPatch({op:'findFree'})` → `romPatch({op:'relocate'})` |
411
458
  | Find the pointer that loads an asset | `romPatch({op:'findPointer', romOffset})` |
412
459
  | FIND the unknown routine touching X | `watch({on:'range', start,end})` (all hits) / `watch({on:'pc'})` (coverage) |
@@ -215,7 +215,11 @@ What you can read:
215
215
  registers.
216
216
  - **`disasm({target:'rom'})`** and **`disasm({target:'references'})`** —
217
217
  both anchor to the top of the bank (`$F000-$FFFF`) and label the vector
218
- table (NMI / RESET / IRQ at `$FFFA`).
218
+ table (NMI / RESET / IRQ at `$FFFA`). On banked carts (F8 = 8 KB,
219
+ F6 = 16 KB, F4 = 32 KB) `references` scans EVERY 4 KB bank at `$F000`,
220
+ refs tagged `romBank`; `disasm({target:'project'})` likewise emits one
221
+ region per bank plus per-bank `BANKn` wrappers and a multi-area `.cfg`
222
+ blob for the native ca65/ld65 rebuild.
219
223
 
220
224
  Memory regions for **`memory({op:'read'})`**:
221
225
 
@@ -159,3 +159,43 @@ snippet for one approach.
159
159
  ## "First build is slow but later ones are fast"
160
160
 
161
161
  Expected. dasm cold-load is ~500ms. Steady-state builds < 100ms.
162
+
163
+
164
+ ## Pressing RIGHT also "presses" LEFT (or the player can't move at all)
165
+
166
+ The classic `LDA SWCHA / ASL / BCS … / ASL / BCS …` carry-chain only works if
167
+ NOTHING between the shifts touches A. The moment a branch body does
168
+ `LDA P_X` (a bounds check, a compare), the next `ASL` shifts your *position*
169
+ instead of SWCHA — and since positions are < $80, carry comes back clear and
170
+ the "other direction" fires too. Net effect: moves cancel, the sprite sticks
171
+ to one edge. **Re-load SWCHA and AND a single bit per direction instead:**
172
+
173
+ ```asm
174
+ LDA SWCHA
175
+ AND #$80 ; bit7 = P0 Right (active LOW: 0 = pressed)
176
+ BNE .noRight
177
+ ...move right (clobber A freely)...
178
+ .noRight:
179
+ LDA SWCHA
180
+ AND #$40 ; bit6 = P0 Left
181
+ BNE .noLeft
182
+ ...
183
+ ```
184
+
185
+ ## Jump plays its sound but the player never leaves the ground
186
+
187
+ Signed-velocity clamps must check the SIGN first. An unsigned
188
+ `CMP #$F8 / BCS keep` "terminal velocity" clamp also catches every POSITIVE
189
+ (rising) velocity — +6 is less than $F8 unsigned — so the jump impulse is
190
+ instantly slammed to falling and the whole arc resolves inside one frame
191
+ (SFX plays, screen blips, no visible jump). Clamp only while falling:
192
+
193
+ ```asm
194
+ LDA P_VY
195
+ BPL .vyok ; rising → terminal clamp doesn't apply
196
+ CMP #$F8
197
+ BCS .vyok ; -8..-1 → fine
198
+ LDA #$F8 ; clamp to -8
199
+ STA P_VY
200
+ .vyok:
201
+ ```
@@ -237,10 +237,23 @@ read:
237
237
 
238
238
  ```c
239
239
  uint8_t pad = ~SWCHA;
240
- if (pad & JOY_UP) /* P1 up */
241
- if (pad & JOY_DOWN) /* P1 down */
242
- if (pad & JOY_LEFT) /* P1 left */
243
240
  if (pad & JOY_RIGHT) /* P1 right */
241
+ if (pad & JOY_LEFT) /* P1 left */
242
+ if (pad & JOY_DOWN) /* P1 down */
243
+ if (pad & JOY_UP) /* P1 up */
244
+ ```
245
+
246
+ **The bit order is the #1 7800 input footgun.** From bit 7 down the P1 nibble
247
+ is **Right ($80), Left ($40), Down ($20), Up ($10)** — same as the 2600. Defining
248
+ `JOY_UP 0x80 … JOY_RIGHT 0x10` (the "reads naturally" order) is exactly
249
+ REVERSED, and the symptom is bizarre enough to misdiagnose: up/down steer
250
+ left/right and vice versa. Always:
251
+
252
+ ```c
253
+ #define JOY_RIGHT 0x80
254
+ #define JOY_LEFT 0x40
255
+ #define JOY_DOWN 0x20
256
+ #define JOY_UP 0x10
244
257
  ```
245
258
 
246
259
  Fire button on `INPT4` at `$0C`, also active low.
@@ -343,9 +356,17 @@ What you can read:
343
356
  P / SP / PC) read from prosystem's `sally` globals.
344
357
  - **`background({view:'renderState'})`** — the MARIA CTRL bits, DPP,
345
358
  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.
359
+ - **`disasm({target:'rom'})`** defaults to the top 16 KB
360
+ (`$C000-$FFFF`), where the reset vector lands.
361
+ - **`disasm({target:'references'})`** — scans the WHOLE cart: flat carts
362
+ (≤48 KB) in one pass at their top-of-space org, SuperGame banked carts
363
+ (>48 KB) per 16 KB bank (last bank fixed at `$C000`, others at `$8000`),
364
+ refs tagged `romBank`. A 128-byte `.a78` header is stripped automatically.
365
+ - **`disasm({target:'project'})`** — flat carts rebuild with one flat cc65
366
+ build; SuperGame carts get per-bank regions + NES-style glue (HEADER
367
+ segment with the original 128 header bytes, `BANKn` wrappers, multi-bank
368
+ `.cfg` via `linkerConfigPath`) — a one-call byte-identical
369
+ `build()` rebuild either way.
349
370
 
350
371
  Memory regions for **`memory({op:'read'})`**:
351
372
 
@@ -68,7 +68,10 @@ returns nothing.
68
68
  `disasm({target:'project'})` route through the native binutils z80 `objdump` in
69
69
  its `gbz80` machine (WASM, `-m gbz80`) — full CB-prefix coverage plus the
70
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.
71
+ binutils serves both plain Z80 (SMS/GG/MSX) and the GB CPU. MBC-banked carts
72
+ (>32 KB) are scanned per 16 KB bank by `references` (bank 0 @ `$0000`, banks
73
+ 1+ @ their `$4000` window; refs tagged `romBank`) and split per-bank by
74
+ `disasm({target:'project'})`.
72
75
 
73
76
  ## Five silent-failure footguns to know before you start (R26 + R27)
74
77
 
@@ -103,6 +106,18 @@ check these first. All five have shipped fixes in the bundled runtime
103
106
  (volatile-safe by construction) or cast through `volatile uint8_t *`.
104
107
  See `gb_runtime/lib/c/SDCC_GOTCHAS.md` § "Writes to VRAM" for detail.
105
108
 
109
+ 3b. **OAM DMA goes FIRST after `wait_vblank()` — before any staging work.**
110
+ The vblank window is ~10 scanlines (~1140 cycles) and SDCC call overhead
111
+ is brutal: even a few dozen `oam_set()` CALLS before the flush push the
112
+ DMA out of vblank into active display, where it tears the sprites on one
113
+ FIXED scanline every frame (the "horizontal line a third of the way down"
114
+ glitch). The robust frame shape is: stage OAM/BG writes into RAM any time
115
+ during the frame, then `wait_vblank(); oam_dma_flush();` as the very first
116
+ thing, then a small bounded batch of BG map writes. One frame of sprite
117
+ latency is imperceptible. Also: statics belong at `dataLoc 0xC200` or
118
+ above (the project recipe sets this) so they can't collide with
119
+ `shadow_oam` at $C100.
120
+
106
121
  4. **OAM DMA must run from HRAM.** During the ~160 µs OAM DMA window
107
122
  the CPU can ONLY fetch from HRAM ($FF80-$FFFE). The bundled
108
123
  `oam_dma_copy()` now installs a 9-byte stub at $FF80 and CALLs it;
@@ -166,6 +166,48 @@ at $0150. If you see code in that window, either:
166
166
  works; don't override it for GB/GBC unless you know what you're
167
167
  doing.
168
168
 
169
+ ## "BG map updates randomly don't stick" / a tile updates one frame late forever
170
+
171
+ The core (like real hardware mid-frame) DROPS writes to VRAM ($8000-$9FFF)
172
+ that land outside vblank while the LCD is on — silently. A game loop that
173
+ pokes the BG map "whenever the state changes" will have SOME of those pokes
174
+ land mid-frame and vanish: stale cells, a piece that visually lags the
175
+ logical grid, glitches that move around as code timing shifts.
176
+
177
+ The robust pattern (used by the bundled puzzle scaffolds):
178
+
179
+ 1. **COLLECT** — during the frame, don't touch VRAM. Append (addr, tile)
180
+ pairs to a small RAM queue whenever game state changes a cell.
181
+ 2. **FLUSH** — immediately after `wait_vblank()` (right after the OAM DMA),
182
+ drain the queue with pure writes. No scanning, no logic — vblank is only
183
+ ~1140 cycles, so the flush must be writes only and bounded.
184
+ 3. **Scrub** — repaint one or two rows per frame round-robin as insurance,
185
+ so any cell that ever got dropped self-heals within a second.
186
+
187
+ If you must write outside that structure, turn the LCD off first (only
188
+ acceptable during init/load screens — mid-game it flashes white).
189
+
190
+ ## "BG map updates randomly don't stick" / a tile updates one frame late forever
191
+
192
+ The core (like real hardware mid-frame) DROPS writes to VRAM ($8000-$9FFF)
193
+ that land outside vblank while the LCD is on — silently. A game loop that
194
+ pokes the BG map "whenever the state changes" will have SOME of those pokes
195
+ land mid-frame and vanish: stale cells, a piece that visually lags the
196
+ logical grid, glitches that move around as code timing shifts.
197
+
198
+ The robust pattern (used by the bundled puzzle scaffolds):
199
+
200
+ 1. **COLLECT** — during the frame, don't touch VRAM. Append (addr, tile)
201
+ pairs to a small RAM queue whenever game state changes a cell.
202
+ 2. **FLUSH** — immediately after `wait_vblank()` (right after the OAM DMA),
203
+ drain the queue with pure writes. No scanning, no logic — vblank is only
204
+ ~1140 cycles, so the flush must be writes only and bounded.
205
+ 3. **Scrub** — repaint one or two rows per frame round-robin as insurance,
206
+ so any cell that ever got dropped self-heals within a second.
207
+
208
+ If you must write outside that structure, turn the LCD off first (only
209
+ acceptable during init/load screens — mid-game it flashes white).
210
+
169
211
  ## Debug recipes
170
212
 
171
213
  A few high-leverage tools you might not know exist:
@@ -7,10 +7,13 @@
7
7
  //
8
8
  // WHY: most libretro GB cores (gambatte) refuse to load a ROM whose
9
9
  // Nintendo logo at $0104-$0133 doesn't match the canonical bytes or
10
- // whose header checksum at $014D doesn't validate. RGBDS's `rgbfix`
11
- // does this in the asm-build path; for SDCC-built C ROMs (which our
12
- // pipeline does NOT auto-patch every byte that compiles is yours),
13
- // this script does the same job.
10
+ // whose header checksum at $014D doesn't validate.
11
+ //
12
+ // NOTE: romdev's own build pipeline DOES auto-patch the header now (it
13
+ // runs a bundled rgbfix after every gb/gbc link — see the
14
+ // "rgbfix (auto header fix)" line in build logs), so you only need this
15
+ // script when rebuilding the project OUTSIDE romdev with stock SDCC and
16
+ // no RGBDS installed. It's what keeps the scaffold self-contained.
14
17
  //
15
18
  // The bundled gb_crt0.s reserves $0100-$014F for the header window,
16
19
  // so the bytes patched in here land on actual cartridge-header
@@ -31,6 +31,18 @@ the same wall.
31
31
  cast through `volatile uint8_t *`. See `lib/c/SDCC_GOTCHAS.md`
32
32
  § "Writes to VRAM".
33
33
 
34
+ 3b. **OAM DMA goes FIRST after `wait_vblank()` — before any staging work.**
35
+ The vblank window is ~10 scanlines (~1140 cycles) and SDCC call overhead
36
+ is brutal: even a few dozen `oam_set()` CALLS before the flush push the
37
+ DMA out of vblank into active display, where it tears the sprites on one
38
+ FIXED scanline every frame (the "horizontal line a third of the way down"
39
+ glitch). The robust frame shape is: stage OAM/BG writes into RAM any time
40
+ during the frame, then `wait_vblank(); oam_dma_flush();` as the very first
41
+ thing, then a small bounded batch of BG map writes. One frame of sprite
42
+ latency is imperceptible. Also: statics belong at `dataLoc 0xC200` or
43
+ above (the project recipe sets this) so they can't collide with
44
+ `shadow_oam` at $C100.
45
+
34
46
  4. **OAM DMA must run from HRAM.** During the ~160 µs OAM DMA window
35
47
  the CPU can ONLY fetch from HRAM ($FF80-$FFFE). The bundled
36
48
  `oam_dma_copy()` installs a 9-byte stub at $FF80 and CALLs it; the
@@ -120,6 +120,27 @@ in DMG mode. To switch a DMG ROM to CGB:
120
120
  2. Run `romPatch({op:'gbHeader', path:"out.gbc"})` — also fixes the global
121
121
  checksum that the boot ROM checks
122
122
 
123
+ ## "BG map updates randomly don't stick" / a tile updates one frame late forever
124
+
125
+ The core (like real hardware mid-frame) DROPS writes to VRAM ($8000-$9FFF)
126
+ that land outside vblank while the LCD is on — silently. A game loop that
127
+ pokes the BG map "whenever the state changes" will have SOME of those pokes
128
+ land mid-frame and vanish: stale cells, a piece that visually lags the
129
+ logical grid, glitches that move around as code timing shifts.
130
+
131
+ The robust pattern (used by the bundled puzzle scaffolds):
132
+
133
+ 1. **COLLECT** — during the frame, don't touch VRAM. Append (addr, tile)
134
+ pairs to a small RAM queue whenever game state changes a cell.
135
+ 2. **FLUSH** — immediately after `wait_vblank()` (right after the OAM DMA),
136
+ drain the queue with pure writes. No scanning, no logic — vblank is only
137
+ ~1140 cycles, so the flush must be writes only and bounded.
138
+ 3. **Scrub** — repaint one or two rows per frame round-robin as insurance,
139
+ so any cell that ever got dropped self-heals within a second.
140
+
141
+ If you must write outside that structure, turn the LCD off first (only
142
+ acceptable during init/load screens — mid-game it flashes white).
143
+
123
144
  ## "Sound is the same as DMG"
124
145
 
125
146
  That's correct — CGB has the **identical** 4-channel APU as DMG. The
@@ -0,0 +1,43 @@
1
+ /* AUTO-GENERATED by gen_font.py — 5x7 font, GB 2bpp, ink=value 3. */
2
+ #ifndef FONT_H
3
+ #define FONT_H
4
+ #define FONT_GLYPHS 36
5
+ static const uint8_t font_data[576] = {
6
+ 0x38, 0x38, 0x44, 0x44, 0x4C, 0x4C, 0x54, 0x54, 0x64, 0x64, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* 0 */
7
+ 0x10, 0x10, 0x30, 0x30, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x38, 0x38, 0x00, 0x00, /* 1 */
8
+ 0x38, 0x38, 0x44, 0x44, 0x04, 0x04, 0x08, 0x08, 0x10, 0x10, 0x20, 0x20, 0x7C, 0x7C, 0x00, 0x00, /* 2 */
9
+ 0x7C, 0x7C, 0x08, 0x08, 0x10, 0x10, 0x08, 0x08, 0x04, 0x04, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* 3 */
10
+ 0x08, 0x08, 0x18, 0x18, 0x28, 0x28, 0x48, 0x48, 0x7C, 0x7C, 0x08, 0x08, 0x08, 0x08, 0x00, 0x00, /* 4 */
11
+ 0x7C, 0x7C, 0x40, 0x40, 0x78, 0x78, 0x04, 0x04, 0x04, 0x04, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* 5 */
12
+ 0x18, 0x18, 0x20, 0x20, 0x40, 0x40, 0x78, 0x78, 0x44, 0x44, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* 6 */
13
+ 0x7C, 0x7C, 0x04, 0x04, 0x08, 0x08, 0x10, 0x10, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x00, /* 7 */
14
+ 0x38, 0x38, 0x44, 0x44, 0x44, 0x44, 0x38, 0x38, 0x44, 0x44, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* 8 */
15
+ 0x38, 0x38, 0x44, 0x44, 0x44, 0x44, 0x3C, 0x3C, 0x04, 0x04, 0x08, 0x08, 0x30, 0x30, 0x00, 0x00, /* 9 */
16
+ 0x38, 0x38, 0x44, 0x44, 0x44, 0x44, 0x7C, 0x7C, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x00, 0x00, /* A */
17
+ 0x78, 0x78, 0x44, 0x44, 0x44, 0x44, 0x78, 0x78, 0x44, 0x44, 0x44, 0x44, 0x78, 0x78, 0x00, 0x00, /* B */
18
+ 0x38, 0x38, 0x44, 0x44, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* C */
19
+ 0x70, 0x70, 0x48, 0x48, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x48, 0x48, 0x70, 0x70, 0x00, 0x00, /* D */
20
+ 0x7C, 0x7C, 0x40, 0x40, 0x40, 0x40, 0x78, 0x78, 0x40, 0x40, 0x40, 0x40, 0x7C, 0x7C, 0x00, 0x00, /* E */
21
+ 0x7C, 0x7C, 0x40, 0x40, 0x40, 0x40, 0x78, 0x78, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x00, 0x00, /* F */
22
+ 0x38, 0x38, 0x44, 0x44, 0x40, 0x40, 0x5C, 0x5C, 0x44, 0x44, 0x44, 0x44, 0x3C, 0x3C, 0x00, 0x00, /* G */
23
+ 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x7C, 0x7C, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x00, 0x00, /* H */
24
+ 0x38, 0x38, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x38, 0x38, 0x00, 0x00, /* I */
25
+ 0x1C, 0x1C, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x48, 0x48, 0x30, 0x30, 0x00, 0x00, /* J */
26
+ 0x44, 0x44, 0x48, 0x48, 0x50, 0x50, 0x60, 0x60, 0x50, 0x50, 0x48, 0x48, 0x44, 0x44, 0x00, 0x00, /* K */
27
+ 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x7C, 0x7C, 0x00, 0x00, /* L */
28
+ 0x44, 0x44, 0x6C, 0x6C, 0x54, 0x54, 0x54, 0x54, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x00, 0x00, /* M */
29
+ 0x44, 0x44, 0x44, 0x44, 0x64, 0x64, 0x54, 0x54, 0x4C, 0x4C, 0x44, 0x44, 0x44, 0x44, 0x00, 0x00, /* N */
30
+ 0x38, 0x38, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* O */
31
+ 0x78, 0x78, 0x44, 0x44, 0x44, 0x44, 0x78, 0x78, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x00, 0x00, /* P */
32
+ 0x38, 0x38, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x54, 0x54, 0x48, 0x48, 0x34, 0x34, 0x00, 0x00, /* Q */
33
+ 0x78, 0x78, 0x44, 0x44, 0x44, 0x44, 0x78, 0x78, 0x50, 0x50, 0x48, 0x48, 0x44, 0x44, 0x00, 0x00, /* R */
34
+ 0x3C, 0x3C, 0x40, 0x40, 0x40, 0x40, 0x38, 0x38, 0x04, 0x04, 0x04, 0x04, 0x78, 0x78, 0x00, 0x00, /* S */
35
+ 0x7C, 0x7C, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x00, 0x00, /* T */
36
+ 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x38, 0x38, 0x00, 0x00, /* U */
37
+ 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x28, 0x28, 0x10, 0x10, 0x00, 0x00, /* V */
38
+ 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x54, 0x54, 0x54, 0x54, 0x6C, 0x6C, 0x44, 0x44, 0x00, 0x00, /* W */
39
+ 0x44, 0x44, 0x44, 0x44, 0x28, 0x28, 0x10, 0x10, 0x28, 0x28, 0x44, 0x44, 0x44, 0x44, 0x00, 0x00, /* X */
40
+ 0x44, 0x44, 0x44, 0x44, 0x28, 0x28, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x00, 0x00, /* Y */
41
+ 0x7C, 0x7C, 0x04, 0x04, 0x08, 0x08, 0x10, 0x10, 0x20, 0x20, 0x40, 0x40, 0x7C, 0x7C, 0x00, 0x00, /* Z */
42
+ };
43
+ #endif
@@ -7,10 +7,13 @@
7
7
  //
8
8
  // WHY: most libretro GB cores (gambatte) refuse to load a ROM whose
9
9
  // Nintendo logo at $0104-$0133 doesn't match the canonical bytes or
10
- // whose header checksum at $014D doesn't validate. RGBDS's `rgbfix`
11
- // does this in the asm-build path; for SDCC-built C ROMs (which our
12
- // pipeline does NOT auto-patch every byte that compiles is yours),
13
- // this script does the same job.
10
+ // whose header checksum at $014D doesn't validate.
11
+ //
12
+ // NOTE: romdev's own build pipeline DOES auto-patch the header now (it
13
+ // runs a bundled rgbfix after every gb/gbc link — see the
14
+ // "rgbfix (auto header fix)" line in build logs), so you only need this
15
+ // script when rebuilding the project OUTSIDE romdev with stock SDCC and
16
+ // no RGBDS installed. It's what keeps the scaffold self-contained.
14
17
  //
15
18
  // The bundled gb_crt0.s reserves $0100-$014F for the header window,
16
19
  // so the bytes patched in here land on actual cartridge-header
@@ -50,9 +50,13 @@ memory({op:'read', region:'system_ram', offset: sym.ramOffset, length:2})
50
50
  - **`static` file-local globals resolve too** (SGDK emits per-symbol sections).
51
51
  A non-`static` global that's never *read* can be optimised away at -O2 — mark
52
52
  game-state vars you inspect `volatile` (you want that anyway).
53
- - **Genesis WRAM is host-LE word-byte-swapped** in gpgx, so a 16-bit value reads
54
- with its two bytes swapped at the offset (0x1234 bytes `34 12`). Read the
55
- word and account for it, or read single bytes.
53
+ - **WRAM (`system_ram`) is normalized to CPU byte order** offset X is the
54
+ byte the 68k sees at $FF0000+X, words read big-endian as expected, and
55
+ offsets line up with disassembly addresses and cheat-DB maps. (gpgx stores
56
+ work RAM host-LE word-swapped internally; the host un-swaps it. Before
57
+ 0.28.0 the raw swapped layout leaked through — value-search/diff loops were
58
+ self-consistent, but any offset cross-referenced against a `move.b $FFxxxx`
59
+ in a disassembly was off-by-XOR-1.)
56
60
  - **PC → which function?** `symbols({op:'addr', pc, symbolsText: b.mapText})` maps
57
61
  a live `cpu({op:'read'}).pc` to the enclosing C function.
58
62
 
@@ -427,10 +431,40 @@ Video is deeply readable; the FM audio chip is only partially exposed:
427
431
  `getPsgState` decodes the SN76489 (3 tone + 1 noise channels).
428
432
  - **Memory regions:** `memory({op:'read'})` exposes CRAM, VSRAM, VDP_REGS,
429
433
  Z80_RAM (the sound CPU's RAM), M68K work RAM, YM2612, PSG, and VRAM.
430
- Remember the gpgx byte-swap quirk: VRAM and WRAM read host-LE
434
+ Remember the gpgx byte-swap quirk for VRAM: it reads host-LE
431
435
  word-byte-swapped (a 16-bit value's two bytes are swapped at the offset)
432
- account for it or read single bytes (see "Reading your C globals
433
- headlessly").
436
+ use tiles({op:'pixels'}) to decode in render order. M68K work RAM
437
+ (`system_ram`) is NOT affected: it's normalized to CPU byte order (see
438
+ "Reading your C globals headlessly").
439
+
440
+ ## Break-instant truth: registersAtHit + pure calls (0.28.0)
441
+
442
+ gpgx schedules its CPUs per scanline, so a `breakpoint` hit mid-frame used to
443
+ leave the LIVE register file hundreds of instructions past the hit by the time
444
+ you could read it — chasing pointer registers read that way burned a real
445
+ session for ~2h. Fixed two ways:
446
+
447
+ - **`registersAtHit`** — `breakpoint({on:'pc'|'write'|'read'})` hits now carry
448
+ the FULL register file (d0-d7/a0-a7/pc/sr/sp) frozen by the core at the hit
449
+ instant. Use it, never a follow-up `cpu({op:'read'})`. The reported `pc` for
450
+ write/read hits is the EXECUTING instruction's first byte (pre-0.28.0 it was
451
+ the post-prefetch PC — one instruction late). On a pc-break the 68k also
452
+ stays FROZEN for the rest of the frame, so even live reads agree.
453
+ - **`cpu({op:'call', pure:true})`** — steps ONLY the 68k: no VDP lines, no
454
+ Z80, no interrupts raised. Without it, a driven routine that spans frames
455
+ runs the game's own VBlank logic concurrently — which can stomp the output
456
+ buffer you're capturing (a real session diffed a CORRECT codec
457
+ reimplementation against that poisoned "ground truth" for hours). Prefer
458
+ `pure:true` for every decompressor/codec call; non-pure results carry a ⚠
459
+ caveat when frame logic ran. (SMS/GG get the same via the shared core; the
460
+ OTHER platforms get the same guarantee via interrupt blocking —
461
+ `pureMode:'irq-blocked'` — so the technique transfers everywhere.)
462
+ - **`watch({on:'copy'})`** — the CPU-port complement of `watch({on:'dma'})`:
463
+ logs every data-port write landing in a VRAM window with the executing
464
+ instruction's PC. Use `dma` when the upload is DMA'd (most Genesis
465
+ graphics), `copy` when the game pokes the data port directly (the
466
+ "video_ram writes don't reach the renderer" class of confusion — `copy`
467
+ shows you who's writing and where).
434
468
 
435
469
  ## ROM layout
436
470
 
@@ -32,6 +32,42 @@ void sfx_noise(u8 length_frames) {
32
32
  sfx_remaining[3] = length_frames;
33
33
  }
34
34
 
35
+ /* ── background music: a 16-step melody loop on PSG channel 2 ───────
36
+ * Ticked from sfx_update(), so every scaffold that already calls
37
+ * sfx_init() + sfx_update() gets continuous music for free ("no sound"
38
+ * was the #1 playtest complaint — a lone 6-frame blip on a rare event
39
+ * reads as silence). sfx_music(0) turns it off. SFX own channels 0-1 +
40
+ * noise, so effects always cut through. */
41
+ static const u16 music_hz[16] = {
42
+ 262, 330, 392, 523, 392, 330, 262, 0, /* C4 E4 G4 C5 G4 E4 C4 - */
43
+ 220, 262, 330, 440, 330, 262, 220, 0, /* A3 C4 E4 A4 E4 C4 A3 - */
44
+ };
45
+ static u8 music_enabled = 1;
46
+ static u8 music_step, music_timer;
47
+
48
+ void sfx_music(u8 on) {
49
+ music_enabled = on;
50
+ music_step = 0;
51
+ music_timer = 0;
52
+ if (!on) PSG_setEnvelope(2, PSG_ENVELOPE_MIN);
53
+ }
54
+
55
+ static void music_tick(void) {
56
+ if (!music_enabled) return;
57
+ if (music_timer == 0) {
58
+ u16 hz = music_hz[music_step & 15];
59
+ if (hz) {
60
+ PSG_setFrequency(2, hz);
61
+ PSG_setEnvelope(2, 5); /* moderate, under the SFX */
62
+ } else {
63
+ PSG_setEnvelope(2, PSG_ENVELOPE_MIN);
64
+ }
65
+ music_step++;
66
+ }
67
+ music_timer++;
68
+ if (music_timer >= 9) music_timer = 0; /* ~6.6 notes/sec */
69
+ }
70
+
35
71
  void sfx_update(void) {
36
72
  for (u8 i = 0; i < 4; i++) {
37
73
  if (sfx_remaining[i] > 0) {
@@ -41,6 +77,7 @@ void sfx_update(void) {
41
77
  }
42
78
  }
43
79
  }
80
+ music_tick();
44
81
  }
45
82
 
46
83
  void sfx_off(void) {
@@ -42,6 +42,7 @@ void sfx_noise(u8 length_frames);
42
42
  * decrement the auto-silence countdown. Without this, notes never
43
43
  * stop ringing. */
44
44
  void sfx_update(void);
45
+ void sfx_music(u8 on); /* background melody loop on PSG ch2 — ON by default; 0 = off */
45
46
 
46
47
  /* Power down all PSG channels immediately. */
47
48
  void sfx_off(void);
@@ -92,20 +92,16 @@ the P2 read + force `p2 = 0` so the AI fallback always engages.
92
92
  The bundled GG `sports.c` already does this — copy that pattern when
93
93
  porting other SMS multiplayer code.
94
94
 
95
- ## "Build errors mention 'TMR SEGA' or ROM header"
96
-
97
- Same magic as SMS gpgx accepts headerless ROMs fine for development.
98
- For real-hardware ROM-burning include a header at $7FF0:
99
-
100
- ```
101
- db "TMR SEGA"
102
- dw 0 ; reserved
103
- dw 0 ; checksum (gpgx ignores)
104
- db 0x00, 0x00, 0x00 ; product code BCD
105
- db 0x00 ; product code high + version
106
- db 0x40 ; region (0x40 = GG)
107
- db 0x4C ; ROM size (0x4C = 32 KB)
108
- ```
109
-
110
- The bundled scaffolds build without a header — sufficient for the
111
- emulator-driven workflow. Add one before shipping to a cartridge.
95
+ ## "TMR SEGA" header / ROM boots in the wrong video mode
96
+
97
+ The build pipeline now stamps the 16-byte header at `$7FF0` automatically
98
+ ("TMR SEGA" + checksum + the region/size byte at `$7FFF`) and pads every
99
+ image to 32 KB — you never hand-write it for romdev builds.
100
+
101
+ The byte that matters is `$7FFF`: **high nibble = region, low nibble = ROM
102
+ size**. romdev writes `$7C` (GG international, 32 KB) on `.gg` builds.
103
+ If you patch a ROM by hand and leave an SMS region nibble there (`$4C` =
104
+ SMS export), gpgx boots the `.gg` file in **SMS compatibility mode** —
105
+ 256×192 timing, SMS palette depth and everything renders dark and
106
+ mis-cropped even though your code is fine. Check `$7FFF` first when a GG
107
+ ROM suddenly looks like an SMS ROM.