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.
- package/AGENTS.md +19 -16
- package/CHANGELOG.md +196 -0
- package/README.md +5 -5
- package/examples/README.md +1 -0
- package/examples/gametank/templates/gt_draw.h +91 -0
- package/examples/gametank/templates/gt_hud.h +89 -0
- package/examples/gametank/templates/gt_palette.h +59 -0
- package/examples/gametank/templates/gt_sound.h +90 -0
- package/examples/gametank/templates/gt_sprites.h +53 -0
- package/examples/gametank/templates/platformer.c +318 -0
- package/examples/gametank/templates/puzzle.c +295 -0
- package/examples/gametank/templates/racing.c +139 -0
- package/examples/gametank/templates/shmup.c +277 -0
- package/examples/gametank/templates/sports.c +119 -0
- package/package.json +23 -22
- package/src/cores/capabilities.js +24 -0
- package/src/cores/registry.js +7 -1
- package/src/host/LibretroHost.js +90 -17
- package/src/host/callbacks.js +20 -2
- package/src/host/cpu-state.js +17 -0
- package/src/host/gametank-acp-state.js +53 -0
- package/src/http/skill-doc.js +10 -6
- package/src/mcp/tools/audio.js +1 -1
- package/src/mcp/tools/cart-parts.js +76 -0
- package/src/mcp/tools/lifecycle.js +1 -1
- package/src/mcp/tools/platform-tools.js +9 -1
- package/src/mcp/tools/platforms.js +46 -10
- package/src/mcp/tools/toolchain.js +35 -10
- package/src/platforms/gametank/lib/gt/audio/acp_image.bin +0 -0
- package/src/platforms/gametank/lib/gt/audio/audio_coprocessor.c +55 -0
- package/src/platforms/gametank/lib/gt/audio/audio_coprocessor.h +34 -0
- package/src/platforms/gametank/lib/gt/audio/audio_fw.asm +223 -0
- package/src/platforms/gametank/lib/gt/audio/audio_fw_blob.s +8 -0
- package/src/platforms/gametank/lib/gt/audio/instruments.c +79 -0
- package/src/platforms/gametank/lib/gt/audio/instruments.h +25 -0
- package/src/platforms/gametank/lib/gt/audio/music.c +370 -0
- package/src/platforms/gametank/lib/gt/audio/music.h +35 -0
- package/src/platforms/gametank/lib/gt/audio/note_numbers.h +132 -0
- package/src/platforms/gametank/lib/gt/audio/scaled_sines.raw +0 -0
- package/src/platforms/gametank/lib/gt/audio/sine_256_-63_63.bin +0 -0
- package/src/platforms/gametank/lib/gt/banking.c +29 -0
- package/src/platforms/gametank/lib/gt/banking.h +8 -0
- package/src/platforms/gametank/lib/gt/banking2.s +41 -0
- package/src/platforms/gametank/lib/gt/crt0.s +102 -0
- package/src/platforms/gametank/lib/gt/draw_logo.s +55 -0
- package/src/platforms/gametank/lib/gt/feature/persist/persist.c +103 -0
- package/src/platforms/gametank/lib/gt/feature/persist/persist.h +13 -0
- package/src/platforms/gametank/lib/gt/feature/random/random.c +40 -0
- package/src/platforms/gametank/lib/gt/feature/random/random.h +18 -0
- package/src/platforms/gametank/lib/gt/feature/text/text.c +111 -0
- package/src/platforms/gametank/lib/gt/feature/text/text.h +25 -0
- package/src/platforms/gametank/lib/gt/gametank.c +12 -0
- package/src/platforms/gametank/lib/gt/gametank.h +80 -0
- package/src/platforms/gametank/lib/gt/gametank_logo.inc +393 -0
- package/src/platforms/gametank/lib/gt/gen/assets/assets_index.h +9 -0
- package/src/platforms/gametank/lib/gt/gen/assets/sdk_default.h +5 -0
- package/src/platforms/gametank/lib/gt/gen/bank_nums.h +11 -0
- package/src/platforms/gametank/lib/gt/gen/modules_enabled.h +4 -0
- package/src/platforms/gametank/lib/gt/gen/modules_enabled.inc +4 -0
- package/src/platforms/gametank/lib/gt/gfx/draw_direct.c +132 -0
- package/src/platforms/gametank/lib/gt/gfx/draw_direct.h +75 -0
- package/src/platforms/gametank/lib/gt/gfx/draw_queue.c +177 -0
- package/src/platforms/gametank/lib/gt/gfx/draw_queue.h +35 -0
- package/src/platforms/gametank/lib/gt/gfx/draw_util.s +157 -0
- package/src/platforms/gametank/lib/gt/gfx/gfx_sys.c +43 -0
- package/src/platforms/gametank/lib/gt/gfx/gfx_sys.h +46 -0
- package/src/platforms/gametank/lib/gt/gfx/sprites.c +216 -0
- package/src/platforms/gametank/lib/gt/gfx/sprites.h +41 -0
- package/src/platforms/gametank/lib/gt/init.c +9 -0
- package/src/platforms/gametank/lib/gt/input.c +26 -0
- package/src/platforms/gametank/lib/gt/input.h +20 -0
- package/src/platforms/gametank/lib/gt/interrupt.s +67 -0
- package/src/platforms/gametank/lib/gt/vectors.s +14 -0
- package/src/platforms/gametank/lib/gt/wait.s +53 -0
- package/src/platforms/gba/MENTAL_MODEL.md +22 -1
- package/src/playtest/playtest.js +174 -26
- package/src/playtest/resampler/build.sh +19 -0
- package/src/playtest/resampler/index.mjs +75 -0
- package/src/playtest/resampler/resampler.c +129 -0
- package/src/playtest/resampler/resampler.mjs +2 -0
- package/src/playtest/resampler/resampler.wasm +0 -0
- package/src/toolchains/arm-none-eabi-gcc/gcc.js +39 -188
- package/src/toolchains/asar/asar.js +10 -15
- package/src/toolchains/cc65/cc65.js +82 -92
- package/src/toolchains/cc65/da65.js +12 -17
- package/src/toolchains/cc65/preset-resolver.js +13 -2
- package/src/toolchains/cc65/presets/gametank/gametank.h +80 -0
- package/src/toolchains/cc65/presets/gametank/sdk.cfg +32 -0
- package/src/toolchains/cc65/presets/gametank/sdk.crt0.s +61 -0
- package/src/toolchains/cc65/presets/gametank/sdk.vectors.s +11 -0
- package/src/toolchains/cc65/presets/gametank/single-bank.cfg +28 -0
- package/src/toolchains/cc65/presets/gametank/single-bank.crt0.s +71 -0
- package/src/toolchains/cc65/presets/gametank/single-bank.vectors.s +12 -0
- package/src/toolchains/common/c-build.js +109 -0
- package/src/toolchains/common/gcc-toolchain.js +164 -0
- package/src/toolchains/common/wasm-tool.js +101 -0
- package/src/toolchains/dasm/dasm.js +12 -18
- package/src/toolchains/gba-c/gba-c.js +253 -305
- package/src/toolchains/genesis-c/genesis-c.js +196 -225
- package/src/toolchains/index.js +58 -4
- package/src/toolchains/m68k-elf-gcc/gcc.js +39 -202
- package/src/toolchains/mips-c/mips-c.js +68 -78
- package/src/toolchains/mips-elf-gcc/gcc.js +55 -118
- package/src/toolchains/rgbds/rgbds.js +7 -19
- package/src/toolchains/sdcc/sdcc.js +35 -26
- package/src/toolchains/sh-c/sh-c.js +44 -52
- package/src/toolchains/sh-elf-gcc/gcc.js +55 -110
- package/src/toolchains/sjasm/sjasm.js +10 -14
- package/src/toolchains/snes-c/snes-c.js +125 -150
- package/src/toolchains/tcc816/tcc816.js +10 -15
- package/src/toolchains/vasm68k/vasm68k.js +11 -16
- package/src/toolchains/wladx/wladx.js +5 -17
- package/src/toolchains/z80/binutils.js +5 -11
package/src/host/LibretroHost.js
CHANGED
|
@@ -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
|
-
//
|
|
524
|
-
//
|
|
525
|
-
|
|
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
|
-
//
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
//
|
|
539
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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.
|
package/src/host/callbacks.js
CHANGED
|
@@ -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,
|
|
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");
|
package/src/host/cpu-state.js
CHANGED
|
@@ -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
|
+
}
|
package/src/http/skill-doc.js
CHANGED
|
@@ -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
|
|
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
|
|
31
|
-
"It also ships a full RE analysis engine (Rizin + Ghidra, all
|
|
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
|
|
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
|
|
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
|
"---",
|
package/src/mcp/tools/audio.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
111
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|