romdevtools 0.40.1 → 0.41.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 +2 -2
- package/CHANGELOG.md +97 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/analysis/analyze.js +405 -46
- package/src/analysis/rizin.js +13 -1
- package/src/cores/capabilities.js +218 -0
- package/src/mcp/tools/disasm.js +23 -4
- package/src/mcp/tools/platform-tools.js +17 -5
- package/src/mcp/tools/platforms.js +18 -3
- package/src/mcp/tools/rendering-context.js +5 -4
- package/src/mcp/tools/watch-memory.js +144 -2
- package/src/mcp/util.js +37 -0
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +23 -8
- package/src/toolchains/_worker/pool.js +41 -3
|
@@ -334,14 +334,27 @@ what touches an address). The **Rizin/Ghidra analysis engine** carves the progra
|
|
|
334
334
|
scan misses. Use it to answer "what calls this routine / reads this table?"
|
|
335
335
|
- **`disasm({target:'decompile', path, address})`** — Ghidra **C-like pseudocode**
|
|
336
336
|
for a function. Read it to UNDERSTAND a routine fast; it is NOT the edit path
|
|
337
|
-
(use `target:'project'`, §7b, to change and rebuild).
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
337
|
+
(use `target:'project'`, §7b, to change and rebuild). Hardware-register MMIO is
|
|
338
|
+
NAMED (`PPUMASK` not `*0x2001`) and on the 6502 family SLEIGH clutter is folded
|
|
339
|
+
to readable C99 (`uint8_t`, `zp_FD`) — see the `/* hw registers: … */` /
|
|
340
|
+
`/* 6502 fold: … */` legends. Quality tracks the CPU — see the `qualityNote` it
|
|
341
|
+
returns: excellent on ARM (GBA) / 68000 (Genesis), good on SM83 (GB) / Z80
|
|
342
|
+
(SMS/GG/MSX), medium on 65816 (SNES) / HuC6280 (PCE), rough on the 6502 family
|
|
343
|
+
(carry-flag idioms and 16-bit-math-on-8-bit decompile to noise — read the
|
|
344
|
+
disassembly there, or let an LLM fold the residual pseudocode).
|
|
345
|
+
- **`breakpoint({on:'jumptable', address})`** — when a routine decompiles to
|
|
346
|
+
`(*_IRQ)()` + "Could not recover jumptable" (the computed-jump dispatchers —
|
|
347
|
+
state machines, script/battle VMs — that static analysis structurally can't
|
|
348
|
+
follow), RESOLVE it live: this breaks at the dispatcher in the running emulator,
|
|
349
|
+
single-steps through the indirect `JMP (table,X)` / RTS-trick, and returns the
|
|
350
|
+
COMPUTED targets it actually lands on. Drive more game states (`pressDuring` /
|
|
351
|
+
`fromState`) to surface rarer arms. `disasm({target:'resolveJumptable'})` is the
|
|
352
|
+
static-side alias. No static-only tool can do this — it's romdev's live-emulator
|
|
353
|
+
edge.
|
|
342
354
|
|
|
343
355
|
**The loop:** `symbols({op:'analyze'})` or `disasm({target:'functions'})` to carve →
|
|
344
|
-
`disasm({target:'cfg'/'xrefs'/'decompile'})` to understand a candidate →
|
|
356
|
+
`disasm({target:'cfg'/'xrefs'/'decompile'})` to understand a candidate (→
|
|
357
|
+
`breakpoint({on:'jumptable'})` when it dispatches through a computed jump) → then the
|
|
345
358
|
dynamic tools (memory search, `breakpoint({on:'write'})`, `watch`) to CONFIRM and
|
|
346
359
|
label which carved function owns the value you care about. Static narrows the
|
|
347
360
|
search space; dynamic proves it.
|
|
@@ -422,8 +435,9 @@ Once you know WHAT to change, the write loop is a handful of calls — no custom
|
|
|
422
435
|
and >32KB HuCards (refs carry `romBank`) — so a hit in bank 12 of a 128KB cart shows up,
|
|
423
436
|
not just the first bank. Zero-page direct + indexed operands match, and `#$nn` immediates
|
|
424
437
|
are excluded (values, not addresses). Limitation: direct addressing only —
|
|
425
|
-
indirect/computed jumps aren't detected
|
|
426
|
-
|
|
438
|
+
indirect/computed jumps aren't detected statically; resolve those LIVE with
|
|
439
|
+
`breakpoint({on:'jumptable', address})` (runs the emulator to record the computed
|
|
440
|
+
targets), or the other runtime `watch`/`breakpoint` tools in §5/§5d.
|
|
427
441
|
- **`cart({op:'extract', path, outputDir})`** — split a ROM into standard parts (NES header/
|
|
428
442
|
prg/chr; SNES copier_header+rom+internal header; Genesis vectors/header/body; GB boot/
|
|
429
443
|
header/body) + a `manifest.json` (mapper, mirroring…). **`cart({op:'wrap'})`** is the inverse:
|
|
@@ -528,6 +542,7 @@ For sprite/tile edits (not text), don't hand-roll the tile-format math:
|
|
|
528
542
|
| Re-inject edited bytes the game accepts | `romPatch({op:'makeStored'})` (verbatim-expand block) → `romPatch({op:'findFree'})` → `romPatch({op:'relocate'})` |
|
|
529
543
|
| Find the pointer that loads an asset | `romPatch({op:'findPointer', romOffset})` |
|
|
530
544
|
| FIND the unknown routine touching X | `watch({on:'range', start,end})` (all hits) / `watch({on:'pc'})` (coverage) |
|
|
545
|
+
| Resolve a computed-jump dispatcher (decompiles to `(*_IRQ)()`) | `breakpoint({on:'jumptable', address})` (live — records the real switch arms) |
|
|
531
546
|
| Which DMA wrote a VRAM tile + its source (Genesis) | `watch({on:'dma', precision:'exact', vramDest})` |
|
|
532
547
|
| Where did a VRAM graphic come from (Genesis) | `watch({on:'dma', precision:'sampled'})` (ROM offset of the DMA source) |
|
|
533
548
|
| Drive a menu fast | `input({op:'navigate'})` (advances on screen change) |
|
|
@@ -57,6 +57,7 @@ function spawnWorker() {
|
|
|
57
57
|
pendingResolve: null,
|
|
58
58
|
pendingReject: null,
|
|
59
59
|
pendingJobId: null,
|
|
60
|
+
timedOut: false, // set true when we kill it for exceeding a job timeout
|
|
60
61
|
};
|
|
61
62
|
|
|
62
63
|
w.child.on("message", (msg) => {
|
|
@@ -108,10 +109,13 @@ function spawnWorker() {
|
|
|
108
109
|
w.pendingResolve = null;
|
|
109
110
|
w.pendingReject = null;
|
|
110
111
|
const err = new Error(
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
w.timedOut
|
|
113
|
+
? `worker ${w.id} killed after job ${w.pendingJobId} exceeded its timeout`
|
|
114
|
+
: `worker ${w.id} exited unexpectedly (code=${code} signal=${signal}) ` +
|
|
115
|
+
`while running job ${w.pendingJobId}`
|
|
113
116
|
);
|
|
114
117
|
/** @type {any} */(err).crash = { exitCode: code, signal: signal ?? null };
|
|
118
|
+
/** @type {any} */(err).timedOut = !!w.timedOut;
|
|
115
119
|
w.pendingJobId = null;
|
|
116
120
|
reject(err);
|
|
117
121
|
}
|
|
@@ -184,12 +188,31 @@ export async function runInWorker(job) {
|
|
|
184
188
|
const w = await acquire();
|
|
185
189
|
const id = nextJobId++;
|
|
186
190
|
return new Promise((resolve, reject) => {
|
|
191
|
+
let done = false;
|
|
192
|
+
let timer = null;
|
|
193
|
+
const finish = () => {
|
|
194
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
195
|
+
};
|
|
187
196
|
const settle = (result, err) => {
|
|
197
|
+
if (done) return;
|
|
198
|
+
done = true;
|
|
199
|
+
finish();
|
|
188
200
|
if (err) reject(err); else resolve(result);
|
|
189
201
|
};
|
|
190
202
|
w.pendingResolve = (result) => settle(result);
|
|
191
203
|
w.pendingReject = (err) => {
|
|
192
|
-
if (err && err.crash) {
|
|
204
|
+
if (err && err.crash && /** @type {any} */ (err).timedOut) {
|
|
205
|
+
// A timeout we triggered — report it cleanly, not as a generic crash.
|
|
206
|
+
settle({
|
|
207
|
+
exitCode: -1,
|
|
208
|
+
log: `[timeout] analysis exceeded ${job.timeoutMs}ms and was killed; the WASM worker was ` +
|
|
209
|
+
`recycled (pool not wedged). For a multi-MB ROM use a scoped pass (analyze one function/bank, ` +
|
|
210
|
+
`not whole-ROM 'aaa').\n`,
|
|
211
|
+
outputs: {},
|
|
212
|
+
timedOut: true,
|
|
213
|
+
crash: err.crash,
|
|
214
|
+
});
|
|
215
|
+
} else if (err && err.crash) {
|
|
193
216
|
// Convert crash to a normal result so callers don't have to catch.
|
|
194
217
|
settle({
|
|
195
218
|
exitCode: err.crash.exitCode ?? -1,
|
|
@@ -202,6 +225,21 @@ export async function runInWorker(job) {
|
|
|
202
225
|
}
|
|
203
226
|
};
|
|
204
227
|
w.pendingJobId = id;
|
|
228
|
+
|
|
229
|
+
// Per-call timeout (A5 reliability): a hung WASM analysis (whole-ROM `aaa` on
|
|
230
|
+
// a multi-MB ROM) never exits the worker, so without this the pending job
|
|
231
|
+
// wedges the slot forever. On timeout we KILL the worker — the exit handler
|
|
232
|
+
// (which sees `w.timedOut`) rejects this job with a clear timeout result and
|
|
233
|
+
// respawns a fresh worker, so the pool keeps its capacity.
|
|
234
|
+
if (job.timeoutMs && job.timeoutMs > 0) {
|
|
235
|
+
timer = setTimeout(() => {
|
|
236
|
+
if (done) return;
|
|
237
|
+
w.timedOut = true;
|
|
238
|
+
try { w.child.kill("SIGKILL"); } catch { /* already gone */ }
|
|
239
|
+
}, job.timeoutMs);
|
|
240
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
241
|
+
}
|
|
242
|
+
|
|
205
243
|
w.child.send({ type: "run", id, job });
|
|
206
244
|
});
|
|
207
245
|
}
|