romdevtools 0.28.0 → 0.30.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 +53 -43
- package/CHANGELOG.md +91 -0
- package/README.md +3 -3
- package/examples/README.md +7 -7
- package/examples/atari2600/templates/platformer.asm +1225 -332
- package/examples/atari2600/templates/puzzle.asm +1056 -0
- package/examples/atari2600/templates/racing.asm +906 -275
- package/examples/atari2600/templates/shmup.asm +1031 -239
- package/examples/atari2600/templates/sports.asm +1135 -253
- package/examples/atari7800/templates/platformer.c +991 -156
- package/examples/atari7800/templates/puzzle.c +1091 -148
- package/examples/atari7800/templates/racing.c +952 -124
- package/examples/atari7800/templates/shmup.c +812 -134
- package/examples/atari7800/templates/sports.c +820 -184
- package/examples/c64/templates/platformer.c +879 -164
- package/examples/c64/templates/puzzle.c +855 -178
- package/examples/c64/templates/racing.c +873 -97
- package/examples/c64/templates/shmup.c +757 -161
- package/examples/c64/templates/sports.c +755 -100
- package/examples/gb/templates/platformer.c +841 -179
- package/examples/gb/templates/puzzle.c +986 -246
- package/examples/gb/templates/racing.c +754 -174
- package/examples/gb/templates/shmup.c +673 -175
- package/examples/gb/templates/sports.c +790 -159
- package/examples/gba/templates/platformer.c +626 -165
- package/examples/gba/templates/puzzle.c +519 -269
- package/examples/gba/templates/racing.c +511 -206
- package/examples/gba/templates/shmup.c +564 -179
- package/examples/gba/templates/sports.c +454 -174
- package/examples/gbc/templates/platformer.c +944 -180
- package/examples/gbc/templates/puzzle.c +363 -109
- package/examples/gbc/templates/racing.c +884 -180
- package/examples/gbc/templates/shmup.c +821 -185
- package/examples/gbc/templates/sports.c +870 -162
- package/examples/genesis/templates/platformer.c +747 -129
- package/examples/genesis/templates/puzzle.c +694 -261
- package/examples/genesis/templates/racing.c +726 -203
- package/examples/genesis/templates/shmup.c +535 -142
- package/examples/genesis/templates/sports.c +495 -158
- package/examples/gg/templates/platformer.c +880 -215
- package/examples/gg/templates/puzzle.c +875 -216
- package/examples/gg/templates/racing.c +915 -172
- package/examples/gg/templates/shmup.c +714 -191
- package/examples/gg/templates/sports.c +732 -129
- package/examples/lynx/templates/platformer.c +604 -69
- package/examples/lynx/templates/puzzle.c +498 -158
- package/examples/lynx/templates/racing.c +538 -102
- package/examples/lynx/templates/shmup.c +458 -131
- package/examples/lynx/templates/sports.c +496 -72
- package/examples/msx/platformer/main.c +649 -162
- package/examples/msx/puzzle/main.c +742 -240
- package/examples/msx/racing/main.c +669 -178
- package/examples/msx/shmup/main.c +460 -178
- package/examples/msx/sports/main.c +592 -126
- package/examples/nes/templates/platformer.c +589 -171
- package/examples/nes/templates/puzzle.c +563 -242
- package/examples/nes/templates/racing.c +502 -208
- package/examples/nes/templates/shmup.c +339 -145
- package/examples/nes/templates/sports.c +341 -183
- package/examples/pce/platformer/main.c +874 -205
- package/examples/pce/puzzle/main.c +802 -287
- package/examples/pce/racing/main.c +783 -208
- package/examples/pce/shmup/main.c +638 -212
- package/examples/pce/sports/main.c +586 -169
- package/examples/porting-across-platforms/README.md +1 -1
- package/examples/sms/templates/platformer.c +762 -177
- package/examples/sms/templates/puzzle.c +752 -212
- package/examples/sms/templates/racing.c +808 -145
- package/examples/sms/templates/shmup.c +599 -162
- package/examples/sms/templates/sports.c +630 -122
- 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 +586 -165
- package/examples/snes/templates/puzzle-data.asm +116 -21
- package/examples/snes/templates/puzzle-hdr.asm +57 -0
- package/examples/snes/templates/puzzle.c +614 -235
- 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 -196
- 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 -198
- 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 -163
- 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 +84 -8
- package/src/http/tool-registry.js +11 -11
- package/src/mcp/tools/cheats.js +2 -1
- package/src/mcp/tools/frame.js +3 -2
- package/src/mcp/tools/index.js +3 -3
- package/src/mcp/tools/input.js +5 -4
- package/src/mcp/tools/lifecycle.js +6 -4
- package/src/mcp/tools/memory.js +131 -24
- package/src/mcp/tools/platform-docs.js +1 -1
- package/src/mcp/tools/preview-tile.js +6 -2
- package/src/mcp/tools/project.js +1098 -130
- package/src/mcp/tools/record.js +6 -7
- package/src/mcp/tools/rom-id.js +5 -1
- package/src/mcp/tools/run-until.js +12 -4
- 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 +12 -1
- package/src/mcp/tools/watch-memory.js +53 -10
- 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 +32 -3
- package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
- 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 +3 -3
- package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
- 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 +13 -3
- 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 +4 -4
- package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
- 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/gb_crt0.s +26 -3
- package/src/platforms/gbc/lib/c/patch-header.js +13 -3
- package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
- package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
- package/src/platforms/gg/MENTAL_MODEL.md +4 -4
- package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
- package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
- 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/msx/MENTAL_MODEL.md +5 -5
- package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
- package/src/platforms/msx/lib/c/msx_hw.h +1 -0
- package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
- package/src/platforms/nes/MENTAL_MODEL.md +2 -2
- package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
- package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
- package/src/platforms/pce/MENTAL_MODEL.md +5 -5
- package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
- package/src/platforms/pce/lib/c/pce_hw.h +11 -0
- package/src/platforms/pce/lib/c/pce_video.c +32 -0
- package/src/platforms/sms/MENTAL_MODEL.md +6 -6
- package/src/platforms/snes/MENTAL_MODEL.md +2 -2
- package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
- 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 +27 -11
package/src/mcp/tools/frame.js
CHANGED
|
@@ -249,12 +249,13 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
249
249
|
// actively playing in the playtest window means this step raced their
|
|
250
250
|
// real-time loop. Field only appears when the conflict is real.
|
|
251
251
|
const coDrive = humanCoDriveWarning(sessionKey);
|
|
252
|
-
|
|
252
|
+
// Livestream: the post-step frame (throttled to 1/2s per tool by the bus).
|
|
253
|
+
return attachObserverFrame(jsonContent({
|
|
253
254
|
framesRun: n,
|
|
254
255
|
frameCount: host.status.frameCount,
|
|
255
256
|
framebuffer: { width: host.status.fbWidth, height: host.status.fbHeight },
|
|
256
257
|
...(coDrive ? { humanCoDriveWarning: coDrive } : {}),
|
|
257
|
-
});
|
|
258
|
+
}), host, `step ×${n}`);
|
|
258
259
|
}
|
|
259
260
|
|
|
260
261
|
// Contract: an image goes to disk (path) OR comes back inline (inline:true).
|
package/src/mcp/tools/index.js
CHANGED
|
@@ -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
|
{
|
package/src/mcp/tools/input.js
CHANGED
|
@@ -2,6 +2,7 @@ import { getHost } from "../state.js";
|
|
|
2
2
|
import { jsonContent, safeTool } from "../util.js";
|
|
3
3
|
import { getInputLayoutCore } from "./input-layout.js";
|
|
4
4
|
import { humanCoDriveWarning } from "./playtest.js";
|
|
5
|
+
import { attachObserverFrame } from "./watch-memory.js";
|
|
5
6
|
|
|
6
7
|
// Spreadable co-drive conflict marker for every input-driving op: while a
|
|
7
8
|
// human is actively playing in this session's playtest window, their input
|
|
@@ -257,21 +258,21 @@ export function registerInputTools(server, z, sessionKey) {
|
|
|
257
258
|
switch (args.op) {
|
|
258
259
|
case "set": {
|
|
259
260
|
if (!args.ports) throw new Error("input({op:'set'}): `ports` is required.");
|
|
260
|
-
return jsonContent(inputSetCore(args, sessionKey));
|
|
261
|
+
return attachObserverFrame(jsonContent(inputSetCore(args, sessionKey)), getHost(sessionKey), "input set");
|
|
261
262
|
}
|
|
262
263
|
case "press": {
|
|
263
264
|
if (!args.button) throw new Error("input({op:'press'}): `button` is required.");
|
|
264
|
-
return jsonContent(inputPressCore(args, sessionKey));
|
|
265
|
+
return attachObserverFrame(jsonContent(inputPressCore(args, sessionKey)), getHost(sessionKey), `press ${args.button}`);
|
|
265
266
|
}
|
|
266
267
|
case "sequence": {
|
|
267
268
|
if (!args.steps) throw new Error("input({op:'sequence'}): `steps` is required.");
|
|
268
|
-
return jsonContent(inputSequenceCore(args, sessionKey));
|
|
269
|
+
return attachObserverFrame(jsonContent(inputSequenceCore(args, sessionKey)), getHost(sessionKey), "input sequence");
|
|
269
270
|
}
|
|
270
271
|
case "navigate": {
|
|
271
272
|
if (!args.steps) throw new Error("input({op:'navigate'}): `steps` is required.");
|
|
272
273
|
// Fill per-step defaults the old navigate schema provided.
|
|
273
274
|
const steps = args.steps.map((s) => ({ holdFrames: 2, maxWaitFrames: 120, settleFrames: 2, ...s }));
|
|
274
|
-
return jsonContent(inputNavigateCore({ steps }, sessionKey));
|
|
275
|
+
return attachObserverFrame(jsonContent(inputNavigateCore({ steps }, sessionKey)), getHost(sessionKey), "navigate");
|
|
275
276
|
}
|
|
276
277
|
case "layout": {
|
|
277
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
|
@@ -3,6 +3,7 @@ import { MemoryRegionToRetro } from "../../host/types.js";
|
|
|
3
3
|
import { jsonContent, safeTool, textContent, writeOutput } from "../util.js";
|
|
4
4
|
import { classifyBytes } from "./classify-region.js";
|
|
5
5
|
import { clusterChanges } from "./diff-cluster.js";
|
|
6
|
+
import { mapNesAddress, mapSnesAddress } from "./disasm.js";
|
|
6
7
|
|
|
7
8
|
// Small reads stay inline (hex) for ergonomics; large reads must go to disk
|
|
8
9
|
// (raw bytes) unless inline:true. The common case — peeking a few bytes of
|
|
@@ -193,9 +194,41 @@ async function memWrite(sessionKey, { region, offset = 0, hex, base64, data, byt
|
|
|
193
194
|
return textContent(`wrote ${buf.length} bytes to ${region}+${offset}`);
|
|
194
195
|
}
|
|
195
196
|
|
|
196
|
-
async function memReadCart(sessionKey, { offset = 0, length = 16, outputPath, inline, echo }) {
|
|
197
|
+
async function memReadCart(sessionKey, { offset = 0, length = 16, cpuAddress, bank, mapper, outputPath, inline, echo }) {
|
|
197
198
|
const host = getHost(sessionKey);
|
|
198
199
|
const rom = host.getCartRom();
|
|
200
|
+
|
|
201
|
+
// Banked CPU-address read (0.28.0 feedback #2a): map {cpuAddress, bank?} →
|
|
202
|
+
// PRG bytes, the inverse of the breakpoint result's bank/prgOffset. Saves
|
|
203
|
+
// the caller the hand-computed `cpuAddr - 0x8000 + bank*0x4000` arithmetic
|
|
204
|
+
// that bit them twice. NES + SNES today (reuses the disasm mappers).
|
|
205
|
+
if (cpuAddress != null) {
|
|
206
|
+
let m;
|
|
207
|
+
if (rom.platform === "nes") {
|
|
208
|
+
m = mapNesAddress(rom.raw, cpuAddress >>> 0, length, bank);
|
|
209
|
+
} else if (rom.platform === "snes") {
|
|
210
|
+
m = mapSnesAddress(rom.raw, cpuAddress >>> 0, length, mapper);
|
|
211
|
+
} else {
|
|
212
|
+
throw new Error(`memory({op:'readCart', cpuAddress}): banked CPU-address mapping is NES/SNES only (got '${rom.platform}'). Use a flat 'offset' for this platform.`);
|
|
213
|
+
}
|
|
214
|
+
const hex = Array.from(m.bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
215
|
+
const meta = {
|
|
216
|
+
platform: rom.platform,
|
|
217
|
+
cpuAddress: "0x" + (cpuAddress >>> 0).toString(16).toUpperCase(),
|
|
218
|
+
...(bank != null ? { bank } : {}),
|
|
219
|
+
fileOffset: "0x" + m.fileOffset.toString(16).toUpperCase(),
|
|
220
|
+
prgOffset: "0x" + (m.fileOffset - (m.prgFileStart ?? 0)).toString(16).toUpperCase(),
|
|
221
|
+
length: m.bytes.length,
|
|
222
|
+
note: m.note,
|
|
223
|
+
};
|
|
224
|
+
if (outputPath) {
|
|
225
|
+
const { path, bytes: written } = writeOutput(Uint8Array.from(m.bytes), { outputPath, what: "readCartRom" });
|
|
226
|
+
if (echo === false) return jsonContent({ ...meta, path, bytes: written });
|
|
227
|
+
return jsonContent({ ...meta, path, bytes: written, hex });
|
|
228
|
+
}
|
|
229
|
+
return jsonContent({ ...meta, hex });
|
|
230
|
+
}
|
|
231
|
+
|
|
199
232
|
if (offset >= rom.bytes.length) {
|
|
200
233
|
throw new Error(`readCartRom: offset ${offset} is past the end of the ${rom.platform} ROM (size ${rom.bytes.length}, header skipped ${rom.headerSkipped}).`);
|
|
201
234
|
}
|
|
@@ -293,23 +326,38 @@ async function memDiffRuns(sessionKey, { region, frames = 60, portsA, portsB, of
|
|
|
293
326
|
});
|
|
294
327
|
}
|
|
295
328
|
|
|
296
|
-
async function memDiff(sessionKey, { region, name = "default", view = "summary", maxChanges = 4096, maxClusters = 64, gap = 4, minDelta }) {
|
|
329
|
+
async function memDiff(sessionKey, { region, name = "default", view = "summary", maxChanges = 4096, maxClusters = 64, gap = 4, minDelta, changeDir, beforeMin, beforeMax, afterMin, afterMax, deltaEq, outputPath, echo = true }) {
|
|
297
330
|
const host = getHost(sessionKey);
|
|
298
331
|
const snap = memSnapshots(sessionKey).get(snapKey(region, name));
|
|
299
332
|
if (!snap) throw new Error(`memory({op:'diff'}): no snapshot named '${name}' for region '${region}'. Call memory({op:'snapshot', region, name}) first.`);
|
|
300
333
|
const now = host.readMemory(region, snap.offset, snap.bytes.length);
|
|
301
334
|
|
|
302
|
-
// Collect changed offsets once
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
// (0.27.0
|
|
335
|
+
// Collect changed offsets once, applying server-side predicate filters so
|
|
336
|
+
// the lives/score/ammo hunt is ONE call instead of dumping the whole diff
|
|
337
|
+
// and filtering client-side (0.28.0 feedback #3). All filters AND together:
|
|
338
|
+
// minDelta — |after-before| >= minDelta (drop small wiggles; 0.27.0 #5)
|
|
339
|
+
// changeDir — 'dec' (after<before) | 'inc' (after>before)
|
|
340
|
+
// deltaEq — after-before === deltaEq EXACTLY (signed; e.g. -1 for "lost one life")
|
|
341
|
+
// beforeMin/Max, afterMin/Max — value-range gates on the old/new byte
|
|
342
|
+
// Example: a 537-byte death diff → the ~3 "decreased by exactly 1 from a
|
|
343
|
+
// small value" rows with {changeDir:'dec', beforeMax:9, deltaEq:-1}.
|
|
306
344
|
const changedOffsets = [];
|
|
307
345
|
for (let i = 0; i < snap.bytes.length; i++) {
|
|
308
|
-
|
|
309
|
-
if (
|
|
346
|
+
const b = snap.bytes[i], a = now[i];
|
|
347
|
+
if (b === a) continue;
|
|
348
|
+
if (minDelta != null && Math.abs(a - b) < minDelta) continue;
|
|
349
|
+
if (changeDir === "dec" && !(a < b)) continue;
|
|
350
|
+
if (changeDir === "inc" && !(a > b)) continue;
|
|
351
|
+
if (deltaEq != null && (a - b) !== deltaEq) continue;
|
|
352
|
+
if (beforeMin != null && b < beforeMin) continue;
|
|
353
|
+
if (beforeMax != null && b > beforeMax) continue;
|
|
354
|
+
if (afterMin != null && a < afterMin) continue;
|
|
355
|
+
if (afterMax != null && a > afterMax) continue;
|
|
310
356
|
changedOffsets.push(i);
|
|
311
357
|
}
|
|
312
358
|
const changedCount = changedOffsets.length;
|
|
359
|
+
const filtered = (changeDir != null || deltaEq != null || beforeMin != null ||
|
|
360
|
+
beforeMax != null || afterMin != null || afterMax != null);
|
|
313
361
|
|
|
314
362
|
if (view === "raw") {
|
|
315
363
|
const changes = changedOffsets.slice(0, maxChanges).map((i) => ({
|
|
@@ -318,11 +366,13 @@ async function memDiff(sessionKey, { region, name = "default", view = "summary",
|
|
|
318
366
|
before: snap.bytes[i].toString(16).padStart(2, "0"),
|
|
319
367
|
after: now[i].toString(16).padStart(2, "0"),
|
|
320
368
|
}));
|
|
321
|
-
|
|
369
|
+
const result = {
|
|
322
370
|
region, name, view, baseOffset: snap.offset, length: snap.bytes.length,
|
|
323
|
-
changedCount
|
|
324
|
-
|
|
325
|
-
|
|
371
|
+
...(filtered ? { filterMatches: changedCount } : { changedCount }),
|
|
372
|
+
changes,
|
|
373
|
+
...(changedCount > changes.length ? { truncated: true, note: `${changedCount} ${filtered ? "matching " : ""}bytes changed; showing first ${changes.length} (raise maxChanges).` } : {}),
|
|
374
|
+
};
|
|
375
|
+
return diffOut(result, { outputPath, echo, region, heavyKey: "changes", count: changedCount });
|
|
326
376
|
}
|
|
327
377
|
|
|
328
378
|
// SUMMARY: cluster adjacent changes (within `gap`) into ranges + stride.
|
|
@@ -353,18 +403,33 @@ async function memDiff(sessionKey, { region, name = "default", view = "summary",
|
|
|
353
403
|
}
|
|
354
404
|
return entry;
|
|
355
405
|
});
|
|
356
|
-
|
|
406
|
+
const result = {
|
|
357
407
|
region, name, view, baseOffset: snap.offset, length: snap.bytes.length,
|
|
358
|
-
changedCount, clusterCount: clusters.length,
|
|
408
|
+
...(filtered ? { filterMatches: changedCount } : { changedCount }), clusterCount: clusters.length,
|
|
359
409
|
clusters: out,
|
|
360
410
|
...(stride !== null ? { stride: "0x" + stride.toString(16), strideHint: strideNote } : {}),
|
|
361
411
|
...(clusters.length > out.length ? { truncated: true } : {}),
|
|
362
412
|
note: changedCount === 0
|
|
363
|
-
? "Nothing changed."
|
|
364
|
-
: `${changedCount} bytes changed in ${clusters.length} cluster(s). ` +
|
|
413
|
+
? (filtered ? "No changed byte matched the filters (try loosening changeDir/deltaEq/before*/after*)." : "Nothing changed.")
|
|
414
|
+
: `${changedCount} ${filtered ? "matching " : ""}bytes changed in ${clusters.length} cluster(s). ` +
|
|
365
415
|
(stride !== null ? strideNote + " " : "") +
|
|
366
|
-
"Use view:'raw' for exact before/after bytes (or narrow with a tighter event window). For 'find the address of value X' use memory({op:'search'}), not diff.",
|
|
367
|
-
}
|
|
416
|
+
"Use view:'raw' for exact before/after bytes (or narrow with a tighter event window / the changeDir/deltaEq/before*/after* filters). For 'find the address of value X' use memory({op:'search'}), not diff.",
|
|
417
|
+
};
|
|
418
|
+
return diffOut(result, { outputPath, echo, region, heavyKey: "clusters", count: changedCount });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Honor outputPath/echo for diff results, mirroring memRead (0.28.0 feedback
|
|
422
|
+
// #2): write the FULL JSON to outputPath regardless of size; with echo:false
|
|
423
|
+
// return only the slim envelope (counts + path), dropping the heavy array so a
|
|
424
|
+
// large diff never streams through context.
|
|
425
|
+
function diffOut(result, { outputPath, echo, region, heavyKey, count }) {
|
|
426
|
+
if (!outputPath) return jsonContent(result);
|
|
427
|
+
const { path, bytes } = writeOutput(JSON.stringify(result, null, 2), { outputPath, what: `diff(${region})` });
|
|
428
|
+
if (echo === false) {
|
|
429
|
+
const { [heavyKey]: _omit, ...slim } = result;
|
|
430
|
+
return jsonContent({ ...slim, path, bytes, echo: false, note: `Full diff written to ${path} (${count} changes); '${heavyKey}' omitted (echo:false).` });
|
|
431
|
+
}
|
|
432
|
+
return jsonContent({ ...result, path, bytes });
|
|
368
433
|
}
|
|
369
434
|
|
|
370
435
|
// diffState lives in the `state` tool (state({op:'diff'})).
|
|
@@ -471,6 +536,37 @@ async function memSearch(sessionKey, { value, size = 1, as = "raw", region = "sy
|
|
|
471
536
|
});
|
|
472
537
|
}
|
|
473
538
|
|
|
539
|
+
// op:'searchUnknown' — the Cheat-Engine UNKNOWN-INITIAL-VALUE hunt: seed the
|
|
540
|
+
// candidate set to the WHOLE region (every size-aligned offset, baselined to
|
|
541
|
+
// its current value), with NO value filter. Then narrow across in-game events
|
|
542
|
+
// with searchNext compare:'dec'/'inc'/'unchanged'/'changed'/'gt'/'lt'. This is
|
|
543
|
+
// the canonical "find the lives/score/timer address you can't see" loop, which
|
|
544
|
+
// op:'search' (requires a value) can't do. (0.28.0 feedback #1.)
|
|
545
|
+
async function memSearchUnknown(sessionKey, { size = 1, as = "raw", region = "system_ram", name = "default", maxCandidates = 64 }) {
|
|
546
|
+
const host = getHost(sessionKey);
|
|
547
|
+
if (as === "digits") throw new Error("memory({op:'searchUnknown'}): as:'digits' needs a value; use as:'raw' or 'bcd' for an unknown-value hunt.");
|
|
548
|
+
const info = REGION_INFO[region] ?? {};
|
|
549
|
+
const little = (info.endianness ?? genericEndianness(host.status.platform)) !== "big";
|
|
550
|
+
const buf = host.readMemory(region, 0, regionLength(host, region, 0));
|
|
551
|
+
const s = { region, size, little, as, digitLen: 0 };
|
|
552
|
+
// Seed EVERY size-aligned offset; baseline each to its current decoded
|
|
553
|
+
// value so the first searchNext relative compare works immediately.
|
|
554
|
+
const candidates = [];
|
|
555
|
+
const prevMap = new Map();
|
|
556
|
+
for (let i = 0; i + size <= buf.length; i += size) {
|
|
557
|
+
const cur = decodeAt(buf, i, s);
|
|
558
|
+
if (cur === null) continue;
|
|
559
|
+
candidates.push(i);
|
|
560
|
+
prevMap.set(i, cur);
|
|
561
|
+
}
|
|
562
|
+
searchSessions(sessionKey).set(name, { ...s, addrs: Uint32Array.from(candidates), prev: prevMap, kMap: null });
|
|
563
|
+
return jsonContent({
|
|
564
|
+
searchId: name, region, size, as, mode: "unknown",
|
|
565
|
+
count: candidates.length,
|
|
566
|
+
note: `Seeded ${candidates.length} candidates (the whole region, no value filter). Now cause the value to change in-game, then narrow with memory({op:'searchNext', name:'${name}', compare:'dec'|'inc'|'unchanged'|'changed'|'gt'|'lt'}) — e.g. 'dec' after losing a life, 'unchanged' across a frame where it shouldn't move. Repeat until 1-2 remain, then confirm with op:'write'.`,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
474
570
|
async function memSearchNext(sessionKey, { compare, value, name = "default", maxCandidates = 64 }) {
|
|
475
571
|
const host = getHost(sessionKey);
|
|
476
572
|
const s = searchSessions(sessionKey).get(name);
|
|
@@ -542,13 +638,17 @@ export function registerMemoryTools(server, z, sessionKey) {
|
|
|
542
638
|
"• 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" +
|
|
543
639
|
"• 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" +
|
|
544
640
|
"• 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" +
|
|
641
|
+
"• op:'searchUnknown' — the UNKNOWN-INITIAL-VALUE hunt (Cheat Engine's 'Unknown initial value'): seed the WHOLE region as candidates with NO value, then narrow across in-game events with op:'searchNext' compare 'dec'/'inc'/'unchanged'/'changed'/'gt'/'lt'. THE way to find a value you can't see (lives/timer/ammo not on the HUD): searchUnknown → lose a life → searchNext compare:'dec' → repeat. Use this when you don't know the number; use op:'search' when you do.\n" +
|
|
545
642
|
"• 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.)",
|
|
546
643
|
{
|
|
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
|
|
644
|
+
op: z.enum(["read", "write", "readCart", "snapshot", "diff", "diffRuns", "classify", "search", "searchUnknown", "searchNext"])
|
|
645
|
+
.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 (you know the number); searchUnknown=seed the whole region (you DON'T know the number); searchNext=narrow either."),
|
|
549
646
|
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.)"),
|
|
550
647
|
offset: z.number().int().min(0).default(0).describe("Byte offset within the region (read/write/snapshot/classify) or the cart ROM image (readCart)."),
|
|
551
648
|
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."),
|
|
649
|
+
cpuAddress: z.number().int().min(0).optional().describe("op:readCart (NES/SNES) — read by a BANKED CPU ADDRESS instead of a flat offset (the inverse of the breakpoint result's bank/prgOffset). e.g. read a jump table at $8654 in bank 6: {op:'readCart', cpuAddress:0x8654, bank:6}. A $C000+ NES address resolves to the fixed top bank. Saves the cpuAddr-0x8000+bank*0x4000 hand-arithmetic."),
|
|
650
|
+
bank: z.number().int().min(0).optional().describe("op:readCart with cpuAddress — which 16KB PRG bank is mapped into the switchable $8000-$BFFF window (NES). Ignored for $C000+ (fixed top bank) and for non-banked ROMs."),
|
|
651
|
+
mapper: z.enum(["lorom", "hirom"]).optional().describe("op:readCart with cpuAddress (SNES) — force LoROM/HiROM mapping if auto-detect is wrong."),
|
|
552
652
|
offsets: offsetsShape.optional().describe("op:read BATCH — a list of addresses (each read `length` bytes, default 1) or {offset,length} objects → reads:[{offset,length,hex}]. Takes precedence over offset/length."),
|
|
553
653
|
// write
|
|
554
654
|
hex: z.string().optional().describe("op:write — hex string, e.g. 'deadbeef' (even length)."),
|
|
@@ -563,6 +663,12 @@ export function registerMemoryTools(server, z, sessionKey) {
|
|
|
563
663
|
maxClusters: z.number().int().min(1).max(4096).default(64).describe("op:diff summary view — cap the cluster list (clusterCount is the true total)."),
|
|
564
664
|
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
665
|
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)."),
|
|
666
|
+
changeDir: z.enum(["inc", "dec"]).optional().describe("op:diff — keep only bytes that went UP ('inc', after>before) or DOWN ('dec', after<before). The lives/score/ammo hunt: a death window's 'dec' bytes are the candidates."),
|
|
667
|
+
deltaEq: z.number().int().min(-255).max(255).optional().describe("op:diff — keep only bytes whose signed change (after-before) is EXACTLY this. e.g. deltaEq:-1 = 'decreased by one' (lost a life); deltaEq:10 = '+10 score tick'."),
|
|
668
|
+
beforeMin: z.number().int().min(0).max(255).optional().describe("op:diff — keep only bytes whose BEFORE value was >= this."),
|
|
669
|
+
beforeMax: z.number().int().min(0).max(255).optional().describe("op:diff — keep only bytes whose BEFORE value was <= this (e.g. beforeMax:9 = a small counter like lives, not a coordinate)."),
|
|
670
|
+
afterMin: z.number().int().min(0).max(255).optional().describe("op:diff — keep only bytes whose AFTER value was >= this."),
|
|
671
|
+
afterMax: z.number().int().min(0).max(255).optional().describe("op:diff — keep only bytes whose AFTER value was <= this."),
|
|
566
672
|
frames: z.number().int().min(1).max(100000).default(60).describe("op:diffRuns — frames to run EACH scenario from the same start state."),
|
|
567
673
|
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
674
|
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."),
|
|
@@ -573,9 +679,9 @@ export function registerMemoryTools(server, z, sessionKey) {
|
|
|
573
679
|
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`."),
|
|
574
680
|
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)."),
|
|
575
681
|
// shared output
|
|
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.)
|
|
682
|
+
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.) op:diff — write the FULL diff JSON here regardless of size (so a big diff routes to YOUR path, not a harness path).`),
|
|
577
683
|
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)."),
|
|
684
|
+
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). op:diff with outputPath — false = return only the slim envelope (counts + path), omitting the changes/clusters array."),
|
|
579
685
|
},
|
|
580
686
|
safeTool(async (args) => {
|
|
581
687
|
switch (args.op) {
|
|
@@ -599,9 +705,10 @@ export function registerMemoryTools(server, z, sessionKey) {
|
|
|
599
705
|
}
|
|
600
706
|
case "classify": return await memClassify(sessionKey, args);
|
|
601
707
|
case "search": {
|
|
602
|
-
if (args.value == null) throw new Error("memory({op:'search'}): `value` is required.");
|
|
708
|
+
if (args.value == null) throw new Error("memory({op:'search'}): `value` is required (use op:'searchUnknown' for an unknown-value hunt).");
|
|
603
709
|
return await memSearch(sessionKey, args);
|
|
604
710
|
}
|
|
711
|
+
case "searchUnknown": return await memSearchUnknown(sessionKey, args);
|
|
605
712
|
case "searchNext": {
|
|
606
713
|
if (!args.compare) throw new Error("memory({op:'searchNext'}): `compare` is required.");
|
|
607
714
|
return await memSearchNext(sessionKey, args);
|
|
@@ -629,7 +736,7 @@ function searchSessions(key) { let m = _searchSessions.get(key); if (!m) { m = n
|
|
|
629
736
|
/** @type {Map<string, Map<string, {offset:number, bytes:Uint8Array}>>} */
|
|
630
737
|
const _memSnaps = new Map();
|
|
631
738
|
function memSnapshots(key) { let m = _memSnaps.get(key); if (!m) { m = new Map(); _memSnaps.set(key, m); } return m; }
|
|
632
|
-
const snapKey = (region, name) => region + "
|
|
739
|
+
const snapKey = (region, name) => region + "" + name;
|
|
633
740
|
|
|
634
741
|
/** Bytes from `offset` to the end of the region — for a whole-region snapshot
|
|
635
742
|
* when no explicit length is given. Uses the core-reported region size. */
|
|
@@ -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
|
}
|
|
@@ -409,7 +409,10 @@ export async function previewTileArtCore(args) {
|
|
|
409
409
|
|
|
410
410
|
if (outputPath) {
|
|
411
411
|
await writeFile(outputPath, png);
|
|
412
|
-
|
|
412
|
+
// Livestream sideband: the human sees the rendered sheet even though the
|
|
413
|
+
// agent only gets the path.
|
|
414
|
+
return { ...result, outputPath, note: `${png.length} bytes of PNG written to ${outputPath}.`,
|
|
415
|
+
_observerImages: [{ kind: "image", mimeType: "image/png", base64: png.toString("base64") }] };
|
|
413
416
|
}
|
|
414
417
|
return { ...result, pngBase64: png.toString("base64") };
|
|
415
418
|
}
|
|
@@ -467,7 +470,8 @@ async function previewMsxScreen2(args, d) {
|
|
|
467
470
|
};
|
|
468
471
|
if (outputPath) {
|
|
469
472
|
await writeFile(outputPath, buf);
|
|
470
|
-
return { ...result, outputPath
|
|
473
|
+
return { ...result, outputPath,
|
|
474
|
+
_observerImages: [{ kind: "image", mimeType: "image/png", base64: buf.toString("base64") }] };
|
|
471
475
|
}
|
|
472
476
|
return { ...result, pngBase64: buf.toString("base64") };
|
|
473
477
|
}
|