romdevtools 0.14.0 → 0.16.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 (104) hide show
  1. package/AGENTS.md +18 -11
  2. package/CHANGELOG.md +94 -0
  3. package/README.md +1 -1
  4. package/examples/atari2600/main.asm +1 -1
  5. package/examples/atari2600/templates/default.asm +1 -1
  6. package/examples/atari2600/templates/paddle.asm +59 -47
  7. package/examples/atari7800/main.c +1 -1
  8. package/examples/atari7800/templates/default.c +1 -1
  9. package/examples/atari7800/templates/music_demo.c +1 -1
  10. package/examples/c64/main.c +1 -1
  11. package/examples/c64/templates/platformer.c +2 -2
  12. package/examples/c64/templates/puzzle.c +1 -1
  13. package/examples/c64/templates/racing.c +3 -3
  14. package/examples/c64/templates/shmup.c +6 -5
  15. package/examples/c64/templates/sports.c +4 -4
  16. package/examples/gb/main.asm +1 -1
  17. package/examples/gb/main.c +1 -1
  18. package/examples/gb/templates/puzzle.c +1 -1
  19. package/examples/gb/templates/racing.c +1 -1
  20. package/examples/gb/templates/shmup.c +1 -1
  21. package/examples/gba/templates/gba_hello.c +1 -1
  22. package/examples/gba/templates/maxmod_demo.c +1 -1
  23. package/examples/gba/templates/puzzle.c +17 -3
  24. package/examples/gba/templates/racing.c +16 -2
  25. package/examples/gba/templates/shmup.c +23 -4
  26. package/examples/gba/templates/tonc_hello.c +6 -4
  27. package/examples/gbc/main.asm +1 -1
  28. package/examples/gbc/templates/puzzle.c +1 -1
  29. package/examples/gbc/templates/racing.c +1 -1
  30. package/examples/gbc/templates/shmup.c +1 -1
  31. package/examples/genesis/main.s +1 -1
  32. package/examples/genesis/templates/puzzle.c +1 -1
  33. package/examples/genesis/templates/racing.c +45 -1
  34. package/examples/genesis/templates/shmup.c +12 -3
  35. package/examples/genesis/templates/shmup_2p.c +2 -2
  36. package/examples/genesis/templates/sports.c +39 -0
  37. package/examples/gg/templates/hello_sprite.c +38 -23
  38. package/examples/gg/templates/music_demo.c +11 -8
  39. package/examples/gg/templates/platformer.c +37 -15
  40. package/examples/gg/templates/racing.c +25 -12
  41. package/examples/gg/templates/shmup.c +12 -6
  42. package/examples/gg/templates/sports.c +30 -16
  43. package/examples/gg/templates/tile_engine.c +24 -10
  44. package/examples/lynx/templates/platformer.c +7 -1
  45. package/examples/lynx/templates/puzzle.c +8 -2
  46. package/examples/lynx/templates/racing.c +7 -1
  47. package/examples/lynx/templates/sports.c +7 -1
  48. package/examples/nes/main.c +2 -2
  49. package/examples/nes/space-shooter/nes_runtime.h +1 -1
  50. package/examples/nes/templates/default.c +4 -1
  51. package/examples/nes/templates/racing.c +50 -1
  52. package/examples/pce/main.c +1 -1
  53. package/examples/sms/templates/hello_sprite.c +1 -1
  54. package/examples/sms/templates/music_demo.c +1 -1
  55. package/examples/sms/templates/puzzle.c +1 -1
  56. package/examples/sms/templates/racing.c +1 -1
  57. package/examples/sms/templates/shmup.c +1 -1
  58. package/examples/sms/templates/shmup_2p.c +2 -2
  59. package/examples/snes/main.asm +1 -1
  60. package/examples/snes/templates/c-hello-data.asm +309 -14
  61. package/examples/snes/templates/c-hello.c +13 -2
  62. package/examples/snes/templates/default.c +1 -1
  63. package/examples/snes/templates/hello_sprite-data.asm +300 -2
  64. package/examples/snes/templates/hello_sprite.c +10 -1
  65. package/examples/snes/templates/music_demo-data.asm +300 -2
  66. package/examples/snes/templates/music_demo.c +10 -1
  67. package/examples/snes/templates/platformer-data.asm +300 -2
  68. package/examples/snes/templates/platformer.c +10 -1
  69. package/examples/snes/templates/puzzle-data.asm +300 -2
  70. package/examples/snes/templates/puzzle.c +11 -1
  71. package/examples/snes/templates/racing-data.asm +300 -2
  72. package/examples/snes/templates/racing.c +40 -4
  73. package/examples/snes/templates/shmup-data.asm +299 -6
  74. package/examples/snes/templates/shmup.c +11 -7
  75. package/examples/snes/templates/sports-data.asm +300 -2
  76. package/examples/snes/templates/sports.c +40 -5
  77. package/package.json +1 -1
  78. package/src/http/skill-doc.js +1 -1
  79. package/src/http/tool-registry.js +1 -1
  80. package/src/mcp/tools/index.js +4 -4
  81. package/src/mcp/tools/project.js +33 -22
  82. package/src/mcp/tools/toolchain.js +196 -20
  83. package/src/observer/livestream.html +34 -4
  84. package/src/platforms/gg/MENTAL_MODEL.md +14 -13
  85. package/src/platforms/gg/lib/c/vdp_init.c +10 -8
  86. package/src/platforms/msx/MENTAL_MODEL.md +1 -1
  87. package/src/platforms/nes/TROUBLESHOOTING.md +1 -1
  88. package/src/platforms/nes/lib/c/nes_runtime.c +28 -6
  89. package/src/platforms/pce/MENTAL_MODEL.md +1 -1
  90. package/src/platforms/pce/lib/c/pce_hw.h +1 -0
  91. package/src/platforms/pce/lib/c/pce_video.c +26 -0
  92. package/src/platforms/sms/MENTAL_MODEL.md +12 -12
  93. package/src/platforms/sms/lib/c/vdp_init.c +10 -8
  94. package/src/platforms/sms/lib/vdp_init.s +1 -1
  95. package/src/toolchains/cc65/cc65.js +8 -1
  96. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +1 -1
  97. package/src/toolchains/cc65/presets/nes/chr-ram.cfg +1 -1
  98. package/src/toolchains/cc65/presets/nes/chr-ram.crt0.s +1 -1
  99. package/src/toolchains/gba-c/gba-c.js +6 -1
  100. package/src/toolchains/genesis-c/README.md +1 -1
  101. package/src/toolchains/genesis-c/genesis-c.js +10 -2
  102. package/src/toolchains/parse-errors.js +67 -5
  103. package/src/toolchains/sdcc/preflight-lint.js +47 -7
  104. package/src/toolchains/snes-c/snes-c.js +3 -7
@@ -3,15 +3,17 @@
3
3
  * Writes the 11 mode-4 registers to a sane baseline:
4
4
  * display OFF, vblank IRQ off, 192-line mode 4, name table at $3800,
5
5
  * BG tile data at $0000, sprite attr table at $3F00, sprite tile data
6
- * at $0000 (R6=0xFB → SA13 clear → tiles read from $0000-$1FFF). Call
6
+ * at $2000 (R6=0xFF → SA13 set → tiles read from $2000-$3FFF). Call
7
7
  * once after reset before uploading palette/tiles/map.
8
8
  *
9
- * Footgun: many SMS references say "R6=0xFB sprite tiles at $2000"
10
- * which is BACKWARDS. R6 bit 2 (the SA13 select) is CLEAR in 0xFB, so
11
- * sprite tiles read from $0000 (sharing the bank with BG tiles). To
12
- * separate sprite tiles to $2000, set R6 = 0xFF instead. The
13
- * sprites({op:'inspect'}) tool's spriteTileDataBase field will show you the
14
- * real address the VDP is reading from.
9
+ * Sprite-tile base: R6 bit 2 (SA13) selects $0000 (clear) vs $2000 (set).
10
+ * We default to R6=0xFF ($2000) because EVERY bundled scaffold uploads its
11
+ * sprite tiles to $2000 (sms_load_tiles(0x2000, ...)) so the baseline must
12
+ * match what consumers do, or sprites read from the empty/BG bank and render
13
+ * invisible. (Many SMS references say "R6=0xFB $2000", which is backwards:
14
+ * 0xFB has SA13 CLEAR = $0000.) If you instead keep sprite tiles in the BG
15
+ * bank at $0000, set R6=0xFB. sprites({op:'inspect'}) → spriteTileDataBase
16
+ * shows the address the VDP is actually reading from.
15
17
  *
16
18
  * After loading assets, enable display by re-writing R1 with bit 6 set:
17
19
  * sms_vdp_display_on();
@@ -33,7 +35,7 @@ void sms_vdp_init(void) {
33
35
  0xFF, /* R3: color table (ignored in M4) */
34
36
  0xFF, /* R4: BG tile data at $0000 */
35
37
  0xFF, /* R5: sprite attr table at $3F00 */
36
- 0xFB, /* R6: sprite tile data at $0000 (set 0xFF for $2000) */
38
+ 0xFF, /* R6: sprite tile data at $2000 (SA13 set; scaffolds upload here) */
37
39
  0x00, /* R7: border = sprite palette entry 0 */
38
40
  0x00, /* R8: BG X scroll */
39
41
  0x00, /* R9: BG Y scroll */
@@ -40,7 +40,7 @@ _vdp_init_regs:
40
40
  .db $FF ; R3: color table — M4 ignores
41
41
  .db $FF ; R4: BG tile data — M4: bit 2 selects $0000 vs $2000
42
42
  .db $FF ; R5: sprite attr table base ($3F00 = $7E << 7)
43
- .db $FB ; R6: sprite tile data bit 2 selects bank ($2000 here)
43
+ .db $FF ; R6: sprite tile data at $2000 (SA13 set; scaffolds upload here)
44
44
  .db $00 ; R7: border color = sprite palette entry 0
45
45
  .db $00 ; R8: BG X scroll
46
46
  .db $00 ; R9: BG Y scroll
@@ -257,7 +257,14 @@ export function parseRamUsage(mapText) {
257
257
  * @param {string} [args.linkerConfig] custom ld65 .cfg (overrides per-target default)
258
258
  */
259
259
  export async function buildC(args) {
260
- const ccOpts = args.debug ? ["-g"] : [];
260
+ // Enable cc65's high-value warnings so the agent SEES real bugs (parsed into
261
+ // structured issues[]). These are the valid cc65 -W names that catch actual
262
+ // mistakes; unused-param is left off (scaffold callbacks commonly ignore
263
+ // params). cc65's warning set is thin to begin with, but errors always surface
264
+ // and these are pure upside. (NOTE: cc65 errors on an unknown -W name, so this
265
+ // list is verified valid against the bundled cc65.)
266
+ const ccWarn = ["-W", "unused-var,unused-func,unused-label,const-comparison,struct-param,pointer-sign"];
267
+ const ccOpts = args.debug ? ["-g", ...ccWarn] : [...ccWarn];
261
268
  const caOpts = args.debug ? ["-g"] : [];
262
269
  const sources = normalizeSources(args, "main.c");
263
270
 
@@ -12,7 +12,7 @@
12
12
  # runtime. There is no CHARS segment.
13
13
  #
14
14
  # To use this preset:
15
- # 1. linkerConfig: "chr-ram" on buildSource
15
+ # 1. linkerConfig: "chr-ram" on build({output:'rom'})
16
16
  # 2. Supply your own crt0/header source that writes the 16-byte iNES
17
17
  # header with byte 5 = 0. Easiest: paste the snippet
18
18
  # getStarterSnippet({platform:"nes", name:"chr_ram_header", language:"asm"}).
@@ -12,7 +12,7 @@
12
12
  # runtime. There is no CHARS segment.
13
13
  #
14
14
  # To use this preset:
15
- # 1. linkerConfig: "chr-ram" on buildSource
15
+ # 1. linkerConfig: "chr-ram" on build({output:'rom'})
16
16
  # 2. Supply your own crt0/header source that writes the 16-byte iNES
17
17
  # header with byte 5 = 0. Easiest: paste the snippet
18
18
  # getStarterSnippet({platform:"nes", name:"chr_ram_header", language:"asm"}).
@@ -91,7 +91,7 @@ _exit: jsr donelib
91
91
  ; rti
92
92
  ;
93
93
  ; ; then DELETE the `nmi: rti` line below from this crt0 (or load a
94
- ; ; modified crt0 via your own buildSource sources entry).
94
+ ; ; modified crt0 via your own build({output:'rom'}) sources entry).
95
95
 
96
96
  .segment "STARTUP"
97
97
 
@@ -92,7 +92,12 @@ export async function buildGbaC(args) {
92
92
  // per-symbol `.bss.<name>`/`.data.<name>` line — that's what lets
93
93
  // symbols({op:'resolve'}) turn a static C global's name into an address on GBA
94
94
  // (same as SGDK does for Genesis). Pure metadata; no codegen change to what's kept.
95
- const cc1Options = args.cc1Options ?? ["-O2", "-mthumb", "-ffunction-sections", "-fdata-sections"];
95
+ // -Wall -Wextra so the agent SEES warnings (unused vars, implicit decls,
96
+ // sign-compare, etc.) — they're parsed into structured issues[]. Without these
97
+ // gcc is silent and agents build blind. -Wno-unused-parameter keeps the common
98
+ // intentional `(void)`-style scaffold params from being noise. Applied to USER
99
+ // .c only (the libtonc/maxmod SDK is a prebuilt seed, not recompiled here).
100
+ const cc1Options = args.cc1Options ?? ["-O2", "-mthumb", "-ffunction-sections", "-fdata-sections", "-Wall", "-Wextra", "-Wno-unused-parameter"];
96
101
  const sources = normalizeGbaSources(args);
97
102
  const binaryIncludes = args.binaryIncludes ?? {};
98
103
 
@@ -23,7 +23,7 @@ once the WASM artifacts ship. Pipeline shape:
23
23
  - **WASM port of cc1/as/ld (stage 2)**: pending.
24
24
  - **SGDK native build against the cross-toolchain (stage 3)**: pending.
25
25
  - **JS driver `buildGenesisC()`**: pending.
26
- - **buildSource wiring**: pending.
26
+ - **build({output:'rom'}) wiring**: pending.
27
27
 
28
28
  ## Why a 3-stage build
29
29
 
@@ -264,6 +264,12 @@ async function buildWithSgdk({ sources, headers, binaryIncludes, cc1Options, reb
264
264
  "-fdata-sections",
265
265
  ...cc1Options,
266
266
  ];
267
+ // USER source gets warnings on so the agent SEES its bugs (unused vars,
268
+ // implicit decls, …) parsed into structured issues[]. The SGDK runtime is
269
+ // compiled WITHOUT these (sgdkCc1Options) — we can't fix SDK warnings and they'd
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"];
267
273
 
268
274
  // ── Stage A: gather SGDK headers (visible to tcc via tcc-style flat mount) ──
269
275
  // cc1's -iquote /work picks up sibling files mounted alongside main.c.
@@ -280,7 +286,7 @@ async function buildWithSgdk({ sources, headers, binaryIncludes, cc1Options, reb
280
286
  const cc = await runCc1m68k({
281
287
  source: sources[cName],
282
288
  headers: tccHeaders,
283
- options: sgdkCc1Options,
289
+ options: userCc1Options,
284
290
  });
285
291
  log += `--- cc1 (${cName}) ---\n` + (cc.log || "(ok)") + "\n";
286
292
  if (cc.exitCode !== 0 || !cc.asmSource) {
@@ -469,12 +475,14 @@ async function buildMinimal(args) {
469
475
  // ── Stage 1: compile each .c file via cc1 → .s ─────────────────
470
476
  /** @type {Record<string, Uint8Array>} */
471
477
  const userObjs = {};
478
+ // User .c gets warnings on (minimal path has no SDK to flood). See buildWithSgdk.
479
+ const userCc1Options = [...cc1Options, "-Wall", "-Wextra", "-Wno-unused-parameter"];
472
480
  const cFiles = Object.keys(sources).filter((n) => /\.c$/i.test(n));
473
481
  for (const cName of cFiles) {
474
482
  const cc = await runCc1m68k({
475
483
  source: sources[cName],
476
484
  headers,
477
- options: cc1Options,
485
+ options: userCc1Options,
478
486
  });
479
487
  log += `--- cc1 (${cName}) ---\n` + (cc.log || "(ok)") + "\n";
480
488
  if (cc.exitCode !== 0 || !cc.asmSource) {
@@ -41,10 +41,12 @@ export function parseBuildLog(log) {
41
41
  } else if (/^vasm/.test(baseStage)) {
42
42
  issues.push(...parseVasm(text));
43
43
  } else if (/^sdcc$|^sdasz80$|^sdasgb$|^sdld$|^mcpp$/.test(baseStage)) {
44
- // SDCC family: sdcc / sdasz80 / sdasgb / sdld / mcpp emit cc65-style
45
- // `file:line: severity: msg` errors. Tag with the actual originating
46
- // tool, not "asar" (the old fallback was wrong).
44
+ // SDCC family: sdcc / sdasz80 / sdasgb / sdld / mcpp. Some diagnostics use
45
+ // the cc65-style `file:line: Error: msg`; SDCC's frontend ALSO emits a
46
+ // keyword-less form `main.c:2: syntax error: token -> ';' ; column 44`
47
+ // and `main.c:N: warning NNN: msg` — which parseCc65Like misses. Run both.
47
48
  issues.push(...parseCc65Like(text, baseStage));
49
+ issues.push(...parseSdcc(text, baseStage));
48
50
  } else if (/^wla|^wlalink|^wladx/.test(baseStage)) {
49
51
  // SNES C path: wla-65816 assembler + wlalink linker. wlalink floods a
50
52
  // symbol-table dump on failure — parseWla extracts just the diagnostics.
@@ -60,11 +62,18 @@ export function parseBuildLog(log) {
60
62
  // Tag everything with the (possibly empty) actual stage name so an
61
63
  // assembler error doesn't mistakenly report as "asar" on a non-SNES
62
64
  // build.
65
+ // Try EVERY parser — some toolchains (vasm genesis-asm) emit no
66
+ // "--- stage ---" marker, so the whole log lands here unnamed; if we skip
67
+ // a parser the error is silently swallowed (issues[] empty on a real
68
+ // failure). Include vasm + sdcc + wla, which the old fallback omitted.
63
69
  const tag = baseStage || "unknown";
64
70
  issues.push(...parseCc65Like(text, tag));
71
+ issues.push(...parseSdcc(text, tag));
65
72
  issues.push(...parseDasm(text));
66
73
  issues.push(...parseAsar(text, tag));
67
74
  issues.push(...parseRgbds(text, tag));
75
+ issues.push(...parseVasm(text));
76
+ issues.push(...parseWla(text, tag));
68
77
  issues.push(...parseGnuToolchain(text, tag));
69
78
  }
70
79
  }
@@ -122,6 +131,57 @@ function parseCc65Like(text, stage) {
122
131
  return out;
123
132
  }
124
133
 
134
+ // SDCC's keyword-less diagnostics that parseCc65Like (which requires an explicit
135
+ // "Error:"/"Warning:" word) doesn't catch:
136
+ // /work/main.c:2: syntax error: token -> ';' ; column 44
137
+ // /work/main.c:7: warning 112: function 'foo' implicit declaration
138
+ // /work/main.c:9: error 20: undefined identifier 'x'
139
+ // We classify by the leading word after `file:line:` (syntax error / error / warning).
140
+ function parseSdcc(text, stage) {
141
+ const out = [];
142
+ const re = /^(?<file>[^\n:]+):(?<line>\d+):\s*(?<kind>syntax error|error(?:\s+\d+)?|warning(?:\s+\d+)?):?\s*(?<msg>.*)$/gm;
143
+ let m;
144
+ while ((m = re.exec(text))) {
145
+ const kind = m.groups.kind.toLowerCase();
146
+ // Skip the forms parseCc65Like already caught ("Error:" capitalized w/ colon)
147
+ // — this regex is case-insensitive on `error`/`warning`, but parseCc65Like
148
+ // only matches when a colon immediately follows the keyword AND it's
149
+ // capitalized; SDCC's lowercase keyword-less form is what we add here.
150
+ const severity = kind.startsWith("warning") ? "warning"
151
+ : (kind.startsWith("error") || kind === "syntax error") ? "error" : "info";
152
+ const message = kind === "syntax error"
153
+ ? ("syntax error: " + m.groups.msg).trim().replace(/\s*;\s*$/, "")
154
+ : m.groups.msg.trim();
155
+ out.push({
156
+ severity,
157
+ file: m.groups.file,
158
+ line: parseInt(m.groups.line, 10),
159
+ message: message.replace(/\x1b\[[0-9;]*m/g, ""),
160
+ stage,
161
+ });
162
+ }
163
+ // sdld/ASlink linker diagnostics have NO file:line — they reference a symbol +
164
+ // module. The most common is an undefined symbol (a call to a function that was
165
+ // never defined/linked). Without parsing these the agent sees "build failed"
166
+ // with no reason in issues[] (the error lived only in the raw log).
167
+ // ?ASlink-Warning-Undefined Global '_foo' referenced by module '_main'
168
+ // ?ASlink-Error-...
169
+ const linkRe = /^\?ASlink-(?<sev>Warning|Error)-(?<msg>.+)$/gm;
170
+ let lm;
171
+ while ((lm = linkRe.exec(text))) {
172
+ const msg = lm.groups.msg.trim();
173
+ // An "Undefined Global" is effectively an error even though ASlink labels it
174
+ // a warning — the ROM won't run. Promote it so the agent treats it as fatal.
175
+ const isUndef = /undefined\s+global/i.test(msg);
176
+ out.push({
177
+ severity: lm.groups.sev === "Error" || isUndef ? "error" : "warning",
178
+ message: "linker: " + msg,
179
+ stage: "sdld",
180
+ });
181
+ }
182
+ return out;
183
+ }
184
+
125
185
  // dasm example:
126
186
  // main.asm (1): error: Unknown Mnemonic 'is'.
127
187
  function parseDasm(text) {
@@ -248,13 +308,15 @@ function parseWla(text, stage = "wla") {
248
308
  // vasm example:
249
309
  // error 22 in line 5 of "/work/main.s": ...
250
310
  // warning 1003 in line 8 of "main.s": ...
311
+ // fatal error 13 in line 1 of "/work/main.s": could not open <x.bin> for input
312
+ // (← a MISSING incbin asset: the #1 thing an agent forgets to pass)
251
313
  function parseVasm(text) {
252
314
  const out = [];
253
- const re = /^(?<sev>error|warning)\s+\d+\s+in\s+line\s+(?<line>\d+)\s+of\s+"(?<file>[^"]+)":\s*(?<msg>.+)$/gm;
315
+ const re = /^(?<sev>fatal error|error|warning)\s+\d+\s+in\s+line\s+(?<line>\d+)\s+of\s+"(?<file>[^"]+)":\s*(?<msg>.+)$/gm;
254
316
  let m;
255
317
  while ((m = re.exec(text))) {
256
318
  out.push({
257
- severity: m.groups.sev,
319
+ severity: m.groups.sev === "warning" ? "warning" : "error",
258
320
  file: m.groups.file,
259
321
  line: parseInt(m.groups.line, 10),
260
322
  message: m.groups.msg.trim(),
@@ -68,13 +68,53 @@ export function lintSdccSource(source, file = "main.c", opts = {}) {
68
68
  // CONSERVATIVE: only flag when we can SEE the counter declared u8 and
69
69
  // the bound is a constant we can evaluate — never guess.
70
70
  {
71
- // Collect names declared as 8-bit ints anywhere in the file.
72
- const u8re = /\b(?:unsigned\s+char|char|u8|uint8_t|uint8|int8_t|int8|signed\s+char)\s+([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)\s*;/g;
73
- const u8names = new Set();
74
- let dm;
75
- while ((dm = u8re.exec(source))) {
76
- for (const n of dm[1].split(",")) u8names.add(n.trim());
71
+ // Build a SCOPE-AWARE map of where each name is declared and at what
72
+ // width. A name like `i` is commonly re-declared in several functions
73
+ // some as uint8_t, some as uint16_t. A flat "is this name ever u8"
74
+ // set wrongly flags the uint16_t loop just because a DIFFERENT
75
+ // function declared its own `i` as uint8_t (the SMS/GG default
76
+ // scaffold false-positive). Instead we record EVERY declaration's
77
+ // line + width, then for each loop consult the nearest declaration of
78
+ // the counter that appears ABOVE the loop — i.e. the one actually in
79
+ // scope — and only flag it when that declaration is 8-bit.
80
+ //
81
+ // decls: Map<name, Array<{line:number, u8:boolean}>> (line is 1-based)
82
+ const decls = new Map();
83
+ const addDecl = (names, line, u8) => {
84
+ for (const raw of names.split(",")) {
85
+ const n = raw.trim();
86
+ if (!n) continue;
87
+ if (!decls.has(n)) decls.set(n, []);
88
+ decls.get(n).push({ line, u8 });
89
+ }
90
+ };
91
+ const u8re = /\b(?:unsigned\s+char|char|u8|uint8_t|uint8|int8_t|int8|signed\s+char)\s+([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)\s*;/;
92
+ // 16-bit-or-wider integer declarations (and float/double, which also
93
+ // can't overflow at 255). Anything not 8-bit is "wide" for our purpose.
94
+ const wideRe = /\b(?:unsigned\s+(?:short|int|long)|signed\s+(?:short|int|long)|short|int|long|u16|u32|u64|uint16_t|uint16|int16_t|int16|uint32_t|uint32|int32_t|int32|uint64_t|uint64|int64_t|int64|size_t|ptrdiff_t)\s+([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)\s*;/;
95
+ for (let i = 0; i < lines.length; i++) {
96
+ const code = lines[i].replace(/\/\/.*$/, "").replace(/\/\*.*?\*\//g, "");
97
+ const m8 = code.match(u8re);
98
+ if (m8) { addDecl(m8[1], i + 1, true); continue; }
99
+ const mw = code.match(wideRe);
100
+ if (mw) addDecl(mw[1], i + 1, false);
77
101
  }
102
+ // Is the counter declared 8-bit in the scope visible at `loopLine`?
103
+ // Use the NEAREST declaration above the loop. If the nearest visible
104
+ // declaration is wide (uint16_t etc.), the loop is fine.
105
+ const counterIsU8AtLine = (counter, loopLine) => {
106
+ const ds = decls.get(counter);
107
+ if (!ds) return false;
108
+ let best = null;
109
+ for (const d of ds) {
110
+ if (d.line <= loopLine && (best === null || d.line > best.line)) best = d;
111
+ }
112
+ // No declaration above the loop (e.g. param/global declared after, or
113
+ // out-of-order) — fall back to "flag only if EVERY decl is u8" so we
114
+ // never wolf-cry on a name that is also declared wide somewhere.
115
+ if (best === null) return ds.every((d) => d.u8);
116
+ return best.u8;
117
+ };
78
118
  const evalConst = (expr) => {
79
119
  // Only literals and pure `A * B [* C]` products of decimal/hex ints.
80
120
  const t = expr.trim();
@@ -91,7 +131,7 @@ export function lintSdccSource(source, file = "main.c", opts = {}) {
91
131
  const m = code.match(/\bfor\s*\([^;]*;\s*([A-Za-z_]\w*)\s*<\s*([^;]+?)\s*;/);
92
132
  if (!m) continue;
93
133
  const [, counter, boundExpr] = m;
94
- if (!u8names.has(counter)) continue;
134
+ if (!counterIsU8AtLine(counter, i + 1)) continue;
95
135
  const bound = evalConst(boundExpr);
96
136
  if (bound !== null && bound > 255) {
97
137
  issues.push({
@@ -148,13 +148,9 @@ function normalizeSnesSources(args) {
148
148
  if (cFiles.length === 0) {
149
149
  throw new Error("buildSnesC: `sources` must include at least one .c file.");
150
150
  }
151
- if (cFiles.length > 1) {
152
- throw new Error(
153
- `buildSnesC: multiple .c files in sources (${cFiles.join(", ")}). ` +
154
- `Today only one .c file is supported per build — combine via #include or wait for ` +
155
- `multi-TU support. .asm/.s siblings work fine.`,
156
- );
157
- }
151
+ // Multiple .c files ARE supported: buildWithPvSnesLib compiles each to its
152
+ // own .obj (tcc→wla) and links them all (Stage 1 + Stage 3). The genre
153
+ // scaffolds ship main.c + snes_sfx.c and rely on this.
158
154
  return args.sources;
159
155
  }
160
156
  if (typeof args.source === "string") {