romdevtools 0.22.1 → 0.24.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 +169 -494
- package/CHANGELOG.md +103 -0
- package/examples/genesis/templates/platformer.c +5 -1
- package/examples/genesis/templates/two_plane_parallax.c +166 -0
- package/package.json +2 -2
- 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 +225 -2
- package/src/host/framebuffer.js +37 -0
- package/src/http/skill-doc.js +1 -1
- package/src/mcp/tools/audio.js +2 -2
- package/src/mcp/tools/frame.js +13 -34
- package/src/mcp/tools/index.js +2 -2
- package/src/mcp/tools/input-layout.js +10 -0
- package/src/mcp/tools/input.js +26 -2
- package/src/mcp/tools/metasprite-tools.js +1 -1
- package/src/mcp/tools/platform-tools.js +18 -11
- package/src/mcp/tools/playtest.js +17 -2
- package/src/mcp/tools/project.js +9 -1
- package/src/mcp/tools/rendering-context.js +1 -1
- package/src/mcp/tools/symbols.js +130 -39
- package/src/mcp/tools/tile-inspect.js +1 -1
- package/src/mcp/tools/toolchain.js +3 -2
- package/src/mcp/tools/watch-memory.js +58 -6
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +155 -6
- package/src/platforms/atari2600/MENTAL_MODEL.md +37 -0
- package/src/platforms/atari7800/MENTAL_MODEL.md +36 -0
- package/src/platforms/c64/MENTAL_MODEL.md +83 -6
- package/src/platforms/gb/MENTAL_MODEL.md +74 -0
- package/src/platforms/gb/lib/c/SDCC_GOTCHAS.md +91 -0
- package/src/platforms/gba/MENTAL_MODEL.md +57 -3
- package/src/platforms/gba/lib/arm-archives/libc.a +0 -0
- package/src/platforms/gba/lib/arm-archives/libgcc.a +0 -0
- package/src/platforms/gba/lib/arm-archives/libnosys.a +0 -0
- package/src/platforms/gbc/MENTAL_MODEL.md +34 -0
- package/src/platforms/gbc/lib/c/SDCC_GOTCHAS.md +91 -0
- package/src/platforms/genesis/MENTAL_MODEL.md +180 -0
- package/src/platforms/genesis/TROUBLESHOOTING.md +32 -0
- package/src/platforms/genesis/lib/c/libc.a +0 -0
- package/src/platforms/genesis/lib/c/libgcc.a +0 -0
- package/src/platforms/genesis/lib/c/libm.a +0 -0
- package/src/platforms/gg/MENTAL_MODEL.md +24 -0
- package/src/platforms/gg/lib/c/gg_crt0.s +30 -0
- package/src/platforms/lynx/MENTAL_MODEL.md +33 -7
- package/src/platforms/msx/MENTAL_MODEL.md +27 -0
- package/src/platforms/nes/MENTAL_MODEL.md +35 -0
- package/src/platforms/sms/MENTAL_MODEL.md +51 -0
- package/src/platforms/sms/lib/c/sms_crt0.s +40 -0
- package/src/platforms/snes/MENTAL_MODEL.md +21 -0
- package/src/platforms/snes/TROUBLESHOOTING.md +43 -0
- package/src/playtest/playtest.js +48 -0
- package/src/toolchains/sdcc/preflight-lint.js +164 -8
- package/examples/msx/catch_game/_verify.mjs +0 -93
- package/examples/pce/catch_game/_verify.mjs +0 -75
package/src/host/LibretroHost.js
CHANGED
|
@@ -138,6 +138,51 @@ export const PLATFORM_VIRTUAL_EXT = {
|
|
|
138
138
|
};
|
|
139
139
|
import { RETRO_DEVICE_JOYPAD } from "./retroConstants.js";
|
|
140
140
|
|
|
141
|
+
// C64 controller→keyboard map (the Batocera/RetroDeck model: a CONTROLLER alone
|
|
142
|
+
// plays C64 — no physical keyboard needed — by mapping spare buttons/stick to
|
|
143
|
+
// the C64 keyboard keys games need at setup screens). Keyed by the button NAME
|
|
144
|
+
// setInput receives (libretro + spatial names + the playtest right-stick virtual
|
|
145
|
+
// buttons c64_f1..f7). The joystick itself (d-pad + Fire) is NOT here — those
|
|
146
|
+
// stay a real joypad. Everything here is routed to the key matrix instead.
|
|
147
|
+
// B / south = Fire → joystick (handled as joypad, not a key)
|
|
148
|
+
// X / west = Space L2 = Run/Stop R2 / start = Return
|
|
149
|
+
// R-stick = F1/F3/F5/F7 (playtest emits c64_f1..f7 from the right stick)
|
|
150
|
+
const C64_BUTTON_KEYS = {
|
|
151
|
+
west: "space", y: "space",
|
|
152
|
+
l2: "run/stop",
|
|
153
|
+
r2: "return", start: "return",
|
|
154
|
+
c64_f1: "f1", c64_f3: "f3", c64_f5: "f5", c64_f7: "f7",
|
|
155
|
+
north: "f1", x: "f1", // also expose F1 on the top face (1-player select is the #1 need)
|
|
156
|
+
};
|
|
157
|
+
// The buttons that ARE the C64 joystick (pass through to the joypad mask):
|
|
158
|
+
const C64_JOY_BUTTONS = new Set(["up", "down", "left", "right", "b", "south", "a", "east"]);
|
|
159
|
+
|
|
160
|
+
// C64 keyboard matrix: key name (lowercase) → [row, col] in the 8×8 matrix the
|
|
161
|
+
// KERNAL scans via CIA1. Drives romdev_key_matrix() in the VICE core. Values
|
|
162
|
+
// taken from VICE's own C64 positional keymap (data/C64/sdl_pos_uk.vkm).
|
|
163
|
+
// Covers the keys RE/startup flows need; extend as needed.
|
|
164
|
+
const C64_KEY_MATRIX = {
|
|
165
|
+
// function keys (the common "1 player / start" selectors)
|
|
166
|
+
f1: [0, 4], f3: [0, 5], f5: [0, 6], f7: [0, 3],
|
|
167
|
+
// editing / control
|
|
168
|
+
return: [0, 1], enter: [0, 1], space: [7, 4], del: [0, 0], backspace: [0, 0],
|
|
169
|
+
"run/stop": [7, 7], runstop: [7, 7], stop: [7, 7],
|
|
170
|
+
ctrl: [7, 2], cbm: [7, 5], commodore: [7, 5],
|
|
171
|
+
lshift: [1, 7], rshift: [6, 4], home: [6, 3], clr: [6, 3],
|
|
172
|
+
// cursor keys (C64 has DOWN + RIGHT; UP/LEFT are the shifted forms — use
|
|
173
|
+
// shift+down / shift+right for those)
|
|
174
|
+
"crsr-down": [0, 7], "crsr-right": [0, 2], down: [0, 7], right: [0, 2],
|
|
175
|
+
// digits
|
|
176
|
+
"0": [4, 3], "1": [7, 0], "2": [7, 3], "3": [1, 0], "4": [1, 3],
|
|
177
|
+
"5": [2, 0], "6": [2, 3], "7": [3, 0], "8": [3, 3], "9": [4, 0],
|
|
178
|
+
// letters
|
|
179
|
+
a: [1, 2], b: [3, 4], c: [2, 4], d: [2, 2], e: [1, 6], f: [2, 5],
|
|
180
|
+
g: [3, 2], h: [3, 5], i: [4, 1], j: [4, 2], k: [4, 5], l: [5, 2],
|
|
181
|
+
m: [4, 4], n: [4, 7], o: [4, 6], p: [5, 1], q: [7, 6], r: [2, 1],
|
|
182
|
+
s: [1, 5], t: [2, 6], u: [3, 6], v: [3, 7], w: [1, 1], x: [2, 7],
|
|
183
|
+
y: [3, 1], z: [1, 4],
|
|
184
|
+
};
|
|
185
|
+
|
|
141
186
|
export class LibretroHost {
|
|
142
187
|
/**
|
|
143
188
|
* @param {Object} [opts]
|
|
@@ -570,12 +615,55 @@ export class LibretroHost {
|
|
|
570
615
|
/** @param {import("./types.js").FrameInput} input */
|
|
571
616
|
setInput(input) {
|
|
572
617
|
const platform = this.status.platform ?? undefined;
|
|
618
|
+
// C64: route the keyboard-mapped controller buttons (Space/Run-Stop/Return/
|
|
619
|
+
// F1-F7) to the key matrix so a CONTROLLER alone can play — the
|
|
620
|
+
// Batocera/RetroDeck model. The joystick bits (d-pad + Fire) still flow to
|
|
621
|
+
// the joypad mask below. Applies to BOTH playtest and the agent's setInput.
|
|
622
|
+
if (platform === "c64" && this.mod && typeof this.mod._romdev_key_matrix === "function") {
|
|
623
|
+
this._applyC64ButtonKeys(input.ports[0] || {});
|
|
624
|
+
}
|
|
573
625
|
for (let port = 0; port < this.state.inputPorts.length; port++) {
|
|
574
|
-
const portInput = input.ports[port];
|
|
626
|
+
const portInput = this._c64StripKeyButtons(input.ports[port], platform);
|
|
575
627
|
this.state.inputPorts[port][0] = portInputToMask(portInput, platform);
|
|
576
628
|
}
|
|
577
629
|
}
|
|
578
630
|
|
|
631
|
+
/** C64: press/release matrix keys for the held keyboard-mapped buttons on a
|
|
632
|
+
* port, edge-tracked so a key releases when its button is no longer held. */
|
|
633
|
+
_applyC64ButtonKeys(portInput) {
|
|
634
|
+
const held = this._c64HeldKeys || (this._c64HeldKeys = new Set());
|
|
635
|
+
const want = new Set();
|
|
636
|
+
for (const [btn, key] of Object.entries(C64_BUTTON_KEYS)) {
|
|
637
|
+
if (portInput[btn] === true) want.add(key);
|
|
638
|
+
}
|
|
639
|
+
// press newly-wanted, release no-longer-wanted
|
|
640
|
+
for (const key of want) {
|
|
641
|
+
if (!held.has(key)) {
|
|
642
|
+
const pos = C64_KEY_MATRIX[key];
|
|
643
|
+
if (pos) this.mod._romdev_key_matrix(pos[0], pos[1], 1);
|
|
644
|
+
held.add(key);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
for (const key of [...held]) {
|
|
648
|
+
if (!want.has(key)) {
|
|
649
|
+
const pos = C64_KEY_MATRIX[key];
|
|
650
|
+
if (pos) this.mod._romdev_key_matrix(pos[0], pos[1], 0);
|
|
651
|
+
held.delete(key);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Strip the C64 keyboard-mapped buttons out of a port so they don't ALSO
|
|
657
|
+
* press a joystick direction/fire (only the real joystick bits remain). */
|
|
658
|
+
_c64StripKeyButtons(portInput, platform) {
|
|
659
|
+
if (platform !== "c64" || !portInput) return portInput;
|
|
660
|
+
const out = {};
|
|
661
|
+
for (const k of Object.keys(portInput)) {
|
|
662
|
+
if (C64_JOY_BUTTONS.has(k)) out[k] = portInput[k];
|
|
663
|
+
}
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
666
|
+
|
|
579
667
|
/** @param {string} name */
|
|
580
668
|
saveState(name) {
|
|
581
669
|
const snapshot = this.serializeState();
|
|
@@ -802,6 +890,87 @@ export class LibretroHost {
|
|
|
802
890
|
}
|
|
803
891
|
}
|
|
804
892
|
|
|
893
|
+
// ── C64 keyboard + joyport (VICE core only) ──────────────────────────
|
|
894
|
+
// C64 games need KEYBOARD input (F1 = 1 player, RUN/STOP, SPACE/RETURN at
|
|
895
|
+
// setup screens) before joystick gameplay — joystick alone can't pass them.
|
|
896
|
+
// The VICE core exports romdev_key_matrix / romdev_kbdbuf_feed /
|
|
897
|
+
// romdev_joyport_* (see scripts/patches/vice-romdev-memory-regions.patch).
|
|
898
|
+
|
|
899
|
+
/** True if the loaded core exposes C64 keyboard injection (VICE only). */
|
|
900
|
+
keyboardSupported() {
|
|
901
|
+
const mod = this.mod;
|
|
902
|
+
return !!(mod && typeof mod._romdev_key_matrix === "function");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Press (and optionally auto-release) a single C64 key by name. Drives the
|
|
907
|
+
* C64 8×8 key matrix directly via the core. Held for `frames` then released.
|
|
908
|
+
* @param {string} key a name from C64_KEY_MATRIX (case-insensitive)
|
|
909
|
+
* @param {number} [frames] frames to hold before release (default 4)
|
|
910
|
+
* @returns {{key:string, row:number, col:number, frames:number}}
|
|
911
|
+
*/
|
|
912
|
+
pressC64Key(key, frames = 4) {
|
|
913
|
+
const mod = this._needMod();
|
|
914
|
+
if (typeof mod._romdev_key_matrix !== "function") {
|
|
915
|
+
throw new Error("this core build does not expose C64 keyboard input (C64/VICE only).");
|
|
916
|
+
}
|
|
917
|
+
const pos = C64_KEY_MATRIX[String(key).toLowerCase()];
|
|
918
|
+
if (!pos) {
|
|
919
|
+
throw new Error(
|
|
920
|
+
`unknown C64 key '${key}'. Known: ${Object.keys(C64_KEY_MATRIX).join(", ")}.`,
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
const [row, col] = pos;
|
|
924
|
+
mod._romdev_key_matrix(row, col, 1); // press
|
|
925
|
+
this.stepFrames(Math.max(1, frames | 0));
|
|
926
|
+
mod._romdev_key_matrix(row, col, 0); // release
|
|
927
|
+
this.stepFrames(1);
|
|
928
|
+
return { key: String(key).toLowerCase(), row, col, frames: Math.max(1, frames | 0) };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Feed a PETSCII string into the C64 kernal keyboard buffer (for typing
|
|
933
|
+
* LOAD/RUN/filenames). `\r` (or `\n`) becomes RETURN. Non-blocking — the
|
|
934
|
+
* kernal drains it as the screen editor runs, so step frames after.
|
|
935
|
+
* @param {string} text
|
|
936
|
+
* @returns {number} kbdbuf_feed's result (>=0 ok)
|
|
937
|
+
*/
|
|
938
|
+
typeC64Text(text) {
|
|
939
|
+
const mod = this._needMod();
|
|
940
|
+
if (typeof mod._romdev_kbdbuf_feed !== "function") {
|
|
941
|
+
throw new Error("this core build does not expose C64 text input (C64/VICE only).");
|
|
942
|
+
}
|
|
943
|
+
const s = String(text).replace(/\n/g, "\r");
|
|
944
|
+
const bytes = Buffer.from(s + "\0", "latin1");
|
|
945
|
+
const ptr = mod._malloc(bytes.length);
|
|
946
|
+
try {
|
|
947
|
+
mod.HEAPU8.set(bytes, ptr);
|
|
948
|
+
return mod._romdev_kbdbuf_feed(ptr) | 0;
|
|
949
|
+
} finally {
|
|
950
|
+
mod._free(ptr);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Get the active C64 joystick port (1 or 2) the RetroPad drives. */
|
|
955
|
+
getC64JoyPort() {
|
|
956
|
+
const mod = this._needMod();
|
|
957
|
+
if (typeof mod._romdev_joyport_get !== "function") {
|
|
958
|
+
throw new Error("this core build does not expose C64 joyport (C64/VICE only).");
|
|
959
|
+
}
|
|
960
|
+
return mod._romdev_joyport_get() | 0;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Set the active C64 joystick port (1 or 2). Default is 2 (most games). */
|
|
964
|
+
setC64JoyPort(port) {
|
|
965
|
+
const mod = this._needMod();
|
|
966
|
+
if (typeof mod._romdev_joyport_set !== "function") {
|
|
967
|
+
throw new Error("this core build does not expose C64 joyport (C64/VICE only).");
|
|
968
|
+
}
|
|
969
|
+
if (port !== 1 && port !== 2) throw new Error("C64 joyport must be 1 or 2.");
|
|
970
|
+
mod._romdev_joyport_set(port | 0);
|
|
971
|
+
return port | 0;
|
|
972
|
+
}
|
|
973
|
+
|
|
805
974
|
reset() {
|
|
806
975
|
const mod = this._needMod();
|
|
807
976
|
mod._retro_reset();
|
|
@@ -1545,6 +1714,60 @@ export class LibretroHost {
|
|
|
1545
1714
|
}
|
|
1546
1715
|
}
|
|
1547
1716
|
|
|
1717
|
+
/**
|
|
1718
|
+
* Per-frame VDP-DMA timeline. Steps `frames` frames ONE AT A TIME, and for
|
|
1719
|
+
* each frame reports how many mem→VDP DMAs fired + how many VRAM/CRAM/VSRAM
|
|
1720
|
+
* bytes they moved. The core's `romdev_dmawatch_set(1)` RESETS its counters
|
|
1721
|
+
* (see the patch), so re-arming before each single-frame step gives a clean
|
|
1722
|
+
* per-frame bucket with no core rebuild — the cheap derivation of "VDP/DMA
|
|
1723
|
+
* work per frame" the feel-diagnostics workflow needs.
|
|
1724
|
+
*
|
|
1725
|
+
* `onFrame(i)` is called at the top of each frame (before the step) so the
|
|
1726
|
+
* caller can drive scheduled input. Genesis-only.
|
|
1727
|
+
*
|
|
1728
|
+
* Returns { frames:[{frame, dmas, words, bytes, romBytes, ramBytes}], ... }.
|
|
1729
|
+
* `romBytes`/`ramBytes` split the moved bytes by source bus (ROM asset upload
|
|
1730
|
+
* vs the RAM→VRAM sprite/scroll refresh) so a per-frame asset-DMA spike — the
|
|
1731
|
+
* "I redrew a tilemap in the loop" smell — stands out from the steady refresh.
|
|
1732
|
+
*/
|
|
1733
|
+
watchDmaPerFrame(frames, onFrame) {
|
|
1734
|
+
const mod = this._needMod();
|
|
1735
|
+
this._needMedia();
|
|
1736
|
+
if (!this.dmaWatchSupported()) throw new Error("VDP-DMA watch not supported by this core (Genesis only).");
|
|
1737
|
+
const CAP = 1024;
|
|
1738
|
+
const outPtr = mod._malloc(CAP * 4 * 4);
|
|
1739
|
+
const out2Ptr = mod._malloc(8);
|
|
1740
|
+
const isRam = (src) => (src >>> 0) >= 0xE00000;
|
|
1741
|
+
const out = [];
|
|
1742
|
+
let peakBytes = 0, peakFrame = -1, totalBytes = 0, totalDmas = 0;
|
|
1743
|
+
try {
|
|
1744
|
+
for (let i = 0; i < frames; i++) {
|
|
1745
|
+
if (onFrame) onFrame(i);
|
|
1746
|
+
mod._romdev_dmawatch_set(1); // arm + RESET counters for this frame
|
|
1747
|
+
this._runFramesExclusive(() => false, 1);
|
|
1748
|
+
const n = mod._romdev_dmawatch_get(outPtr, CAP, out2Ptr);
|
|
1749
|
+
const out2 = new Uint32Array(mod.HEAPU8.buffer, out2Ptr, 2);
|
|
1750
|
+
const total = out2[0]; // total DMAs this frame (>= stored)
|
|
1751
|
+
const u = new Uint32Array(mod.HEAPU8.buffer, outPtr, n * 4);
|
|
1752
|
+
let words = 0, romBytes = 0, ramBytes = 0;
|
|
1753
|
+
for (let k = 0; k < n; k++) {
|
|
1754
|
+
const src = u[k * 4 + 1], len = u[k * 4 + 2];
|
|
1755
|
+
words += len;
|
|
1756
|
+
if (isRam(src)) ramBytes += len * 2; else romBytes += len * 2;
|
|
1757
|
+
}
|
|
1758
|
+
const bytes = words * 2;
|
|
1759
|
+
out.push({ frame: i, dmas: total, words, bytes, romBytes, ramBytes,
|
|
1760
|
+
...(total > n ? { coreBufferTruncated: true } : {}) });
|
|
1761
|
+
totalBytes += bytes; totalDmas += total;
|
|
1762
|
+
if (bytes > peakBytes) { peakBytes = bytes; peakFrame = i; }
|
|
1763
|
+
}
|
|
1764
|
+
mod._romdev_dmawatch_set(0); // disarm
|
|
1765
|
+
return { frames: out, totalBytes, totalDmas, peakBytes, peakFrame };
|
|
1766
|
+
} finally {
|
|
1767
|
+
mod._free(outPtr); mod._free(out2Ptr);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1548
1771
|
pause() {
|
|
1549
1772
|
this.status.paused = true;
|
|
1550
1773
|
}
|
|
@@ -1630,7 +1853,7 @@ export class LibretroHost {
|
|
|
1630
1853
|
sms: { video_ram: "sms_vram (or sms_cram for palette, sms_vdp_regs for VDP regs)" },
|
|
1631
1854
|
gg: { video_ram: "gg_vram (or gg_cram for the 64-byte 12-bit palette, sms_vdp_regs for VDP regs)" },
|
|
1632
1855
|
snes: { video_ram: "snes_oam (sprite OAM), snes_cgram (palette), snes_aram (SPC700), or snes_fillram (PPU/DMA reg shadow). The libretro generic 'video_ram' id isn't wired in snes9x." },
|
|
1633
|
-
genesis: { video_ram: "
|
|
1856
|
+
genesis: { video_ram: "Genesis VRAM IS exposed via 'video_ram' (gpgx) once a ROM is loaded and a frame has run — if it reads empty, step a frame first (the SAT/sprites need the game to have written VRAM). Palette/scroll/VDP regs are genesis_cram / genesis_vsram / genesis_vdp_regs. For decoded views use inspectSprites / inspectPatternTiles / inspectBackgroundMap / getRenderingContext." },
|
|
1634
1857
|
c64: { video_ram: "c64_color_ram (1 KB) / c64_vic_regs / c64_sid_regs / c64_cia1_regs / c64_cia2_regs. The C64 has no separate VRAM — the VIC-II reads from main system_ram." },
|
|
1635
1858
|
};
|
|
1636
1859
|
const hint = suggestions[plat] && suggestions[plat][region];
|
package/src/host/framebuffer.js
CHANGED
|
@@ -113,3 +113,40 @@ export function framebufferToScreenshot(width, height, src, pitch, format) {
|
|
|
113
113
|
const buf = framebufferToPng(width, height, src, pitch, format);
|
|
114
114
|
return { width, height, pngBase64: buf.toString("base64") };
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Nearest-neighbor resample of a base64 PNG by `scale`. Works BOTH directions:
|
|
119
|
+
* scale<1 → downscale (e.g. 0.5 = half size; ~75% fewer image tokens for a
|
|
120
|
+
* routine "did it change?" sanity check).
|
|
121
|
+
* scale>=2 → integer up-scale (e.g. 4 = 4x size) so tiny handheld targets
|
|
122
|
+
* (GB/GG 160x144, etc.) are legible inline without ImageMagick.
|
|
123
|
+
*
|
|
124
|
+
* Nearest-neighbor (not averaging/smoothing) is deliberate in both directions:
|
|
125
|
+
* it keeps pixel-art edges crisp and palette colors exact, so a scaled shot
|
|
126
|
+
* still reads accurately. The PNG is fully decoded already (it's a tiny
|
|
127
|
+
* framebuffer), so this is cheap. Platform-agnostic — same pixel scaling for
|
|
128
|
+
* every core.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} pngBase64 source PNG, base64-encoded
|
|
131
|
+
* @param {number} scale resample factor (>0)
|
|
132
|
+
* @returns {{ base64: string, width: number, height: number }}
|
|
133
|
+
*/
|
|
134
|
+
export function resamplePng(pngBase64, scale) {
|
|
135
|
+
const src = PNG.sync.read(Buffer.from(pngBase64, "base64"));
|
|
136
|
+
const dw = Math.max(1, Math.round(src.width * scale));
|
|
137
|
+
const dh = Math.max(1, Math.round(src.height * scale));
|
|
138
|
+
const dst = new PNG({ width: dw, height: dh });
|
|
139
|
+
for (let y = 0; y < dh; y++) {
|
|
140
|
+
const sy = Math.min(src.height - 1, Math.floor(y / scale));
|
|
141
|
+
for (let x = 0; x < dw; x++) {
|
|
142
|
+
const sx = Math.min(src.width - 1, Math.floor(x / scale));
|
|
143
|
+
const si = (sy * src.width + sx) * 4;
|
|
144
|
+
const di = (y * dw + x) * 4;
|
|
145
|
+
dst.data[di] = src.data[si];
|
|
146
|
+
dst.data[di + 1] = src.data[si + 1];
|
|
147
|
+
dst.data[di + 2] = src.data[si + 2];
|
|
148
|
+
dst.data[di + 3] = src.data[si + 3];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { base64: PNG.sync.write(dst).toString("base64"), width: dw, height: dh };
|
|
152
|
+
}
|
package/src/http/skill-doc.js
CHANGED
|
@@ -112,7 +112,7 @@ export function sanitizeForSkillChannel(text) {
|
|
|
112
112
|
// there's no session-id header / reconnect / "connect your agent" step here;
|
|
113
113
|
// the skillPreamble already gives the skill-appropriate intro + prereq).
|
|
114
114
|
if (/Mcp-Session-Id|re-?initialize|session not found|MCP client|MCP connection|MCP sessions/i.test(line)) continue;
|
|
115
|
-
if (/
|
|
115
|
+
if (/connect your (agent|coding agent)|restart its MCP connection|restart your MCP|your MCP client should/i.test(line)) continue;
|
|
116
116
|
|
|
117
117
|
let l = line
|
|
118
118
|
// section header that frames romdev as "a server you connect to"
|
package/src/mcp/tools/audio.js
CHANGED
|
@@ -380,7 +380,7 @@ export function registerAudioTools(server, z, sessionKey) {
|
|
|
380
380
|
if (args.frames != null && args.frames > 0) {
|
|
381
381
|
return await traceAudioChip(sessionKey, args);
|
|
382
382
|
}
|
|
383
|
-
return await getAudioStateCore(args);
|
|
383
|
+
return await getAudioStateCore(args, sessionKey);
|
|
384
384
|
}
|
|
385
385
|
if (args.op !== "record") throw new Error(`audioDebug: unknown op '${args.op}'`);
|
|
386
386
|
if (!args.path) throw new Error("audioDebug({op:'record'}): `path` is required.");
|
|
@@ -485,7 +485,7 @@ async function traceAudioChip(sessionKey, { chip, platform, frames, sampleEvery
|
|
|
485
485
|
|
|
486
486
|
// getAudioStateCore returns jsonContent({...}); pull the structured object.
|
|
487
487
|
const decode = async () => {
|
|
488
|
-
const r = await getAudioStateCore({ chip, platform });
|
|
488
|
+
const r = await getAudioStateCore({ chip, platform }, sessionKey);
|
|
489
489
|
const txt = r.content?.find?.((c) => c.type === "text")?.text;
|
|
490
490
|
return txt ? JSON.parse(txt) : {};
|
|
491
491
|
};
|
package/src/mcp/tools/frame.js
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { PNG } from "pngjs";
|
|
5
|
+
import { resamplePng } from "../../host/framebuffer.js";
|
|
5
6
|
import { getHost } from "../state.js";
|
|
6
7
|
import { imageContent, jsonContent, safeTool } from "../util.js";
|
|
7
8
|
import { decodeOAM, decodePpuRegs, ppuRegsPopulated } from "../../platforms/snes/ppu.js";
|
|
@@ -258,30 +259,6 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
258
259
|
}
|
|
259
260
|
}
|
|
260
261
|
|
|
261
|
-
// Nearest-neighbor downscale of a PNG by an integer divisor. Nearest-neighbor
|
|
262
|
-
// (not averaging) is deliberate: it keeps pixel-art edges crisp and palette
|
|
263
|
-
// colors exact, so a half-size sanity-check shot still reads accurately. The
|
|
264
|
-
// PNG is fully decoded already (it's a tiny framebuffer), so this is cheap.
|
|
265
|
-
function downscalePng(pngBase64, scale) {
|
|
266
|
-
const src = PNG.sync.read(Buffer.from(pngBase64, "base64"));
|
|
267
|
-
const dw = Math.max(1, Math.round(src.width * scale));
|
|
268
|
-
const dh = Math.max(1, Math.round(src.height * scale));
|
|
269
|
-
const dst = new PNG({ width: dw, height: dh });
|
|
270
|
-
for (let y = 0; y < dh; y++) {
|
|
271
|
-
const sy = Math.min(src.height - 1, Math.floor(y / scale));
|
|
272
|
-
for (let x = 0; x < dw; x++) {
|
|
273
|
-
const sx = Math.min(src.width - 1, Math.floor(x / scale));
|
|
274
|
-
const si = (sy * src.width + sx) * 4;
|
|
275
|
-
const di = (y * dw + x) * 4;
|
|
276
|
-
dst.data[di] = src.data[si];
|
|
277
|
-
dst.data[di + 1] = src.data[si + 1];
|
|
278
|
-
dst.data[di + 2] = src.data[si + 2];
|
|
279
|
-
dst.data[di + 3] = src.data[si + 3];
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
return { base64: PNG.sync.write(dst).toString("base64"), width: dw, height: dh };
|
|
283
|
-
}
|
|
284
|
-
|
|
285
262
|
// PNG capture. Writes to outPath, or returns inline when `inline`.
|
|
286
263
|
async function shootPng({ path: outPath, inline, overlayBoxes, scale }) {
|
|
287
264
|
const host = getHost(sessionKey);
|
|
@@ -300,16 +277,18 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
300
277
|
overlayInfo = { platform, spritesDrawn: 0, note: `overlay not yet supported for '${platform}'` };
|
|
301
278
|
}
|
|
302
279
|
}
|
|
303
|
-
//
|
|
304
|
-
// unset) is the
|
|
305
|
-
// fewer image tokens for routine "did it change?" sanity checks
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
280
|
+
// Resample AFTER overlay so the boxes scale with the image. scale=1 (or
|
|
281
|
+
// unset) is the native-resolution default; scale<1 is a downscaled shot
|
|
282
|
+
// (~75% fewer image tokens for routine "did it change?" sanity checks),
|
|
283
|
+
// scale>=2 is an integer up-scale so tiny handheld targets read legibly.
|
|
284
|
+
const scaled = scale && scale !== 1;
|
|
285
|
+
if (scaled) {
|
|
286
|
+
const r = resamplePng(pngBase64, scale);
|
|
287
|
+
pngBase64 = r.base64; width = r.width; height = r.height;
|
|
309
288
|
}
|
|
310
289
|
if (!inline) {
|
|
311
290
|
await writeFile(outPath, Buffer.from(pngBase64, "base64"));
|
|
312
|
-
const json = jsonContent({ path: outPath, width, height, ...(
|
|
291
|
+
const json = jsonContent({ path: outPath, width, height, ...(scaled ? { scale, fullWidth: shot.width, fullHeight: shot.height } : {}), overlay: overlayInfo });
|
|
313
292
|
json._observerImages = [{ kind: "image", mimeType: "image/png", base64: pngBase64 }];
|
|
314
293
|
return json;
|
|
315
294
|
}
|
|
@@ -321,7 +300,7 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
321
300
|
return {
|
|
322
301
|
content: [
|
|
323
302
|
imageContent(pngBase64),
|
|
324
|
-
{ type: "text", text: `framebuffer ${shot.width}x${shot.height}${
|
|
303
|
+
{ type: "text", text: `framebuffer ${shot.width}x${shot.height}${scaled ? ` (scaled ${scale}x to ${width}x${height})` : ""}${overlayInfo ? ` (overlay: ${overlayInfo.spritesDrawn} sprites)` : ""} — also written to ${tempPath} (use this path for ImageMagick/crops; pass outputPath for a permanent location).` },
|
|
325
304
|
],
|
|
326
305
|
};
|
|
327
306
|
}
|
|
@@ -414,7 +393,7 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
414
393
|
"level with 7200; prefer ONE big call.\n" +
|
|
415
394
|
"'screenshot': capture the latest frame. `format:'png'` (default, exact colors) or `'ascii'` (lossy chafa text " +
|
|
416
395
|
"render for agents that can't view images). `overlayBoxes` (png) draws a box per visible sprite (SNES+NES only); " +
|
|
417
|
-
"`scale` (0
|
|
396
|
+
"`scale` (png) resamples nearest-neighbor BOTH ways: 0<scale<1 DOWNscales (~75% fewer image tokens at 0.5), integer scale≥2 UPscales so tiny handheld targets read inline (e.g. scale:4 → GB 160x144 becomes 640x576); ascii cols/rows/symbols/colors knobs in the param hints. " +
|
|
418
397
|
"**CHEAP VERIFY: for a binary pass/fail check (theme changed? sprite present? HUD ticked?) prefer scale:0.5 or " +
|
|
419
398
|
"format:'ascii' — BETTER, read the byte directly: symbols({op:'resolve', name}) → memory({op:'read'}) is a 1-byte " +
|
|
420
399
|
"assertion that costs zero image tokens.**\n" +
|
|
@@ -438,7 +417,7 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
438
417
|
path: z.string().optional().describe("op=screenshot/stepAndShot: absolute path to write to (required unless inline:true)."),
|
|
439
418
|
inline: z.boolean().default(false).describe("op=screenshot/stepAndShot: return the image in the response instead of writing to disk."),
|
|
440
419
|
overlayBoxes: z.boolean().default(false).describe("op=screenshot png: draw a colored bounding box per visible sprite (SNES+NES only)."),
|
|
441
|
-
scale: z.number().gt(0).max(1).optional().describe("op=screenshot png:
|
|
420
|
+
scale: z.number().gt(0).max(16).refine((s) => s <= 1 || Number.isInteger(s), { message: "scale must be 0<scale≤1 (downscale) or an integer ≥2 (upscale)" }).optional().describe("op=screenshot png: nearest-neighbor resample factor. 0<scale<1 DOWNscales (0.5 ≈ 75% fewer image tokens for cheap 'did it change?' checks); scale≥2 (integer) UPscales (nearest-neighbor — keeps pixel-art crisp) so tiny handheld targets are legible inline, e.g. scale:4 makes a GB 160x144 shot 640x576. scale=1/unset = native resolution."),
|
|
442
421
|
cols: z.number().int().min(4).max(640).optional().describe("op=screenshot ascii: terminal columns (default fb_width/16)."),
|
|
443
422
|
rows: z.number().int().min(4).max(480).optional().describe("op=screenshot ascii: terminal rows (default fb_height/16)."),
|
|
444
423
|
symbols: z.enum(["ascii", "halfblock", "block", "quad", "sextant"]).default("ascii").describe("op=screenshot ascii: chafa symbol set."),
|
package/src/mcp/tools/index.js
CHANGED
|
@@ -188,8 +188,8 @@ const CATEGORIES = [
|
|
|
188
188
|
},
|
|
189
189
|
{
|
|
190
190
|
name: "advanced",
|
|
191
|
-
description: "Less common automation: runUntil (drive a ROM headlessly until a condition),
|
|
192
|
-
useWhen: ["want to automate reaching a specific game state", "tracking down which code writes a specific RAM byte (gameplay variable hunting)", "recording an input macro for regression testing"],
|
|
191
|
+
description: "Less common automation + MOTION/TELEMETRY tracing: runUntil (drive a ROM headlessly until a condition), watch({on:'mem', format:'series'}) (a compact value-vs-frame CURVE per byte — the primitive for velocity/scroll/sprite-position over time), runUntilWrite (step until target byte is written, return the PC), recordSession (hold/script input over N frames while sampling memory + screenshots into an analyzable timeline — use it to diagnose game-FEEL issues: choppy movement, scroll jumps, camera-vs-sprite desync, NOT just input macros).",
|
|
192
|
+
useWhen: ["want to automate reaching a specific game state", "tracking down which code writes a specific RAM byte (gameplay variable hunting)", "diagnosing why movement/scrolling feels choppy or wrong — sample sprite X + scroll regs over frames with recordSession or watch series", "recording an input macro for regression testing"],
|
|
193
193
|
register: (s, z, k) => { registerRunUntilTools(s, z, k); registerWatchMemoryTools(s, z, k); registerRecordTools(s, z, k); },
|
|
194
194
|
},
|
|
195
195
|
];
|
|
@@ -74,6 +74,16 @@ const HARDWARE_LAYOUTS = {
|
|
|
74
74
|
readSequence: "Bits 0-4 are Up/Down/Left/Right/Fire, active-low.",
|
|
75
75
|
bitOrder: ["Up", "Down", "Left", "Right", "Fire"],
|
|
76
76
|
faceButtons: FACE_BUTTON_MAP.c64,
|
|
77
|
+
// The C64 needs more than joystick: many games use KEYBOARD setup screens
|
|
78
|
+
// (F1=1 player, RUN/STOP, SPACE/RETURN) before joystick gameplay starts.
|
|
79
|
+
keyboard: {
|
|
80
|
+
note: "C64 games often need keyboard input to START (F1 to pick 1 player, fire/RETURN to begin). Joystick alone can't pass these. Use input({op:'pressKey', key}) for a single key, input({op:'typeText', text}) to type a string (LOAD/RUN/filenames).",
|
|
81
|
+
keys: ["f1", "f3", "f5", "f7", "return", "space", "run/stop", "ctrl", "cbm", "home", "down", "right", "lshift", "rshift", "0-9", "a-z"],
|
|
82
|
+
},
|
|
83
|
+
joyport: {
|
|
84
|
+
note: "The RetroPad drives ONE C64 joystick port at a time. Default is port 2 (most games). Use input({op:'joyport'}) to read it, input({op:'joyport', joyport:1|2}) to switch.",
|
|
85
|
+
default: 2,
|
|
86
|
+
},
|
|
77
87
|
},
|
|
78
88
|
sms: {
|
|
79
89
|
register: "I/O port $DC (controllers, read via `in a,($DC)`) and $DD (port 2 high bits + reset)",
|
package/src/mcp/tools/input.js
CHANGED
|
@@ -213,17 +213,21 @@ export function registerInputTools(server, z, sessionKey) {
|
|
|
213
213
|
"when ITS code polls; re-apply immediately before the consuming stepFrames and verify via the held-buttons RAM " +
|
|
214
214
|
"byte, not this echo.",
|
|
215
215
|
{
|
|
216
|
-
op: z.enum(["set", "press", "sequence", "navigate", "layout"]).describe("set/hold buttons; press one button; run a sequence; navigate a menu;
|
|
216
|
+
op: z.enum(["set", "press", "sequence", "navigate", "layout", "pressKey", "typeText", "joyport"]).describe("set/hold buttons; press one button; run a sequence; navigate a menu; get the input layout. C64-ONLY: pressKey (press a C64 KEYBOARD key — F1/Return/Space/Run-Stop — many C64 games need these to start, joystick alone can't); typeText (feed a string into the C64 keyboard buffer — LOAD/RUN/filenames); joyport (get/set which C64 joystick port the pad drives, 1 or 2; default 2)."),
|
|
217
217
|
// set
|
|
218
218
|
ports: z.array(port).min(1).max(2).optional().describe("op=set: per-port input. [{a:true,right:true}] holds A+Right on port 0."),
|
|
219
219
|
// press
|
|
220
220
|
button: z.enum(BUTTON_ENUM).optional().describe("op=press: button to press (native aliases + spatial names accepted)."),
|
|
221
|
-
frames: z.number().int().min(1).max(600).default(2).describe("op=press: frames to hold the button."),
|
|
221
|
+
frames: z.number().int().min(1).max(600).default(2).describe("op=press: frames to hold the button. op=pressKey: frames to hold the C64 key (default 4)."),
|
|
222
222
|
port: z.number().int().min(0).max(1).default(0).describe("op=press: which port (default 0)."),
|
|
223
223
|
// sequence
|
|
224
224
|
steps: z.array(z.any()).optional().describe("op=sequence: [{input:{ports:[...]}, frames}]. op=navigate: [{button, holdFrames?, maxWaitFrames?, settleFrames?}]. (Two distinct step shapes by op.)"),
|
|
225
225
|
// layout
|
|
226
226
|
platform: z.string().optional().describe("op=layout: platform id (nes, gb, snes, genesis, ...)."),
|
|
227
|
+
// C64 keyboard
|
|
228
|
+
key: z.string().optional().describe("op=pressKey (C64): key name — f1/f3/f5/f7, return, space, run/stop, a-z, 0-9, ctrl, cbm, home, down, right, lshift, rshift."),
|
|
229
|
+
text: z.string().optional().describe("op=typeText (C64): string fed into the keyboard buffer; \\r / \\n become RETURN. e.g. 'LOAD\"*\",8,1\\rRUN\\r'."),
|
|
230
|
+
joyport: z.number().int().min(1).max(2).optional().describe("op=joyport (C64): set the active joystick port (1 or 2). Omit to just GET the current port. Default is 2 (most C64 games)."),
|
|
227
231
|
},
|
|
228
232
|
safeTool(async (args) => {
|
|
229
233
|
switch (args.op) {
|
|
@@ -249,6 +253,26 @@ export function registerInputTools(server, z, sessionKey) {
|
|
|
249
253
|
if (!args.platform) throw new Error("input({op:'layout'}): `platform` is required.");
|
|
250
254
|
return jsonContent(getInputLayoutCore(args));
|
|
251
255
|
}
|
|
256
|
+
case "pressKey": {
|
|
257
|
+
if (!args.key) throw new Error("input({op:'pressKey'}): `key` is required (C64 keyboard key, e.g. 'f1', 'return', 'run/stop').");
|
|
258
|
+
const host = getHost(sessionKey);
|
|
259
|
+
const r = host.pressC64Key(args.key, args.frames ?? 4);
|
|
260
|
+
return jsonContent({ pressedKey: r.key, matrix: [r.row, r.col], frames: r.frames, frameCount: host.status.frameCount });
|
|
261
|
+
}
|
|
262
|
+
case "typeText": {
|
|
263
|
+
if (typeof args.text !== "string") throw new Error("input({op:'typeText'}): `text` is required (string fed into the C64 keyboard buffer).");
|
|
264
|
+
const host = getHost(sessionKey);
|
|
265
|
+
const rc = host.typeC64Text(args.text);
|
|
266
|
+
return jsonContent({ typed: args.text, fedResult: rc, note: "Queued into the C64 keyboard buffer — step frames so the screen editor drains it." });
|
|
267
|
+
}
|
|
268
|
+
case "joyport": {
|
|
269
|
+
const host = getHost(sessionKey);
|
|
270
|
+
if (args.joyport === undefined) {
|
|
271
|
+
return jsonContent({ joyport: host.getC64JoyPort(), note: "Active C64 joystick port. Most C64 games use port 2 (the default). Pass `joyport:1` or `joyport:2` to change it." });
|
|
272
|
+
}
|
|
273
|
+
const set = host.setC64JoyPort(args.joyport);
|
|
274
|
+
return jsonContent({ joyport: set, set: true });
|
|
275
|
+
}
|
|
252
276
|
default: throw new Error(`input: unknown op '${args.op}'`);
|
|
253
277
|
}
|
|
254
278
|
}),
|
|
@@ -221,7 +221,7 @@ export function registerMetaSpriteTools(server, z, sessionKey) {
|
|
|
221
221
|
},
|
|
222
222
|
safeTool(async (args) => {
|
|
223
223
|
switch (args.op) {
|
|
224
|
-
case "inspect": return await inspectSpritesCore(args);
|
|
224
|
+
case "inspect": return await inspectSpritesCore(args, sessionKey);
|
|
225
225
|
case "group": return await spritesGroup(sessionKey, args);
|
|
226
226
|
case "preview": return await spritesPreview(sessionKey, args);
|
|
227
227
|
case "capture": return await spritesCapture(sessionKey, { ...args, name: args.name ?? "metasprite" });
|
|
@@ -53,7 +53,7 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
53
53
|
// inspectPatternTiles lives in the `tiles` tool (tiles({op:'png'}), in
|
|
54
54
|
// tile-inspect.js) now — extracted here as a live-binding core. Reads the
|
|
55
55
|
// running emulator's pattern tables / VRAM (or an iNES file via `path`).
|
|
56
|
-
inspectPatternTilesCore = async ({ platform, path: romPath, bpp = 4, tileBaseByte = 0, paletteBase = 0, paletteIndex = 0, tileCount = 0, scale = 1, outputPath, inline }) => {
|
|
56
|
+
inspectPatternTilesCore = async ({ platform, path: romPath, bpp = 4, tileBaseByte = 0, paletteBase = 0, paletteIndex = 0, tileCount = 0, scale = 1, outputPath, inline }, callerSessionKey) => {
|
|
57
57
|
requireImageTarget(outputPath, inline, "inspectPatternTiles");
|
|
58
58
|
// Integer nearest-neighbor upscale of a PNG — keeps pixel-art tiles crisp
|
|
59
59
|
// while making a small tile strip actually readable inline.
|
|
@@ -102,7 +102,7 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
102
102
|
if (!hasChr) throw new Error(`'${romPath}' is a CHR-RAM cart — no graphics in the ROM file. Load it and call inspectPatternTiles without path.`);
|
|
103
103
|
return emit(png, { platform: p, source: "file", sourcePath: romPath, width, height });
|
|
104
104
|
}
|
|
105
|
-
const host = getHost(sessionKey);
|
|
105
|
+
const host = getHost(callerSessionKey ?? sessionKey);
|
|
106
106
|
const p = resolvePlatform(host, platform);
|
|
107
107
|
if (p === "nes") {
|
|
108
108
|
const { snapshotPatternTables } = await import("../../platforms/nes/ppu.js");
|
|
@@ -342,8 +342,8 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
342
342
|
};
|
|
343
343
|
|
|
344
344
|
// getCPUState → cpu({op:'read'}) (router in watch-memory.js). Live-binding core.
|
|
345
|
-
getCPUStateCore = async ({ platform, cpu = "main" }) => {
|
|
346
|
-
const host = getHost(sessionKey);
|
|
345
|
+
getCPUStateCore = async ({ platform, cpu = "main" }, callerSessionKey) => {
|
|
346
|
+
const host = getHost(callerSessionKey ?? sessionKey);
|
|
347
347
|
const p = resolvePlatform(host, platform);
|
|
348
348
|
const state = getCPUState(host, p, cpu);
|
|
349
349
|
if (!state) {
|
|
@@ -357,8 +357,8 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
357
357
|
// Subsumes the old getDspState / getPsgState / getYm2612State, which
|
|
358
358
|
// remain as thin deprecated aliases below for back-compat.
|
|
359
359
|
/** Run the chip decoder for one chip; returns the structured JSON object. */
|
|
360
|
-
function readAudioChip(chip) {
|
|
361
|
-
const host = getHost(sessionKey);
|
|
360
|
+
function readAudioChip(chip, callerSessionKey) {
|
|
361
|
+
const host = getHost(callerSessionKey ?? sessionKey);
|
|
362
362
|
if (chip === "dsp") {
|
|
363
363
|
const p = resolvePlatform(host, "snes");
|
|
364
364
|
const dsp = getDspState(host, p);
|
|
@@ -421,13 +421,20 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
421
421
|
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), or 'mikey' (Lynx).`);
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
-
getAudioStateCore = async ({ chip }) => jsonContent(readAudioChip(chip));
|
|
424
|
+
getAudioStateCore = async ({ chip }, callerSessionKey) => jsonContent(readAudioChip(chip, callerSessionKey));
|
|
425
425
|
|
|
426
426
|
// inspectSprites lives in the `sprites` tool (metasprite-tools.js) now —
|
|
427
427
|
// extracted here as a live-binding core so the router can call it without
|
|
428
428
|
// disturbing the other handlers registerPlatformTools owns.
|
|
429
|
-
inspectSpritesCore = async ({ platform, maxSlots, slots, outputPath, inline }) => {
|
|
430
|
-
|
|
429
|
+
inspectSpritesCore = async ({ platform, maxSlots, slots, outputPath, inline }, callerSessionKey) => {
|
|
430
|
+
// sessionKey MUST come from the live call, not the closure: these *Core
|
|
431
|
+
// functions are module-level `export let` bindings reassigned on every
|
|
432
|
+
// registerPlatformTools() run, so the closure's `sessionKey` is whatever
|
|
433
|
+
// session registered LAST — not the caller's. Threading it through the
|
|
434
|
+
// call args keeps each session reading its OWN host. (Same fix shape as
|
|
435
|
+
// inspectPaletteCore.) Falls back to the registration key for any caller
|
|
436
|
+
// that still invokes the old 1-arg form.
|
|
437
|
+
const host = getHost(callerSessionKey ?? sessionKey);
|
|
431
438
|
const p = resolvePlatform(host, platform);
|
|
432
439
|
// Generic slot filter, applied by each platform branch before returning.
|
|
433
440
|
// `slots` (explicit index list) wins over `maxSlots` (first N); the
|
|
@@ -693,8 +700,8 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
693
700
|
// inspectBackgroundMap lives in the `background` tool (rendering-context.js)
|
|
694
701
|
// now — extracted here as a live-binding core so the router can call it
|
|
695
702
|
// without disturbing the other handlers registerPlatformTools owns.
|
|
696
|
-
inspectBackgroundMapCore = async ({ platform, render, region, attributesOnly, tilesOnly, which, window, plane, tilemapBaseByte, tileBaseByte, bpp, mapWidth, mapHeight, outputPath, inline }) => {
|
|
697
|
-
const host = getHost(sessionKey);
|
|
703
|
+
inspectBackgroundMapCore = async ({ platform, render, region, attributesOnly, tilesOnly, which, window, plane, tilemapBaseByte, tileBaseByte, bpp, mapWidth, mapHeight, outputPath, inline }, callerSessionKey) => {
|
|
704
|
+
const host = getHost(callerSessionKey ?? sessionKey);
|
|
698
705
|
const p = resolvePlatform(host, platform);
|
|
699
706
|
if (attributesOnly && tilesOnly) {
|
|
700
707
|
throw new Error("inspectBackgroundMap: attributesOnly and tilesOnly are mutually exclusive — omit both to get tiles + subPaletteGrid together.");
|
|
@@ -126,7 +126,7 @@ export function registerPlaytestTools(server, z, sessionKey) {
|
|
|
126
126
|
});
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
const { playtest, KEYBOARD_BINDINGS_HELP } = await import("../../playtest/playtest.js");
|
|
129
|
+
const { playtest, KEYBOARD_BINDINGS_HELP, C64_BINDINGS_HELP } = await import("../../playtest/playtest.js");
|
|
130
130
|
let session;
|
|
131
131
|
try {
|
|
132
132
|
// Pass a live-host accessor so the window FOLLOWS rebuilds: runSource/
|
|
@@ -214,6 +214,7 @@ export function registerPlaytestTools(server, z, sessionKey) {
|
|
|
214
214
|
// isn't left guessing which keys drive the game. (A pad hot-plugged later
|
|
215
215
|
// is picked up automatically — this is just the at-open state.)
|
|
216
216
|
const noController = session.controllerCount === 0;
|
|
217
|
+
const isC64 = host.status?.platform === "c64";
|
|
217
218
|
return jsonContent({
|
|
218
219
|
opened: true,
|
|
219
220
|
reusedExistingWindow: false,
|
|
@@ -222,7 +223,21 @@ export function registerPlaytestTools(server, z, sessionKey) {
|
|
|
222
223
|
scale,
|
|
223
224
|
aspect,
|
|
224
225
|
controllerCount: session.controllerCount,
|
|
225
|
-
|
|
226
|
+
// C64 input is non-obvious (games need keyboard keys to START), so ALWAYS
|
|
227
|
+
// relay the controls — a controller alone IS enough (spare buttons/stick
|
|
228
|
+
// map to F1/Run-Stop/Space/Return), and the keyboard fallback covers the
|
|
229
|
+
// no-controller case. This is the Batocera/RetroDeck model.
|
|
230
|
+
...(isC64
|
|
231
|
+
? {
|
|
232
|
+
c64Controls: C64_BINDINGS_HELP,
|
|
233
|
+
tellUser:
|
|
234
|
+
"C64 game: RELAY `c64Controls` to the user. A CONTROLLER ALONE is " +
|
|
235
|
+
"enough — they do NOT need a keyboard. Most C64 games need a " +
|
|
236
|
+
"keyboard key to START (e.g. F1 for 1 player); the pad's spare " +
|
|
237
|
+
"buttons/right-stick map to those (F1/F3/F5/F7, Space, Run/Stop, " +
|
|
238
|
+
"Return). Default joystick port is 2; change with input({op:'joyport'}).",
|
|
239
|
+
}
|
|
240
|
+
: noController
|
|
226
241
|
? {
|
|
227
242
|
keyboardControls: KEYBOARD_BINDINGS_HELP,
|
|
228
243
|
tellUser:
|