romdevtools 0.25.0 → 0.27.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 CHANGED
@@ -4,6 +4,46 @@ 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.27.0
8
+
9
+ ### Added — `breakpoint(on:'pc', captureMemory:[…])` reads named RAM at the hit
10
+ Completes item 2 of the NES Rygar report. 0.26.0 shipped `registersAtHit` (the
11
+ break-instant register file) but not the memory half. Now `breakpoint(on:'pc')`
12
+ takes `captureMemory:[{region,offset,length,label}]` and returns those reads inline
13
+ as `capturedMemory`, so register + RAM inspection at a PC collapses into ONE call —
14
+ no follow-up `cpu`/`memory` round trips. `registersAtHit` is the true break instant
15
+ (core snapshot); `capturedMemory` reflects the routine's RAM side effects for the
16
+ hit frame (stable + what RE needs), documented as such.
17
+
18
+ ## 0.26.0
19
+
20
+ ### Fixed — NES `breakpoint(on:'pc')` now returns reliable break-instant registers
21
+ An agent RE'ing NES Rygar found that after a `pc` breakpoint hit, a follow-up
22
+ `cpu({op:'read'})` returned the **idle-loop PC**, not the breakpoint instruction —
23
+ the documented "break, then read the live register file" workflow gave end-of-frame
24
+ state. Root cause: fceumm drains the cycle budget on hit but `retro_run` still
25
+ finishes the frame, so the live X6502 registers are clobbered before the host reads
26
+ them (the schema's "CPU is FROZEN at this instruction" was wrong for NES).
27
+ - **fceumm core rebuild** (romdev-core-fceumm 0.8.0): the PC-break handler now
28
+ SNAPSHOTS A/X/Y/P/S at the hit instant, exposed via `romdev_pcbreak_get`.
29
+ - **`breakpoint(on:'pc')` returns `registersAtHit`** — the reliable break-instant
30
+ register file. The schema + hit note now steer to it and explicitly warn that a
31
+ live `cpu({op:'read'})` after a hit is end-of-frame state on fceumm. (The
32
+ `captureMemory` companion that reads named RAM inline at the hit landed in 0.27.0.)
33
+ - **NES `cpu({op:'read'})` core-internal fields relabeled** (item 3): `DB`,
34
+ `IRQlow`, `tcount`, `count` are fceumm internals (data-bus latch / IRQ bitmask /
35
+ cycle counters), not 6502 registers — moved out of `registers` into a labeled
36
+ `coreInternal` object so they're not misread as CPU state.
37
+
38
+ ### Added — `/livestream` shows the SYSTEM (platform) on every tool call + frame
39
+ A human watching `/livestream` on a multi-agent server saw the session id + tool
40
+ name, but not WHICH console each call/frame belonged to. Every observer event now
41
+ carries `platform` (the session host's loaded system — nes, genesis, …), surfaced
42
+ as a badge on the log row and the frame card. Wired on BOTH transports (the MCP
43
+ observer middleware and the REST tool registry), resolved AFTER the handler runs so
44
+ a `loadMedia` / `build({output:'run'})` that sets the platform mid-call labels its
45
+ own frame correctly. Null until a ROM is loaded.
46
+
7
47
  ## 0.25.0
8
48
 
9
49
  ### 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.25.0",
3
+ "version": "0.27.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.7.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
@@ -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. The PC breakpoint freezes the CPU at the
1188
- // target instruction mid-frame (the core's execute loop bails on hit); the
1189
- // read watchpoint records the PC that READ an address. Both require a core
1190
- // patched with the romdev_pcbreak_*/romdev_readwatch_* exports (Genesis today;
1191
- // other cores as they're patched). Capability is feature-detected per core.
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(24); // up to 6 × uint32 (older cores write 5)
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 slot 5 (watchdog) so a 5-element older core leaves it 0.
1225
- new Uint32Array(mod.HEAPU8.buffer, ptr, 6).fill(0);
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, 6);
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);
@@ -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
- registers: { A, X, Y, S, P, DB, IRQlow, tcount, count },
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
- if (img) observer.push({ type: "call_frame", sessionKey: sessionKey ?? "http", ts: startedAt, tool: tool.name, images: [img] });
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
  }
@@ -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), OR inline:true to embed screenshotBase64 per entry — one is required. Set includeScreenshots:false for memory-only runs. " +
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("Directory to write per-sample PNGs (frame-<n>.png). Required when includeScreenshots is true unless inline:true."),
66
- inline: z.boolean().default(false).describe("If true, embed screenshotBase64 in each timeline entry instead of writing PNGs to disk. Default false then outputDir is required when includeScreenshots is true."),
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
- if (includeScreenshots && !inline && !outputDir) {
72
- throw new Error("recordSession: includeScreenshots is true — pass outputDir (writes frame-<n>.png per sample, returns screenshotPath) or inline:true (embeds screenshotBase64 in each entry). Or set includeScreenshots:false for memory-only sampling.");
73
- }
74
- if (includeScreenshots && !inline && outputDir) {
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
- if (inline) {
124
- sample.screenshotBase64 = shot.pngBase64;
125
- } else {
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
  }
@@ -633,7 +633,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
633
633
  });
634
634
  }
635
635
 
636
- async function bpRunUntilPC({ address, maxFrames = 600, pressDuring }) {
636
+ async function bpRunUntilPC({ address, maxFrames = 600, pressDuring, captureMemory }) {
637
637
  const host = getHost(sessionKey);
638
638
  if (!host.pcBreakSupported || !host.pcBreakSupported()) {
639
639
  return jsonContent({
@@ -666,19 +666,54 @@ 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;
672
+ // captureMemory: read the requested regions AT the hit (before we clear/step),
673
+ // returned inline so break→read RAM collapses into ONE call. NOTE: registers
674
+ // are the true break instant (core snapshot); these RAM reads are taken now —
675
+ // i.e. after the hit frame finished — so on run-to-frame-end cores (fceumm)
676
+ // they reflect the routine's RAM SIDE EFFECTS for that frame (which is what
677
+ // RE wants: "what did this routine touch"), not necessarily the exact byte
678
+ // mid-instruction. Stable + reliable; that's the property the report leaned on.
679
+ let capturedMemory = null;
680
+ if (Array.isArray(captureMemory) && captureMemory.length) {
681
+ capturedMemory = {};
682
+ for (const m of captureMemory) {
683
+ const label = m.label ?? `${m.region}+${m.offset}`;
684
+ try {
685
+ const bytes = host.readMemory(m.region, m.offset, m.length ?? 1);
686
+ capturedMemory[label] = {
687
+ region: m.region, offset: m.offset, length: m.length ?? 1,
688
+ hex: Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""),
689
+ };
690
+ } catch (e) {
691
+ capturedMemory[label] = { region: m.region, offset: m.offset, error: String(e?.message ?? e) };
692
+ }
693
+ }
694
+ }
669
695
  const fin = host.getPCBreak(true); // clear hit
696
+ // registersAtHit (NES/fceumm and any core that snapshots regs on hit) is the
697
+ // RELIABLE break-instant register file. The LIVE register file (a follow-up
698
+ // cpu({op:'read'})) is NOT reliable on fceumm: the core drains the cycle
699
+ // budget on hit but retro_run still finishes the frame, so the live regs are
700
+ // end-of-frame state. Prefer registersAtHit; only fall back to a live read on
701
+ // cores that don't snapshot.
702
+ const frozenNote = atHit
703
+ ? "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 RAM at the hit, pass captureMemory:[{region,offset,length}] to get it inline (capturedMemory) in THIS call instead of a follow-up read. frame({op:'stepInstruction'}) to single-step from here."
704
+ : "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
705
  return attachObserverFrame(jsonContent({
671
706
  hit: true,
672
707
  address: "$" + address.toString(16).toUpperCase(),
673
708
  pc: last.lastPC != null ? "$" + last.lastPC.toString(16).toUpperCase() : null,
674
709
  pcRaw: last.lastPC,
710
+ ...(atHit ? { registersAtHit: atHit } : {}),
711
+ ...(capturedMemory ? { capturedMemory } : {}),
675
712
  frame: host.status.frameCount,
676
713
  framesRun,
677
714
  hits: fin.hits,
678
715
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
679
- note: "CPU is FROZEN at this instruction. Call cpu({op:'read'}) to read all registers at this exact " +
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.",
716
+ note: frozenNote,
682
717
  }), host);
683
718
  }
684
719
 
@@ -741,14 +776,16 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
741
776
  "interrupted main-thread PC (an idle loop), a LIE. Use 'exact' when you need the real writer.**\n" +
742
777
  "• on:'read' — break when the CPU READS `address` (the read-side mirror of on:'write' exact): the EXACT instruction PC that " +
743
778
  "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`, freezing the CPU EXACTLY at that instruction (a real execution breakpoint). " +
745
- "**The RE primitive for 'read the register at this instruction': break, then cpu({op:'read'}) the live register file** " +
746
- "(e.g. break at a decoder's `move.b (a0),d0` and read A0 = the source address). After a hit the CPU stays FROZEN mid-frame — " +
747
- "inspect, then frame({op:'step'/'stepInstruction'}) to continue. (on:'read'/'write' finish the frame; on:'pc' freezes.)\n" +
748
- "All supported on every CPU core; out-of-date core packages return notSupported.",
779
+ "• on:'pc' — break when the PC reaches `address` (a real execution breakpoint). " +
780
+ "**The RE primitive for 'read the register at this instruction': break, then use the `registersAtHit` SNAPSHOT in the hit response** " +
781
+ "(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 — " +
782
+ "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, " +
783
+ "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`; " +
784
+ "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" +
785
+ "All supported on every CPU core; `registersAtHit` is present on cores that snapshot regs (NES today); out-of-date core packages return notSupported.",
749
786
  {
750
787
  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 (freezes mid-instruction)."),
788
+ .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
789
  precision: z.enum(["exact", "sampled"]).default("exact")
753
790
  .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
791
  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."),
@@ -767,6 +804,12 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
767
804
  offset: z.number().int().min(0).describe("byte offset within the region"),
768
805
  label: z.string().optional().describe("human name for this guard byte"),
769
806
  })).optional().describe("on:'write' exact — ABORT GUARD for a pressDuring run: caller-named 'is this scenario still valid?' bytes (e.g. the area/scene id, the player object-active flag). If ANY changes mid-run the watchpoint stops IMMEDIATELY and returns {aborted:true, abortedBy, before, after} — so a driven scenario that derailed (player died → title screen) doesn't burn all maxFrames and return a meaningless found:false. Each is sampled once per frame (cheap)."),
807
+ captureMemory: z.array(z.object({
808
+ region: z.enum(MEMORY_REGIONS).describe("memory region to read"),
809
+ offset: z.number().int().min(0).describe("byte offset within the region"),
810
+ length: z.number().int().min(1).max(256).default(1).describe("bytes to read"),
811
+ label: z.string().optional().describe("human name for this read (else 'region+offset')"),
812
+ })).optional().describe("on:'pc' — read these memory regions AT the hit and return them inline as `capturedMemory` (collapses break→read-RAM into ONE call, the token win). Pair with `registersAtHit` to get the routine's register + RAM state in a single round trip (e.g. capture the ZP pointer bytes a decoder just wrote). NOTE: registersAtHit is the true break instant (core snapshot); these RAM reads are taken after the hit frame finishes, so on run-to-frame-end cores (fceumm) they're the routine's RAM side effects for that frame — stable + reliable, which is exactly what RE needs."),
770
813
  },
771
814
  safeTool(async (args) => {
772
815
  switch (args.on) {
@@ -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
- card.innerHTML = `<div class="meta"><span>${img.tool}</span><span>${dt}</span></div>`;
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
  });