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.
Files changed (40) hide show
  1. package/AGENTS.md +145 -498
  2. package/CHANGELOG.md +114 -3
  3. package/examples/atari7800/templates/sports.c +6 -2
  4. package/examples/sms/templates/shmup.c +5 -2
  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 +250 -1
  9. package/src/http/skill-doc.js +1 -1
  10. package/src/mcp/tools/index.js +4 -46
  11. package/src/mcp/tools/input-layout.js +10 -0
  12. package/src/mcp/tools/input.js +31 -2
  13. package/src/mcp/tools/playtest.js +17 -2
  14. package/src/mcp/tools/project.js +39 -6
  15. package/src/mcp/tools/record.js +9 -3
  16. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +155 -6
  17. package/src/platforms/atari2600/MENTAL_MODEL.md +37 -0
  18. package/src/platforms/atari7800/MENTAL_MODEL.md +36 -0
  19. package/src/platforms/c64/MENTAL_MODEL.md +103 -6
  20. package/src/platforms/gb/MENTAL_MODEL.md +56 -0
  21. package/src/platforms/gba/MENTAL_MODEL.md +57 -3
  22. package/src/platforms/gba/lib/arm-archives/libc.a +0 -0
  23. package/src/platforms/gba/lib/arm-archives/libgcc.a +0 -0
  24. package/src/platforms/gba/lib/arm-archives/libnosys.a +0 -0
  25. package/src/platforms/gbc/MENTAL_MODEL.md +21 -0
  26. package/src/platforms/genesis/MENTAL_MODEL.md +19 -0
  27. package/src/platforms/genesis/lib/c/libc.a +0 -0
  28. package/src/platforms/genesis/lib/c/libgcc.a +0 -0
  29. package/src/platforms/genesis/lib/c/libm.a +0 -0
  30. package/src/platforms/gg/MENTAL_MODEL.md +24 -0
  31. package/src/platforms/lynx/MENTAL_MODEL.md +33 -7
  32. package/src/platforms/msx/MENTAL_MODEL.md +27 -0
  33. package/src/platforms/nes/MENTAL_MODEL.md +35 -0
  34. package/src/platforms/sms/MENTAL_MODEL.md +51 -0
  35. package/src/platforms/snes/MENTAL_MODEL.md +21 -0
  36. package/src/platforms/snes/TROUBLESHOOTING.md +43 -0
  37. package/src/playtest/playtest.js +48 -0
  38. package/examples/msx/catch_game/_verify.mjs +0 -93
  39. package/examples/pce/catch_game/_verify.mjs +0 -75
  40. package/src/mcp/tool-manifest.js +0 -92
@@ -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();
@@ -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"
@@ -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'|'whatsNew'}) so an agent can
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.\n" +
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", "whatsNew"]).default("categories")
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); whatsNew=recent CHANGELOG + old→new tool rename table."),
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)",
@@ -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; 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)."),
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
- ...(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:
@@ -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
- 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\n${buildBlock}\n\n## Iterating\n\n- Edit \`${mainFilename}\` (or any of the runtime / crt0 / cfg files — they're yours).\n- Call \`build({output:"run", ...})\` to see your changes. It builds + 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`;
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: writtenFiles,
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: `Edit ${path.join(projPath, mainFilename)} and call build({output:"run", ...}) with sourcesPaths/includePaths pointing at the project's files (see the README's "Build + run" block for the exact call). Everything you need is in the directory nothing is hidden.`,
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
@@ -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
- host.setInput({ ports: script[scriptIdx].ports });
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);