romdevtools 0.40.2 → 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.
@@ -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). Quality tracks the CPU —
338
- see the `qualityNote` it returns: excellent on ARM (GBA) / 68000 (Genesis),
339
- good on SM83 (GB) / Z80 (SMS/GG/MSX), medium on 65816 (SNES) / HuC6280 (PCE),
340
- rough on the 6502 family (carry-flag idioms and 16-bit-math-on-8-bit decompile
341
- to noise read the disassembly there, or let an LLM fold the pseudocode).
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 → then the
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 (use the runtime `watch`/`breakpoint` tools in
426
- §5/§5d for those).
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
- `worker ${w.id} exited unexpectedly (code=${code} signal=${signal}) ` +
112
- `while running job ${w.pendingJobId}`
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
  }