romdevtools 0.29.0 → 0.40.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 +14 -5
- package/CHANGELOG.md +114 -12
- package/README.md +2 -1
- package/examples/gb/templates/tile_engine.c +1 -1
- package/examples/gbc/templates/tile_engine.c +1 -1
- package/examples/genesis/templates/two_plane_parallax.c +4 -4
- package/examples/nes/templates/tile_engine.c +1 -1
- package/package.json +14 -12
- package/src/analysis/analyze.js +263 -0
- package/src/analysis/decompile.js +108 -0
- package/src/analysis/decompiler/sleigh/6502.cspec +34 -0
- package/src/analysis/decompiler/sleigh/6502.ldefs +33 -0
- package/src/analysis/decompiler/sleigh/6502.pspec +16 -0
- package/src/analysis/decompiler/sleigh/6502.sla +3414 -0
- package/src/analysis/decompiler/sleigh/65816-snes.pspec +249 -0
- package/src/analysis/decompiler/sleigh/65816.cspec +17 -0
- package/src/analysis/decompiler/sleigh/65816.ldefs +15 -0
- package/src/analysis/decompiler/sleigh/65816.sla +23670 -0
- package/src/analysis/decompiler/sleigh/65c02.sla +4683 -0
- package/src/analysis/decompiler/sleigh/68000.cspec +67 -0
- package/src/analysis/decompiler/sleigh/68000.ldefs +68 -0
- package/src/analysis/decompiler/sleigh/68000.pspec +9 -0
- package/src/analysis/decompiler/sleigh/68000_register.cspec +118 -0
- package/src/analysis/decompiler/sleigh/68040.sla +81693 -0
- package/src/analysis/decompiler/sleigh/ARM.ldefs +377 -0
- package/src/analysis/decompiler/sleigh/ARM4t_le.sla +53758 -0
- package/src/analysis/decompiler/sleigh/ARM_v45.cspec +209 -0
- package/src/analysis/decompiler/sleigh/ARM_v45.pspec +40 -0
- package/src/analysis/decompiler/sleigh/ARMt_v45.pspec +43 -0
- package/src/analysis/decompiler/sleigh/HuC6280.cspec +67 -0
- package/src/analysis/decompiler/sleigh/HuC6280.ldefs +18 -0
- package/src/analysis/decompiler/sleigh/HuC6280.pspec +150 -0
- package/src/analysis/decompiler/sleigh/HuC6280.sla +7524 -0
- package/src/analysis/decompiler/sleigh/sm83.cspec +70 -0
- package/src/analysis/decompiler/sleigh/sm83.ldefs +29 -0
- package/src/analysis/decompiler/sleigh/sm83.pspec +19 -0
- package/src/analysis/decompiler/sleigh/sm83.sla +7695 -0
- package/src/analysis/decompiler/sleigh/z80.cspec +122 -0
- package/src/analysis/decompiler/sleigh/z80.ldefs +57 -0
- package/src/analysis/decompiler/sleigh/z80.pspec +43 -0
- package/src/analysis/decompiler/sleigh/z80.sla +20518 -0
- package/src/analysis/decompiler/wasm/decompile.js +2 -0
- package/src/analysis/decompiler/wasm/decompile.wasm +0 -0
- package/src/analysis/rizin.js +129 -0
- package/src/analysis/wasm/rizin.js +6032 -0
- package/src/analysis/wasm/rizin.wasm +0 -0
- 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 +25 -7
- package/src/http/routes.js +1 -1
- package/src/http/skill-doc.js +1 -1
- package/src/mcp/tools/cart-parts.js +5 -2
- package/src/mcp/tools/disasm.js +32 -5
- package/src/mcp/tools/font-map.js +3 -3
- package/src/mcp/tools/index.js +2 -2
- package/src/mcp/tools/memory.js +131 -24
- package/src/mcp/tools/project.js +1 -1
- package/src/mcp/tools/record.js +6 -7
- package/src/mcp/tools/reinject.js +1 -1
- package/src/mcp/tools/run-until.js +8 -2
- package/src/mcp/tools/symbols.js +10 -4
- package/src/mcp/tools/trace-vram-source.js +1 -1
- package/src/mcp/tools/watch-memory.js +50 -8
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +80 -6
- package/src/platforms/atari2600/MENTAL_MODEL.md +6 -0
- package/src/platforms/atari7800/MENTAL_MODEL.md +6 -0
- package/src/platforms/c64/MENTAL_MODEL.md +6 -0
- package/src/platforms/gb/MENTAL_MODEL.md +6 -0
- package/src/platforms/gb/lib/c/README.md +1 -1
- package/src/platforms/gba/MENTAL_MODEL.md +7 -1
- package/src/platforms/gbc/MENTAL_MODEL.md +6 -0
- package/src/platforms/gbc/lib/c/README.md +1 -1
- package/src/platforms/genesis/MENTAL_MODEL.md +8 -2
- package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
- package/src/platforms/genesis/lib/wram.s +1 -1
- package/src/platforms/gg/MENTAL_MODEL.md +6 -0
- package/src/platforms/lynx/MENTAL_MODEL.md +6 -0
- package/src/platforms/msx/MENTAL_MODEL.md +6 -0
- package/src/platforms/nes/MENTAL_MODEL.md +6 -0
- package/src/platforms/pce/MENTAL_MODEL.md +6 -0
- package/src/platforms/sms/MENTAL_MODEL.md +6 -0
- package/src/platforms/snes/MENTAL_MODEL.md +10 -4
- package/src/toolchains/_worker/wasm-worker.js +5 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// learnFontMap / encodeTextForRom / findEncodedText — text-hack workflow.
|
|
2
2
|
//
|
|
3
|
-
// Every retro game maps characters to tile-IDs differently (
|
|
4
|
-
// A=$0A, B=$0B, ..., Z=$23;
|
|
3
|
+
// Every retro game maps characters to tile-IDs differently (one NES racer:
|
|
4
|
+
// A=$0A, B=$0B, ..., Z=$23; another game: ASCII offset; a third: sparse table). The
|
|
5
5
|
// agent currently reverse-engineers this by hand each session. These
|
|
6
6
|
// three tools automate it:
|
|
7
7
|
//
|
|
@@ -186,7 +186,7 @@ async function makeTilemapReader(host, platform, which) {
|
|
|
186
186
|
* Decide whether a run of (char, tileId) reads from a live tilemap is FONT TEXT
|
|
187
187
|
* (a reusable character→tile map) or a PRE-RENDERED GRAPHIC (a name/logo drawn as
|
|
188
188
|
* a bitmap, where each cell is a unique tile). The trap a long RE session hit:
|
|
189
|
-
*
|
|
189
|
+
* Some games' player names are bitmaps, so patching the ASCII string does nothing.
|
|
190
190
|
* Signals a graphic when: (1) a repeated character used a DIFFERENT tile each
|
|
191
191
|
* time (a real font reuses one tile per letter) — the direct proof; OR (2) every
|
|
192
192
|
* tile is unique AND the ids form a near-contiguous run (tiles X,X+1,X+2,… = one
|
package/src/mcp/tools/index.js
CHANGED
|
@@ -116,8 +116,8 @@ const CATEGORIES = [
|
|
|
116
116
|
},
|
|
117
117
|
{
|
|
118
118
|
name: "debug",
|
|
119
|
-
description: "Cross-platform debugging
|
|
120
|
-
useWhen: ["sprites rendering wrong", "audio silent or distorted", "CPU stuck in unknown state", "need to read what existing ROM bytes do"],
|
|
119
|
+
description: "Cross-platform debugging + reverse-engineering: inspectSprites, inspectPalette, getCPUState (main/spc700/z80), getAudioState (dsp/psg/ym2612), and the disasm/RE engine — disassemble (raw/ROM/rebuildable-project), plus the Rizin/Ghidra ops disasm({target:'cfg'|'xrefs'|'functions'|'decompile'}) (control-flow graphs, deep xrefs, auto-detected functions, and C pseudocode — all 14 platforms) and symbols({op:'analyze'}) (one-shot structural map).",
|
|
120
|
+
useWhen: ["sprites rendering wrong", "audio silent or distorted", "CPU stuck in unknown state", "need to read what existing ROM bytes do", "reverse-engineering an unknown ROM — carve its functions/structure before labeling them live", "want C-like pseudocode to understand a routine"],
|
|
121
121
|
register: (s, z, k) => {
|
|
122
122
|
registerPlatformSpecificTools(s, z, k); // inspectSprites/Palette, getCPUState, getDspState, ...
|
|
123
123
|
registerSymbolTools(s, z, k); // buildSourceWithDebug, resolveSymbol, lookupAddress, ...
|
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. */
|
package/src/mcp/tools/project.js
CHANGED
|
@@ -1464,7 +1464,7 @@ TEMPLATES.genesis = {
|
|
|
1464
1464
|
runtimeDirs: SGDK_RUNTIME_DIRS,
|
|
1465
1465
|
lang: SGDK_LANG,
|
|
1466
1466
|
ext: ".bin",
|
|
1467
|
-
describe: "Two-plane parallax SCROLLING scaffold — the smooth-feel starting point for a
|
|
1467
|
+
describe: "Two-plane parallax SCROLLING scaffold — the smooth-feel starting point for a parallax side-scroller. Plane A = a painted foreground world (ground + platform blocks), Plane B = a repeated starfield, one player sprite. The frame loop does HARDWARE SCROLL ONLY (two VDP_setHorizontalScroll writes + one VDP_updateSprites) — ZERO tilemap writes per frame, which is what keeps movement smooth (rewriting a plane each frame is the #1 'choppy horizontal movement' bug). Plane B scrolls at 1/4 speed for depth. Exposes volatile g_player_x / g_cam_x so you can motion-trace it headlessly (symbols->memory->recordSession). Extend by streaming one offscreen column per 8-px camera step for worlds wider than 512 px — see Genesis MENTAL_MODEL.md 'Scrolling, parallax & the feel trap'.",
|
|
1468
1468
|
},
|
|
1469
1469
|
puzzle: {
|
|
1470
1470
|
main: "templates/puzzle.c",
|
package/src/mcp/tools/record.js
CHANGED
|
@@ -12,13 +12,12 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { getHost } from "../state.js";
|
|
14
14
|
import { jsonContent, safeTool } from "../util.js";
|
|
15
|
-
import { MemoryRegionToRetro } from "../../host/types.js";
|
|
16
|
-
|
|
17
|
-
// Single source of truth for memorySamples regions — the same canonical set
|
|
18
|
-
// readMemory accepts. Previously hardcoded to 8 NES regions, so Genesis and
|
|
19
|
-
// hardware-register regions (nes_apu_regs, etc.) couldn't be batch-sampled.
|
|
20
|
-
const SAMPLE_REGIONS = /** @type {[string, ...string[]]} */ (Object.keys(MemoryRegionToRetro));
|
|
21
15
|
|
|
16
|
+
// memorySamples regions accept the same canonical set readMemory accepts (incl.
|
|
17
|
+
// hardware-register regions like nes_apu_regs). The region is a runtime-validated
|
|
18
|
+
// string rather than an inlined ~62-value schema enum — the per-sample
|
|
19
|
+
// host.readMemory(region,…) lookup throws on an unknown region with a clear
|
|
20
|
+
// message, so the schema enum was pure deferred-load weight (0.28.0 feedback #5).
|
|
22
21
|
export function registerRecordTools(server, z, sessionKey) {
|
|
23
22
|
const inputShape = z.object({
|
|
24
23
|
up: z.boolean().optional(), down: z.boolean().optional(),
|
|
@@ -54,7 +53,7 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
54
53
|
.array(
|
|
55
54
|
z.object({
|
|
56
55
|
label: z.string(),
|
|
57
|
-
region: z.
|
|
56
|
+
region: z.string().describe("memory region (full readMemory set incl. hardware registers; validated at runtime)"),
|
|
58
57
|
offset: z.number().int().min(0),
|
|
59
58
|
length: z.number().int().min(1).max(256),
|
|
60
59
|
}),
|
|
@@ -390,7 +390,7 @@ const PLATFORM_REGISTRY = {
|
|
|
390
390
|
msx: { forms: msxPointerForms, verdict: "literal-escape", formats: ["raw", "konami-rle"],
|
|
391
391
|
note: "RAW when uncompressed; Konami/other RLE has a literal-run token, so a literal-escape stored block is producible." },
|
|
392
392
|
genesis: { forms: genesisPointerForms, verdict: "literal-escape", formats: ["raw", "kosinski-literal"],
|
|
393
|
-
note: "Pointers are 32-bit BE = ROM offset (1:1 at $000000). Kosinski has an all-literal path (experimental terminator — self-verify). Nemesis is Huffman — NO stored escape; custom LZ (e.g.
|
|
393
|
+
note: "Pointers are 32-bit BE = ROM offset (1:1 at $000000). Kosinski has an all-literal path (experimental terminator — self-verify). Nemesis is Huffman — NO stored escape; custom LZ (e.g. some Genesis sports titles) — confirm the literal-run shape per game." },
|
|
394
394
|
snes: { forms: snesPointerForms, verdict: "literal-escape", formats: ["raw", "lz2-direct"],
|
|
395
395
|
note: "Pointers 16-bit (bank-implied) or 24-bit long LE; needs LoROM/HiROM (auto-detected). LC_LZ2 has a clean direct-copy (000) literal command + 0xFF end — common but per-game; confirm the codec." },
|
|
396
396
|
gba: { forms: gbaPointerForms, verdict: "literal-escape", formats: ["raw", "lz77-literal"],
|
|
@@ -13,9 +13,15 @@ import { jsonContent, safeTool } from "../util.js";
|
|
|
13
13
|
import { attachObserverFrame } from "./watch-memory.js";
|
|
14
14
|
|
|
15
15
|
export function registerRunUntilTools(server, z, sessionKey) {
|
|
16
|
+
// Condition `region` is a runtime-validated string, not a schema enum. It was
|
|
17
|
+
// an inlined 8-value list — which both bloated the schema AND silently rejected
|
|
18
|
+
// valid non-NES regions (genesis_*, c64_*, *_apu_regs) that host.readMemory
|
|
19
|
+
// accepts. The readMemory(region,…) call in the handler validates and throws a
|
|
20
|
+
// clear message on an unknown region (full canonical set, same as `memory`).
|
|
21
|
+
const regionStr = z.string().describe("memory region (full readMemory set, e.g. system_ram, nes_oam, genesis_vram, c64_color_ram; validated at runtime)");
|
|
16
22
|
const memoryCondition = z.object({
|
|
17
23
|
type: z.literal("memory"),
|
|
18
|
-
region:
|
|
24
|
+
region: regionStr,
|
|
19
25
|
offset: z.number().int().min(0),
|
|
20
26
|
equals: z.number().int().min(0).max(255).optional(),
|
|
21
27
|
notEquals: z.number().int().min(0).max(255).optional(),
|
|
@@ -24,7 +30,7 @@ export function registerRunUntilTools(server, z, sessionKey) {
|
|
|
24
30
|
|
|
25
31
|
const memoryChangedCondition = z.object({
|
|
26
32
|
type: z.literal("memoryChanged"),
|
|
27
|
-
region:
|
|
33
|
+
region: regionStr,
|
|
28
34
|
offset: z.number().int().min(0),
|
|
29
35
|
length: z.number().int().min(1).max(8192).default(1),
|
|
30
36
|
}).describe("Stop when memory[region][offset..offset+length] changes from its initial value.");
|
package/src/mcp/tools/symbols.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { writeFileSync } from "node:fs";
|
|
11
11
|
import { readFile } from "node:fs/promises";
|
|
12
12
|
import { jsonContent, safeTool, writeOutput } from "../util.js";
|
|
13
|
+
import { analyzeStructure } from "../../analysis/analyze.js";
|
|
13
14
|
import { addressToSymbolCore } from "./address-to-symbol.js";
|
|
14
15
|
|
|
15
16
|
// Tail length kept inline when a big log is written to a sibling file.
|
|
@@ -459,7 +460,7 @@ function registerSymbolsTool(server, z) {
|
|
|
459
460
|
"Auto-detects GNU ld (Genesis/m68k + GBA/ARM), sdld (`XXXX _name`, GB/GBC/SMS/GG/MSX), and ld65 VICE " +
|
|
460
461
|
"(`al XXXX .name`, cc65/dasm).",
|
|
461
462
|
{
|
|
462
|
-
op: z.enum(["resolve", "lookup", "map", "list", "addr"]).describe("resolve name→addr; lookup addr→sym; map = layout by region; list all; addr = PC→nearest symbol."),
|
|
463
|
+
op: z.enum(["resolve", "lookup", "map", "list", "addr", "analyze"]).describe("resolve name→addr; lookup addr→sym; map = layout by region; list all; addr = PC→nearest symbol; analyze = Rizin structural map of a ROM (no .dbg/.map needed — auto-detected functions + strings + entrypoints)."),
|
|
463
464
|
dbg: z.string().optional().describe("op=resolve/lookup/list/map: cc65 .dbg text from build({output:'romWithDebug'}) (NES/C64/Atari7800/Lynx/PCE). Pass this OR `map`/`dbgPath`/`mapPath`."),
|
|
464
465
|
map: z.string().optional().describe("op=resolve/lookup/list/map: .map text (build's `mapText`/`symbols`) — auto-detects sdld (GB/GBC/SMS/GG/MSX) vs GNU ld (Genesis/m68k). Pass this OR `dbg`/`dbgPath`/`mapPath`."),
|
|
465
466
|
dbgPath: z.string().optional().describe("op=resolve/lookup/list/map: ABSOLUTE path to a cc65 .dbg on disk (the `dbgPath` build({output:'romWithDebug'}) returned). Server reads it — the map never enters your context. Inline `dbg` wins if both passed."),
|
|
@@ -472,11 +473,16 @@ function registerSymbolsTool(server, z) {
|
|
|
472
473
|
pc: z.number().int().min(0).max(0xFFFFFF).optional().describe("op=addr: CPU address to look up (e.g. 0x01A7)."),
|
|
473
474
|
symbolsText: z.string().optional().describe("op=addr: inline .map/.sym text (build's `symbols`). Takes precedence over symbolsPath."),
|
|
474
475
|
symbolsPath: z.string().optional().describe("op=addr: path to a .map/.sym file (used only if symbolsText absent)."),
|
|
476
|
+
// analyze
|
|
477
|
+
romPath: z.string().optional().describe("op=analyze: ABSOLUTE path to the ROM file to structurally map via Rizin."),
|
|
475
478
|
},
|
|
476
479
|
safeTool(async (args) => {
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
+
// op=analyze and op=addr don't take a .dbg/.map source. Everything else
|
|
481
|
+
// resolves dbgPath/mapPath → text off disk so the map never enters context.
|
|
482
|
+
if (args.op === "analyze") {
|
|
483
|
+
if (!args.romPath) throw new Error("symbols op='analyze' requires `romPath` (the ROM file).");
|
|
484
|
+
return jsonContent(await analyzeStructure(args.romPath, args.platform));
|
|
485
|
+
}
|
|
480
486
|
const a = args.op === "addr" ? args : { ...args, ...(await loadDebugSource(args)) };
|
|
481
487
|
switch (a.op) {
|
|
482
488
|
case "resolve": return jsonContent(await resolveSymbolCore(a));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// traceVramSource (Genesis) — "which ROM offset did this VRAM graphic come from?"
|
|
2
2
|
//
|
|
3
|
-
// The trap it kills:
|
|
3
|
+
// The trap it kills: some sports games' player names are pre-rendered tile bitmaps
|
|
4
4
|
// DMA'd into VRAM from ROM, not font-rendered from a string. You can SEE the
|
|
5
5
|
// name but patching the ASCII string does nothing — the source is the bitmap in
|
|
6
6
|
// ROM. Genesis makes this traceable: a memory→VRAM DMA leaves its SOURCE address
|
|
@@ -139,6 +139,19 @@ export function makePressDriver(host, presses) {
|
|
|
139
139
|
// never disagree again.
|
|
140
140
|
const MEMORY_REGIONS = /** @type {[string, ...string[]]} */ (Object.keys(MemoryRegionToRetro));
|
|
141
141
|
|
|
142
|
+
// A region param that does NOT inline the full ~62-value enum into the JSON
|
|
143
|
+
// schema. The enum array is ~214 tokens PER param site; inlining it on every
|
|
144
|
+
// secondary region sub-param across this file was the dominant tool-schema
|
|
145
|
+
// bloat (0.28.0 feedback #5). Used on SECONDARY/sub params; the PRIMARY region
|
|
146
|
+
// inputs keep z.enum so the full list stays discoverable where the region IS
|
|
147
|
+
// the choice. A plain string — validated at RUNTIME by the handler (the
|
|
148
|
+
// host.readMemory / MemoryRegionToRetro lookup throws on an unknown region with
|
|
149
|
+
// a clear message), so dropping the schema enum here costs no safety.
|
|
150
|
+
// NOTE: `z` is passed into registerWatchMemoryTools (not a module import), so
|
|
151
|
+
// this factory takes `z` and is invoked once inside the register fn.
|
|
152
|
+
const makeRegionStr = (z) => (desc) =>
|
|
153
|
+
z.string().describe(desc + " (validated at runtime against the canonical region set).");
|
|
154
|
+
|
|
142
155
|
// Abort-guard for input-driven watchpoint runs: sample caller-named bytes each
|
|
143
156
|
// frame; the FIRST one to change stops the run with {label,addr,before,after}.
|
|
144
157
|
// Lets a derailed driven scenario (player died, scene flipped) return immediately
|
|
@@ -266,8 +279,9 @@ function downsample(arr, n) {
|
|
|
266
279
|
}
|
|
267
280
|
|
|
268
281
|
export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
282
|
+
const regionStr = makeRegionStr(z);
|
|
269
283
|
const rangeShape = z.object({
|
|
270
|
-
region:
|
|
284
|
+
region: regionStr("memory region for THIS range (same canonical set `memory` uses)"),
|
|
271
285
|
offset: z.number().int().min(0),
|
|
272
286
|
length: z.number().int().min(1).max(4096).default(1),
|
|
273
287
|
label: z.string().optional().describe("Name echoed on every event from this range — tells disjoint ranges apart in one stream."),
|
|
@@ -510,7 +524,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
510
524
|
|
|
511
525
|
// breakpoint({on:write|read|pc}) STOP-on-first. on:write precision:exact=bpFindWriter
|
|
512
526
|
// (core watchpoint, true PC under IRQ), precision:sampled=bpRunUntilWrite (frame PC).
|
|
513
|
-
async function bpFindWriter({ address, maxFrames = 600, pressDuring, abortIf }) {
|
|
527
|
+
async function bpFindWriter({ address, maxFrames = 600, pressDuring, abortIf, condition, conditionValue }) {
|
|
514
528
|
const host = getHost(sessionKey);
|
|
515
529
|
if (!host.watchpointSupported || !host.watchpointSupported()) {
|
|
516
530
|
return jsonContent({
|
|
@@ -519,7 +533,18 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
519
533
|
"Use watchMemory/runUntilWrite here — their pc is frame-sampled, so cross-check the value trace.",
|
|
520
534
|
});
|
|
521
535
|
}
|
|
522
|
-
|
|
536
|
+
if (condition === "equals" && conditionValue == null) {
|
|
537
|
+
throw new Error("breakpoint({on:'write', condition:'equals'}): `conditionValue` (the byte to stop on) is required.");
|
|
538
|
+
}
|
|
539
|
+
// Pass the condition to the core's watchpoint so its hook only COUNTS +
|
|
540
|
+
// records writes that satisfy it (qualifying writes), ignoring restoring/
|
|
541
|
+
// churn writes — and so the reported PC is a meaningful write, not just the
|
|
542
|
+
// last write of the frame. Core support is feature-detected; if the loaded
|
|
543
|
+
// core build predates condition support, we fall back to a host-side
|
|
544
|
+
// 'equals' filter on the reported value (inc/dec need the core's old byte).
|
|
545
|
+
const wantCond = condition != null;
|
|
546
|
+
const coreCond = host.setWatchpoint(address, true, wantCond ? { condition, value: conditionValue } : undefined);
|
|
547
|
+
const coreHandledCond = wantCond && coreCond && coreCond.conditionApplied === true;
|
|
523
548
|
const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
|
|
524
549
|
const pressDriver = makePressDriver(host, presses);
|
|
525
550
|
// Abort-guard: sample caller-named "still valid?" bytes each frame; if any
|
|
@@ -533,7 +558,17 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
533
558
|
pressDriver.applyForFrame(i);
|
|
534
559
|
host.stepFrames(1);
|
|
535
560
|
const w = host.getWatchpoint();
|
|
536
|
-
if (w.hits > 0) {
|
|
561
|
+
if (w.hits > 0) {
|
|
562
|
+
// Host-side fallback for condition:'equals' on a core that didn't
|
|
563
|
+
// apply the condition itself: only accept when the reported (last)
|
|
564
|
+
// written value equals the target; otherwise keep waiting. (inc/dec
|
|
565
|
+
// can't be faked host-side — they need the core's pre-write byte, so
|
|
566
|
+
// we only reach here for them when the core DID handle the condition.)
|
|
567
|
+
if (wantCond && !coreHandledCond && condition === "equals" && (w.lastValue & 0xFF) !== (conditionValue & 0xFF)) {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
result = { ...w, framesStepped: i + 1 }; break;
|
|
571
|
+
}
|
|
537
572
|
const ab = guard.check();
|
|
538
573
|
if (ab) { aborted = { ...ab, framesStepped: i + 1 }; break; }
|
|
539
574
|
}
|
|
@@ -584,12 +619,17 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
584
619
|
// address — a word/long store shows only its byte here, not the operand
|
|
585
620
|
// (a real session read 0x00 as "the move.l wrote zero").
|
|
586
621
|
valueByte: "0x" + result.lastValue.toString(16).toUpperCase().padStart(2, "0"),
|
|
622
|
+
...(result.lastOldValue != null ? { oldValueByte: "0x" + (result.lastOldValue & 0xFF).toString(16).toUpperCase().padStart(2, "0") } : {}),
|
|
623
|
+
...(condition ? { condition, ...(coreHandledCond ? {} : { conditionAppliedBy: "host" }) } : {}),
|
|
587
624
|
hits: result.hits,
|
|
588
625
|
framesStepped: result.framesStepped,
|
|
589
626
|
...(wpRegs ? { registersAtHit: wpRegs } : {}),
|
|
590
627
|
...(bankInfo ? bankInfo : {}),
|
|
591
628
|
...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
|
|
592
629
|
note: "pc is the EXACT writing instruction (captured in the CPU write path), not a frame sample. " +
|
|
630
|
+
(condition
|
|
631
|
+
? `condition:'${condition}' filtered to the MEANINGFUL write — pc/valueByte/hits reflect only qualifying writes${result.lastOldValue != null ? ` (oldValueByte→valueByte = ${"0x" + (result.lastOldValue & 0xFF).toString(16)}→${"0x" + result.lastValue.toString(16)})` : ""}. `
|
|
632
|
+
: "Without a `condition`, on:'write' runs to END OF FRAME and reports the LAST matching write of the frame (NOT the first) — `hits` is the count of all matching writes that frame. If a restoring/churn write hides the change you want, pass condition:'increase'|'decrease'|'equals'. ") +
|
|
593
633
|
"valueByte is the single byte written to the watched address (a 16/32-bit store shows only its byte here). " +
|
|
594
634
|
"hits counts watched-byte writes during the hit frame — the same instruction looping twice in one frame is hits:2, one event. " +
|
|
595
635
|
(wpRegs ? "registersAtHit is the register file frozen AT the write (the live regs drift for the rest of the frame — don't cpu({op:'read'}) instead). " : "") +
|
|
@@ -830,9 +870,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
830
870
|
precision: z.enum(["exact", "sampled"]).default("exact")
|
|
831
871
|
.describe("on:'write' ONLY. exact=core watchpoint, the real writing instruction PC even under interrupts (uses `address`). sampled=cheap frame-boundary PC (uses region/offset/length) — NOT the writer under IRQ. Ignored for on:read/pc (always exact)."),
|
|
832
872
|
address: z.number().int().min(0).optional().describe("on:'write' exact / on:'read' / on:'pc' — CPU address to break on (write target, read target, or instruction boundary). Required for those."),
|
|
833
|
-
region:
|
|
873
|
+
region: regionStr("on:'write' precision:'sampled' — region whose byte to watch for change.").optional(),
|
|
834
874
|
offset: z.number().int().min(0).optional().describe("on:'write' precision:'sampled' — offset within the region."),
|
|
835
875
|
length: z.number().int().min(1).max(4096).default(1).describe("on:'write' precision:'sampled' — bytes to watch from offset."),
|
|
876
|
+
condition: z.enum(["increase", "decrease", "equals"]).optional().describe("on:'write' precision:'exact' ONLY — stop only on the MEANINGFUL write, ignoring restoring/churn writes. 'decrease'/'increase' = the stored byte actually went down/up (e.g. a real lives−1, not a per-frame pointer-arithmetic restore); 'equals' = the byte became `value` (e.g. $00→$01 respawn re-arm). Without it, on:'write' reports the LAST matching write of the frame, which may be the churn, not the change you want."),
|
|
877
|
+
conditionValue: z.number().int().min(0).max(255).optional().describe("on:'write' condition:'equals' — the byte value to stop on (the NEW value written)."),
|
|
836
878
|
maxFrames: z.number().int().min(1).max(1_000_000).default(600).describe("Max frames to run while waiting for the condition."),
|
|
837
879
|
pressDuring: z.array(z.object({
|
|
838
880
|
frame: z.number().int().min(0),
|
|
@@ -841,12 +883,12 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
841
883
|
holdFrames: z.number().int().min(1).default(2),
|
|
842
884
|
})).optional().describe("Schedule input while waiting (drive the game to the state that triggers the condition). If OMITTED, this run inherits whatever input({op:'set'}) last held — same as frame({op:'step'}). If GIVEN, the schedule OWNS the pad for the whole run (a prior input({op:'set'}) is ignored); use it to drive the watched window itself. Entries with OVERLAPPING windows on the same port are OR'd into a chord (e.g. b+right held while a fires mid-window), not overwritten."),
|
|
843
885
|
abortIf: z.array(z.object({
|
|
844
|
-
region:
|
|
886
|
+
region: regionStr("memory region (default system_ram)").optional(),
|
|
845
887
|
offset: z.number().int().min(0).describe("byte offset within the region"),
|
|
846
888
|
label: z.string().optional().describe("human name for this guard byte"),
|
|
847
889
|
})).optional().describe("on:'write' exact — ABORT GUARD for a pressDuring run: caller-named 'is this scenario still valid?' bytes (e.g. the area/scene id, the player object-active flag). If ANY changes mid-run the watchpoint stops IMMEDIATELY and returns {aborted:true, abortedBy, before, after} — so a driven scenario that derailed (player died → title screen) doesn't burn all maxFrames and return a meaningless found:false. Each is sampled once per frame (cheap)."),
|
|
848
890
|
captureMemory: z.array(z.object({
|
|
849
|
-
region:
|
|
891
|
+
region: regionStr("memory region to read"),
|
|
850
892
|
offset: z.number().int().min(0).describe("byte offset within the region"),
|
|
851
893
|
length: z.number().int().min(1).max(256).default(1).describe("bytes to read"),
|
|
852
894
|
label: z.string().optional().describe("human name for this read (else 'region+offset')"),
|
|
@@ -985,7 +1027,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
985
1027
|
"hours diffing a CORRECT codec against that poisoned output. Prefer pure for every decompressor/codec call.\n" +
|
|
986
1028
|
"• op:'decompress' — convenience wrapper over op:'call' for the common decompressor shape: call `entryPC` with " +
|
|
987
1029
|
"A0=`sourceAddress` (and optionally A1=`destAddress`), run until it returns, then read `destAddress`. For the " +
|
|
988
|
-
"
|
|
1030
|
+
"the 'pre-rendered name + portrait are LZ-compressed' wall: point it at the game's own decompressor.",
|
|
989
1031
|
{
|
|
990
1032
|
op: z.enum(["read", "setReg", "call", "decompress"])
|
|
991
1033
|
.describe("read=CPU registers/flags; setReg=write one register; call=drive a subroutine until it returns; decompress=call shortcut (A0=source, A1=dest)."),
|