romdevtools 0.24.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 CHANGED
@@ -148,10 +148,12 @@ worry about ground truth:
148
148
 
149
149
  1. **`scaffold({op:'project', platform, template, name, path})`** — drops a
150
150
  complete, self-contained project tree on disk (main.c + the
151
- runtime files it needs + your `vendor/` library source for
152
- reference + README + .gitignore). Build with `build({output:'run'})` against
153
- the project's files; the bundled examples ARE the reference
154
- implementation.
151
+ runtime files it needs + the vendored library source for
152
+ reference + README + .gitignore). The response lists only the files you EDIT
153
+ (`files`) + a `vendorFileCount`; pass `verbose:true` for the full manifest.
154
+ Build the whole dir in one call with `build({output:'project', path,
155
+ outputPath})` (toolchain/crt0/linker inferred — no manifest); the bundled
156
+ examples ARE the reference implementation.
155
157
  2. **`scaffold({op:'game', platform, genre})`** — same but picks a known-good
156
158
  genre scaffold (shmup / platformer / puzzle / sports / racing).
157
159
  3. **`scaffold({op:'snippets', platform, mode})`** (mode `list`/`get`/`getAll`)
package/CHANGELOG.md CHANGED
@@ -4,6 +4,79 @@ All notable changes to `romdevtools`. Dates are release dates.
4
4
  (Published as `romdev-mcp` through 0.11.0; renamed to `romdevtools` in 0.13.0 —
5
5
  the `romdev-mcp` bin is kept as an alias.)
6
6
 
7
+ ## 0.25.0
8
+
9
+ ### Added — C64 input scripting + verification (RE startup-flow telemetry)
10
+ Follow-up to the 0.24.0 C64 keyboard work: an agent RE'ing C64 Uridium could now
11
+ press keys, but couldn't (a) script a keyboard+joystick startup TIMELINE in one
12
+ call, or (b) tell whether a non-responsive key reached VICE at all. Both added — no
13
+ core rebuild (the `c64_cia1_regs` region + key matrix already existed):
14
+ - **`recordSession` `inputScript[].keys`** — hold C64 keyboard keys from a frame
15
+ until the next entry, interleaved with joystick `ports`, in one deterministic
16
+ timeline (e.g. `{atFrame:0,keys:['f1']},{atFrame:30,ports:[{b:true}]},
17
+ {atFrame:60,keys:['run/stop']},{atFrame:90,keys:[]}`). `ports` is now optional
18
+ (a step may set just keys). Unknown keys are **rejected with a clear error**, not
19
+ silently ignored.
20
+ - **`input({op:'pressKey', verify:true})`** — also samples CIA1 **`$DC00`/`$DC01`**
21
+ (the keyboard/joystick scan ports the KERNAL reads) **before / during (key held)
22
+ / after**, plus matrix coords + active joyport. Lets you distinguish "my key
23
+ never reached VICE" (`before==during`) from "VICE saw it but the game ignored it"
24
+ (they differ, no reaction) when a C64 game doesn't respond.
25
+
26
+ ### Changed — `scaffold` no longer echoes the vendored toolchain manifest
27
+ `scaffold({op:'project'|'game'})` used to return a flat `files[]` of EVERY written
28
+ file — including the toolchain copies (35 of 44 entries on NES, **173 of 264 on
29
+ GBA**, ~270 on SGDK Genesis) that an agent never touches. Across a matrix run (one
30
+ game × every genre × every platform) that was ~100 KB of pure vendored-path lists
31
+ in context with zero decision value. Now the response is a compact receipt:
32
+ - `files` — only the project-**OWNED** files you edit (main source, runtime, crt0,
33
+ cfg, README).
34
+ - `fileCount` (total written) + `vendorFileCount` (the summarized vendored copies,
35
+ on disk if you ever need them).
36
+ - `verbose:true` restores the full flat list as `allFiles`.
37
+
38
+ "Owned" is classified by what a file **is**, not just a `vendor/` prefix — so it
39
+ correctly excludes the SDK header trees the GBA (libtonc `include/`+`sysinclude/`)
40
+ and Genesis (SGDK `include/`) toolchains drop OUTSIDE `vendor/`, plus prebuilt
41
+ `crt*.o` / `*.a` / `*.lib`. (The initial fix used a `vendor/`-prefix denylist and
42
+ missed exactly those two SDK platforms — caught + fixed via a 0.24.0 matrix-run
43
+ report. GBA dropped 173→9 owned, Genesis 82→13.) Mirrors the `inline`/`outputPath`
44
+ choose-your-payload pattern the snippets op already had.
45
+
46
+ ### Changed — scaffold README + `nextStep` lead with `build({output:'project'})`
47
+ The generated project README and the scaffold's `nextStep` now lead with the
48
+ one-call **`build({output:'project', platform, path, outputPath})`** form (infers
49
+ toolchain/crt0/linker from the directory — no `sourcesPaths`/`includePaths`/
50
+ `linkerConfig` to hand-specify), and demote the verbose `output:'run'` + manifest
51
+ form to a collapsed "compiling edited loose source" alternative. The project-dir
52
+ build was already the easier path; now it's the one a fresh agent copies first.
53
+
54
+ ### Fixed — SMS shmup + Atari 7800 sports scaffolds rendered with wrong colors
55
+ Both built and booted but looked broken (a 0.24.0 matrix report flagged them):
56
+ - **SMS shmup** rendered the starfield as blue/**GREEN** striped bands. The BG
57
+ palette had colour 1 = `0x08`, which in SMS 2-2-2 BGR is green (G bits), not the
58
+ "deep space blue" the comment claimed. Fixed to a pure-blue depth gradient
59
+ (`0x10/0x20/0x30`) — the bands now read as space, dominant colour went green
60
+ `#00aa00` → blue `#0000ad`.
61
+ - **Atari 7800 sports** rendered a near-black playfield that looked dead. Two MARIA
62
+ colour-byte bugs: the court walls used `0x48` (hue 4 = RED → **pink**, not the
63
+ intended blue) and the court floor was `0x00` (black, indistinguishable from a
64
+ blank screen). Fixed to blue walls (`0x8A`, hue 8) + a dark-green court floor
65
+ (`0xB4`) — now reads as an actual court (dominant black → green `#008221`).
66
+
67
+ Both verified by screenshot + `frame({op:'verify'})`. (These were colour-value
68
+ bugs in the scaffold templates, not the render pipeline.)
69
+
70
+ ### Removed — `catalog({op:'whatsNew'})` + the old→new tool rename table
71
+ `whatsNew` returned a 125-entry map of pre-1.0 renamed tool names (plus, until now,
72
+ ~1.4k tokens of inlined CHANGELOG prose) so an agent resuming an old handoff could
73
+ re-map a tool that had moved. The pre-1.0 consolidation is long settled — the old
74
+ names are git history, and no running agent carries them — so maintaining a
75
+ forever-growing rename record (and risking it landing in context) wasn't worth it.
76
+ Dropped the op, the `tool-manifest.js` map, and its tests. An agent that hits an
77
+ unknown tool name now just reads the current surface (`catalog({op:'categories'})`
78
+ or the tool list); full release notes remain in CHANGELOG.md for humans.
79
+
7
80
  ## 0.24.0
8
81
 
9
82
  ### Added — C64 keyboard + joyport input (VICE core patch)
@@ -180,11 +180,15 @@ void main(void) {
180
180
  p1y = 110; p2y = 110;
181
181
  serve_ball(0);
182
182
 
183
- BACKGRND = 0x00; /* black court */
183
+ /* MARIA color byte = (hue<<4)|lum. Court = dark green so the play area
184
+ * reads as an actual field (0x00 black looked like a blank/dead screen);
185
+ * walls = blue. NB: 0x48 is hue 4 = RED-magenta (renders PINK), not blue —
186
+ * blue is hue 8/9, so the walls use 0x8A. */
187
+ BACKGRND = 0xB4; /* dark green court floor (hue 11) */
184
188
  P0C1 = 0x0F; /* white paddles + ball */
185
189
  P0C2 = 0x0F;
186
190
  P0C3 = 0x0F;
187
- P1C1 = 0x48; /* court walls (blue) */
191
+ P1C1 = 0x8A; /* court walls — BLUE (hue 8); was 0x48 = pink */
188
192
  CHARBASE = 0;
189
193
  OFFSET = 0;
190
194
 
@@ -30,8 +30,11 @@ extern void sms_sat_upload(void);
30
30
  #define T_ENEMY 2
31
31
 
32
32
  static const uint8_t palette[32] = {
33
- /* BG: 0 = backdrop, 1 = deep space blue, 2 = lighter space blue, 3 = star white */
34
- 0x10,0x08,0x20,0x3F, 0x00,0x00,0x00,0x00,
33
+ /* BG: 0 = backdrop, 1 = deep space blue, 2 = lighter space blue, 3 = star white.
34
+ * SMS CRAM is 2-2-2 BGR (bits --BB GGRR), so PURE blue uses only the B bits:
35
+ * 0x10=dark, 0x20=medium, 0x30=bright. (The old 0x08 for colour 1 was G=2 =
36
+ * GREEN, which made the alternating starfield bands render blue/GREEN striped.) */
37
+ 0x10,0x20,0x30,0x3F, 0x00,0x00,0x00,0x00,
35
38
  0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,
36
39
  /* Sprite palette: white, yellow, red */
37
40
  0x00,0x3F,0x0F,0x03, 0x00,0x00,0x00,0x00,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "romdevtools",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Tool server giving coding agents full control of homebrew ROM development AND reverse-engineering/romhacking across 14 retro platforms (NES, SNES, GB, Genesis, Atari, C64, PC Engine, MSX, ...) via WASM toolchains + emulator cores. Use over plain HTTP, as an Agent Skill, or as an MCP server.",
5
5
  "type": "module",
6
6
  "main": "src/mcp/server.js",
@@ -928,6 +928,86 @@ export class LibretroHost {
928
928
  return { key: String(key).toLowerCase(), row, col, frames: Math.max(1, frames | 0) };
929
929
  }
930
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
+
931
1011
  /**
932
1012
  * Feed a PETSCII string into the C64 kernal keyboard buffer (for typing
933
1013
  * LOAD/RUN/filenames). `\r` (or `\n`) becomes RETURN. Non-blocking — the
@@ -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();
@@ -228,6 +228,7 @@ export function registerInputTools(server, z, sessionKey) {
228
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
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
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."),
231
232
  },
232
233
  safeTool(async (args) => {
233
234
  switch (args.op) {
@@ -256,6 +257,10 @@ export function registerInputTools(server, z, sessionKey) {
256
257
  case "pressKey": {
257
258
  if (!args.key) throw new Error("input({op:'pressKey'}): `key` is required (C64 keyboard key, e.g. 'f1', 'return', 'run/stop').");
258
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
+ }
259
264
  const r = host.pressC64Key(args.key, args.frames ?? 4);
260
265
  return jsonContent({ pressedKey: r.key, matrix: [r.row, r.col], frames: r.frames, frameCount: host.status.frameCount });
261
266
  }
@@ -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);
@@ -134,6 +134,26 @@ key:
134
134
  A typical C64 RE startup: load → step to the title → `pressKey f1` (1 player) →
135
135
  `set {b:true}` (fire to start) → step → you're in gameplay → `state({op:'save'})`.
136
136
 
137
+ **Script the whole startup in one call.** `recordSession`'s `inputScript` takes
138
+ C64 `keys` alongside joystick `ports` — held from each entry until the next — so a
139
+ key+joystick startup is one deterministic timeline instead of many calls:
140
+ ```js
141
+ recordSession({ frames:600, sampleEvery:60, outputDir:'…', inputScript:[
142
+ { atFrame:0, keys:['f1'] }, // 1 player
143
+ { atFrame:30, ports:[{ b:true }] }, // fire (port 2)
144
+ { atFrame:90, keys:['run/stop'] }, // start
145
+ { atFrame:120, keys:[] } ] }) // release
146
+ ```
147
+ A step may set just `keys`, just `ports`, or both; `keys:[]` releases all. An
148
+ unknown key is rejected with a clear error (not a silent no-op).
149
+
150
+ **When a key seems ignored, probe it.** `input({op:'pressKey', key:'run/stop',
151
+ verify:true})` returns the matrix coords, active joyport, and a CIA1 snapshot
152
+ (`$DC00`/`$DC01`) **before / during (held) / after**. `before==during` ⇒ the key
153
+ never moved the matrix line (didn't reach VICE); they differ but the game didn't
154
+ react ⇒ it scanned a different key/port, or that screen (crack/doc/trainer)
155
+ ignores it. This is how you tell a romdev problem from a game-flow problem.
156
+
137
157
  **Controller-alone (playtest):** a human in `playtest` needs no keyboard — the
138
158
  pad's spare buttons map to the C64 keys (X=Space, L2=Run/Stop, R2=Return,
139
159
  right-stick=F1/F3/F5/F7, top face=F1; d-pad+Fire=joystick). The same mapping
@@ -1,92 +0,0 @@
1
- // Tool manifest — the SINGLE SOURCE OF TRUTH for the consolidated tool surface.
2
- //
3
- // The 132→34 consolidation (see internal CONSOLIDATION_PLAN) merges narrow tools
4
- // into domain tools with a typed operation axis. This manifest records, for each
5
- // consolidated tool, the OLD tools it absorbs and the axis it routes on — so:
6
- // 1. a coverage-gate test can assert every old tool name maps to exactly one
7
- // new tool (no capability silently dropped, no dupe);
8
- // 2. a tool-count budget test can fail the build if the surface regrows;
9
- // 3. docs + the rename map for downstream agents derive from one place.
10
- //
11
- // GOVERNANCE: a new capability is a new PARAMETER/op-value on an existing tool by
12
- // default — NOT a new top-level tool. Adding an entry here is a deliberate act the
13
- // budget test surfaces at PR time.
14
- //
15
- // Each MERGE_MAP entry: newTool → { absorbs:[...oldNames], axis:'op'|'as'|... }.
16
- // `absorbs: []` + `unchanged:true` means the tool kept its name (no merge).
17
- // This map grows one domain at a time as each consolidated tool lands.
18
-
19
- export const MERGE_MAP = {
20
- // ── files (generic disk I/O) ──
21
- files: { absorbs: ["writeAsset", "readAsset", "listAssets"], axis: "op" },
22
- // ── cheats (DB lookup/search + apply/clear + make) ──
23
- cheats: { absorbs: ["gameCheats", "searchCheats", "applyCheat", "clearCheats", "makeCheat"], axis: "op" },
24
- // ── text (custom-font learn/encode/find for romhacking) ──
25
- text: { absorbs: ["learnFontMap", "encodeTextForRom", "findEncodedText"], axis: "op" },
26
- // ── symbols (name↔addr, memory map, PC→symbol). buildSourceWithDebug stays for `build`. ──
27
- symbols: { absorbs: ["resolveSymbol", "lookupAddress", "getMemoryMap", "listSymbols", "addressToSymbol"], axis: "op" },
28
- // ── disasm (raw bytes / ROM / project / references) ──
29
- disasm: { absorbs: ["disassemble", "disassembleRom", "disassembleProject", "findReferences"], axis: "target" },
30
- // ── state (save/load/list/export/dump/diff; diffState moved here from memory.js) ──
31
- state: { absorbs: ["saveState", "loadState", "listStates", "exportState", "dumpState", "diffState"], axis: "op" },
32
- // ── input (set/press/sequence/navigate/layout; getInputLayout folded in) ──
33
- input: { absorbs: ["setInput", "pressButton", "inputSequence", "navigate", "getInputLayout"], axis: "op" },
34
- // ── platform (list/resolve/toolchains/docs/doc; spans platforms.js+platform-docs.js+toolchain.js) ──
35
- platform: { absorbs: ["listPlatforms", "resolvePlatform", "listToolchains", "installToolchain", "listPlatformDocs", "getPlatformDoc"], axis: "op" },
36
- // ── host (unload/shutdown/reset/pause/resume FSM; loadMedia + getStatus stay separate) ──
37
- host: { absorbs: ["unloadMedia", "shutdown", "reset", "pause", "resume"], axis: "op" },
38
- // ── frame (step/screenshot/stepAndShot/stepInstruction; stepInstruction folded from watch-memory.js) ──
39
- frame: { absorbs: ["stepFrames", "screenshot", "stepAndScreenshot", "stepInstruction"], axis: "op" },
40
- // ── scaffold (project/game + snippets; patchGbHeader folded into romPatch op:'gbHeader') ──
41
- scaffold: { absorbs: ["createProject", "createGame", "starterSnippets", "copyStarterSnippets"], axis: "op" },
42
- // ── cart (identify/extract/wrap; identifyRom from rom-id.js, rest from cart-parts.js) ──
43
- cart: { absorbs: ["identifyRom", "extractCart", "wrapRomFromParts"], axis: "op" },
44
- // ── palette (live/platformMaster/lospec; spans platform-tools.js + lospec.js) ──
45
- palette: { absorbs: ["inspectPalette", "getPlatformPalettePng", "getLospecPalette"], axis: "source" },
46
- // ── audioDebug (inspect/record; getAudioState from platform-tools.js, recordAudio from audio.js; pcmToBrr/wavToXgm2Pcm stay) ──
47
- audioDebug: { absorbs: ["getAudioState", "recordAudio"], axis: "op" },
48
- // ── sprites (inspect OAM + meta-sprite pipeline; inspectSprites from platform-tools.js, rest from metasprite-tools.js; validateGenesisTiles stays for encodeArt) ──
49
- sprites: { absorbs: ["inspectSprites", "groupVisibleSprites", "previewVisibleSprites", "captureMetaSprite", "renderMetaSpritePreview", "emitMetaSpriteRenderer", "extractSpriteFromScreenshot"], axis: "op" },
50
- // ── background (tilemap/render-state; inspectBackgroundMap from platform-tools.js, getRenderingContext from rendering-context.js, whichTilesAreRendered from which-tiles.js) ──
51
- background: { absorbs: ["inspectBackgroundMap", "getRenderingContext", "whichTilesAreRendered"], axis: "view" },
52
- // ── tiles (decode/render tile bytes; inspectPatternTiles from platform-tools.js, getTile/tileFingerprints/tilesAscii from tile-inspect.js, extractSpriteSheet from rom-id.js, previewTileArt from preview-tile.js) ──
53
- tiles: { absorbs: ["inspectPatternTiles", "getTile", "tileFingerprints", "tilesAscii", "extractSpriteSheet", "previewTileArt"], axis: "op" },
54
- // ── encodeArt (PNG→native art; convertImageToTiles+imageToTilemap from platform-tools.js, quantizePngForPlatform+cropSpriteSheet from sprite-pipeline.js, validateGenesisTiles from metasprite-tools.js) ──
55
- encodeArt: { absorbs: ["convertImageToTiles", "imageToTilemap", "quantizePngForPlatform", "cropSpriteSheet", "validateGenesisTiles"], axis: "stage" },
56
- // ── importArt (editor-file/ROM → native tiles; load* from art-loaders.js, crossPlatformSpriteImport from sprite-pipeline.js as from:'rom') ──
57
- importArt: { absorbs: ["loadAsepriteSheet", "loadGifAnimation", "loadSpriteSheet", "loadTilemap", "crossPlatformSpriteImport"], axis: "from" },
58
- // ── memory (read/write/search; all 8 from memory.js) ──
59
- memory: { absorbs: ["readMemory", "writeMemory", "readCartRom", "snapshotMemory", "diffMemory", "classifyRegion", "searchValue", "searchNext"], axis: "op" },
60
- // ── cpu (read/drive; getCPUState from platform-tools.js, setRegister/callSubroutine/decompressWith from watch-memory.js) ──
61
- cpu: { absorbs: ["getCPUState", "setRegister", "callSubroutine", "decompressWith"], axis: "op" },
62
- // ── breakpoint (STOP-on-first; all 4 from watch-memory.js) ──
63
- breakpoint: { absorbs: ["findWriter", "runUntilWrite", "runUntilPC", "runUntilRead"], axis: "on" },
64
- // ── watch (LOG-ALL; watchMemory/watchRange/logPCRange + Genesis VDP-DMA trace
65
- // on:'dma' from watchDma/traceVramSource — all from watch-memory.js +
66
- // trace-vram-source.js. dmaTrace was folded in as watch({on:'dma'}).) ──
67
- watch: { absorbs: ["watchMemory", "watchRange", "logPCRange", "watchDma", "traceVramSource"], axis: "on" },
68
- // ── build (compile/run; buildSource/buildProject/runSource from toolchain.js, buildSourceWithDebug from symbols.js). ENTRY-TIER. ──
69
- build: { absorbs: ["buildSource", "buildSourceWithDebug", "buildProject", "runSource"], axis: "output" },
70
- // ── romPatch (9-op ROM-hack toolkit; patchFile/patchRom from rom-id.js, spliceCHR from splice-chr.js, relocateBlock/makeStoredBlock/findPointerTo from reinject.js, findFreeSpace from free-space.js, diffRoms from diff-roms.js, patchGbHeader as op:'gbHeader') ──
71
- romPatch: { absorbs: ["patchFile", "patchRom", "spliceCHR", "relocateBlock", "makeStoredBlock", "findFreeSpace", "findPointerTo", "diffRoms", "patchGbHeader"], axis: "op" },
72
- // ── catalog (orient; listCategories + getStatus, both entry-tier in index.js) ──
73
- catalog: { absorbs: ["listCategories", "getStatus"], axis: "op" },
74
- // ── playtest (show-a-human window FSM; all 4 from playtest.js). ENTRY-TIER. ──
75
- playtest: { absorbs: ["playtestStop", "playtestStatus", "playtestFramebuffer"], axis: "op" },
76
- // ── encodeAudio (external clip → native sample format; pcmToBrr + wavToXgm2Pcm from audio.js) ──
77
- encodeAudio: { absorbs: ["pcmToBrr", "wavToXgm2Pcm"], axis: "target" },
78
- };
79
-
80
- /** Every OLD tool name that the consolidation removes (absorbed into a new tool). */
81
- export function absorbedToolNames() {
82
- const names = [];
83
- for (const entry of Object.values(MERGE_MAP)) {
84
- if (entry.absorbs) names.push(...entry.absorbs);
85
- }
86
- return names;
87
- }
88
-
89
- /** The consolidated (new) tool names this manifest defines. */
90
- export function consolidatedToolNames() {
91
- return Object.keys(MERGE_MAP);
92
- }