romdevtools 0.16.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.
- package/AGENTS.md +60 -12
- package/CHANGELOG.md +258 -0
- package/examples/README.md +2 -0
- package/examples/atari2600/templates/platformer.asm +460 -0
- package/examples/atari2600/templates/racing.asm +463 -0
- package/examples/atari2600/templates/shmup.asm +386 -0
- package/examples/atari2600/templates/sports.asm +362 -0
- package/examples/atari7800/templates/default.c +49 -5
- package/examples/atari7800/templates/platformer.c +43 -4
- package/examples/atari7800/templates/puzzle.c +39 -4
- package/examples/atari7800/templates/racing.c +39 -4
- package/examples/atari7800/templates/shmup.c +40 -2
- package/examples/atari7800/templates/sports.c +36 -5
- package/examples/c64/templates/platformer.c +19 -5
- package/examples/c64/templates/puzzle.c +32 -2
- package/examples/c64/templates/shmup.c +28 -2
- package/examples/c64/templates/sports.c +30 -2
- package/examples/gb/templates/default.c +110 -16
- package/examples/gb/templates/platformer.c +25 -4
- package/examples/gb/templates/puzzle.c +32 -2
- package/examples/gb/templates/racing.c +72 -8
- package/examples/gb/templates/shmup.c +38 -1
- package/examples/gb/templates/sports.c +48 -1
- package/examples/gba/templates/gba_hello.c +29 -11
- package/examples/gba/templates/puzzle.c +15 -3
- package/examples/gba/templates/racing.c +65 -3
- package/examples/gba/templates/shmup.c +41 -4
- package/examples/gba/templates/sports.c +36 -2
- package/examples/gba/templates/tonc_hello.c +41 -5
- package/examples/gbc/templates/default.c +103 -26
- package/examples/gbc/templates/platformer.c +25 -4
- package/examples/gbc/templates/puzzle.c +32 -2
- package/examples/gbc/templates/racing.c +85 -19
- package/examples/gbc/templates/shmup.c +34 -1
- package/examples/gbc/templates/sports.c +45 -1
- package/examples/genesis/templates/puzzle.c +37 -3
- package/examples/genesis/templates/racing.c +44 -11
- package/examples/genesis/templates/sgdk_hello.c +34 -1
- package/examples/genesis/templates/shmup.c +31 -1
- package/examples/gg/templates/default.c +56 -18
- package/examples/gg/templates/platformer.c +18 -12
- package/examples/gg/templates/puzzle.c +38 -7
- package/examples/gg/templates/racing.c +51 -5
- package/examples/gg/templates/shmup.c +47 -3
- package/examples/gg/templates/sports.c +46 -3
- package/examples/lynx/templates/default.c +39 -8
- package/examples/lynx/templates/puzzle.c +28 -1
- package/examples/lynx/templates/racing.c +34 -7
- package/examples/lynx/templates/shmup.c +42 -3
- package/examples/lynx/templates/sports.c +29 -2
- package/examples/msx/platformer/main.c +213 -0
- package/examples/msx/puzzle/main.c +250 -0
- package/examples/msx/racing/main.c +249 -0
- package/examples/msx/shmup/main.c +288 -0
- package/examples/msx/sports/main.c +182 -0
- package/examples/nes/templates/default.c +67 -19
- package/examples/nes/templates/platformer.c +65 -6
- package/examples/nes/templates/puzzle.c +67 -6
- package/examples/nes/templates/racing.c +45 -13
- package/examples/nes/templates/shmup.c +51 -2
- package/examples/nes/templates/sports.c +51 -6
- package/examples/pce/platformer/main.c +283 -0
- package/examples/pce/puzzle/main.c +304 -0
- package/examples/pce/racing/main.c +304 -0
- package/examples/pce/shmup/main.c +346 -0
- package/examples/pce/sports/main.c +254 -0
- package/examples/sms/main.c +35 -6
- package/examples/sms/templates/puzzle.c +34 -5
- package/examples/sms/templates/racing.c +39 -2
- package/examples/sms/templates/shmup.c +41 -2
- package/examples/sms/templates/sports.c +43 -2
- package/examples/snes/templates/default.c +50 -28
- package/examples/snes/templates/platformer-data.asm +22 -0
- package/examples/snes/templates/platformer.c +16 -1
- package/examples/snes/templates/puzzle-data.asm +22 -0
- package/examples/snes/templates/puzzle.c +17 -1
- package/examples/snes/templates/racing-data.asm +22 -0
- package/examples/snes/templates/racing.c +17 -1
- package/examples/snes/templates/shmup-data.asm +22 -0
- package/examples/snes/templates/shmup.c +20 -1
- package/examples/snes/templates/sports-data.asm +22 -0
- package/examples/snes/templates/sports.c +16 -1
- package/package.json +1 -1
- package/src/cores/wasm/vice_x64_libretro.js +1 -1
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +122 -1
- package/src/host/callbacks.js +9 -1
- package/src/host/types.js +15 -8
- package/src/http/tool-registry.js +26 -1
- package/src/mcp/tools/cart-parts.js +75 -3
- package/src/mcp/tools/disasm-rebuild.js +507 -0
- package/src/mcp/tools/disasm.js +95 -6
- package/src/mcp/tools/frame.js +168 -3
- package/src/mcp/tools/lifecycle.js +4 -2
- package/src/mcp/tools/project.js +54 -9
- package/src/mcp/tools/state.js +201 -14
- package/src/mcp/tools/toolchain.js +76 -3
- package/src/mcp/tools/watch-memory.js +125 -14
- package/src/platforms/c64/MENTAL_MODEL.md +45 -1
- package/src/platforms/c64/d64.js +281 -0
- package/src/platforms/gb/MENTAL_MODEL.md +10 -0
- package/src/platforms/msx/MENTAL_MODEL.md +10 -6
- package/src/platforms/nes/MENTAL_MODEL.md +63 -2
- package/src/platforms/pce/MENTAL_MODEL.md +9 -4
- package/src/platforms/pce/lib/c/pce_video.c +1 -1
- package/src/rom-id/identifier.js +15 -0
- package/src/toolchains/cc65/ines.js +145 -0
- package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
- package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
- package/src/toolchains/common/reassemble.js +10 -2
package/src/host/LibretroHost.js
CHANGED
|
@@ -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
|
-
|
|
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)" },
|
package/src/host/callbacks.js
CHANGED
|
@@ -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:
|
|
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
|
-
*
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
/**
|
|
@@ -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
|
-
|
|
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
|
}),
|