romdevtools 0.40.1 → 0.40.2

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/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to `romdevtools`. Dates are release dates.
4
4
  (Published as `romdev-mcp` through 0.11.0; renamed to `romdevtools` in 0.13.0 —
5
5
  the `romdev-mcp` bin is kept as an alias.)
6
6
 
7
+ ## 0.40.2 — 2026-06-11
8
+
9
+ ### Fixed — SNES `disasm({target:'decompile'})` treated the address as a raw file offset
10
+
11
+ On SNES, `decompile` decompiled the function at raw FILE offset `address`, but
12
+ `functions` / `cfg` / `xrefs` all report LoROM/HiROM **CPU** addresses — so the
13
+ documented "decompile an address from `functions`" loop silently returned the
14
+ wrong function (e.g. asking for the entry at CPU `$00:8000` decompiled file
15
+ `0x8000` = CPU `$01:8000`, the wrong bank).
16
+
17
+ Fix: SNES now lays the cart out by **24-bit CPU address** — each ROM chunk is
18
+ placed at its CPU bank (LoROM `$bank:8000`, HiROM `$C0+bank`), with the
19
+ `$80-$FF` FastROM (and HiROM `$40-$7F`) mirrors filled in — then decompiles at
20
+ the CPU address directly. This fixes both the function lookup AND in-bank /
21
+ `jsl` operand resolution (a flat-at-0 image would mis-label every cross-bank
22
+ call). The CPU-addressed image is ~16MB sparse (zero-filled between banks); a
23
+ real 4MB cart decompiles in ~1.2s. (No change to the `romdev-analysis*`
24
+ packages — the fix is in the address-mapping JS.)
25
+
26
+ Note: 65816 decompiler output is medium quality (variable register width, BCD/
27
+ decimal-flag expansion, direct-page guards) — for SNES, lean on `cfg` / `xrefs`
28
+ + targeted `decompile` of leaf routines over big dispatchers.
29
+
7
30
  ## 0.40.1 — 2026-06-11
8
31
 
9
32
  ### Fixed — Genesis `disasm({target:'decompile'})` was shifted +0x200
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "romdevtools",
3
- "version": "0.40.1",
3
+ "version": "0.40.2",
4
4
  "description": "Tool server giving coding agents full control of homebrew ROM development AND reverse-engineering/romhacking across 14 retro platforms (NES, SNES, GB, Genesis, Atari, C64, PC Engine, MSX, ...) via WASM toolchains + emulator cores. Use over plain HTTP, as an Agent Skill, or as an MCP server.",
5
5
  "type": "module",
6
6
  "main": "src/mcp/server.js",
@@ -204,6 +204,69 @@ export async function vaMapping(romBytes, arch, bits, vaddr, platform) {
204
204
  return { paddr: vaddr, vbase: 0 };
205
205
  }
206
206
 
207
+ /** Build a CPU-ADDRESSED sparse image of a SNES cart for the decompiler.
208
+ *
209
+ * SNES is banked: the langid is `65816:LE:24:snes` (24-bit space). If we hand
210
+ * the decompiler the flat file, a LoROM function at CPU $00:8000 lives at file
211
+ * 0, but its in-bank `jsr $80xx` operands resolve to file 0x80xx — bank-1 code,
212
+ * a plausible-but-WRONG body. So we lay each ROM chunk at its CPU address
213
+ * (sparse, zero-filled between), making BOTH the function address and every
214
+ * in-bank/JSL operand resolve. ~2x ROM size; fine at SNES cart sizes.
215
+ *
216
+ * Mirrors the detection/fold in disasm.js's mapSnesAddress (kept local to avoid
217
+ * a circular import: disasm.js imports analyze.js).
218
+ *
219
+ * The image is laid out BY CPU address, so the decompiler offset for a CPU
220
+ * address is the address itself (24-bit). @returns {{ image: Uint8Array, isLo:boolean }}
221
+ */
222
+ export function buildSnesCpuImage(romBytes, mapperHint) {
223
+ const copierOff = (romBytes.length % 0x8000 === 0x200) ? 0x200 : 0;
224
+ let isLo;
225
+ if (mapperHint === "lorom") isLo = true;
226
+ else if (mapperHint === "hirom") isLo = false;
227
+ else {
228
+ const loByte = romBytes[copierOff + 0x7FC0 + 0x15];
229
+ const hiByte = romBytes[copierOff + 0xFFC0 + 0x15];
230
+ const detLo = loByte === 0x20 || loByte === 0x30 || loByte === 0x32;
231
+ const detHi = hiByte === 0x21 || hiByte === 0x31;
232
+ isLo = detHi && !detLo ? false : true; // default LoROM when ambiguous
233
+ }
234
+ const body = romBytes.subarray(copierOff);
235
+
236
+ if (isLo) {
237
+ // LoROM: 32KB file chunk N maps to CPU bank N, $8000-$FFFF. Banks $80-$FF
238
+ // MIRROR $00-$7F (the FastROM image), and code commonly runs there (a JML to
239
+ // $F9xxxx is bank 0x79's ROM via the $80+ mirror). So we lay the full 16MB
240
+ // 24-bit space and mirror each chunk into BOTH its $00-$7F home and its
241
+ // $80-$FF twin — otherwise a reference into the high half "can't load N
242
+ // bytes" and the decompiler bails.
243
+ const fileBanks = Math.ceil(body.length / 0x8000); // ROM chunks (≤128)
244
+ const image = new Uint8Array(0x1000000); // full 16MB CPU space
245
+ for (let b = 0; b < fileBanks; b++) {
246
+ const src = body.subarray(b * 0x8000, (b + 1) * 0x8000);
247
+ const lo = (b & 0x7F); // home bank $00-$7F
248
+ image.set(src, lo * 0x10000 + 0x8000); // $lo:8000
249
+ image.set(src, (lo | 0x80) * 0x10000 + 0x8000); // $(lo|80):8000 mirror
250
+ }
251
+ return { image, isLo: true };
252
+ }
253
+ // HiROM: file 64KB chunk N is CPU bank $C0+N ($0000-$FFFF, the primary image),
254
+ // mirrored to bank $40+N. The upper half of each chunk also appears at
255
+ // $00-$3F:$8000-$FFFF and $80-$BF:$8000-$FFFF. Lay the full 16MB space and
256
+ // mirror so any of those references resolve.
257
+ const fileBanks = Math.ceil(body.length / 0x10000); // 64KB chunks
258
+ const image = new Uint8Array(0x1000000);
259
+ for (let b = 0; b < fileBanks; b++) {
260
+ const src = body.subarray(b * 0x10000, (b + 1) * 0x10000);
261
+ image.set(src, (0xC0 + b) * 0x10000); // $C0+b: full bank (primary)
262
+ image.set(src, (0x40 + b) * 0x10000); // $40+b: mirror
263
+ const upper = src.subarray(0x8000); // $8000-$FFFF half
264
+ image.set(upper, b * 0x10000 + 0x8000); // $00+b:8000 mirror
265
+ image.set(upper, (0x80 + b) * 0x10000 + 0x8000); // $80+b:8000 mirror
266
+ }
267
+ return { image, isLo: false };
268
+ }
269
+
207
270
  /**
208
271
  * Decompile the function containing `address` to C pseudocode (Ghidra).
209
272
  * @returns {{platform, langid, address, code, warnings, qualityNote}}
@@ -215,6 +278,30 @@ export async function analyzeDecompile(romPath, address, platformOverride) {
215
278
  if (!SLEIGH_LANGID[platform]) throw new Error(`analyze decompile: unsupported platform '${platform}'`);
216
279
  const romBytes = new Uint8Array(await readFile(romPath));
217
280
 
281
+ // SNES: banked 24-bit space. `address` is a LoROM/HiROM CPU address (what
282
+ // target='functions'/'cfg' report). Lay the cart out by CPU address so BOTH
283
+ // the function address AND its in-bank/JSL operands resolve, then decompile at
284
+ // the CPU address directly. (Flat-at-0 would decompile file[address] — the
285
+ // wrong bank — and mis-label every operand.)
286
+ if (platform === "snes") {
287
+ const { image } = buildSnesCpuImage(romBytes);
288
+ // The image is laid out by CPU address, so the file offset IS the address.
289
+ const imgOff = address >>> 0;
290
+ if (imgOff < 0 || imgOff >= image.length) {
291
+ throw new Error(
292
+ `decompile: SNES address ${hx(address)} is outside the ${image.length}-byte CPU image ` +
293
+ `(is it a valid LoROM/HiROM code address?).`
294
+ );
295
+ }
296
+ const rs = await decompileFunction({ platform, romBytes: image, fileOffset: imgOff });
297
+ return {
298
+ platform, langid: rs.langid,
299
+ address, addressHex: hx(address),
300
+ code: rs.code, warnings: rs.warnings,
301
+ qualityNote: "medium (65816 variable register width)",
302
+ };
303
+ }
304
+
218
305
  // Use rizin's loader mapping to turn the VA (what the user sees from
219
306
  // target='functions') into the file offset the raw decompiler image needs.
220
307
  // PCE uses the 6502 plugin only for the map/loader (HuC6280 decode is the