romdevtools 0.27.0 → 0.29.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 +56 -44
- package/CHANGELOG.md +355 -0
- package/README.md +4 -4
- package/examples/README.md +7 -7
- package/examples/atari2600/templates/platformer.asm +1227 -325
- package/examples/atari2600/templates/puzzle.asm +1056 -0
- package/examples/atari2600/templates/racing.asm +909 -257
- package/examples/atari2600/templates/shmup.asm +1035 -218
- package/examples/atari2600/templates/sports.asm +1143 -229
- package/examples/atari7800/templates/hello_sprite.c +8 -4
- package/examples/atari7800/templates/platformer.c +991 -152
- package/examples/atari7800/templates/puzzle.c +1091 -145
- package/examples/atari7800/templates/racing.c +949 -118
- package/examples/atari7800/templates/shmup.c +812 -130
- package/examples/atari7800/templates/sports.c +820 -181
- package/examples/c64/templates/platformer.c +876 -157
- package/examples/c64/templates/puzzle.c +881 -143
- package/examples/c64/templates/racing.c +873 -88
- package/examples/c64/templates/shmup.c +762 -154
- package/examples/c64/templates/sports.c +755 -95
- package/examples/gb/templates/platformer.c +841 -175
- package/examples/gb/templates/puzzle.c +1094 -176
- package/examples/gb/templates/racing.c +761 -169
- package/examples/gb/templates/shmup.c +679 -169
- package/examples/gb/templates/sports.c +790 -153
- package/examples/gba/templates/platformer.c +624 -169
- package/examples/gba/templates/puzzle.c +535 -207
- package/examples/gba/templates/racing.c +513 -196
- package/examples/gba/templates/shmup.c +565 -168
- package/examples/gba/templates/sports.c +454 -162
- package/examples/gbc/templates/platformer.c +944 -176
- package/examples/gbc/templates/puzzle.c +1131 -177
- package/examples/gbc/templates/racing.c +891 -175
- package/examples/gbc/templates/shmup.c +827 -179
- package/examples/gbc/templates/sports.c +870 -156
- package/examples/genesis/templates/platformer.c +747 -129
- package/examples/genesis/templates/puzzle.c +702 -208
- package/examples/genesis/templates/racing.c +728 -193
- package/examples/genesis/templates/shmup.c +535 -142
- package/examples/genesis/templates/shmup_2p.c +13 -1
- package/examples/genesis/templates/sports.c +495 -158
- package/examples/gg/templates/platformer.c +883 -214
- package/examples/gg/templates/puzzle.c +906 -181
- package/examples/gg/templates/racing.c +919 -160
- package/examples/gg/templates/shmup.c +716 -177
- package/examples/gg/templates/sports.c +735 -128
- package/examples/lynx/templates/platformer.c +604 -50
- package/examples/lynx/templates/puzzle.c +533 -130
- package/examples/lynx/templates/racing.c +538 -102
- package/examples/lynx/templates/shmup.c +461 -122
- package/examples/lynx/templates/sports.c +496 -69
- package/examples/msx/platformer/main.c +648 -159
- package/examples/msx/puzzle/main.c +750 -185
- package/examples/msx/racing/main.c +669 -177
- package/examples/msx/shmup/main.c +460 -177
- package/examples/msx/sports/main.c +591 -124
- package/examples/nes/templates/platformer.c +586 -160
- package/examples/nes/templates/puzzle.c +603 -222
- package/examples/nes/templates/racing.c +505 -197
- package/examples/nes/templates/shmup.c +339 -144
- package/examples/nes/templates/sports.c +341 -182
- package/examples/pce/platformer/main.c +875 -204
- package/examples/pce/puzzle/main.c +797 -216
- package/examples/pce/racing/main.c +782 -206
- package/examples/pce/shmup/main.c +638 -211
- package/examples/pce/sports/main.c +585 -167
- package/examples/porting-across-platforms/README.md +1 -1
- package/examples/sms/templates/platformer.c +765 -176
- package/examples/sms/templates/puzzle.c +783 -177
- package/examples/sms/templates/racing.c +812 -133
- package/examples/sms/templates/shmup.c +601 -148
- package/examples/sms/templates/shmup_2p.c +17 -1
- package/examples/sms/templates/sports.c +633 -121
- package/examples/snes/templates/music_demo.c +7 -0
- package/examples/snes/templates/platformer-data.asm +123 -24
- package/examples/snes/templates/platformer-hdr.asm +57 -0
- package/examples/snes/templates/platformer.c +587 -149
- package/examples/snes/templates/puzzle-data.asm +116 -21
- package/examples/snes/templates/puzzle-hdr.asm +57 -0
- package/examples/snes/templates/puzzle.c +632 -185
- package/examples/snes/templates/racing-data.asm +390 -32
- package/examples/snes/templates/racing-hdr.asm +57 -0
- package/examples/snes/templates/racing.c +807 -177
- package/examples/snes/templates/shmup-data.asm +87 -29
- package/examples/snes/templates/shmup-hdr.asm +57 -0
- package/examples/snes/templates/shmup.c +459 -180
- package/examples/snes/templates/sports-data.asm +48 -2
- package/examples/snes/templates/sports-hdr.asm +57 -0
- package/examples/snes/templates/sports.c +414 -156
- 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 +304 -11
- package/src/http/tool-registry.js +11 -11
- package/src/mcp/server.js +6 -0
- package/src/mcp/tools/cheats.js +2 -1
- 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 +14 -6
- package/src/mcp/tools/index.js +18 -4
- package/src/mcp/tools/input.js +31 -7
- package/src/mcp/tools/lifecycle.js +6 -4
- package/src/mcp/tools/memory.js +208 -39
- package/src/mcp/tools/platform-docs.js +1 -1
- package/src/mcp/tools/playtest.js +56 -4
- package/src/mcp/tools/preview-tile.js +6 -2
- package/src/mcp/tools/project.js +1114 -120
- package/src/mcp/tools/rom-id.js +5 -1
- package/src/mcp/tools/run-until.js +4 -2
- package/src/mcp/tools/snippets.js +6 -6
- package/src/mcp/tools/sprite-pipeline.js +14 -2
- package/src/mcp/tools/state.js +2 -1
- package/src/mcp/tools/tile-inspect.js +8 -1
- package/src/mcp/tools/toolchain.js +55 -11
- package/src/mcp/tools/watch-memory.js +145 -27
- package/src/observer/bus.js +73 -0
- package/src/observer/livestream.html +4 -2
- package/src/observer/tool-wrap.js +17 -14
- 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 +32 -11
- package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
- package/src/platforms/c64/MENTAL_MODEL.md +11 -4
- package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
- package/src/platforms/gb/MENTAL_MODEL.md +19 -4
- package/src/platforms/gb/TROUBLESHOOTING.md +101 -6
- package/src/platforms/gb/lib/c/README.md +10 -11
- package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
- package/src/platforms/gb/lib/c/patch-header.js +19 -6
- package/src/platforms/gba/MENTAL_MODEL.md +4 -4
- package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
- package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
- package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
- package/src/platforms/gbc/MENTAL_MODEL.md +16 -4
- package/src/platforms/gbc/TROUBLESHOOTING.md +24 -3
- package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
- package/src/platforms/gbc/lib/c/README.md +10 -11
- package/src/platforms/gbc/lib/c/font.h +43 -0
- package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
- package/src/platforms/gbc/lib/c/patch-header.js +19 -6
- package/src/platforms/genesis/MENTAL_MODEL.md +43 -9
- package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
- 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/MENTAL_MODEL.md +4 -4
- package/src/platforms/gg/TROUBLESHOOTING.md +14 -18
- package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
- package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
- package/src/platforms/gg/lib/c/joypad_read.c +29 -0
- package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
- package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
- 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 +11 -5
- 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 +3 -0
- package/src/platforms/msx/lib/c/msx_vdp.c +70 -0
- package/src/platforms/nes/MENTAL_MODEL.md +12 -5
- package/src/platforms/nes/lib/c/nes_runtime.c +190 -34
- package/src/platforms/nes/lib/c/nes_runtime.h +35 -0
- package/src/platforms/pce/MENTAL_MODEL.md +14 -5
- package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
- package/src/platforms/pce/lib/c/pce_hw.h +13 -1
- package/src/platforms/pce/lib/c/pce_sound.c +22 -0
- package/src/platforms/pce/lib/c/pce_video.c +32 -0
- package/src/platforms/sms/MENTAL_MODEL.md +11 -6
- 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 +7 -2
- package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
- package/src/playtest/playtest.js +73 -3
- package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
- package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
- package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
- package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
- package/src/toolchains/index.js +64 -19
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";
|
|
@@ -87,7 +87,7 @@ const CATEGORIES = [
|
|
|
87
87
|
{
|
|
88
88
|
name: "platforms",
|
|
89
89
|
description: "Discover supported platforms, their cores, toolchains, and language matrices.",
|
|
90
|
-
useWhen: ["
|
|
90
|
+
useWhen: ["before forking an example for a new game", "checking which platforms are available", "looking up a platform's default language"],
|
|
91
91
|
register: (s, z, k) => registerPlatformTools(s, z, k), // listPlatforms, resolvePlatform
|
|
92
92
|
},
|
|
93
93
|
{
|
|
@@ -138,8 +138,8 @@ const CATEGORIES = [
|
|
|
138
138
|
},
|
|
139
139
|
{
|
|
140
140
|
name: "project",
|
|
141
|
-
description: "
|
|
142
|
-
useWhen: ["starting a new game
|
|
141
|
+
description: "The example-game library (fork/list/show) + starter snippets per platform.",
|
|
142
|
+
useWhen: ["starting a new game (ALWAYS fork the nearest example — never a blank file)", "looking up canonical patterns like NMI handler, OAM DMA, joypad read"],
|
|
143
143
|
register: (s, z, k) => { registerProjectTools(s, z, k); registerSnippetTools(s, z, k); registerPlatformDocsTools(s, z); },
|
|
144
144
|
},
|
|
145
145
|
{
|
|
@@ -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,17 @@
|
|
|
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
|
+
import { attachObserverFrame } from "./watch-memory.js";
|
|
6
|
+
|
|
7
|
+
// Spreadable co-drive conflict marker for every input-driving op: while a
|
|
8
|
+
// human is actively playing in this session's playtest window, their input
|
|
9
|
+
// overwrites the agent's each tick — so the agent must be TOLD its press/set
|
|
10
|
+
// may not take. Empty (no field) when there's no conflict.
|
|
11
|
+
function coDriveFields(sessionKey) {
|
|
12
|
+
const warning = humanCoDriveWarning(sessionKey);
|
|
13
|
+
return warning ? { humanCoDriveWarning: warning } : {};
|
|
14
|
+
}
|
|
4
15
|
|
|
5
16
|
// Resolve a platform-native button alias to the libretro button the host
|
|
6
17
|
// understands. Genesis pads have A/B/C (+ X/Y/Z on 6-button) which libretro
|
|
@@ -103,6 +114,7 @@ function inputSetCore({ ports }, sessionKey) {
|
|
|
103
114
|
...(ignoredButtons.length
|
|
104
115
|
? { ignoredButtons, ignoredNote: `Ignored ${ignoredButtons.length} unknown button name(s) — not pressed. Valid: ${[...KNOWN_BUTTONS].join(", ")}.` }
|
|
105
116
|
: {}),
|
|
117
|
+
...coDriveFields(sessionKey),
|
|
106
118
|
};
|
|
107
119
|
}
|
|
108
120
|
|
|
@@ -110,6 +122,13 @@ function inputSetCore({ ports }, sessionKey) {
|
|
|
110
122
|
function inputPressCore({ button, frames = 2, port: p = 0 }, sessionKey) {
|
|
111
123
|
const host = getHost(sessionKey);
|
|
112
124
|
const resolved = resolveButtonAlias(button, host.status.platform);
|
|
125
|
+
// GUARANTEE a released->pressed EDGE. If the button is already held
|
|
126
|
+
// (a prior input({op:'set'}) or an overlapping schedule), the game's
|
|
127
|
+
// newpress detector never fires and the press silently does nothing —
|
|
128
|
+
// the "one-shot press didn't pause the game" report (0.27.0 #7).
|
|
129
|
+
// One released frame first makes the edge unconditional.
|
|
130
|
+
host.setInput({ ports: [{}, {}] });
|
|
131
|
+
host.stepFrames(1);
|
|
113
132
|
const pressed = { ports: [{}, {}] };
|
|
114
133
|
pressed.ports[p][resolved] = true;
|
|
115
134
|
host.setInput(pressed);
|
|
@@ -121,8 +140,10 @@ function inputPressCore({ button, frames = 2, port: p = 0 }, sessionKey) {
|
|
|
121
140
|
...(resolved !== button ? { resolvedTo: resolved } : {}),
|
|
122
141
|
frames,
|
|
123
142
|
releaseFrames: 1,
|
|
124
|
-
|
|
143
|
+
preReleaseFrames: 1,
|
|
144
|
+
framesStepped: frames + 2,
|
|
125
145
|
frameCount: host.status.frameCount,
|
|
146
|
+
...coDriveFields(sessionKey),
|
|
126
147
|
};
|
|
127
148
|
}
|
|
128
149
|
|
|
@@ -135,7 +156,7 @@ function inputSequenceCore({ steps }, sessionKey) {
|
|
|
135
156
|
host.stepFrames(step.frames);
|
|
136
157
|
total += step.frames;
|
|
137
158
|
}
|
|
138
|
-
return { stepsRun: steps.length, framesRun: total, frameCount: host.status.frameCount };
|
|
159
|
+
return { stepsRun: steps.length, framesRun: total, frameCount: host.status.frameCount, ...coDriveFields(sessionKey) };
|
|
139
160
|
}
|
|
140
161
|
|
|
141
162
|
/** op:'navigate' — drive menus by advancing on SCREEN CHANGE; reports consumed per step. */
|
|
@@ -178,6 +199,7 @@ function inputNavigateCore({ steps }, sessionKey) {
|
|
|
178
199
|
framesRun: totalFrames,
|
|
179
200
|
frameCount: host.status.frameCount,
|
|
180
201
|
...(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.` } : {}),
|
|
202
|
+
...coDriveFields(sessionKey),
|
|
181
203
|
};
|
|
182
204
|
}
|
|
183
205
|
|
|
@@ -199,7 +221,9 @@ export function registerInputTools(server, z, sessionKey) {
|
|
|
199
221
|
"The held state is honored by frame({op:'step'}) AND by watch/breakpoint runs that have NO `pressDuring` " +
|
|
200
222
|
"schedule (they inherit it). If a watch/breakpoint IS given `pressDuring`, that schedule OWNS the pad for " +
|
|
201
223
|
"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)
|
|
224
|
+
"'press': press one named `button` for `frames` then release (port 0 default). Runs ONE released frame " +
|
|
225
|
+
"first so edge-triggered handlers (START pause, menu confirm) always see a fresh newpress even if the " +
|
|
226
|
+
"button was already held by a prior set.\n" +
|
|
203
227
|
"'sequence': scripted frame-by-frame `steps:[{input:{ports}, frames}]` for replays/tests.\n" +
|
|
204
228
|
"'navigate': walk a menu by advancing on SCREEN CHANGE — `steps:[{button, holdFrames?, maxWaitFrames?, " +
|
|
205
229
|
"settleFrames?}]`; reports `consumed` per step (false = the screen never reacted: wrong screen / press dropped / " +
|
|
@@ -234,21 +258,21 @@ export function registerInputTools(server, z, sessionKey) {
|
|
|
234
258
|
switch (args.op) {
|
|
235
259
|
case "set": {
|
|
236
260
|
if (!args.ports) throw new Error("input({op:'set'}): `ports` is required.");
|
|
237
|
-
return jsonContent(inputSetCore(args, sessionKey));
|
|
261
|
+
return attachObserverFrame(jsonContent(inputSetCore(args, sessionKey)), getHost(sessionKey), "input set");
|
|
238
262
|
}
|
|
239
263
|
case "press": {
|
|
240
264
|
if (!args.button) throw new Error("input({op:'press'}): `button` is required.");
|
|
241
|
-
return jsonContent(inputPressCore(args, sessionKey));
|
|
265
|
+
return attachObserverFrame(jsonContent(inputPressCore(args, sessionKey)), getHost(sessionKey), `press ${args.button}`);
|
|
242
266
|
}
|
|
243
267
|
case "sequence": {
|
|
244
268
|
if (!args.steps) throw new Error("input({op:'sequence'}): `steps` is required.");
|
|
245
|
-
return jsonContent(inputSequenceCore(args, sessionKey));
|
|
269
|
+
return attachObserverFrame(jsonContent(inputSequenceCore(args, sessionKey)), getHost(sessionKey), "input sequence");
|
|
246
270
|
}
|
|
247
271
|
case "navigate": {
|
|
248
272
|
if (!args.steps) throw new Error("input({op:'navigate'}): `steps` is required.");
|
|
249
273
|
// Fill per-step defaults the old navigate schema provided.
|
|
250
274
|
const steps = args.steps.map((s) => ({ holdFrames: 2, maxWaitFrames: 120, settleFrames: 2, ...s }));
|
|
251
|
-
return jsonContent(inputNavigateCore({ steps }, sessionKey));
|
|
275
|
+
return attachObserverFrame(jsonContent(inputNavigateCore({ steps }, sessionKey)), getHost(sessionKey), "navigate");
|
|
252
276
|
}
|
|
253
277
|
case "layout": {
|
|
254
278
|
if (!args.platform) throw new Error("input({op:'layout'}): `platform` is required.");
|
|
@@ -2,6 +2,7 @@ import { resolveCore } from "../../cores/registry.js";
|
|
|
2
2
|
import { clearHost, getHost, getHostOrNull, rememberLastMedia, resetHost } from "../state.js";
|
|
3
3
|
import { jsonContent, safeTool, textContent } from "../util.js";
|
|
4
4
|
import { resolveCheatCodeForApply } from "./cheats.js";
|
|
5
|
+
import { attachObserverFrame } from "./watch-memory.js";
|
|
5
6
|
|
|
6
7
|
const MEDIA_KINDS = ["cartridge", "disk", "tape", "program"];
|
|
7
8
|
|
|
@@ -58,7 +59,8 @@ export function registerLifecycleTools(server, z, sessionKey) {
|
|
|
58
59
|
// on dimensions, so we omit it until a frame has been stepped and point the
|
|
59
60
|
// caller at stepFrames instead.
|
|
60
61
|
const framebufferKnown = host.status.frameCount > 0;
|
|
61
|
-
|
|
62
|
+
// Livestream: show what just loaded (the boot frame).
|
|
63
|
+
return attachObserverFrame(jsonContent({
|
|
62
64
|
loaded: true,
|
|
63
65
|
platform,
|
|
64
66
|
core: resolved.coreName,
|
|
@@ -68,7 +70,7 @@ export function registerLifecycleTools(server, z, sessionKey) {
|
|
|
68
70
|
? { framebuffer: { width: host.status.fbWidth, height: host.status.fbHeight } }
|
|
69
71
|
: { framebufferNote: "Framebuffer dimensions are unknown until the core runs — call stepFrames first, then getStatus (the pre-boot default does not match the real output resolution)." }),
|
|
70
72
|
...(appliedCheats ? { cheats: appliedCheats } : {}),
|
|
71
|
-
});
|
|
73
|
+
}), host, `loaded ${host.status.mediaPath ? host.status.mediaPath.split("/").pop() : platform}`);
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
server.tool(
|
|
@@ -125,10 +127,10 @@ export function registerLifecycleTools(server, z, sessionKey) {
|
|
|
125
127
|
const host = getHost(sessionKey);
|
|
126
128
|
if (hard) {
|
|
127
129
|
const reloaded = await host.hardReset();
|
|
128
|
-
return textContent(reloaded ? "reset (hard / power-cycle — RAM cleared)" : "reset (soft — no cached ROM to reload for a hard reset)");
|
|
130
|
+
return attachObserverFrame(textContent(reloaded ? "reset (hard / power-cycle — RAM cleared)" : "reset (soft — no cached ROM to reload for a hard reset)"), host, "reset (hard)");
|
|
129
131
|
}
|
|
130
132
|
host.reset();
|
|
131
|
-
return textContent("reset (soft — RESET button; work RAM persists, use hard:true to clear it)");
|
|
133
|
+
return attachObserverFrame(textContent("reset (soft — RESET button; work RAM persists, use hard:true to clear it)"), host, "reset");
|
|
132
134
|
}
|
|
133
135
|
case "pause":
|
|
134
136
|
getHost(sessionKey).pause();
|
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);
|
|
@@ -69,7 +69,7 @@ export async function listPlatformDocsCore({ platform }) {
|
|
|
69
69
|
platform,
|
|
70
70
|
docs,
|
|
71
71
|
note: docs.length === 0
|
|
72
|
-
? `No docs shipped for '${platform}' yet. Try a different platform or
|
|
72
|
+
? `No docs shipped for '${platform}' yet. Try a different platform, or fork an example game (examples({op:'fork'})) for boilerplate. (For RE/patching workflow, see platform({op:'doc', platform:'romhacking', name:'playbook'}).)`
|
|
73
73
|
: `Call platform({op:'doc', platform, name}) to read one. 'name' is 'mental_model' or 'troubleshooting'. For RE/patching workflow across platforms, see platform({op:'doc', platform:'romhacking', name:'playbook'}).`,
|
|
74
74
|
};
|
|
75
75
|
}
|