romdevtools 0.27.0 → 0.29.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 (199) hide show
  1. package/AGENTS.md +56 -44
  2. package/CHANGELOG.md +355 -0
  3. package/README.md +4 -4
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1227 -325
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +909 -257
  8. package/examples/atari2600/templates/shmup.asm +1035 -218
  9. package/examples/atari2600/templates/sports.asm +1143 -229
  10. package/examples/atari7800/templates/hello_sprite.c +8 -4
  11. package/examples/atari7800/templates/platformer.c +991 -152
  12. package/examples/atari7800/templates/puzzle.c +1091 -145
  13. package/examples/atari7800/templates/racing.c +949 -118
  14. package/examples/atari7800/templates/shmup.c +812 -130
  15. package/examples/atari7800/templates/sports.c +820 -181
  16. package/examples/c64/templates/platformer.c +876 -157
  17. package/examples/c64/templates/puzzle.c +881 -143
  18. package/examples/c64/templates/racing.c +873 -88
  19. package/examples/c64/templates/shmup.c +762 -154
  20. package/examples/c64/templates/sports.c +755 -95
  21. package/examples/gb/templates/platformer.c +841 -175
  22. package/examples/gb/templates/puzzle.c +1094 -176
  23. package/examples/gb/templates/racing.c +761 -169
  24. package/examples/gb/templates/shmup.c +679 -169
  25. package/examples/gb/templates/sports.c +790 -153
  26. package/examples/gba/templates/platformer.c +624 -169
  27. package/examples/gba/templates/puzzle.c +535 -207
  28. package/examples/gba/templates/racing.c +513 -196
  29. package/examples/gba/templates/shmup.c +565 -168
  30. package/examples/gba/templates/sports.c +454 -162
  31. package/examples/gbc/templates/platformer.c +944 -176
  32. package/examples/gbc/templates/puzzle.c +1131 -177
  33. package/examples/gbc/templates/racing.c +891 -175
  34. package/examples/gbc/templates/shmup.c +827 -179
  35. package/examples/gbc/templates/sports.c +870 -156
  36. package/examples/genesis/templates/platformer.c +747 -129
  37. package/examples/genesis/templates/puzzle.c +702 -208
  38. package/examples/genesis/templates/racing.c +728 -193
  39. package/examples/genesis/templates/shmup.c +535 -142
  40. package/examples/genesis/templates/shmup_2p.c +13 -1
  41. package/examples/genesis/templates/sports.c +495 -158
  42. package/examples/gg/templates/platformer.c +883 -214
  43. package/examples/gg/templates/puzzle.c +906 -181
  44. package/examples/gg/templates/racing.c +919 -160
  45. package/examples/gg/templates/shmup.c +716 -177
  46. package/examples/gg/templates/sports.c +735 -128
  47. package/examples/lynx/templates/platformer.c +604 -50
  48. package/examples/lynx/templates/puzzle.c +533 -130
  49. package/examples/lynx/templates/racing.c +538 -102
  50. package/examples/lynx/templates/shmup.c +461 -122
  51. package/examples/lynx/templates/sports.c +496 -69
  52. package/examples/msx/platformer/main.c +648 -159
  53. package/examples/msx/puzzle/main.c +750 -185
  54. package/examples/msx/racing/main.c +669 -177
  55. package/examples/msx/shmup/main.c +460 -177
  56. package/examples/msx/sports/main.c +591 -124
  57. package/examples/nes/templates/platformer.c +586 -160
  58. package/examples/nes/templates/puzzle.c +603 -222
  59. package/examples/nes/templates/racing.c +505 -197
  60. package/examples/nes/templates/shmup.c +339 -144
  61. package/examples/nes/templates/sports.c +341 -182
  62. package/examples/pce/platformer/main.c +875 -204
  63. package/examples/pce/puzzle/main.c +797 -216
  64. package/examples/pce/racing/main.c +782 -206
  65. package/examples/pce/shmup/main.c +638 -211
  66. package/examples/pce/sports/main.c +585 -167
  67. package/examples/porting-across-platforms/README.md +1 -1
  68. package/examples/sms/templates/platformer.c +765 -176
  69. package/examples/sms/templates/puzzle.c +783 -177
  70. package/examples/sms/templates/racing.c +812 -133
  71. package/examples/sms/templates/shmup.c +601 -148
  72. package/examples/sms/templates/shmup_2p.c +17 -1
  73. package/examples/sms/templates/sports.c +633 -121
  74. package/examples/snes/templates/music_demo.c +7 -0
  75. package/examples/snes/templates/platformer-data.asm +123 -24
  76. package/examples/snes/templates/platformer-hdr.asm +57 -0
  77. package/examples/snes/templates/platformer.c +587 -149
  78. package/examples/snes/templates/puzzle-data.asm +116 -21
  79. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  80. package/examples/snes/templates/puzzle.c +632 -185
  81. package/examples/snes/templates/racing-data.asm +390 -32
  82. package/examples/snes/templates/racing-hdr.asm +57 -0
  83. package/examples/snes/templates/racing.c +807 -177
  84. package/examples/snes/templates/shmup-data.asm +87 -29
  85. package/examples/snes/templates/shmup-hdr.asm +57 -0
  86. package/examples/snes/templates/shmup.c +459 -180
  87. package/examples/snes/templates/sports-data.asm +48 -2
  88. package/examples/snes/templates/sports-hdr.asm +57 -0
  89. package/examples/snes/templates/sports.c +414 -156
  90. package/package.json +12 -12
  91. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  92. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  93. package/src/cores/wasm/fceumm_libretro.js +1 -1
  94. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  95. package/src/cores/wasm/gambatte_libretro.js +1 -1
  96. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  97. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  98. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  99. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  100. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  101. package/src/cores/wasm/handy_libretro.js +1 -1
  102. package/src/cores/wasm/handy_libretro.wasm +0 -0
  103. package/src/cores/wasm/mgba_libretro.js +1 -1
  104. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  105. package/src/cores/wasm/prosystem_libretro.js +1 -1
  106. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  107. package/src/cores/wasm/snes9x_libretro.js +1 -1
  108. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  109. package/src/cores/wasm/stella2014_libretro.js +1 -1
  110. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  111. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  112. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  113. package/src/host/LibretroHost.js +304 -11
  114. package/src/http/tool-registry.js +11 -11
  115. package/src/mcp/server.js +6 -0
  116. package/src/mcp/tools/cheats.js +2 -1
  117. package/src/mcp/tools/disasm-rebuild.js +315 -65
  118. package/src/mcp/tools/disasm.js +149 -28
  119. package/src/mcp/tools/find-references.js +216 -51
  120. package/src/mcp/tools/frame.js +14 -6
  121. package/src/mcp/tools/index.js +18 -4
  122. package/src/mcp/tools/input.js +31 -7
  123. package/src/mcp/tools/lifecycle.js +6 -4
  124. package/src/mcp/tools/memory.js +208 -39
  125. package/src/mcp/tools/platform-docs.js +1 -1
  126. package/src/mcp/tools/playtest.js +56 -4
  127. package/src/mcp/tools/preview-tile.js +6 -2
  128. package/src/mcp/tools/project.js +1114 -120
  129. package/src/mcp/tools/rom-id.js +5 -1
  130. package/src/mcp/tools/run-until.js +4 -2
  131. package/src/mcp/tools/snippets.js +6 -6
  132. package/src/mcp/tools/sprite-pipeline.js +14 -2
  133. package/src/mcp/tools/state.js +2 -1
  134. package/src/mcp/tools/tile-inspect.js +8 -1
  135. package/src/mcp/tools/toolchain.js +55 -11
  136. package/src/mcp/tools/watch-memory.js +145 -27
  137. package/src/observer/bus.js +73 -0
  138. package/src/observer/livestream.html +4 -2
  139. package/src/observer/tool-wrap.js +17 -14
  140. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
  141. package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
  142. package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
  143. package/src/platforms/atari7800/MENTAL_MODEL.md +32 -11
  144. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  145. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  146. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  147. package/src/platforms/gb/MENTAL_MODEL.md +19 -4
  148. package/src/platforms/gb/TROUBLESHOOTING.md +101 -6
  149. package/src/platforms/gb/lib/c/README.md +10 -11
  150. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  151. package/src/platforms/gb/lib/c/patch-header.js +19 -6
  152. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  153. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  154. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  155. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  156. package/src/platforms/gbc/MENTAL_MODEL.md +16 -4
  157. package/src/platforms/gbc/TROUBLESHOOTING.md +24 -3
  158. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  159. package/src/platforms/gbc/lib/c/README.md +10 -11
  160. package/src/platforms/gbc/lib/c/font.h +43 -0
  161. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  162. package/src/platforms/gbc/lib/c/patch-header.js +19 -6
  163. package/src/platforms/genesis/MENTAL_MODEL.md +43 -9
  164. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  165. package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
  166. package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
  167. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  168. package/src/platforms/gg/TROUBLESHOOTING.md +14 -18
  169. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  170. package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
  171. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  172. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  173. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  174. package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
  175. package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
  176. package/src/platforms/msx/MENTAL_MODEL.md +11 -5
  177. package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
  178. package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
  179. package/src/platforms/msx/lib/c/msx_hw.h +3 -0
  180. package/src/platforms/msx/lib/c/msx_vdp.c +70 -0
  181. package/src/platforms/nes/MENTAL_MODEL.md +12 -5
  182. package/src/platforms/nes/lib/c/nes_runtime.c +190 -34
  183. package/src/platforms/nes/lib/c/nes_runtime.h +35 -0
  184. package/src/platforms/pce/MENTAL_MODEL.md +14 -5
  185. package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
  186. package/src/platforms/pce/lib/c/pce_hw.h +13 -1
  187. package/src/platforms/pce/lib/c/pce_sound.c +22 -0
  188. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  189. package/src/platforms/sms/MENTAL_MODEL.md +11 -6
  190. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  191. package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
  192. package/src/platforms/snes/MENTAL_MODEL.md +7 -2
  193. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  194. package/src/playtest/playtest.js +73 -3
  195. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  196. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  197. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  198. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  199. package/src/toolchains/index.js +64 -19
@@ -1105,6 +1105,7 @@ function absolutizeBuild(build, outputDir) {
1105
1105
  out[key] = m;
1106
1106
  }
1107
1107
  }
1108
+ if (out.linkerConfigPath) out.linkerConfigPath = nodePath.join(outputDir, out.linkerConfigPath);
1108
1109
  return out;
1109
1110
  }
1110
1111
 
@@ -1148,12 +1149,16 @@ export function registerDisasmTools(server, z) {
1148
1149
  "`endAddress`/`untilReturn` for one routine, `dataRanges` to mark non-code, `bank` for a banked slot. " +
1149
1150
  "pce/msx are 'project'-only here — 'rom' doesn't map them yet.\n" +
1150
1151
  "'project' = turn a ROM into a complete re-buildable disassembly in one call across all systems; splits into " +
1151
- "regions, REASSEMBLES each and verifies BYTE-EXACT (`roundTripOk`); non-faithful lines fall back to `.byte` so " +
1152
- "it ALWAYS rebuilds; `readablePercent` reports instruction-vs-data. (SNES 65816 usually lands at the byte-exact " +
1152
+ "regions (PER-BANK on every banked format: NES mappers, SNES LoROM, GB MBC, Sega-mapper SMS/GG, MSX megaROM, " +
1153
+ "2600 F8/F6/F4, 7800 SuperGame, >32KB HuCards), REASSEMBLES each and verifies BYTE-EXACT (`roundTripOk`); " +
1154
+ "non-faithful lines fall back to `.byte` so it ALWAYS rebuilds; `readablePercent` reports instruction-vs-data. " +
1155
+ "NES/C64/7800/Lynx/PCE ship a one-call `build()` rebuild in rebuild.json (flat AND banked); the rest ship a " +
1156
+ "proven native recipe in BUILD.md. (SNES 65816 usually lands at the byte-exact " +
1153
1157
  "data-only floor — its `.a8/.i8` width state desyncs when instructions and pinned `.byte` mix.)\n" +
1154
1158
  "'references' = scan a ROM's code for operands matching a CPU `address` and classify each (call/jump/branch/" +
1155
- "read/write); also walks the vector table. LIMITATION: direct addressing only (indirect/computed jumps + " +
1156
- "cross-bank refs are missed).",
1159
+ "read/write); also walks the vector table. Banked carts are scanned PER BANK (all of the formats above) — " +
1160
+ "refs carry `prgBank` (NES) / `romBank` (everything else). LIMITATION: direct addressing only " +
1161
+ "(indirect/computed jumps are missed).",
1157
1162
  {
1158
1163
  target: z.enum(["bytes", "rom", "project", "references"]).describe("bytes = raw chunk; rom = mapper-aware ROM; project = full rebuildable disasm; references = find refs to an address."),
1159
1164
  // shared
@@ -1314,9 +1319,26 @@ function planRegions(platform, data) {
1314
1319
  return regions;
1315
1320
  }
1316
1321
  if (platform === "sms" || platform === "gg") {
1317
- // Z80 mapped at $0000; one region for the whole ROM (Sega mapper banks
1318
- // beyond 48KB are rarer in homebrew treat as flat for now).
1319
- regions.push({ name: "rom", file: "rom.asm", bytes: trimTrailingPad(data.slice(0)), startAddress: 0x0000, fileOffset: 0, label: "ROM ($0000)" });
1322
+ // Z80 mapped at $0000. ≤48KB fits the three slots flat one region.
1323
+ // Bigger carts use the Sega mapper: 16KB banks, banks 0-1 fixed in slots
1324
+ // 0-1 ($0000/$4000), banks 2+ page into slot 2 ($8000) — one region per
1325
+ // bank so instructions never straddle a bank edge and every bank gets a
1326
+ // correct base address.
1327
+ if (data.length <= 0xC000) {
1328
+ regions.push({ name: "rom", file: "rom.asm", bytes: trimTrailingPad(data.slice(0)), startAddress: 0x0000, fileOffset: 0, label: "ROM ($0000)" });
1329
+ return regions;
1330
+ }
1331
+ const BANK = 0x4000;
1332
+ for (let off = 0; off < data.length; off += BANK) {
1333
+ const idx = off / BANK;
1334
+ const org = idx === 0 ? 0x0000 : idx === 1 ? 0x4000 : 0x8000;
1335
+ regions.push({
1336
+ name: `bank${idx}`, file: `bank${idx}.asm`,
1337
+ bytes: data.slice(off, off + BANK),
1338
+ startAddress: org, fileOffset: off,
1339
+ label: `Sega-mapper bank ${idx}${idx < 2 ? ` (fixed $${org.toString(16).toUpperCase()})` : " (slot 2, $8000)"}`,
1340
+ });
1341
+ }
1320
1342
  return regions;
1321
1343
  }
1322
1344
  if (platform === "genesis") {
@@ -1334,12 +1356,58 @@ function planRegions(platform, data) {
1334
1356
  return regions;
1335
1357
  }
1336
1358
  if (platform === "atari2600") {
1337
- regions.push({ name: "rom", file: "rom.asm", bytes: data.slice(0), startAddress: 0xF000, fileOffset: 0, label: "4KB cart @ $F000" });
1359
+ // 4KB carts map flat at $F000. Banked carts (F8=8KB, F6=16KB, F4=32KB)
1360
+ // page 4KB banks into the SAME $F000 window — one region per bank.
1361
+ if (data.length <= 0x1000) {
1362
+ regions.push({ name: "rom", file: "rom.asm", bytes: data.slice(0), startAddress: 0xF000, fileOffset: 0, label: "4KB cart @ $F000" });
1363
+ return regions;
1364
+ }
1365
+ const BANK = 0x1000;
1366
+ for (let off = 0; off < data.length; off += BANK) {
1367
+ const idx = off / BANK;
1368
+ regions.push({
1369
+ name: `bank${idx}`, file: `bank${idx}.asm`,
1370
+ bytes: data.slice(off, off + BANK),
1371
+ startAddress: 0xF000, fileOffset: off,
1372
+ label: `banked cart 4KB bank ${idx} @ $F000`,
1373
+ });
1374
+ }
1338
1375
  return regions;
1339
1376
  }
1340
1377
  if (platform === "atari7800") {
1341
- const org = 0x10000 - data.length; // cart maps to top of address space
1342
- regions.push({ name: "rom", file: "rom.asm", bytes: data.slice(0), startAddress: org & 0xFFFF, fileOffset: 0, label: `cart @ $${(org & 0xFFFF).toString(16)}` });
1378
+ // ≤48KB carts map flat at the top of the address space. SuperGame banked
1379
+ // carts (>48KB) page 16KB banks into $8000-$BFFF with the LAST bank fixed
1380
+ // at $C000. A 128-byte .a78 header (magic "ATARI7800" at offset 1) is
1381
+ // split into its own data region so the cart banks stay 16KB-aligned.
1382
+ const hasA78 = data.length >= 17 &&
1383
+ String.fromCharCode(...data.subarray(1, 10)) === "ATARI7800";
1384
+ const base = hasA78 ? 128 : 0;
1385
+ const cartLen = data.length - base;
1386
+ if (cartLen <= 0xC000) {
1387
+ const org = 0x10000 - data.length; // header kept in-region (proven flat path)
1388
+ regions.push({ name: "rom", file: "rom.asm", bytes: data.slice(0), startAddress: org & 0xFFFF, fileOffset: 0, label: `cart @ $${(org & 0xFFFF).toString(16)}` });
1389
+ return regions;
1390
+ }
1391
+ if (hasA78) {
1392
+ regions.push({
1393
+ name: "a78_header", file: "a78_header.asm", bytes: data.slice(0, 128), kind: "data",
1394
+ startAddress: 0x0000, fileOffset: 0,
1395
+ label: ".a78 header (128 B data)",
1396
+ });
1397
+ }
1398
+ const BANK = 0x4000;
1399
+ const nBanks = Math.ceil(cartLen / BANK);
1400
+ for (let b = 0; b < nBanks; b++) {
1401
+ const isFixedTop = b === nBanks - 1;
1402
+ const org = isFixedTop ? 0xC000 : 0x8000;
1403
+ const fileOffset = base + b * BANK;
1404
+ regions.push({
1405
+ name: `bank${b}`, file: `bank${b}.asm`,
1406
+ bytes: data.slice(fileOffset, fileOffset + BANK),
1407
+ startAddress: org, fileOffset,
1408
+ label: `SuperGame bank ${b}${isFixedTop ? " (fixed $C000)" : " (switchable $8000)"}`,
1409
+ });
1410
+ }
1343
1411
  return regions;
1344
1412
  }
1345
1413
  if (platform === "lynx") {
@@ -1363,30 +1431,83 @@ function planRegions(platform, data) {
1363
1431
  return regions;
1364
1432
  }
1365
1433
  if (platform === "pce") {
1366
- // PC Engine HuCard: the HuC6280 (65C02-family) image. cc65 pce.cfg maps the
1367
- // reset/IRQ vectors at $FFF6+ and the program high; homebrew typically runs
1368
- // from the ROM mapped at the top of the address space. Disassemble the image
1369
- // as a flat region from the cart base a HuCard has no header to strip.
1370
- const body = trimTrailingPad(data.slice(0));
1371
- const org = (0x10000 - body.length) & 0xffff; // cart maps to top of space
1372
- regions.push({
1373
- name: "rom", file: "rom.asm", bytes: body,
1374
- startAddress: org, fileOffset: 0,
1375
- label: `HuCard @ $${org.toString(16)} (HuC6280)`,
1376
- });
1434
+ // PC Engine HuCard: the HuC6280 (65C02-family) image, banked in 8KB pages
1435
+ // via the MPRs. A 512-byte copier header (len % 1024 == 512) is split into
1436
+ // its own data region. NO trailing-pad trim HuCard $FF padding is REAL
1437
+ // cart bytes and trimming it made the old rebuild lossy (the planner's own
1438
+ // notes flagged this as the fix needed).
1439
+ // ≤32KB: flat at the top of the address space (vectors at $FFF6+ land
1440
+ // correctly — the proven small-HuCard assumption).
1441
+ // >32KB: one region per 8KB page; page 0 at $E000 (where MPR7 maps it
1442
+ // at reset — the vectors live there), pages 1+ at $8000 (neutral
1443
+ // window; bank registers decide at runtime).
1444
+ const hasCopier = (data.length % 1024) === 512;
1445
+ const base = hasCopier ? 512 : 0;
1446
+ if (hasCopier) {
1447
+ regions.push({
1448
+ name: "copier_header", file: "copier_header.asm", bytes: data.slice(0, 512), kind: "data",
1449
+ startAddress: 0x0000, fileOffset: 0,
1450
+ label: "512-byte copier header (data)",
1451
+ });
1452
+ }
1453
+ const body = data.subarray(base);
1454
+ if (body.length <= 0x8000) {
1455
+ const org = (0x10000 - body.length) & 0xffff;
1456
+ regions.push({
1457
+ name: "rom", file: "rom.asm", bytes: body.slice(0),
1458
+ startAddress: org, fileOffset: base,
1459
+ label: `HuCard @ $${org.toString(16)} (HuC6280)`,
1460
+ });
1461
+ return regions;
1462
+ }
1463
+ const PAGE = 0x2000;
1464
+ const nPages = Math.ceil(body.length / PAGE);
1465
+ for (let b = 0; b < nPages; b++) {
1466
+ const org = b === 0 ? 0xE000 : 0x8000;
1467
+ regions.push({
1468
+ name: `page${b}`, file: `page${b}.asm`,
1469
+ bytes: body.slice(b * PAGE, (b + 1) * PAGE),
1470
+ startAddress: org, fileOffset: base + b * PAGE,
1471
+ label: `HuCard 8KB page ${b}${b === 0 ? " (reset MPR7 → $E000, vectors)" : " ($8000 ASSUMED — MPRs map pages at runtime)"}`,
1472
+ });
1473
+ }
1377
1474
  return regions;
1378
1475
  }
1379
1476
  if (platform === "msx") {
1380
1477
  // MSX cartridge maps at $4000-$BFFF; the 16-byte "AB" header at $4000 is
1381
- // data (magic + INIT/STATEMENT/DEVICE/TEXT pointers), code follows. Skip the
1382
- // header and disassemble the Z80 image from $4010.
1478
+ // data (magic + INIT/STATEMENT/DEVICE/TEXT pointers), code follows.
1479
+ // ≤32KB: skip the header, one flat region from $4010.
1480
+ // >32KB (megaROM): 16KB banks via an ASCII16-style mapper — bank 0 at
1481
+ // $4000 (header split out as a data region), banks 1+ at $8000.
1383
1482
  const hdr = data.length >= 2 && data[0] === 0x41 && data[1] === 0x42;
1384
1483
  const base = hdr ? 16 : 0;
1385
- regions.push({
1386
- name: "rom", file: "rom.asm", bytes: trimTrailingPad(data.slice(base)),
1387
- startAddress: 0x4000 + base, fileOffset: base,
1388
- label: `cart @ $${(0x4000 + base).toString(16)} (Z80)`,
1389
- });
1484
+ if (data.length <= 0x8000 + base) {
1485
+ regions.push({
1486
+ name: "rom", file: "rom.asm", bytes: trimTrailingPad(data.slice(base)),
1487
+ startAddress: 0x4000 + base, fileOffset: base,
1488
+ label: `cart @ $${(0x4000 + base).toString(16)} (Z80)`,
1489
+ });
1490
+ return regions;
1491
+ }
1492
+ if (hdr) {
1493
+ regions.push({
1494
+ name: "ab_header", file: "ab_header.asm", bytes: data.slice(0, 16), kind: "data",
1495
+ startAddress: 0x4000, fileOffset: 0,
1496
+ label: `"AB" cartridge header (16 B data @ $4000)`,
1497
+ });
1498
+ }
1499
+ const BANK = 0x4000;
1500
+ const nBanks = Math.ceil(data.length / BANK);
1501
+ for (let b = 0; b < nBanks; b++) {
1502
+ const skip = b === 0 ? base : 0;
1503
+ const org = b === 0 ? 0x4000 + base : 0x8000;
1504
+ regions.push({
1505
+ name: `bank${b}`, file: `bank${b}.asm`,
1506
+ bytes: data.slice(b * BANK + skip, (b + 1) * BANK),
1507
+ startAddress: org, fileOffset: b * BANK + skip,
1508
+ label: `megaROM 16KB bank ${b}${b === 0 ? ` ($${org.toString(16)})` : " ($8000 ASSUMED — mapper pages at runtime)"}`,
1509
+ });
1510
+ }
1390
1511
  return regions;
1391
1512
  }
1392
1513
  if (platform === "gba") {
@@ -7,7 +7,7 @@
7
7
  // though they're not "instructions" per se.
8
8
 
9
9
  import { readFile } from "node:fs/promises";
10
- import { mapSnesAddress, mapAtari2600Address, mapAtari7800Address, mapC64Address } from "./disasm.js";
10
+ import { mapAtari2600Address, mapC64Address } from "./disasm.js";
11
11
 
12
12
  /**
13
13
  * Classify a referring instruction by its mnemonic.
@@ -75,8 +75,9 @@ function scanAsmForReferences(asm, targetAddr, sourceLabels) {
75
75
  // Match: any `$XXXX` (or `$XX:XXXX`) in operand that resolves to target,
76
76
  // OR the LXXXX auto-label for the target address, OR a named label.
77
77
  let matched = false;
78
- for (const m of operand.matchAll(/\$([0-9A-Fa-f]+)\b/g)) {
79
- const v = parseInt(m[1], 16);
78
+ for (const m of operand.matchAll(/(#?)\$([0-9A-Fa-f]+)\b/g)) {
79
+ if (m[1] === "#") continue; /* immediate (`lda #$02`) — a value, not an address */
80
+ const v = parseInt(m[2], 16);
80
81
  if (v === targetAddr || v === (targetAddr & 0xFFFF)) { matched = true; break; }
81
82
  }
82
83
  if (!matched && autoLabelRe.test(operand)) matched = true;
@@ -231,54 +232,163 @@ export async function findReferencesCore({ path, platform, address, mapper, maxR
231
232
  throw new Error(`findReferences: could not detect platform for '${path}'. Pass platform explicitly.`);
232
233
  }
233
234
 
234
- // Disassemble the whole code area.
235
+ // Disassemble the whole code area. Flat platforms produce one asm blob;
236
+ // BANKED carts (NES mappers, SNES LoROM, GB MBC, Sega mapper, MSX megaROM,
237
+ // 2600 F8/F6/F4, 7800 SuperGame, >32KB HuCards) produce one segment PER
238
+ // BANK (segments[]) — a flat-blob disasm mis-addresses everything past the
239
+ // first bank and lets instructions straddle bank edges, which corrupts the
240
+ // decode stream (the 0.27.0 refsFound:0 bug, fixed for NES first and now
241
+ // applied to every banked platform). Refs from a segment carry a bank tag.
235
242
  let asm;
243
+ /** @type {{asm: string, bank: number}[] | null} */
244
+ let segments = null;
245
+ // Bound the per-bank da65/objdump fan-out on huge carts. 64 banks covers
246
+ // 1MB (16KB banks) / 512KB (8KB pages) — beyond that we scan the first 64
247
+ // and SAY SO in notes rather than silently truncating.
248
+ const SEGMENT_CAP = 64;
249
+ let segmentsCapped = 0;
236
250
  if (resolved === "nes") {
237
251
  const prgSize = data[4] * 16384;
238
- const startAddress = prgSize === 16384 ? 0xC000 : 0x8000;
239
- const bytes = data.slice(16, 16 + prgSize);
240
252
  const { runDa65 } = await import("../../toolchains/cc65/da65.js");
241
- const r = await runDa65({ bytes, startAddress, cpu: "6502", options: ["--comments", "4"] });
242
- asm = r.asm;
253
+ if (prgSize <= 32768) {
254
+ const startAddress = prgSize === 16384 ? 0xC000 : 0x8000;
255
+ const bytes = data.slice(16, 16 + prgSize);
256
+ const r = await runDa65({ bytes, startAddress, cpu: "6502", options: ["--comments", "4"] });
257
+ asm = r.asm;
258
+ } else {
259
+ // Banked PRG (>32KB, e.g. UxROM/MMC1/MMC3): the old code disassembled
260
+ // the whole PRG as ONE flat blob at $8000, which mis-addresses every
261
+ // bank past the first — a 128KB mapper-2 scan returned refsFound:0
262
+ // for bytes referenced in dozens of places (0.27.0 feedback #3).
263
+ // Disassemble each 16KB bank separately: switchable banks at $8000,
264
+ // the (conventionally fixed) last bank at $C000; tag refs with the
265
+ // bank index.
266
+ const banks = Math.floor(prgSize / 16384);
267
+ segments = [];
268
+ for (let b = 0; b < banks; b++) {
269
+ const bytes = data.slice(16 + b * 16384, 16 + (b + 1) * 16384);
270
+ const startAddress = b === banks - 1 ? 0xC000 : 0x8000;
271
+ const r = await runDa65({ bytes, startAddress, cpu: "6502", options: ["--comments", "4"] });
272
+ segments.push({ asm: r.asm, bank: b });
273
+ }
274
+ }
243
275
  } else if (resolved === "snes") {
244
- const mapped = mapSnesAddress(data, 0x008000, 0x8000, mapper);
245
- const startAddress = 0x008000;
276
+ // LoROM: 32KB banks each mapped at $xx:8000. The old code disassembled
277
+ // ONLY the first 32KB bank — a 1MB cart's other 31 banks were invisible.
278
+ // Scan every 32KB bank at $8000 (absolute 16-bit operands are bank-window
279
+ // addresses on LoROM), tagged with the bank index.
280
+ const hasHeader = (data.length % 1024) === 512;
281
+ const body = hasHeader ? data.subarray(512) : data;
246
282
  const { runDa65 } = await import("../../toolchains/cc65/da65.js");
247
- const r = await runDa65({
248
- bytes: mapped.bytes, startAddress, cpu: "65816",
249
- options: ["--comments", "4"],
250
- info: `RANGE { START $${(startAddress & 0xFFFF).toString(16).toUpperCase()}; END $${((startAddress + mapped.bytes.length - 1) & 0xFFFF).toString(16).toUpperCase()}; TYPE Code; ADDRMODE "MX"; };\n`,
251
- });
252
- asm = r.asm;
283
+ const BANK = 0x8000;
284
+ const nBanks = Math.ceil(body.length / BANK);
285
+ const scanBanks = Math.min(nBanks, SEGMENT_CAP);
286
+ segmentsCapped = nBanks - scanBanks;
287
+ if (nBanks <= 1) {
288
+ const r = await runDa65({
289
+ bytes: body.slice(0, BANK), startAddress: 0x008000, cpu: "65816",
290
+ options: ["--comments", "4"],
291
+ info: `RANGE { START $8000; END $${(0x8000 + Math.min(body.length, BANK) - 1).toString(16).toUpperCase()}; TYPE Code; ADDRMODE "MX"; };\n`,
292
+ });
293
+ asm = r.asm;
294
+ } else {
295
+ segments = [];
296
+ for (let b = 0; b < scanBanks; b++) {
297
+ const bytes = body.slice(b * BANK, (b + 1) * BANK);
298
+ const r = await runDa65({
299
+ bytes, startAddress: 0x008000, cpu: "65816",
300
+ options: ["--comments", "4"],
301
+ info: `RANGE { START $8000; END $${(0x8000 + bytes.length - 1).toString(16).toUpperCase()}; TYPE Code; ADDRMODE "MX"; };\n`,
302
+ });
303
+ segments.push({ asm: r.asm, bank: b });
304
+ }
305
+ }
253
306
  } else if (resolved === "sms" || resolved === "gg") {
254
- // SMS: disasm slot 0+1 ($0000-$7FFF, fixed in the sega mapper).
255
- // Slot 2 ($8000-$BFFF) is banked skip cross-bank scanning for now.
256
- // Native binutils z80 objdump.
257
- const bytes = data.slice(0, Math.min(data.length, 0x8000));
307
+ // Sega mapper: slots 0+1 ($0000-$7FFF) hold banks 0-1; slot 2 ($8000-
308
+ // $BFFF) pages in banks 2+. The old code scanned only the first 32KB —
309
+ // every bank past 1 was invisible. Scan bank 0 @ $0000, bank 1 @ $4000,
310
+ // banks 2+ @ $8000 (their pageable window), tagged with the bank index.
258
311
  const { runObjdump } = await import("../../toolchains/objdump.js");
259
- asm = (await runObjdump({ bytes, arch: "z80", startAddress: 0x0000 })).asm;
312
+ if (data.length <= 0x8000) {
313
+ asm = (await runObjdump({ bytes: data.slice(0), arch: "z80", startAddress: 0x0000 })).asm;
314
+ } else {
315
+ const BANK = 0x4000;
316
+ const nBanks = Math.ceil(data.length / BANK);
317
+ const scanBanks = Math.min(nBanks, SEGMENT_CAP);
318
+ segmentsCapped = nBanks - scanBanks;
319
+ segments = [];
320
+ for (let b = 0; b < scanBanks; b++) {
321
+ const bytes = data.slice(b * BANK, (b + 1) * BANK);
322
+ const startAddress = b === 0 ? 0x0000 : b === 1 ? 0x4000 : 0x8000;
323
+ segments.push({ asm: (await runObjdump({ bytes, arch: "z80", startAddress })).asm, bank: b });
324
+ }
325
+ }
260
326
  } else if (resolved === "gb" || resolved === "gbc") {
261
- // GB: bank 0 + bank 1 default ($0000-$7FFF). SM83 via binutils' gbz80
262
- // machine. Higher banks need disassembleRom + bank.
263
- const bytes = data.slice(0, Math.min(data.length, 0x8000));
327
+ // MBC banking: bank 0 fixed at $0000, banks 1+ page into $4000-$7FFF.
328
+ // The old code scanned only the first 32KB (banks 0-1) a 128KB MBC1
329
+ // cart's other 6 banks were invisible. Scan every 16KB bank (bank 0 @
330
+ // $0000, banks 1+ @ $4000), tagged with the bank index.
264
331
  const { runObjdump } = await import("../../toolchains/objdump.js");
265
- asm = (await runObjdump({ bytes, arch: "gbz80", startAddress: 0x0000 })).asm;
332
+ if (data.length <= 0x8000) {
333
+ asm = (await runObjdump({ bytes: data.slice(0), arch: "gbz80", startAddress: 0x0000 })).asm;
334
+ } else {
335
+ const BANK = 0x4000;
336
+ const nBanks = Math.ceil(data.length / BANK);
337
+ const scanBanks = Math.min(nBanks, SEGMENT_CAP);
338
+ segmentsCapped = nBanks - scanBanks;
339
+ segments = [];
340
+ for (let b = 0; b < scanBanks; b++) {
341
+ const bytes = data.slice(b * BANK, (b + 1) * BANK);
342
+ const startAddress = b === 0 ? 0x0000 : 0x4000;
343
+ segments.push({ asm: (await runObjdump({ bytes, arch: "gbz80", startAddress })).asm, bank: b });
344
+ }
345
+ }
266
346
  } else if (resolved === "atari2600") {
267
- // 2600 cart maps to $F000-$FFFF (top of 4 KB bank). For larger
268
- // banked carts we scan the last 4 KB which is what's typically
269
- // resident at boot.
270
- const mapped = mapAtari2600Address(data, 0xF000, 0x1000, 0);
347
+ // 2600 cart maps to $F000-$FFFF. Banked carts (F8=8KB, F6=16KB, F4=32KB,
348
+ // …) page 4KB banks into the SAME $F000 window. The old code scanned only
349
+ // the boot bank — fixed: scan every 4KB bank at $F000, tagged.
271
350
  const { runDa65 } = await import("../../toolchains/cc65/da65.js");
272
- const r = await runDa65({ bytes: mapped.bytes, startAddress: 0xF000, cpu: "6502", options: ["--comments", "4"] });
273
- asm = r.asm;
351
+ if (data.length <= 0x1000) {
352
+ const mapped = mapAtari2600Address(data, 0xF000, 0x1000, 0);
353
+ const r = await runDa65({ bytes: mapped.bytes, startAddress: 0xF000, cpu: "6502", options: ["--comments", "4"] });
354
+ asm = r.asm;
355
+ } else {
356
+ const BANK = 0x1000;
357
+ const nBanks = Math.ceil(data.length / BANK);
358
+ segments = [];
359
+ for (let b = 0; b < nBanks; b++) {
360
+ const bytes = data.slice(b * BANK, (b + 1) * BANK);
361
+ const r = await runDa65({ bytes, startAddress: 0xF000, cpu: "6502", options: ["--comments", "4"] });
362
+ segments.push({ asm: r.asm, bank: b });
363
+ }
364
+ }
274
365
  } else if (resolved === "atari7800") {
275
- // 7800 cart maps to $4000-$FFFF; agents typically focus on the top
276
- // 16 KB ($C000-$FFFF) where reset+main code lives.
277
- const start = 0xC000;
278
- const mapped = mapAtari7800Address(data, start, 0x10000 - start, 0);
366
+ // 7800: flat carts (≤48KB) map at the top of the address space — scan the
367
+ // WHOLE cart (the old code scanned only $C000-$FFFF, hiding code at
368
+ // $4000-$BFFF on 32/48KB carts). SuperGame banked carts (>48KB) page
369
+ // 16KB banks into $8000-$BFFF with the last bank fixed at $C000 — scan
370
+ // per-bank, tagged. A 128-byte .a78 header is stripped if present.
371
+ const hasA78 = data.length >= 17 &&
372
+ String.fromCharCode(...data.subarray(1, 10)) === "ATARI7800";
373
+ const cart = hasA78 ? data.subarray(128) : data;
279
374
  const { runDa65 } = await import("../../toolchains/cc65/da65.js");
280
- const r = await runDa65({ bytes: mapped.bytes, startAddress: start, cpu: "6502", options: ["--comments", "4"] });
281
- asm = r.asm;
375
+ if (cart.length <= 0xC000) {
376
+ const start = (0x10000 - cart.length) & 0xFFFF;
377
+ const r = await runDa65({ bytes: cart.slice(0), startAddress: start, cpu: "6502", options: ["--comments", "4"] });
378
+ asm = r.asm;
379
+ } else {
380
+ const BANK = 0x4000;
381
+ const nBanks = Math.ceil(cart.length / BANK);
382
+ const scanBanks = Math.min(nBanks, SEGMENT_CAP);
383
+ segmentsCapped = nBanks - scanBanks;
384
+ segments = [];
385
+ for (let b = 0; b < scanBanks; b++) {
386
+ const bytes = cart.slice(b * BANK, (b + 1) * BANK);
387
+ const startAddress = b === nBanks - 1 ? 0xC000 : 0x8000;
388
+ const r = await runDa65({ bytes, startAddress, cpu: "6502", options: ["--comments", "4"] });
389
+ segments.push({ asm: r.asm, bank: b });
390
+ }
391
+ }
282
392
  } else if (resolved === "c64") {
283
393
  // c64 .prg: 2-byte load addr + code. Disasm from the load addr through
284
394
  // EOF. For typical BASIC-stub programs that's $0801 + a few KB.
@@ -308,21 +418,58 @@ export async function findReferencesCore({ path, platform, address, mapper, maxR
308
418
  const { runObjdump } = await import("../../toolchains/objdump.js");
309
419
  asm = (await runObjdump({ bytes, arch: "m68k", startAddress: start })).asm;
310
420
  } else if (resolved === "pce") {
311
- // PC Engine HuCard: HuC6280 (65C02 superset). da65 has an explicit huc6280
312
- // CPU mode. The cart maps to the top of the address space (no header).
313
- const body = data.slice(0);
314
- const start = (0x10000 - body.length) & 0xffff;
421
+ // PC Engine HuCard: HuC6280 (65C02 superset), 8KB pages mapped via the
422
+ // MPRs. ≤32KB images map flat at the top of the address space (the old
423
+ // assumption correct there). Bigger HuCards are banked: the old code
424
+ // computed a WRAPPED start address (garbage for >64KB) fixed: scan
425
+ // every 8KB page, page 0 at $E000 (where MPR7 maps it at reset — the
426
+ // vectors live there), pages 1+ at $8000 (a neutral MPR4 window; the
427
+ // base only affects branch-target/auto-label matching, absolute operands
428
+ // match regardless). A 512-byte copier header is stripped if present.
429
+ const hasCopier = (data.length % 1024) === 512;
430
+ const body = hasCopier ? data.subarray(512) : data;
315
431
  const { runDa65 } = await import("../../toolchains/cc65/da65.js");
316
- const r = await runDa65({ bytes: body, startAddress: start, cpu: "huc6280", options: ["--comments", "4"] });
317
- asm = r.asm;
432
+ if (body.length <= 0x8000) {
433
+ const start = (0x10000 - body.length) & 0xffff;
434
+ const r = await runDa65({ bytes: body.slice(0), startAddress: start, cpu: "huc6280", options: ["--comments", "4"] });
435
+ asm = r.asm;
436
+ } else {
437
+ const PAGE = 0x2000;
438
+ const nPages = Math.ceil(body.length / PAGE);
439
+ const scanPages = Math.min(nPages, SEGMENT_CAP);
440
+ segmentsCapped = nPages - scanPages;
441
+ segments = [];
442
+ for (let b = 0; b < scanPages; b++) {
443
+ const bytes = body.slice(b * PAGE, (b + 1) * PAGE);
444
+ const startAddress = b === 0 ? 0xE000 : 0x8000;
445
+ const r = await runDa65({ bytes, startAddress, cpu: "huc6280", options: ["--comments", "4"] });
446
+ segments.push({ asm: r.asm, bank: b });
447
+ }
448
+ }
318
449
  } else if (resolved === "msx") {
319
- // MSX cartridge maps at $4000-$BFFF; skip the 16-byte "AB" header, then
320
- // disassemble the Z80 image from $4010.
450
+ // MSX cartridge maps at $4000-$BFFF. MegaROMs (>32KB) page 16KB banks via
451
+ // an ASCII16-style mapper — the old code scanned only the first 32KB.
452
+ // Scan bank 0 at $4000 (its fixed home, header skipped) and banks 1+ at
453
+ // $8000 (the conventional second window), tagged with the bank index.
321
454
  const hdr = data.length >= 2 && data[0] === 0x41 && data[1] === 0x42;
322
455
  const base = hdr ? 16 : 0;
323
- const bytes = data.slice(base, Math.min(data.length, base + 0x8000));
324
456
  const { runObjdump } = await import("../../toolchains/objdump.js");
325
- asm = (await runObjdump({ bytes, arch: "z80", startAddress: 0x4000 + base })).asm;
457
+ if (data.length <= 0x8000 + base) {
458
+ const bytes = data.slice(base, Math.min(data.length, base + 0x8000));
459
+ asm = (await runObjdump({ bytes, arch: "z80", startAddress: 0x4000 + base })).asm;
460
+ } else {
461
+ const BANK = 0x4000;
462
+ const nBanks = Math.ceil(data.length / BANK);
463
+ const scanBanks = Math.min(nBanks, SEGMENT_CAP);
464
+ segmentsCapped = nBanks - scanBanks;
465
+ segments = [];
466
+ for (let b = 0; b < scanBanks; b++) {
467
+ const skip = b === 0 ? base : 0;
468
+ const bytes = data.slice(b * BANK + skip, (b + 1) * BANK);
469
+ const startAddress = b === 0 ? 0x4000 + base : 0x8000;
470
+ segments.push({ asm: (await runObjdump({ bytes, arch: "z80", startAddress })).asm, bank: b });
471
+ }
472
+ }
326
473
  } else if (resolved === "gba") {
327
474
  // GBA = ARM7TDMI, ROM maps flat at 0x08000000. Disassemble as ARM (the
328
475
  // default; Thumb regions need disassembleRom with thumb:true). Native
@@ -339,7 +486,20 @@ export async function findReferencesCore({ path, platform, address, mapper, maxR
339
486
  throw new Error(`findReferences: platform '${resolved}' not supported yet.`);
340
487
  }
341
488
 
342
- const refs = scanAsmForReferences(asm, address, []);
489
+ let refs;
490
+ if (segments) {
491
+ refs = [];
492
+ // NES refs keep the shipped `prgBank` tag; other banked platforms use the
493
+ // platform-neutral `romBank`.
494
+ const bankKey = resolved === "nes" ? "prgBank" : "romBank";
495
+ for (const seg of segments) {
496
+ for (const r of scanAsmForReferences(seg.asm, address, [])) {
497
+ refs.push({ ...r, [bankKey]: seg.bank });
498
+ }
499
+ }
500
+ } else {
501
+ refs = scanAsmForReferences(asm, address, []);
502
+ }
343
503
  // Add vector-table refs.
344
504
  if (resolved === "nes") {
345
505
  refs.push(...nesVectorRefs(data, address));
@@ -363,9 +523,14 @@ export async function findReferencesCore({ path, platform, address, mapper, maxR
363
523
  truncated: refs.length > maxRefsReturned
364
524
  ? `${refs.length - maxRefsReturned} additional references not returned (raise maxRefsReturned).`
365
525
  : undefined,
366
- notes: refs.length === 0
367
- ? `No references found. Address $${address.toString(16).toUpperCase()} may be unreached, or an indirect/computed jump target.`
368
- : undefined,
526
+ notes: [
527
+ refs.length === 0
528
+ ? `No references found. Address $${address.toString(16).toUpperCase()} may be unreached, or an indirect/computed jump target.`
529
+ : null,
530
+ segmentsCapped > 0
531
+ ? `Scan covered the first ${SEGMENT_CAP} banks only — ${segmentsCapped} additional bank(s) were NOT scanned (very large cart).`
532
+ : null,
533
+ ].filter(Boolean).join(" ") || undefined,
369
534
  };
370
535
  }
371
536
 
@@ -8,6 +8,7 @@ import { imageContent, jsonContent, safeTool } from "../util.js";
8
8
  import { decodeOAM, decodePpuRegs, ppuRegsPopulated } from "../../platforms/snes/ppu.js";
9
9
  import { stepInstructionCore, attachObserverFrame } from "./watch-memory.js";
10
10
  import { getRenderingContextCore } from "./rendering-context.js";
11
+ import { humanCoDriveWarning } from "./playtest.js";
11
12
 
12
13
  // Normalize each platform's render-context into a CONSERVATIVE renderEnabled
13
14
  // (true | false | null). null = "can't tell from the registers" — verify never
@@ -244,11 +245,17 @@ export function registerFrameTools(server, z, sessionKey) {
244
245
  async function doStep({ frames }) {
245
246
  const host = getHost(sessionKey);
246
247
  const n = host.stepFrames(frames);
247
- return jsonContent({
248
+ // Surface a co-drive conflict the moment the agent steps: a human
249
+ // actively playing in the playtest window means this step raced their
250
+ // real-time loop. Field only appears when the conflict is real.
251
+ const coDrive = humanCoDriveWarning(sessionKey);
252
+ // Livestream: the post-step frame (throttled to 1/2s per tool by the bus).
253
+ return attachObserverFrame(jsonContent({
248
254
  framesRun: n,
249
255
  frameCount: host.status.frameCount,
250
256
  framebuffer: { width: host.status.fbWidth, height: host.status.fbHeight },
251
- });
257
+ ...(coDrive ? { humanCoDriveWarning: coDrive } : {}),
258
+ }), host, `step ×${n}`);
252
259
  }
253
260
 
254
261
  // Contract: an image goes to disk (path) OR comes back inline (inline:true).
@@ -371,16 +378,17 @@ export function registerFrameTools(server, z, sessionKey) {
371
378
  const host = getHost(sessionKey);
372
379
  host.stepFrames(frames);
373
380
  const shot = host.screenshot();
381
+ const coDrive = humanCoDriveWarning(sessionKey);
374
382
  if (!inline) {
375
383
  await writeFile(outPath, Buffer.from(shot.pngBase64, "base64"));
376
- const json = jsonContent({ path: outPath, frameCount: host.status.frameCount, width: shot.width, height: shot.height });
384
+ const json = jsonContent({ path: outPath, frameCount: host.status.frameCount, width: shot.width, height: shot.height, ...(coDrive ? { humanCoDriveWarning: coDrive } : {}) });
377
385
  json._observerImages = [{ kind: "image", mimeType: "image/png", base64: shot.pngBase64 }];
378
386
  return json;
379
387
  }
380
388
  return {
381
389
  content: [
382
390
  imageContent(shot.pngBase64),
383
- { type: "text", text: `stepped ${frames} → frame ${host.status.frameCount} (${shot.width}x${shot.height})` },
391
+ { type: "text", text: `stepped ${frames} → frame ${host.status.frameCount} (${shot.width}x${shot.height})${coDrive ? `\nWARNING: ${coDrive}` : ""}` },
384
392
  ],
385
393
  };
386
394
  }
@@ -393,7 +401,7 @@ export function registerFrameTools(server, z, sessionKey) {
393
401
  "level with 7200; prefer ONE big call.\n" +
394
402
  "'screenshot': capture the latest frame. `format:'png'` (default, exact colors) or `'ascii'` (lossy chafa text " +
395
403
  "render for agents that can't view images). `overlayBoxes` (png) draws a box per visible sprite (SNES+NES only); " +
396
- "`scale` (png) resamples nearest-neighbor BOTH ways: 0<scale<1 DOWNscales (~75% fewer image tokens at 0.5), integer scale≥2 UPscales so tiny handheld targets read inline (e.g. scale:4 → GB 160x144 becomes 640x576); ascii cols/rows/symbols/colors knobs in the param hints. " +
404
+ "`scale` (png) resamples nearest-neighbor: 0<scale<1 DOWNscales (~75% fewer image tokens at 0.5 — the useful direction, for cheap 'did it change?' checks). integer scale≥2 UPscales (pixel-duplication, e.g. scale:4 → GB 160x144 640x576) — but this adds NO detail (it's the same pixels enlarged) and costs MORE image tokens; the native frame already has every pixel. Prefer scale:1 (default, native). Only upscale if YOUR client renders tiny images too small to be useful AND can't zoom — and know that VLM encoders resize to a fixed resolution anyway, so it may not change what the model sees (and can slightly degrade it). ascii cols/rows/symbols/colors knobs in the param hints. " +
397
405
  "**CHEAP VERIFY: for a binary pass/fail check (theme changed? sprite present? HUD ticked?) prefer scale:0.5 or " +
398
406
  "format:'ascii' — BETTER, read the byte directly: symbols({op:'resolve', name}) → memory({op:'read'}) is a 1-byte " +
399
407
  "assertion that costs zero image tokens.**\n" +
@@ -417,7 +425,7 @@ export function registerFrameTools(server, z, sessionKey) {
417
425
  path: z.string().optional().describe("op=screenshot/stepAndShot: absolute path to write to (required unless inline:true)."),
418
426
  inline: z.boolean().default(false).describe("op=screenshot/stepAndShot: return the image in the response instead of writing to disk."),
419
427
  overlayBoxes: z.boolean().default(false).describe("op=screenshot png: draw a colored bounding box per visible sprite (SNES+NES only)."),
420
- scale: z.number().gt(0).max(16).refine((s) => s <= 1 || Number.isInteger(s), { message: "scale must be 0<scale≤1 (downscale) or an integer ≥2 (upscale)" }).optional().describe("op=screenshot png: nearest-neighbor resample factor. 0<scale<1 DOWNscales (0.5 ≈ 75% fewer image tokens for cheap 'did it change?' checks); scale≥2 (integer) UPscales (nearest-neighbor keeps pixel-art crisp) so tiny handheld targets are legible inline, e.g. scale:4 makes a GB 160x144 shot 640x576. scale=1/unset = native resolution."),
428
+ scale: z.number().gt(0).max(16).refine((s) => s <= 1 || Number.isInteger(s), { message: "scale must be 0<scale≤1 (downscale) or an integer ≥2 (upscale)" }).optional().describe("op=screenshot png: nearest-neighbor resample factor. DEFAULT (unset/1) = NATIVE resolution — perfect pixels, the accurate representation; use this. 0<scale<1 DOWNscales (0.5 ≈ 75% fewer image tokens — useful for cheap 'did it change?' checks). integer scale≥2 UPscales by pixel-duplication (e.g. scale:4 GB 160x144 → 640x576): it adds NO information (same pixels enlarged), costs MORE image tokens, and since VLM encoders resize to their own fixed resolution it may not change what the model sees and can slightly degrade it. Only for clients that render tiny images too small to use and can't zoom."),
421
429
  cols: z.number().int().min(4).max(640).optional().describe("op=screenshot ascii: terminal columns (default fb_width/16)."),
422
430
  rows: z.number().int().min(4).max(480).optional().describe("op=screenshot ascii: terminal rows (default fb_height/16)."),
423
431
  symbols: z.enum(["ascii", "halfblock", "block", "quad", "sextant"]).default("ascii").describe("op=screenshot ascii: chafa symbol set."),