romdevtools 0.15.0 → 0.21.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 (117) hide show
  1. package/AGENTS.md +61 -13
  2. package/CHANGELOG.md +289 -0
  3. package/README.md +1 -1
  4. package/examples/README.md +2 -0
  5. package/examples/atari2600/templates/platformer.asm +460 -0
  6. package/examples/atari2600/templates/racing.asm +463 -0
  7. package/examples/atari2600/templates/shmup.asm +386 -0
  8. package/examples/atari2600/templates/sports.asm +362 -0
  9. package/examples/atari7800/templates/default.c +49 -5
  10. package/examples/atari7800/templates/platformer.c +43 -4
  11. package/examples/atari7800/templates/puzzle.c +39 -4
  12. package/examples/atari7800/templates/racing.c +39 -4
  13. package/examples/atari7800/templates/shmup.c +40 -2
  14. package/examples/atari7800/templates/sports.c +36 -5
  15. package/examples/c64/templates/platformer.c +19 -5
  16. package/examples/c64/templates/puzzle.c +32 -2
  17. package/examples/c64/templates/shmup.c +28 -2
  18. package/examples/c64/templates/sports.c +30 -2
  19. package/examples/gb/templates/default.c +110 -16
  20. package/examples/gb/templates/platformer.c +25 -4
  21. package/examples/gb/templates/puzzle.c +32 -2
  22. package/examples/gb/templates/racing.c +72 -8
  23. package/examples/gb/templates/shmup.c +38 -1
  24. package/examples/gb/templates/sports.c +48 -1
  25. package/examples/gba/templates/gba_hello.c +29 -11
  26. package/examples/gba/templates/puzzle.c +15 -3
  27. package/examples/gba/templates/racing.c +65 -3
  28. package/examples/gba/templates/shmup.c +41 -4
  29. package/examples/gba/templates/sports.c +36 -2
  30. package/examples/gba/templates/tonc_hello.c +41 -5
  31. package/examples/gbc/templates/default.c +103 -26
  32. package/examples/gbc/templates/platformer.c +25 -4
  33. package/examples/gbc/templates/puzzle.c +32 -2
  34. package/examples/gbc/templates/racing.c +85 -19
  35. package/examples/gbc/templates/shmup.c +34 -1
  36. package/examples/gbc/templates/sports.c +45 -1
  37. package/examples/genesis/templates/puzzle.c +37 -3
  38. package/examples/genesis/templates/racing.c +44 -11
  39. package/examples/genesis/templates/sgdk_hello.c +34 -1
  40. package/examples/genesis/templates/shmup.c +31 -1
  41. package/examples/gg/templates/default.c +56 -18
  42. package/examples/gg/templates/platformer.c +18 -12
  43. package/examples/gg/templates/puzzle.c +38 -7
  44. package/examples/gg/templates/racing.c +51 -5
  45. package/examples/gg/templates/shmup.c +47 -3
  46. package/examples/gg/templates/sports.c +46 -3
  47. package/examples/lynx/templates/default.c +39 -8
  48. package/examples/lynx/templates/puzzle.c +28 -1
  49. package/examples/lynx/templates/racing.c +34 -7
  50. package/examples/lynx/templates/shmup.c +42 -3
  51. package/examples/lynx/templates/sports.c +29 -2
  52. package/examples/msx/platformer/main.c +213 -0
  53. package/examples/msx/puzzle/main.c +250 -0
  54. package/examples/msx/racing/main.c +249 -0
  55. package/examples/msx/shmup/main.c +288 -0
  56. package/examples/msx/sports/main.c +182 -0
  57. package/examples/nes/templates/default.c +67 -19
  58. package/examples/nes/templates/platformer.c +65 -6
  59. package/examples/nes/templates/puzzle.c +67 -6
  60. package/examples/nes/templates/racing.c +45 -13
  61. package/examples/nes/templates/shmup.c +51 -2
  62. package/examples/nes/templates/sports.c +51 -6
  63. package/examples/pce/platformer/main.c +283 -0
  64. package/examples/pce/puzzle/main.c +304 -0
  65. package/examples/pce/racing/main.c +304 -0
  66. package/examples/pce/shmup/main.c +346 -0
  67. package/examples/pce/sports/main.c +254 -0
  68. package/examples/sms/main.c +35 -6
  69. package/examples/sms/templates/puzzle.c +34 -5
  70. package/examples/sms/templates/racing.c +39 -2
  71. package/examples/sms/templates/shmup.c +41 -2
  72. package/examples/sms/templates/sports.c +43 -2
  73. package/examples/snes/templates/default.c +50 -28
  74. package/examples/snes/templates/platformer-data.asm +22 -0
  75. package/examples/snes/templates/platformer.c +16 -1
  76. package/examples/snes/templates/puzzle-data.asm +22 -0
  77. package/examples/snes/templates/puzzle.c +17 -1
  78. package/examples/snes/templates/racing-data.asm +22 -0
  79. package/examples/snes/templates/racing.c +17 -1
  80. package/examples/snes/templates/shmup-data.asm +22 -0
  81. package/examples/snes/templates/shmup.c +20 -1
  82. package/examples/snes/templates/sports-data.asm +22 -0
  83. package/examples/snes/templates/sports.c +16 -1
  84. package/package.json +1 -1
  85. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  86. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  87. package/src/host/LibretroHost.js +122 -1
  88. package/src/host/callbacks.js +9 -1
  89. package/src/host/types.js +15 -8
  90. package/src/http/skill-doc.js +1 -1
  91. package/src/http/tool-registry.js +27 -2
  92. package/src/mcp/tools/cart-parts.js +75 -3
  93. package/src/mcp/tools/disasm-rebuild.js +507 -0
  94. package/src/mcp/tools/disasm.js +95 -6
  95. package/src/mcp/tools/frame.js +168 -3
  96. package/src/mcp/tools/index.js +4 -4
  97. package/src/mcp/tools/lifecycle.js +4 -2
  98. package/src/mcp/tools/project.js +54 -9
  99. package/src/mcp/tools/state.js +201 -14
  100. package/src/mcp/tools/toolchain.js +89 -4
  101. package/src/mcp/tools/watch-memory.js +125 -14
  102. package/src/platforms/c64/MENTAL_MODEL.md +45 -1
  103. package/src/platforms/c64/d64.js +281 -0
  104. package/src/platforms/gb/MENTAL_MODEL.md +10 -0
  105. package/src/platforms/msx/MENTAL_MODEL.md +10 -6
  106. package/src/platforms/nes/MENTAL_MODEL.md +63 -2
  107. package/src/platforms/pce/MENTAL_MODEL.md +9 -4
  108. package/src/platforms/pce/lib/c/pce_video.c +1 -1
  109. package/src/rom-id/identifier.js +15 -0
  110. package/src/toolchains/cc65/cc65.js +8 -1
  111. package/src/toolchains/cc65/ines.js +145 -0
  112. package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
  113. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
  114. package/src/toolchains/common/reassemble.js +10 -2
  115. package/src/toolchains/gba-c/gba-c.js +6 -1
  116. package/src/toolchains/genesis-c/genesis-c.js +10 -2
  117. package/src/toolchains/parse-errors.js +67 -5
@@ -43,6 +43,21 @@ const PLATFORM_CORE_OPTIONS = {
43
43
  // `… - C-BIOS` machine tree ships in romdev-core-bluemsx/bios and is mirrored
44
44
  // into the wasm FS as the system dir (see loadMedia + resolveSystemDir).
45
45
  msx: { bluemsx_msxtype: "MSX2+ - C-BIOS" },
46
+ // VICE mounts a .d64/.tap/.crt but, with autostart off, just sits at the BASIC
47
+ // `READY.` prompt — the agent would see a blue boot screen, not the game. Force
48
+ // autostart so a disk/tape image runs the first program automatically (same as
49
+ // typing LOAD"*",8,1 : RUN). warp-during-autostart skips the slow 1541 load so
50
+ // the game is up in a fraction of the wall-clock. A bare .prg is injected and
51
+ // run directly by the core regardless of this; it matters for disk/tape/cart.
52
+ c64: {
53
+ vice_autostart: "enabled",
54
+ vice_autoloadwarp: "enabled",
55
+ vice_warp_boost: "enabled",
56
+ // Leave the mounted disk WRITABLE so a save-capable game can write its save
57
+ // files back into the .d64 (VICE updates the in-FS image in place). Without
58
+ // this a game's SAVE silently fails / errors — defeating disk-save support.
59
+ vice_floppy_write_protection: "disabled",
60
+ },
46
61
  };
47
62
 
48
63
  /**
@@ -188,7 +203,11 @@ export class LibretroHost {
188
203
  async loadMedia(args) {
189
204
  const mod = this._needMod();
190
205
  const { platform } = args;
191
- const mediaKind = args.mediaKind ?? defaultMediaKind(platform);
206
+ // Derive the kind from the file/virtual extension when the caller didn't say
207
+ // — so a C64 .d64 reports mediaKind:"disk" (writable save target) vs a .prg
208
+ // "program". For an in-memory load, the virtualName carries the ext.
209
+ const kindExt = path.extname(args.path || args.virtualName || "");
210
+ const mediaKind = args.mediaKind ?? defaultMediaKind(platform, kindExt);
192
211
 
193
212
  // Apply per-platform core option defaults BEFORE retro_load_game.
194
213
  // Most cores work with their option defaults; a few need explicit
@@ -676,6 +695,89 @@ export class LibretroHost {
676
695
  mod.HEAPU8.set(bytes, ptr + offset);
677
696
  }
678
697
 
698
+ // ── C64 disk image read/write (VICE) ───────────────────────────────────────
699
+ // The VICE WASM core takes content in-memory and exposes no disk memory region,
700
+ // so these go through dedicated core exports that read/write the LIVE mounted
701
+ // 1541 disk_image_t directly (see scripts/patches/vice-romdev-memory-regions.patch).
702
+ // Only the standard 35-track 1541 .d64 (174848 bytes) is supported. unit 8.
703
+
704
+ /** True if the loaded core exposes the romdev disk read/write exports. */
705
+ diskImageSupported() {
706
+ const mod = this.mod;
707
+ return !!(mod && typeof mod._romdev_disk_export === "function"
708
+ && typeof mod._romdev_disk_import === "function");
709
+ }
710
+
711
+ /**
712
+ * Read the LIVE mounted disk image out as a flat .d64.
713
+ * @param {number} [unit] drive unit (default 8)
714
+ * @returns {Uint8Array} the .d64 bytes (copied out of WASM memory)
715
+ */
716
+ exportDiskImage(unit = 8) {
717
+ const mod = this._needMod();
718
+ if (typeof mod._romdev_disk_export !== "function") {
719
+ throw new Error("this core build does not expose disk export (C64/VICE only).");
720
+ }
721
+ const len = mod._romdev_disk_export(unit >>> 0, 0) >>> 0;
722
+ if (!len) {
723
+ throw new Error("no disk image mounted on this unit, or it is not a 35-track .d64. " +
724
+ "Load a .d64 with loadMedia({platform:'c64', path}) first.");
725
+ }
726
+ const ptr = mod._romdev_disk_ptr();
727
+ // copy out — the core buffer is reused on the next export
728
+ return mod.HEAPU8.slice(ptr, ptr + len);
729
+ }
730
+
731
+ /**
732
+ * Write a flat .d64 back into the LIVE mounted disk image (sector by sector).
733
+ * @param {Uint8Array} bytes a 174848-byte .d64
734
+ * @param {number} [unit] drive unit (default 8)
735
+ * @returns {number} bytes written
736
+ */
737
+ importDiskImage(bytes, unit = 8) {
738
+ const mod = this._needMod();
739
+ if (typeof mod._romdev_disk_import !== "function") {
740
+ throw new Error("this core build does not expose disk import (C64/VICE only).");
741
+ }
742
+ const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
743
+ const ptr = mod._malloc(data.length);
744
+ try {
745
+ mod.HEAPU8.set(data, ptr);
746
+ const n = mod._romdev_disk_import(ptr, data.length >>> 0, unit >>> 0, 0) >>> 0;
747
+ if (!n) throw new Error("disk import failed — no writable .d64 mounted on this unit.");
748
+ return n;
749
+ } finally {
750
+ mod._free(ptr);
751
+ }
752
+ }
753
+
754
+ /**
755
+ * Write ONE PRG file (name + bytes incl. its 2-byte load address) straight into
756
+ * the LIVE mounted disk via the vdrive — the "inject a save" primitive.
757
+ * @param {string} name file name (PETSCII, ≤16 chars)
758
+ * @param {Uint8Array} bytes the PRG file bytes (load address + body)
759
+ * @param {number} [unit] drive unit (default 8)
760
+ */
761
+ putDiskFile(name, bytes, unit = 8) {
762
+ const mod = this._needMod();
763
+ if (typeof mod._romdev_disk_putfile !== "function") {
764
+ throw new Error("this core build does not expose disk putfile (C64/VICE only).");
765
+ }
766
+ const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
767
+ const nameBytes = Buffer.from(String(name) + "\0", "latin1");
768
+ const namePtr = mod._malloc(nameBytes.length);
769
+ const dataPtr = mod._malloc(data.length || 1);
770
+ try {
771
+ mod.HEAPU8.set(nameBytes, namePtr);
772
+ mod.HEAPU8.set(data, dataPtr);
773
+ const rc = mod._romdev_disk_putfile(unit >>> 0, namePtr, dataPtr, data.length >>> 0);
774
+ if (rc !== 0) throw new Error(`disk putfile failed (rc=${rc}) — no writable .d64 mounted, or the disk is full.`);
775
+ } finally {
776
+ mod._free(namePtr);
777
+ mod._free(dataPtr);
778
+ }
779
+ }
780
+
679
781
  reset() {
680
782
  const mod = this._needMod();
681
783
  mod._retro_reset();
@@ -1448,6 +1550,25 @@ export class LibretroHost {
1448
1550
  */
1449
1551
  _emptyRegionError(region) {
1450
1552
  const plat = this.status && this.status.platform;
1553
+ // SRAM gets an honest, specific answer: empty save_ram almost always means
1554
+ // "this cart/system has no battery save," NOT "the core is broken."
1555
+ if (region === "save_ram") {
1556
+ if (["atari2600", "atari7800", "lynx"].includes(plat)) {
1557
+ return `'${plat}' has no cartridge battery saves (the hardware never supported them) — ` +
1558
+ `save_ram is always empty here, there's no save file to read/write.`;
1559
+ }
1560
+ if (plat === "c64") {
1561
+ return `C64 has no cartridge battery SRAM — the C64 save medium is the FLOPPY (.d64), ` +
1562
+ `not save_ram (so save_ram is empty, as expected). A game's own KERNAL SAVE writes ` +
1563
+ `into the live disk; capture it with state({op:'exportDisk', path}) (the .d64 then ` +
1564
+ `includes the saved file, re-loadable to resume). Inject an outside save with ` +
1565
+ `state({op:'importDisk', path}) or state({op:'putDiskFile', path}).`;
1566
+ }
1567
+ return `save_ram is empty on platform '${plat}': this CART has no battery save ` +
1568
+ `(check cart({op:'identify'}).saveRam.hasBattery — many ROMs use passwords or no save). ` +
1569
+ `If you expected a save, confirm the cart header marks it battery-backed. ` +
1570
+ `For a full-machine snapshot regardless of SRAM, use state({op:'save'/'load', path}).`;
1571
+ }
1451
1572
  const suggestions = {
1452
1573
  // platform → { generic-region-name: "use this instead" }
1453
1574
  gb: { video_ram: "gb_vram", save_ram: "save_ram (likely empty on cartless ROMs — try gb_oam / gb_io / gb_hram for non-VRAM state)" },
@@ -310,10 +310,18 @@ function handleEnv(mod, state, rawCmd, dataPtr, log) {
310
310
  const semi = desc.indexOf("; ");
311
311
  if (semi >= 0) {
312
312
  const options = desc.substring(semi + 2).split("|");
313
+ // PRESERVE a value the host already pre-seeded (e.g. via
314
+ // PLATFORM_CORE_OPTIONS, set before retro_load_game). The core
315
+ // registering its variables must NOT clobber that override back to
316
+ // its own default (options[0]) — that silently reset forced options
317
+ // like bluemsx's machine type / cart mapper. Keep the prior value if
318
+ // it's still a valid option; otherwise fall back to the default.
319
+ const prior = state.coreVariables.get(key);
320
+ const keep = prior && options.includes(prior.value) ? prior.value : options[0];
313
321
  state.coreVariables.set(key, {
314
322
  description: desc.substring(0, semi),
315
323
  options,
316
- value: options[0],
324
+ value: keep,
317
325
  });
318
326
  }
319
327
  ptr += 8;
package/src/host/types.js CHANGED
@@ -223,18 +223,25 @@ export const MemoryRegionToRetro = {
223
223
  */
224
224
 
225
225
  /**
226
- * Default mediaKind for a platform when caller doesn't specify.
227
- * Consoles default to cartridge; C64 defaults to program (`.prg`).
226
+ * Default mediaKind for a platform when caller doesn't specify. Consoles default
227
+ * to cartridge; C64 depends on the file kind — a `.d64`/`.g64`/`.d71`/`.d81` is a
228
+ * disk, a `.tap` is a tape, a `.crt` is a cartridge, and a bare `.prg`/`.p00` is
229
+ * a program injected directly. The extension is passed when known (loadMedia has
230
+ * it) so disk/tape images report honestly in status() and the agent knows a
231
+ * writable disk exists for saves.
228
232
  * @param {string} platform
233
+ * @param {string} [ext] lower- or mixed-case file extension incl. dot, e.g. ".d64"
229
234
  * @returns {MediaKind}
230
235
  */
231
- export function defaultMediaKind(platform) {
232
- switch (platform) {
233
- case "c64":
234
- return "program";
235
- default:
236
- return "cartridge";
236
+ export function defaultMediaKind(platform, ext) {
237
+ if (platform === "c64") {
238
+ const e = (ext || "").toLowerCase();
239
+ if (/\.(d64|g64|d71|d81|d80|d82|nib)$/.test(e)) return "disk";
240
+ if (/\.(tap|t64)$/.test(e)) return "tape";
241
+ if (/\.(crt|bin)$/.test(e)) return "cartridge";
242
+ return "program"; // .prg / .p00 / unknown → injected program
237
243
  }
244
+ return "cartridge";
238
245
  }
239
246
 
240
247
  /**
@@ -18,7 +18,7 @@ import { toolJsonSchema } from "./tool-registry.js";
18
18
  */
19
19
  export const mcpPreamble = [
20
20
  "romdev: homebrew retro game development + reverse-engineering for coding agents.",
21
- "All ~34 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}), …",
21
+ "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
22
  "catalog({op:'categories'}) maps the tools by purpose (a guide, not a gate); catalog({op:'status'}) is a session re-orient.",
23
23
  ].join("\n");
24
24
 
@@ -1,7 +1,7 @@
1
1
  // Tool registry harvester — the single source the HTTP route/skill/OpenAPI
2
2
  // surfaces build from.
3
3
  //
4
- // The MCP path registers 34 tools via registerTools(server, z, sessionKey),
4
+ // The MCP path registers 32 tools via registerTools(server, z, sessionKey),
5
5
  // where `server` is an McpServer and each handler closes over `sessionKey` for
6
6
  // per-session host isolation. The HTTP surfaces (POST /tool/{name},
7
7
  // /skills/romdev/SKILL.md, /openapi.json, /documentation) want the EXACT same handlers,
@@ -128,7 +128,32 @@ export async function runTool(tool, args, sessionKey) {
128
128
  emit({ ok: false, error: text });
129
129
  return { ok: false, error: text };
130
130
  }
131
- const images = extractImages(r);
131
+ // Observer sidebands — identical to the MCP observer middleware (tool-wrap.js),
132
+ // because the HTTP path runs handlers directly and would otherwise drop them:
133
+ // • _observerImages — a frame the tool wrote to DISK instead of returning
134
+ // inline (e.g. screenshot({path:...})); surface it to /livestream anyway.
135
+ // • _observerFrameProvider — a DEFERRED framebuffer thunk (frame({op:'verify'}),
136
+ // watch/breakpoint tools): the tool advanced/looked at the emulator but
137
+ // returns JSON-only to the caller. We encode the PNG ASYNC (setImmediate,
138
+ // after the HTTP response goes out) and push it as a `call_frame` event so
139
+ // the human's livestream sees the frame at zero cost to the caller.
140
+ // Strip both from the caller-visible result before it's serialized.
141
+ let sidebandImages = [];
142
+ let frameProvider = null;
143
+ if (r && typeof r === "object") {
144
+ if (Array.isArray(r._observerImages)) { sidebandImages = r._observerImages; delete r._observerImages; }
145
+ if (typeof r._observerFrameProvider === "function") { frameProvider = r._observerFrameProvider; delete r._observerFrameProvider; }
146
+ }
147
+ if (frameProvider) {
148
+ setImmediate(() => {
149
+ try {
150
+ const img = frameProvider();
151
+ if (img) observer.push({ type: "call_frame", sessionKey: sessionKey ?? "http", ts: startedAt, tool: tool.name, images: [img] });
152
+ } catch { /* livestream is best-effort; never affects the caller */ }
153
+ });
154
+ }
155
+ const inlineImages = extractImages(r);
156
+ const images = inlineImages.length > 0 ? inlineImages : sidebandImages;
132
157
  const text = r?.content?.[0]?.text;
133
158
  if (typeof text === "string") {
134
159
  // most tools return jsonContent(...) → text is JSON; parse it back so the
@@ -11,6 +11,66 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
11
11
  import path from "node:path";
12
12
  import { jsonContent, safeTool } from "../util.js";
13
13
  import { identifyRomCore } from "./rom-id.js";
14
+ import { prgToD64, readDirectory as readD64Dir, extractFile as extractD64File } from "../../platforms/c64/d64.js";
15
+
16
+ // ─── C64 .d64 disk image ──────────────────────────────────────────
17
+ //
18
+ // The C64 world ships and loads games as .d64 disk images (the new Commodore 64
19
+ // Ultimate FPGA hardware + the homebrew/demo scene), not as bare .prg files. A
20
+ // cc65 build emits a .prg; packDisk wraps it into a distributable, autostart-able
21
+ // .d64. Extract on a .d64 lists/pulls its files back out.
22
+
23
+ /**
24
+ * Pack a built .prg into a .d64 disk image (the distribution format).
25
+ * @param {{prgPath?:string, bodyPath?:string, romPath?:string, base64?:string,
26
+ * outputPath?:string, name?:string, diskName?:string, inline?:boolean}} args
27
+ */
28
+ export async function packDiskCore(args) {
29
+ const src = args.prgPath || args.bodyPath || args.romPath;
30
+ let prg;
31
+ if (args.base64) prg = new Uint8Array(Buffer.from(args.base64, "base64"));
32
+ else if (src) prg = new Uint8Array(await readFile(src));
33
+ else throw new Error("cart({op:'packDisk'}): provide `prgPath` (the built .prg) or `base64`.");
34
+
35
+ const name = (args.name || (src ? path.basename(src).replace(/\.[^.]+$/, "") : "GAME"))
36
+ .toUpperCase().replace(/[^A-Z0-9 ]/g, "").slice(0, 16) || "GAME";
37
+ const d64 = prgToD64(prg, { name, diskName: args.diskName || name });
38
+
39
+ if (args.inline) {
40
+ return { packed: true, format: "d64", name, bytes: d64.length, base64: Buffer.from(d64).toString("base64") };
41
+ }
42
+ const out = args.outputPath
43
+ || (src ? src.replace(/\.[^.]+$/, "") + ".d64" : null);
44
+ if (!out) throw new Error("cart({op:'packDisk'}): `outputPath` required (or pass `prgPath` to derive it, or inline:true).");
45
+ await writeFile(out, Buffer.from(d64));
46
+ return {
47
+ packed: true, format: "d64", name, bytes: d64.length, path: out,
48
+ note: "Autostart-able 1541 disk image. Load it with loadMedia({platform:'c64', path}) — it boots the program automatically. This is the format the Commodore 64 Ultimate hardware and the homebrew scene load.",
49
+ };
50
+ }
51
+
52
+ /** Read a .d64's directory + (optionally) extract a file. */
53
+ export async function extractDiskCore(args) {
54
+ const data = new Uint8Array(await readFile(args.path));
55
+ const dir = readD64Dir(data);
56
+ const result = { format: "d64", path: args.path, files: dir };
57
+ // If a specific file was named, also return its bytes.
58
+ const which = args.name;
59
+ if (which != null) {
60
+ const bytes = extractD64File(data, which);
61
+ if (!bytes) throw new Error(`cart({op:'extract'}) .d64: no file '${which}' on the disk (have: ${dir.map((d) => d.name).join(", ") || "none"}).`);
62
+ if (args.inline) result.file = { name: which, bytes: bytes.length, base64: Buffer.from(bytes).toString("base64") };
63
+ else {
64
+ const out = args.outputDir
65
+ ? path.join(args.outputDir, which.replace(/[^A-Za-z0-9._-]/g, "_") + ".prg")
66
+ : args.path.replace(/\.d64$/i, "") + "." + which.replace(/[^A-Za-z0-9._-]/g, "_") + ".prg";
67
+ if (args.outputDir) await mkdir(args.outputDir, { recursive: true });
68
+ await writeFile(out, Buffer.from(bytes));
69
+ result.file = { name: which, bytes: bytes.length, path: out };
70
+ }
71
+ }
72
+ return result;
73
+ }
14
74
 
15
75
  // ─── extractCart ──────────────────────────────────────────────────
16
76
 
@@ -590,7 +650,7 @@ function wrapC64({ loadAddress, bodyPath, romPath }) {
590
650
  export function registerCartPartsTools(server, z) {
591
651
  server.tool(
592
652
  "cart",
593
- "Cartridge container ops — identify / split / reassemble a ROM file. `op`: 'identify' | 'extract' | 'wrap'.\n" +
653
+ "Cartridge container ops — identify / split / reassemble a ROM file. `op`: 'identify' | 'extract' | 'wrap' | 'packDisk'.\n" +
594
654
  "'identify': sniff an unknown ROM/zip's platform (which core to load). Handles zip-wrapped ROMs; `path` OR " +
595
655
  "`base64` (+`hint` ext for headerless). Returns {platform, format, title, mapper, region, sizes, confidence}. " +
596
656
  "RE next steps: cheats({op:'lookup'}) is a free labeled memory/code map; disasm is how you change behavior.\n" +
@@ -601,9 +661,13 @@ export function registerCartPartsTools(server, z) {
601
661
  "Round-trips with 'wrap' (extract → romPatch a part → wrap → build).\n" +
602
662
  "'wrap': generate a build-ready wrapper source (+ NES linker config; null for other platforms) that reassembles " +
603
663
  "parts back into a cart. NES auto-generates the iNES header from mapper+mirror (chrPath:null for CHR-RAM; only " +
604
- "prgBanks 1/2 = NROM-128/256). Per-platform part paths in the param hints (pass `romPath` for a one-shot whole-body incbin).",
664
+ "prgBanks 1/2 = NROM-128/256). Per-platform part paths in the param hints (pass `romPath` for a one-shot whole-body incbin).\n" +
665
+ "'packDisk' (C64): wrap a built `.prg` (`prgPath` or `base64`) into a distributable, autostart-able `.d64` disk " +
666
+ "image — the format the new Commodore 64 Ultimate hardware and the homebrew/demo scene actually load. " +
667
+ "Writes `<prg>.d64` (or `outputPath`/`inline`). loadMedia({platform:'c64', path:<.d64>}) boots it directly. " +
668
+ "(extract on a `.d64` lists its directory; pass `name` to pull one file off the disk.)",
605
669
  {
606
- op: z.enum(["identify", "extract", "wrap"]).describe("identify the ROM's platform; extract into parts; wrap parts back into a cart."),
670
+ op: z.enum(["identify", "extract", "wrap", "packDisk"]).describe("identify the ROM's platform; extract into parts (or list/pull files from a C64 .d64); wrap parts back into a cart; packDisk wraps a C64 .prg into a distributable .d64 disk image."),
607
671
  // identify
608
672
  path: z.string().optional().describe("op=identify/extract: absolute path to the ROM file."),
609
673
  base64: z.string().optional().describe("op=identify: base64 ROM bytes (OR path)."),
@@ -631,18 +695,26 @@ export function registerCartPartsTools(server, z) {
631
695
  a78HeaderPath: z.string().optional().describe("op=wrap Atari 7800: the 128-byte A78 header (if present)."),
632
696
  bodyBytes: z.number().int().min(1).optional().describe("op=wrap Atari 7800: size of the 6502 image body (computes the cart origin; default 0xC000)."),
633
697
  loadAddress: z.number().int().min(0).max(0xFFFF).optional().describe("op=wrap C64: load address (default 0x0801)."),
698
+ // packDisk (C64 .d64)
699
+ name: z.string().optional().describe("op=packDisk: disk file name (PETSCII, ≤16 chars; default from prgPath). op=extract .d64: a file to pull off the disk."),
700
+ diskName: z.string().optional().describe("op=packDisk: disk label (≤16 chars; default = name)."),
701
+ outputPath: z.string().optional().describe("op=extract: dir for parts (+ manifest.json). op=packDisk: .d64 output path (default: prgPath with a .d64 extension). Required unless inline:true."),
634
702
  },
635
703
  safeTool(async (args) => {
636
704
  switch (args.op) {
637
705
  case "identify": return await identifyRomCore(args);
638
706
  case "extract": {
639
707
  if (!args.path) throw new Error("cart({op:'extract'}): `path` is required.");
708
+ // A .d64 is a disk image (a container of files), not a flat cart —
709
+ // route it to the disk reader so extract lists/pulls its contents.
710
+ if (/\.d64$/i.test(args.path)) return jsonContent(await extractDiskCore(args));
640
711
  return jsonContent(await extractCartCore(args));
641
712
  }
642
713
  case "wrap": {
643
714
  if (!args.platform) throw new Error("cart({op:'wrap'}): `platform` is required.");
644
715
  return jsonContent(await wrapRomFromPartsCore(args));
645
716
  }
717
+ case "packDisk": return jsonContent(await packDiskCore(args));
646
718
  default: throw new Error(`cart: unknown op '${args.op}'`);
647
719
  }
648
720
  }),