romdevtools 0.27.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.
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +309 -0
- package/README.md +1 -1
- package/examples/README.md +1 -1
- package/examples/atari2600/templates/platformer.asm +18 -9
- package/examples/atari2600/templates/racing.asm +25 -4
- package/examples/atari2600/templates/shmup.asm +30 -5
- package/examples/atari2600/templates/sports.asm +41 -9
- package/examples/atari7800/templates/hello_sprite.c +8 -4
- package/examples/atari7800/templates/platformer.c +12 -8
- package/examples/atari7800/templates/puzzle.c +7 -4
- package/examples/atari7800/templates/racing.c +5 -2
- package/examples/atari7800/templates/shmup.c +8 -4
- package/examples/atari7800/templates/sports.c +6 -3
- package/examples/c64/templates/platformer.c +28 -24
- package/examples/c64/templates/puzzle.c +77 -16
- package/examples/c64/templates/racing.c +9 -0
- package/examples/c64/templates/shmup.c +13 -1
- package/examples/c64/templates/sports.c +9 -4
- package/examples/gb/templates/platformer.c +6 -2
- package/examples/gb/templates/puzzle.c +279 -101
- package/examples/gb/templates/racing.c +13 -1
- package/examples/gb/templates/shmup.c +13 -1
- package/examples/gb/templates/sports.c +9 -3
- package/examples/gba/templates/platformer.c +7 -13
- package/examples/gba/templates/puzzle.c +93 -15
- package/examples/gba/templates/racing.c +13 -1
- package/examples/gba/templates/shmup.c +13 -1
- package/examples/gba/templates/sports.c +17 -5
- package/examples/gbc/templates/platformer.c +6 -2
- package/examples/gbc/templates/puzzle.c +878 -178
- package/examples/gbc/templates/racing.c +13 -1
- package/examples/gbc/templates/shmup.c +13 -1
- package/examples/gbc/templates/sports.c +9 -3
- package/examples/genesis/templates/puzzle.c +76 -15
- package/examples/genesis/templates/racing.c +13 -1
- package/examples/genesis/templates/shmup_2p.c +13 -1
- package/examples/gg/templates/platformer.c +4 -0
- package/examples/gg/templates/puzzle.c +80 -14
- package/examples/gg/templates/racing.c +17 -1
- package/examples/gg/templates/shmup.c +17 -1
- package/examples/gg/templates/sports.c +4 -0
- package/examples/lynx/templates/platformer.c +25 -6
- package/examples/lynx/templates/puzzle.c +77 -14
- package/examples/lynx/templates/shmup.c +13 -1
- package/examples/lynx/templates/sports.c +5 -2
- package/examples/msx/platformer/main.c +2 -0
- package/examples/msx/puzzle/main.c +78 -15
- package/examples/msx/racing/main.c +1 -0
- package/examples/msx/shmup/main.c +1 -0
- package/examples/msx/sports/main.c +3 -2
- package/examples/nes/templates/platformer.c +11 -3
- package/examples/nes/templates/puzzle.c +81 -21
- package/examples/nes/templates/racing.c +15 -1
- package/examples/nes/templates/shmup.c +1 -0
- package/examples/nes/templates/sports.c +1 -0
- package/examples/pce/platformer/main.c +3 -1
- package/examples/pce/puzzle/main.c +78 -12
- package/examples/pce/racing/main.c +1 -0
- package/examples/pce/shmup/main.c +5 -4
- package/examples/pce/sports/main.c +4 -3
- package/examples/sms/templates/platformer.c +4 -0
- package/examples/sms/templates/puzzle.c +80 -14
- package/examples/sms/templates/racing.c +17 -1
- package/examples/sms/templates/shmup.c +17 -1
- package/examples/sms/templates/shmup_2p.c +17 -1
- package/examples/sms/templates/sports.c +4 -0
- package/examples/snes/templates/platformer.c +32 -15
- package/examples/snes/templates/puzzle.c +84 -16
- package/examples/snes/templates/racing.c +20 -1
- package/examples/snes/templates/shmup.c +20 -2
- package/examples/snes/templates/sports.c +7 -0
- package/package.json +12 -12
- package/src/cores/wasm/bluemsx_libretro.js +1 -1
- package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
- package/src/cores/wasm/fceumm_libretro.js +1 -1
- package/src/cores/wasm/fceumm_libretro.wasm +0 -0
- package/src/cores/wasm/gambatte_libretro.js +1 -1
- package/src/cores/wasm/gambatte_libretro.wasm +0 -0
- package/src/cores/wasm/geargrafx_libretro.js +1 -1
- package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
- package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
- package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
- package/src/cores/wasm/handy_libretro.js +1 -1
- package/src/cores/wasm/handy_libretro.wasm +0 -0
- package/src/cores/wasm/mgba_libretro.js +1 -1
- package/src/cores/wasm/mgba_libretro.wasm +0 -0
- package/src/cores/wasm/prosystem_libretro.js +1 -1
- package/src/cores/wasm/prosystem_libretro.wasm +0 -0
- package/src/cores/wasm/snes9x_libretro.js +1 -1
- package/src/cores/wasm/snes9x_libretro.wasm +0 -0
- package/src/cores/wasm/stella2014_libretro.js +1 -1
- package/src/cores/wasm/stella2014_libretro.wasm +0 -0
- package/src/cores/wasm/vice_x64_libretro.js +1 -1
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +245 -10
- package/src/mcp/server.js +6 -0
- package/src/mcp/tools/disasm-rebuild.js +315 -65
- package/src/mcp/tools/disasm.js +149 -28
- package/src/mcp/tools/find-references.js +216 -51
- package/src/mcp/tools/frame.js +11 -4
- package/src/mcp/tools/index.js +15 -1
- package/src/mcp/tools/input.js +26 -3
- package/src/mcp/tools/memory.js +208 -39
- package/src/mcp/tools/playtest.js +56 -4
- package/src/mcp/tools/project.js +35 -9
- package/src/mcp/tools/toolchain.js +43 -10
- package/src/mcp/tools/watch-memory.js +141 -24
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
- package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
- package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
- package/src/platforms/atari7800/MENTAL_MODEL.md +27 -6
- package/src/platforms/gb/MENTAL_MODEL.md +16 -1
- package/src/platforms/gb/TROUBLESHOOTING.md +42 -0
- package/src/platforms/gb/lib/c/patch-header.js +7 -4
- package/src/platforms/gbc/MENTAL_MODEL.md +12 -0
- package/src/platforms/gbc/TROUBLESHOOTING.md +21 -0
- package/src/platforms/gbc/lib/c/font.h +43 -0
- package/src/platforms/gbc/lib/c/patch-header.js +7 -4
- package/src/platforms/genesis/MENTAL_MODEL.md +40 -6
- package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
- package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
- package/src/platforms/gg/TROUBLESHOOTING.md +13 -17
- package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
- package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
- package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
- package/src/platforms/msx/MENTAL_MODEL.md +6 -0
- package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
- package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
- package/src/platforms/msx/lib/c/msx_hw.h +2 -0
- package/src/platforms/msx/lib/c/msx_vdp.c +45 -0
- package/src/platforms/nes/MENTAL_MODEL.md +10 -3
- package/src/platforms/nes/lib/c/nes_runtime.c +41 -0
- package/src/platforms/nes/lib/c/nes_runtime.h +2 -0
- package/src/platforms/pce/MENTAL_MODEL.md +9 -0
- package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
- package/src/platforms/pce/lib/c/pce_hw.h +2 -1
- package/src/platforms/pce/lib/c/pce_sound.c +22 -0
- package/src/platforms/sms/MENTAL_MODEL.md +5 -0
- package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
- package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
- package/src/platforms/snes/MENTAL_MODEL.md +5 -0
- package/src/playtest/playtest.js +73 -3
- package/src/toolchains/index.js +37 -8
package/src/mcp/tools/index.js
CHANGED
|
@@ -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
|
});
|
package/src/mcp/tools/input.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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 / " +
|
package/src/mcp/tools/memory.js
CHANGED
|
@@ -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
|
|
146
|
-
//
|
|
147
|
-
//
|
|
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
|
-
|
|
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++)
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
|
425
|
+
const digitStr = String(value >>> 0);
|
|
426
|
+
const s = { region, size, little, as, digitLen: digitStr.length };
|
|
298
427
|
const candidates = [];
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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) =>
|
|
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).
|
|
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
|
-
: "
|
|
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 = (
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:search — value 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
|
|
358
|
-
"
|
|
359
|
-
"
|
|
360
|
-
"
|
|
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" +
|