romdevtools 0.16.0 → 0.22.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 (209) hide show
  1. package/AGENTS.md +75 -16
  2. package/CHANGELOG.md +316 -0
  3. package/examples/README.md +2 -0
  4. package/examples/atari2600/templates/platformer.asm +460 -0
  5. package/examples/atari2600/templates/racing.asm +463 -0
  6. package/examples/atari2600/templates/shmup.asm +386 -0
  7. package/examples/atari2600/templates/sports.asm +362 -0
  8. package/examples/atari7800/templates/default.c +49 -5
  9. package/examples/atari7800/templates/hello_sprite.c +48 -4
  10. package/examples/atari7800/templates/music_demo.c +47 -2
  11. package/examples/atari7800/templates/platformer.c +43 -4
  12. package/examples/atari7800/templates/puzzle.c +39 -4
  13. package/examples/atari7800/templates/racing.c +39 -4
  14. package/examples/atari7800/templates/shmup.c +40 -2
  15. package/examples/atari7800/templates/sports.c +36 -5
  16. package/examples/c64/templates/platformer.c +19 -5
  17. package/examples/c64/templates/puzzle.c +32 -2
  18. package/examples/c64/templates/shmup.c +28 -2
  19. package/examples/c64/templates/sports.c +30 -2
  20. package/examples/c64/templates/tile_engine.c +77 -27
  21. package/examples/gb/templates/default.c +110 -16
  22. package/examples/gb/templates/hello_sprite.c +15 -6
  23. package/examples/gb/templates/music_demo.c +36 -0
  24. package/examples/gb/templates/platformer.c +28 -6
  25. package/examples/gb/templates/puzzle.c +35 -4
  26. package/examples/gb/templates/racing.c +75 -10
  27. package/examples/gb/templates/shmup.c +41 -3
  28. package/examples/gb/templates/sports.c +51 -3
  29. package/examples/gb/templates/tile_engine.c +3 -2
  30. package/examples/gba/templates/gba_hello.c +29 -11
  31. package/examples/gba/templates/maxmod_demo.c +36 -2
  32. package/examples/gba/templates/platformer.c +3 -1
  33. package/examples/gba/templates/puzzle.c +15 -3
  34. package/examples/gba/templates/racing.c +65 -3
  35. package/examples/gba/templates/shmup.c +41 -4
  36. package/examples/gba/templates/sports.c +36 -2
  37. package/examples/gba/templates/tonc_hello.c +41 -5
  38. package/examples/gba/templates/tonc_hello_sprite.c +35 -1
  39. package/examples/gbc/templates/default.c +103 -26
  40. package/examples/gbc/templates/hello_sprite.c +12 -3
  41. package/examples/gbc/templates/music_demo.c +56 -12
  42. package/examples/gbc/templates/platformer.c +28 -6
  43. package/examples/gbc/templates/puzzle.c +35 -4
  44. package/examples/gbc/templates/racing.c +88 -21
  45. package/examples/gbc/templates/shmup.c +37 -3
  46. package/examples/gbc/templates/sports.c +48 -3
  47. package/examples/gbc/templates/tile_engine.c +3 -2
  48. package/examples/genesis/main.s +53 -1
  49. package/examples/genesis/templates/hello_sprite.c +25 -3
  50. package/examples/genesis/templates/puzzle.c +37 -3
  51. package/examples/genesis/templates/racing.c +44 -11
  52. package/examples/genesis/templates/sgdk_hello.c +34 -1
  53. package/examples/genesis/templates/shmup.c +31 -1
  54. package/examples/genesis/templates/shmup_2p.c +31 -0
  55. package/examples/genesis/templates/xgm2_demo.c +20 -0
  56. package/examples/gg/templates/default.c +56 -18
  57. package/examples/gg/templates/hello_sprite.c +25 -2
  58. package/examples/gg/templates/music_demo.c +24 -2
  59. package/examples/gg/templates/platformer.c +18 -12
  60. package/examples/gg/templates/puzzle.c +38 -7
  61. package/examples/gg/templates/racing.c +58 -9
  62. package/examples/gg/templates/shmup.c +47 -3
  63. package/examples/gg/templates/sports.c +57 -16
  64. package/examples/gg/templates/tile_engine.c +12 -6
  65. package/examples/lynx/templates/default.c +39 -8
  66. package/examples/lynx/templates/hello_sprite.c +15 -1
  67. package/examples/lynx/templates/music_demo.c +13 -1
  68. package/examples/lynx/templates/puzzle.c +28 -1
  69. package/examples/lynx/templates/racing.c +34 -7
  70. package/examples/lynx/templates/shmup.c +42 -3
  71. package/examples/lynx/templates/sports.c +29 -2
  72. package/examples/msx/platformer/main.c +213 -0
  73. package/examples/msx/puzzle/main.c +250 -0
  74. package/examples/msx/racing/main.c +249 -0
  75. package/examples/msx/shmup/main.c +288 -0
  76. package/examples/msx/sports/main.c +182 -0
  77. package/examples/nes/templates/default.c +67 -19
  78. package/examples/nes/templates/hello_sprite.c +35 -0
  79. package/examples/nes/templates/music_demo.c +40 -0
  80. package/examples/nes/templates/platformer.c +65 -6
  81. package/examples/nes/templates/puzzle.c +67 -6
  82. package/examples/nes/templates/racing.c +45 -13
  83. package/examples/nes/templates/shmup.c +51 -2
  84. package/examples/nes/templates/sports.c +51 -6
  85. package/examples/pce/catch_game/main.c +22 -3
  86. package/examples/pce/music_sfx/main.c +28 -1
  87. package/examples/pce/platformer/main.c +283 -0
  88. package/examples/pce/puzzle/main.c +304 -0
  89. package/examples/pce/racing/main.c +304 -0
  90. package/examples/pce/shmup/main.c +346 -0
  91. package/examples/pce/sports/main.c +254 -0
  92. package/examples/pce/sprite_move/main.c +7 -2
  93. package/examples/sms/main.c +35 -6
  94. package/examples/sms/templates/hello_sprite.c +29 -3
  95. package/examples/sms/templates/music_demo.c +18 -4
  96. package/examples/sms/templates/puzzle.c +34 -5
  97. package/examples/sms/templates/racing.c +39 -2
  98. package/examples/sms/templates/shmup.c +41 -2
  99. package/examples/sms/templates/shmup_2p.c +24 -1
  100. package/examples/sms/templates/sports.c +47 -4
  101. package/examples/snes/main.asm +108 -17
  102. package/examples/snes/templates/c-hello-data.asm +23 -0
  103. package/examples/snes/templates/c-hello.c +18 -1
  104. package/examples/snes/templates/default.c +50 -28
  105. package/examples/snes/templates/hello_sprite-data.asm +23 -0
  106. package/examples/snes/templates/hello_sprite.c +17 -1
  107. package/examples/snes/templates/music_demo-data.asm +23 -0
  108. package/examples/snes/templates/music_demo.c +22 -4
  109. package/examples/snes/templates/platformer-data.asm +22 -0
  110. package/examples/snes/templates/platformer.c +20 -2
  111. package/examples/snes/templates/puzzle-data.asm +22 -0
  112. package/examples/snes/templates/puzzle.c +21 -2
  113. package/examples/snes/templates/racing-data.asm +22 -0
  114. package/examples/snes/templates/racing.c +17 -1
  115. package/examples/snes/templates/shmup-data.asm +22 -0
  116. package/examples/snes/templates/shmup.c +20 -1
  117. package/examples/snes/templates/sports-data.asm +22 -0
  118. package/examples/snes/templates/sports.c +16 -1
  119. package/package.json +1 -1
  120. package/src/cheats/gamegenie.js +0 -1
  121. package/src/cli/smoke.js +1 -3
  122. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  123. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  124. package/src/host/LibretroHost.js +191 -16
  125. package/src/host/callbacks.js +9 -1
  126. package/src/host/chafa-render.js +2 -0
  127. package/src/host/dsp-state.js +2 -2
  128. package/src/host/gpgx-state.js +4 -0
  129. package/src/host/types.js +15 -8
  130. package/src/http/routes.js +1 -1
  131. package/src/http/tool-registry.js +26 -1
  132. package/src/mcp/server.js +1 -1
  133. package/src/mcp/state.js +36 -0
  134. package/src/mcp/tools/address-to-symbol.js +0 -1
  135. package/src/mcp/tools/art-loaders.js +1 -1
  136. package/src/mcp/tools/cart-parts.js +75 -4
  137. package/src/mcp/tools/classify-region.js +1 -1
  138. package/src/mcp/tools/diff-roms.js +1 -1
  139. package/src/mcp/tools/disasm-rebuild.js +507 -0
  140. package/src/mcp/tools/disasm.js +97 -9
  141. package/src/mcp/tools/find-references.js +1 -2
  142. package/src/mcp/tools/font-map.js +1 -1
  143. package/src/mcp/tools/frame.js +168 -3
  144. package/src/mcp/tools/index.js +0 -49
  145. package/src/mcp/tools/input-layout.js +0 -1
  146. package/src/mcp/tools/input.js +33 -3
  147. package/src/mcp/tools/lifecycle.js +18 -4
  148. package/src/mcp/tools/lospec.js +0 -19
  149. package/src/mcp/tools/platform-docs.js +1 -1
  150. package/src/mcp/tools/platform-tools.js +4 -4
  151. package/src/mcp/tools/project.js +54 -11
  152. package/src/mcp/tools/reinject.js +0 -1
  153. package/src/mcp/tools/rom-id.js +2 -2
  154. package/src/mcp/tools/snippets.js +2 -2
  155. package/src/mcp/tools/sprite-pipeline.js +1 -2
  156. package/src/mcp/tools/state.js +201 -14
  157. package/src/mcp/tools/tile-inspect.js +1 -1
  158. package/src/mcp/tools/toolchain.js +105 -12
  159. package/src/mcp/tools/watch-memory.js +137 -16
  160. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +34 -0
  161. package/src/platforms/atari2600/TROUBLESHOOTING.md +6 -0
  162. package/src/platforms/atari7800/TROUBLESHOOTING.md +6 -0
  163. package/src/platforms/c64/MENTAL_MODEL.md +45 -1
  164. package/src/platforms/c64/TROUBLESHOOTING.md +6 -0
  165. package/src/platforms/c64/d64.js +280 -0
  166. package/src/platforms/c64/sid.js +0 -2
  167. package/src/platforms/common/metasprite-adapters.js +1 -1
  168. package/src/platforms/common/metasprite-codegen.js +3 -3
  169. package/src/platforms/common/registers.js +5 -3
  170. package/src/platforms/gb/MENTAL_MODEL.md +10 -0
  171. package/src/platforms/gb/TROUBLESHOOTING.md +6 -0
  172. package/src/platforms/gb/lib/c/gb_runtime.c +4 -4
  173. package/src/platforms/gba/TROUBLESHOOTING.md +6 -0
  174. package/src/platforms/gbc/TROUBLESHOOTING.md +6 -0
  175. package/src/platforms/gbc/lib/c/gb_runtime.c +4 -4
  176. package/src/platforms/genesis/TROUBLESHOOTING.md +6 -0
  177. package/src/platforms/gg/TROUBLESHOOTING.md +6 -0
  178. package/src/platforms/lynx/TROUBLESHOOTING.md +6 -0
  179. package/src/platforms/msx/MENTAL_MODEL.md +10 -6
  180. package/src/platforms/msx/TROUBLESHOOTING.md +6 -0
  181. package/src/platforms/nes/MENTAL_MODEL.md +63 -2
  182. package/src/platforms/nes/TROUBLESHOOTING.md +6 -0
  183. package/src/platforms/nes/image-to-tilemap.js +3 -0
  184. package/src/platforms/nes/lib/asm/famitone2.s +5 -1
  185. package/src/platforms/pce/MENTAL_MODEL.md +9 -4
  186. package/src/platforms/pce/TROUBLESHOOTING.md +6 -0
  187. package/src/platforms/pce/lib/c/pce_video.c +1 -1
  188. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  189. package/src/platforms/snes/TROUBLESHOOTING.md +6 -0
  190. package/src/platforms/snes/brr.js +0 -2
  191. package/src/playtest/playtest.js +0 -7
  192. package/src/rom-id/identifier.js +15 -0
  193. package/src/toolchains/asar/asar.js +0 -9
  194. package/src/toolchains/assemble-snippet.js +30 -12
  195. package/src/toolchains/cc65/ines.js +145 -0
  196. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +14 -1
  197. package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
  198. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
  199. package/src/toolchains/common/reassemble.js +10 -3
  200. package/src/toolchains/common/sdk-cache.js +1 -1
  201. package/src/toolchains/genesis-c/genesis-c.js +5 -3
  202. package/src/toolchains/index.js +27 -3
  203. package/src/toolchains/parse-errors.js +78 -1
  204. package/src/toolchains/sdcc/preflight-lint.js +5 -1
  205. package/src/toolchains/sdcc/sdcc.js +1 -1
  206. package/src/toolchains/sjasm/sjasm.js +1 -1
  207. package/src/toolchains/snes-c/snes-c.js +2 -2
  208. package/src/toolchains/vasm68k/vasm68k.js +2 -4
  209. package/src/toolchains/wladx/wladx.js +1 -1
@@ -211,6 +211,21 @@ Build calls explicitly point at these files via `sourcesPaths` /
211
211
  `includePaths` + `linkerConfig: <contents of chr-ram-runtime.cfg>`. The
212
212
  project README shows the exact incantation.
213
213
 
214
+ ## Blank screen? Verify rendering before you guess (no vision needed)
215
+
216
+ If the screen looks black/blank, don't iterate blind — call
217
+ **`frame({op:'verify', frames:60})`**. One call fuses a framebuffer pixel scan
218
+ with the live PPU registers and tells you `{verified:true|false|null, issues[]}`:
219
+ - `renderDisabled` → PPUMASK has BG+sprites off (footgun, see below) — set
220
+ PPUMASK bits 3/4.
221
+ - `blankScreen`/`nearlyBlank` but render IS enabled → the PPU is on but nothing's
222
+ in the nametable/OAM/palette: check the loop-order + OAM-DMA footguns below, and
223
+ read the raw regions (`memory({op:'read', region:'nes_nametables'/'nes_oam'/'nes_palette'})`).
224
+ - `verified:null` (unsettled) → you haven't stepped a frame yet; step first.
225
+
226
+ It won't false-fire on boot, and it costs zero image tokens. Use it as the first
227
+ move whenever a change "did nothing" on screen.
228
+
214
229
  ## Five footguns to know before you start
215
230
 
216
231
  Read these BEFORE writing your game-loop. Each one cost a previous
@@ -304,14 +319,60 @@ incorrectly aligned."
304
319
  onChange:"reset", outputPath:...})` logs each note onset, or
305
320
  `recordSession({memorySamples:[{region:"nes_apu_regs",...}], sampleEvery:1,
306
321
  memoryOutputPath:...})` streams per-frame samples to disk.
307
- - Mapper support — only NROM-256 (32 KB PRG, no banks) is wired. For
308
- MMC1/MMC3/UNROM you'll need a different linker config.
322
+ - Mapper support — the homebrew presets target NROM (no PRG banking). For
323
+ MMC1/MMC3/UNROM you'll need a different linker config. (For *rebuilding* an
324
+ existing CHR-ROM NROM game byte-identical, see "Rebuilding a CHR-ROM NROM
325
+ image" below — `inesHeader` / the `chr-rom` preset / `disasm({target:'project'})`.)
309
326
  - IRQ — the IRQ vector returns. Most NES games use a custom IRQ
310
327
  handler for mid-frame scroll splits; you'll need to write that asm.
311
328
  - Multi-screen scrolling — the runtime sets one nametable; for big
312
329
  scrolling worlds you need to manage the nametable buffer + bank
313
330
  switching yourself.
314
331
 
332
+ ## Rebuilding a CHR-ROM NROM image (reverse-engineering)
333
+
334
+ The homebrew presets above are CHR-**RAM** (the CPU uploads tiles at runtime).
335
+ Most *commercial* games are CHR-**ROM**: an 8 KB (or more) bank of fixed tile
336
+ data the PPU reads pattern tables from directly. When you rebuild a commercial
337
+ game from its disassembly into a byte-identical `.nes`, you need the iNES
338
+ header + the CHR-ROM blob + a linker config that concatenates HEADER + PRG +
339
+ CHR. romdev has three ways to do this so you never hand-derive header bytes or
340
+ write glue `.s`/`.cfg` files.
341
+
342
+ **The iNES header** (16 bytes at the very start of a `.nes`): `4E 45 53 1A`
343
+ ("NES"+EOF), then byte 4 = PRG-ROM 16 KB bank count, byte 5 = CHR-ROM 8 KB bank
344
+ count (**0 = CHR-RAM**), byte 6 = flags6 (bit0 mirroring 0=horizontal/1=vertical,
345
+ bit1 battery, high nibble = mapper low nibble), byte 7 = flags7 (high nibble =
346
+ mapper high nibble), bytes 8-15 = 0. NROM is mapper 0; NROM-128 = 1 PRG bank
347
+ (maps at $C000, mirrored to $8000), NROM-256 = 2 PRG banks (maps at $8000).
348
+
349
+ **1. `build({inesHeader:{...}})` — the parametric, no-glue path (recommended).**
350
+ Pass `inesHeader: {prgBanks, chrBanks, mapper, mirroring}` and the build
351
+ auto-emits the HEADER segment, wires your CHR blob (from `binaryIncludePaths`)
352
+ into a CHARS segment, and uses a flat NROM `.cfg`. You supply only the PRG
353
+ source(s) + the CHR blob:
354
+ ```
355
+ build({ output:'rom', platform:'nes',
356
+ sourcesPaths:{ "prg.asm": "bank0.asm" }, // the PRG disassembly
357
+ binaryIncludePaths:{ "chr.bin": "chr.bin" }, // extracted CHR-ROM
358
+ inesHeader:{ prgBanks:2, chrBanks:1, mapper:0, mirroring:"vertical" } })
359
+ ```
360
+ Mutually exclusive with `linkerConfig`. Works for any NROM (mapper 0, ≤2 PRG
361
+ banks); for a banked mapper supply a linker `.cfg` that places each bank.
362
+
363
+ **2. `linkerConfig:"chr-rom"` — for homebrew C that ships FIXED tile art.**
364
+ A cc65-C preset (segment split + a CHARS segment in an 8 KB ROM2 bank). Put your
365
+ tiles in `.segment "CHARS"` (`.incbin "tiles.chr"`) + pass the blob via
366
+ `binaryIncludePaths`. It ships a companion crt0 with an 8 KB-CHR-ROM header. For
367
+ other bank configs, prefer `inesHeader`.
368
+
369
+ **3. `disasm({target:'project'})` — disassemble → rebuild, in two calls.**
370
+ For NES it now extracts the CHR-ROM to `chr.bin`, writes a `rebuild.json` (the
371
+ exact `build({inesHeader})` call, with absolute paths) and a `BUILD.md`. Feed
372
+ `rebuild.json` straight back to `build` and you get a byte-identical ROM. This
373
+ is the RE workhorse loop: `disasm({target:'project'})` → edit the `.asm` →
374
+ rebuild → `diffRoms` to confirm your patch landed.
375
+
315
376
  ## When to drop to asm
316
377
 
317
378
  Game-loop in C is fine for ~80% of homebrew. Drop to asm when:
@@ -1,5 +1,11 @@
1
1
  # NES — symptom → fix
2
2
 
3
+ > **A build failed? Read `issues[]` FIRST.** Every build/compile call returns
4
+ > `issues: [{file, line, col, severity, message, stage}]` — the structured error
5
+ > list. It almost always names the exact line to fix. Read that before matching a
6
+ > symptom below or touching your source. Fall back to the raw `log` only if
7
+ > `issues[]` is empty but `ok:false`.
8
+
3
9
  Find your symptom below; each entry has the 1-line diagnosis + the
4
10
  MCP tool call that confirms it. Run these BEFORE you start bisecting
5
11
  your C source.
@@ -275,6 +275,9 @@ export function nesImageToTilemap(args) {
275
275
  // unique 8×8 patterns exist naturally. Returning the unmerged result
276
276
  // gives the caller a chance to retry; just be aware nametable indices
277
277
  // > 255 will wrap on hardware.
278
+ // Permanently disabled (see note above) but kept as documentation of the
279
+ // rejected greedy-merge approach.
280
+ // eslint-disable-next-line no-constant-condition, no-constant-binary-expression
278
281
  if (false && dedup && tileList.length > maxTiles) {
279
282
  const tileDist = (a, b) => {
280
283
  let d = 0;
@@ -8,7 +8,11 @@
8
8
 
9
9
  ;settings, uncomment or put them into your main program; the latter makes possible updates easier
10
10
 
11
- FT_BASE_ADR = $0300 ;page in the RAM used for FT2 variables, should be $xx00
11
+ FT_BASE_ADR = $0700 ;page in the RAM used for FT2 variables, should be $xx00
12
+ ;romdev: pinned to $0700 (the SNDRAM page reserved in
13
+ ;chr-ram-runtime.cfg). $0300 — the cc65 default — overlaps
14
+ ;the C BSS/DATA region, so FT2's per-frame writes would
15
+ ;clobber _ppuctrl_value / NMI state and stall rendering.
12
16
  FT_TEMP = $fd ;3 bytes in zeropage used by the library as a scratchpad
13
17
  FT_DPCM_OFF = $fc00 ;$c000..$ffc0, 64-byte steps
14
18
  FT_SFX_STREAMS = 1 ;number of sound effects played at once, 1..4
@@ -12,10 +12,15 @@ romdev ships a **hardware helper library** (`src/platforms/pce/lib/c/`:
12
12
  `psg_tone()` instead of poking VDC/VCE registers by hand. cc65 has **no** sprite
13
13
  library, so this lib is how you get pixels on screen.
14
14
 
15
- The fastest way to a working game: **`scaffold({op:'project', platform: "pce", template:
16
- "sprite_move"})`** (also `music_sfx`, `catch_game`). It drops a complete,
17
- *building* project a verified playable example + the helper lib + docs. Read
18
- the example's `main.c`, then change it. The examples live in `examples/pce/`.
15
+ The fastest way to a working game: **`scaffold({op:'game', platform: "pce", genre:
16
+ "shmup"})`** or any of `platformer` / `puzzle` / `sports` / `racing`, the full
17
+ genre set. For a smaller starting point use **`scaffold({op:'project', platform:
18
+ "pce", template: "sprite_move"})`** (also `music_sfx`, `catch_game`). Either drops
19
+ a complete, *building* project — a verified playable example + the helper lib +
20
+ docs. Read the example's `main.c`, then change it. The examples live in
21
+ `examples/pce/`. The genre scaffolds fill the BAT (32×32 virtual screen); the
22
+ `platformer` smooth-scrolls the background via the VDC BXR (R7) register.
23
+ **Gotcha:** `#include <stdint.h>` for int8/16/32_t — `pce.h` only typedefs u8/u16.
19
24
 
20
25
  ## CPU — HuC6280 (a 65C02 superset)
21
26
 
@@ -1,5 +1,11 @@
1
1
  # PC Engine — troubleshooting (symptom → cause → fix)
2
2
 
3
+ > **A build failed? Read `issues[]` FIRST.** Every build/compile call returns
4
+ > `issues: [{file, line, col, severity, message, stage}]` — the structured error
5
+ > list. It almost always names the exact line to fix. Read that before matching a
6
+ > symptom below or touching your source. Fall back to the raw `log` only if
7
+ > `issues[]` is empty but `ok:false`.
8
+
3
9
  Read this when something's broken. For the "how it works" overview, read
4
10
  MENTAL_MODEL.md first.
5
11
 
@@ -83,7 +83,7 @@ static u8 _pce_vdc_inited = 0;
83
83
  void vdc_init(void) {
84
84
  if (_pce_vdc_inited) return;
85
85
  _pce_vdc_inited = 1;
86
- vdc_set_reg(VDC_MWR, 0x0010); /* 32x32 virtual map, 256px BAT */
86
+ vdc_set_reg(VDC_MWR, 0x0000); /* 32x32 virtual map (SCREEN field=000); 256px BAT. (0x10 was 64x32 — its 64-wide stride left the bottom rows as uninitialized VRAM = vertical-stripe garbage.) */
87
87
  vdc_set_reg(VDC_BXR, 0x0000); /* BG X scroll = 0 */
88
88
  vdc_set_reg(VDC_BYR, 0x0000); /* BG Y scroll = 0 */
89
89
  vdc_set_reg(VDC_HSR, 0x0202); /* horizontal sync width/start */
@@ -1,5 +1,11 @@
1
1
  # Sega Master System / Game Gear — troubleshooting
2
2
 
3
+ > **A build failed? Read `issues[]` FIRST.** Every build/compile call returns
4
+ > `issues: [{file, line, col, severity, message, stage}]` — the structured error
5
+ > list. It almost always names the exact line to fix. Read that before matching a
6
+ > symptom below or touching your source. Fall back to the raw `log` only if
7
+ > `issues[]` is empty but `ok:false`.
8
+
3
9
  When something's broken. Read MENTAL_MODEL.md first for the
4
10
  "what's going on" version (via `platform({op:'doc', platform:"sms", name:"mental_model"})`).
5
11
 
@@ -1,5 +1,11 @@
1
1
  # Super Nintendo / Super Famicom — troubleshooting
2
2
 
3
+ > **A build failed? Read `issues[]` FIRST.** Every build/compile call returns
4
+ > `issues: [{file, line, col, severity, message, stage}]` — the structured error
5
+ > list. It almost always names the exact line to fix. Read that before matching a
6
+ > symptom below or touching your source. Fall back to the raw `log` only if
7
+ > `issues[]` is empty but `ok:false`.
8
+
3
9
  When something's broken. Read MENTAL_MODEL.md first for the "what's
4
10
  going on" version (via `platform({op:'doc', platform:"snes", name:"mental_model"})`).
5
11
 
@@ -23,8 +23,6 @@
23
23
  // lowest error and use that. The first block of any sample MUST use
24
24
  // filter 0 (no other choice has valid p1/p2 history).
25
25
 
26
- const BRR_BUF_DECODE = 16; // samples per block
27
-
28
26
  /** snes9x CLAMP16: saturate to int16. */
29
27
  function clamp16(io) {
30
28
  // The macro: if int16(io) != io, io = (io >> 31) ^ 0x7FFF
@@ -17,9 +17,6 @@ import { createRequire } from "node:module";
17
17
  const execFileAsync = promisify(execFile);
18
18
  const require = createRequire(import.meta.url);
19
19
 
20
- // One-pixel solid-black RGBA buffer; we stretch it across the letterbox
21
- // bars each frame so they don't smear with leftover pixels.
22
- const BLACK_PIXEL = Buffer.from([0, 0, 0, 0xFF]);
23
20
 
24
21
  /**
25
22
  * Choose a default window title from the loaded host. Prefers the loaded
@@ -796,10 +793,6 @@ export async function playtest(args) {
796
793
  };
797
794
  }
798
795
 
799
- function sleep(ms) {
800
- return new Promise((r) => setTimeout(r, ms));
801
- }
802
-
803
796
  function bitToName(bit) {
804
797
  return ({
805
798
  0: "b", 1: "y", 2: "select", 3: "start",
@@ -107,6 +107,7 @@ function parseINes(b) {
107
107
  if (f6 & 0x04) notes.push("trainer present");
108
108
  if (f6 & 0x08) notes.push("four-screen VRAM");
109
109
 
110
+ const hasBattery = !!(f6 & 0x02);
110
111
  return {
111
112
  platform: "nes",
112
113
  format: ".nes",
@@ -117,6 +118,10 @@ function parseINes(b) {
117
118
  chr: chrBanks * 8192,
118
119
  total: b.length,
119
120
  },
121
+ // Battery-backed cartridge SAVE RAM: present iff the iNES battery flag is set
122
+ // (8 KB is the standard PRG-RAM window). Read/write live via
123
+ // memory({region:'save_ram'}); persist with state({op:'exportSram'/'importSram'}).
124
+ saveRam: { hasBattery, bytes: hasBattery ? 8192 : 0 },
120
125
  notes: [...notes, `mirroring: ${mirroring}`],
121
126
  confidence: 1,
122
127
  };
@@ -208,6 +213,13 @@ function parseGameBoy(b) {
208
213
  0x1E: "MBC5+RUMBLE+RAM+BATTERY", 0xFC: "Pocket Camera", 0xFE: "HuC3", 0xFF: "HuC1+RAM+BATTERY",
209
214
  };
210
215
 
216
+ // Battery save = cart type whose name carries BATTERY. MBC2 has 512×4 bits of
217
+ // internal RAM (ramSizeCode is 0 but it still saves), so treat it specially.
218
+ const typeName = cartTypeNames[cartType] ?? "";
219
+ const hasBattery = /BATTERY/.test(typeName);
220
+ const isMbc2 = cartType === 0x05 || cartType === 0x06;
221
+ const saveBytes = hasBattery ? (isMbc2 ? 512 : Math.max(ramSize, 0)) : 0;
222
+
211
223
  return {
212
224
  platform,
213
225
  format: isGbc ? ".gbc" : ".gb",
@@ -215,6 +227,9 @@ function parseGameBoy(b) {
215
227
  region,
216
228
  mapper: cartTypeNames[cartType] ?? `0x${cartType.toString(16)}`,
217
229
  sizes: { rom: romSize, ram: ramSize, total: b.length },
230
+ // Battery-backed cartridge SAVE RAM (the .sav). Read/write live via
231
+ // memory({region:'save_ram'}); persist with state({op:'exportSram'/'importSram'}).
232
+ saveRam: { hasBattery, bytes: saveBytes },
218
233
  notes: [
219
234
  isGbc ? "Game Boy Color cartridge" : "Original Game Boy cartridge",
220
235
  sgbFlag === 0x03 && "supports Super Game Boy enhancements",
@@ -260,15 +260,6 @@ function lorom(fileStart, fileEnd) {
260
260
  return `${fmt(bankStart, offStart)}..${fmt(bankEnd, offEnd)} (spans banks)`;
261
261
  }
262
262
 
263
- function ensureDir(FS, dir) {
264
- const parts = dir.split("/").filter(Boolean);
265
- let cur = "";
266
- for (const p of parts) {
267
- cur += "/" + p;
268
- try { FS.mkdir(cur); } catch {}
269
- }
270
- }
271
-
272
263
  // Static analyzer for known asar landmines. Runs before the WASM call so
273
264
  // we can return a helpful error instead of letting asar abort silently.
274
265
  // Returns null when source looks clean, or a string with the diagnostic.
@@ -25,11 +25,30 @@ import { runCa65, runLd65 } from "./cc65/cc65.js";
25
25
  import { runAsar } from "./asar/asar.js";
26
26
  import { runVasm68k } from "./vasm68k/vasm68k.js";
27
27
  import { runSdasz80, runSdld, ihxToBin } from "./sdcc/sdcc.js";
28
- import { runRgbasm, runRgblink, runRgbfix } from "./rgbds/rgbds.js";
28
+ import { runRgbasm, runRgblink } from "./rgbds/rgbds.js";
29
+ import { parseBuildLog } from "./parse-errors.js";
29
30
 
30
31
  const __filename = fileURLToPath(import.meta.url);
31
32
  const __dirname = path.dirname(__filename);
32
33
 
34
+ // Build a transparent snippet-assembly error: lead with the FIRST structured
35
+ // diagnostic (file:line: message) parsed out of the raw log — the same
36
+ // issues[]-style surfacing build() does — instead of dumping the unparsed
37
+ // assembler stdout and making the agent grep it. Full log still appended for
38
+ // fallback. `where` is e.g. "ca65" / "ld65 link" / "asar".
39
+ function asmError(where, log) {
40
+ const issues = parseBuildLog(log ?? "");
41
+ const first = issues.find((i) => i.severity === "error") ?? issues[0];
42
+ const headline = first
43
+ ? `${first.file ? first.file + ":" : ""}${first.line ? first.line + ": " : ""}${first.message}`
44
+ : "no structured diagnostic found";
45
+ return new Error(
46
+ `assembleSnippet[${where}] failed: ${headline}` +
47
+ (issues.length > 1 ? ` (+${issues.length - 1} more issue(s))` : "") +
48
+ `\nFix the source line above. Full assembler log:\n${log ?? ""}`,
49
+ );
50
+ }
51
+
33
52
  /**
34
53
  * CPU dialect → assembler dispatch. Keys are also the public API.
35
54
  */
@@ -102,7 +121,7 @@ async function assembleCa65({ origin, code }, cpu = "6502") {
102
121
 
103
122
  const asm = await runCa65({ source });
104
123
  if (!asm.object) {
105
- throw new Error(`assembleSnippet[ca65]: assembly failed.\nlog:\n${asm.log ?? ""}`);
124
+ throw asmError("ca65", asm.log);
106
125
  }
107
126
 
108
127
  // Minimal linker config: one MEMORY block at `origin`, one SEGMENT
@@ -116,7 +135,7 @@ SEGMENTS { CODE: load = OUT, type = ro; }
116
135
  linkerConfig: cfg,
117
136
  });
118
137
  if (!linked.binary) {
119
- throw new Error(`assembleSnippet[ld65]: link failed.\nlog:\n${linked.log ?? ""}`);
138
+ throw asmError("ld65 link", linked.log);
120
139
  }
121
140
  return { bytes: linked.binary, log: (asm.log ?? "") + (linked.log ?? "") };
122
141
  }
@@ -137,7 +156,7 @@ async function assembleAsar({ origin, code }) {
137
156
  const source = `org ${hex24}\n${code}\n`;
138
157
  const r = await runAsar({ source, baseRom, symbols: false });
139
158
  if (!r.binary) {
140
- throw new Error(`assembleSnippet[asar]: assembly failed.\nlog:\n${r.log ?? ""}`);
159
+ throw asmError("asar", r.log);
141
160
  }
142
161
  const bin = r.binary;
143
162
  // Find first non-sentinel byte (asar may have written anywhere depending
@@ -148,7 +167,7 @@ async function assembleAsar({ origin, code }) {
148
167
  if (bin[i] !== SENTINEL) { start = i; break; }
149
168
  }
150
169
  if (start < 0) {
151
- throw new Error(`assembleSnippet[asar]: no bytes written (origin 0x${origin.toString(16)})\nlog:\n${r.log ?? ""}`);
170
+ throw new Error(`assembleSnippet[asar]: no bytes written (origin 0x${origin.toString(16)}) — the source assembled but emitted nothing at this origin. Check the org address and that the code actually emits bytes.\nFull log:\n${r.log ?? ""}`);
152
171
  }
153
172
  let end = start + 1;
154
173
  let sentinelRun = 0;
@@ -181,7 +200,7 @@ async function assembleVasm68k({ origin, code }) {
181
200
  const source = `\torg $${origin.toString(16).toUpperCase()}\n${indented}\n`;
182
201
  const r = await runVasm68k({ source, options: ["-Fbin"] });
183
202
  if (!r.binary || (r.exitCode != null && r.exitCode !== 0)) {
184
- throw new Error(`assembleSnippet[vasm68k]: assembly failed.\nlog:\n${r.log ?? ""}`);
203
+ throw asmError("vasm68k", r.log);
185
204
  }
186
205
  return { bytes: r.binary, log: r.log ?? "" };
187
206
  }
@@ -199,7 +218,7 @@ async function assembleSdcc({ origin, code }) {
199
218
  const source = `\t.module snippet\n\t.area _CODE\n${indented}\n`;
200
219
  const asm = await runSdasz80({ source });
201
220
  if (!asm.rel) {
202
- throw new Error(`assembleSnippet[sdasz80]: assembly failed.\nlog:\n${asm.log ?? ""}`);
221
+ throw asmError("sdasz80", asm.log);
203
222
  }
204
223
  // Minimal empty crt0 rel: just declares _HEADER0 (zero bytes) and is
205
224
  // enough for sdld to satisfy its hardcoded crt0.rel dependency.
@@ -212,7 +231,7 @@ async function assembleSdcc({ origin, code }) {
212
231
  libraries: [],
213
232
  });
214
233
  if (!linked.ihx) {
215
- throw new Error(`assembleSnippet[sdld]: link failed.\nlog:\n${linked.log ?? ""}`);
234
+ throw asmError("sdld link", linked.log);
216
235
  }
217
236
  const padded = ihxToBin(linked.ihx, 0x10000, 0xFF);
218
237
  // Find first and last non-FF byte from origin onwards. The snippet bytes
@@ -232,19 +251,18 @@ async function assembleSdcc({ origin, code }) {
232
251
  async function assembleRgbds({ origin, code }) {
233
252
  // rgbasm needs SECTION declarations to place code at an address.
234
253
  const sectionAt = `$${origin.toString(16).toUpperCase()}`;
235
- const source = `SECTION "snippet", ROMX[${sectionAt}], BANK[0]\n${code}\n`;
236
- // Actually GB has no BANK[0] for fixed ROM — use ROM0 if origin < 0x4000.
254
+ // GB has no BANK[0] for fixed ROM — use ROM0 if origin < 0x4000, else ROMX BANK[1].
237
255
  const useRom0 = origin < 0x4000;
238
256
  const realSource = useRom0
239
257
  ? `SECTION "snippet", ROM0[${sectionAt}]\n${code}\n`
240
258
  : `SECTION "snippet", ROMX[${sectionAt}], BANK[1]\n${code}\n`;
241
259
  const asm = await runRgbasm({ source: realSource });
242
260
  if (!asm.object) {
243
- throw new Error(`assembleSnippet[rgbasm]: assembly failed.\nlog:\n${asm.log ?? ""}`);
261
+ throw asmError("rgbasm", asm.log);
244
262
  }
245
263
  const linked = await runRgblink({ objects: { "snippet.o": asm.object }, padValue: 0x00 });
246
264
  if (!linked.binary) {
247
- throw new Error(`assembleSnippet[rgblink]: link failed.\nlog:\n${linked.log ?? ""}`);
265
+ throw asmError("rgblink link", linked.log);
248
266
  }
249
267
  // Slice from origin, find last non-zero byte.
250
268
  const start = useRom0 ? origin : (origin - 0x4000 + 0x4000); // bank 1 lives at file 0x4000
@@ -0,0 +1,145 @@
1
+ // iNES header + NROM linker-config synthesis for cc65/ca65 NES builds.
2
+ //
3
+ // The most common NES *reverse-engineering* build shape — rebuilding a
4
+ // commercial NROM game from its disassembly (e.g. SMBDIS) into a byte-identical
5
+ // `.nes` — needs three pieces of pure boilerplate that are identical for every
6
+ // NROM CHR-ROM cart:
7
+ // 1. the 16-byte iNES header (`.segment "HEADER"`),
8
+ // 2. a CHARS segment fed from the extracted CHR-ROM blob (`.incbin`),
9
+ // 3. a 3-region MEMORY/SEGMENTS .cfg concatenating HEADER + PRG + CHARS into
10
+ // one output file.
11
+ //
12
+ // `build({inesHeader:{prgBanks, chrBanks, mapper, mirroring}})` and
13
+ // `disasm({target:'project'})` both use this so the agent never hand-derives
14
+ // header bytes or writes glue `.s`/`.cfg` files. Proven byte-identical against
15
+ // nestest.nes (NROM-128, 16K PRG + 8K CHR) and 32K-PRG NROM-256 carts.
16
+
17
+ /**
18
+ * @typedef {Object} InesHeaderSpec
19
+ * @property {number} prgBanks - 16KB PRG-ROM banks (1 = NROM-128, 2 = NROM-256).
20
+ * @property {number} [chrBanks] - 8KB CHR-ROM banks (0 = CHR-RAM, no CHARS). Default 0.
21
+ * @property {number} [mapper] - iNES mapper number. Default 0 (NROM).
22
+ * @property {"horizontal"|"vertical"} [mirroring] - nametable mirroring. Default "horizontal".
23
+ * @property {boolean} [battery] - PRG-RAM battery (flags6 bit 1). Default false.
24
+ */
25
+
26
+ /**
27
+ * Build the 16 raw iNES header bytes for a spec.
28
+ * @param {InesHeaderSpec} spec
29
+ * @returns {Uint8Array} exactly 16 bytes
30
+ */
31
+ export function inesHeaderBytes(spec) {
32
+ const prg = spec.prgBanks;
33
+ const chr = spec.chrBanks ?? 0;
34
+ const mapper = spec.mapper ?? 0;
35
+ const mirroring = spec.mirroring ?? "horizontal";
36
+ if (!Number.isInteger(prg) || prg < 1 || prg > 255) {
37
+ throw new Error(`inesHeader.prgBanks must be an integer 1..255 (16KB each); got ${spec.prgBanks}`);
38
+ }
39
+ if (!Number.isInteger(chr) || chr < 0 || chr > 255) {
40
+ throw new Error(`inesHeader.chrBanks must be an integer 0..255 (8KB each; 0 = CHR-RAM); got ${spec.chrBanks}`);
41
+ }
42
+ if (!Number.isInteger(mapper) || mapper < 0 || mapper > 255) {
43
+ throw new Error(`inesHeader.mapper must be an integer 0..255; got ${spec.mapper}`);
44
+ }
45
+ if (mirroring !== "horizontal" && mirroring !== "vertical") {
46
+ throw new Error(`inesHeader.mirroring must be "horizontal" or "vertical"; got ${JSON.stringify(spec.mirroring)}`);
47
+ }
48
+ const flags6 = (mirroring === "vertical" ? 0x01 : 0x00) | (spec.battery ? 0x02 : 0x00) | ((mapper & 0x0f) << 4);
49
+ const flags7 = mapper & 0xf0;
50
+ const h = new Uint8Array(16);
51
+ h[0] = 0x4e; h[1] = 0x45; h[2] = 0x53; h[3] = 0x1a; // "NES\x1a"
52
+ h[4] = prg;
53
+ h[5] = chr;
54
+ h[6] = flags6;
55
+ h[7] = flags7;
56
+ // bytes 8..15 stay 0 (iNES 1.0 padding)
57
+ return h;
58
+ }
59
+
60
+ /**
61
+ * ca65 source that emits the iNES header as `.segment "HEADER"`.
62
+ * @param {InesHeaderSpec} spec
63
+ * @returns {string}
64
+ */
65
+ export function inesHeaderSource(spec) {
66
+ const h = inesHeaderBytes(spec);
67
+ const prg = spec.prgBanks;
68
+ const chr = spec.chrBanks ?? 0;
69
+ const mapper = spec.mapper ?? 0;
70
+ const mirroring = spec.mirroring ?? "horizontal";
71
+ const hex = (b) => "$" + b.toString(16).padStart(2, "0").toUpperCase();
72
+ return [
73
+ "; iNES header — auto-generated by build({inesHeader:{...}}).",
74
+ `; prgBanks=${prg} (${prg * 16}KB) chrBanks=${chr} (${chr * 8}KB${chr === 0 ? " = CHR-RAM" : ""})` +
75
+ ` mapper=${mapper} mirroring=${mirroring}`,
76
+ '.segment "HEADER"',
77
+ ` .byte ${hex(h[0])},${hex(h[1])},${hex(h[2])},${hex(h[3])} ; "NES"+EOF`,
78
+ ` .byte ${h[4]} ; PRG-ROM 16KB banks`,
79
+ ` .byte ${h[5]} ; CHR-ROM 8KB banks${chr === 0 ? " (0 = CHR-RAM)" : ""}`,
80
+ ` .byte ${hex(h[6])} ; flags6 (mapper lo nibble + mirroring${spec.battery ? " + battery" : ""})`,
81
+ ` .byte ${hex(h[7])} ; flags7 (mapper hi nibble)`,
82
+ " .byte 0,0,0,0,0,0,0,0 ; padding (iNES 1.0)",
83
+ "",
84
+ ].join("\n");
85
+ }
86
+
87
+ /**
88
+ * ca65 source that pulls the CHR-ROM blob into the CHARS segment.
89
+ * @param {string} incbinName - the binaryInclude file name to `.incbin`.
90
+ * @returns {string}
91
+ */
92
+ export function charsSource(incbinName) {
93
+ return [
94
+ "; CHR-ROM data — auto-generated by build({inesHeader:{...}}).",
95
+ '.segment "CHARS"',
96
+ ` .incbin "${incbinName}"`,
97
+ "",
98
+ ].join("\n");
99
+ }
100
+
101
+ /**
102
+ * A flat NROM linker .cfg: HEADER(16B) + PRG + (optional) CHARS, all concatenated
103
+ * into one %O output file. "Flat" = one CODE segment carrying the whole PRG image
104
+ * with its OWN embedded reset/NMI/IRQ vectors (the shape a disassembly produces),
105
+ * NOT cc65's STARTUP/VECTORS/CONDES split. Use `chr-rom` (the named preset) for
106
+ * cc65-C-with-segments builds instead.
107
+ *
108
+ * - prgBanks=1 (NROM-128): the 16KB image maps at $C000 (mirrored to $8000).
109
+ * - prgBanks=2 (NROM-256): the 32KB image maps at $8000.
110
+ *
111
+ * @param {InesHeaderSpec} spec
112
+ * @returns {string} ld65 config text
113
+ */
114
+ export function nromFlatCfg(spec) {
115
+ const prg = spec.prgBanks;
116
+ const chr = spec.chrBanks ?? 0;
117
+ const prgSize = prg * 0x4000;
118
+ // NROM-128's single 16KB bank lives in the upper half so $FFFA vectors land;
119
+ // anything larger fills from $8000 down.
120
+ const prgStart = prg === 1 ? 0xc000 : 0x10000 - prgSize;
121
+ const lines = [
122
+ "# Flat NROM linker config — auto-generated by build({inesHeader:{...}}).",
123
+ "# HEADER(16B) + PRG + " + (chr > 0 ? "CHARS(CHR-ROM)" : "(no CHARS — CHR-RAM)") +
124
+ ", concatenated into one .nes.",
125
+ "# 'Flat' CODE segment = the whole PRG image with its own embedded vectors",
126
+ "# (disassembly shape). For cc65-C with segment split, use linkerConfig:'chr-rom'.",
127
+ "MEMORY {",
128
+ " HEADER: file = %O, start = $0000, size = $0010, fill = yes;",
129
+ ` PRG: file = %O, start = $${prgStart.toString(16).toUpperCase()}, size = $${prgSize.toString(16).toUpperCase()}, fill = yes, fillval = $FF;`,
130
+ ];
131
+ if (chr > 0) {
132
+ lines.push(` CHARS: file = %O, start = $0000, size = $${(chr * 0x2000).toString(16).toUpperCase()}, fill = yes;`);
133
+ }
134
+ lines.push(
135
+ "}",
136
+ "SEGMENTS {",
137
+ " HEADER: load = HEADER, type = ro;",
138
+ " CODE: load = PRG, type = ro;",
139
+ );
140
+ if (chr > 0) {
141
+ lines.push(" CHARS: load = CHARS, type = ro;");
142
+ }
143
+ lines.push("}", "");
144
+ return lines.join("\n");
145
+ }
@@ -19,7 +19,13 @@
19
19
  # 3. Write CHR data from C at runtime: PPUADDR = 0x00; PPUDATA = byte; etc.
20
20
 
21
21
  SYMBOLS {
22
- __STACKSIZE__: type = weak, value = $0300;
22
+ # Stack is $0200 (512 B) so the top RAM page ($0700-$07FF) can be
23
+ # reserved below for a music driver's scratch RAM (FamiTone2 et al.),
24
+ # which needs a dedicated, page-aligned block that the C BSS/DATA
25
+ # region must NOT overlap. Tiny NROM scaffolds use far less than 512 B
26
+ # of stack, so this is safe; scaffolds with no music driver simply
27
+ # leave the reserved page unused.
28
+ __STACKSIZE__: type = weak, value = $0200;
23
29
  }
24
30
  MEMORY {
25
31
  ZP: file = "", start = $0002, size = $001A, type = rw, define = yes;
@@ -37,6 +43,13 @@ MEMORY {
37
43
 
38
44
  SRAM: file = "", start = $0500, size = __STACKSIZE__, define = yes;
39
45
 
46
+ # Reserved page for a sound-driver's RAM scratch ($0700-$07FF). The
47
+ # bundled FamiTone2 engine (music_demo scaffold) pins FT_BASE_ADR here
48
+ # so its ~90 bytes of channel/envelope state can't collide with the C
49
+ # BSS/DATA at $0300-$04FF — that collision silently clobbers the NMI's
50
+ # cached PPUCTRL and stalls rendering. Unused by non-music scaffolds.
51
+ SNDRAM: file = "", start = $0700, size = $0100, define = yes;
52
+
40
53
  # BSS / DATA live in real RAM ($0300-$04FF, 512 bytes). NROM (mapper 0)
41
54
  # with no battery has $6000-$7FFF UNMAPPED — reads return open bus.
42
55
  # Putting BSS there means `int counter = 0;` reads garbage (intermittent