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.
Files changed (54) hide show
  1. package/AGENTS.md +169 -494
  2. package/CHANGELOG.md +103 -0
  3. package/examples/genesis/templates/platformer.c +5 -1
  4. package/examples/genesis/templates/two_plane_parallax.c +166 -0
  5. package/package.json +2 -2
  6. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  7. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  8. package/src/host/LibretroHost.js +225 -2
  9. package/src/host/framebuffer.js +37 -0
  10. package/src/http/skill-doc.js +1 -1
  11. package/src/mcp/tools/audio.js +2 -2
  12. package/src/mcp/tools/frame.js +13 -34
  13. package/src/mcp/tools/index.js +2 -2
  14. package/src/mcp/tools/input-layout.js +10 -0
  15. package/src/mcp/tools/input.js +26 -2
  16. package/src/mcp/tools/metasprite-tools.js +1 -1
  17. package/src/mcp/tools/platform-tools.js +18 -11
  18. package/src/mcp/tools/playtest.js +17 -2
  19. package/src/mcp/tools/project.js +9 -1
  20. package/src/mcp/tools/rendering-context.js +1 -1
  21. package/src/mcp/tools/symbols.js +130 -39
  22. package/src/mcp/tools/tile-inspect.js +1 -1
  23. package/src/mcp/tools/toolchain.js +3 -2
  24. package/src/mcp/tools/watch-memory.js +58 -6
  25. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +155 -6
  26. package/src/platforms/atari2600/MENTAL_MODEL.md +37 -0
  27. package/src/platforms/atari7800/MENTAL_MODEL.md +36 -0
  28. package/src/platforms/c64/MENTAL_MODEL.md +83 -6
  29. package/src/platforms/gb/MENTAL_MODEL.md +74 -0
  30. package/src/platforms/gb/lib/c/SDCC_GOTCHAS.md +91 -0
  31. package/src/platforms/gba/MENTAL_MODEL.md +57 -3
  32. package/src/platforms/gba/lib/arm-archives/libc.a +0 -0
  33. package/src/platforms/gba/lib/arm-archives/libgcc.a +0 -0
  34. package/src/platforms/gba/lib/arm-archives/libnosys.a +0 -0
  35. package/src/platforms/gbc/MENTAL_MODEL.md +34 -0
  36. package/src/platforms/gbc/lib/c/SDCC_GOTCHAS.md +91 -0
  37. package/src/platforms/genesis/MENTAL_MODEL.md +180 -0
  38. package/src/platforms/genesis/TROUBLESHOOTING.md +32 -0
  39. package/src/platforms/genesis/lib/c/libc.a +0 -0
  40. package/src/platforms/genesis/lib/c/libgcc.a +0 -0
  41. package/src/platforms/genesis/lib/c/libm.a +0 -0
  42. package/src/platforms/gg/MENTAL_MODEL.md +24 -0
  43. package/src/platforms/gg/lib/c/gg_crt0.s +30 -0
  44. package/src/platforms/lynx/MENTAL_MODEL.md +33 -7
  45. package/src/platforms/msx/MENTAL_MODEL.md +27 -0
  46. package/src/platforms/nes/MENTAL_MODEL.md +35 -0
  47. package/src/platforms/sms/MENTAL_MODEL.md +51 -0
  48. package/src/platforms/sms/lib/c/sms_crt0.s +40 -0
  49. package/src/platforms/snes/MENTAL_MODEL.md +21 -0
  50. package/src/platforms/snes/TROUBLESHOOTING.md +43 -0
  51. package/src/playtest/playtest.js +48 -0
  52. package/src/toolchains/sdcc/preflight-lint.js +164 -8
  53. package/examples/msx/catch_game/_verify.mjs +0 -93
  54. package/examples/pce/catch_game/_verify.mjs +0 -75
@@ -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: "genesis_cram / genesis_vsram / genesis_vdp_regs — the generic 'video_ram' id isn't wired in gpgx for Genesis. VRAM itself isn't exposed; use inspectPatternTiles / inspectBackgroundMap / getRenderingContext instead." },
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];
@@ -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
+ }
@@ -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 (/you are reading this because romdev is connected|connect your (agent|coding agent)|restart its MCP connection|restart your MCP|your MCP client should/i.test(line)) continue;
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"
@@ -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
  };
@@ -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
- // Downscale AFTER overlay so the boxes scale with the image. scale=1 (or
304
- // unset) is the full-resolution default; a quarter-size shot is ~75%
305
- // fewer image tokens for routine "did it change?" sanity checks.
306
- if (scale && scale < 1) {
307
- const small = downscalePng(pngBase64, scale);
308
- pngBase64 = small.base64; width = small.width; height = small.height;
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, ...(scale && scale < 1 ? { scale, fullWidth: shot.width, fullHeight: shot.height } : {}), overlay: overlayInfo });
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}${scale && scale < 1 ? ` (scaled 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).` },
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<≤1) downscales (~75% fewer image tokens at 0.5); ascii cols/rows/symbols/colors knobs in the param hints. " +
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: downscale factor (0<scale1, nearest-neighbor). 0.5 ≈ 75% fewer image tokens."),
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."),
@@ -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), watchMemory (cross-platform memory-write tracesee what code touched a RAM byte), runUntilWrite (step until target byte is written, return the PC), record (capture inputs for replay).",
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)",
@@ -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; or get the input layout."),
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
- const host = getHost(sessionKey);
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
- ...(noController
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: