romdevtools 0.24.0 → 0.26.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 +6 -4
- package/CHANGELOG.md +103 -0
- 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/fceumm_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +102 -9
- package/src/host/cpu-state.js +12 -1
- package/src/http/tool-registry.js +16 -1
- package/src/mcp/tools/index.js +4 -46
- package/src/mcp/tools/input.js +5 -0
- package/src/mcp/tools/project.js +39 -6
- package/src/mcp/tools/record.js +46 -18
- package/src/mcp/tools/watch-memory.js +22 -9
- package/src/observer/livestream.html +5 -2
- package/src/observer/tool-wrap.js +13 -1
- package/src/platforms/c64/MENTAL_MODEL.md +20 -0
- package/src/mcp/tool-manifest.js +0 -92
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 +
|
|
152
|
-
reference + README + .gitignore).
|
|
153
|
-
|
|
154
|
-
|
|
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,109 @@ 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.26.0
|
|
8
|
+
|
|
9
|
+
### Fixed — NES `breakpoint(on:'pc')` now returns reliable break-instant registers
|
|
10
|
+
An agent RE'ing NES Rygar found that after a `pc` breakpoint hit, a follow-up
|
|
11
|
+
`cpu({op:'read'})` returned the **idle-loop PC**, not the breakpoint instruction —
|
|
12
|
+
the documented "break, then read the live register file" workflow gave end-of-frame
|
|
13
|
+
state. Root cause: fceumm drains the cycle budget on hit but `retro_run` still
|
|
14
|
+
finishes the frame, so the live X6502 registers are clobbered before the host reads
|
|
15
|
+
them (the schema's "CPU is FROZEN at this instruction" was wrong for NES).
|
|
16
|
+
- **fceumm core rebuild** (romdev-core-fceumm 0.8.0): the PC-break handler now
|
|
17
|
+
SNAPSHOTS A/X/Y/P/S at the hit instant, exposed via `romdev_pcbreak_get`.
|
|
18
|
+
- **`breakpoint(on:'pc')` returns `registersAtHit`** — the reliable break-instant
|
|
19
|
+
register file. The schema + hit note now steer to it and explicitly warn that a
|
|
20
|
+
live `cpu({op:'read'})` after a hit is end-of-frame state on fceumm. (Item 1+2
|
|
21
|
+
of the report, collapsed: the snapshot is taken in the same call that detects the
|
|
22
|
+
hit, so there's no freeze-durability race and no extra round trip.)
|
|
23
|
+
- **NES `cpu({op:'read'})` core-internal fields relabeled** (item 3): `DB`,
|
|
24
|
+
`IRQlow`, `tcount`, `count` are fceumm internals (data-bus latch / IRQ bitmask /
|
|
25
|
+
cycle counters), not 6502 registers — moved out of `registers` into a labeled
|
|
26
|
+
`coreInternal` object so they're not misread as CPU state.
|
|
27
|
+
|
|
28
|
+
### Added — `/livestream` shows the SYSTEM (platform) on every tool call + frame
|
|
29
|
+
A human watching `/livestream` on a multi-agent server saw the session id + tool
|
|
30
|
+
name, but not WHICH console each call/frame belonged to. Every observer event now
|
|
31
|
+
carries `platform` (the session host's loaded system — nes, genesis, …), surfaced
|
|
32
|
+
as a badge on the log row and the frame card. Wired on BOTH transports (the MCP
|
|
33
|
+
observer middleware and the REST tool registry), resolved AFTER the handler runs so
|
|
34
|
+
a `loadMedia` / `build({output:'run'})` that sets the platform mid-call labels its
|
|
35
|
+
own frame correctly. Null until a ROM is loaded.
|
|
36
|
+
|
|
37
|
+
## 0.25.0
|
|
38
|
+
|
|
39
|
+
### Added — C64 input scripting + verification (RE startup-flow telemetry)
|
|
40
|
+
Follow-up to the 0.24.0 C64 keyboard work: an agent RE'ing C64 Uridium could now
|
|
41
|
+
press keys, but couldn't (a) script a keyboard+joystick startup TIMELINE in one
|
|
42
|
+
call, or (b) tell whether a non-responsive key reached VICE at all. Both added — no
|
|
43
|
+
core rebuild (the `c64_cia1_regs` region + key matrix already existed):
|
|
44
|
+
- **`recordSession` `inputScript[].keys`** — hold C64 keyboard keys from a frame
|
|
45
|
+
until the next entry, interleaved with joystick `ports`, in one deterministic
|
|
46
|
+
timeline (e.g. `{atFrame:0,keys:['f1']},{atFrame:30,ports:[{b:true}]},
|
|
47
|
+
{atFrame:60,keys:['run/stop']},{atFrame:90,keys:[]}`). `ports` is now optional
|
|
48
|
+
(a step may set just keys). Unknown keys are **rejected with a clear error**, not
|
|
49
|
+
silently ignored.
|
|
50
|
+
- **`input({op:'pressKey', verify:true})`** — also samples CIA1 **`$DC00`/`$DC01`**
|
|
51
|
+
(the keyboard/joystick scan ports the KERNAL reads) **before / during (key held)
|
|
52
|
+
/ after**, plus matrix coords + active joyport. Lets you distinguish "my key
|
|
53
|
+
never reached VICE" (`before==during`) from "VICE saw it but the game ignored it"
|
|
54
|
+
(they differ, no reaction) when a C64 game doesn't respond.
|
|
55
|
+
|
|
56
|
+
### Changed — `scaffold` no longer echoes the vendored toolchain manifest
|
|
57
|
+
`scaffold({op:'project'|'game'})` used to return a flat `files[]` of EVERY written
|
|
58
|
+
file — including the toolchain copies (35 of 44 entries on NES, **173 of 264 on
|
|
59
|
+
GBA**, ~270 on SGDK Genesis) that an agent never touches. Across a matrix run (one
|
|
60
|
+
game × every genre × every platform) that was ~100 KB of pure vendored-path lists
|
|
61
|
+
in context with zero decision value. Now the response is a compact receipt:
|
|
62
|
+
- `files` — only the project-**OWNED** files you edit (main source, runtime, crt0,
|
|
63
|
+
cfg, README).
|
|
64
|
+
- `fileCount` (total written) + `vendorFileCount` (the summarized vendored copies,
|
|
65
|
+
on disk if you ever need them).
|
|
66
|
+
- `verbose:true` restores the full flat list as `allFiles`.
|
|
67
|
+
|
|
68
|
+
"Owned" is classified by what a file **is**, not just a `vendor/` prefix — so it
|
|
69
|
+
correctly excludes the SDK header trees the GBA (libtonc `include/`+`sysinclude/`)
|
|
70
|
+
and Genesis (SGDK `include/`) toolchains drop OUTSIDE `vendor/`, plus prebuilt
|
|
71
|
+
`crt*.o` / `*.a` / `*.lib`. (The initial fix used a `vendor/`-prefix denylist and
|
|
72
|
+
missed exactly those two SDK platforms — caught + fixed via a 0.24.0 matrix-run
|
|
73
|
+
report. GBA dropped 173→9 owned, Genesis 82→13.) Mirrors the `inline`/`outputPath`
|
|
74
|
+
choose-your-payload pattern the snippets op already had.
|
|
75
|
+
|
|
76
|
+
### Changed — scaffold README + `nextStep` lead with `build({output:'project'})`
|
|
77
|
+
The generated project README and the scaffold's `nextStep` now lead with the
|
|
78
|
+
one-call **`build({output:'project', platform, path, outputPath})`** form (infers
|
|
79
|
+
toolchain/crt0/linker from the directory — no `sourcesPaths`/`includePaths`/
|
|
80
|
+
`linkerConfig` to hand-specify), and demote the verbose `output:'run'` + manifest
|
|
81
|
+
form to a collapsed "compiling edited loose source" alternative. The project-dir
|
|
82
|
+
build was already the easier path; now it's the one a fresh agent copies first.
|
|
83
|
+
|
|
84
|
+
### Fixed — SMS shmup + Atari 7800 sports scaffolds rendered with wrong colors
|
|
85
|
+
Both built and booted but looked broken (a 0.24.0 matrix report flagged them):
|
|
86
|
+
- **SMS shmup** rendered the starfield as blue/**GREEN** striped bands. The BG
|
|
87
|
+
palette had colour 1 = `0x08`, which in SMS 2-2-2 BGR is green (G bits), not the
|
|
88
|
+
"deep space blue" the comment claimed. Fixed to a pure-blue depth gradient
|
|
89
|
+
(`0x10/0x20/0x30`) — the bands now read as space, dominant colour went green
|
|
90
|
+
`#00aa00` → blue `#0000ad`.
|
|
91
|
+
- **Atari 7800 sports** rendered a near-black playfield that looked dead. Two MARIA
|
|
92
|
+
colour-byte bugs: the court walls used `0x48` (hue 4 = RED → **pink**, not the
|
|
93
|
+
intended blue) and the court floor was `0x00` (black, indistinguishable from a
|
|
94
|
+
blank screen). Fixed to blue walls (`0x8A`, hue 8) + a dark-green court floor
|
|
95
|
+
(`0xB4`) — now reads as an actual court (dominant black → green `#008221`).
|
|
96
|
+
|
|
97
|
+
Both verified by screenshot + `frame({op:'verify'})`. (These were colour-value
|
|
98
|
+
bugs in the scaffold templates, not the render pipeline.)
|
|
99
|
+
|
|
100
|
+
### Removed — `catalog({op:'whatsNew'})` + the old→new tool rename table
|
|
101
|
+
`whatsNew` returned a 125-entry map of pre-1.0 renamed tool names (plus, until now,
|
|
102
|
+
~1.4k tokens of inlined CHANGELOG prose) so an agent resuming an old handoff could
|
|
103
|
+
re-map a tool that had moved. The pre-1.0 consolidation is long settled — the old
|
|
104
|
+
names are git history, and no running agent carries them — so maintaining a
|
|
105
|
+
forever-growing rename record (and risking it landing in context) wasn't worth it.
|
|
106
|
+
Dropped the op, the `tool-manifest.js` map, and its tests. An agent that hits an
|
|
107
|
+
unknown tool name now just reads the current surface (`catalog({op:'categories'})`
|
|
108
|
+
or the tool list); full release notes remain in CHANGELOG.md for humans.
|
|
109
|
+
|
|
7
110
|
## 0.24.0
|
|
8
111
|
|
|
9
112
|
### 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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.26.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",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"omggif": "^1.0.10",
|
|
52
52
|
"pngjs": "^7.0.0",
|
|
53
53
|
"romdev-core-bluemsx": "0.4.0",
|
|
54
|
-
"romdev-core-fceumm": "0.
|
|
54
|
+
"romdev-core-fceumm": "0.8.0",
|
|
55
55
|
"romdev-core-gambatte": "0.7.0",
|
|
56
56
|
"romdev-core-geargrafx": "0.5.0",
|
|
57
57
|
"romdev-core-gpgx": "0.10.0",
|
|
Binary file
|
package/src/host/LibretroHost.js
CHANGED
|
@@ -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
|
|
@@ -1104,11 +1184,14 @@ export class LibretroHost {
|
|
|
1104
1184
|
}
|
|
1105
1185
|
|
|
1106
1186
|
// ── PC breakpoint + read watchpoint + single-step (core-side, exact) ────────
|
|
1107
|
-
// Symmetric to the write watchpoint.
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1187
|
+
// Symmetric to the write watchpoint. On PC hit the core's execute loop drains
|
|
1188
|
+
// the cycle budget and bails, but retro_run still finishes the frame — so the
|
|
1189
|
+
// LIVE register file is end-of-frame state by the time the host reads it. Cores
|
|
1190
|
+
// that snapshot the registers AT the hit (NES/fceumm: getPCBreak().registersAtHit)
|
|
1191
|
+
// give the reliable break-instant regs; others expose only lastPC + the RAM side
|
|
1192
|
+
// effects. The read watchpoint records the PC that READ an address. All require a
|
|
1193
|
+
// core patched with the romdev_pcbreak_*/romdev_readwatch_* exports. Capability
|
|
1194
|
+
// (and reg-snapshot availability) is feature-detected per core.
|
|
1112
1195
|
|
|
1113
1196
|
/** True when this core build exposes the PC breakpoint + single-step. */
|
|
1114
1197
|
pcBreakSupported() {
|
|
@@ -1139,13 +1222,22 @@ export class LibretroHost {
|
|
|
1139
1222
|
if (typeof mod._romdev_pcbreak_get !== "function") {
|
|
1140
1223
|
throw new Error("this core build does not expose the PC breakpoint.");
|
|
1141
1224
|
}
|
|
1142
|
-
const ptr = mod._malloc(
|
|
1225
|
+
const ptr = mod._malloc(44); // up to 11 × uint32 (newer fceumm writes 11: +A/X/Y/P/S snapshot)
|
|
1143
1226
|
try {
|
|
1144
|
-
// Pre-seed
|
|
1145
|
-
|
|
1227
|
+
// Pre-seed all 11 slots. The reg-snapshot slots (6-10) default to
|
|
1228
|
+
// 0xFFFFFFFF = "no snapshot" so a core that only writes 6 (Genesis et al.)
|
|
1229
|
+
// leaves them as "unavailable" rather than 0 (a valid register value).
|
|
1230
|
+
const seed = new Uint32Array(mod.HEAPU8.buffer, ptr, 11);
|
|
1231
|
+
seed.fill(0); seed[6] = seed[7] = seed[8] = seed[9] = seed[10] = 0xFFFFFFFF;
|
|
1146
1232
|
mod._romdev_pcbreak_get(ptr, clearHit ? 1 : 0);
|
|
1147
|
-
const u = new Uint32Array(mod.HEAPU8.buffer, ptr,
|
|
1233
|
+
const u = new Uint32Array(mod.HEAPU8.buffer, ptr, 11);
|
|
1148
1234
|
const lastPC = u[3];
|
|
1235
|
+
// Register snapshot at the hit instant (fceumm). 0xFFFFFFFF = not captured
|
|
1236
|
+
// (older core, or no hit yet). When present, these are the RELIABLE
|
|
1237
|
+
// break-instant regs — the live X6502 regs are clobbered by end-of-frame.
|
|
1238
|
+
const snap = (u[6] === 0xFFFFFFFF && u[7] === 0xFFFFFFFF)
|
|
1239
|
+
? null
|
|
1240
|
+
: { A: u[6] & 0xFF, X: u[7] & 0xFF, Y: u[8] & 0xFF, P: u[9] & 0xFF, S: u[10] & 0xFF };
|
|
1149
1241
|
return {
|
|
1150
1242
|
enabled: !!u[0],
|
|
1151
1243
|
address: u[1],
|
|
@@ -1153,6 +1245,7 @@ export class LibretroHost {
|
|
|
1153
1245
|
lastPC: lastPC === 0xFFFFFFFF ? null : lastPC,
|
|
1154
1246
|
hits: u[4],
|
|
1155
1247
|
watchdog: !!u[5], // the run was force-stopped by the instruction watchdog
|
|
1248
|
+
registersAtHit: snap,
|
|
1156
1249
|
};
|
|
1157
1250
|
} finally {
|
|
1158
1251
|
mod._free(ptr);
|
package/src/host/cpu-state.js
CHANGED
|
@@ -157,7 +157,18 @@ function decode6502(bytes) {
|
|
|
157
157
|
// 6502 P flags: N V - B D I Z C (bit 5 unused / always reads 1)
|
|
158
158
|
return formatCpuState({
|
|
159
159
|
pc: PC,
|
|
160
|
-
|
|
160
|
+
// Only the architectural 6502 registers go in `registers`. fceumm also
|
|
161
|
+
// exposes core-internal latches/counters (data-bus latch, pending-IRQ
|
|
162
|
+
// bitmask, cycle counters) — those are NOT 6502 state and were easy to
|
|
163
|
+
// misread, so they live under `coreInternal`, clearly labeled.
|
|
164
|
+
registers: { A, X, Y, S, P },
|
|
165
|
+
coreInternal: {
|
|
166
|
+
DB, // data-bus latch (last value on the bus) — emulator-internal
|
|
167
|
+
IRQlow, // pending-IRQ source bitmask — emulator-internal
|
|
168
|
+
tcount, // temporary cycle counter — emulator-internal
|
|
169
|
+
count, // cycles remaining in the current slice — emulator-internal
|
|
170
|
+
note: "fceumm core-internal values, NOT architectural 6502 registers — don't read these as CPU state.",
|
|
171
|
+
},
|
|
161
172
|
flags: {
|
|
162
173
|
N: !!(P & 0x80),
|
|
163
174
|
V: !!(P & 0x40),
|
|
@@ -18,6 +18,7 @@ import { z } from "zod";
|
|
|
18
18
|
import { registerTools } from "../mcp/tools/index.js";
|
|
19
19
|
import { withClearToolErrors } from "../mcp/util.js";
|
|
20
20
|
import { observer, summarizeForLog, extractImages } from "../observer/bus.js";
|
|
21
|
+
import { getHostOrNull } from "../mcp/state.js";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Build a tool registry for a given session key. Each entry's handler closes
|
|
@@ -94,11 +95,17 @@ export async function runTool(tool, args, sessionKey) {
|
|
|
94
95
|
// /livestream view updates for HTTP/skill tool calls too (the MCP path wraps
|
|
95
96
|
// server.tool with installObserverMiddleware; the HTTP path runs handlers
|
|
96
97
|
// directly, so we emit here — the single HTTP execution chokepoint).
|
|
98
|
+
// Which console this session's host currently has loaded — shown on every
|
|
99
|
+
// livestream event so a human watching a multi-agent server sees the SYSTEM
|
|
100
|
+
// (nes, genesis, …) alongside the tool, not just the session id. Best-effort.
|
|
101
|
+
let platform = null;
|
|
102
|
+
try { platform = getHostOrNull(sessionKey)?.status?.platform ?? null; } catch { /* none yet */ }
|
|
97
103
|
const emit = (extra) => {
|
|
98
104
|
try {
|
|
99
105
|
observer.push({
|
|
100
106
|
type: "call",
|
|
101
107
|
sessionKey: sessionKey ?? "http",
|
|
108
|
+
platform,
|
|
102
109
|
ts: startedAt,
|
|
103
110
|
tool: tool.name,
|
|
104
111
|
args: summarizeForLog(a),
|
|
@@ -122,6 +129,10 @@ export async function runTool(tool, args, sessionKey) {
|
|
|
122
129
|
}
|
|
123
130
|
try {
|
|
124
131
|
const r = await tool.handler(a, {});
|
|
132
|
+
// Re-resolve the platform AFTER the handler: a call like loadMedia /
|
|
133
|
+
// build({output:'run'}) sets it during the call, so the post-call value
|
|
134
|
+
// correctly labels this call's event + frame (the pre-call value was null).
|
|
135
|
+
try { platform = getHostOrNull(sessionKey)?.status?.platform ?? platform; } catch { /* keep */ }
|
|
125
136
|
// Unwrap the MCP content envelope to plain JSON for HTTP clients.
|
|
126
137
|
if (r && r.isError) {
|
|
127
138
|
const text = r.content?.[0]?.text ?? "tool error";
|
|
@@ -148,7 +159,11 @@ export async function runTool(tool, args, sessionKey) {
|
|
|
148
159
|
setImmediate(() => {
|
|
149
160
|
try {
|
|
150
161
|
const img = frameProvider();
|
|
151
|
-
|
|
162
|
+
// re-resolve platform: a call like loadMedia sets it DURING the call, so
|
|
163
|
+
// the post-call value is the most accurate for the frame's system label.
|
|
164
|
+
let framePlatform = platform;
|
|
165
|
+
try { framePlatform = getHostOrNull(sessionKey)?.status?.platform ?? platform; } catch { /* keep */ }
|
|
166
|
+
if (img) observer.push({ type: "call_frame", sessionKey: sessionKey ?? "http", platform: framePlatform, ts: startedAt, tool: tool.name, images: [img] });
|
|
152
167
|
} catch { /* livestream is best-effort; never affects the caller */ }
|
|
153
168
|
});
|
|
154
169
|
}
|
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();
|
package/src/mcp/tools/input.js
CHANGED
|
@@ -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
|
}
|
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
|
@@ -34,7 +34,7 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
34
34
|
server.tool(
|
|
35
35
|
"recordSession",
|
|
36
36
|
"Run the loaded ROM for N frames, sampling screenshots and/or memory every sampleEvery frames. Returns a timeline the agent can analyze. Inputs are either held for the whole session (holdInputs) or scripted as {atFrame, ports} entries each held until the next (inputScript). " +
|
|
37
|
-
"Screenshots (includeScreenshots, default true): pass outputDir to write frame-<n>.png per sample (timeline gets screenshotPath),
|
|
37
|
+
"Screenshots (includeScreenshots, default true): every sampled frame ALWAYS streams to the human's /livestream (over REST or MCP, no flag needed). For the AGENT's response: pass outputDir to also write frame-<n>.png per sample (timeline gets screenshotPath), or inline:true to embed screenshotBase64 per entry (opt-in — NOT default, so image bytes don't flood your context). With neither, frames still go to /livestream and the response stays compact (just the timeline). Set includeScreenshots:false to skip capture entirely (memory-only runs). " +
|
|
38
38
|
"Memory (memorySamples): accepts the full readMemory region set incl. hardware registers (nes_apu_regs, etc.); hex appears per-sample in the timeline. For dense sampling (sampleEvery:1 over a long loop, e.g. APU regs across a music loop) add memoryOutputPath to stream rows to NDJSON on disk and keep the hex OUT of context — the response returns a compact summary {path, rows, regions, valueRanges} instead.",
|
|
39
39
|
{
|
|
40
40
|
frames: z.number().int().min(1).max(36000).default(300).describe("Total frames to run."),
|
|
@@ -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({
|
|
@@ -61,18 +62,22 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
61
62
|
.optional()
|
|
62
63
|
.describe("Memory regions to sample at each capture point. Accepts the full readMemory region set (incl. nes_apu_regs and other hardware registers). Tip: sampleEvery:1 + memoryOutputPath gives a per-frame telemetry stream (e.g. APU registers over a music loop) without flooding context with hex."),
|
|
63
64
|
includeScreenshots: z.boolean().default(true).describe("If false, skip PNG capture (just memory samples)."),
|
|
64
|
-
outputDir: z.string().optional().describe("
|
|
65
|
-
inline: z.boolean().default(false).describe("If true, embed screenshotBase64 in each timeline entry
|
|
65
|
+
outputDir: z.string().optional().describe("OPTIONAL. If set, also write per-sample PNGs (frame-<n>.png) to this dir; the timeline gets each one's `screenshotPath`. Captured frames stream to /livestream regardless; outputDir just additionally persists them to disk for the agent."),
|
|
66
|
+
inline: z.boolean().default(false).describe("OPT-IN base64. If true, embed screenshotBase64 in each timeline entry. Default false — frames go to /livestream + (if outputDir) disk, but image BYTES are NOT put in your response context unless you ask. Only set this if you genuinely need the base64 inline."),
|
|
66
67
|
memoryOutputPath: z.string().optional().describe("If set, write per-sample memory to this path as newline-delimited JSON (one row per sample) and OMIT the bulky per-sample `memory` from the timeline — returns a compact summary {path, rows, regions, valueRanges} instead. Use for dense sampling (sampleEvery:1 over a long loop) so ~200KB of hex never enters context."),
|
|
67
68
|
},
|
|
68
|
-
safeTool(async ({ frames, sampleEvery, holdInputs, inputScript, memorySamples, includeScreenshots, outputDir, inline, memoryOutputPath }) => {
|
|
69
|
+
safeTool(async ({ frames, sampleEvery, holdInputs, inputScript, memorySamples, includeScreenshots = true, outputDir, inline = false, memoryOutputPath }) => {
|
|
69
70
|
const host = getHost(sessionKey);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
// No outputDir/inline requirement anymore: captured frames ALWAYS stream to
|
|
72
|
+
// the human's /livestream (observer sideband). outputDir additionally writes
|
|
73
|
+
// PNGs to disk; inline additionally embeds base64 in the RESPONSE (opt-in, so
|
|
74
|
+
// we never flood agent context by default). Bare call = frames go to the
|
|
75
|
+
// human, nothing bulky comes back to the agent.
|
|
76
|
+
if (includeScreenshots && outputDir) {
|
|
74
77
|
await mkdir(outputDir, { recursive: true });
|
|
75
78
|
}
|
|
79
|
+
// Frames pushed to the /livestream observer this run (every captured sample).
|
|
80
|
+
const observerFrames = [];
|
|
76
81
|
// Sort input script by frame.
|
|
77
82
|
const script = (inputScript ?? []).slice().sort((a, b) => a.atFrame - b.atFrame);
|
|
78
83
|
let scriptIdx = 0;
|
|
@@ -99,7 +104,12 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
99
104
|
while (elapsed < frames) {
|
|
100
105
|
// Apply any scripted inputs whose atFrame ≤ current frame.
|
|
101
106
|
while (scriptIdx < script.length && script[scriptIdx].atFrame <= elapsed) {
|
|
102
|
-
|
|
107
|
+
const entry = script[scriptIdx];
|
|
108
|
+
if (entry.ports) host.setInput({ ports: entry.ports });
|
|
109
|
+
// C64 keyboard keys held from this entry until the next. Pass [] to
|
|
110
|
+
// release all. Only valid on a C64/VICE host (setC64HeldKeys throws
|
|
111
|
+
// otherwise — surfaced as a clear error, not a silent no-op).
|
|
112
|
+
if (entry.keys !== undefined) host.setC64HeldKeys(entry.keys);
|
|
103
113
|
scriptIdx++;
|
|
104
114
|
}
|
|
105
115
|
const batch = Math.min(sampleEvery, frames - elapsed);
|
|
@@ -114,13 +124,22 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
114
124
|
try {
|
|
115
125
|
const shot = host.screenshot();
|
|
116
126
|
sample.framebuffer = { width: shot.width, height: shot.height };
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
127
|
+
// ALWAYS push the frame to the human's /livestream (observer sideband),
|
|
128
|
+
// independent of how the AGENT wants it returned and independent of the
|
|
129
|
+
// transport (REST or MCP — both consume _observerImages). The human
|
|
130
|
+
// watching does not depend on the agent passing inline/outputDir.
|
|
131
|
+
observerFrames.push({ kind: "image", mimeType: "image/png", base64: shot.pngBase64 });
|
|
132
|
+
// The agent's RESPONSE: a path if outputDir was given, else nothing.
|
|
133
|
+
// Base64 goes into the response ONLY when explicitly inline:true — we do
|
|
134
|
+
// NOT dump image bytes into context by default.
|
|
135
|
+
if (outputDir) {
|
|
120
136
|
const framePath = path.join(outputDir, `frame-${sample.frame}.png`);
|
|
121
137
|
await writeFile(framePath, Buffer.from(shot.pngBase64, "base64"));
|
|
122
138
|
sample.screenshotPath = framePath;
|
|
123
139
|
}
|
|
140
|
+
if (inline) {
|
|
141
|
+
sample.screenshotBase64 = shot.pngBase64;
|
|
142
|
+
}
|
|
124
143
|
} catch (e) {
|
|
125
144
|
sample.screenshotError = String(e?.message ?? e);
|
|
126
145
|
}
|
|
@@ -150,10 +169,19 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
150
169
|
timeline.push(sample);
|
|
151
170
|
}
|
|
152
171
|
|
|
172
|
+
// Attach the captured frames as a /livestream observer sideband (top-level
|
|
173
|
+
// sibling of `content`, NOT inside the JSON text — same pattern as frame.js,
|
|
174
|
+
// so the observer middleware forwards them and they never bloat the response
|
|
175
|
+
// text). The wrapper (MCP + REST both) strips _observerImages before reply.
|
|
176
|
+
const withObserver = (result) => {
|
|
177
|
+
if (observerFrames.length) Object.assign(result, { _observerImages: observerFrames });
|
|
178
|
+
return result;
|
|
179
|
+
};
|
|
180
|
+
|
|
153
181
|
if (streamMemory) {
|
|
154
182
|
await mkdir(path.dirname(memoryOutputPath), { recursive: true });
|
|
155
183
|
await writeFile(memoryOutputPath, memRows.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
156
|
-
return jsonContent({
|
|
184
|
+
return withObserver(jsonContent({
|
|
157
185
|
framesRun: elapsed,
|
|
158
186
|
samples: timeline.length,
|
|
159
187
|
// Timeline retains screenshot paths + framebuffer dims but NOT the
|
|
@@ -167,14 +195,14 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
167
195
|
valueRanges,
|
|
168
196
|
note: "Per-sample memory written to disk (one JSON object per row: {frame, elapsed, <label>:hex,...}). valueRanges shows each label's first-byte min/max so you can tell at a glance which watched bytes actually changed.",
|
|
169
197
|
},
|
|
170
|
-
});
|
|
198
|
+
}));
|
|
171
199
|
}
|
|
172
200
|
|
|
173
|
-
return jsonContent({
|
|
201
|
+
return withObserver(jsonContent({
|
|
174
202
|
framesRun: elapsed,
|
|
175
203
|
samples: timeline.length,
|
|
176
204
|
timeline,
|
|
177
|
-
});
|
|
205
|
+
}));
|
|
178
206
|
}),
|
|
179
207
|
);
|
|
180
208
|
}
|
|
@@ -666,19 +666,30 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
666
666
|
"to reach the right game state), or the address isn't an instruction boundary (a mid-instruction address never matches REG_PC).",
|
|
667
667
|
}), host);
|
|
668
668
|
}
|
|
669
|
+
// Snapshot the registers AT the hit BEFORE clearing (last already holds the
|
|
670
|
+
// hit state; read it without clearing so registersAtHit survives).
|
|
671
|
+
const atHit = last.registersAtHit ?? host.getPCBreak(false).registersAtHit ?? null;
|
|
669
672
|
const fin = host.getPCBreak(true); // clear hit
|
|
673
|
+
// registersAtHit (NES/fceumm and any core that snapshots regs on hit) is the
|
|
674
|
+
// RELIABLE break-instant register file. The LIVE register file (a follow-up
|
|
675
|
+
// cpu({op:'read'})) is NOT reliable on fceumm: the core drains the cycle
|
|
676
|
+
// budget on hit but retro_run still finishes the frame, so the live regs are
|
|
677
|
+
// end-of-frame state. Prefer registersAtHit; only fall back to a live read on
|
|
678
|
+
// cores that don't snapshot.
|
|
679
|
+
const frozenNote = atHit
|
|
680
|
+
? "registersAtHit holds the register file CAPTURED AT this instruction (A/X/Y/P/S) — use THESE, not a follow-up cpu({op:'read'}), which on NES/fceumm returns end-of-frame state, not the break instant. For a source pointer in a 16-bit reg pair, read the two ZP bytes via memory({op:'read'}). frame({op:'stepInstruction'}) to single-step from here."
|
|
681
|
+
: "This core does not snapshot registers at the hit. cpu({op:'read'}) reflects the CPU state now; on cores that run-to-frame-end (fceumm) that is NOT the break instant — prefer the RAM side effects (memory({op:'read'})) over the live register file.";
|
|
670
682
|
return attachObserverFrame(jsonContent({
|
|
671
683
|
hit: true,
|
|
672
684
|
address: "$" + address.toString(16).toUpperCase(),
|
|
673
685
|
pc: last.lastPC != null ? "$" + last.lastPC.toString(16).toUpperCase() : null,
|
|
674
686
|
pcRaw: last.lastPC,
|
|
687
|
+
...(atHit ? { registersAtHit: atHit } : {}),
|
|
675
688
|
frame: host.status.frameCount,
|
|
676
689
|
framesRun,
|
|
677
690
|
hits: fin.hits,
|
|
678
691
|
...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
|
|
679
|
-
note:
|
|
680
|
-
"moment (the value you want — e.g. an address register holding a source pointer — is live now), then memory({op:'read'/'readCart'}) " +
|
|
681
|
-
"at that pointer. frame({op:'stepInstruction'}) to single-step, or frame({op:'step'})/host({op:'resume'}) to continue.",
|
|
692
|
+
note: frozenNote,
|
|
682
693
|
}), host);
|
|
683
694
|
}
|
|
684
695
|
|
|
@@ -741,14 +752,16 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
741
752
|
"interrupted main-thread PC (an idle loop), a LIE. Use 'exact' when you need the real writer.**\n" +
|
|
742
753
|
"• on:'read' — break when the CPU READS `address` (the read-side mirror of on:'write' exact): the EXACT instruction PC that " +
|
|
743
754
|
"read the byte. Finds who CONSUMES a value. Does NOT freeze mid-frame — records the PC and finishes the frame.\n" +
|
|
744
|
-
"• on:'pc' — break when the PC reaches `address
|
|
745
|
-
"**The RE primitive for 'read the register at this instruction': break, then
|
|
746
|
-
"(e.g. break at a decoder's
|
|
747
|
-
"
|
|
748
|
-
"
|
|
755
|
+
"• on:'pc' — break when the PC reaches `address` (a real execution breakpoint). " +
|
|
756
|
+
"**The RE primitive for 'read the register at this instruction': break, then use the `registersAtHit` SNAPSHOT in the hit response** " +
|
|
757
|
+
"(e.g. break at a decoder's load and read the index reg = the source offset). IMPORTANT: `registersAtHit` is the register file captured AT the break instant — " +
|
|
758
|
+
"use it, NOT a follow-up cpu({op:'read'}). On some cores (notably NES/fceumm) the core drains the cycle budget on hit but the frame still finishes, " +
|
|
759
|
+
"so a live cpu read afterward returns END-OF-FRAME registers, not the break instant. `registersAtHit` sidesteps that. The break PC is reported as `pc`/`pcRaw`; " +
|
|
760
|
+
"the RAM side effects are also reliable via memory({op:'read'}). frame({op:'stepInstruction'}) to single-step from the break. (on:'read'/'write' finish the frame.)\n" +
|
|
761
|
+
"All supported on every CPU core; `registersAtHit` is present on cores that snapshot regs (NES today); out-of-date core packages return notSupported.",
|
|
749
762
|
{
|
|
750
763
|
on: z.enum(["write", "read", "pc"])
|
|
751
|
-
.describe("write=break on a write to address (precision:exact=true writer PC / sampled=frame PC, a lie under IRQ); read=break on a read (exact PC, who consumes it); pc=break when PC reaches address (
|
|
764
|
+
.describe("write=break on a write to address (precision:exact=true writer PC / sampled=frame PC, a lie under IRQ); read=break on a read (exact PC, who consumes it); pc=break when PC reaches address — the hit returns `registersAtHit` (the break-instant A/X/Y/P/S on NES) + the break PC; use registersAtHit, not a follow-up cpu read (which is end-of-frame state on fceumm)."),
|
|
752
765
|
precision: z.enum(["exact", "sampled"]).default("exact")
|
|
753
766
|
.describe("on:'write' ONLY. exact=core watchpoint, the real writing instruction PC even under interrupts (uses `address`). sampled=cheap frame-boundary PC (uses region/offset/length) — NOT the writer under IRQ. Ignored for on:read/pc (always exact)."),
|
|
754
767
|
address: z.number().int().min(0).optional().describe("on:'write' exact / on:'read' / on:'pc' — CPU address to break on (write target, read target, or instruction boundary). Required for those."),
|
|
@@ -148,6 +148,7 @@
|
|
|
148
148
|
font-size: 11px; color: #888;
|
|
149
149
|
}
|
|
150
150
|
.log-row .ts { color: #666; }
|
|
151
|
+
.log-row .plat { color: #0d1117; background: #80cbc4; font-weight: 600; border-radius: 3px; padding: 0 5px; text-transform: uppercase; font-size: 10px; letter-spacing: .3px; }
|
|
151
152
|
.log-row .tool { color: #ffeb3b; font-weight: 600; }
|
|
152
153
|
.log-row .dur { color: #999; margin-left: auto; }
|
|
153
154
|
.log-row .summary {
|
|
@@ -304,7 +305,7 @@
|
|
|
304
305
|
// One latest image per "tool" (kind = tool name); ev.tool
|
|
305
306
|
// identifies which inspect call produced it.
|
|
306
307
|
s.latestByKind[ev.tool] = { ts: ev.ts, base64: img.base64,
|
|
307
|
-
mimeType: img.mimeType, tool: ev.tool };
|
|
308
|
+
mimeType: img.mimeType, tool: ev.tool, platform: ev.platform ?? null };
|
|
308
309
|
}
|
|
309
310
|
}
|
|
310
311
|
// screenshotAscii pushes both the PNG (above) AND the raw ANSI
|
|
@@ -414,7 +415,8 @@
|
|
|
414
415
|
const card = document.createElement("div");
|
|
415
416
|
card.className = "image-card";
|
|
416
417
|
const dt = new Date(img.ts).toLocaleTimeString();
|
|
417
|
-
|
|
418
|
+
const platBadge = img.platform ? `<span class="plat">${escapeHtml(img.platform)}</span> ` : "";
|
|
419
|
+
card.innerHTML = `<div class="meta"><span>${platBadge}${escapeHtml(img.tool)}</span><span>${dt}</span></div>`;
|
|
418
420
|
const el = document.createElement("img");
|
|
419
421
|
el.src = `data:${img.mimeType};base64,${img.base64}`;
|
|
420
422
|
el.alt = img.tool;
|
|
@@ -563,6 +565,7 @@
|
|
|
563
565
|
const head = document.createElement("div");
|
|
564
566
|
head.className = "head";
|
|
565
567
|
head.innerHTML = `<span class="ts">${dt}</span>`
|
|
568
|
+
+ (ev.platform ? `<span class="plat">${escapeHtml(ev.platform)}</span>` : "")
|
|
566
569
|
+ `<span class="tool">${escapeHtml(ev.tool)}</span>`
|
|
567
570
|
+ `<span class="dur">${ev.durationMs}ms</span>`;
|
|
568
571
|
row.appendChild(head);
|
|
@@ -6,9 +6,18 @@
|
|
|
6
6
|
// Idempotent per server instance — installs once, repeats are no-ops.
|
|
7
7
|
|
|
8
8
|
import { observer, extractImages, summarizeForLog } from "./bus.js";
|
|
9
|
+
import { getHostOrNull } from "../mcp/state.js";
|
|
9
10
|
|
|
10
11
|
const INSTALLED = Symbol.for("romdev.observer-installed");
|
|
11
12
|
|
|
13
|
+
// The platform/system the session's host currently has loaded (nes, genesis, …),
|
|
14
|
+
// or null if no ROM is loaded yet. Surfaced on every livestream event so a human
|
|
15
|
+
// watching a multi-agent server sees WHICH console each tool call / frame belongs
|
|
16
|
+
// to, not just the session id + tool name. Best-effort: never throws.
|
|
17
|
+
function sessionPlatform(sessionKey) {
|
|
18
|
+
try { return getHostOrNull(sessionKey)?.status?.platform ?? null; } catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
/**
|
|
13
22
|
* Install tool-call instrumentation on an MCP server.
|
|
14
23
|
*
|
|
@@ -42,12 +51,14 @@ export function installObserverMiddleware(server, sessionKey) {
|
|
|
42
51
|
// log isn't dominated by base64 / huge source strings, but keep
|
|
43
52
|
// top-level property names intact.
|
|
44
53
|
const argsSummary = summarizeForLog(args);
|
|
54
|
+
const platform = sessionPlatform(sessionKey); // which console this call drives
|
|
45
55
|
let event;
|
|
46
56
|
let frameProvider = null; // deferred framebuffer thunk (encoded async below)
|
|
47
57
|
if (thrown) {
|
|
48
58
|
event = {
|
|
49
59
|
type: "call",
|
|
50
60
|
sessionKey,
|
|
61
|
+
platform,
|
|
51
62
|
ts: startedAt,
|
|
52
63
|
tool: name,
|
|
53
64
|
args: argsSummary,
|
|
@@ -93,6 +104,7 @@ export function installObserverMiddleware(server, sessionKey) {
|
|
|
93
104
|
event = {
|
|
94
105
|
type: "call",
|
|
95
106
|
sessionKey,
|
|
107
|
+
platform,
|
|
96
108
|
ts: startedAt,
|
|
97
109
|
tool: name,
|
|
98
110
|
args: argsSummary,
|
|
@@ -119,7 +131,7 @@ export function installObserverMiddleware(server, sessionKey) {
|
|
|
119
131
|
try {
|
|
120
132
|
const img = frameProvider();
|
|
121
133
|
if (img) {
|
|
122
|
-
observer.push({ type: "call_frame", sessionKey, ts: startedAt, tool: name, images: [img] });
|
|
134
|
+
observer.push({ type: "call_frame", sessionKey, platform, ts: startedAt, tool: name, images: [img] });
|
|
123
135
|
}
|
|
124
136
|
} catch { /* livestream is best-effort; never affects the agent */ }
|
|
125
137
|
});
|
|
@@ -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
|
package/src/mcp/tool-manifest.js
DELETED
|
@@ -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
|
-
}
|