romdevtools 0.25.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/CHANGELOG.md +30 -0
- package/package.json +2 -2
- package/src/cores/wasm/fceumm_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +22 -9
- package/src/host/cpu-state.js +12 -1
- package/src/http/tool-registry.js +16 -1
- package/src/mcp/tools/record.js +37 -15
- 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/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ 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
|
+
|
|
7
37
|
## 0.25.0
|
|
8
38
|
|
|
9
39
|
### Added — C64 input scripting + verification (RE startup-flow telemetry)
|
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
|
@@ -1184,11 +1184,14 @@ export class LibretroHost {
|
|
|
1184
1184
|
}
|
|
1185
1185
|
|
|
1186
1186
|
// ── PC breakpoint + read watchpoint + single-step (core-side, exact) ────────
|
|
1187
|
-
// Symmetric to the write watchpoint.
|
|
1188
|
-
//
|
|
1189
|
-
//
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
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.
|
|
1192
1195
|
|
|
1193
1196
|
/** True when this core build exposes the PC breakpoint + single-step. */
|
|
1194
1197
|
pcBreakSupported() {
|
|
@@ -1219,13 +1222,22 @@ export class LibretroHost {
|
|
|
1219
1222
|
if (typeof mod._romdev_pcbreak_get !== "function") {
|
|
1220
1223
|
throw new Error("this core build does not expose the PC breakpoint.");
|
|
1221
1224
|
}
|
|
1222
|
-
const ptr = mod._malloc(
|
|
1225
|
+
const ptr = mod._malloc(44); // up to 11 × uint32 (newer fceumm writes 11: +A/X/Y/P/S snapshot)
|
|
1223
1226
|
try {
|
|
1224
|
-
// Pre-seed
|
|
1225
|
-
|
|
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;
|
|
1226
1232
|
mod._romdev_pcbreak_get(ptr, clearHit ? 1 : 0);
|
|
1227
|
-
const u = new Uint32Array(mod.HEAPU8.buffer, ptr,
|
|
1233
|
+
const u = new Uint32Array(mod.HEAPU8.buffer, ptr, 11);
|
|
1228
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 };
|
|
1229
1241
|
return {
|
|
1230
1242
|
enabled: !!u[0],
|
|
1231
1243
|
address: u[1],
|
|
@@ -1233,6 +1245,7 @@ export class LibretroHost {
|
|
|
1233
1245
|
lastPC: lastPC === 0xFFFFFFFF ? null : lastPC,
|
|
1234
1246
|
hits: u[4],
|
|
1235
1247
|
watchdog: !!u[5], // the run was force-stopped by the instruction watchdog
|
|
1248
|
+
registersAtHit: snap,
|
|
1236
1249
|
};
|
|
1237
1250
|
} finally {
|
|
1238
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/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."),
|
|
@@ -62,18 +62,22 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
62
62
|
.optional()
|
|
63
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."),
|
|
64
64
|
includeScreenshots: z.boolean().default(true).describe("If false, skip PNG capture (just memory samples)."),
|
|
65
|
-
outputDir: z.string().optional().describe("
|
|
66
|
-
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."),
|
|
67
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."),
|
|
68
68
|
},
|
|
69
|
-
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 }) => {
|
|
70
70
|
const host = getHost(sessionKey);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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) {
|
|
75
77
|
await mkdir(outputDir, { recursive: true });
|
|
76
78
|
}
|
|
79
|
+
// Frames pushed to the /livestream observer this run (every captured sample).
|
|
80
|
+
const observerFrames = [];
|
|
77
81
|
// Sort input script by frame.
|
|
78
82
|
const script = (inputScript ?? []).slice().sort((a, b) => a.atFrame - b.atFrame);
|
|
79
83
|
let scriptIdx = 0;
|
|
@@ -120,13 +124,22 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
120
124
|
try {
|
|
121
125
|
const shot = host.screenshot();
|
|
122
126
|
sample.framebuffer = { width: shot.width, height: shot.height };
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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) {
|
|
126
136
|
const framePath = path.join(outputDir, `frame-${sample.frame}.png`);
|
|
127
137
|
await writeFile(framePath, Buffer.from(shot.pngBase64, "base64"));
|
|
128
138
|
sample.screenshotPath = framePath;
|
|
129
139
|
}
|
|
140
|
+
if (inline) {
|
|
141
|
+
sample.screenshotBase64 = shot.pngBase64;
|
|
142
|
+
}
|
|
130
143
|
} catch (e) {
|
|
131
144
|
sample.screenshotError = String(e?.message ?? e);
|
|
132
145
|
}
|
|
@@ -156,10 +169,19 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
156
169
|
timeline.push(sample);
|
|
157
170
|
}
|
|
158
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
|
+
|
|
159
181
|
if (streamMemory) {
|
|
160
182
|
await mkdir(path.dirname(memoryOutputPath), { recursive: true });
|
|
161
183
|
await writeFile(memoryOutputPath, memRows.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
162
|
-
return jsonContent({
|
|
184
|
+
return withObserver(jsonContent({
|
|
163
185
|
framesRun: elapsed,
|
|
164
186
|
samples: timeline.length,
|
|
165
187
|
// Timeline retains screenshot paths + framebuffer dims but NOT the
|
|
@@ -173,14 +195,14 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
173
195
|
valueRanges,
|
|
174
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.",
|
|
175
197
|
},
|
|
176
|
-
});
|
|
198
|
+
}));
|
|
177
199
|
}
|
|
178
200
|
|
|
179
|
-
return jsonContent({
|
|
201
|
+
return withObserver(jsonContent({
|
|
180
202
|
framesRun: elapsed,
|
|
181
203
|
samples: timeline.length,
|
|
182
204
|
timeline,
|
|
183
|
-
});
|
|
205
|
+
}));
|
|
184
206
|
}),
|
|
185
207
|
);
|
|
186
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
|
});
|