romdevtools 0.70.0 → 0.71.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/examples/dreamcast/platformer/main.c +31 -0
  3. package/examples/dreamcast/puzzle/main.c +44 -0
  4. package/examples/dreamcast/racing/main.c +39 -0
  5. package/examples/dreamcast/shmup/main.c +50 -0
  6. package/examples/dreamcast/sports/main.c +39 -0
  7. package/package.json +3 -3
  8. package/src/cores/capabilities.js +13 -9
  9. package/src/host/LibretroHost.js +242 -23
  10. package/src/host/callbacks.js +68 -1
  11. package/src/host/cpu-state.js +32 -0
  12. package/src/host/dc-aica-state.js +67 -0
  13. package/src/mcp/tools/audio.js +1 -1
  14. package/src/mcp/tools/disasm.js +1 -1
  15. package/src/mcp/tools/index.js +1 -1
  16. package/src/mcp/tools/platform-docs.js +1 -1
  17. package/src/mcp/tools/platform-tools.js +9 -1
  18. package/src/mcp/tools/toolchain.js +115 -10
  19. package/src/platforms/dreamcast/MENTAL_MODEL.md +87 -0
  20. package/src/platforms/dreamcast/TROUBLESHOOTING.md +55 -0
  21. package/src/platforms/dreamcast/UPSTREAM_SOURCES.md +57 -0
  22. package/src/platforms/n64/MENTAL_MODEL.md +84 -0
  23. package/src/platforms/n64/TROUBLESHOOTING.md +60 -0
  24. package/src/platforms/n64/UPSTREAM_SOURCES.md +52 -0
  25. package/src/platforms/n64/lib/c/n64.c +181 -80
  26. package/src/platforms/ps1/MENTAL_MODEL.md +85 -0
  27. package/src/platforms/ps1/TROUBLESHOOTING.md +55 -0
  28. package/src/platforms/ps1/UPSTREAM_SOURCES.md +54 -0
  29. package/src/platforms/snes/TROUBLESHOOTING.md +10 -0
  30. package/src/toolchains/asar/asar.js +84 -14
  31. package/src/toolchains/mips-c/mips-c.js +35 -1
  32. package/src/toolchains/sh-c/lib/dc.h +65 -15
  33. package/src/toolchains/sh-c/sh-c.js +6 -3
@@ -74,6 +74,17 @@ export async function runAsar(args) {
74
74
  });
75
75
  let { binary, log, exitCode } = first;
76
76
 
77
+ // asar exits a NORMAL error build via its C++ exception path (quit throws a
78
+ // numeric heap pointer, not a clean status), so the worker appends a generic
79
+ // `[worker] Abort in WASM: <ptr>` line even though asar DID write proper
80
+ // diagnostics (e.g. `…: error: (Elabel_not_found): …`). That line reads as a
81
+ // silent crash + the heap-pointer "<number>" looks like an OOM/trap — it cost
82
+ // real diagnosis time (v0.70.0 feedback #5). When the log already has a real
83
+ // asar diagnostic, this was an ordinary error exit: drop the misleading line.
84
+ if (exitCode !== 0 && /:\s*(error|warning)\s*:/i.test(log)) {
85
+ log = log.replace(/\n?\s*\[worker\] Abort in WASM:[^\n]*\n?/g, "\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
86
+ }
87
+
77
88
  // Silent-fail recovery. When asar throws a numeric value through Emscripten's
78
89
  // C++-exception path, that "number" is a heap pointer to the thrown object,
79
90
  // not an exit code — and we've lost the actual error message. Under
@@ -101,9 +112,19 @@ export async function runAsar(args) {
101
112
 
102
113
  const hex = "0x" + (exitCode >>> 0).toString(16).toUpperCase();
103
114
  const looksLikeHeapPointer = exitCode > 1024;
104
- const hint = looksLikeHeapPointer
115
+ // Count DISTINCT files touched via the readfile/filesize/canreadfile API.
116
+ // asar-WASM has a known resource issue where reading enough distinct files
117
+ // that way (the canonical commercial-disassembly asset-conversion idiom, e.g.
118
+ // an incpal macro converting many .pal palettes) can abort with no diagnostic
119
+ // (v0.70.0 feedback #1). Surfacing the count turns a silent dead-end into a
120
+ // lead. (incbin of the same files does NOT trip it — only readfile1/filesize.)
121
+ const distinctReadfileFiles = countReadfileFiles(source);
122
+ const readfileHint = distinctReadfileFiles >= READFILE_DISTINCT_WARN
123
+ ? ` This source reads ${distinctReadfileFiles} DISTINCT files via readfile1/filesize/canreadfile — asar-WASM can abort with no diagnostic above a threshold of distinct files read that way (a known limitation). If you're converting assets at assemble time (e.g. .pal -> 15-bit), pre-convert them to .bin blobs offline and incbin those instead (incbin does NOT leak) — the output is byte-identical.`
124
+ : "";
125
+ const hint = (looksLikeHeapPointer
105
126
  ? "Exit code looks like a heap pointer, not a real exit code — likely a C++ exception that escaped without writing to stderr. Common causes: bank-border-crossed, section overlap, ROM size exceeded, or out-of-memory while assembling."
106
- : "asar exited non-zero with no output.";
127
+ : "asar exited non-zero with no output.") + readfileHint;
107
128
 
108
129
  const layoutText = formatLayout(summarizeWrittenRegions(verboseBinary, baseRom, { flatBinary }));
109
130
 
@@ -155,6 +176,20 @@ export async function runAsar(args) {
155
176
  includesInfo[name] = { size };
156
177
  }
157
178
 
179
+ // Proactive advisory: even when THIS build squeaked by, a source reading many
180
+ // distinct files via readfile1/filesize is at risk of the asar-WASM abort —
181
+ // surface it (non-fatal) so a growing disassembly doesn't dead-end later.
182
+ {
183
+ const distinct = countReadfileFiles(source);
184
+ if (distinct >= READFILE_DISTINCT_WARN && !/readfile.*distinct files/i.test(log)) {
185
+ log =
186
+ `[asar advisory] this source reads ${distinct} distinct files via readfile1/filesize — ` +
187
+ `asar-WASM can abort (no diagnostic) above a threshold of distinct files read that way. ` +
188
+ `If you're converting assets at assemble time, prefer pre-converting them to .bin and ` +
189
+ `'incbin' (byte-identical, no leak).\n` + log;
190
+ }
191
+ }
192
+
158
193
  // Try to read the symbol file that asar emitted, if any.
159
194
  let symbolsText = null;
160
195
  if (wantSymbols) {
@@ -346,20 +381,30 @@ function asarPreflight(source, { binaryIncludes = {}, includes = {} } = {}) {
346
381
  }
347
382
 
348
383
  // -----------------------------------------------------------------------
349
- // Header-overlap detection: if an `incbin "foo.bin"` lands in a 32 KB
350
- // bank window starting at $XX8000 and the file size would extend past
351
- // $XXFFC0 (16 KB of usable area before the LoROM header), warn the user
352
- // BEFORE asar happily clobbers the header during pass 2.
384
+ // Header-overlap detection: an `incbin "foo.bin"` whose span would reach
385
+ // $00FFC0 clobbers the LoROM header during pass 2. **The LoROM header lives
386
+ // ONLY in bank $00** ($00FFC0..$00FFFF) for banks $01+, $XXFFC0 is ordinary
387
+ // ROM, and games (e.g. SMW's GFX banks 08-0B) legitimately fill across those
388
+ // boundaries under `check bankcross off`. So only flag BANK $00 (v0.70.0 #6:
389
+ // the old check false-positived on every bank as "$08FFC0 header"). Also honor
390
+ // `check bankcross off` (the user is explicitly opting into bank-crossing fills).
353
391
  //
354
- // We only check incbin's pointing at files in binaryIncludes (where we
355
- // know the size). We assume each incbin is preceded by an `org $XX8000`
356
- // and that the file fits or doesn't from that base.
392
+ // We only check incbin's pointing at files in binaryIncludes (size known) and
393
+ // assume each is preceded by an `org $008000`. Note: this scans only the entry
394
+ // source `incbin`s inside `incsrc`'d files aren't seen (so the check is a
395
+ // best-effort early warning, not full coverage).
357
396
  // -----------------------------------------------------------------------
358
397
  const incbinDirective = /^\s*incbin\s+"([^"]+)"/;
359
- // Walk top-to-bottom, tracking the most recent org as the "cursor".
398
+ const bankcrossOffRe = /^\s*check\s+bankcross\s+off\b/i;
399
+ const bankcrossOnRe = /^\s*check\s+bankcross\s+(on|half)\b/i;
400
+ // Walk top-to-bottom, tracking the most recent org as the "cursor" and the
401
+ // current `check bankcross` state (off = the user opted into crossing fills).
360
402
  let cursor = null;
403
+ let bankcrossOff = false;
361
404
  for (let i = 0; i < lines.length; i++) {
362
405
  const line = stripComment(lines[i]);
406
+ if (bankcrossOffRe.test(line)) { bankcrossOff = true; continue; }
407
+ if (bankcrossOnRe.test(line)) { bankcrossOff = false; continue; }
363
408
  const orgMatch = line.match(orgDirective);
364
409
  if (orgMatch) {
365
410
  cursor = parseInt(orgMatch[1], 16);
@@ -373,15 +418,16 @@ function asarPreflight(source, { binaryIncludes = {}, includes = {} } = {}) {
373
418
  const size = blob instanceof Uint8Array ? blob.length : Buffer.from(blob, "base64").length;
374
419
  const bank = (cursor >>> 16) & 0xFF;
375
420
  const off = cursor & 0xFFFF;
376
- const headerStart = 0xFFC0; // LoROM header lives at $XXFFC0..$XXFFFF
377
- if (off >= 0x8000 && off + size > headerStart) {
421
+ const headerStart = 0xFFC0; // LoROM header lives at $00FFC0..$00FFFF (bank $00 ONLY)
422
+ if (!bankcrossOff && bank === 0x00 && off >= 0x8000 && off + size > headerStart) {
378
423
  const fmtAddr = (a) => "$" + a.toString(16).padStart(6, "0").toUpperCase();
379
424
  const endAddr = (bank << 16) | (off + size - 1);
380
425
  issues.push(
381
426
  `[asar preflight] line ${i + 1}: 'incbin "${name}"' (${size} bytes) starting at ` +
382
427
  `${fmtAddr(cursor)} would extend to ${fmtAddr(endAddr)}, overlapping the LoROM ` +
383
- `header at $${bank.toString(16).padStart(2, "0").toUpperCase()}FFC0. Move this data ` +
384
- `to a higher bank (e.g. 'org $018000') or shrink it below ${headerStart - off} bytes.`,
428
+ `header at $00FFC0. Move this data to a higher bank (e.g. 'org $018000') or ` +
429
+ `shrink it below ${headerStart - off} bytes. (Add 'check bankcross off' if this ` +
430
+ `crossing is intentional.)`,
385
431
  );
386
432
  }
387
433
  // Advance cursor so consecutive incbins in the same bank stack.
@@ -531,3 +577,27 @@ function asarPreflight(source, { binaryIncludes = {}, includes = {} } = {}) {
531
577
  }
532
578
  return null;
533
579
  }
580
+
581
+ // asar-WASM aborts (no diagnostic) once readfile1/filesize/canreadfile touch
582
+ // enough DISTINCT files — the canonical commercial-disassembly asset-conversion
583
+ // idiom (an incpal-style macro converting many .pal palettes at assemble time).
584
+ // We can't always predict the exact ceiling, so warn above this many distinct
585
+ // files so the build doesn't silently dead-end (v0.70.0 feedback #1).
586
+ const READFILE_DISTINCT_WARN = 40;
587
+
588
+ /**
589
+ * Count DISTINCT file names passed to the readfile / filesize / canreadfile
590
+ * functions. Only literal string filenames are counted (computed names can't be
591
+ * resolved statically). Used for the readfile-leak advisory.
592
+ * @param {string} source
593
+ * @returns {number}
594
+ */
595
+ function countReadfileFiles(source) {
596
+ const names = new Set();
597
+ // readfile1("x",..) / readfile2(...) / readfile3 / readfile4 / filesize("x")
598
+ // / canreadfile("x",..) / canreadfile1..4 — first string arg is the filename.
599
+ const re = /\b(?:readfile[1-4]|canreadfile[1-4]?|filesize)\s*\(\s*"([^"]+)"/gi;
600
+ let m;
601
+ while ((m = re.exec(source)) !== null) names.add(m[1]);
602
+ return names.size;
603
+ }
@@ -77,9 +77,30 @@ function wrapN64Rom(text, entry = 0x80000400) {
77
77
  export async function buildMipsC(args) {
78
78
  const platform = args.platform;
79
79
  const endian = platform === "ps1" ? "little" : "big";
80
- const headers = args.headers ?? {};
81
80
  const cc1Options = [...(args.cc1Options ?? []), "-O2", "-G0", "-ffreestanding", "-fno-builtin", "-Wall"];
82
81
  const sources = args.sources ?? (args.source != null ? { "main.c": args.source } : {});
82
+
83
+ // Auto-bundle the platform helper lib so `#include "n64.h"` / `#include "psx.h"`
84
+ // just works (parity with the Dreamcast sh-c path that auto-bundles dc.h). The
85
+ // header is added to the virtual headers; the matching .c is compiled + linked as
86
+ // an extra source — UNLESS the caller already provides their own (caller wins, and
87
+ // we skip auto-linking the .c if a same-named source is already present so there's
88
+ // no duplicate-symbol clash). The helper lives in platforms/<platform>/lib/c/.
89
+ const helperName = platform === "ps1" ? "psx" : platform === "n64" ? "n64" : null;
90
+ const headers = { ...(args.headers ?? {}) };
91
+ /** @type {Record<string,string>} */
92
+ let autoHelperSrc = null;
93
+ if (helperName) {
94
+ const helperDir = path.join(__dirname, "..", "..", "platforms", platform, "lib", "c");
95
+ const hPath = path.join(helperDir, `${helperName}.h`);
96
+ const cPath = path.join(helperDir, `${helperName}.c`);
97
+ if (headers[`${helperName}.h`] == null) {
98
+ const hSrc = await readFile(hPath, "utf-8").catch(() => null);
99
+ if (hSrc != null) headers[`${helperName}.h`] = hSrc;
100
+ }
101
+ const callerHasC = (args.sources && (args.sources[`${helperName}.c`] != null));
102
+ if (!callerHasC) autoHelperSrc = await readFile(cPath, "utf-8").catch(() => null);
103
+ }
83
104
  let log = "";
84
105
 
85
106
  /** @type {Record<string, Uint8Array>} */
@@ -93,6 +114,19 @@ export async function buildMipsC(args) {
93
114
  if (as.exitCode !== 0 || !as.object) return { ok: false, binary: null, log, exitCode: as.exitCode || 1, stage: `as (${cName})`, ...(as.crash ? { crash: as.crash } : {}) };
94
115
  userObjs[cName.replace(/\.c$/i, ".o")] = as.object;
95
116
  }
117
+ // Auto-bundled helper .c (n64.c / psx.c) — compiled with the SAME headers so it can
118
+ // see its own header, linked alongside the user objects. Skipped when the caller
119
+ // supplied their own helper .c (callerHasC) above.
120
+ if (autoHelperSrc != null) {
121
+ const cc = await runCc1mips({ source: autoHelperSrc, headers, options: cc1Options, endian });
122
+ log += `--- cc1 (${helperName}.c, bundled) ---\n${cc.log || "(ok)"}\n`;
123
+ if (cc.exitCode !== 0 || !cc.asmSource) return { ok: false, binary: null, log, exitCode: cc.exitCode || 1, stage: `cc1 (${helperName}.c bundled)`, ...(cc.crash ? { crash: cc.crash } : {}) };
124
+ const as = await runMipsAs({ source: cc.asmSource, endian });
125
+ log += `--- as (${helperName}.c, bundled) ---\n${as.log || "(ok)"}\n`;
126
+ if (as.exitCode !== 0 || !as.object) return { ok: false, binary: null, log, exitCode: as.exitCode || 1, stage: `as (${helperName}.c bundled)` };
127
+ userObjs[`${helperName}.o`] = as.object;
128
+ }
129
+
96
130
  // raw .s sources too
97
131
  for (const sName of Object.keys(sources).filter((n) => /\.(s|asm)$/i.test(n))) {
98
132
  const as = await runMipsAs({ source: sources[sName], endian });
@@ -55,9 +55,11 @@ static inline void dc_video_init(void) {
55
55
  PVR_REG(0x0E8) = 0x00000000u;
56
56
  /* VO_BORDER_COL: black border. */
57
57
  PVR_REG(0x040) = 0x00000000u;
58
- /* SPG_LOAD / SPG_CONTROL: NTSC 640x480 progressive timing. */
58
+ /* SPG_LOAD / SPG_CONTROL: NTSC 640x480 INTERLACED (480i). Non-interlace (240p)
59
+ * only displays 240 lines, so the top half of a 480-line framebuffer never shows.
60
+ * interlace=1 outputs the full 480-line image. */
59
61
  PVR_REG(0x0D8) = (524u << 16) | 857u; /* SPG_LOAD: vcount, hcount */
60
- PVR_REG(0x0D0) = 0x00000000u; /* SPG_CONTROL: NTSC, non-interlace */
62
+ PVR_REG(0x0D0) = 0x00000050u; /* SPG_CONTROL: NTSC, interlace=1 (bit4), PAL=0 */
61
63
  }
62
64
 
63
65
  /* Plot a pixel (no bounds clamp on the hot path; caller keeps in range). */
@@ -85,18 +87,66 @@ static inline void dc_rect(int x0, int y0, int w, int h, u16 c) {
85
87
  }
86
88
 
87
89
  /* ── Controller input (Maple bus, port 0) ─────────────────────────────────────
88
- * The DC Maple controller digital-button bit layout. Full Maple DMA setup is
89
- * non-trivial; the romdev host injects input (setInput) that the core maps onto
90
- * Maple, so these constants document the bit layout for game logic. */
91
- #define DC_BTN_C 0x0001
92
- #define DC_BTN_B 0x0002
93
- #define DC_BTN_A 0x0004
94
- #define DC_BTN_START 0x0008
95
- #define DC_BTN_UP 0x0010
96
- #define DC_BTN_DOWN 0x0020
97
- #define DC_BTN_LEFT 0x0040
98
- #define DC_BTN_RIGHT 0x0080
99
- #define DC_BTN_Y 0x0200
100
- #define DC_BTN_X 0x0400
90
+ * Digital-button bits, matching Flycast's DC kcode (active-LOW on the wire a bit
91
+ * is 0 when pressed). dc_pad() does a real Maple "Get Condition" DMA, inverts the
92
+ * kcode to active-HIGH, and masks to the real button bits. The romdev host's
93
+ * setInput drives the emulated pad. */
94
+ #define DC_BTN_C (1u << 0)
95
+ #define DC_BTN_B (1u << 1)
96
+ #define DC_BTN_A (1u << 2)
97
+ #define DC_BTN_START (1u << 3)
98
+ #define DC_BTN_UP (1u << 4)
99
+ #define DC_BTN_DOWN (1u << 5)
100
+ #define DC_BTN_LEFT (1u << 6)
101
+ #define DC_BTN_RIGHT (1u << 7)
102
+ #define DC_BTN_Y (1u << 9)
103
+ #define DC_BTN_X (1u << 10)
104
+ /* only the bits above are real digital buttons; mask out reserved bits on read. */
105
+ #define DC_BTN_MASK 0x06FFu
106
+
107
+ /* Maple DMA registers (Holly system bus). */
108
+ #define DC_SB(o) (*(volatile u32 *)(0xA05F6C00u + (o)))
109
+ #define DC_MDSTAR 0x04 /* command-table address */
110
+ #define DC_MDEN 0x14 /* DMA enable */
111
+ #define DC_MDST 0x18 /* DMA start */
112
+
113
+ /* Read controller port 0 via a Maple "Get Condition" DMA. Builds a one-shot command
114
+ * frame (recipient = port-0 controller AP 0x20, cmd 0x09 GetCondition, function =
115
+ * controller 0x01000000), points the recv buffer at a scratch, kicks the DMA, then
116
+ * scans the response for the controller's function-id marker (0x01000000) and reads
117
+ * the 16-bit kcode that immediately follows. kcode is active-LOW on the wire, so we
118
+ * invert + mask to the real button bits → DC_BTN_* set == pressed.
119
+ *
120
+ * NOTE: the exact word offset of the kcode in the recv frame can vary; we locate it
121
+ * by the MFID marker rather than a fixed index, which is robust across the response
122
+ * framing. Returns 0 if no controller response is found. */
123
+ static inline u32 dc_pad(void) {
124
+ static volatile u32 cmd[8] __attribute__((aligned(32)));
125
+ static volatile u32 rsp[16] __attribute__((aligned(32)));
126
+ u32 phys_cmd = ((u32)(unsigned long)cmd) & 0x1FFFFFFF;
127
+ u32 phys_rsp = ((u32)(unsigned long)rsp) & 0x1FFFFFFF;
128
+ int i;
129
+ for (i = 0; i < 16; i++) rsp[i] = 0;
130
+ cmd[0] = 0x80000000u; /* last xfer (bit31), op=START(0), frame len-1=0 */
131
+ cmd[1] = phys_rsp; /* receive address */
132
+ cmd[2] = (1u << 24) /* data length in words (1) */
133
+ | (0x20u << 16) /* sender address */
134
+ | (0x20u << 8) /* recipient: port 0 controller (AP 0x20) */
135
+ | 0x09u; /* command: Get Condition */
136
+ cmd[3] = 0x01000000u; /* function: controller */
137
+ DC_SB(DC_MDEN) = 1;
138
+ DC_SB(DC_MDSTAR) = phys_cmd;
139
+ DC_SB(DC_MDST) = 1; /* synchronous under the romdev/HLE core */
140
+ /* find the controller function-id (0x01000000) in the response; the kcode is the
141
+ * next word's low half. The response data starts after the frame header word. */
142
+ for (i = 0; i < 15; i++) {
143
+ if (rsp[i] == 0x01000000u) {
144
+ u32 kcode = rsp[i + 1] & 0xFFFF;
145
+ return ((~kcode) & DC_BTN_MASK);
146
+ }
147
+ }
148
+ return 0;
149
+ }
150
+ static inline int dc_pressed(u32 mask) { return (dc_pad() & mask) ? 1 : 0; }
101
151
 
102
152
  #endif /* ROMDEV_DC_H */
@@ -36,11 +36,14 @@ export async function buildShC(args) {
36
36
  const bundledDcH = await readFile(path.join(LIB, "dc.h"), "utf-8").catch(() => null);
37
37
  const headers = { ...(bundledDcH != null ? { "dc.h": bundledDcH } : {}), ...(args.headers ?? {}) };
38
38
  // -m4-single-only is passed by the cc1 wrapper; here the language/codegen knobs.
39
- // Default to -O2, but let a user-supplied -O<level> win (gcc honors the LAST -O, so a
40
- // default appended AFTER the user's would clobber it only add -O2 if absent).
39
+ // Default to -O1, NOT -O2: the sh-elf cc1.wasm build has an -O2-only pass that aborts
40
+ // ("memory access out of bounds" during "Assembling functions") on common control
41
+ // flow — e.g. an infinite loop that mutates locals through both `if`/`else` branches.
42
+ // -O1 dodges it entirely and is plenty for DC homebrew. A user-supplied -O<level>
43
+ // still wins (gcc honors the LAST -O, so only add a default when none is present).
41
44
  const userOpts = args.cc1Options ?? [];
42
45
  const hasOpt = userOpts.some((o) => /^-O/.test(o));
43
- const cc1Options = [...(hasOpt ? [] : ["-O2"]), ...userOpts, "-ffreestanding", "-fno-builtin", "-Wall"];
46
+ const cc1Options = [...(hasOpt ? [] : ["-O1"]), ...userOpts, "-ffreestanding", "-fno-builtin", "-Wall"];
44
47
  const sources = args.sources ?? (args.source != null ? { "main.c": args.source } : {});
45
48
  let log = "";
46
49