romdevtools 0.71.0 → 0.84.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 (113) hide show
  1. package/AGENTS.md +19 -16
  2. package/CHANGELOG.md +196 -0
  3. package/README.md +5 -5
  4. package/examples/README.md +1 -0
  5. package/examples/gametank/templates/gt_draw.h +91 -0
  6. package/examples/gametank/templates/gt_hud.h +89 -0
  7. package/examples/gametank/templates/gt_palette.h +59 -0
  8. package/examples/gametank/templates/gt_sound.h +90 -0
  9. package/examples/gametank/templates/gt_sprites.h +53 -0
  10. package/examples/gametank/templates/platformer.c +318 -0
  11. package/examples/gametank/templates/puzzle.c +295 -0
  12. package/examples/gametank/templates/racing.c +139 -0
  13. package/examples/gametank/templates/shmup.c +277 -0
  14. package/examples/gametank/templates/sports.c +119 -0
  15. package/package.json +23 -22
  16. package/src/cores/capabilities.js +24 -0
  17. package/src/cores/registry.js +7 -1
  18. package/src/host/LibretroHost.js +90 -17
  19. package/src/host/callbacks.js +20 -2
  20. package/src/host/cpu-state.js +17 -0
  21. package/src/host/gametank-acp-state.js +53 -0
  22. package/src/http/skill-doc.js +10 -6
  23. package/src/mcp/tools/audio.js +1 -1
  24. package/src/mcp/tools/cart-parts.js +76 -0
  25. package/src/mcp/tools/lifecycle.js +1 -1
  26. package/src/mcp/tools/platform-tools.js +9 -1
  27. package/src/mcp/tools/platforms.js +46 -10
  28. package/src/mcp/tools/toolchain.js +35 -10
  29. package/src/platforms/gametank/lib/gt/audio/acp_image.bin +0 -0
  30. package/src/platforms/gametank/lib/gt/audio/audio_coprocessor.c +55 -0
  31. package/src/platforms/gametank/lib/gt/audio/audio_coprocessor.h +34 -0
  32. package/src/platforms/gametank/lib/gt/audio/audio_fw.asm +223 -0
  33. package/src/platforms/gametank/lib/gt/audio/audio_fw_blob.s +8 -0
  34. package/src/platforms/gametank/lib/gt/audio/instruments.c +79 -0
  35. package/src/platforms/gametank/lib/gt/audio/instruments.h +25 -0
  36. package/src/platforms/gametank/lib/gt/audio/music.c +370 -0
  37. package/src/platforms/gametank/lib/gt/audio/music.h +35 -0
  38. package/src/platforms/gametank/lib/gt/audio/note_numbers.h +132 -0
  39. package/src/platforms/gametank/lib/gt/audio/scaled_sines.raw +0 -0
  40. package/src/platforms/gametank/lib/gt/audio/sine_256_-63_63.bin +0 -0
  41. package/src/platforms/gametank/lib/gt/banking.c +29 -0
  42. package/src/platforms/gametank/lib/gt/banking.h +8 -0
  43. package/src/platforms/gametank/lib/gt/banking2.s +41 -0
  44. package/src/platforms/gametank/lib/gt/crt0.s +102 -0
  45. package/src/platforms/gametank/lib/gt/draw_logo.s +55 -0
  46. package/src/platforms/gametank/lib/gt/feature/persist/persist.c +103 -0
  47. package/src/platforms/gametank/lib/gt/feature/persist/persist.h +13 -0
  48. package/src/platforms/gametank/lib/gt/feature/random/random.c +40 -0
  49. package/src/platforms/gametank/lib/gt/feature/random/random.h +18 -0
  50. package/src/platforms/gametank/lib/gt/feature/text/text.c +111 -0
  51. package/src/platforms/gametank/lib/gt/feature/text/text.h +25 -0
  52. package/src/platforms/gametank/lib/gt/gametank.c +12 -0
  53. package/src/platforms/gametank/lib/gt/gametank.h +80 -0
  54. package/src/platforms/gametank/lib/gt/gametank_logo.inc +393 -0
  55. package/src/platforms/gametank/lib/gt/gen/assets/assets_index.h +9 -0
  56. package/src/platforms/gametank/lib/gt/gen/assets/sdk_default.h +5 -0
  57. package/src/platforms/gametank/lib/gt/gen/bank_nums.h +11 -0
  58. package/src/platforms/gametank/lib/gt/gen/modules_enabled.h +4 -0
  59. package/src/platforms/gametank/lib/gt/gen/modules_enabled.inc +4 -0
  60. package/src/platforms/gametank/lib/gt/gfx/draw_direct.c +132 -0
  61. package/src/platforms/gametank/lib/gt/gfx/draw_direct.h +75 -0
  62. package/src/platforms/gametank/lib/gt/gfx/draw_queue.c +177 -0
  63. package/src/platforms/gametank/lib/gt/gfx/draw_queue.h +35 -0
  64. package/src/platforms/gametank/lib/gt/gfx/draw_util.s +157 -0
  65. package/src/platforms/gametank/lib/gt/gfx/gfx_sys.c +43 -0
  66. package/src/platforms/gametank/lib/gt/gfx/gfx_sys.h +46 -0
  67. package/src/platforms/gametank/lib/gt/gfx/sprites.c +216 -0
  68. package/src/platforms/gametank/lib/gt/gfx/sprites.h +41 -0
  69. package/src/platforms/gametank/lib/gt/init.c +9 -0
  70. package/src/platforms/gametank/lib/gt/input.c +26 -0
  71. package/src/platforms/gametank/lib/gt/input.h +20 -0
  72. package/src/platforms/gametank/lib/gt/interrupt.s +67 -0
  73. package/src/platforms/gametank/lib/gt/vectors.s +14 -0
  74. package/src/platforms/gametank/lib/gt/wait.s +53 -0
  75. package/src/platforms/gba/MENTAL_MODEL.md +22 -1
  76. package/src/playtest/playtest.js +174 -26
  77. package/src/playtest/resampler/build.sh +19 -0
  78. package/src/playtest/resampler/index.mjs +75 -0
  79. package/src/playtest/resampler/resampler.c +129 -0
  80. package/src/playtest/resampler/resampler.mjs +2 -0
  81. package/src/playtest/resampler/resampler.wasm +0 -0
  82. package/src/toolchains/arm-none-eabi-gcc/gcc.js +39 -188
  83. package/src/toolchains/asar/asar.js +10 -15
  84. package/src/toolchains/cc65/cc65.js +82 -92
  85. package/src/toolchains/cc65/da65.js +12 -17
  86. package/src/toolchains/cc65/preset-resolver.js +13 -2
  87. package/src/toolchains/cc65/presets/gametank/gametank.h +80 -0
  88. package/src/toolchains/cc65/presets/gametank/sdk.cfg +32 -0
  89. package/src/toolchains/cc65/presets/gametank/sdk.crt0.s +61 -0
  90. package/src/toolchains/cc65/presets/gametank/sdk.vectors.s +11 -0
  91. package/src/toolchains/cc65/presets/gametank/single-bank.cfg +28 -0
  92. package/src/toolchains/cc65/presets/gametank/single-bank.crt0.s +71 -0
  93. package/src/toolchains/cc65/presets/gametank/single-bank.vectors.s +12 -0
  94. package/src/toolchains/common/c-build.js +109 -0
  95. package/src/toolchains/common/gcc-toolchain.js +164 -0
  96. package/src/toolchains/common/wasm-tool.js +101 -0
  97. package/src/toolchains/dasm/dasm.js +12 -18
  98. package/src/toolchains/gba-c/gba-c.js +253 -305
  99. package/src/toolchains/genesis-c/genesis-c.js +196 -225
  100. package/src/toolchains/index.js +58 -4
  101. package/src/toolchains/m68k-elf-gcc/gcc.js +39 -202
  102. package/src/toolchains/mips-c/mips-c.js +68 -78
  103. package/src/toolchains/mips-elf-gcc/gcc.js +55 -118
  104. package/src/toolchains/rgbds/rgbds.js +7 -19
  105. package/src/toolchains/sdcc/sdcc.js +35 -26
  106. package/src/toolchains/sh-c/sh-c.js +44 -52
  107. package/src/toolchains/sh-elf-gcc/gcc.js +55 -110
  108. package/src/toolchains/sjasm/sjasm.js +10 -14
  109. package/src/toolchains/snes-c/snes-c.js +125 -150
  110. package/src/toolchains/tcc816/tcc816.js +10 -15
  111. package/src/toolchains/vasm68k/vasm68k.js +11 -16
  112. package/src/toolchains/wladx/wladx.js +5 -17
  113. package/src/toolchains/z80/binutils.js +5 -11
@@ -19,7 +19,7 @@
19
19
  import { readFile } from "node:fs/promises";
20
20
  import path from "node:path";
21
21
  import os from "node:os";
22
- import { mkdtempSync, readdirSync, statSync, readFileSync, existsSync } from "node:fs";
22
+ import { mkdtempSync, readdirSync, statSync, readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import { loadLibretroCore } from "./coreLoader.js";
25
25
  import { newCallbackState, registerCallbacks } from "./callbacks.js";
@@ -324,6 +324,10 @@ export class LibretroHost {
324
324
  // marks them; the single-threaded GL cores keep the main-thread GL path below.
325
325
  this._proxied = !!opts.proxied;
326
326
  this.state.proxied = this._proxied;
327
+ // NODERAWFS cores (flycast): the WASM FS is Node's real fs, so the core fopens the
328
+ // disc image straight off disk — loadMedia must pass the REAL path and NOT slurp
329
+ // the (up to ~1GB) file into the WASM heap. See loadMedia.
330
+ this._noderawfs = !!opts.noderawfs;
327
331
 
328
332
  let glCanvas = null;
329
333
  if (opts.hwRender && !this._proxied) {
@@ -348,6 +352,20 @@ export class LibretroHost {
348
352
  const mod = await loadLibretroCore({ jsPath, wasmPath, glCanvas });
349
353
  this.mod = mod;
350
354
 
355
+ // AUTO-DETECT NODERAWFS regardless of the caller's opt. A NODERAWFS build replaces
356
+ // the in-RAM MEMFS with Node's real fs, so `FS.writeFile("/rom.elf", …)` would target
357
+ // the REAL root path (EACCES → WASM abort). loadMedia must know this even when a
358
+ // caller (a test, runSource) didn't pass the flag — and the build STILL registers
359
+ // FS.filesystems, so that's not a tell. The reliable probe: write to a real temp path
360
+ // via FS and check whether it actually lands on the host disk.
361
+ if (!this._noderawfs && mod.FS) {
362
+ try {
363
+ const probe = path.join(os.tmpdir(), `.romdev-noderawfs-probe-${process.pid}`);
364
+ mod.FS.writeFile(probe, "");
365
+ if (existsSync(probe)) { this._noderawfs = true; try { unlinkSync(probe); } catch {} }
366
+ } catch { /* MEMFS: the temp path isn't real → not NODERAWFS, leave as-is */ }
367
+ }
368
+
351
369
  if (this._proxied) {
352
370
  // Spawn the app thread, then build the whole GL stack on it (native-gles + webgl-node +
353
371
  // the Emscripten GL context). 480x272 is PSP native; the readback crops per-frame.
@@ -514,16 +532,35 @@ export class LibretroHost {
514
532
  if (!args.virtualName) mediaPath = "<memory" + defaultExt + ">";
515
533
  } else if (args.path) {
516
534
  mediaPath = args.path;
517
- data = await readFile(mediaPath);
518
535
  ext = path.extname(mediaPath);
536
+ // NODERAWFS cores (flycast) fopen the disc straight off Node's real fs — DON'T
537
+ // read the (up-to-~1GB) image into a JS buffer; libchdr seeks the sectors it
538
+ // needs on demand. `data` stays null; the real path is passed below. This ONLY
539
+ // applies to a real disk PATH — an in-memory `bytes` load (a freshly-built ELF in
540
+ // the tests/runSource) has no file to fopen, so it still mirrors into the FS below.
541
+ if (!this._noderawfs) data = await readFile(mediaPath);
519
542
  } else {
520
543
  throw new Error("loadMedia requires either `path` or `bytes`");
521
544
  }
545
+ // NODERAWFS: the WASM FS IS Node's real fs, so a `mod.FS.writeFile("/rom.elf", …)`
546
+ // would try to write the REAL root path `/rom.elf` (EACCES → WASM abort). For an
547
+ // in-memory `bytes` load on a NODERAWFS core (a freshly-built ELF in the tests /
548
+ // runSource), spill the bytes to a REAL temp file and load THAT path — the core
549
+ // fopens it off disk like any other media.
550
+ if (this._noderawfs && data != null) {
551
+ const tmpFile = path.join(mkdtempSync(path.join(os.tmpdir(), "romdev-dc-")), "rom" + (ext || ".bin"));
552
+ writeFileSync(tmpFile, data);
553
+ this._noderawfsTmp = tmpFile; // remembered so unloadMedia can clean it up
554
+ mediaPath = tmpFile;
555
+ data = null; // now streamed from the temp path, not the heap
556
+ }
557
+ // Stream-from-disk is valid for a NODERAWFS core once data is null (a real path OR
558
+ // the temp file we just wrote). A non-NODERAWFS core always mirrors into the FS.
559
+ const streamFromDisk = this._noderawfs && data == null;
522
560
  const vfsPath = "/rom" + ext;
523
- // For proxied cores the core fopen's on the app thread, so the ROM goes to the app-thread
524
- // MEMFS below (romdev_app_fs_write) — writing it to the main-thread FS too is wasted work
525
- // (and for a 60MB ISO, a needless copy). Non-proxied cores read main's FS directly.
526
- if (mod.FS && !this._proxied) {
561
+ // Mirror the bytes into the (in-RAM) MEMFS for normal cores. NODERAWFS skips this
562
+ // it streams from the real path (mediaPath) instead.
563
+ if (mod.FS && !this._proxied && !streamFromDisk) {
527
564
  try {
528
565
  mod.FS.writeFile(vfsPath, data);
529
566
  } catch (e) {
@@ -531,12 +568,18 @@ export class LibretroHost {
531
568
  }
532
569
  }
533
570
 
534
- // Allocate ROM data into the WASM heap (the core may keep this pointer).
535
- const dataPtr = mod._malloc(data.length);
536
- mod.HEAPU8.set(data, dataPtr);
571
+ // ROM data WASM heap (the core may keep this pointer). When streaming off disk
572
+ // there's no buffer — dataPtr stays 0 and dataLen 0; the core reads from the path.
573
+ let dataPtr = 0;
574
+ const dataLen = streamFromDisk ? 0 : data.length;
575
+ if (!streamFromDisk) {
576
+ dataPtr = mod._malloc(data.length);
577
+ mod.HEAPU8.set(data, dataPtr);
578
+ }
537
579
 
538
- // Allocate path string.
539
- const pathStr = mod.FS ? vfsPath : mediaPath;
580
+ // Path string. Streaming → the REAL host path (the core fopens it). Otherwise the
581
+ // in-FS vfs path (or the real path if the core has no FS).
582
+ const pathStr = streamFromDisk ? mediaPath : (mod.FS ? vfsPath : mediaPath);
540
583
  const pathBytes = Buffer.from(pathStr + "\0", "utf-8");
541
584
  const pathPtr = mod._malloc(pathBytes.length);
542
585
  mod.HEAPU8.set(pathBytes, pathPtr);
@@ -546,14 +589,14 @@ export class LibretroHost {
546
589
  // thread, so write the ROM into the app thread's MEMFS too (the bytes are already in shared
547
590
  // WASM memory at dataPtr).
548
591
  if (this._proxied) {
549
- mod._romdev_app_fs_write(pathPtr, dataPtr, data.length);
592
+ mod._romdev_app_fs_write(pathPtr, dataPtr, dataLen);
550
593
  }
551
594
 
552
595
  // retro_game_info struct (16 bytes on wasm32).
553
596
  const infoPtr = mod._malloc(16);
554
597
  mod.setValue(infoPtr + 0, pathPtr, "i32");
555
- mod.setValue(infoPtr + 4, dataPtr, "i32");
556
- mod.setValue(infoPtr + 8, data.length, "i32");
598
+ mod.setValue(infoPtr + 4, dataPtr, "i32"); // 0 for NODERAWFS (core reads the path)
599
+ mod.setValue(infoPtr + 8, dataLen, "i32"); // 0 for NODERAWFS
557
600
  mod.setValue(infoPtr + 12, 0, "i32"); // meta = null
558
601
 
559
602
  // Proxied cores: run retro_load_game on the app thread ASYNCHRONOUSLY (it creates the GL
@@ -600,7 +643,7 @@ export class LibretroHost {
600
643
  // rather than leaving the agent with "failed".
601
644
  throw new Error(
602
645
  `The '${platform}' core REFUSED this ${mediaKind || "media"} ` +
603
- `(${data.length} bytes${ext ? `, ${ext}` : ""}, path ${mediaPath}). ` +
646
+ `(${data ? data.length + " bytes" : "streamed from disk"}${ext ? `, ${ext}` : ""}, path ${mediaPath}). ` +
604
647
  `retro_load_game returned false — the bytes reached the core but it would not accept them. ` +
605
648
  `Common causes: (1) wrong platform for this file (a GB ROM loaded as 'nes', etc.) — ` +
606
649
  `confirm the platform matches the file; (2) a corrupt or TRUNCATED image — re-check the byte length; ` +
@@ -621,8 +664,10 @@ export class LibretroHost {
621
664
  // it does NOT clear work RAM on most cores, so boot-seeded state persists.
622
665
  // Stash the raw bytes (a copy, so a later free of the caller's buffer can't
623
666
  // corrupt it) + the load descriptor; hardReset() replays loadMedia with it.
667
+ // NODERAWFS streamed from disk — there's no buffer to stash; replay from the path.
624
668
  this._loadArgs = {
625
- bytes: data instanceof Uint8Array ? data.slice() : new Uint8Array(data),
669
+ ...(data ? { bytes: data instanceof Uint8Array ? data.slice() : new Uint8Array(data) }
670
+ : { path: mediaPath }),
626
671
  platform,
627
672
  mediaKind,
628
673
  virtualName: args.virtualName,
@@ -660,6 +705,13 @@ export class LibretroHost {
660
705
  this.status.displayAspect = reportedAspect > 0
661
706
  ? reportedAspect
662
707
  : this.status.fbWidth / this.status.fbHeight;
708
+ // timing.fps is at offset +24 (double) — the core's NATIVE refresh rate. Most
709
+ // are ~60, but some games/cores run 50 (PAL) or ~30 (e.g. Sonic Adventure on
710
+ // flycast). The playtest window paces its tick to THIS, not a hardcoded 60, so a
711
+ // 30fps title isn't double-ticked (which wasted half the budget + glitched on the
712
+ // heavy interpreter-only DC core). 0/garbage → fall back to 60.
713
+ const reportedFps = mod.getValue(avInfoPtr + 24, "double");
714
+ this.status.coreFps = (reportedFps >= 1 && reportedFps <= 120) ? reportedFps : 60;
663
715
  // timing.sample_rate is at offset +32 (double).
664
716
  this.status.audioSampleRate = mod.getValue(avInfoPtr + 32, "double");
665
717
  // max_width/max_height (+8/+12) bound the FBO for HW-render cores (the core may
@@ -1785,6 +1837,27 @@ export class LibretroHost {
1785
1837
  }
1786
1838
  }
1787
1839
 
1840
+ /** True when the GameTank core exposes the ACP audio-coprocessor state
1841
+ * (getAudioState chip:'acp'). */
1842
+ acpStateSupported() {
1843
+ return !!(this.mod && typeof this.mod._romdev_acp_get === "function");
1844
+ }
1845
+
1846
+ /** Read the GameTank ACP state as a Uint32Array(10): [0]dacReg [1]irqRate
1847
+ * [2]irqCounter [3]running [4]resetting [5]muted [6]volume [7]audioPC
1848
+ * [8]samplesPerFrame [9]clkMult. null if the core doesn't expose it. */
1849
+ getAcpState() {
1850
+ const mod = this.mod;
1851
+ if (!mod || typeof mod._romdev_acp_get !== "function") return null;
1852
+ const ptr = mod._malloc(10 * 4);
1853
+ try {
1854
+ mod._romdev_acp_get(ptr);
1855
+ return new Uint32Array(mod.HEAPU8.buffer, ptr, 10).slice();
1856
+ } finally {
1857
+ mod._free(ptr);
1858
+ }
1859
+ }
1860
+
1788
1861
  /** Read the PS1 SPU's 0x400-word register block as a Uint16Array(1024). null if
1789
1862
  * the core doesn't expose it. */
1790
1863
  getSpuRegs() {
@@ -2381,7 +2454,7 @@ export class LibretroHost {
2381
2454
  // $FF0000-$FFFFFF → system_ram (& 0xFFFF). Sentinel 0 (vector area).
2382
2455
  // 7.67MHz / ~10 cyc/instr / 60 ≈ 12.8k; use 50k (tight loops are denser).
2383
2456
  return { spReg: 18, pcReg: 16, retBytes: 4, retBigEndian: true, defaultSentinel: 0, ramMask: 0xFFFF, instrPerFrame: 50000 };
2384
- case "nes": case "atari2600": case "atari7800": case "lynx": case "c64": case "pce":
2457
+ case "nes": case "atari2600": case "atari7800": case "lynx": case "c64": case "pce": case "gametank":
2385
2458
  // 6502/65C02/HuC6280/6510: A=0,X=1,Y=2,P=3,SP=4,PC=16. RTS pops 2 bytes
2386
2459
  // (PCL,PCH) from the $0100 page. system_ram is the low RAM; the stack page
2387
2460
  // $0100-$01FF maps to system_ram offset 0x100-0x1FF on most of these.
@@ -213,15 +213,33 @@ export function registerCallbacks(args) {
213
213
  }, "v");
214
214
  mod._retro_set_input_poll(inputPollCb);
215
215
 
216
- const inputStateCb = mod.addFunction((port, device, _idx, id) => {
216
+ const inputStateCb = mod.addFunction((port, device, idx, id) => {
217
217
  // device 3 = RETRO_DEVICE_KEYBOARD — id is the libretro keycode
218
218
  if (device === 3) {
219
219
  return state.keysDown.has(id) ? 1 : 0;
220
220
  }
221
- // Default: JOYPAD (device 1) and unknown devices fall through to bitfield.
222
221
  const portBits = state.inputPorts[port];
223
222
  if (!portBits) return 0;
224
223
  const bits = portBits[0];
224
+ // device 5 = RETRO_DEVICE_ANALOG. Games with an analog stick (N64, and the
225
+ // dual-stick consoles) read this INSTEAD of the d-pad for movement — MK64
226
+ // steers entirely off the stick, so a d-pad-only mask leaves the kart dead.
227
+ // We have no real axis source (setInput is digital), so SYNTHESIZE a full-
228
+ // deflection stick from the d-pad bits: left/right → X = -/+32767, up/down →
229
+ // Y = -/+32767. idx 0 = left stick, id 0 = X, id 1 = Y (libretro convention).
230
+ if (device === 5) {
231
+ if (idx === 0) {
232
+ if (id === 0) { // X axis
233
+ if (bits & (1 << 6)) return -32767; // JOYPAD_LEFT
234
+ if (bits & (1 << 7)) return 32767; // JOYPAD_RIGHT
235
+ } else if (id === 1) { // Y axis (up = negative)
236
+ if (bits & (1 << 4)) return -32767; // JOYPAD_UP
237
+ if (bits & (1 << 5)) return 32767; // JOYPAD_DOWN
238
+ }
239
+ }
240
+ return 0;
241
+ }
242
+ // Default: JOYPAD (device 1) and unknown devices fall through to bitfield.
225
243
  if (id === 256) return bits; // RETRO_DEVICE_ID_JOYPAD_MASK
226
244
  return bits & (1 << id) ? 1 : 0;
227
245
  }, "iiiii");
@@ -533,6 +533,23 @@ export function getCPUState(host, platform, cpu = "main") {
533
533
  "(PC-8 in ARM, PC-4 in THUMB). Registers are decimal; cpsr/spsr/execPc are hex.",
534
534
  };
535
535
  }
536
+ if (platform === "gametank") {
537
+ // The patched GameTank core exposes the LIVE W65C02S register file via
538
+ // romdev_getreg (regId: 0=A 1=X 2=Y 3=P 4=SP 16=PC) — no synthesized region
539
+ // needed, read it directly. (At a watch/break HIT the frozen snapshot also
540
+ // comes through romdev_regsnap_get like every other core.)
541
+ const A = host.getReg(0), X = host.getReg(1), Y = host.getReg(2);
542
+ const P = host.getReg(3), SP = host.getReg(4), pc = host.getReg(16);
543
+ return {
544
+ pc, sp: 0x100 | (SP & 0xFF),
545
+ registers: { A, X, Y, P, SP },
546
+ cpu: "65c02",
547
+ flags: {
548
+ N: !!(P & 0x80), V: !!(P & 0x40), B: !!(P & 0x10), D: !!(P & 0x08),
549
+ I: !!(P & 0x04), Z: !!(P & 0x02), C: !!(P & 0x01),
550
+ },
551
+ };
552
+ }
536
553
  if (platform === "lynx") {
537
554
  // Patched handy exposes a 16-byte 65C02 snapshot via lynx_cpu_regs (see
538
555
  // the handy memory-regions patch). Layout: [0] PC lo, [1] PC hi, [2] A,
@@ -0,0 +1,53 @@
1
+ // GameTank ACP (audio coprocessor) state decoder — the "what is the sound doing
2
+ // this frame?" view that getAudioState gives the other platforms' chips.
3
+ //
4
+ // GameTank has no fixed-register synth (no APU/SID); its "sound chip" is a SECOND
5
+ // 65C02 running an audio program in 4 KB RAM that drives an 8-bit DAC at an IRQ
6
+ // rate. So this decode reports the ACP's STATE rather than per-voice registers:
7
+ // the live DAC output, the IRQ/sample rate, run/mute flags, volume, and which
8
+ // audio-CPU routine is executing. Data source: the romdev_acp_get export
9
+ // (gametank core) — a Uint32Array(10) (see LibretroHost.getAcpState for layout).
10
+
11
+ /**
12
+ * @param {Uint32Array|null} a romdev_acp_get block (10 u32) or null
13
+ * @returns {object} audioDebug-shaped state
14
+ */
15
+ export function decodeGameTankAcp(a) {
16
+ if (!a || a.length < 10) return { chip: "acp", playing: false, note: "no ACP data" };
17
+ const dacReg = a[0] & 0xFF;
18
+ const irqRate = a[1] & 0xFF;
19
+ const irqCounter = a[2] & 0xFFFF;
20
+ const running = !!a[3];
21
+ const resetting = !!a[4];
22
+ const muted = !!a[5];
23
+ const volume = a[6] | 0;
24
+ const audioPC = a[7] & 0xFFFF;
25
+ const samplesPerFrame = a[8] >>> 0;
26
+ const clkMult = a[9] & 0xFF;
27
+
28
+ // "playing" = the audio CPU is running, not muted/resetting, and the DAC isn't
29
+ // parked at the midpoint silence ($80) — a coarse but honest activity signal.
30
+ const playing = running && !muted && !resetting && dacReg !== 0x80;
31
+
32
+ return {
33
+ chip: "acp",
34
+ cpu: "65c02", // the audio coprocessor is a second 65C02
35
+ playing,
36
+ registers: {
37
+ dac: "$" + dacReg.toString(16).padStart(2, "0").toUpperCase(),
38
+ irqRate: "$" + irqRate.toString(16).padStart(2, "0").toUpperCase(),
39
+ audioPC: "$" + audioPC.toString(16).padStart(4, "0").toUpperCase(),
40
+ volume,
41
+ },
42
+ dacOutput: dacReg, // 0..255, $80 = silence midpoint
43
+ irqRate, // sets the effective sample rate
44
+ irqCounter,
45
+ samplesPerFrame,
46
+ clkMult,
47
+ running, resetting, muted,
48
+ note: muted ? "ACP muted"
49
+ : resetting ? "ACP in reset"
50
+ : !running ? "ACP idle (not running)"
51
+ : "ACP running — driving the DAC from audio RAM",
52
+ };
53
+ }
@@ -17,9 +17,10 @@ import { toolJsonSchema } from "./tool-registry.js";
17
17
  * hands instructions to an MCP client. Talks ONLY about MCP tool-calling.
18
18
  */
19
19
  export const mcpPreamble = [
20
- "romdev: homebrew retro game development + reverse-engineering for coding agents.",
20
+ "romdev: homebrew retro game development + reverse-engineering for coding agents — 18 platforms (NES through GBA, C64, GameTank, + the 3D consoles N64/PlayStation/Dreamcast).",
21
+ "HARD RULE: NEVER install a host compiler or emulator (no clang/gcc/Xcode/devkitPro/brew/apt, no downloaded emulator). romdev BUNDLES every compiler (cc65, sdcc, gcc, arm/m68k/mips/sh-gcc, tcc, wla, rgbds, vasm, asar, dasm) + every emulator core as WASM and runs them through these tools — build({output:'rom'|'run'}) compiles, loadMedia+frame runs. If you're about to install or call a host toolchain, STOP and use the romdev build tool instead; an install kicking off is a DEFECT to report.",
21
22
  "All ~32 tools register at session init — call any by name directly, no loading step. Each is a domain VERB with an operation axis: memory({op}), build({output}), breakpoint({on}), cpu({op}), sprites({op}), tiles({op}), disasm({target}), romPatch({op}), …",
22
- "RE engine (all 14 platforms): disasm({target:'functions'}) auto-detects functions, disasm({target:'cfg'}) graphs control flow, disasm({target:'xrefs'}) finds cross-references, disasm({target:'decompile'}) emits Ghidra C pseudocode, symbols({op:'analyze'}) maps a ROM's structure in one call.",
23
+ "RE engine (all 18 platforms): disasm({target:'functions'}) auto-detects functions, disasm({target:'cfg'}) graphs control flow, disasm({target:'xrefs'}) finds cross-references, disasm({target:'decompile'}) emits Ghidra C pseudocode, symbols({op:'analyze'}) maps a ROM's structure in one call.",
23
24
  "catalog({op:'categories'}) maps the tools by purpose (a guide, not a gate); catalog({op:'status'}) is a session re-orient.",
24
25
  ].join("\n");
25
26
 
@@ -27,11 +28,14 @@ export const mcpPreamble = [
27
28
  * Skill-channel preamble — talks ONLY about the HTTP routes. No MCP.
28
29
  */
29
30
  export const skillPreamble = [
30
- "romdev gives you homebrew retro game development + reverse-engineering for ~14 platforms (NES, SNES, Game Boy, Genesis, GBA, Atari, C64, and more) — build, run, screenshot, inspect, patch, disassemble, convert assets, drive emulators.",
31
- "It also ships a full RE analysis engine (Rizin + Ghidra, all 14 platforms): control-flow graphs, cross-references, auto-detected functions, a one-shot structural map, and a C-pseudocode decompiler — `disasm({target:'cfg'|'xrefs'|'functions'|'decompile'})` and `symbols({op:'analyze'})`.",
31
+ "romdev gives you homebrew retro game development + reverse-engineering for 18 platforms (NES, SNES, Game Boy, Genesis, GBA, Atari, C64, GameTank, the 3D consoles N64/PlayStation/Dreamcast, and more) — build, run, screenshot, inspect, patch, disassemble, convert assets, drive emulators.",
32
+ "It also ships a full RE analysis engine (Rizin + Ghidra, all 18 platforms): control-flow graphs, cross-references, auto-detected functions, a one-shot structural map, and a C-pseudocode decompiler — `disasm({target:'cfg'|'xrefs'|'functions'|'decompile'})` and `symbols({op:'analyze'})`.",
33
+ "",
34
+ "## HARD RULE: never install a compiler or emulator — romdev already bundles every one",
35
+ "Every compiler/assembler/linker (cc65, sdcc, gcc, arm/m68k/mips/sh-gcc, tcc, wla, rgbds, vasm, asar, dasm) and every emulator core ships as WASM INSIDE romdev and runs in-process through these tools — `build({output:'rom'|'run'})` compiles, `loadMedia`+`frame` runs. You do NOT need, and must NOT install, a host `clang`/`gcc`/Xcode/Command-Line-Tools/devkitPro/`brew`/`apt` compiler or any emulator to build or run a ROM here. If you catch yourself about to install or invoke a host compiler/emulator — STOP. That's never the move: use the romdev `build` tool. (`platform({op:'toolchains'})` lists what's bundled for each platform.) A compiler/emulator install kicking off while using romdev is a DEFECT to report, not a step to take.",
32
36
  "",
33
37
  "## Prerequisite: romdev runs LOCALLY (same machine as you)",
34
- "romdev bundles every compiler + emulator as WASM and runs them in-process that engine lives in the romdev SERVER, started once with `npx romdevtools` (listens on http://localhost:7331; no other install, no host gcc/emulator needed). If a call gets connection-refused, it isn't running — start it.",
38
+ "The romdev SERVER hosts all that bundled WASM in-process; start it once with `npx romdevtools` (listens on http://localhost:7331 that single `npx` is the ONLY install, and it pulls the toolchains/cores as bundled WASM, never a host compiler). If a call gets connection-refused, the server isn't running — start it.",
35
39
  "**romdev runs on the SAME machine as you, and tools take FILESYSTEM PATHS** (`path`, `outputPath`, `modulePath`, `vgmPath`, …) — those are paths on the local disk romdev shares with you, NOT uploads. Pass an absolute local path; romdev reads/writes it directly. (This is also why it's localhost-only and needs no auth.) Likewise output paths land on the local disk where you can read them back.",
36
40
  "",
37
41
  "## How to call romdev",
@@ -58,7 +62,7 @@ export function buildSkillDoc({ registry, agentsBody, version }) {
58
62
  const frontmatter = [
59
63
  "---",
60
64
  "name: romdev",
61
- "description: Homebrew retro game development and ROM reverse-engineering for ~14 platforms (NES, SNES, Game Boy/Color, Genesis, GBA, Atari 2600/7800, Lynx, C64, SMS, Game Gear, PC Engine, MSX). Use when building, running, debugging, disassembling, asset-converting, or romhacking a retro game — drives bundled emulators and toolchains over HTTP.",
65
+ "description: Homebrew retro game development and ROM reverse-engineering for 18 platforms (NES, SNES, Game Boy/Color, Genesis, GBA, Atari 2600/7800, Lynx, C64, SMS, Game Gear, PC Engine, MSX, GameTank, N64, PlayStation, Dreamcast). Use when building, running, debugging, disassembling, asset-converting, or romhacking a retro game — drives bundled emulators and toolchains over HTTP. NEVER install a host compiler/emulator; romdev bundles all of them as WASM (use the build tool).",
62
66
  `metadata:`,
63
67
  ` version: "${version ?? "0.0.0"}"`,
64
68
  "---",
@@ -367,7 +367,7 @@ export function registerAudioTools(server, z, sessionKey) {
367
367
  "sampled SFX fire' on Genesis is still record-and-FFT.",
368
368
  {
369
369
  op: z.enum(["inspect", "record"]).describe("inspect a sound chip's live state (single-frame, or a frames trace); or record audio to a WAV."),
370
- chip: z.enum(["nes", "gb", "gba", "dsp", "psg", "ym2612", "sid", "mikey", "pce", "ay8910", "spu", "ai", "aica"]).optional().describe("op=inspect: which sound chip to decode. Tile systems: nes/gb/gba/dsp(SNES)/psg(SN76489)/ym2612(Genesis FM)/sid(C64)/mikey(Lynx)/pce/ay8910(MSX). 3D systems: spu (PS1, 24 ADPCM voices), ai (N64 AI output), aica (Dreamcast, 64 PCM/ADPCM channels)."),
370
+ chip: z.enum(["nes", "gb", "gba", "dsp", "psg", "ym2612", "sid", "mikey", "pce", "ay8910", "spu", "ai", "aica", "acp"]).optional().describe("op=inspect: which sound chip to decode. Tile systems: nes/gb/gba/dsp(SNES)/psg(SN76489)/ym2612(Genesis FM)/sid(C64)/mikey(Lynx)/pce/ay8910(MSX). 3D systems: spu (PS1, 24 ADPCM voices), ai (N64 AI output), aica (Dreamcast, 64 PCM/ADPCM channels)."),
371
371
  frames: z.number().int().min(1).max(60000).optional().describe("op=record: emulator frames to capture (default 180 = 3s NTSC). op=inspect: if set, TRACE the chip over N frames into a per-channel timeline; OMIT for a single-frame snapshot."),
372
372
  sampleEvery: z.number().int().min(1).default(1).describe("op=inspect trace: sample the chip only every Nth frame (thins a long trace; the last frame is always sampled). Changes on skipped frames surface at the next sampled frame, so >1 loses fine timing — keep at 1 to catch every transition."),
373
373
  path: z.string().optional().describe("op=record: absolute path to write the WAV (required for record)."),
@@ -296,6 +296,44 @@ function extractAtari7800(data) {
296
296
  };
297
297
  }
298
298
 
299
+ // GameTank .gtr — a flat, headerless cart whose mapper is keyed by SIZE
300
+ // (8 KB EEPROM8K / 32 KB EEPROM32K / 2 MB FLASH2M). For the single-bank 32 KB
301
+ // format the 6502 vector table (NMI/RESET/IRQ) is the last 6 bytes ($FFFA), like
302
+ // the 7800. Split into body + vectors (32 KB) or just rom.bin (other sizes), and
303
+ // decode the vectors. No header to strip.
304
+ function extractGameTank(data) {
305
+ const n = data.length;
306
+ const mapper = n === 0x2000 ? "EEPROM8K"
307
+ : n === 0x8000 ? "EEPROM32K"
308
+ : n === 0x200000 ? "FLASH2M"
309
+ : "UNKNOWN";
310
+ const parts = {};
311
+ let vectors = null;
312
+ // The 32 KB single-bank format maps at $8000-$FFFF, so the CPU vector table is
313
+ // the last 6 bytes. (FLASH2M banks the cart — the live vectors are in bank $FF,
314
+ // also the last 6 bytes of the 2 MB image; EEPROM8K mirrors up into $FFFA too.)
315
+ if (n >= 6) {
316
+ parts["body.bin"] = data.slice(0, n - 6);
317
+ parts["vectors.bin"] = data.slice(n - 6);
318
+ const off = n - 6;
319
+ const w = (lo) => "$" + (((data[off + lo + 1] << 8) | data[off + lo]) & 0xFFFF)
320
+ .toString(16).toUpperCase().padStart(4, "0");
321
+ vectors = { nmi: w(0), reset: w(2), irq: w(4) };
322
+ } else {
323
+ parts["rom.bin"] = data.slice(0);
324
+ }
325
+ return {
326
+ parts,
327
+ manifest: {
328
+ platform: "gametank",
329
+ bytes: n,
330
+ mapper, // SIZE is the mapper — keep the byte count exact on re-wrap
331
+ bodyBytes: n >= 6 ? n - 6 : n,
332
+ vectors,
333
+ },
334
+ };
335
+ }
336
+
299
337
  function extractGb(data, platform) {
300
338
  return {
301
339
  parts: {
@@ -348,6 +386,8 @@ export async function extractCartCore({ path: romPath, platform, outputDir, inli
348
386
  case "atari7800":
349
387
  case "a7800": result = extractAtari7800(data); break;
350
388
  case "c64": result = extractC64(data); break;
389
+ case "gametank":
390
+ case "gtr": result = extractGameTank(data); break;
351
391
  default:
352
392
  throw new Error(`extractCart: platform '${resolved}' not supported`);
353
393
  }
@@ -619,6 +659,8 @@ export async function wrapRomFromPartsCore(args) {
619
659
  case "atari7800":
620
660
  case "a7800": return wrapAtari7800(args);
621
661
  case "c64": return wrapC64(args);
662
+ case "gametank":
663
+ case "gtr": return wrapGameTank(args);
622
664
  default:
623
665
  throw new Error(`wrapRomFromParts: platform '${platform}' not supported`);
624
666
  }
@@ -646,6 +688,40 @@ function wrapC64({ loadAddress, bodyPath, romPath }) {
646
688
  return { wrapperSource, linkerConfig: null };
647
689
  }
648
690
 
691
+ /**
692
+ * Wrap GameTank parts back into a flat .gtr. body.bin + vectors.bin (6 bytes at
693
+ * the end) → a size-keyed image (default 32 KB EEPROM32K). The mapper IS the
694
+ * size, so the wrapper PADS to exactly romSize (default $8000) with the vectors
695
+ * forced to the last 6 bytes. Emits a ca65 source that .incbin's the body, pads,
696
+ * then .incbin's the vectors at $FFFA — assemble+link with the gametank preset,
697
+ * or just `cat body.bin <pad> vectors.bin` to the exact size.
698
+ */
699
+ function wrapGameTank({ bodyPath, vectorsPath, romPath, romSize }) {
700
+ if (romPath) {
701
+ // already-flat image — just (re)assert the size by including it verbatim.
702
+ const wrapperSource =
703
+ `; GameTank .gtr wrapper — a prebuilt flat image (size = mapper).
704
+ .incbin "${romPath}"
705
+ `;
706
+ return { wrapperSource, linkerConfig: null };
707
+ }
708
+ const size = romSize ?? 0x8000; // default EEPROM32K
709
+ const body = bodyPath ?? "body.bin";
710
+ const vecs = vectorsPath ?? "vectors.bin";
711
+ // ca65: body at the start of the ROM segment, the 6-byte vector table pinned to
712
+ // $FFFA. The gametank single-bank linker cfg (or a flat cat) places these so the
713
+ // final image is exactly `size` bytes with the vectors last.
714
+ const wrapperSource =
715
+ `; GameTank .gtr wrapper (size-keyed mapper; default EEPROM32K = $${size.toString(16).toUpperCase()} bytes).
716
+ ; body.bin = code/data ($8000..), vectors.bin = the 6-byte NMI/RESET/IRQ table at $FFFA.
717
+ .segment "STARTUP"
718
+ .incbin "${body}"
719
+ .segment "VECTORS"
720
+ .incbin "${vecs}"
721
+ `;
722
+ return { wrapperSource, linkerConfig: "single-bank", romSize: size };
723
+ }
724
+
649
725
  // ─── MCP registration ─────────────────────────────────────────────
650
726
 
651
727
  export function registerCartPartsTools(server, z) {
@@ -21,7 +21,7 @@ export function registerLifecycleTools(server, z, sessionKey) {
21
21
  if (path && base64) throw new Error("loadMedia: provide `path` OR `base64`, not both.");
22
22
  const slotB = slot === "b";
23
23
  const host = slotB ? resetHostB(sessionKey) : resetHost(sessionKey);
24
- await host.loadCore(resolved.jsPath, resolved.wasmPath, { hwRender: resolved.hwRender });
24
+ await host.loadCore(resolved.jsPath, resolved.wasmPath, { hwRender: resolved.hwRender, noderawfs: resolved.noderawfs });
25
25
  const bytes = base64 ? new Uint8Array(Buffer.from(base64, "base64")) : undefined;
26
26
  await host.loadMedia({
27
27
  platform,
@@ -42,6 +42,7 @@ import { decodeLynxMikey, decodeLynxPalette } from "../../host/lynx-mikey-state.
42
42
  import { getPcePsgState } from "../../host/pce-psg-state.js";
43
43
  import { decodePs1Spu } from "../../host/ps1-spu-state.js";
44
44
  import { decodeN64Ai } from "../../host/n64-ai-state.js";
45
+ import { decodeGameTankAcp } from "../../host/gametank-acp-state.js";
45
46
  import { decodeAica } from "../../host/dc-aica-state.js";
46
47
  import { getMsxAyState } from "../../host/msx-ay-state.js";
47
48
  import { decodeGbaSprites, decodeGbaPalette } from "../../host/gba-video-state.js";
@@ -448,7 +449,14 @@ export function registerPlatformTools(server, z, sessionKey) {
448
449
  if (!regs) throw new Error("getAudioState chip:'aica' — no AICA region (load a Dreamcast program into the rebuilt flycast core).");
449
450
  return { platform: "dreamcast", ...decodeAica(regs) };
450
451
  }
451
- throw new Error(`getAudioState: unknown chip '${chip}'. Use 'nes' (NES 2A03), 'gb' (Game Boy/GBC), 'gba' (GBA), 'dsp' (SNES), 'psg' (Genesis/SMS/GG SN76489), 'ym2612' (Genesis FM), 'sid' (C64), 'mikey' (Lynx), 'pce', 'ay8910' (MSX), 'spu' (PS1), or 'aica' (Dreamcast).`);
452
+ if (chip === "acp") {
453
+ // GameTank ACP — a second 65C02 driving a DAC from 4 KB audio RAM (no fixed
454
+ // synth registers), from the patched core's romdev_acp_get state block.
455
+ const regs = host.getAcpState?.();
456
+ if (!regs) throw new Error("getAudioState chip:'acp' — no ACP state (load a GameTank ROM into the patched gametank core).");
457
+ return { platform: "gametank", ...decodeGameTankAcp(regs) };
458
+ }
459
+ throw new Error(`getAudioState: unknown chip '${chip}'. Use 'nes' (NES 2A03), 'gb' (Game Boy/GBC), 'gba' (GBA), 'dsp' (SNES), 'psg' (Genesis/SMS/GG SN76489), 'ym2612' (Genesis FM), 'sid' (C64), 'mikey' (Lynx), 'pce', 'ay8910' (MSX), 'spu' (PS1), 'aica' (Dreamcast), or 'acp' (GameTank).`);
452
460
  }
453
461
 
454
462
  getAudioStateCore = async ({ chip }, callerSessionKey) => jsonContent(readAudioChip(chip, callerSessionKey));
@@ -93,9 +93,17 @@ const PLATFORM_QUIRKS = {
93
93
  };
94
94
 
95
95
  /** op:'list' — every platform with core/toolchains/languages/quirks. */
96
- export function listPlatformsCore() {
96
+ export function listPlatformsCore({ platform, slim } = {}) {
97
97
  const available = new Set(listAvailableCores());
98
- const platforms = Object.entries(CORES).map(([id, info]) => {
98
+ let ids = Object.keys(CORES);
99
+ if (platform) {
100
+ if (!CORES[platform]) {
101
+ throw new Error(`platform({op:'list'}): unknown platform '${platform}'. Known: ${ids.join(", ")}.`);
102
+ }
103
+ ids = [platform]; // per-platform filter — the big token-sink fix (v0.71.0 fb)
104
+ }
105
+ const platforms = ids.map((id) => {
106
+ const info = CORES[id];
99
107
  const toolchains = Object.values(TOOLCHAINS)
100
108
  .filter((t) => t.platforms.includes(id))
101
109
  .map((t) => ({ id: t.id, displayName: t.displayName, tier: t.tier }));
@@ -107,18 +115,45 @@ export function listPlatformsCore() {
107
115
  toolchains,
108
116
  };
109
117
  const langs = getLanguageOptions(id);
110
- if (langs) entry.languages = langs;
111
- if (PLATFORM_QUIRKS[id]) entry.quirks = PLATFORM_QUIRKS[id];
118
+ if (langs) {
119
+ // `slim` drops the heavy per-language `note` + `quirks` prose (the big
120
+ // token sink when you only need "which platforms/toolchains/languages
121
+ // exist"). Detail stays behind platform({op:'doc'}) + op:'capabilities'.
122
+ entry.languages = slim
123
+ ? { defaultLanguage: langs.defaultLanguage,
124
+ languages: (langs.languages || []).map((l) => ({ language: l.language, toolchain: l.toolchain, available: l.available })) }
125
+ : langs;
126
+ }
127
+ if (!slim && PLATFORM_QUIRKS[id]) entry.quirks = PLATFORM_QUIRKS[id];
112
128
  return entry;
113
129
  });
114
- return { platforms };
130
+ // A single-platform query returns just that row (not wrapped in `platforms[]`).
131
+ return platform ? platforms[0] : { platforms };
115
132
  }
116
133
 
117
- /** op:'resolve' — resolved core paths for a platform (debugging aid). */
134
+ /** op:'resolve' — resolved core paths + the toolchain summary for a platform. */
118
135
  export function resolvePlatformCore({ platform }) {
119
136
  const r = resolveCore(platform);
120
137
  if (!r) throw new Error(`no core available for platform '${platform}'`);
121
- return r;
138
+ // Also surface the toolchain(s) — resolve used to report only the emulator
139
+ // core, so agents had to spelunk node_modules to learn the build path (v0.71.0
140
+ // fb #3). We do NOT hand out the WASM/.mjs artifact paths: those tools run ONLY
141
+ // inside romdev's `build` worker harness (virtual FS), so a node_modules path
142
+ // invites the wrong mental model (shimming them into an external Makefile —
143
+ // which does NOT work). The `note` states that plainly (fb #4/#5).
144
+ const toolchains = Object.values(TOOLCHAINS)
145
+ .filter((t) => t.platforms.includes(platform))
146
+ .map((t) => ({ id: t.id, displayName: t.displayName, tier: t.tier }));
147
+ return {
148
+ ...r,
149
+ toolchains,
150
+ toolchainNote:
151
+ "Build via the `build` tool (it compiles a source set into one ROM). The toolchain " +
152
+ "binaries are WASM, run ONLY inside romdev's build worker (virtual FS) — they are NOT " +
153
+ "host-callable and CANNOT back an external project's Makefile. For an existing decomp/" +
154
+ "romhack that needs its own legacy compiler (e.g. agbcc) + Makefile, build it on the host " +
155
+ "and use romdev to run/inspect/debug the resulting ROM.",
156
+ };
122
157
  }
123
158
 
124
159
  export function registerPlatformTools(server, z) {
@@ -142,14 +177,15 @@ export function registerPlatformTools(server, z) {
142
177
  "troubleshooting / upstream_sources; `platform:'romhacking'` + `name:'playbook'` for the RE decision tree). " +
143
178
  "Read MENTAL_MODEL before writing code, and the romhacking playbook before a hack.",
144
179
  {
145
- op: z.enum(["list", "capabilities", "resolve", "toolchains", "docs", "doc"]).describe("list=platforms; capabilities=per-platform op support matrix; resolve=core paths; toolchains; docs=a platform's doc names; doc=read one doc."),
146
- platform: z.string().optional().describe("op=resolve/docs/doc: platform id (e.g. nes, gb, genesis; 'romhacking' for the RE playbook)."),
180
+ op: z.enum(["list", "capabilities", "resolve", "toolchains", "docs", "doc"]).describe("list=platforms (pass `platform` to get just ONE, `slim:true` to drop the verbose notes); capabilities=per-platform op support matrix; resolve=core + toolchain artifact paths; toolchains; docs=a platform's doc names; doc=read one doc."),
181
+ platform: z.string().optional().describe("op=list/resolve/docs/doc/capabilities: platform id (e.g. nes, gb, genesis; 'romhacking' for the RE playbook). On op=list it filters to that ONE platform's row instead of the whole matrix (big token saver)."),
182
+ slim: z.boolean().optional().describe("op=list: drop the heavy per-language `note` + `quirks` prose; return just {platform, toolchains[], languages{defaultLanguage,…}}. Detail stays behind op:'doc' / op:'capabilities'."),
147
183
  id: z.string().optional().describe("op=toolchains: a specific toolchain's install status (e.g. 'cc65')."),
148
184
  name: z.string().optional().describe("op=doc: which doc — mental_model | troubleshooting | upstream_sources | playbook."),
149
185
  },
150
186
  safeTool(async (args) => {
151
187
  switch (args.op) {
152
- case "list": return jsonContent(listPlatformsCore());
188
+ case "list": return jsonContent(listPlatformsCore({ platform: args.platform, slim: args.slim }));
153
189
  case "capabilities": {
154
190
  if (args.platform) {
155
191
  const cap = capabilitiesFor(args.platform);