romdevtools 0.71.1 → 0.84.1

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 (105) hide show
  1. package/AGENTS.md +19 -16
  2. package/CHANGELOG.md +184 -0
  3. package/README.md +5 -5
  4. package/examples/README.md +1 -0
  5. package/examples/gametank/templates/gt_draw.h +91 -0
  6. package/examples/gametank/templates/gt_hud.h +89 -0
  7. package/examples/gametank/templates/gt_palette.h +59 -0
  8. package/examples/gametank/templates/gt_sound.h +90 -0
  9. package/examples/gametank/templates/gt_sprites.h +53 -0
  10. package/examples/gametank/templates/platformer.c +318 -0
  11. package/examples/gametank/templates/puzzle.c +295 -0
  12. package/examples/gametank/templates/racing.c +139 -0
  13. package/examples/gametank/templates/shmup.c +277 -0
  14. package/examples/gametank/templates/sports.c +119 -0
  15. package/package.json +24 -22
  16. package/src/cores/capabilities.js +24 -0
  17. package/src/cores/registry.js +7 -1
  18. package/src/host/LibretroHost.js +90 -17
  19. package/src/host/callbacks.js +20 -2
  20. package/src/host/cpu-state.js +17 -0
  21. package/src/host/gametank-acp-state.js +53 -0
  22. package/src/http/skill-doc.js +10 -6
  23. package/src/mcp/tools/audio.js +1 -1
  24. package/src/mcp/tools/cart-parts.js +76 -0
  25. package/src/mcp/tools/lifecycle.js +1 -1
  26. package/src/mcp/tools/platform-tools.js +9 -1
  27. package/src/platforms/gametank/lib/gt/audio/acp_image.bin +0 -0
  28. package/src/platforms/gametank/lib/gt/audio/audio_coprocessor.c +55 -0
  29. package/src/platforms/gametank/lib/gt/audio/audio_coprocessor.h +34 -0
  30. package/src/platforms/gametank/lib/gt/audio/audio_fw.asm +223 -0
  31. package/src/platforms/gametank/lib/gt/audio/audio_fw_blob.s +8 -0
  32. package/src/platforms/gametank/lib/gt/audio/instruments.c +79 -0
  33. package/src/platforms/gametank/lib/gt/audio/instruments.h +25 -0
  34. package/src/platforms/gametank/lib/gt/audio/music.c +370 -0
  35. package/src/platforms/gametank/lib/gt/audio/music.h +35 -0
  36. package/src/platforms/gametank/lib/gt/audio/note_numbers.h +132 -0
  37. package/src/platforms/gametank/lib/gt/audio/scaled_sines.raw +0 -0
  38. package/src/platforms/gametank/lib/gt/audio/sine_256_-63_63.bin +0 -0
  39. package/src/platforms/gametank/lib/gt/banking.c +29 -0
  40. package/src/platforms/gametank/lib/gt/banking.h +8 -0
  41. package/src/platforms/gametank/lib/gt/banking2.s +41 -0
  42. package/src/platforms/gametank/lib/gt/crt0.s +102 -0
  43. package/src/platforms/gametank/lib/gt/draw_logo.s +55 -0
  44. package/src/platforms/gametank/lib/gt/feature/persist/persist.c +103 -0
  45. package/src/platforms/gametank/lib/gt/feature/persist/persist.h +13 -0
  46. package/src/platforms/gametank/lib/gt/feature/random/random.c +40 -0
  47. package/src/platforms/gametank/lib/gt/feature/random/random.h +18 -0
  48. package/src/platforms/gametank/lib/gt/feature/text/text.c +111 -0
  49. package/src/platforms/gametank/lib/gt/feature/text/text.h +25 -0
  50. package/src/platforms/gametank/lib/gt/gametank.c +12 -0
  51. package/src/platforms/gametank/lib/gt/gametank.h +80 -0
  52. package/src/platforms/gametank/lib/gt/gametank_logo.inc +393 -0
  53. package/src/platforms/gametank/lib/gt/gen/assets/assets_index.h +9 -0
  54. package/src/platforms/gametank/lib/gt/gen/assets/sdk_default.h +5 -0
  55. package/src/platforms/gametank/lib/gt/gen/bank_nums.h +11 -0
  56. package/src/platforms/gametank/lib/gt/gen/modules_enabled.h +4 -0
  57. package/src/platforms/gametank/lib/gt/gen/modules_enabled.inc +4 -0
  58. package/src/platforms/gametank/lib/gt/gfx/draw_direct.c +132 -0
  59. package/src/platforms/gametank/lib/gt/gfx/draw_direct.h +75 -0
  60. package/src/platforms/gametank/lib/gt/gfx/draw_queue.c +177 -0
  61. package/src/platforms/gametank/lib/gt/gfx/draw_queue.h +35 -0
  62. package/src/platforms/gametank/lib/gt/gfx/draw_util.s +157 -0
  63. package/src/platforms/gametank/lib/gt/gfx/gfx_sys.c +43 -0
  64. package/src/platforms/gametank/lib/gt/gfx/gfx_sys.h +46 -0
  65. package/src/platforms/gametank/lib/gt/gfx/sprites.c +216 -0
  66. package/src/platforms/gametank/lib/gt/gfx/sprites.h +41 -0
  67. package/src/platforms/gametank/lib/gt/init.c +9 -0
  68. package/src/platforms/gametank/lib/gt/input.c +26 -0
  69. package/src/platforms/gametank/lib/gt/input.h +20 -0
  70. package/src/platforms/gametank/lib/gt/interrupt.s +67 -0
  71. package/src/platforms/gametank/lib/gt/vectors.s +14 -0
  72. package/src/platforms/gametank/lib/gt/wait.s +53 -0
  73. package/src/playtest/playtest.js +174 -26
  74. package/src/toolchains/arm-none-eabi-gcc/gcc.js +39 -188
  75. package/src/toolchains/asar/asar.js +10 -15
  76. package/src/toolchains/cc65/cc65.js +82 -92
  77. package/src/toolchains/cc65/da65.js +12 -17
  78. package/src/toolchains/cc65/preset-resolver.js +13 -2
  79. package/src/toolchains/cc65/presets/gametank/gametank.h +80 -0
  80. package/src/toolchains/cc65/presets/gametank/sdk.cfg +32 -0
  81. package/src/toolchains/cc65/presets/gametank/sdk.crt0.s +61 -0
  82. package/src/toolchains/cc65/presets/gametank/sdk.vectors.s +11 -0
  83. package/src/toolchains/cc65/presets/gametank/single-bank.cfg +28 -0
  84. package/src/toolchains/cc65/presets/gametank/single-bank.crt0.s +71 -0
  85. package/src/toolchains/cc65/presets/gametank/single-bank.vectors.s +12 -0
  86. package/src/toolchains/common/c-build.js +109 -0
  87. package/src/toolchains/common/gcc-toolchain.js +164 -0
  88. package/src/toolchains/common/wasm-tool.js +101 -0
  89. package/src/toolchains/dasm/dasm.js +12 -18
  90. package/src/toolchains/gba-c/gba-c.js +253 -305
  91. package/src/toolchains/genesis-c/genesis-c.js +196 -225
  92. package/src/toolchains/index.js +58 -4
  93. package/src/toolchains/m68k-elf-gcc/gcc.js +39 -202
  94. package/src/toolchains/mips-c/mips-c.js +68 -78
  95. package/src/toolchains/mips-elf-gcc/gcc.js +55 -118
  96. package/src/toolchains/rgbds/rgbds.js +7 -19
  97. package/src/toolchains/sdcc/sdcc.js +35 -26
  98. package/src/toolchains/sh-c/sh-c.js +44 -52
  99. package/src/toolchains/sh-elf-gcc/gcc.js +55 -110
  100. package/src/toolchains/sjasm/sjasm.js +10 -14
  101. package/src/toolchains/snes-c/snes-c.js +125 -150
  102. package/src/toolchains/tcc816/tcc816.js +10 -15
  103. package/src/toolchains/vasm68k/vasm68k.js +11 -16
  104. package/src/toolchains/wladx/wladx.js +5 -17
  105. package/src/toolchains/z80/binutils.js +5 -11
@@ -35,6 +35,7 @@ import {
35
35
  import { runSjasm, runBintos } from "../sjasm/sjasm.js";
36
36
  import { packAr } from "../common/ar.js";
37
37
  import { resolveSdkArchive } from "../common/sdk-cache.js";
38
+ import { CBuild, BuildError } from "../common/c-build.js";
38
39
 
39
40
  const __filename = fileURLToPath(import.meta.url);
40
41
  const __dirname = path.dirname(__filename);
@@ -254,7 +255,7 @@ export async function buildGenesisC(args) {
254
255
  * 7. objcopy -O binary → final .bin
255
256
  */
256
257
  async function buildWithSgdk({ sources, headers, binaryIncludes, cc1Options, rebuildSdk = false, writeSeed = false }) {
257
- let log = "";
258
+ const cb = new CBuild();
258
259
  // SGDK_GCC define + freestanding-style flags must be in cc1 invocations
259
260
  const sgdkCc1Options = [
260
261
  "-DSGDK_GCC",
@@ -280,154 +281,135 @@ async function buildWithSgdk({ sources, headers, binaryIncludes, cc1Options, reb
280
281
  const sgdkHeaders = await loadSgdkHeaders();
281
282
  const tccHeaders = { ...headers, ...sgdkHeaders };
282
283
 
283
- // ── Stage B: compile + assemble each user .c source ──
284
- /** @type {Record<string, Uint8Array>} */
285
- const userObjs = {};
286
- const cFiles = Object.keys(sources).filter((n) => /\.c$/i.test(n));
287
- for (const cName of cFiles) {
288
- const cc = await runCc1m68k({
289
- source: sources[cName],
290
- headers: tccHeaders,
291
- options: userCc1Options,
292
- });
293
- log += `--- cc1 (${cName}) ---\n` + (cc.log || "(ok)") + "\n";
294
- if (cc.exitCode !== 0 || !cc.asmSource) {
295
- return { ok: false, binary: null, log, exitCode: cc.exitCode || 1, stage: `cc1 (${cName})`, runtime: "sgdk", ...(cc.crash ? { crash: cc.crash } : {}) };
296
- }
297
- const as = await runM68kAs({ source: cc.asmSource });
298
- log += `--- as (${cName} → .o) ---\n` + (as.log || "(ok)") + "\n";
299
- if (as.exitCode !== 0 || !as.object) {
300
- return { ok: false, binary: null, log, exitCode: as.exitCode || 1, stage: `as (${cName})`, runtime: "sgdk", ...(as.crash ? { crash: as.crash } : {}) };
284
+ try {
285
+ // ── Stage B: compile + assemble each user .c source ──
286
+ /** @type {Record<string, Uint8Array>} */
287
+ const userObjs = {};
288
+ const cFiles = Object.keys(sources).filter((n) => /\.c$/i.test(n));
289
+ for (const cName of cFiles) {
290
+ const cc = await cb.stage(`cc1 (${cName})`,
291
+ () => runCc1m68k({ source: sources[cName], headers: tccHeaders, options: userCc1Options }),
292
+ (r) => r.asmSource);
293
+ const as = await cb.stage(`as (${cName})`,
294
+ () => runM68kAs({ source: cc.asmSource }),
295
+ (r) => r.object, { logName: `as (${cName} → .o)` });
296
+ userObjs[cName.replace(/\.c$/i, ".o")] = as.object;
301
297
  }
302
- userObjs[cName.replace(/\.c$/i, ".o")] = as.object;
303
- }
304
298
 
305
- // ── Stage B': assemble user .s sibling files directly ──
306
- // User .s files may .incbin sibling binary blobs (e.g. xgm2 music) — pass
307
- // the same binaryIncludes map to each so the assembler can mount them.
308
- const asmFiles = Object.keys(sources).filter((n) => /\.(s|asm)$/i.test(n));
309
- for (const asmName of asmFiles) {
310
- const as = await runM68kAs({ source: sources[asmName], binaryIncludes });
311
- log += `--- as (${asmName}) ---\n` + (as.log || "(ok)") + "\n";
312
- if (as.exitCode !== 0 || !as.object) {
313
- return { ok: false, binary: null, log, exitCode: as.exitCode || 1, stage: `as (${asmName})`, runtime: "sgdk", ...(as.crash ? { crash: as.crash } : {}) };
299
+ // ── Stage B': assemble user .s sibling files directly ──
300
+ // User .s files may .incbin sibling binary blobs (e.g. xgm2 music) — pass
301
+ // the same binaryIncludes map to each so the assembler can mount them.
302
+ const asmFiles = Object.keys(sources).filter((n) => /\.(s|asm)$/i.test(n));
303
+ for (const asmName of asmFiles) {
304
+ const as = await cb.stage(`as (${asmName})`,
305
+ () => runM68kAs({ source: sources[asmName], binaryIncludes }),
306
+ (r) => r.object);
307
+ userObjs[asmName.replace(/\.(s|asm)$/i, ".o")] = as.object;
314
308
  }
315
- userObjs[asmName.replace(/\.(s|asm)$/i, ".o")] = as.object;
316
- }
317
309
 
318
- // ── Stage C: build rom_header.bin from SGDK's rom_header.c ──
319
- const romHeaderC = await readFile(path.join(SGDK_LIB_DIR, "rom_header.c"), "utf-8");
320
- const rhCc = await runCc1m68k({
321
- source: romHeaderC,
322
- headers: tccHeaders,
323
- options: sgdkCc1Options,
324
- });
325
- log += "--- cc1 (rom_header.c) ---\n" + (rhCc.log || "(ok)") + "\n";
326
- if (rhCc.exitCode !== 0 || !rhCc.asmSource) {
327
- return { ok: false, binary: null, log, exitCode: rhCc.exitCode || 1, stage: "cc1 (rom_header)", runtime: "sgdk", ...(rhCc.crash ? { crash: rhCc.crash } : {}) };
328
- }
329
- const rhAs = await runM68kAs({ source: rhCc.asmSource });
330
- log += "--- as (rom_header.s) ---\n" + (rhAs.log || "(ok)") + "\n";
331
- if (rhAs.exitCode !== 0 || !rhAs.object) {
332
- return { ok: false, binary: null, log, exitCode: rhAs.exitCode || 1, stage: "as (rom_header)", runtime: "sgdk", ...(rhAs.crash ? { crash: rhAs.crash } : {}) };
333
- }
334
- // Objcopy raw header bin (256 bytes)
335
- const rhObjcopy = await runM68kObjcopy({ elf: rhAs.object });
336
- log += "--- objcopy (rom_header.o → .bin) ---\n" + (rhObjcopy.log || "(ok)") + "\n";
337
- if (rhObjcopy.exitCode !== 0 || !rhObjcopy.binary) {
338
- return { ok: false, binary: null, log, exitCode: rhObjcopy.exitCode || 1, stage: "objcopy (rom_header)", runtime: "sgdk", ...(rhObjcopy.crash ? { crash: rhObjcopy.crash } : {}) };
339
- }
340
-
341
- // ── Stage D: assemble sega.s with the just-built rom_header.bin as a sibling ──
342
- // sega.s `.incbin "out/rom_header.bin"` — we mount it at /work/out/rom_header.bin
343
- // via the worker's binaryFile facility. runM68kAs's includes map only handles text,
344
- // so we need to extend the as call to accept binary siblings.
345
- //
346
- // Note: we use `sega.preprocessed.s` (the cpp-expanded form). The raw sega.s
347
- // uses `#include <task_cst.h>` + `#define`d constants, which SGDK normally
348
- // expands via gcc's `-x assembler-with-cpp` driver mode. Our `runM68kAs`
349
- // wrapper calls `as` directly without cpp, so we ship the preprocessed
350
- // version as a build artifact (~7 KB) instead of running cpp at every build.
351
- const segaSrc = await readFile(path.join(SGDK_LIB_DIR, "sega.preprocessed.s"), "utf-8");
352
- const segaAs = await runM68kAs({
353
- source: segaSrc,
354
- binaryIncludes: { "out/rom_header.bin": rhObjcopy.binary },
355
- // SGDK assembles sega.s with the same -DSGDK_GCC etc. flags as C — pass them
356
- // via -Wa,--register-prefix-optional,--bitwise-or implicitly via cc1's driver.
357
- // Our as wrapper takes raw flags; the equivalent here is just --bitwise-or.
358
- options: ["--register-prefix-optional", "--bitwise-or"],
359
- });
360
- log += "--- as (sega.s) ---\n" + (segaAs.log || "(ok)") + "\n";
361
- if (segaAs.exitCode !== 0 || !segaAs.object) {
362
- return { ok: false, binary: null, log, exitCode: segaAs.exitCode || 1, stage: "as (sega.s)", runtime: "sgdk", ...(segaAs.crash ? { crash: segaAs.crash } : {}) };
363
- }
364
-
365
- // ── Stage D2: compile the SGDK runtime FROM SOURCE → libmd.a ──
366
- // (Z80 drivers via sjasm+bintos, all SGDK .c via m68k-gcc, libres + .s
367
- // assembled, packed into an archive. No prebuilt libmd.a black box.)
368
- // Seed by default (compiling SGDK from source is ~18s); rebuildSdk:true
369
- // recompiles; an edit to the vendored SGDK source without the flag is flagged.
370
- const sdkWarnings = [];
371
- const sgdkRes = await resolveSdkArchive({
372
- name: "SGDK",
373
- sources: await readSgdkSources(),
374
- seedPath: path.join(SGDK_LIB_DIR, "libmd.seed.a"),
375
- seedHashPath: path.join(SGDK_LIB_DIR, "libmd.seed.hash"),
376
- rebuild: rebuildSdk, writeSeed,
377
- compileFromSource: async () => {
378
- const r = await compileSgdkRuntime({ ...tccHeaders }, sgdkCc1Options);
379
- return r.ok ? { ok: true, archive: r.libmd } : r;
380
- },
381
- });
382
- if (!sgdkRes.ok) {
383
- return { ok: false, binary: null, log: log + (sgdkRes.log || ""), exitCode: 1, stage: `sgdk runtime: ${sgdkRes.stage}`, runtime: "sgdk" };
384
- }
385
- if (sgdkRes.sdkEditIgnored) sdkWarnings.push(sgdkRes.sdkEditIgnored);
386
- log += `--- SGDK runtime ${sgdkRes.fromSource ? "compiled from source" : "from prebuilt seed"} ---\n`;
387
-
388
- // ── Stage E: link everything ──
389
- const mdLd = await readFile(path.join(SGDK_LIB_DIR, "md.ld"), "utf-8");
390
- const [libgcc, libc, libm] = await Promise.all([
391
- readFile(path.join(MINIMAL_LIB_DIR, "libgcc.a")),
392
- readFile(path.join(MINIMAL_LIB_DIR, "libc.a")),
393
- readFile(path.join(MINIMAL_LIB_DIR, "libm.a")),
394
- ]);
395
-
396
- const ld = await runM68kLd({
397
- objects: { "sega.o": segaAs.object, ...userObjs },
398
- linkScript: mdLd,
399
- archives: {
400
- "libmd.a": sgdkRes.archive,
401
- "libgcc.a": new Uint8Array(libgcc),
402
- "libc.a": new Uint8Array(libc),
403
- "libm.a": new Uint8Array(libm),
404
- },
405
- libraries: ["md", "gcc", "c"],
406
- libraryPaths: ["/work"],
407
- options: ["--no-warn-rwx-segments"],
408
- });
409
- log += "--- ld ---\n" + (ld.log || "(ok)") + "\n";
410
- if (ld.exitCode !== 0 || !ld.elf) {
411
- return { ok: false, binary: null, log, exitCode: ld.exitCode || 1, stage: "ld", runtime: "sgdk", ...(ld.crash ? { crash: ld.crash } : {}) };
412
- }
413
-
414
- // ── Stage F: extract raw ROM ──
415
- const objcopy = await runM68kObjcopy({ elf: ld.elf });
416
- log += "--- objcopy ---\n" + (objcopy.log || "(ok)") + "\n";
417
- if (objcopy.exitCode !== 0 || !objcopy.binary) {
418
- return { ok: false, binary: null, log, exitCode: objcopy.exitCode || 1, stage: "objcopy", runtime: "sgdk", ...(objcopy.crash ? { crash: objcopy.crash } : {}) };
310
+ // ── Stage C: build rom_header.bin from SGDK's rom_header.c ──
311
+ const romHeaderC = await readFile(path.join(SGDK_LIB_DIR, "rom_header.c"), "utf-8");
312
+ const rhCc = await cb.stage("cc1 (rom_header)",
313
+ () => runCc1m68k({ source: romHeaderC, headers: tccHeaders, options: sgdkCc1Options }),
314
+ (r) => r.asmSource, { logName: "cc1 (rom_header.c)" });
315
+ const rhAs = await cb.stage("as (rom_header)",
316
+ () => runM68kAs({ source: rhCc.asmSource }),
317
+ (r) => r.object, { logName: "as (rom_header.s)" });
318
+ // Objcopy raw header bin (256 bytes)
319
+ const rhObjcopy = await cb.stage("objcopy (rom_header)",
320
+ () => runM68kObjcopy({ elf: rhAs.object }),
321
+ (r) => r.binary, { logName: "objcopy (rom_header.o → .bin)" });
322
+
323
+ // ── Stage D: assemble sega.s with the just-built rom_header.bin as a sibling ──
324
+ // sega.s `.incbin "out/rom_header.bin"` we mount it at /work/out/rom_header.bin
325
+ // via the worker's binaryFile facility. runM68kAs's includes map only handles text,
326
+ // so we need to extend the as call to accept binary siblings.
327
+ //
328
+ // Note: we use `sega.preprocessed.s` (the cpp-expanded form). The raw sega.s
329
+ // uses `#include <task_cst.h>` + `#define`d constants, which SGDK normally
330
+ // expands via gcc's `-x assembler-with-cpp` driver mode. Our `runM68kAs`
331
+ // wrapper calls `as` directly without cpp, so we ship the preprocessed
332
+ // version as a build artifact (~7 KB) instead of running cpp at every build.
333
+ const segaSrc = await readFile(path.join(SGDK_LIB_DIR, "sega.preprocessed.s"), "utf-8");
334
+ const segaAs = await cb.stage("as (sega.s)",
335
+ () => runM68kAs({
336
+ source: segaSrc,
337
+ binaryIncludes: { "out/rom_header.bin": rhObjcopy.binary },
338
+ // SGDK assembles sega.s with the same -DSGDK_GCC etc. flags as C — pass them
339
+ // via -Wa,--register-prefix-optional,--bitwise-or implicitly via cc1's driver.
340
+ // Our as wrapper takes raw flags; the equivalent here is just --bitwise-or.
341
+ options: ["--register-prefix-optional", "--bitwise-or"],
342
+ }),
343
+ (r) => r.object);
344
+
345
+ // ── Stage D2: compile the SGDK runtime FROM SOURCE → libmd.a ──
346
+ // (Z80 drivers via sjasm+bintos, all SGDK .c via m68k-gcc, libres + .s
347
+ // assembled, packed into an archive. No prebuilt libmd.a black box.)
348
+ // Seed by default (compiling SGDK from source is ~18s); rebuildSdk:true
349
+ // recompiles; an edit to the vendored SGDK source without the flag is flagged.
350
+ const sdkWarnings = [];
351
+ const sgdkRes = await resolveSdkArchive({
352
+ name: "SGDK",
353
+ sources: await readSgdkSources(),
354
+ seedPath: path.join(SGDK_LIB_DIR, "libmd.seed.a"),
355
+ seedHashPath: path.join(SGDK_LIB_DIR, "libmd.seed.hash"),
356
+ rebuild: rebuildSdk, writeSeed,
357
+ compileFromSource: async () => {
358
+ const r = await compileSgdkRuntime({ ...tccHeaders }, sgdkCc1Options);
359
+ return r.ok ? { ok: true, archive: r.libmd } : r;
360
+ },
361
+ });
362
+ if (!sgdkRes.ok) {
363
+ // Original appended sgdkRes.log raw (no newline normalization) to the log.
364
+ cb.log += (sgdkRes.log || "");
365
+ throw new BuildError({ stage: `sgdk runtime: ${sgdkRes.stage}`, exitCode: 1, log: cb.log });
366
+ }
367
+ if (sgdkRes.sdkEditIgnored) sdkWarnings.push(sgdkRes.sdkEditIgnored);
368
+ cb.log += `--- SGDK runtime ${sgdkRes.fromSource ? "compiled from source" : "from prebuilt seed"} ---\n`;
369
+
370
+ // ── Stage E: link everything ──
371
+ const mdLd = await readFile(path.join(SGDK_LIB_DIR, "md.ld"), "utf-8");
372
+ const [libgcc, libc, libm] = await Promise.all([
373
+ readFile(path.join(MINIMAL_LIB_DIR, "libgcc.a")),
374
+ readFile(path.join(MINIMAL_LIB_DIR, "libc.a")),
375
+ readFile(path.join(MINIMAL_LIB_DIR, "libm.a")),
376
+ ]);
377
+
378
+ const ld = await cb.stage("ld",
379
+ () => runM68kLd({
380
+ objects: { "sega.o": segaAs.object, ...userObjs },
381
+ linkScript: mdLd,
382
+ archives: {
383
+ "libmd.a": sgdkRes.archive,
384
+ "libgcc.a": new Uint8Array(libgcc),
385
+ "libc.a": new Uint8Array(libc),
386
+ "libm.a": new Uint8Array(libm),
387
+ },
388
+ libraries: ["md", "gcc", "c"],
389
+ libraryPaths: ["/work"],
390
+ options: ["--no-warn-rwx-segments"],
391
+ }),
392
+ (r) => r.elf);
393
+
394
+ // ── Stage F: extract raw ROM ──
395
+ const objcopy = await cb.stage("objcopy",
396
+ () => runM68kObjcopy({ elf: ld.elf }),
397
+ (r) => r.binary);
398
+
399
+ return {
400
+ ok: true,
401
+ binary: objcopy.binary,
402
+ log: cb.log,
403
+ exitCode: 0,
404
+ stage: "done",
405
+ runtime: "sgdk",
406
+ ...(ld.map ? { symbols: ld.map } : {}),
407
+ ...(sdkWarnings.length ? { sdkEditIgnored: sdkWarnings } : {}),
408
+ };
409
+ } catch (e) {
410
+ if (e instanceof BuildError) return e.toResult({ runtime: "sgdk" });
411
+ throw e;
419
412
  }
420
-
421
- return {
422
- ok: true,
423
- binary: objcopy.binary,
424
- log,
425
- exitCode: 0,
426
- stage: "done",
427
- runtime: "sgdk",
428
- ...(ld.map ? { symbols: ld.map } : {}),
429
- ...(sdkWarnings.length ? { sdkEditIgnored: sdkWarnings } : {}),
430
- };
431
413
  }
432
414
 
433
415
  /**
@@ -472,93 +454,82 @@ export function finalizeGenesisRom(bin) {
472
454
  */
473
455
  async function buildMinimal(args) {
474
456
  const { sources, headers, binaryIncludes, cc1Options } = args;
475
- let log = "";
457
+ const cb = new CBuild();
476
458
 
477
- // ── Stage 1: compile each .c file via cc1 → .s ─────────────────
478
- /** @type {Record<string, Uint8Array>} */
479
- const userObjs = {};
480
- // User .c gets warnings on (minimal path has no SDK to flood). See buildWithSgdk.
481
- const userCc1Options = [...cc1Options, "-Wall", "-Wextra", "-Wno-unused-parameter"];
482
- const cFiles = Object.keys(sources).filter((n) => /\.c$/i.test(n));
483
- for (const cName of cFiles) {
484
- const cc = await runCc1m68k({
485
- source: sources[cName],
486
- headers,
487
- options: userCc1Options,
488
- });
489
- log += `--- cc1 (${cName}) ---\n` + (cc.log || "(ok)") + "\n";
490
- if (cc.exitCode !== 0 || !cc.asmSource) {
491
- return { ok: false, binary: null, log, exitCode: cc.exitCode || 1, stage: `cc1 (${cName})`, runtime: "minimal", ...(cc.crash ? { crash: cc.crash } : {}) };
492
- }
493
- // ── Stage 2: assemble that .s with m68k-elf-as ────────────────
494
- const as = await runM68kAs({ source: cc.asmSource });
495
- log += `--- as (${cName} → .o) ---\n` + (as.log || "(ok)") + "\n";
496
- if (as.exitCode !== 0 || !as.object) {
497
- return { ok: false, binary: null, log, exitCode: as.exitCode || 1, stage: `as (${cName})`, runtime: "minimal", ...(as.crash ? { crash: as.crash } : {}) };
459
+ try {
460
+ // ── Stage 1: compile each .c file via cc1 → .s ─────────────────
461
+ /** @type {Record<string, Uint8Array>} */
462
+ const userObjs = {};
463
+ // User .c gets warnings on (minimal path has no SDK to flood). See buildWithSgdk.
464
+ const userCc1Options = [...cc1Options, "-Wall", "-Wextra", "-Wno-unused-parameter"];
465
+ const cFiles = Object.keys(sources).filter((n) => /\.c$/i.test(n));
466
+ for (const cName of cFiles) {
467
+ const cc = await cb.stage(`cc1 (${cName})`,
468
+ () => runCc1m68k({ source: sources[cName], headers, options: userCc1Options }),
469
+ (r) => r.asmSource);
470
+ // ── Stage 2: assemble that .s with m68k-elf-as ────────────────
471
+ const as = await cb.stage(`as (${cName})`,
472
+ () => runM68kAs({ source: cc.asmSource }),
473
+ (r) => r.object, { logName: `as (${cName} .o)` });
474
+ userObjs[cName.replace(/\.c$/i, ".o")] = as.object;
498
475
  }
499
- userObjs[cName.replace(/\.c$/i, ".o")] = as.object;
500
- }
501
476
 
502
- // ── Stage 2b: assemble each .s file directly ───────────────────
503
- const asmFiles = Object.keys(sources).filter((n) => /\.(s|asm)$/i.test(n));
504
- for (const asmName of asmFiles) {
505
- const as = await runM68kAs({ source: sources[asmName], binaryIncludes });
506
- log += `--- as (${asmName}) ---\n` + (as.log || "(ok)") + "\n";
507
- if (as.exitCode !== 0 || !as.object) {
508
- return { ok: false, binary: null, log, exitCode: as.exitCode || 1, stage: `as (${asmName})`, runtime: "minimal", ...(as.crash ? { crash: as.crash } : {}) };
477
+ // ── Stage 2b: assemble each .s file directly ───────────────────
478
+ const asmFiles = Object.keys(sources).filter((n) => /\.(s|asm)$/i.test(n));
479
+ for (const asmName of asmFiles) {
480
+ const as = await cb.stage(`as (${asmName})`,
481
+ () => runM68kAs({ source: sources[asmName], binaryIncludes }),
482
+ (r) => r.object);
483
+ userObjs[asmName.replace(/\.(s|asm)$/i, ".o")] = as.object;
509
484
  }
510
- userObjs[asmName.replace(/\.(s|asm)$/i, ".o")] = as.object;
511
- }
512
-
513
- // ── Stage 3: assemble the bundled sega.s crt0 ───────────────────
514
- const sega = await readFile(path.join(MINIMAL_LIB_DIR, "sega.s"), "utf-8");
515
- const segaAs = await runM68kAs({ source: sega });
516
- log += "--- as (sega.s crt0) ---\n" + (segaAs.log || "(ok)") + "\n";
517
- if (segaAs.exitCode !== 0 || !segaAs.object) {
518
- return { ok: false, binary: null, log, exitCode: segaAs.exitCode || 1, stage: "as (sega.s)", runtime: "minimal", ...(segaAs.crash ? { crash: segaAs.crash } : {}) };
519
- }
520
485
 
521
- // ── Stage 4: link everything ────────────────────────────────────
522
- const linkScript = await readFile(path.join(MINIMAL_LIB_DIR, "genesis.ld"), "utf-8");
523
- const [libgcc, libc, libm] = await Promise.all([
524
- readFile(path.join(MINIMAL_LIB_DIR, "libgcc.a")),
525
- readFile(path.join(MINIMAL_LIB_DIR, "libc.a")),
526
- readFile(path.join(MINIMAL_LIB_DIR, "libm.a")),
527
- ]);
528
-
529
- const ld = await runM68kLd({
530
- objects: { "sega.o": segaAs.object, ...userObjs },
531
- linkScript,
532
- archives: {
533
- "libgcc.a": new Uint8Array(libgcc),
534
- "libc.a": new Uint8Array(libc),
535
- "libm.a": new Uint8Array(libm),
536
- },
537
- libraries: ["c", "gcc", "m"],
538
- libraryPaths: ["/work"],
539
- options: ["--no-warn-rwx-segments"],
540
- });
541
- log += "--- ld ---\n" + (ld.log || "(ok)") + "\n";
542
- if (ld.exitCode !== 0 || !ld.elf) {
543
- return { ok: false, binary: null, log, exitCode: ld.exitCode || 1, stage: "ld", runtime: "minimal", ...(ld.crash ? { crash: ld.crash } : {}) };
486
+ // ── Stage 3: assemble the bundled sega.s crt0 ───────────────────
487
+ const sega = await readFile(path.join(MINIMAL_LIB_DIR, "sega.s"), "utf-8");
488
+ const segaAs = await cb.stage("as (sega.s)",
489
+ () => runM68kAs({ source: sega }),
490
+ (r) => r.object, { logName: "as (sega.s crt0)" });
491
+
492
+ // ── Stage 4: link everything ────────────────────────────────────
493
+ const linkScript = await readFile(path.join(MINIMAL_LIB_DIR, "genesis.ld"), "utf-8");
494
+ const [libgcc, libc, libm] = await Promise.all([
495
+ readFile(path.join(MINIMAL_LIB_DIR, "libgcc.a")),
496
+ readFile(path.join(MINIMAL_LIB_DIR, "libc.a")),
497
+ readFile(path.join(MINIMAL_LIB_DIR, "libm.a")),
498
+ ]);
499
+
500
+ const ld = await cb.stage("ld",
501
+ () => runM68kLd({
502
+ objects: { "sega.o": segaAs.object, ...userObjs },
503
+ linkScript,
504
+ archives: {
505
+ "libgcc.a": new Uint8Array(libgcc),
506
+ "libc.a": new Uint8Array(libc),
507
+ "libm.a": new Uint8Array(libm),
508
+ },
509
+ libraries: ["c", "gcc", "m"],
510
+ libraryPaths: ["/work"],
511
+ options: ["--no-warn-rwx-segments"],
512
+ }),
513
+ (r) => r.elf);
514
+
515
+ // ── Stage 5: extract raw binary ─────────────────────────────────
516
+ const objcopy = await cb.stage("objcopy",
517
+ () => runM68kObjcopy({ elf: ld.elf }),
518
+ (r) => r.binary);
519
+
520
+ return {
521
+ ok: true,
522
+ binary: objcopy.binary,
523
+ log: cb.log,
524
+ exitCode: 0,
525
+ stage: "done",
526
+ runtime: "minimal",
527
+ ...(ld.map ? { symbols: ld.map } : {}),
528
+ };
529
+ } catch (e) {
530
+ if (e instanceof BuildError) return e.toResult({ runtime: "minimal" });
531
+ throw e;
544
532
  }
545
-
546
- // ── Stage 5: extract raw binary ─────────────────────────────────
547
- const objcopy = await runM68kObjcopy({ elf: ld.elf });
548
- log += "--- objcopy ---\n" + (objcopy.log || "(ok)") + "\n";
549
- if (objcopy.exitCode !== 0 || !objcopy.binary) {
550
- return { ok: false, binary: null, log, exitCode: objcopy.exitCode || 1, stage: "objcopy", runtime: "minimal", ...(objcopy.crash ? { crash: objcopy.crash } : {}) };
551
- }
552
-
553
- return {
554
- ok: true,
555
- binary: objcopy.binary,
556
- log,
557
- exitCode: 0,
558
- stage: "done",
559
- runtime: "minimal",
560
- ...(ld.map ? { symbols: ld.map } : {}),
561
- };
562
533
  }
563
534
 
564
535
  /** @param {{source?:string, sources?:Record<string,string>}} args */
@@ -17,6 +17,23 @@ const CC65_TARGET = {
17
17
  atari7800: "atari7800",
18
18
  lynx: "lynx",
19
19
  pce: "pce",
20
+ // GameTank has no built-in cc65 target — it links bare via `-t none` + the
21
+ // bundled single-bank preset cfg/crt0/vectors (presets/gametank/).
22
+ gametank: "none",
23
+ };
24
+
25
+ /** cc65 platforms whose CPU is NOT implied by a built-in target. The built-in
26
+ * targets (lynx/pce/…) set the CPU themselves; `-t none` defaults to plain 6502,
27
+ * so GameTank (a real W65C02S) MUST be told `--cpu 65c02` for cc65 codegen +
28
+ * `--cpu W65C02` for ca65, or it silently emits 6502 code on a 65C02. */
29
+ const CC65_CPU = {
30
+ gametank: { cc: "65c02", ca: "W65C02" },
31
+ };
32
+
33
+ /** Exact ROM sizes for cc65 platforms whose mapper is size-keyed (no header).
34
+ * GameTank detects EEPROM32K by a flat 32 KB image — pad to exactly 32768. */
35
+ const CC65_ROM_SIZE = {
36
+ gametank: 32768,
20
37
  };
21
38
 
22
39
  /**
@@ -81,6 +98,10 @@ const LANGUAGE_TOOLCHAIN = {
81
98
  c: { toolchain: "cc65", available: true, note: "C for PC Engine via cc65's huc6280 target — crt0 + pce.lib (VDC/VCE/PSG/joypad helpers) auto-linked. #include <pce.h>." },
82
99
  asm: { toolchain: "cc65", available: true },
83
100
  },
101
+ gametank: {
102
+ c: { toolchain: "cc65", available: true, note: "C for GameTank (Clyde Shaffer's open W65C02S console) via cc65 `--cpu 65c02` + a bundled single-bank 32KB preset (crt0 + linker cfg + vectors), pass linkerConfig:'single-bank'. #include \"gametank.h\" for the blitter/DMA/banking/gamepad registers ($4000-$4007 blitter, $2007 dma_flags, $2005 bank_reg, $2008/9 gamepads). Bare path — color-fill the 128x128 framebuffer via the blitter; the full SDK gfx/audio/text runtime + 2MB multi-bank flash pipeline is not bundled (Tier B, deferred). Output is a flat 32KB .gtr (EEPROM32K, size-keyed mapper)." },
103
+ asm: { toolchain: "cc65", available: true },
104
+ },
84
105
  msx: {
85
106
  c: { toolchain: "sdcc", available: true, note: "C for MSX via SDCC's z80 port. Cartridge ROM with the 'AB' header + INIT pointer at $4000; boots on C-BIOS (open MSX BIOS). MSX2 by default (runs MSX1 carts too)." },
86
107
  asm: { toolchain: "sdcc", available: true },
@@ -114,6 +135,7 @@ const PLATFORM_DEFAULT_LANGUAGE = {
114
135
  c64: "c",
115
136
  atari7800: "c",
116
137
  lynx: "c",
138
+ gametank: "c",
117
139
  gb: "c",
118
140
  gbc: "c",
119
141
  snes: "c",
@@ -425,24 +447,56 @@ export async function buildForPlatform(args) {
425
447
  // the build pipeline does NOT auto-select presets based on source
426
448
  // contents — every byte that compiles must be visible to the caller.
427
449
  const { resolveLinkerConfig } = await import("./cc65/preset-resolver.js");
428
- const { cfg, supportSources } = await resolveLinkerConfig(args.platform, args.linkerConfig);
450
+ const { cfg, supportSources, headers: presetHeaders } = await resolveLinkerConfig(args.platform, args.linkerConfig);
429
451
 
430
452
  // Splice in any preset support sources (crt0, etc.). User sources win
431
453
  // if there's a name collision, but the preset uses `_preset_*` so it
432
454
  // wouldn't normally collide.
433
455
  sources = { ...supportSources, ...sources };
434
456
 
457
+ // A preset can bundle headers (e.g. GameTank's gametank.h) so `#include
458
+ // "gametank.h"` just works; caller-supplied headers win on collision.
459
+ const mergedHeaders = { ...(presetHeaders ?? {}), ...(args.includes ?? {}) };
460
+
461
+ // CPU override for `-t none` platforms whose CPU isn't implied by the target
462
+ // (GameTank → cc65 `--cpu 65c02`, ca65 `--cpu W65C02`).
463
+ const cpu = CC65_CPU[args.platform];
464
+ const ccCpuOpts = cpu ? ["--cpu", cpu.cc] : [];
465
+ const caCpuOpts = cpu ? ["--cpu", cpu.ca] : [];
466
+
435
467
  const builder = anyC ? buildC : buildAsm;
436
468
  const r = await builder({
437
469
  sources,
438
470
  target: cc65Target,
439
- [anyC ? "headers" : "includes"]: args.includes,
471
+ [anyC ? "headers" : "includes"]: anyC ? mergedHeaders : args.includes,
472
+ // ca65 .include files (e.g. an SDK's modules_enabled.inc) — distinct from C
473
+ // headers; the C path's per-TU ca65 pass needs them, and they weren't being
474
+ // forwarded. A platform with a multi-file asm-backed SDK (GameTank) needs this.
475
+ ...(args.asmIncludes ? { asmIncludes: args.asmIncludes } : {}),
440
476
  binaryIncludes: args.binaryIncludes,
441
477
  linkerConfig: cfg,
478
+ ccOptions: ccCpuOpts,
479
+ caOptions: caCpuOpts,
442
480
  });
481
+
482
+ // Size-keyed mappers (GameTank EEPROM32K): pad the flat image to the exact
483
+ // ROM size the core size-detects. Under-size or a few bytes over would fall
484
+ // into the UNKNOWN mapper path.
485
+ let binary = r.binary;
486
+ const romSize = CC65_ROM_SIZE[args.platform];
487
+ if (binary && r.exitCode === 0 && romSize && binary.length !== romSize) {
488
+ if (binary.length < romSize) {
489
+ const padded = new Uint8Array(romSize);
490
+ padded.set(binary);
491
+ binary = padded;
492
+ } else {
493
+ binary = binary.slice(0, romSize);
494
+ }
495
+ }
496
+
443
497
  return {
444
- ok: r.exitCode === 0 && r.binary !== null,
445
- binary: r.binary,
498
+ ok: r.exitCode === 0 && binary !== null,
499
+ binary,
446
500
  listing: "",
447
501
  symbols: "",
448
502
  log: r.log,