romdevtools 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +322 -3
  3. package/README.md +1 -1
  4. package/examples/README.md +1 -1
  5. package/examples/atari2600/templates/platformer.asm +18 -9
  6. package/examples/atari2600/templates/racing.asm +25 -4
  7. package/examples/atari2600/templates/shmup.asm +30 -5
  8. package/examples/atari2600/templates/sports.asm +41 -9
  9. package/examples/atari7800/templates/hello_sprite.c +8 -4
  10. package/examples/atari7800/templates/platformer.c +12 -8
  11. package/examples/atari7800/templates/puzzle.c +7 -4
  12. package/examples/atari7800/templates/racing.c +5 -2
  13. package/examples/atari7800/templates/shmup.c +8 -4
  14. package/examples/atari7800/templates/sports.c +6 -3
  15. package/examples/c64/templates/platformer.c +28 -24
  16. package/examples/c64/templates/puzzle.c +77 -16
  17. package/examples/c64/templates/racing.c +9 -0
  18. package/examples/c64/templates/shmup.c +13 -1
  19. package/examples/c64/templates/sports.c +9 -4
  20. package/examples/gb/templates/platformer.c +6 -2
  21. package/examples/gb/templates/puzzle.c +279 -101
  22. package/examples/gb/templates/racing.c +13 -1
  23. package/examples/gb/templates/shmup.c +13 -1
  24. package/examples/gb/templates/sports.c +9 -3
  25. package/examples/gba/templates/platformer.c +7 -13
  26. package/examples/gba/templates/puzzle.c +93 -15
  27. package/examples/gba/templates/racing.c +13 -1
  28. package/examples/gba/templates/shmup.c +13 -1
  29. package/examples/gba/templates/sports.c +17 -5
  30. package/examples/gbc/templates/platformer.c +6 -2
  31. package/examples/gbc/templates/puzzle.c +878 -178
  32. package/examples/gbc/templates/racing.c +13 -1
  33. package/examples/gbc/templates/shmup.c +13 -1
  34. package/examples/gbc/templates/sports.c +9 -3
  35. package/examples/genesis/templates/puzzle.c +76 -15
  36. package/examples/genesis/templates/racing.c +13 -1
  37. package/examples/genesis/templates/shmup_2p.c +13 -1
  38. package/examples/gg/templates/platformer.c +4 -0
  39. package/examples/gg/templates/puzzle.c +80 -14
  40. package/examples/gg/templates/racing.c +17 -1
  41. package/examples/gg/templates/shmup.c +17 -1
  42. package/examples/gg/templates/sports.c +4 -0
  43. package/examples/lynx/templates/platformer.c +25 -6
  44. package/examples/lynx/templates/puzzle.c +77 -14
  45. package/examples/lynx/templates/shmup.c +13 -1
  46. package/examples/lynx/templates/sports.c +5 -2
  47. package/examples/msx/platformer/main.c +2 -0
  48. package/examples/msx/puzzle/main.c +78 -15
  49. package/examples/msx/racing/main.c +1 -0
  50. package/examples/msx/shmup/main.c +1 -0
  51. package/examples/msx/sports/main.c +3 -2
  52. package/examples/nes/templates/platformer.c +11 -3
  53. package/examples/nes/templates/puzzle.c +81 -21
  54. package/examples/nes/templates/racing.c +15 -1
  55. package/examples/nes/templates/shmup.c +1 -0
  56. package/examples/nes/templates/sports.c +1 -0
  57. package/examples/pce/platformer/main.c +3 -1
  58. package/examples/pce/puzzle/main.c +78 -12
  59. package/examples/pce/racing/main.c +1 -0
  60. package/examples/pce/shmup/main.c +5 -4
  61. package/examples/pce/sports/main.c +4 -3
  62. package/examples/sms/templates/platformer.c +4 -0
  63. package/examples/sms/templates/puzzle.c +80 -14
  64. package/examples/sms/templates/racing.c +17 -1
  65. package/examples/sms/templates/shmup.c +17 -1
  66. package/examples/sms/templates/shmup_2p.c +17 -1
  67. package/examples/sms/templates/sports.c +4 -0
  68. package/examples/snes/templates/platformer.c +32 -15
  69. package/examples/snes/templates/puzzle.c +84 -16
  70. package/examples/snes/templates/racing.c +20 -1
  71. package/examples/snes/templates/shmup.c +20 -2
  72. package/examples/snes/templates/sports.c +7 -0
  73. package/package.json +12 -12
  74. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  75. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  76. package/src/cores/wasm/fceumm_libretro.js +1 -1
  77. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  78. package/src/cores/wasm/gambatte_libretro.js +1 -1
  79. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  80. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  81. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  82. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  83. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  84. package/src/cores/wasm/handy_libretro.js +1 -1
  85. package/src/cores/wasm/handy_libretro.wasm +0 -0
  86. package/src/cores/wasm/mgba_libretro.js +1 -1
  87. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  88. package/src/cores/wasm/prosystem_libretro.js +1 -1
  89. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  90. package/src/cores/wasm/snes9x_libretro.js +1 -1
  91. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  92. package/src/cores/wasm/stella2014_libretro.js +1 -1
  93. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  94. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  95. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  96. package/src/host/LibretroHost.js +245 -10
  97. package/src/mcp/server.js +6 -0
  98. package/src/mcp/tools/disasm-rebuild.js +315 -65
  99. package/src/mcp/tools/disasm.js +149 -28
  100. package/src/mcp/tools/find-references.js +216 -51
  101. package/src/mcp/tools/frame.js +11 -4
  102. package/src/mcp/tools/index.js +15 -1
  103. package/src/mcp/tools/input.js +26 -3
  104. package/src/mcp/tools/memory.js +208 -39
  105. package/src/mcp/tools/playtest.js +56 -4
  106. package/src/mcp/tools/project.js +35 -9
  107. package/src/mcp/tools/toolchain.js +43 -10
  108. package/src/mcp/tools/watch-memory.js +172 -25
  109. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
  110. package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
  111. package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
  112. package/src/platforms/atari7800/MENTAL_MODEL.md +27 -6
  113. package/src/platforms/gb/MENTAL_MODEL.md +16 -1
  114. package/src/platforms/gb/TROUBLESHOOTING.md +42 -0
  115. package/src/platforms/gb/lib/c/patch-header.js +7 -4
  116. package/src/platforms/gbc/MENTAL_MODEL.md +12 -0
  117. package/src/platforms/gbc/TROUBLESHOOTING.md +21 -0
  118. package/src/platforms/gbc/lib/c/font.h +43 -0
  119. package/src/platforms/gbc/lib/c/patch-header.js +7 -4
  120. package/src/platforms/genesis/MENTAL_MODEL.md +40 -6
  121. package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
  122. package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
  123. package/src/platforms/gg/TROUBLESHOOTING.md +13 -17
  124. package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
  125. package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
  126. package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
  127. package/src/platforms/msx/MENTAL_MODEL.md +6 -0
  128. package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
  129. package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
  130. package/src/platforms/msx/lib/c/msx_hw.h +2 -0
  131. package/src/platforms/msx/lib/c/msx_vdp.c +45 -0
  132. package/src/platforms/nes/MENTAL_MODEL.md +10 -3
  133. package/src/platforms/nes/lib/c/nes_runtime.c +41 -0
  134. package/src/platforms/nes/lib/c/nes_runtime.h +2 -0
  135. package/src/platforms/pce/MENTAL_MODEL.md +9 -0
  136. package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
  137. package/src/platforms/pce/lib/c/pce_hw.h +2 -1
  138. package/src/platforms/pce/lib/c/pce_sound.c +22 -0
  139. package/src/platforms/sms/MENTAL_MODEL.md +5 -0
  140. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  141. package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
  142. package/src/platforms/snes/MENTAL_MODEL.md +5 -0
  143. package/src/playtest/playtest.js +73 -3
  144. package/src/toolchains/index.js +37 -8
@@ -24,7 +24,7 @@ import { registerInputTools } from "./input.js";
24
24
  import { registerStateTools } from "./state.js";
25
25
  import { registerMemoryTools } from "./memory.js";
26
26
  import { registerToolchainTools } from "./toolchain.js";
27
- import { registerPlaytestTools } from "./playtest.js";
27
+ import { registerPlaytestTools, getPlaytestHumanStatus } from "./playtest.js";
28
28
  import { registerPlatformTools as registerPlatformSpecificTools } from "./platform-tools.js";
29
29
  import { registerPlatformTools } from "./platforms.js";
30
30
  import { registerSymbolTools } from "./symbols.js";
@@ -199,9 +199,23 @@ export function registerTools(server, z, sessionKey) {
199
199
  const base = host
200
200
  ? { ...host.getStatus() }
201
201
  : { loaded: false, hint: "no host yet; call loadMedia (in category 'run') to load a ROM" };
202
+ // Human co-drive signals: an agent re-grounding mid-session needs to
203
+ // know a human is playing in a playtest window BEFORE it fights them
204
+ // for input/stepping (pause, or use a second session).
205
+ const human = getPlaytestHumanStatus(sessionKey);
202
206
  return jsonContent({
203
207
  romdevVersion: PKG_VERSION,
204
208
  ...base,
209
+ playtestWindowOpen: human.windowOpen,
210
+ ...(human.windowOpen
211
+ ? {
212
+ humanInputActive: human.humanInputActive,
213
+ ...(human.framesSinceHumanInput != null ? { framesSinceHumanInput: human.framesSinceHumanInput } : {}),
214
+ ...(human.humanInputActive
215
+ ? { humanInputNote: "A human is ACTIVELY playing in the playtest window — their input overwrites yours each tick and real-time stepping races yours. host({op:'pause'}) to inspect, or use a second session (different x-romdev-session) for deterministic work." }
216
+ : {}),
217
+ }
218
+ : {}),
205
219
  loadedCategories: cats.filter((c) => c.loaded).map((c) => c.name),
206
220
  unloadedCategories: cats.filter((c) => !c.loaded).map((c) => c.name),
207
221
  });
@@ -1,6 +1,16 @@
1
1
  import { getHost } from "../state.js";
2
2
  import { jsonContent, safeTool } from "../util.js";
3
3
  import { getInputLayoutCore } from "./input-layout.js";
4
+ import { humanCoDriveWarning } from "./playtest.js";
5
+
6
+ // Spreadable co-drive conflict marker for every input-driving op: while a
7
+ // human is actively playing in this session's playtest window, their input
8
+ // overwrites the agent's each tick — so the agent must be TOLD its press/set
9
+ // may not take. Empty (no field) when there's no conflict.
10
+ function coDriveFields(sessionKey) {
11
+ const warning = humanCoDriveWarning(sessionKey);
12
+ return warning ? { humanCoDriveWarning: warning } : {};
13
+ }
4
14
 
5
15
  // Resolve a platform-native button alias to the libretro button the host
6
16
  // understands. Genesis pads have A/B/C (+ X/Y/Z on 6-button) which libretro
@@ -103,6 +113,7 @@ function inputSetCore({ ports }, sessionKey) {
103
113
  ...(ignoredButtons.length
104
114
  ? { ignoredButtons, ignoredNote: `Ignored ${ignoredButtons.length} unknown button name(s) — not pressed. Valid: ${[...KNOWN_BUTTONS].join(", ")}.` }
105
115
  : {}),
116
+ ...coDriveFields(sessionKey),
106
117
  };
107
118
  }
108
119
 
@@ -110,6 +121,13 @@ function inputSetCore({ ports }, sessionKey) {
110
121
  function inputPressCore({ button, frames = 2, port: p = 0 }, sessionKey) {
111
122
  const host = getHost(sessionKey);
112
123
  const resolved = resolveButtonAlias(button, host.status.platform);
124
+ // GUARANTEE a released->pressed EDGE. If the button is already held
125
+ // (a prior input({op:'set'}) or an overlapping schedule), the game's
126
+ // newpress detector never fires and the press silently does nothing —
127
+ // the "one-shot press didn't pause the game" report (0.27.0 #7).
128
+ // One released frame first makes the edge unconditional.
129
+ host.setInput({ ports: [{}, {}] });
130
+ host.stepFrames(1);
113
131
  const pressed = { ports: [{}, {}] };
114
132
  pressed.ports[p][resolved] = true;
115
133
  host.setInput(pressed);
@@ -121,8 +139,10 @@ function inputPressCore({ button, frames = 2, port: p = 0 }, sessionKey) {
121
139
  ...(resolved !== button ? { resolvedTo: resolved } : {}),
122
140
  frames,
123
141
  releaseFrames: 1,
124
- framesStepped: frames + 1,
142
+ preReleaseFrames: 1,
143
+ framesStepped: frames + 2,
125
144
  frameCount: host.status.frameCount,
145
+ ...coDriveFields(sessionKey),
126
146
  };
127
147
  }
128
148
 
@@ -135,7 +155,7 @@ function inputSequenceCore({ steps }, sessionKey) {
135
155
  host.stepFrames(step.frames);
136
156
  total += step.frames;
137
157
  }
138
- return { stepsRun: steps.length, framesRun: total, frameCount: host.status.frameCount };
158
+ return { stepsRun: steps.length, framesRun: total, frameCount: host.status.frameCount, ...coDriveFields(sessionKey) };
139
159
  }
140
160
 
141
161
  /** op:'navigate' — drive menus by advancing on SCREEN CHANGE; reports consumed per step. */
@@ -178,6 +198,7 @@ function inputNavigateCore({ steps }, sessionKey) {
178
198
  framesRun: totalFrames,
179
199
  frameCount: host.status.frameCount,
180
200
  ...(dropped ? { droppedPresses: dropped, note: `${dropped} step(s) had consumed:false — the screen never changed after the press (wrong screen / press dropped / game polls input on a specific frame). Re-run those steps, increase holdFrames, or reach the screen via state save/load.` } : {}),
201
+ ...coDriveFields(sessionKey),
181
202
  };
182
203
  }
183
204
 
@@ -199,7 +220,9 @@ export function registerInputTools(server, z, sessionKey) {
199
220
  "The held state is honored by frame({op:'step'}) AND by watch/breakpoint runs that have NO `pressDuring` " +
200
221
  "schedule (they inherit it). If a watch/breakpoint IS given `pressDuring`, that schedule OWNS the pad for " +
201
222
  "the run and this set state is ignored — so drive a watched window with `pressDuring`, not a prior `set`.\n" +
202
- "'press': press one named `button` for `frames` then release (port 0 default).\n" +
223
+ "'press': press one named `button` for `frames` then release (port 0 default). Runs ONE released frame " +
224
+ "first so edge-triggered handlers (START pause, menu confirm) always see a fresh newpress even if the " +
225
+ "button was already held by a prior set.\n" +
203
226
  "'sequence': scripted frame-by-frame `steps:[{input:{ports}, frames}]` for replays/tests.\n" +
204
227
  "'navigate': walk a menu by advancing on SCREEN CHANGE — `steps:[{button, holdFrames?, maxWaitFrames?, " +
205
228
  "settleFrames?}]`; reports `consumed` per step (false = the screen never reacted: wrong screen / press dropped / " +
@@ -81,7 +81,7 @@ function genericEndianness(platform) {
81
81
  // Each function is the body of one former narrow tool, verbatim. The `memory`
82
82
  // router dispatches on `op`. They share the module-scope helpers below.
83
83
 
84
- async function memRead(sessionKey, { region, offset = 0, length, offsets, outputPath, inline }) {
84
+ async function memRead(sessionKey, { region, offset = 0, length, offsets, outputPath, inline, echo }) {
85
85
  const host = getHost(sessionKey);
86
86
  const info0 = REGION_INFO[region] ?? {};
87
87
  const endianness0 = info0.endianness ?? genericEndianness(host.status.platform);
@@ -112,7 +112,7 @@ async function memRead(sessionKey, { region, offset = 0, length, offsets, output
112
112
  // agent doesn't have to figure byte order out empirically. For
113
113
  // generic regions (system_ram etc) fall back to the loaded
114
114
  // platform's CPU endianness.
115
- const info = REGION_INFO[region] ?? {};
115
+ const info = REGION_INFO[region] ?? {}; /* (restored — a careless replace-all removed it) */
116
116
  const endianness = info.endianness ?? genericEndianness(host.status.platform);
117
117
  // Genesis VRAM is stored by genesis-plus-gx as 16-bit words in HOST
118
118
  // (little-endian) byte order, so these raw bytes have each word's two
@@ -121,6 +121,12 @@ async function memRead(sessionKey, { region, offset = 0, length, offsets, output
121
121
  // correct.) Use getTile (logicalPixels:true, the default) to decode tiles
122
122
  // in render order instead of un-swapping by hand.
123
123
  let note = info.note ?? null;
124
+ if (region === "system_ram" && host.status.platform === "genesis") {
125
+ note = (note ? note + " " : "") +
126
+ "GENESIS: normalized to CPU byte order — offset X IS the byte the 68k sees at $FF0000+X " +
127
+ "(the host un-swaps gpgx's word-swapped storage), so offsets line up with disassembly " +
128
+ "addresses and cheat-DB maps. Words are big-endian, as the meta says.";
129
+ }
124
130
  if (region === "video_ram" && host.status.platform === "genesis") {
125
131
  note = (note ? note + " " : "") +
126
132
  "GENESIS: these are RAW host-LE bytes — each 16-bit VRAM word's two bytes are SWAPPED " +
@@ -142,12 +148,14 @@ async function memRead(sessionKey, { region, offset = 0, length, offsets, output
142
148
  return jsonContent({ ...meta, path, bytes: written });
143
149
  }
144
150
  // Small read WITH an explicit outputPath: honor it — write the raw bytes
145
- // to disk AND still return the hex inline (small, useful). The intent of
146
- // passing outputPath is unambiguous; silently ignoring it broke the
147
- // "snapshot RAM to a file, then diff two files" workflow.
151
+ // to disk AND (by default) still return the hex inline. Pass echo:false
152
+ // to get just {path, bytes}: a 2KB RAM dump's ~4KB hex echo was the
153
+ // largest avoidable token cost in a real RE session (0.27.0 feedback #4)
154
+ // when the whole point of outputPath was keeping it out of context.
148
155
  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
149
156
  if (outputPath) {
150
157
  const { path, bytes: written } = writeOutput(bytes, { outputPath, what: `readMemory(${region})` });
158
+ if (echo === false) return jsonContent({ ...meta, path, bytes: written });
151
159
  return jsonContent({ ...meta, path, bytes: written, hex });
152
160
  }
153
161
  return jsonContent({ ...meta, hex });
@@ -185,7 +193,7 @@ async function memWrite(sessionKey, { region, offset = 0, hex, base64, data, byt
185
193
  return textContent(`wrote ${buf.length} bytes to ${region}+${offset}`);
186
194
  }
187
195
 
188
- async function memReadCart(sessionKey, { offset = 0, length = 16, outputPath, inline }) {
196
+ async function memReadCart(sessionKey, { offset = 0, length = 16, outputPath, inline, echo }) {
189
197
  const host = getHost(sessionKey);
190
198
  const rom = host.getCartRom();
191
199
  if (offset >= rom.bytes.length) {
@@ -210,6 +218,7 @@ async function memReadCart(sessionKey, { offset = 0, length = 16, outputPath, in
210
218
  const hex = Array.from(slice, (b) => b.toString(16).padStart(2, "0")).join("");
211
219
  if (outputPath) {
212
220
  const { path, bytes: written } = writeOutput(slice, { outputPath, what: "readCartRom" });
221
+ if (echo === false) return jsonContent({ ...meta, path, bytes: written });
213
222
  return jsonContent({ ...meta, path, bytes: written, hex });
214
223
  }
215
224
  return jsonContent({ ...meta, hex });
@@ -223,15 +232,83 @@ async function memSnapshot(sessionKey, { region, name = "default", offset = 0, l
223
232
  return jsonContent({ region, name, offset, length: bytes.length, note: "Baseline captured — trigger your event, then memory({op:'diff', region, name}) for the changed bytes." });
224
233
  }
225
234
 
226
- async function memDiff(sessionKey, { region, name = "default", view = "summary", maxChanges = 4096, maxClusters = 64, gap = 4 }) {
235
+ // ── diffRuns A/B scenario diff: THE input→RAM mapping primitive ─────────
236
+ // Runs the SAME starting state twice (savestate restore in between) under two
237
+ // different held inputs, then diffs the two post-run memories. Replaces the
238
+ // hand-rolled save → hold A → step → dump → restore → hold B → step → dump →
239
+ // client-side python diff loop (~6 calls + a 4KB context hit) with ONE call
240
+ // (0.27.0 feedback #6). The emulator is left at the END OF RUN B.
241
+ async function memDiffRuns(sessionKey, { region, frames = 60, portsA, portsB, offset = 0, length, minDelta, maxClusters = 64, gap = 4 }) {
242
+ const host = getHost(sessionKey);
243
+ const baseline = host.serializeState();
244
+ let bufA, bufB;
245
+ try {
246
+ host.setInput({ ports: portsA ?? [{}] });
247
+ host.stepFrames(frames);
248
+ bufA = host.readMemory(region, offset, length ?? regionLength(host, region, offset));
249
+ } finally {
250
+ host.unserializeState(baseline);
251
+ }
252
+ host.setInput({ ports: portsB ?? [{}] });
253
+ host.stepFrames(frames);
254
+ bufB = host.readMemory(region, offset, bufA.length);
255
+ host.setInput({ ports: [{}] });
256
+
257
+ const divergent = [];
258
+ for (let i = 0; i < Math.min(bufA.length, bufB.length); i++) {
259
+ if (bufA[i] === bufB[i]) continue;
260
+ if (minDelta != null && Math.abs(bufB[i] - bufA[i]) < minDelta) continue;
261
+ divergent.push(offset + i);
262
+ }
263
+ const { clusters, stride } = clusterChanges(divergent, { gap });
264
+ const out = clusters.slice(0, maxClusters).map((c) => {
265
+ const entry = {
266
+ start: "0x" + c.startDec.toString(16).toUpperCase(),
267
+ end: "0x" + c.endDec.toString(16).toUpperCase(),
268
+ span: c.endDec - c.startDec + 1,
269
+ bytes: c.bytes,
270
+ };
271
+ if (c.endDec - c.startDec + 1 <= 8) {
272
+ let a = "", b = "";
273
+ for (let addr = c.startDec; addr <= c.endDec; addr++) {
274
+ a += bufA[addr - offset].toString(16).padStart(2, "0");
275
+ b += bufB[addr - offset].toString(16).padStart(2, "0");
276
+ }
277
+ entry.runA = a;
278
+ entry.runB = b;
279
+ }
280
+ return entry;
281
+ });
282
+ return jsonContent({
283
+ region, frames, offset, length: bufA.length,
284
+ portsA: portsA ?? [{}], portsB: portsB ?? [{}],
285
+ divergentCount: divergent.length,
286
+ clusterCount: clusters.length,
287
+ clusters: out,
288
+ ...(stride !== null ? { stride: "0x" + stride.toString(16) } : {}),
289
+ ...(clusters.length > out.length ? { truncated: true } : {}),
290
+ note: divergent.length === 0
291
+ ? "No divergent bytes — the two inputs produced identical memory after " + frames + " frames. Try more frames, or inputs the game actually distinguishes in this state."
292
+ : "Each cluster diverges between the two runs; runA/runB are the post-run bytes (small clusters only). The byte that tracks your input is usually the small cluster whose runA-vs-runB delta matches the expected movement. Emulator is left at the END OF RUN B.",
293
+ });
294
+ }
295
+
296
+ async function memDiff(sessionKey, { region, name = "default", view = "summary", maxChanges = 4096, maxClusters = 64, gap = 4, minDelta }) {
227
297
  const host = getHost(sessionKey);
228
298
  const snap = memSnapshots(sessionKey).get(snapKey(region, name));
229
299
  if (!snap) throw new Error(`memory({op:'diff'}): no snapshot named '${name}' for region '${region}'. Call memory({op:'snapshot', region, name}) first.`);
230
300
  const now = host.readMemory(region, snap.offset, snap.bytes.length);
231
301
 
232
- // Collect changed offsets once.
302
+ // Collect changed offsets once. minDelta filters OUT small wiggles
303
+ // (|after - before| < minDelta) so "find the position byte amid OAM/RNG
304
+ // churn" is one cheap call instead of a raw dump + client-side filtering
305
+ // (0.27.0 feedback #5).
233
306
  const changedOffsets = [];
234
- for (let i = 0; i < snap.bytes.length; i++) if (snap.bytes[i] !== now[i]) changedOffsets.push(i);
307
+ for (let i = 0; i < snap.bytes.length; i++) {
308
+ if (snap.bytes[i] === now[i]) continue;
309
+ if (minDelta != null && Math.abs(now[i] - snap.bytes[i]) < minDelta) continue;
310
+ changedOffsets.push(i);
311
+ }
235
312
  const changedCount = changedOffsets.length;
236
313
 
237
314
  if (view === "raw") {
@@ -253,12 +330,29 @@ async function memDiff(sessionKey, { region, name = "default", view = "summary",
253
330
  const strideNote = stride !== null
254
331
  ? `${clusters.length} change-islands evenly spaced at stride 0x${stride.toString(16)} — likely a struct/entity ARRAY (each island = one record's changed fields).`
255
332
  : null;
256
- const out = clusters.slice(0, maxClusters).map((c) => ({
257
- start: "0x" + c.startDec.toString(16).toUpperCase(),
258
- end: "0x" + c.endDec.toString(16).toUpperCase(),
259
- span: c.endDec - c.startDec + 1,
260
- bytes: c.bytes,
261
- }));
333
+ // Per-cluster before/after for SMALL clusters (≤8 bytes): the summary
334
+ // view used to give only ranges, forcing a fall back to view:'raw' to
335
+ // see the values (0.27.0 feedback #5). Large clusters stay range-only.
336
+ const out = clusters.slice(0, maxClusters).map((c) => {
337
+ const entry = {
338
+ start: "0x" + c.startDec.toString(16).toUpperCase(),
339
+ end: "0x" + c.endDec.toString(16).toUpperCase(),
340
+ span: c.endDec - c.startDec + 1,
341
+ bytes: c.bytes,
342
+ };
343
+ const span = c.endDec - c.startDec + 1;
344
+ if (span <= 8) {
345
+ let before = "", after = "";
346
+ for (let a = c.startDec; a <= c.endDec; a++) {
347
+ const i = a - snap.offset;
348
+ before += snap.bytes[i].toString(16).padStart(2, "0");
349
+ after += now[i].toString(16).padStart(2, "0");
350
+ }
351
+ entry.before = before;
352
+ entry.after = after;
353
+ }
354
+ return entry;
355
+ });
262
356
  return jsonContent({
263
357
  region, name, view, baseOffset: snap.offset, length: snap.bytes.length,
264
358
  changedCount, clusterCount: clusters.length,
@@ -289,26 +383,91 @@ async function memClassify(sessionKey, { region = "system_ram", offset = 0, leng
289
383
  // value changes with op:'searchNext' (compare:'eq'|'changed'|'unchanged'|'gt'|'lt'|'inc'|'dec').
290
384
  // The candidate list lives per session (keyed by `name`); each narrow reads
291
385
  // the region fresh and keeps only candidates that still satisfy the compare.
292
- async function memSearch(sessionKey, { value, size = 1, region = "system_ram", name = "default", maxCandidates = 64 }) {
386
+ /**
387
+ * Decode one candidate value at `i` under the search's representation.
388
+ * raw — `size`-byte unsigned int, region endianness.
389
+ * bcd — `size` bytes of packed BCD (2 decimal digits per byte, region
390
+ * endianness): bytes [0x25,0x01] (LE) = 125. Returns null when any
391
+ * nibble is >9 (not a BCD value).
392
+ * digits — `digitLen` consecutive bytes, one DECIMAL DIGIT per byte, most
393
+ * significant first (HUD order), each offset by the candidate's
394
+ * constant tile base `k` (0 for raw digits, 0x30 for ASCII, or the
395
+ * game's digit-tile index). Returns null when any byte fails to
396
+ * decode as k+0..9.
397
+ * @returns {number|null}
398
+ */
399
+ function decodeAt(buf, i, s, k = 0) {
400
+ if (s.as === "digits") {
401
+ if (i + s.digitLen > buf.length) return null;
402
+ let v = 0;
403
+ for (let j = 0; j < s.digitLen; j++) {
404
+ const d = buf[i + j] - k;
405
+ if (d < 0 || d > 9) return null;
406
+ v = v * 10 + d;
407
+ }
408
+ return v;
409
+ }
410
+ if (i + s.size > buf.length) return null;
411
+ const u = readUint(buf, i, s.size, s.little);
412
+ if (s.as === "bcd") {
413
+ const hex = u.toString(16);
414
+ if (!/^[0-9]+$/.test(hex)) return null; // a nibble >9 → not BCD
415
+ return parseInt(hex, 10);
416
+ }
417
+ return u;
418
+ }
419
+
420
+ async function memSearch(sessionKey, { value, size = 1, as = "raw", region = "system_ram", name = "default", maxCandidates = 64 }) {
293
421
  const host = getHost(sessionKey);
294
422
  const info = REGION_INFO[region] ?? {};
295
423
  const little = (info.endianness ?? genericEndianness(host.status.platform)) !== "big";
296
424
  const buf = host.readMemory(region, 0, regionLength(host, region, 0));
297
- const read = (i) => readUint(buf, i, size, little);
425
+ const digitStr = String(value >>> 0);
426
+ const s = { region, size, little, as, digitLen: digitStr.length };
298
427
  const candidates = [];
299
- for (let i = 0; i + size <= buf.length; i++) {
300
- if (read(i) === (value >>> 0)) candidates.push(i);
428
+ /** digits mode: per-candidate constant tile-base offset (addr k). */
429
+ const kMap = as === "digits" ? new Map() : null;
430
+ if (as === "digits") {
431
+ // One byte per decimal digit, MSD first, all offset by a constant k
432
+ // (HUD digits are usually tile indices: k=0 raw, k=0x30 ASCII, or the
433
+ // font's digit base). k is derived per candidate from the first digit.
434
+ // Single-digit values would match EVERY byte with a free k, so they
435
+ // only accept the common bases.
436
+ const digits = Array.from(digitStr, (c) => c.charCodeAt(0) - 0x30);
437
+ const SINGLE_DIGIT_BASES = [0x00, 0x30];
438
+ for (let i = 0; i + digits.length <= buf.length; i++) {
439
+ const k = buf[i] - digits[0];
440
+ if (k < 0 || k > 255 - 9) continue;
441
+ if (digits.length === 1 && !SINGLE_DIGIT_BASES.includes(k)) continue;
442
+ let ok = true;
443
+ for (let j = 1; j < digits.length; j++) {
444
+ if (buf[i + j] !== digits[j] + k) { ok = false; break; }
445
+ }
446
+ if (ok) { candidates.push(i); kMap.set(i, k); }
447
+ }
448
+ } else {
449
+ for (let i = 0; i + size <= buf.length; i++) {
450
+ if (decodeAt(buf, i, s) === (value >>> 0)) candidates.push(i);
451
+ }
301
452
  }
302
- searchSessions(sessionKey).set(name, { region, size, little, addrs: Uint32Array.from(candidates) });
453
+ // Baseline EVERY candidate at seed time so relative compares
454
+ // ('inc'/'dec'/'changed'/'unchanged') work as the FIRST narrow. Pre-fix,
455
+ // the baseline only existed after a value-based round — the first
456
+ // relative searchNext silently returned 0 candidates (a real session
457
+ // burned rounds on this; it was documented as a footgun instead of fixed).
458
+ const prevMap = new Map();
459
+ for (const a of candidates) prevMap.set(a, value >>> 0);
460
+ searchSessions(sessionKey).set(name, { ...s, addrs: Uint32Array.from(candidates), prev: prevMap, kMap });
303
461
  return jsonContent({
304
- searchId: name, region, size,
462
+ searchId: name, region, size, as,
305
463
  count: candidates.length,
306
- candidates: candidates.slice(0, maxCandidates).map((a) => "0x" + a.toString(16)),
464
+ candidates: candidates.slice(0, maxCandidates).map((a) =>
465
+ "0x" + a.toString(16) + (kMap && kMap.get(a) ? ` (digitBase 0x${kMap.get(a).toString(16)})` : "")),
307
466
  note: candidates.length === 0
308
- ? "0 matches — wrong size? (try size:2 for a score). Or the value isn't in this region (try a different region) or is stored offset/encoded."
467
+ ? "0 matches — wrong size? (try size:2 for a score). Stored displayed is common: lives are often displayed−1 (re-seed with value-1), scores ÷10. Try as:'bcd' (packed BCD) or as:'digits' (one byte per on-screen digit, any constant tile base) or a different region."
309
468
  : candidates.length === 1
310
469
  ? "1 candidate — likely THE address. Confirm with memory({op:'write', region, offset, hex}) and watch the screen."
311
- : "Make the value change in-game, then memory({op:'searchNext', name, compare:'eq', value:<new>}) to narrow. Repeat until 1-2 remain.",
470
+ : "Change the value in-game, then memory({op:'searchNext', name, compare:'eq', value:<new>}) to narrow — or compare:'inc'/'dec'/'changed' right away (baselines are recorded at seed). Repeat until 1-2 remain.",
312
471
  });
313
472
  }
314
473
 
@@ -320,21 +479,21 @@ async function memSearchNext(sessionKey, { compare, value, name = "default", max
320
479
  throw new Error(`searchNext: compare '${compare}' needs a \`value\` (the number now on screen).`);
321
480
  }
322
481
  const buf = host.readMemory(s.region, 0, regionLength(host, s.region, 0));
323
- const read = (i) => readUint(buf, i, s.size, s.little);
482
+ const read = (a) => decodeAt(buf, a, s, s.kMap ? s.kMap.get(a) ?? 0 : 0);
324
483
  const v = (value ?? 0) >>> 0;
325
484
  const kept = [];
326
485
  for (const a of s.addrs) {
327
- const cur = read(a);
486
+ const cur = read(a); // null = no longer decodes (bcd/digits)
328
487
  const prev = s.prev ? s.prev.get(a) : undefined;
329
488
  let ok = false;
330
489
  switch (compare) {
331
490
  case "eq": ok = cur === v; break;
332
- case "gt": ok = cur > v; break;
333
- case "lt": ok = cur < v; break;
491
+ case "gt": ok = cur !== null && cur > v; break;
492
+ case "lt": ok = cur !== null && cur < v; break;
334
493
  case "changed": ok = prev !== undefined && cur !== prev; break;
335
494
  case "unchanged": ok = prev !== undefined && cur === prev; break;
336
- case "inc": ok = prev !== undefined && cur > prev; break;
337
- case "dec": ok = prev !== undefined && cur < prev; break;
495
+ case "inc": ok = cur !== null && prev !== undefined && cur > prev; break;
496
+ case "dec": ok = cur !== null && prev !== undefined && cur < prev; break;
338
497
  }
339
498
  if (ok) kept.push(a);
340
499
  }
@@ -348,9 +507,9 @@ async function memSearchNext(sessionKey, { compare, value, name = "default", max
348
507
  searchId: name, compare, count: kept.length,
349
508
  candidates: kept.slice(0, maxCandidates).map((a) => "0x" + a.toString(16) + "=" + read(a)),
350
509
  note: kept.length === 0
351
- ? "0 left — narrowed too far (wrong op, or the value moved between reads). Re-seed with searchValue."
510
+ ? "0 left — narrowed too far (wrong op, or the value moved between reads — e.g. the scene changed/player died mid-step; screenshot before blaming the compare). Re-seed with memory({op:'search'})."
352
511
  : kept.length <= 2
353
- ? "Down to 1-2 — confirm: writeMemory({region, offset, bytes}) and watch the screen change."
512
+ ? "Down to 1-2 — confirm: memory({op:'write', region, offset, hex:'..'}) and watch the screen change."
354
513
  : "Still multiple — change the value again and memory({op:'searchNext'}) to keep narrowing.",
355
514
  });
356
515
  }
@@ -374,7 +533,7 @@ export function registerMemoryTools(server, z, sessionKey) {
374
533
  "snapshot → {region, name, offset?, length?}; " +
375
534
  "diff → {region, name, view?}; " +
376
535
  "classify → {region?, offset?, length?}; " +
377
- "search → {value, size?, region?}; " +
536
+ "search → {value, size?, as?, region?}; " +
378
537
  "searchNext → {compare, value?}.\n" +
379
538
  `• op:'read' — bytes as a \`hex\` string. ≤${INLINE_HEX_LIMIT}B come back inline; >${INLINE_HEX_LIMIT}B need \`outputPath\` (RAW bytes written → {path,bytes}) or \`inline:true\`. BATCH: \`offsets\` (addresses or {offset,length}) reads many non-contiguous spots in ONE call → reads:[{offset,length,hex}]. (Genesis video_ram is raw host-LE word-swapped — not a direct tile map; use tiles({op:'pixels'}).)\n` +
380
539
  "• op:'write' — pass payload as `hex` (e.g. 'deadbeef') OR `base64` — **NOT `data`, `bytes`, or an array (those are REJECTED with guidance).** hex for byte patterns, base64 for binary blobs.\n" +
@@ -382,11 +541,11 @@ export function registerMemoryTools(server, z, sessionKey) {
382
541
  "• op:'snapshot' — capture a baseline of `region` (server RAM, keyed by `name`) to later diff. The 'which bytes did THIS event touch?' workflow: snapshot → trigger event → op:'diff'.\n" +
383
542
  "• op:'diff' — compare a region against a snapshot baseline → the CHANGED bytes. DEFAULT `view:'summary'` is a CLUSTERED summary (+ stride detection — '4 islands at stride 0x80' = a struct array) so a churny gameplay diff doesn't flood context; `view:'raw'` = the per-byte before/after list.\n" +
384
543
  "• op:'classify' — heuristically classify the bytes at an offset BEFORE you trust a 'found table'. **Kills the classic trap: a run that 'matches' your stats is often ASCII TEXT (bytes 82/79/68 = 'ROD' from a taunt string) or code.** Returns looksLike/printableRatio/entropy/asciiPreview/confidence.\n" +
385
- "• op:'search' — seed the iterative RAM value search (Cheat Engine / RetroArch style): all addresses currently holding `value` (`size` 1/2/4 bytes, region's endianness). The primitive for 'the screen shows X, find its RAM address' — better than snapshot+diff for this.\n" +
386
- "• op:'searchNext' — narrow the active candidate list against CURRENT memory. `compare`: 'eq'/'gt'/'lt' (need `value`), 'changed'/'unchanged'/'inc'/'dec' (vs the previous read). Repeat until 1-2 remain, then confirm with op:'write'.",
544
+ "• op:'search' — seed the iterative RAM value search (Cheat Engine / RetroArch style): all addresses currently holding `value` (`size` 1/2/4 bytes, region's endianness). The primitive for 'the screen shows X, find its RAM address' — better than snapshot+diff for this. STORED ≠ DISPLAYED is common — `as:'bcd'` (packed BCD scores) and `as:'digits'` (one byte per on-screen digit at ANY constant tile base, auto-detected per candidate) search those representations directly; for displayed−1 lives or ÷10 scores just seed the transformed number.\n" +
545
+ "• op:'searchNext' — narrow the active candidate list against CURRENT memory. `compare`: 'eq'/'gt'/'lt' (need `value`), 'changed'/'unchanged'/'inc'/'dec' (vs the previous read — usable as the FIRST narrow too; baselines are recorded at seed). Comparisons happen in the seed's `as` representation. Repeat until 1-2 remain, then confirm with op:'write'. (For values an INPUT drives — position, velocity — op:'diffRuns' is usually one call instead of a narrowing loop.)",
387
546
  {
388
- op: z.enum(["read", "write", "readCart", "snapshot", "diff", "classify", "search", "searchNext"])
389
- .describe("read=bytes→hex; write=hex/base64→region; readCart=loaded cart ROM image; snapshot=capture a baseline; diff=changed bytes vs a baseline; classify=what kind of data is here; search=seed a value search; searchNext=narrow it."),
547
+ op: z.enum(["read", "write", "readCart", "snapshot", "diff", "diffRuns", "classify", "search", "searchNext"])
548
+ .describe("read=bytes→hex; write=hex/base64→region; readCart=loaded cart ROM image; snapshot=capture a baseline; diff=changed bytes vs a baseline; diffRuns=run the SAME start state twice under two different held inputs and return only the DIVERGENT bytes (THE input→RAM mapping primitive — replaces save/run/dump/restore/run/dump/python-diff); classify=what kind of data is here; search=seed a value search; searchNext=narrow it."),
390
549
  region: z.enum(REGIONS).optional().describe("Memory region. Required for read/write/snapshot/diff; defaults to system_ram for classify/search. (readCart targets the cart ROM image, not a region.)"),
391
550
  offset: z.number().int().min(0).default(0).describe("Byte offset within the region (read/write/snapshot/classify) or the cart ROM image (readCart)."),
392
551
  length: z.number().int().min(1).max(1 << 20).optional().describe("Bytes to read (max 1MB). op:read default 1; op:readCart default 16; op:snapshot default = whole region from offset; op:classify default 256."),
@@ -403,14 +562,20 @@ export function registerMemoryTools(server, z, sessionKey) {
403
562
  maxChanges: z.number().int().min(1).max(65536).default(4096).describe("op:diff raw view — cap the per-byte list (changedCount is the true total)."),
404
563
  maxClusters: z.number().int().min(1).max(4096).default(64).describe("op:diff summary view — cap the cluster list (clusterCount is the true total)."),
405
564
  gap: z.number().int().min(1).max(256).default(4).describe("op:diff summary view — merge changed bytes within this many bytes into one cluster (default 4)."),
565
+ minDelta: z.number().int().min(1).max(255).optional().describe("op:diff — ignore changes where |after-before| < minDelta (filters RNG/counter wiggle so a position byte that moved by the entity's speed stands out)."),
566
+ frames: z.number().int().min(1).max(100000).default(60).describe("op:diffRuns — frames to run EACH scenario from the same start state."),
567
+ portsA: z.array(z.record(z.string(), z.boolean())).max(2).optional().describe("op:diffRuns — held input for run A (e.g. [{right:true}]). Default released."),
568
+ portsB: z.array(z.record(z.string(), z.boolean())).max(2).optional().describe("op:diffRuns — held input for run B. Default released — A-vs-idle is the classic 'which byte does this input drive?' probe."),
406
569
  // search / searchNext
407
570
  value: z.number().int().optional().describe("op:search — the value the screen shows now. op:searchNext — required for compare 'eq'/'gt'/'lt'."),
408
- size: z.number().int().min(1).max(4).default(1).describe("op:search — value width in bytes: 1 (stats/lives), 2 (scores/timers), 4 (big counters)."),
409
- compare: z.enum(["eq", "changed", "unchanged", "inc", "dec", "gt", "lt"]).optional().describe("op:searchNexteq=now equals `value`; changed/unchanged vs the last read; inc/dec=went up/down; gt/lt=now >/< `value`."),
571
+ size: z.number().int().min(1).max(4).default(1).describe("op:search — value width in bytes: 1 (stats/lives), 2 (scores/timers), 4 (big counters). Ignored for as:'digits' (width = the value's digit count)."),
572
+ as: z.enum(["raw", "bcd", "digits"]).default("raw").describe("op:searchvalue representation: 'raw' (binary int, region endianness), 'bcd' (packed BCD, 2 decimal digits/byte common for NES scores), 'digits' (one byte per ON-SCREEN digit, MSD first, any constant tile base — HUD/tile-index score buffers; the matched base is reported per candidate). searchNext compares in the SAME representation automatically."),
573
+ compare: z.enum(["eq", "changed", "unchanged", "inc", "dec", "gt", "lt"]).optional().describe("op:searchNext — eq=now equals `value`; changed/unchanged vs the last read; inc/dec=went up/down. All of these work as the FIRST narrow too (baselines are recorded at seed). gt/lt=now >/< `value`."),
410
574
  maxCandidates: z.number().int().min(1).max(8192).default(64).describe("op:search/searchNext — cap the candidates RETURNED (the full list is kept server-side; `count` is the true total)."),
411
575
  // shared output
412
576
  outputPath: z.string().optional().describe(`op:read/readCart — write RAW bytes here. Required for reads >${INLINE_HEX_LIMIT}B unless inline. Small reads honor it too (writes file AND returns hex), so 'dump to disk then diff two files' works at any size. (Ignored with offsets.)`),
413
577
  inline: z.boolean().default(false).describe(`op:read/readCart — for reads >${INLINE_HEX_LIMIT}B, return the hex in the response instead of writing to disk.`),
578
+ echo: z.boolean().default(true).describe("op:read/readCart with outputPath — false = return only {path, bytes} with NO inline hex (keeps a 2-4KB dump out of context; the raw bytes are in the file)."),
414
579
  },
415
580
  safeTool(async (args) => {
416
581
  switch (args.op) {
@@ -424,6 +589,10 @@ export function registerMemoryTools(server, z, sessionKey) {
424
589
  if (!args.region) throw new Error("memory({op:'snapshot'}): `region` is required.");
425
590
  return await memSnapshot(sessionKey, args);
426
591
  }
592
+ case "diffRuns": {
593
+ if (!args.region) throw new Error("memory({op:'diffRuns'}): `region` is required.");
594
+ return await memDiffRuns(sessionKey, args);
595
+ }
427
596
  case "diff": {
428
597
  if (!args.region) throw new Error("memory({op:'diff'}): `region` is required.");
429
598
  return await memDiff(sessionKey, args);
@@ -102,6 +102,48 @@ export function isPlaytestRunning(sessionKey) {
102
102
  return reconcileSession(sessionKey);
103
103
  }
104
104
 
105
+ /**
106
+ * Human co-drive snapshot for one session: is a playtest window open, and has
107
+ * the HUMAN pressed anything (pad / keyboard / rewind-scrub) within the last
108
+ * ~2 s? Drives catalog({op:'status'}) and the co-drive warning attached to
109
+ * frame/input responses. Cheap, never throws; with no window everything is
110
+ * inactive. Frames are window ticks ≈ frames at 60fps real time.
111
+ * @param {string} sessionKey
112
+ * @returns {{windowOpen: boolean, humanInputActive: boolean, framesSinceHumanInput: number | null}}
113
+ */
114
+ export function getPlaytestHumanStatus(sessionKey) {
115
+ if (!reconcileSession(sessionKey)) {
116
+ return { windowOpen: false, humanInputActive: false, framesSinceHumanInput: null };
117
+ }
118
+ const s = sessions.get(sessionKey);
119
+ return {
120
+ windowOpen: true,
121
+ humanInputActive: typeof s.humanInputActive === "function" ? !!s.humanInputActive() : false,
122
+ framesSinceHumanInput: typeof s.framesSinceHumanInput === "function" ? s.framesSinceHumanInput() : null,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * The warning attached to frame({op:'step'/'stepAndShot'}) and input(set/press/
128
+ * sequence/navigate) responses while a human is co-driving this session's
129
+ * playtest window. null when there's no window or the human hasn't pressed
130
+ * recently — so the field only appears when there's a REAL conflict.
131
+ * @param {string} sessionKey
132
+ * @returns {string | null}
133
+ */
134
+ export function humanCoDriveWarning(sessionKey) {
135
+ const st = getPlaytestHumanStatus(sessionKey);
136
+ if (!st.windowOpen || !st.humanInputActive) return null;
137
+ const ago = st.framesSinceHumanInput != null ? `~${st.framesSinceHumanInput} frames ago` : "moments ago";
138
+ return (
139
+ `A playtest window is open and the HUMAN last pressed buttons ${ago} — you are co-driving the same ` +
140
+ "emulator. While they press, the window's input overwrites yours each tick (the human wins), and its " +
141
+ "real-time 60fps loop races your frame-stepping (non-deterministic results). Either host({op:'pause'}) " +
142
+ "while you inspect (the window keeps rendering, frozen), do deterministic work in a SECOND session " +
143
+ "(a different x-romdev-session header = a fully isolated emulator), or wait for the human to stop."
144
+ );
145
+ }
146
+
105
147
  export function registerPlaytestTools(server, z, sessionKey) {
106
148
  // op:'open' — open (or reuse) the SDL window for this session.
107
149
  async function ptOpen({ scale = 3, title, aspect = "tv" }) {
@@ -283,8 +325,14 @@ export function registerPlaytestTools(server, z, sessionKey) {
283
325
  // REPLACED by resetHost() on every runSource/loadMedia. Same object →
284
326
  // screenshot() and the window agree. Different object → they've diverged.
285
327
  const matches = !!windowHost && activeHost === windowHost;
328
+ const human = getPlaytestHumanStatus(sessionKey);
286
329
  return jsonContent({
287
330
  running: true,
331
+ // Is the human ACTIVELY playing right now (pressed within ~2 s)? While
332
+ // true, your input/setInput is overwritten each tick and real-time
333
+ // stepping races yours — pause, or use a second session.
334
+ humanInputActive: human.humanInputActive,
335
+ ...(human.framesSinceHumanInput != null ? { framesSinceHumanInput: human.framesSinceHumanInput } : {}),
288
336
  // What the HUMAN is looking at (the window's own host):
289
337
  windowMediaPath: windowHost?.status?.mediaPath ?? null,
290
338
  windowFrameCount: windowHost?.status?.frameCount ?? session.frameCount,
@@ -354,10 +402,14 @@ export function registerPlaytestTools(server, z, sessionKey) {
354
402
  "eyes (boots, renders, the feature is visible) — a window on a black screen/crash just wastes their attention. " +
355
403
  "BEST FOR diagnosing a USER-REPORTED bug: hand them the window, let them drive to the exact moment, then " +
356
404
  "inspect the SAME live host in real time (memory/watch/sprites/state). Every other tool keeps working against " +
357
- "that live host while the window is open. FOOTGUN — the window's loop owns input AND stepping: each tick it " +
358
- "rebuilds controller state from the human's gamepad+keyboard, calls setInput, then steps a frame, so your " +
359
- "input({op:'set'}) is OVERWRITTEN on the next tick (the human wins). To inspect a moving state freeze it first: " +
360
- "host({op:'pause'}) read host({op:'resume'}). Requires @kmamal/sdl. `scale`/`title`/`aspect` shape the window.\n" +
405
+ "that live host while the window is open. FOOTGUN — the window's loop steps the core in REAL TIME, and while " +
406
+ "the human is pressing (pad/keyboard) it writes their input each tick, overwriting yours the human wins. " +
407
+ "(When the human is idle the window leaves your input({op:'set'}) alone, but its 60fps stepping still races " +
408
+ "your frame({op:'step'}).) You'll KNOW: frame/input responses carry `humanCoDriveWarning` while the human " +
409
+ "pressed within ~2s, and catalog({op:'status'})/playtest({op:'status'}) expose `humanInputActive`. To inspect " +
410
+ "a moving state freeze it first: host({op:'pause'}) → read → host({op:'resume'}); for deterministic stepping " +
411
+ "while the human plays, use a SECOND session (different x-romdev-session = fully isolated emulator). " +
412
+ "Requires @kmamal/sdl. `scale`/`title`/`aspect` shape the window.\n" +
361
413
  "• op:'stop' — close THIS session's window (the host stays loaded; other agents' windows unaffected).\n" +
362
414
  "• op:'status' — is a window open, what ROM/frame it shows, and `activeHostMatchesWindow` (false = a build/" +
363
415
  "loadMedia swapped the active host, so frame({op:'screenshot'}) no longer shows what the human sees — use op:'framebuffer').\n" +