romdevtools 0.21.0 → 0.22.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 (127) hide show
  1. package/AGENTS.md +15 -4
  2. package/CHANGELOG.md +58 -0
  3. package/examples/atari7800/templates/hello_sprite.c +48 -4
  4. package/examples/atari7800/templates/music_demo.c +47 -2
  5. package/examples/c64/templates/tile_engine.c +77 -27
  6. package/examples/gb/templates/hello_sprite.c +15 -6
  7. package/examples/gb/templates/music_demo.c +36 -0
  8. package/examples/gb/templates/platformer.c +3 -2
  9. package/examples/gb/templates/puzzle.c +3 -2
  10. package/examples/gb/templates/racing.c +3 -2
  11. package/examples/gb/templates/shmup.c +3 -2
  12. package/examples/gb/templates/sports.c +3 -2
  13. package/examples/gb/templates/tile_engine.c +3 -2
  14. package/examples/gba/templates/maxmod_demo.c +36 -2
  15. package/examples/gba/templates/platformer.c +3 -1
  16. package/examples/gba/templates/tonc_hello_sprite.c +35 -1
  17. package/examples/gbc/templates/hello_sprite.c +12 -3
  18. package/examples/gbc/templates/music_demo.c +56 -12
  19. package/examples/gbc/templates/platformer.c +3 -2
  20. package/examples/gbc/templates/puzzle.c +3 -2
  21. package/examples/gbc/templates/racing.c +3 -2
  22. package/examples/gbc/templates/shmup.c +3 -2
  23. package/examples/gbc/templates/sports.c +3 -2
  24. package/examples/gbc/templates/tile_engine.c +3 -2
  25. package/examples/genesis/main.s +53 -1
  26. package/examples/genesis/templates/hello_sprite.c +25 -3
  27. package/examples/genesis/templates/shmup_2p.c +31 -0
  28. package/examples/genesis/templates/xgm2_demo.c +20 -0
  29. package/examples/gg/templates/hello_sprite.c +25 -2
  30. package/examples/gg/templates/music_demo.c +24 -2
  31. package/examples/gg/templates/racing.c +7 -4
  32. package/examples/gg/templates/sports.c +11 -13
  33. package/examples/gg/templates/tile_engine.c +12 -6
  34. package/examples/lynx/templates/hello_sprite.c +15 -1
  35. package/examples/lynx/templates/music_demo.c +13 -1
  36. package/examples/nes/templates/hello_sprite.c +35 -0
  37. package/examples/nes/templates/music_demo.c +40 -0
  38. package/examples/pce/catch_game/main.c +22 -3
  39. package/examples/pce/music_sfx/main.c +28 -1
  40. package/examples/pce/sprite_move/main.c +7 -2
  41. package/examples/sms/templates/hello_sprite.c +29 -3
  42. package/examples/sms/templates/music_demo.c +18 -4
  43. package/examples/sms/templates/shmup_2p.c +24 -1
  44. package/examples/sms/templates/sports.c +4 -2
  45. package/examples/snes/main.asm +108 -17
  46. package/examples/snes/templates/c-hello-data.asm +23 -0
  47. package/examples/snes/templates/c-hello.c +18 -1
  48. package/examples/snes/templates/hello_sprite-data.asm +23 -0
  49. package/examples/snes/templates/hello_sprite.c +17 -1
  50. package/examples/snes/templates/music_demo-data.asm +23 -0
  51. package/examples/snes/templates/music_demo.c +22 -4
  52. package/examples/snes/templates/platformer.c +4 -1
  53. package/examples/snes/templates/puzzle.c +4 -1
  54. package/package.json +1 -1
  55. package/src/cheats/gamegenie.js +0 -1
  56. package/src/cli/smoke.js +1 -3
  57. package/src/host/LibretroHost.js +69 -15
  58. package/src/host/chafa-render.js +2 -0
  59. package/src/host/dsp-state.js +2 -2
  60. package/src/host/gpgx-state.js +4 -0
  61. package/src/http/routes.js +1 -1
  62. package/src/mcp/server.js +1 -1
  63. package/src/mcp/state.js +36 -0
  64. package/src/mcp/tools/address-to-symbol.js +0 -1
  65. package/src/mcp/tools/art-loaders.js +1 -1
  66. package/src/mcp/tools/cart-parts.js +0 -1
  67. package/src/mcp/tools/classify-region.js +1 -1
  68. package/src/mcp/tools/diff-roms.js +1 -1
  69. package/src/mcp/tools/disasm-rebuild.js +1 -1
  70. package/src/mcp/tools/disasm.js +2 -3
  71. package/src/mcp/tools/find-references.js +1 -2
  72. package/src/mcp/tools/font-map.js +1 -1
  73. package/src/mcp/tools/index.js +0 -49
  74. package/src/mcp/tools/input-layout.js +0 -1
  75. package/src/mcp/tools/input.js +33 -3
  76. package/src/mcp/tools/lifecycle.js +14 -2
  77. package/src/mcp/tools/lospec.js +0 -19
  78. package/src/mcp/tools/platform-docs.js +1 -1
  79. package/src/mcp/tools/platform-tools.js +4 -4
  80. package/src/mcp/tools/project.js +0 -2
  81. package/src/mcp/tools/reinject.js +0 -1
  82. package/src/mcp/tools/rom-id.js +2 -2
  83. package/src/mcp/tools/snippets.js +2 -2
  84. package/src/mcp/tools/sprite-pipeline.js +1 -2
  85. package/src/mcp/tools/tile-inspect.js +1 -1
  86. package/src/mcp/tools/toolchain.js +29 -9
  87. package/src/mcp/tools/watch-memory.js +13 -3
  88. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +34 -0
  89. package/src/platforms/atari2600/TROUBLESHOOTING.md +6 -0
  90. package/src/platforms/atari7800/TROUBLESHOOTING.md +6 -0
  91. package/src/platforms/c64/TROUBLESHOOTING.md +6 -0
  92. package/src/platforms/c64/d64.js +0 -1
  93. package/src/platforms/c64/sid.js +0 -2
  94. package/src/platforms/common/metasprite-adapters.js +1 -1
  95. package/src/platforms/common/metasprite-codegen.js +3 -3
  96. package/src/platforms/common/registers.js +5 -3
  97. package/src/platforms/gb/TROUBLESHOOTING.md +6 -0
  98. package/src/platforms/gb/lib/c/gb_runtime.c +4 -4
  99. package/src/platforms/gba/TROUBLESHOOTING.md +6 -0
  100. package/src/platforms/gbc/TROUBLESHOOTING.md +6 -0
  101. package/src/platforms/gbc/lib/c/gb_runtime.c +4 -4
  102. package/src/platforms/genesis/TROUBLESHOOTING.md +6 -0
  103. package/src/platforms/gg/TROUBLESHOOTING.md +6 -0
  104. package/src/platforms/lynx/TROUBLESHOOTING.md +6 -0
  105. package/src/platforms/msx/TROUBLESHOOTING.md +6 -0
  106. package/src/platforms/nes/TROUBLESHOOTING.md +6 -0
  107. package/src/platforms/nes/image-to-tilemap.js +3 -0
  108. package/src/platforms/nes/lib/asm/famitone2.s +5 -1
  109. package/src/platforms/pce/TROUBLESHOOTING.md +6 -0
  110. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  111. package/src/platforms/snes/TROUBLESHOOTING.md +6 -0
  112. package/src/platforms/snes/brr.js +0 -2
  113. package/src/playtest/playtest.js +0 -7
  114. package/src/toolchains/asar/asar.js +0 -9
  115. package/src/toolchains/assemble-snippet.js +30 -12
  116. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +14 -1
  117. package/src/toolchains/common/reassemble.js +0 -1
  118. package/src/toolchains/common/sdk-cache.js +1 -1
  119. package/src/toolchains/genesis-c/genesis-c.js +5 -3
  120. package/src/toolchains/index.js +27 -3
  121. package/src/toolchains/parse-errors.js +78 -1
  122. package/src/toolchains/sdcc/preflight-lint.js +5 -1
  123. package/src/toolchains/sdcc/sdcc.js +1 -1
  124. package/src/toolchains/sjasm/sjasm.js +1 -1
  125. package/src/toolchains/snes-c/snes-c.js +2 -2
  126. package/src/toolchains/vasm68k/vasm68k.js +2 -4
  127. package/src/toolchains/wladx/wladx.js +1 -1
@@ -25,11 +25,30 @@ import { runCa65, runLd65 } from "./cc65/cc65.js";
25
25
  import { runAsar } from "./asar/asar.js";
26
26
  import { runVasm68k } from "./vasm68k/vasm68k.js";
27
27
  import { runSdasz80, runSdld, ihxToBin } from "./sdcc/sdcc.js";
28
- import { runRgbasm, runRgblink, runRgbfix } from "./rgbds/rgbds.js";
28
+ import { runRgbasm, runRgblink } from "./rgbds/rgbds.js";
29
+ import { parseBuildLog } from "./parse-errors.js";
29
30
 
30
31
  const __filename = fileURLToPath(import.meta.url);
31
32
  const __dirname = path.dirname(__filename);
32
33
 
34
+ // Build a transparent snippet-assembly error: lead with the FIRST structured
35
+ // diagnostic (file:line: message) parsed out of the raw log — the same
36
+ // issues[]-style surfacing build() does — instead of dumping the unparsed
37
+ // assembler stdout and making the agent grep it. Full log still appended for
38
+ // fallback. `where` is e.g. "ca65" / "ld65 link" / "asar".
39
+ function asmError(where, log) {
40
+ const issues = parseBuildLog(log ?? "");
41
+ const first = issues.find((i) => i.severity === "error") ?? issues[0];
42
+ const headline = first
43
+ ? `${first.file ? first.file + ":" : ""}${first.line ? first.line + ": " : ""}${first.message}`
44
+ : "no structured diagnostic found";
45
+ return new Error(
46
+ `assembleSnippet[${where}] failed: ${headline}` +
47
+ (issues.length > 1 ? ` (+${issues.length - 1} more issue(s))` : "") +
48
+ `\nFix the source line above. Full assembler log:\n${log ?? ""}`,
49
+ );
50
+ }
51
+
33
52
  /**
34
53
  * CPU dialect → assembler dispatch. Keys are also the public API.
35
54
  */
@@ -102,7 +121,7 @@ async function assembleCa65({ origin, code }, cpu = "6502") {
102
121
 
103
122
  const asm = await runCa65({ source });
104
123
  if (!asm.object) {
105
- throw new Error(`assembleSnippet[ca65]: assembly failed.\nlog:\n${asm.log ?? ""}`);
124
+ throw asmError("ca65", asm.log);
106
125
  }
107
126
 
108
127
  // Minimal linker config: one MEMORY block at `origin`, one SEGMENT
@@ -116,7 +135,7 @@ SEGMENTS { CODE: load = OUT, type = ro; }
116
135
  linkerConfig: cfg,
117
136
  });
118
137
  if (!linked.binary) {
119
- throw new Error(`assembleSnippet[ld65]: link failed.\nlog:\n${linked.log ?? ""}`);
138
+ throw asmError("ld65 link", linked.log);
120
139
  }
121
140
  return { bytes: linked.binary, log: (asm.log ?? "") + (linked.log ?? "") };
122
141
  }
@@ -137,7 +156,7 @@ async function assembleAsar({ origin, code }) {
137
156
  const source = `org ${hex24}\n${code}\n`;
138
157
  const r = await runAsar({ source, baseRom, symbols: false });
139
158
  if (!r.binary) {
140
- throw new Error(`assembleSnippet[asar]: assembly failed.\nlog:\n${r.log ?? ""}`);
159
+ throw asmError("asar", r.log);
141
160
  }
142
161
  const bin = r.binary;
143
162
  // Find first non-sentinel byte (asar may have written anywhere depending
@@ -148,7 +167,7 @@ async function assembleAsar({ origin, code }) {
148
167
  if (bin[i] !== SENTINEL) { start = i; break; }
149
168
  }
150
169
  if (start < 0) {
151
- throw new Error(`assembleSnippet[asar]: no bytes written (origin 0x${origin.toString(16)})\nlog:\n${r.log ?? ""}`);
170
+ throw new Error(`assembleSnippet[asar]: no bytes written (origin 0x${origin.toString(16)}) — the source assembled but emitted nothing at this origin. Check the org address and that the code actually emits bytes.\nFull log:\n${r.log ?? ""}`);
152
171
  }
153
172
  let end = start + 1;
154
173
  let sentinelRun = 0;
@@ -181,7 +200,7 @@ async function assembleVasm68k({ origin, code }) {
181
200
  const source = `\torg $${origin.toString(16).toUpperCase()}\n${indented}\n`;
182
201
  const r = await runVasm68k({ source, options: ["-Fbin"] });
183
202
  if (!r.binary || (r.exitCode != null && r.exitCode !== 0)) {
184
- throw new Error(`assembleSnippet[vasm68k]: assembly failed.\nlog:\n${r.log ?? ""}`);
203
+ throw asmError("vasm68k", r.log);
185
204
  }
186
205
  return { bytes: r.binary, log: r.log ?? "" };
187
206
  }
@@ -199,7 +218,7 @@ async function assembleSdcc({ origin, code }) {
199
218
  const source = `\t.module snippet\n\t.area _CODE\n${indented}\n`;
200
219
  const asm = await runSdasz80({ source });
201
220
  if (!asm.rel) {
202
- throw new Error(`assembleSnippet[sdasz80]: assembly failed.\nlog:\n${asm.log ?? ""}`);
221
+ throw asmError("sdasz80", asm.log);
203
222
  }
204
223
  // Minimal empty crt0 rel: just declares _HEADER0 (zero bytes) and is
205
224
  // enough for sdld to satisfy its hardcoded crt0.rel dependency.
@@ -212,7 +231,7 @@ async function assembleSdcc({ origin, code }) {
212
231
  libraries: [],
213
232
  });
214
233
  if (!linked.ihx) {
215
- throw new Error(`assembleSnippet[sdld]: link failed.\nlog:\n${linked.log ?? ""}`);
234
+ throw asmError("sdld link", linked.log);
216
235
  }
217
236
  const padded = ihxToBin(linked.ihx, 0x10000, 0xFF);
218
237
  // Find first and last non-FF byte from origin onwards. The snippet bytes
@@ -232,19 +251,18 @@ async function assembleSdcc({ origin, code }) {
232
251
  async function assembleRgbds({ origin, code }) {
233
252
  // rgbasm needs SECTION declarations to place code at an address.
234
253
  const sectionAt = `$${origin.toString(16).toUpperCase()}`;
235
- const source = `SECTION "snippet", ROMX[${sectionAt}], BANK[0]\n${code}\n`;
236
- // Actually GB has no BANK[0] for fixed ROM — use ROM0 if origin < 0x4000.
254
+ // GB has no BANK[0] for fixed ROM — use ROM0 if origin < 0x4000, else ROMX BANK[1].
237
255
  const useRom0 = origin < 0x4000;
238
256
  const realSource = useRom0
239
257
  ? `SECTION "snippet", ROM0[${sectionAt}]\n${code}\n`
240
258
  : `SECTION "snippet", ROMX[${sectionAt}], BANK[1]\n${code}\n`;
241
259
  const asm = await runRgbasm({ source: realSource });
242
260
  if (!asm.object) {
243
- throw new Error(`assembleSnippet[rgbasm]: assembly failed.\nlog:\n${asm.log ?? ""}`);
261
+ throw asmError("rgbasm", asm.log);
244
262
  }
245
263
  const linked = await runRgblink({ objects: { "snippet.o": asm.object }, padValue: 0x00 });
246
264
  if (!linked.binary) {
247
- throw new Error(`assembleSnippet[rgblink]: link failed.\nlog:\n${linked.log ?? ""}`);
265
+ throw asmError("rgblink link", linked.log);
248
266
  }
249
267
  // Slice from origin, find last non-zero byte.
250
268
  const start = useRom0 ? origin : (origin - 0x4000 + 0x4000); // bank 1 lives at file 0x4000
@@ -19,7 +19,13 @@
19
19
  # 3. Write CHR data from C at runtime: PPUADDR = 0x00; PPUDATA = byte; etc.
20
20
 
21
21
  SYMBOLS {
22
- __STACKSIZE__: type = weak, value = $0300;
22
+ # Stack is $0200 (512 B) so the top RAM page ($0700-$07FF) can be
23
+ # reserved below for a music driver's scratch RAM (FamiTone2 et al.),
24
+ # which needs a dedicated, page-aligned block that the C BSS/DATA
25
+ # region must NOT overlap. Tiny NROM scaffolds use far less than 512 B
26
+ # of stack, so this is safe; scaffolds with no music driver simply
27
+ # leave the reserved page unused.
28
+ __STACKSIZE__: type = weak, value = $0200;
23
29
  }
24
30
  MEMORY {
25
31
  ZP: file = "", start = $0002, size = $001A, type = rw, define = yes;
@@ -37,6 +43,13 @@ MEMORY {
37
43
 
38
44
  SRAM: file = "", start = $0500, size = __STACKSIZE__, define = yes;
39
45
 
46
+ # Reserved page for a sound-driver's RAM scratch ($0700-$07FF). The
47
+ # bundled FamiTone2 engine (music_demo scaffold) pins FT_BASE_ADR here
48
+ # so its ~90 bytes of channel/envelope state can't collide with the C
49
+ # BSS/DATA at $0300-$04FF — that collision silently clobbers the NMI's
50
+ # cached PPUCTRL and stalls rendering. Unused by non-music scaffolds.
51
+ SNDRAM: file = "", start = $0700, size = $0100, define = yes;
52
+
40
53
  # BSS / DATA live in real RAM ($0300-$04FF, 512 bytes). NROM (mapper 0)
41
54
  # with no battery has $6000-$7FFF UNMAPPED — reads return open bus.
42
55
  # Putting BSS there means `int counter = 0;` reads garbage (intermittent
@@ -20,7 +20,6 @@ const hex2 = (b) => "$" + (b & 0xFF).toString(16).padStart(2, "0").toUpperCase()
20
20
  // Label form across disassemblers: `L` + 4..8 hex digits (m68k targets can be
21
21
  // 24-bit, e.g. an $E0FF00xx RAM mirror → LE0FF0080).
22
22
  const LABEL_RE = /^(L[0-9A-Fa-f]{4,8}):\s*$/;
23
- const LABEL_REF_RE = /\bL[0-9A-Fa-f]{4,8}\b/g;
24
23
 
25
24
  /** Parse one disasm line into {label?, code?, bytes?[]}. */
26
25
  function parseLine(line) {
@@ -17,7 +17,7 @@
17
17
  // This module is platform-agnostic: each toolchain passes in how to hash its
18
18
  // source, where its seed lives, and how to compile from source.
19
19
 
20
- import { readFile, writeFile, mkdir, stat } from "node:fs/promises";
20
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
21
21
  import { createHash } from "node:crypto";
22
22
  import path from "node:path";
23
23
  import os from "node:os";
@@ -127,7 +127,7 @@ async function compileSgdkRuntime(baseHeaders, cc1Options) {
127
127
  for (const [k, v] of Object.entries(localHeaders)) {
128
128
  if (/\.i80$/i.test(k)) z80Includes[path.basename(k)] = v;
129
129
  }
130
- async function loadI80From(dir, rel = "") {
130
+ async function loadI80From(dir, _rel = "") {
131
131
  let ents;
132
132
  try { ents = await readdir(dir, { withFileTypes: true }); } catch { return; }
133
133
  for (const e of ents) {
@@ -268,8 +268,10 @@ async function buildWithSgdk({ sources, headers, binaryIncludes, cc1Options, reb
268
268
  // implicit decls, …) parsed into structured issues[]. The SGDK runtime is
269
269
  // compiled WITHOUT these (sgdkCc1Options) — we can't fix SDK warnings and they'd
270
270
  // bury the agent's own. -Wno-unused-parameter avoids the common `(void)hard`
271
- // scaffold-param noise.
272
- const userCc1Options = [...sgdkCc1Options, "-Wall", "-Wextra", "-Wno-unused-parameter"];
271
+ // scaffold-param noise. -Wno-main: SGDK MANDATES `int main(bool hardReset)`
272
+ // (sys.c declares it and calls main(TRUE)/main(FALSE)); GCC's -Wmain objects to
273
+ // the non-standard signature, but it's REQUIRED here, not a bug — so silence it.
274
+ const userCc1Options = [...sgdkCc1Options, "-Wall", "-Wextra", "-Wno-unused-parameter", "-Wno-main"];
273
275
 
274
276
  // ── Stage A: gather SGDK headers (visible to tcc via tcc-style flat mount) ──
275
277
  // cc1's -iquote /work picks up sibling files mounted alongside main.c.
@@ -117,6 +117,27 @@ const PLATFORM_DEFAULT_LANGUAGE = {
117
117
  msx: "c",
118
118
  };
119
119
 
120
+ /**
121
+ * Order build issues by DANGER so the agent reads the lethal ones first:
122
+ * critical (crash-class, e.g. the uint8 infinite-loop) → error → warning → info.
123
+ * Stable within a rank (preserves source order / file:line). Pure; no dedup.
124
+ * @param {Array<{severity?:string, critical?:boolean}>} issues
125
+ * @returns {Array} the same issues, ranked
126
+ */
127
+ export function rankIssues(issues) {
128
+ const rank = (i) =>
129
+ i?.critical ? 0
130
+ : i?.severity === "error" ? 1
131
+ : i?.severity === "warning" ? 2
132
+ : 3;
133
+ // map→sort→unmap keeps it stable (Array.prototype.sort is stable in V8, but
134
+ // tie-break on original index to be explicit and portable).
135
+ return issues
136
+ .map((issue, idx) => ({ issue, idx }))
137
+ .sort((a, b) => rank(a.issue) - rank(b.issue) || a.idx - b.idx)
138
+ .map((x) => x.issue);
139
+ }
140
+
120
141
  /**
121
142
  * Public API for the platforms tool to discover the language matrix.
122
143
  * Returns `{defaultLanguage, languages: [{language, toolchain, available, note?}]}`.
@@ -835,10 +856,13 @@ export async function buildForPlatform(args) {
835
856
  r.log += "\n--- MSX ROM header present (\"AB\") ---";
836
857
  }
837
858
  }
838
- // Combine lint warnings with parsed build log. Lint comes first so
839
- // agents see them at the top of the issues array.
859
+ // Combine lint warnings with parsed build log, then RANK so an agent
860
+ // triaging issues[] sees the dangerous ones first: crash-class (critical)
861
+ // → errors → plain warnings. Without this, a "WILL HANG" infinite-loop
862
+ // warning sits among unused-variable noise and gets skipped (the exact
863
+ // "agent missed the warning, hit the crash 100 functions later" failure).
840
864
  const buildIssues = parseBuildLog(r.log);
841
- const issues = [...lintIssues, ...buildIssues];
865
+ const issues = rankIssues([...lintIssues, ...buildIssues]);
842
866
  return {
843
867
  ok: r.exitCode === 0 && binary !== null,
844
868
  binary,
@@ -32,6 +32,9 @@ export function parseBuildLog(log) {
32
32
  const baseStage = stage.split(/\s|\(/)[0].toLowerCase();
33
33
  if (/^cc65$|^ca65$|^ld65$/.test(baseStage)) {
34
34
  issues.push(...parseCc65Like(text, baseStage));
35
+ // ld65's linker-level errors (segment-missing / memory-overflow) carry no
36
+ // file:line and slip past parseCc65Like — pick them up too.
37
+ if (baseStage === "ld65") issues.push(...parseLd65Linker(text, baseStage));
35
38
  } else if (/^dasm$/.test(baseStage)) {
36
39
  issues.push(...parseDasm(text));
37
40
  } else if (/^asar$/.test(baseStage)) {
@@ -68,6 +71,7 @@ export function parseBuildLog(log) {
68
71
  // failure). Include vasm + sdcc + wla, which the old fallback omitted.
69
72
  const tag = baseStage || "unknown";
70
73
  issues.push(...parseCc65Like(text, tag));
74
+ issues.push(...parseLd65Linker(text, tag));
71
75
  issues.push(...parseSdcc(text, tag));
72
76
  issues.push(...parseDasm(text));
73
77
  issues.push(...parseAsar(text, tag));
@@ -109,6 +113,50 @@ function splitByStage(log) {
109
113
  return stages;
110
114
  }
111
115
 
116
+ // ld65 LINKER diagnostics have NO file:line — parseCc65Like (which requires a
117
+ // `file:line:` lead) misses them entirely, so a failed link returned issues[]
118
+ // EMPTY even though the real error sat in the log. The common ones on a
119
+ // mis-wired NES rebuild (wrong/absent linker config for the project's segments):
120
+ // ld65: Warning: Segment 'HEADER' does not exist
121
+ // ld65: Error: Memory area overflow in 'ROM0', segment 'CODE' (6 bytes)
122
+ // Error: Cannot generate most of the files due to memory area overflow
123
+ // They appear either bare or with an `ld65:`/`ld65.exe:` tool prefix. We add a
124
+ // short hint on the segment/overflow case (the linker config doesn't match the
125
+ // project's segments — the exact CHR-ROM-vs-CHR-RAM mismatch agents hit).
126
+ function parseLd65Linker(text, stage) {
127
+ const out = [];
128
+ const re = /^(?:ld65(?:\.exe)?:\s*)?(?<sev>Error|Warning):\s*(?<msg>.+)$/gm;
129
+ let m;
130
+ while ((m = re.exec(text))) {
131
+ const msg = m.groups.msg.trim().replace(/\x1b\[[0-9;]*m/g, "");
132
+ // Skip the file:line form — parseCc65Like already owns those (and a leading
133
+ // path would have been consumed as the message here, doubling the issue).
134
+ if (/^[^\s:]+:\d+:/.test(msg)) continue;
135
+ // Actionable hint in a SEPARATE field (matching GNU ld / sdld), not glued
136
+ // onto the message — two common ld65 link failures:
137
+ let hint;
138
+ if (/does not exist|overflow|Cannot generate/i.test(msg)) {
139
+ hint =
140
+ "The linker config's segments don't match the project. For an NROM " +
141
+ "rebuild use build({inesHeader:{...}}) or linkerConfig:'chr-rom'; otherwise " +
142
+ "check that your .cfg's SEGMENTS match the .segment names in your source.";
143
+ } else if (/unresolved external|undefined symbol/i.test(msg)) {
144
+ const sym = msg.match(/['"`]?(?<s>[A-Za-z_]\w*)['"`]?\s*$/)?.groups?.s;
145
+ hint =
146
+ `${sym ? `\`${sym}'` : "That symbol"} is referenced but never defined or linked. ` +
147
+ "Add the source/object that defines it (or the right cc65 lib for this platform) " +
148
+ "to your build, or check for a typo.";
149
+ }
150
+ out.push({
151
+ severity: m.groups.sev.toLowerCase() === "error" ? "error" : "warning",
152
+ message: msg,
153
+ stage: stage || "ld65",
154
+ ...(hint ? { hint } : {}),
155
+ });
156
+ }
157
+ return out;
158
+ }
159
+
112
160
  // cc65, ca65, ld65 all use the gcc-style `file:line:col?: severity: message`.
113
161
  // Example:
114
162
  // /work/main.s:12: Error: Cannot open include file 'longbranch.mac'
@@ -173,10 +221,25 @@ function parseSdcc(text, stage) {
173
221
  // An "Undefined Global" is effectively an error even though ASlink labels it
174
222
  // a warning — the ROM won't run. Promote it so the agent treats it as fatal.
175
223
  const isUndef = /undefined\s+global/i.test(msg);
224
+ // Give the same actionable hint GNU ld's undefined-reference path gets, so
225
+ // the SDCC platforms (GB/GBC/SMS/GG/MSX) reach parity on the single most
226
+ // common link failure (forgot to include the source/runtime that defines it).
227
+ // SDCC mangles C symbols with a leading '_' — show the C name too.
228
+ let hint;
229
+ if (isUndef) {
230
+ const sym = msg.match(/global\s+['"]?(?<s>\w+)['"]?/i)?.groups?.s;
231
+ const cName = sym && sym.startsWith("_") ? sym.slice(1) : sym;
232
+ hint =
233
+ `${sym ? `\`${sym}'` : "That symbol"} is called but never defined or linked` +
234
+ `${cName && cName !== sym ? ` (the C name is \`${cName}'; SDCC prefixes an underscore)` : ""}. ` +
235
+ "Add the source file (or runtime/library) that defines it to your build's sources/includes, " +
236
+ "or check for a typo in the name.";
237
+ }
176
238
  out.push({
177
239
  severity: lm.groups.sev === "Error" || isUndef ? "error" : "warning",
178
240
  message: "linker: " + msg,
179
241
  stage: "sdld",
242
+ ...(hint ? { hint } : {}),
180
243
  });
181
244
  }
182
245
  return out;
@@ -268,12 +331,26 @@ function parseWla(text, stage = "wla") {
268
331
  const reLink = /^(?<obj>\S+\.obj):\s*(?<file>[^\s:]+):(?<line>\d+):\s*(?<phase>[A-Z_]+):\s*(?<msg>.+)$/gm;
269
332
  let m;
270
333
  while ((m = reLink.exec(text))) {
334
+ const rawMsg = m.groups.msg.trim();
335
+ // An "unknown label" reference is SNES's undefined-symbol failure — give it
336
+ // the same actionable hint ld65/sdld/GNU ld carry, so SNES reaches parity.
337
+ let hint;
338
+ const lbl = /unknown label\s+["'`]?(?<l>[A-Za-z_]\w*)/i.exec(rawMsg)?.groups?.l;
339
+ if (lbl || /reference to an unknown/i.test(rawMsg)) {
340
+ hint =
341
+ `${lbl ? `\`${lbl}'` : "That label"} is referenced but never defined or linked. ` +
342
+ "Add the source/object that defines it (or the right PVSnesLib runtime) to your build, " +
343
+ "or check for a typo.";
344
+ }
271
345
  out.push({
272
346
  severity: "error",
273
347
  file: m.groups.file,
274
348
  line: parseInt(m.groups.line, 10),
275
- message: `${m.groups.phase}: ${m.groups.msg.trim()}`,
349
+ // Keep the wla internal phase name (FIX_REFERENCES, etc.) out of the
350
+ // agent-facing message — it's noise; the message + hint say what to do.
351
+ message: rawMsg,
276
352
  stage: stage === "wla" ? "wlalink" : stage,
353
+ ...(hint ? { hint } : {}),
277
354
  });
278
355
  }
279
356
  // wla-65816 assembler: "<file>:<line>: ERROR|WARNING: <message>"
@@ -136,10 +136,14 @@ export function lintSdccSource(source, file = "main.c", opts = {}) {
136
136
  if (bound !== null && bound > 255) {
137
137
  issues.push({
138
138
  severity: "warning",
139
+ // CRASH-CLASS: not cosmetic — this loop never exits and hangs the
140
+ // game. `critical` lifts it above ordinary warnings so an agent
141
+ // triaging issues[] can't miss it among unused-variable noise.
142
+ critical: true,
139
143
  file,
140
144
  line: i + 1,
141
145
  stage: "lint",
142
- message: `uint8 loop counter '${counter}' with bound ${bound} (> 255) — infinite loop`,
146
+ message: `WILL HANG: uint8 loop counter '${counter}' with bound ${bound} (> 255) — infinite loop`,
143
147
  details: `A u8/uint8_t/char counter can never reach ${bound}, so this loop never exits and all code after it is dead. ${portLabel} gives no warning. Declare '${counter}' as uint16_t. See GB TROUBLESHOOTING § uint8 loop-bound trap.`,
144
148
  ref: "uint8-loop-bound",
145
149
  });
@@ -56,7 +56,7 @@ export const SDCC_PORTS = {
56
56
  msx: { marg: "z80", libDir: "z80" },
57
57
  };
58
58
 
59
- import { runIsolated, textFile, binaryFile, getOutputBytes, getOutputText } from "../_worker/run.js";
59
+ import { runIsolated, textFile, getOutputText } from "../_worker/run.js";
60
60
 
61
61
  /**
62
62
  * Tag the abort/crash log with the SDCC-flavored hint pointing at the
@@ -5,7 +5,7 @@
5
5
  // #include that generated .h. Built by scripts/build-sjasm.sh.
6
6
 
7
7
  import { fileURLToPath } from "node:url";
8
- import { existsSync, readFileSync } from "node:fs";
8
+ import { existsSync } from "node:fs";
9
9
  import path from "node:path";
10
10
 
11
11
  const __filename = fileURLToPath(import.meta.url);
@@ -288,7 +288,7 @@ async function buildWithPvSnesLib({ sources, headers, tccOptions, wlaOptions, bi
288
288
  * Minimum-viable path (R16 original behavior). No PVSnesLib runtime;
289
289
  * bundled original crt0.asm + hdr.asm support a bare `int main()`.
290
290
  */
291
- async function buildMinimal({ sources, headers, tccOptions, wlaOptions, binaryIncludes = {} }) {
291
+ async function buildMinimal({ sources, headers, tccOptions, wlaOptions, _binaryIncludes = {} }) {
292
292
  let log = "";
293
293
  const hdrAsm = await readFile(path.join(MINIMAL_LIB_DIR, "hdr.asm"), "utf-8");
294
294
  const crt0Asm = await readFile(path.join(MINIMAL_LIB_DIR, "crt0.asm"), "utf-8");
@@ -380,7 +380,7 @@ async function buildMinimal({ sources, headers, tccOptions, wlaOptions, binaryIn
380
380
  let _headerCache = null;
381
381
  async function loadPvSnesLibHeaders() {
382
382
  if (_headerCache) return _headerCache;
383
- const { readdir, stat } = await import("node:fs/promises");
383
+ const { readdir } = await import("node:fs/promises");
384
384
  const out = {};
385
385
  /**
386
386
  * @param {string} dir
@@ -49,10 +49,8 @@ function vasm68kPreflight(source) {
49
49
  const issues = [];
50
50
 
51
51
  // Check 1: source includes an org $00000000 or starts at $00 implicitly.
52
- // vasm68k defaults to $00000000 if no org is given, so missing org is fine.
53
- // But if an `org $00000200` (or similar) appears WITHOUT a preceding
54
- // vector table, the cart is unbootable.
55
- const hasOrg0 = /\borg\s+\$0+\b/i.test(source) || /\borg\s+0\b/i.test(source);
52
+ // vasm68k defaults to $00000000 if no org is given, so a missing org is fine;
53
+ // what actually breaks boot is a missing reset vector, which we check below.
56
54
  const hasReset = /\bdc\.l\s+(\$00FF[E-F][0-9A-F]{3}|_reset|reset)\b/i.test(source);
57
55
  if (!hasReset) {
58
56
  issues.push(
@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
15
15
  import { existsSync } from "node:fs";
16
16
  import path from "node:path";
17
17
 
18
- import { runIsolated, textFile, binaryFile, getOutputBytes, getOutputText } from "../_worker/run.js";
18
+ import { runIsolated, textFile, binaryFile, getOutputBytes } from "../_worker/run.js";
19
19
 
20
20
  const __filename = fileURLToPath(import.meta.url);
21
21
  const __dirname = path.dirname(__filename);