romdevtools 0.23.0 → 0.25.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 +145 -498
- package/CHANGELOG.md +114 -3
- package/examples/atari7800/templates/sports.c +6 -2
- package/examples/sms/templates/shmup.c +5 -2
- 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 +250 -1
- package/src/http/skill-doc.js +1 -1
- package/src/mcp/tools/index.js +4 -46
- package/src/mcp/tools/input-layout.js +10 -0
- package/src/mcp/tools/input.js +31 -2
- package/src/mcp/tools/playtest.js +17 -2
- package/src/mcp/tools/project.js +39 -6
- package/src/mcp/tools/record.js +9 -3
- 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 +103 -6
- package/src/platforms/gb/MENTAL_MODEL.md +56 -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 +21 -0
- package/src/platforms/genesis/MENTAL_MODEL.md +19 -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/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/snes/MENTAL_MODEL.md +21 -0
- package/src/platforms/snes/TROUBLESHOOTING.md +43 -0
- package/src/playtest/playtest.js +48 -0
- package/examples/msx/catch_game/_verify.mjs +0 -93
- package/examples/pce/catch_game/_verify.mjs +0 -75
- package/src/mcp/tool-manifest.js +0 -92
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,167 @@ 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
|
+
* Press a C64 key like pressC64Key, but sample the machine-visible input
|
|
933
|
+
* state (CIA1 $DC00/$DC01 — the keyboard/joystick scan ports) BEFORE,
|
|
934
|
+
* DURING (key held), and AFTER (released). Lets an RE agent tell apart
|
|
935
|
+
* "my key never reached VICE" from "VICE saw it but the game didn't scan
|
|
936
|
+
* it this frame". No core change — reads the already-exposed c64_cia1_regs
|
|
937
|
+
* region ($DC00..$DC0F).
|
|
938
|
+
* @param {string} key
|
|
939
|
+
* @param {number} frames frames to hold (sampled at the midpoint)
|
|
940
|
+
* @returns {object} matrix coords + held flag + per-phase CIA snapshots
|
|
941
|
+
*/
|
|
942
|
+
pressC64KeyVerify(key, frames = 4) {
|
|
943
|
+
const mod = this._needMod();
|
|
944
|
+
if (typeof mod._romdev_key_matrix !== "function") {
|
|
945
|
+
throw new Error("this core build does not expose C64 keyboard input (C64/VICE only).");
|
|
946
|
+
}
|
|
947
|
+
const pos = C64_KEY_MATRIX[String(key).toLowerCase()];
|
|
948
|
+
if (!pos) {
|
|
949
|
+
throw new Error(`unknown C64 key '${key}'. Known: ${Object.keys(C64_KEY_MATRIX).join(", ")}.`);
|
|
950
|
+
}
|
|
951
|
+
const [row, col] = pos;
|
|
952
|
+
const held = Math.max(1, frames | 0);
|
|
953
|
+
// $DC00 = CIA1 PRA (port A — joystick 2 + keyboard col select),
|
|
954
|
+
// $DC01 = CIA1 PRB (port B — joystick 1 + keyboard row read).
|
|
955
|
+
const cia = () => {
|
|
956
|
+
try {
|
|
957
|
+
const r = this.readMemory("c64_cia1_regs", 0, 2);
|
|
958
|
+
return { DC00: r[0], DC01: r[1] };
|
|
959
|
+
} catch { return null; }
|
|
960
|
+
};
|
|
961
|
+
const before = cia();
|
|
962
|
+
mod._romdev_key_matrix(row, col, 1); // press
|
|
963
|
+
this.stepFrames(Math.ceil(held / 2));
|
|
964
|
+
const during = cia(); // key still held
|
|
965
|
+
this.stepFrames(Math.max(1, held - Math.ceil(held / 2)));
|
|
966
|
+
mod._romdev_key_matrix(row, col, 0); // release
|
|
967
|
+
this.stepFrames(1);
|
|
968
|
+
const after = cia();
|
|
969
|
+
return {
|
|
970
|
+
key: String(key).toLowerCase(),
|
|
971
|
+
row, col,
|
|
972
|
+
frames: held,
|
|
973
|
+
joyport: this.getC64JoyPort?.() ?? null,
|
|
974
|
+
autoReleased: true,
|
|
975
|
+
cia1: { before, during, after },
|
|
976
|
+
note: "CIA1 $DC00 (port A) / $DC01 (port B) are the keyboard/joystick scan ports the KERNAL reads. `during` is sampled with the key held; if before==during the key never moved the matrix line (didn't reach VICE); if they differ but the game didn't react, it scanned a different key/port or that screen ignores it.",
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Set the SET of C64 keyboard keys held down (for scripted timelines like
|
|
982
|
+
* recordSession). Diffs against the currently-held set: presses newly-added
|
|
983
|
+
* keys' matrix lines, releases removed ones. Pass [] to release all. Does NOT
|
|
984
|
+
* step frames — the caller's loop owns stepping. Unknown keys throw.
|
|
985
|
+
* @param {string[]} keys
|
|
986
|
+
* @returns {{held: string[], matrix: Array<[number,number]>}}
|
|
987
|
+
*/
|
|
988
|
+
setC64HeldKeys(keys) {
|
|
989
|
+
const mod = this._needMod();
|
|
990
|
+
if (typeof mod._romdev_key_matrix !== "function") {
|
|
991
|
+
throw new Error("this core build does not expose C64 keyboard input (C64/VICE only).");
|
|
992
|
+
}
|
|
993
|
+
const want = new Set((keys ?? []).map((k) => String(k).toLowerCase()));
|
|
994
|
+
for (const k of want) {
|
|
995
|
+
if (!C64_KEY_MATRIX[k]) {
|
|
996
|
+
throw new Error(`unknown C64 key '${k}'. Known: ${Object.keys(C64_KEY_MATRIX).join(", ")}.`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
const have = this._c64HeldKeys ?? (this._c64HeldKeys = new Set());
|
|
1000
|
+
// release keys no longer wanted
|
|
1001
|
+
for (const k of have) {
|
|
1002
|
+
if (!want.has(k)) { const [r, c] = C64_KEY_MATRIX[k]; mod._romdev_key_matrix(r, c, 0); have.delete(k); }
|
|
1003
|
+
}
|
|
1004
|
+
// press newly-added keys
|
|
1005
|
+
for (const k of want) {
|
|
1006
|
+
if (!have.has(k)) { const [r, c] = C64_KEY_MATRIX[k]; mod._romdev_key_matrix(r, c, 1); have.add(k); }
|
|
1007
|
+
}
|
|
1008
|
+
return { held: [...have], matrix: [...have].map((k) => C64_KEY_MATRIX[k]) };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Feed a PETSCII string into the C64 kernal keyboard buffer (for typing
|
|
1013
|
+
* LOAD/RUN/filenames). `\r` (or `\n`) becomes RETURN. Non-blocking — the
|
|
1014
|
+
* kernal drains it as the screen editor runs, so step frames after.
|
|
1015
|
+
* @param {string} text
|
|
1016
|
+
* @returns {number} kbdbuf_feed's result (>=0 ok)
|
|
1017
|
+
*/
|
|
1018
|
+
typeC64Text(text) {
|
|
1019
|
+
const mod = this._needMod();
|
|
1020
|
+
if (typeof mod._romdev_kbdbuf_feed !== "function") {
|
|
1021
|
+
throw new Error("this core build does not expose C64 text input (C64/VICE only).");
|
|
1022
|
+
}
|
|
1023
|
+
const s = String(text).replace(/\n/g, "\r");
|
|
1024
|
+
const bytes = Buffer.from(s + "\0", "latin1");
|
|
1025
|
+
const ptr = mod._malloc(bytes.length);
|
|
1026
|
+
try {
|
|
1027
|
+
mod.HEAPU8.set(bytes, ptr);
|
|
1028
|
+
return mod._romdev_kbdbuf_feed(ptr) | 0;
|
|
1029
|
+
} finally {
|
|
1030
|
+
mod._free(ptr);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Get the active C64 joystick port (1 or 2) the RetroPad drives. */
|
|
1035
|
+
getC64JoyPort() {
|
|
1036
|
+
const mod = this._needMod();
|
|
1037
|
+
if (typeof mod._romdev_joyport_get !== "function") {
|
|
1038
|
+
throw new Error("this core build does not expose C64 joyport (C64/VICE only).");
|
|
1039
|
+
}
|
|
1040
|
+
return mod._romdev_joyport_get() | 0;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** Set the active C64 joystick port (1 or 2). Default is 2 (most games). */
|
|
1044
|
+
setC64JoyPort(port) {
|
|
1045
|
+
const mod = this._needMod();
|
|
1046
|
+
if (typeof mod._romdev_joyport_set !== "function") {
|
|
1047
|
+
throw new Error("this core build does not expose C64 joyport (C64/VICE only).");
|
|
1048
|
+
}
|
|
1049
|
+
if (port !== 1 && port !== 2) throw new Error("C64 joyport must be 1 or 2.");
|
|
1050
|
+
mod._romdev_joyport_set(port | 0);
|
|
1051
|
+
return port | 0;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
805
1054
|
reset() {
|
|
806
1055
|
const mod = this._needMod();
|
|
807
1056
|
mod._retro_reset();
|
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/index.js
CHANGED
|
@@ -58,13 +58,11 @@ import { registerCheatTools } from "./cheats.js";
|
|
|
58
58
|
import { createDisclosure } from "../disclosure.js";
|
|
59
59
|
import { jsonContent, safeTool, withClearToolErrors } from "../util.js";
|
|
60
60
|
import { getHostOrNull, setDisclosure } from "../state.js";
|
|
61
|
-
import { MERGE_MAP } from "../tool-manifest.js";
|
|
62
|
-
import { readFile } from "node:fs/promises";
|
|
63
61
|
import { readFileSync } from "node:fs";
|
|
64
62
|
import { fileURLToPath } from "node:url";
|
|
65
63
|
import { dirname, join } from "node:path";
|
|
66
64
|
|
|
67
|
-
// Package version — surfaced by catalog({op:'status'
|
|
65
|
+
// Package version — surfaced by catalog({op:'status'}) so an agent can
|
|
68
66
|
// check the running romdev version with a plain TOOL CALL (works over MCP AND the
|
|
69
67
|
// HTTP/skill surface), e.g. to detect a saved skill is stale. (GET /healthz also
|
|
70
68
|
// reports it for non-tool HTTP clients.)
|
|
@@ -76,42 +74,6 @@ const PKG_VERSION = (() => {
|
|
|
76
74
|
}
|
|
77
75
|
})();
|
|
78
76
|
|
|
79
|
-
// catalog({op:'whatsNew'}): the recent CHANGELOG + an old→new RENAME TABLE
|
|
80
|
-
// derived from MERGE_MAP (the single source of truth for the consolidation), so
|
|
81
|
-
// an agent resuming a handoff written against an older server can re-map every
|
|
82
|
-
// renamed tool in ONE read. Pre-1.0 the surface is consolidated freely with NO
|
|
83
|
-
// deprecated aliases (see the consolidation), which is exactly why this exists.
|
|
84
|
-
async function buildWhatsNew() {
|
|
85
|
-
// old name → "newTool({axis:'oldOpName'})". The op value is best-effort: most
|
|
86
|
-
// absorbed tools become an op whose name is a shortened form, so we surface the
|
|
87
|
-
// axis + the new tool and let the tool's own description give the exact op enum.
|
|
88
|
-
const renames = {};
|
|
89
|
-
for (const [newTool, entry] of Object.entries(MERGE_MAP)) {
|
|
90
|
-
if (entry.unchanged) continue;
|
|
91
|
-
for (const old of entry.absorbs ?? []) {
|
|
92
|
-
renames[old] = { nowOn: newTool, axis: entry.axis };
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
// Recent CHANGELOG entries (top of the file = newest). Best-effort: if the file
|
|
96
|
-
// isn't packaged in some install, return the rename table alone.
|
|
97
|
-
let changelog = null;
|
|
98
|
-
try {
|
|
99
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
100
|
-
// src/mcp/tools/ → package root is three up.
|
|
101
|
-
const text = await readFile(join(here, "..", "..", "..", "CHANGELOG.md"), "utf8");
|
|
102
|
-
// Keep the two most recent version sections.
|
|
103
|
-
const sections = text.split(/^## /m);
|
|
104
|
-
changelog = sections.slice(0, 3).join("## ").trim();
|
|
105
|
-
} catch { /* changelog not present in this install */ }
|
|
106
|
-
return {
|
|
107
|
-
romdevVersion: PKG_VERSION,
|
|
108
|
-
note: "Pre-1.0 the tool surface is consolidated freely with NO deprecated aliases. If a tool name from an older handoff is missing, it's almost certainly now an `op` (or other axis) on a domain tool — find it below, then read that tool's description for the exact op enum and params.",
|
|
109
|
-
renameTable: renames,
|
|
110
|
-
axisLegend: "Every domain tool is keyed by ONE axis: op (most), output (build), on (breakpoint), target (disasm), view (background), source (palette), stage (encodeArt), from (importArt). The value names the operation, e.g. romPatch({op:'findPointer'}).",
|
|
111
|
-
...(changelog ? { changelog } : { changelogNote: "CHANGELOG.md not bundled in this install." }),
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
77
|
/**
|
|
116
78
|
* Categories for progressive disclosure. Each entry's `register` is the
|
|
117
79
|
* existing per-module registration function — we don't rewrite the
|
|
@@ -225,16 +187,12 @@ export function registerTools(server, z, sessionKey) {
|
|
|
225
187
|
"catalog",
|
|
226
188
|
"Orient yourself, keyed by `op`.\n" +
|
|
227
189
|
"• op:'categories' (default) — the catalog of tool categories, each {name, description, useWhen[], loaded}. This server registers EVERY tool at session start, so this is just a map grouped by purpose for orientation, NOT a gate — you do NOT need to load anything before calling a tool.\n" +
|
|
228
|
-
"• op:'status' — a snapshot of the current session: which platform's core/ROM is in the running host (if any), current frame count, last-loaded media, loaded categories. Call this when you've lost context across many tool calls and want to re-ground
|
|
229
|
-
"• op:'whatsNew' — the recent CHANGELOG + an OLD→NEW tool RENAME TABLE. Call this FIRST if you're resuming work from a handoff written against an older server: pre-1.0 the surface is consolidated freely (no deprecated aliases), so a name you remember may now be an `op` on a domain tool. This maps them in one read instead of probing each tool.",
|
|
190
|
+
"• op:'status' — a snapshot of the current session: which platform's core/ROM is in the running host (if any), current frame count, last-loaded media, loaded categories. Call this when you've lost context across many tool calls and want to re-ground.",
|
|
230
191
|
{
|
|
231
|
-
op: z.enum(["categories", "status"
|
|
232
|
-
.describe("categories=tool-category catalog; status=live session snapshot (romdevVersion + host/platform/frameCount/media — call this to check the running version, e.g. is a saved skill stale)
|
|
192
|
+
op: z.enum(["categories", "status"]).default("categories")
|
|
193
|
+
.describe("categories=tool-category catalog; status=live session snapshot (romdevVersion + host/platform/frameCount/media — call this to check the running version, e.g. is a saved skill stale)."),
|
|
233
194
|
},
|
|
234
195
|
safeTool(async ({ op = "categories" }) => {
|
|
235
|
-
if (op === "whatsNew") {
|
|
236
|
-
return jsonContent(await buildWhatsNew());
|
|
237
|
-
}
|
|
238
196
|
if (op === "status") {
|
|
239
197
|
const host = getHostOrNull(sessionKey);
|
|
240
198
|
const cats = disclosure.listCategories();
|
|
@@ -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,22 @@ 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)."),
|
|
231
|
+
verify: z.boolean().default(false).describe("op=pressKey (C64): also sample CIA1 $DC00/$DC01 (the keyboard/joystick scan ports the KERNAL reads) BEFORE / DURING (key held) / AFTER, plus matrix coords + active joyport. Use to tell apart 'my key never reached VICE' (before==during) from 'VICE saw it but the game ignored it' (they differ but no reaction) when a C64 game doesn't respond to a key."),
|
|
227
232
|
},
|
|
228
233
|
safeTool(async (args) => {
|
|
229
234
|
switch (args.op) {
|
|
@@ -249,6 +254,30 @@ export function registerInputTools(server, z, sessionKey) {
|
|
|
249
254
|
if (!args.platform) throw new Error("input({op:'layout'}): `platform` is required.");
|
|
250
255
|
return jsonContent(getInputLayoutCore(args));
|
|
251
256
|
}
|
|
257
|
+
case "pressKey": {
|
|
258
|
+
if (!args.key) throw new Error("input({op:'pressKey'}): `key` is required (C64 keyboard key, e.g. 'f1', 'return', 'run/stop').");
|
|
259
|
+
const host = getHost(sessionKey);
|
|
260
|
+
if (args.verify) {
|
|
261
|
+
const v = host.pressC64KeyVerify(args.key, args.frames ?? 4);
|
|
262
|
+
return jsonContent({ pressedKey: v.key, matrix: [v.row, v.col], frames: v.frames, joyport: v.joyport, autoReleased: v.autoReleased, cia1: v.cia1, frameCount: host.status.frameCount, note: v.note });
|
|
263
|
+
}
|
|
264
|
+
const r = host.pressC64Key(args.key, args.frames ?? 4);
|
|
265
|
+
return jsonContent({ pressedKey: r.key, matrix: [r.row, r.col], frames: r.frames, frameCount: host.status.frameCount });
|
|
266
|
+
}
|
|
267
|
+
case "typeText": {
|
|
268
|
+
if (typeof args.text !== "string") throw new Error("input({op:'typeText'}): `text` is required (string fed into the C64 keyboard buffer).");
|
|
269
|
+
const host = getHost(sessionKey);
|
|
270
|
+
const rc = host.typeC64Text(args.text);
|
|
271
|
+
return jsonContent({ typed: args.text, fedResult: rc, note: "Queued into the C64 keyboard buffer — step frames so the screen editor drains it." });
|
|
272
|
+
}
|
|
273
|
+
case "joyport": {
|
|
274
|
+
const host = getHost(sessionKey);
|
|
275
|
+
if (args.joyport === undefined) {
|
|
276
|
+
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." });
|
|
277
|
+
}
|
|
278
|
+
const set = host.setC64JoyPort(args.joyport);
|
|
279
|
+
return jsonContent({ joyport: set, set: true });
|
|
280
|
+
}
|
|
252
281
|
default: throw new Error(`input: unknown op '${args.op}'`);
|
|
253
282
|
}
|
|
254
283
|
}),
|
|
@@ -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:
|
package/src/mcp/tools/project.js
CHANGED
|
@@ -1422,7 +1422,7 @@ async function copyDirRecursive(fs, path, srcDir, dstDir, writtenFiles, dstPrefi
|
|
|
1422
1422
|
* handler returns: {path, platform, template, files, sourceFile,
|
|
1423
1423
|
* toolchain, nextStep}.
|
|
1424
1424
|
*/
|
|
1425
|
-
export async function createProjectImpl({ platform, name, path: projPath, title, template, overwrite = false, withSnippets = false }) {
|
|
1425
|
+
export async function createProjectImpl({ platform, name, path: projPath, title, template, overwrite = false, withSnippets = false, verbose = false }) {
|
|
1426
1426
|
const fs = await import("node:fs/promises");
|
|
1427
1427
|
const path = await import("node:path");
|
|
1428
1428
|
const { fileURLToPath } = await import("node:url");
|
|
@@ -1768,7 +1768,12 @@ Compiles **C89**, not C99/C11. Stick to:
|
|
|
1768
1768
|
}
|
|
1769
1769
|
filesSection += `\nEvery byte that compiles into your ROM is in this directory. If you move the repo somewhere else, you don't need to install anything from romdev to rebuild it — the compiler binaries are the only external dependency.\n\n`;
|
|
1770
1770
|
|
|
1771
|
-
|
|
1771
|
+
// Lead with the project-dir build — ONE call, no manifest. The verbose
|
|
1772
|
+
// output:'run' + sourcesPaths form (buildBlock) is the "editing loose
|
|
1773
|
+
// source" variant, shown second.
|
|
1774
|
+
const projectBuildBlock =
|
|
1775
|
+
"```js\nbuild({\n output: \"project\",\n platform: \"" + platform + "\",\n path: \"" + projPath + "\",\n outputPath: \"" + name + romExt + "\",\n})\n```";
|
|
1776
|
+
const readme = `# ${title ?? name}\n\nA ${lang} project for ${platform}, scaffolded by romdev.\n\n${tmpl?.describe ? tmpl.describe + "\n\n" : ""}${filesSection}${c89Note}## Build + run with romdev\n\nThe whole project directory builds in ONE call — romdev infers the toolchain, crt0, and linker from the directory, so you don't pass a file manifest:\n\n${projectBuildBlock}\n\nAdd \`output:"run"\` instead of \`"project"\` to also load + run + screenshot in the same round trip. Re-run the exact same call after every edit.\n\n<details>\n<summary>Alternative: build from a hand-specified source manifest (when compiling edited loose source, not a project dir)</summary>\n\n${buildBlock}\n</details>\n\n## Iterating\n\n- Edit \`${mainFilename}\` (or any of the runtime / crt0 / cfg files — they're yours).\n- Re-run the \`build({output:"project"|"run", path})\` call above to see your changes — it builds + (for run) loads + runs + screenshots in one round trip.\n- Inspect at byte level: \`memory({op:"read"})\`, \`sprites({op:"inspect"})\`, \`palette({source:"live"})\`, \`background({view:"rendered"})\`.\n- Open a playtest window for human eyes: \`playtest({op:"open"})\` — returns immediately, the window follows your rebuilds, and the emulator stays live for every other tool.\n`;
|
|
1772
1777
|
await fs.writeFile(path.join(projPath, "README.md"), readme, "utf-8");
|
|
1773
1778
|
writtenFiles.push("README.md");
|
|
1774
1779
|
|
|
@@ -1828,19 +1833,46 @@ Compiles **C89**, not C99/C11. Stick to:
|
|
|
1828
1833
|
}
|
|
1829
1834
|
}
|
|
1830
1835
|
|
|
1836
|
+
// Split the manifest: project-OWNED files (main.c, runtime helpers, crt0,
|
|
1837
|
+
// cfg, README…) are the only ones an agent touches; the rest are internal
|
|
1838
|
+
// toolchain copies on disk that never enter a decision. Echoing all of
|
|
1839
|
+
// them — 35/44 on NES (vendor/cc65/libsrc/*), 173/264 on GBA (libtonc
|
|
1840
|
+
// include/+sysinclude/), ~270 on SGDK Genesis — was pure context noise
|
|
1841
|
+
// across a matrix run. Default to a compact receipt (owned list + a
|
|
1842
|
+
// not-owned COUNT); `verbose:true` restores the full flat list.
|
|
1843
|
+
//
|
|
1844
|
+
// Classify NON-owned by what it actually is, NOT just a `vendor/` prefix:
|
|
1845
|
+
// the cc65 path lands under vendor/, but the GBA/Genesis SDKs drop their
|
|
1846
|
+
// header trees at include/ + sysinclude/ (no vendor/ prefix) and prebuilt
|
|
1847
|
+
// crt objects/archives at the root — none of which an agent edits. (R: the
|
|
1848
|
+
// original `!startsWith('vendor/')` denylist missed exactly these two SDK
|
|
1849
|
+
// platforms — same bug class as the original fix, second location.)
|
|
1850
|
+
const isVendored = (f) =>
|
|
1851
|
+
f.startsWith("vendor/") || // cc65 libsrc, pvsneslib, sgdk src
|
|
1852
|
+
f.startsWith("include/") || // SDK header trees (libtonc/libgba/SGDK/maxmod)
|
|
1853
|
+
f.startsWith("sysinclude/") || // libgba/libtonc system headers
|
|
1854
|
+
/^crt[a-z0-9]*\.o$/i.test(f) || // prebuilt crt objects (crti/crtn/crtbegin/crtend)
|
|
1855
|
+
/\.(a|lib)$/i.test(f); // prebuilt static archives
|
|
1856
|
+
const ownedFiles = writtenFiles.filter((f) => !isVendored(f));
|
|
1857
|
+
const vendorFileCount = writtenFiles.length - ownedFiles.length;
|
|
1831
1858
|
return {
|
|
1832
1859
|
path: projPath,
|
|
1833
1860
|
platform,
|
|
1834
1861
|
template: hasTemplates ? (template ?? "default") : null,
|
|
1835
|
-
files
|
|
1862
|
+
// The files you actually edit. Vendored toolchain copies are summarized,
|
|
1863
|
+
// not listed — they're on disk under vendor/ if you ever need them.
|
|
1864
|
+
files: ownedFiles,
|
|
1865
|
+
fileCount: writtenFiles.length,
|
|
1866
|
+
vendorFileCount,
|
|
1867
|
+
...(verbose ? { allFiles: writtenFiles } : {}),
|
|
1836
1868
|
snippetsCopied: withSnippets ? snippetFiles : null,
|
|
1837
1869
|
sourceFile: path.join(projPath, mainFilename),
|
|
1838
1870
|
toolchain: lang,
|
|
1839
|
-
nextStep: `
|
|
1871
|
+
nextStep: `Build the scaffold AS-IS in one call: build({output:"project", platform:"${platform}", path:"${projPath}", outputPath:"<game>.<ext>"}) — it infers the toolchain/crt0/linker from the directory, no sourcesPaths/includePaths/linkerConfig needed. Then edit ${mainFilename} and re-run the same call. (build({output:"run", ...}) with a hand-specified sourcesPaths manifest is the alternative when you're compiling edited loose source instead of a project dir.)`,
|
|
1840
1872
|
};
|
|
1841
1873
|
}
|
|
1842
1874
|
|
|
1843
|
-
async function createGameCore({ platform, genre, name, path: projPath, title, overwrite }) {
|
|
1875
|
+
async function createGameCore({ platform, genre, name, path: projPath, title, overwrite, verbose = false }) {
|
|
1844
1876
|
// The five canonical genres. A genre is available on a platform iff
|
|
1845
1877
|
// TEMPLATES[platform] has a matching template entry — we DERIVE
|
|
1846
1878
|
// availability from TEMPLATES rather than maintain a parallel table,
|
|
@@ -1887,7 +1919,7 @@ async function createGameCore({ platform, genre, name, path: projPath, title, ov
|
|
|
1887
1919
|
// Genre id IS the template id (they're 1:1 by construction).
|
|
1888
1920
|
const templateId = genre;
|
|
1889
1921
|
const result = await createProjectImpl({
|
|
1890
|
-
platform, template: templateId, name, path: projPath, title, overwrite,
|
|
1922
|
+
platform, template: templateId, name, path: projPath, title, overwrite, verbose,
|
|
1891
1923
|
});
|
|
1892
1924
|
return { ...result, genre, template: templateId };
|
|
1893
1925
|
}
|
|
@@ -1917,6 +1949,7 @@ export function registerProjectTools(server, z) {
|
|
|
1917
1949
|
// project
|
|
1918
1950
|
template: z.string().optional().describe("op=project: template id ('default' | 'hello_sprite' | 'tile_engine' on NES/GB/GBC; 'default' elsewhere)."),
|
|
1919
1951
|
withSnippets: z.boolean().default(false).describe("op=project: also drop every vetted snippet alongside main (= scaffold copySnippets after)."),
|
|
1952
|
+
verbose: z.boolean().default(false).describe("op=project/game: echo the FULL flat file manifest (incl. vendor/** toolchain copies) as `allFiles`. Default false — the response lists only project-OWNED files you edit (`files`) plus a `vendorFileCount`, since the vendored toolchain copies are on disk and never need echoing (they're 35 of 44 entries on NES, ~270 on SGDK Genesis). Set true only if you specifically need every path in the response."),
|
|
1920
1953
|
// game
|
|
1921
1954
|
genre: z.string().optional().describe("op=game: 'shmup' | 'platformer' | 'puzzle' | 'sports' | 'racing'."),
|
|
1922
1955
|
// snippets
|
package/src/mcp/tools/record.js
CHANGED
|
@@ -44,11 +44,12 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
44
44
|
.array(
|
|
45
45
|
z.object({
|
|
46
46
|
atFrame: z.number().int().min(0),
|
|
47
|
-
ports: z.array(inputShape).max(2),
|
|
47
|
+
ports: z.array(inputShape).max(2).optional(),
|
|
48
|
+
keys: z.array(z.string()).optional().describe("C64-ONLY: C64 keyboard keys held from this frame until the next entry (f1/f3/f5/f7, return, space, run/stop, a-z, 0-9, …). [] releases all held keys. Unknown keys are rejected with a clear error. Lets you script a keyboard+joystick startup timeline (e.g. {atFrame:0,keys:['f1']},{atFrame:30,ports:[{b:true}]},{atFrame:90,keys:['run/stop']}) in one call."),
|
|
48
49
|
}),
|
|
49
50
|
)
|
|
50
51
|
.optional()
|
|
51
|
-
.describe("Per-frame input changes. Each entry sets the input at `atFrame` and holds it until the next entry."),
|
|
52
|
+
.describe("Per-frame input changes. Each entry sets the input at `atFrame` (joystick `ports` and/or C64 `keys`) and holds it until the next entry. Either field is optional — a step may set just keys, just ports, or both."),
|
|
52
53
|
memorySamples: z
|
|
53
54
|
.array(
|
|
54
55
|
z.object({
|
|
@@ -99,7 +100,12 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
99
100
|
while (elapsed < frames) {
|
|
100
101
|
// Apply any scripted inputs whose atFrame ≤ current frame.
|
|
101
102
|
while (scriptIdx < script.length && script[scriptIdx].atFrame <= elapsed) {
|
|
102
|
-
|
|
103
|
+
const entry = script[scriptIdx];
|
|
104
|
+
if (entry.ports) host.setInput({ ports: entry.ports });
|
|
105
|
+
// C64 keyboard keys held from this entry until the next. Pass [] to
|
|
106
|
+
// release all. Only valid on a C64/VICE host (setC64HeldKeys throws
|
|
107
|
+
// otherwise — surfaced as a clear error, not a silent no-op).
|
|
108
|
+
if (entry.keys !== undefined) host.setC64HeldKeys(entry.keys);
|
|
103
109
|
scriptIdx++;
|
|
104
110
|
}
|
|
105
111
|
const batch = Math.min(sampleEvery, frames - elapsed);
|