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
@@ -68,6 +68,7 @@ const LOG_TAIL = 1200;
68
68
  // stays visible. Left intact on failure (timing can matter when diagnosing a
69
69
  // hang/OOM). The full untrimmed log is still written to logPath when spilled.
70
70
  export function denoiseSuccessLog(log) {
71
+ if (typeof log !== "string") return log ?? "";
71
72
  const lines = log.split("\n");
72
73
  const kept = [];
73
74
  let inTiming = false;
@@ -293,7 +294,7 @@ export function registerToolchainTools(server, z, sessionKey) {
293
294
  codeLoc,
294
295
  dataLoc,
295
296
  });
296
- logBuildResult("buildSource", platform, result);
297
+ logBuildResult("build:rom", platform, result);
297
298
  // lint:"strict" — if any lint warning fired, fail the build with
298
299
  // stage:"lint" so the agent must fix patterns before iterating.
299
300
  // We mutate the result rather than re-running because the lint
@@ -396,11 +397,40 @@ export function registerToolchainTools(server, z, sessionKey) {
396
397
  return jsonContent(payload);
397
398
  }
398
399
 
399
- async function runSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, runtime, maxmod, rebuildSdk, crt0, crt0Path, codeLoc, dataLoc, linkerConfig, frames = 60, holdInputs, screenshotPath, projectName }) {
400
+ async function runSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, runtime, maxmod, rebuildSdk, crt0, crt0Path, codeLoc, dataLoc, linkerConfig, path: projPath, frames = 60, holdInputs, screenshotPath, projectName }) {
400
401
  const { buildForPlatform } = await import("../../toolchains/index.js");
401
402
  const resolved = resolveCore(platform);
402
403
  if (!resolved) throw new Error(`no core available for platform '${platform}'`);
403
404
 
405
+ // PROJECT-DIR run: `build({output:'run', path})` with no explicit sources →
406
+ // read the scaffolded dir via the per-platform recipe (same as
407
+ // output:'project'), then run it. This is the documented "iterate on a dir"
408
+ // happy path; without it, output:'run' + path errored ("requires source").
409
+ const noExplicitSources = source == null && sourcePath == null && sources == null && sourcesPaths == null;
410
+ if (projPath && noExplicitSources) {
411
+ const r = await readProjectDir(projPath, platform);
412
+ includes = { ...(includes ?? {}), ...r.includes };
413
+ binaryIncludes = { ...(binaryIncludes ?? {}), ...r.binaryIncludes };
414
+ if (r.crt0 != null) crt0 = r.crt0;
415
+ if (r.codeLoc != null) codeLoc = r.codeLoc;
416
+ if (r.linkerConfig != null && linkerConfig == null) linkerConfig = r.linkerConfig;
417
+ if (r.runtime != null && runtime == null) runtime = r.runtime;
418
+ if (r.maxmod != null && maxmod == null) maxmod = r.maxmod;
419
+ // Single-source toolchains (dasm/atari2600, vasm/asm) need `source`, not
420
+ // a `sources` map. Collapse a lone source so those targets build via the
421
+ // dir path too. (resolveLinkerConfig support-sources, if any, force the
422
+ // map form below.)
423
+ const srcNames = Object.keys(r.sources);
424
+ if (srcNames.length === 1 && r.crt0 == null && r.linkerConfig == null) {
425
+ // Leave `source` null and set sourcePath — runSourceImpl reads it +
426
+ // derives sourceName (extension) for language inference. Single-source
427
+ // targets (dasm/vasm) require this form, not a `sources` map.
428
+ sourcePath = path.join(projPath, srcNames[0]);
429
+ } else {
430
+ sources = r.sources;
431
+ }
432
+ }
433
+
404
434
  if (source != null && sourcePath != null) {
405
435
  throw new Error("build({output:'run'}): pass either `source` OR `sourcePath`, not both.");
406
436
  }
@@ -474,7 +504,7 @@ export function registerToolchainTools(server, z, sessionKey) {
474
504
  codeLoc,
475
505
  dataLoc,
476
506
  });
477
- logBuildResult("runSource", platform, build);
507
+ logBuildResult("build:run", platform, build);
478
508
  if (!build.ok || !build.binary) {
479
509
  // runSource builds in-memory (no ROM path), so a large failure log
480
510
  // has nowhere to land — gate it to a tail + size rather than dumping
@@ -521,10 +551,10 @@ export function registerToolchainTools(server, z, sessionKey) {
521
551
  if (!isPlaytestRunning(sessionKey) && !playtestHintGiven.has(sessionKey)) {
522
552
  playtestHintGiven.add(sessionKey);
523
553
  hint = "No playtest window is open. If a human is watching, consider " +
524
- "`loadCategory({category:\"show\"})` then `playtest()` so they can " +
525
- "play this ROM live while you keep iterating with runSource " +
526
- "(rebuilds update the live game in place). Skip if this session " +
527
- "is headless (CI / batch / automated).";
554
+ "`playtest({op:\"open\"})` so they can play this ROM live while you " +
555
+ "keep iterating with build({output:\"run\"}) (rebuilds update the " +
556
+ "live game in place). Skip if this session is headless " +
557
+ "(CI / batch / automated).";
528
558
  }
529
559
 
530
560
  const summary = {
@@ -628,7 +658,18 @@ export function registerToolchainTools(server, z, sessionKey) {
628
658
  },
629
659
  safeTool(async (args) => {
630
660
  switch (args.output) {
631
- case "rom": return await buildSourceImpl(args);
661
+ case "rom": {
662
+ // `build({output:'rom', path})` (a project dir, no explicit sources) is
663
+ // the natural "build my scaffolded dir to a ROM file" call. Route it to
664
+ // the dir builder (same recipe as output:'project'/'run') — otherwise it
665
+ // fell into buildSourceImpl with no source and crashed on an undefined
666
+ // log. With path AND explicit sources, the sources win (manual build).
667
+ if (args.path && args.source == null && args.sources == null &&
668
+ args.sourcePath == null && args.sourcesPaths == null) {
669
+ return await buildProjectImpl(args);
670
+ }
671
+ return await buildSourceImpl(args);
672
+ }
632
673
  case "run": return await runSourceImpl(args);
633
674
  case "project": {
634
675
  if (!args.path) throw new Error("build({output:'project'}): `path` (the project directory) is required.");
@@ -648,13 +689,107 @@ export function registerToolchainTools(server, z, sessionKey) {
648
689
  * a jsonContent payload (the router calls it via safeTool, which turns a throw —
649
690
  * e.g. no entry point — into an {isError:true} result).
650
691
  */
651
- export async function buildProjectCore({ path: projPath, platform, outputPath }) {
692
+ /**
693
+ * Per-platform recipe for building a SCAFFOLDED project directory. Given the
694
+ * platform + the list of filenames present, decide which file (if any) is a crt0
695
+ * that must be routed via `crt0`/`codeLoc` (not linked as a plain TU), which
696
+ * linker preset to apply, which SDK runtime to select, and which files to SKIP
697
+ * (preset-supplied crt0s, SDK intermediates). This is the single source of truth
698
+ * that makes `build({output:'project'|'run', path})` match what the scaffold
699
+ * README's hand-written build call does.
700
+ * @param {string} platform
701
+ * @param {string[]} names filenames present in the project dir
702
+ */
703
+ export function projectBuildRecipe(platform, names) {
704
+ const has = (n) => names.includes(n);
705
+ /** @type {{crt0File:string|null, codeLoc:number|undefined, linkerConfig:string|undefined, runtime:string|undefined, maxmod:boolean|undefined, skip:Set<string>, includeAsC:Set<string>}} */
706
+ const r = { crt0File: null, codeLoc: undefined, linkerConfig: undefined, runtime: undefined, maxmod: undefined, skip: new Set(), includeAsC: new Set() };
707
+
708
+ // Reference/upstream sources ship for grepping, not compiling (e.g. GB
709
+ // music_demo's hUGEDriver.upstream.asm — the .c port is what builds). Skip
710
+ // any *.upstream.* on every platform.
711
+ for (const n of names) if (/\.upstream\./i.test(n)) r.skip.add(n);
712
+
713
+ if (platform === "gb" || platform === "gbc") {
714
+ // GB/GBC ship gb_crt0.s — it MUST go via crt0+codeLoc:0x150, never as a
715
+ // source (SDCC emits its own gsinit → "Multiple definition of gsinit").
716
+ if (has("gb_crt0.s")) { r.crt0File = "gb_crt0.s"; r.codeLoc = 0x150; }
717
+ } else if (platform === "nes") {
718
+ // A SCAFFOLDED NES project ships nes_runtime.c + a crt0 + a .cfg and needs
719
+ // the chr-ram-runtime preset (it defines the OAM/CHARS segments + a NMI with
720
+ // OAM-DMA; without it: "Missing memory area 'OAM'"). The preset SUPPLIES its
721
+ // own crt0 + expects nes_runtime.c, so skip the scaffold's crt0/.cfg (the
722
+ // preset replaces them). A BARE hand-rolled NES dir (no scaffold crt0/.cfg)
723
+ // is left alone — forcing the preset there would demand runtime symbols it
724
+ // doesn't have. Detect "scaffolded" by the presence of a crt0 or .cfg.
725
+ const looksScaffolded = names.some((n) => /crt0.*\.s$/i.test(n) || /\.cfg$/i.test(n));
726
+ if (looksScaffolded) {
727
+ r.linkerConfig = "chr-ram-runtime";
728
+ for (const n of names) {
729
+ if (/crt0.*\.s$/i.test(n) || /\.cfg$/i.test(n)) r.skip.add(n);
730
+ }
731
+ }
732
+ } else if (platform === "sms" || platform === "gg") {
733
+ // SMS/GG auto-inject their bundled crt0 inside buildForPlatform — so the
734
+ // scaffold's own *_crt0.s would be a DUPLICATE. Skip it.
735
+ for (const n of names) if (/_crt0\.s$/i.test(n)) r.skip.add(n);
736
+ } else if (platform === "genesis" || platform === "megadrive" || platform === "md") {
737
+ // SGDK supplies sega startup + rom header. The scaffold dir may contain
738
+ // generated intermediates (sega.s, sega.preprocessed.s, rom_header.*, and an
739
+ // out/ build dir) that must NOT be recompiled — sega.preprocessed.s refs a
740
+ // missing out/rom_header.bin and aborts the build.
741
+ for (const n of names) {
742
+ if (/^sega(\.preprocessed)?\.s$/i.test(n) || /^rom_header\./i.test(n) || /\.preprocessed\.s$/i.test(n)) r.skip.add(n);
743
+ }
744
+ } else if (platform === "snes") {
745
+ const asmEntry = has("main.asm") && !has("main.c"); // asar asm template
746
+ if (asmEntry) {
747
+ // SNES asar asm template: main.asm `.include`s its siblings
748
+ // (lorom_header/reset_init/cgram_upload.asm). asar takes ONE source +
749
+ // resolves .include from the includes mount — so route non-main .asm as
750
+ // includes, leaving main.asm the single source.
751
+ for (const n of names) {
752
+ if (/\.asm$/i.test(n) && n !== "main.asm") r.includeAsC.add(n);
753
+ }
754
+ } else {
755
+ // SNES (PVSnesLib/tcc) C scaffolds combine C via `#include "snes_sfx.c"`
756
+ // from main.c — a single TU. A non-main .c is an INCLUDE (tcc must find it
757
+ // for the #include), NOT a separate source TU (which would double-define).
758
+ // (data.asm / snes_sfx_data.asm stay real wla sources, compiled + linked.)
759
+ for (const n of names) {
760
+ if (/\.c$/i.test(n) && n !== "main.c") r.includeAsC.add(n);
761
+ }
762
+ }
763
+ // The SPC700 audio driver sources (spc_driver.asm, apu_blob.asm) are 65816-
764
+ // INCOMPATIBLE SPC700 asm used OFFLINE to regenerate apu_blob.bin — the
765
+ // scaffold already ships the built .bin (incbin'd by snes_sfx_data.asm).
766
+ // Compiling them as 65816 sources fails ("Cannot process spc700"). Skip them.
767
+ for (const n of names) {
768
+ if (n === "spc_driver.asm" || n === "apu_blob.asm") r.skip.add(n);
769
+ }
770
+ } else if (platform === "gba") {
771
+ // GBA: default runtime is libtonc; a soundbank.bin means the maxmod path.
772
+ // The libgba-vs-libtonc choice needs the source CONTENT (which header it
773
+ // includes), so buildProjectCore refines r.runtime after reading main.c.
774
+ r.runtime = "libtonc";
775
+ if (has("soundbank.bin")) r.maxmod = true;
776
+ }
777
+ return r;
778
+ }
779
+
780
+ /**
781
+ * Read a scaffolded project DIRECTORY into the build inputs, applying the
782
+ * per-platform recipe (crt0 routing, linker preset, runtime, skip-list) and
783
+ * the GBA runtime content-sniff. The SINGLE source of truth shared by
784
+ * build({output:'project'}) and build({output:'run', path}) — so the two paths
785
+ * can never drift. Returns crt0 as RAW source text (callers assemble it).
786
+ * @param {string} projPath
787
+ * @param {string} platform
788
+ */
789
+ export async function readProjectDir(projPath, platform) {
652
790
  const entries = await readdir(projPath, { withFileTypes: true });
653
791
  const files = entries.filter((e) => e.isFile());
654
792
 
655
- // Entry point: a C project uses main.c (SGDK/Genesis, GBA, cc65/SDCC C); an
656
- // asm project uses main.s / main.asm. Pick whichever exists — so the SAME
657
- // dir-build works for C/SGDK Genesis projects, not just asm/cc65.
658
793
  const hasC = files.some((f) => f.name === "main.c");
659
794
  const hasAsm = files.some((f) => f.name === "main.s" || f.name === "main.asm");
660
795
  if (!hasC && !hasAsm) {
@@ -664,40 +799,81 @@ export async function buildProjectCore({ path: projPath, platform, outputPath })
664
799
  );
665
800
  }
666
801
 
667
- // Every .c/.s/.asm is its own translation unit (linked together — cc65 routes
668
- // .c→cc65 + .s→ca65; SDCC/SGDK/GBA compile every .c). Every .h/.inc is an
669
- // include (NOT a TU). Binary assets become binaryIncludes so .incbin survives.
802
+ // Per-platform PROJECT RECIPE see projectBuildRecipe. Globbing every file as
803
+ // a source is what broke GB (gsinit double-def), NES (no OAM/CHARS), Genesis
804
+ // (sega.preprocessed.s). The recipe routes crt0 / preset / runtime / skips so
805
+ // the dir build matches the hand-written build({output:'run'}) call.
806
+ const recipe = projectBuildRecipe(platform, files.map((f) => f.name));
807
+
670
808
  /** @type {Record<string,string>} */ const sources = {};
671
809
  /** @type {Record<string,string>} */ const includes = {};
672
810
  /** @type {Record<string,string>} */ const binaryIncludes = {};
811
+ let crt0 = null;
673
812
  for (const f of files) {
674
813
  const n = f.name;
814
+ if (recipe.skip.has(n)) continue;
815
+ if (recipe.crt0File === n) { crt0 = await readFile(path.join(projPath, n), "utf-8"); continue; }
816
+ // includeAsC: a .c that's `#include`d by another TU (e.g. SNES main.c
817
+ // includes snes_sfx.c) — make it an include, NOT a separate source TU.
818
+ if (recipe.includeAsC.has(n)) { includes[n] = await readFile(path.join(projPath, n), "utf-8"); continue; }
675
819
  if (/\.(c|s|asm)$/i.test(n)) sources[n] = await readFile(path.join(projPath, n), "utf-8");
676
820
  else if (/\.(h|inc)$/i.test(n)) includes[n] = await readFile(path.join(projPath, n), "utf-8");
677
- else if (/\.(bin|chr|pcm|brr|vgm|xgm|nsf|raw|pal)$/i.test(n)) {
821
+ else if (/\.(bin|chr|pcm|brr|vgm|vgz|xgm|xgc|xgm2|esf|tfi|eif|nsf|raw|pal|map|tmx|spc|wav|gbs)$/i.test(n)) {
822
+ // Any binary asset an .incbin might reference. (.xgc = compiled XGM2 blob,
823
+ // .vgz/.esf/etc = music driver inputs.) Missing one = "file not found: X".
678
824
  binaryIncludes[n] = (await readFile(path.join(projPath, n))).toString("base64");
679
825
  }
680
826
  }
681
827
 
682
- // Route through resolveLinkerConfig like build({output:'rom'}) so cc65 presets
683
- // + support sources still apply for a dir-built cc65 project.
684
- const { cfg: resolvedLinkerConfig, supportSources } = await resolveLinkerConfig(platform, undefined);
828
+ // GBA runtime refinement: libgba if the entry includes <gba.h>, else the
829
+ // libtonc default the recipe set.
830
+ let runtime = recipe.runtime;
831
+ if (platform === "gba" && sources["main.c"] && /#\s*include\s*[<"]gba\.h[>"]/.test(sources["main.c"])) {
832
+ runtime = "libgba";
833
+ }
834
+
835
+ return { sources, includes, binaryIncludes, crt0, codeLoc: recipe.codeLoc, linkerConfig: recipe.linkerConfig, runtime, maxmod: recipe.maxmod };
836
+ }
837
+
838
+ export async function buildProjectCore({ path: projPath, platform, outputPath }) {
839
+ const { sources, includes, binaryIncludes, crt0, codeLoc, linkerConfig, runtime, maxmod } = await readProjectDir(projPath, platform);
840
+
841
+ // Linker preset: the recipe names it (e.g. NES 'chr-ram-runtime', which ships
842
+ // the OAM/CHARS segments + its own crt0). resolveLinkerConfig also returns any
843
+ // preset support sources (the preset crt0) — those merge into the sources.
844
+ const { cfg: resolvedLinkerConfig, supportSources } = await resolveLinkerConfig(platform, linkerConfig);
685
845
  const mergedSources = Object.keys(supportSources).length ? { ...supportSources, ...sources } : sources;
686
846
 
847
+ // Assemble a routed crt0 (SDCC sm83/z80) into a .rel, exactly like the
848
+ // output:'rom'/'run' path — passing it as `crt0` (NOT a source TU) is what
849
+ // avoids the gsinit double-definition on GB/GBC.
850
+ let crt0Rel;
851
+ if (crt0) {
852
+ const isSm83 = platform === "gb" || platform === "gbc";
853
+ const { runSdasgb, runSdasz80 } = await import("../../toolchains/sdcc/sdcc.js");
854
+ const asm = isSm83 ? await runSdasgb({ source: crt0 }) : await runSdasz80({ source: crt0 });
855
+ if (!asm.rel) throw new Error(`crt0 assembly failed:\n${asm.log}`);
856
+ crt0Rel = asm.rel;
857
+ }
858
+
687
859
  // Single-source toolchains (dasm/atari2600, vasm/asm) take `source`, not the
688
860
  // multi-TU `sources` map. When the dir has exactly one source file and no
689
861
  // preset support sources, pass it as `source` so those targets still build
690
862
  // (the original asm-only buildProject behavior).
691
863
  const srcNames = Object.keys(sources);
692
- const singleSource = srcNames.length === 1 && Object.keys(supportSources).length === 0;
864
+ const singleSource = srcNames.length === 1 && Object.keys(supportSources).length === 0 && !crt0Rel;
693
865
  const result = await buildForPlatform({
694
866
  platform,
867
+ runtime,
868
+ maxmod,
695
869
  ...(singleSource
696
870
  ? { source: sources[srcNames[0]], sourceName: srcNames[0] }
697
871
  : { sources: mergedSources }),
698
872
  includes: Object.keys(includes).length ? includes : undefined,
699
873
  binaryIncludes: Object.keys(binaryIncludes).length ? binaryIncludes : undefined,
700
874
  linkerConfig: resolvedLinkerConfig,
875
+ crt0: crt0Rel,
876
+ codeLoc,
701
877
  });
702
878
  if (outputPath && result.binary) {
703
879
  await mkdir(path.dirname(outputPath), { recursive: true });
@@ -28,6 +28,11 @@
28
28
  #status { font-size: 11px; color: #888; margin-left: auto; }
29
29
  #status.connected { color: #4caf50; }
30
30
  #status.disconnected { color: #f44336; }
31
+ #log-session {
32
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
33
+ font-size: 12px; color: #6cb6ff; padding: 2px 0 8px;
34
+ word-break: break-all;
35
+ }
31
36
  #tabs {
32
37
  display: flex; flex-wrap: wrap; gap: 4px;
33
38
  padding: 6px 12px; background: #111; border-bottom: 1px solid #333;
@@ -189,6 +194,7 @@
189
194
  </div>
190
195
  <div id="log-pane">
191
196
  <h2>Activity log</h2>
197
+ <div id="log-session"></div>
192
198
  <div id="log-list"></div>
193
199
  </div>
194
200
  </div>
@@ -201,6 +207,7 @@
201
207
  const tabsEl = document.getElementById("tabs");
202
208
  const imageListEl = document.getElementById("image-list");
203
209
  const logListEl = document.getElementById("log-list");
210
+ const logSessionEl = document.getElementById("log-session");
204
211
  const emptyEl = document.getElementById("empty");
205
212
 
206
213
  // Per-session state.
@@ -351,10 +358,22 @@
351
358
  tab.className = "tab" + (k === activeSessionKey ? " active" : "")
352
359
  + (!s.connected ? " disconnected" : "");
353
360
  tab.onclick = () => selectTab(k);
354
- tab.innerHTML = `<span>${k.slice(0, 8)}</span>`
355
- + (s.unseen > 0 && k !== activeSessionKey
356
- ? ` <span class="badge">${s.unseen}</span>` : "");
357
- if (!s.connected) tab.title = "session closed";
361
+ // Show the FULL session id — agents pick descriptive ids (e.g.
362
+ // "scaffold-nes-platformer") and truncating to 8 chars collapsed distinct
363
+ // tasks into identical-looking tabs ("scaffold" × N). Cap very long ids in
364
+ // the middle so the meaningful head+tail both show; full id in the tooltip.
365
+ const label = k.length > 28 ? k.slice(0, 18) + "…" + k.slice(-8) : k;
366
+ const labelSpan = document.createElement("span");
367
+ labelSpan.textContent = label;
368
+ tab.appendChild(labelSpan);
369
+ if (s.unseen > 0 && k !== activeSessionKey) {
370
+ const badge = document.createElement("span");
371
+ badge.className = "badge";
372
+ badge.textContent = String(s.unseen);
373
+ tab.appendChild(document.createTextNode(" "));
374
+ tab.appendChild(badge);
375
+ }
376
+ tab.title = s.connected ? k : `${k} (session closed)`;
358
377
  tabsEl.appendChild(tab);
359
378
  }
360
379
  }
@@ -504,6 +523,17 @@
504
523
 
505
524
  function renderLog() {
506
525
  logListEl.innerHTML = "";
526
+ // Full session id above the log — the tab label can be middle-truncated, so
527
+ // this is the one place that always shows the EXACT id you're looking at.
528
+ if (logSessionEl) {
529
+ if (activeSessionKey) {
530
+ logSessionEl.textContent = "session: " + activeSessionKey;
531
+ logSessionEl.style.display = "";
532
+ } else {
533
+ logSessionEl.textContent = "";
534
+ logSessionEl.style.display = "none";
535
+ }
536
+ }
507
537
  if (!activeSessionKey) return;
508
538
  const s = sessions.get(activeSessionKey);
509
539
  // Newest first — easier to scan when working live.
@@ -73,19 +73,20 @@ check the live OAM Y bytes for $D0 in a slot before them. That's
73
73
  still the diagnosis; the runtime just doesn't create the problem
74
74
  on its own anymore.
75
75
 
76
- ### R6 sprite-tile-base default: $0000, NOT $2000
77
-
78
- `gg_vdp_init()` sets R6 = 0xFB. R6 bit 2 is the SA13 select for
79
- sprite tile data — and bit 2 is **CLEAR** in 0xFB. That means
80
- sprite tiles read from `$0000-$1FFF`, **sharing the bank with BG
81
- tiles**. Many references (including the older comments we just
82
- fixed in `vdp_init.c` and `load_tiles.c`) say "R6=0xFB → sprite
83
- tiles at $2000" that's wrong.
84
-
85
- If you want sprite tiles in their own bank at $2000, set
86
- `vdp_write_reg(6, 0xFF)` AND upload tiles to VRAM $2000. Otherwise
87
- upload sprite tiles to $0000 alongside BG tiles (just make sure
88
- they don't collide).
76
+ ### R6 sprite-tile-base: default is $2000 (0xFF)
77
+
78
+ `gg_vdp_init()` sets R6 = 0xFF. R6 bit 2 is the SA13 select for
79
+ sprite tile data — bit 2 is **SET** in 0xFF, so sprite tiles read
80
+ from `$2000-$3FFF`, in their **own bank** separate from BG tiles at
81
+ $0000. This is the baseline because every bundled scaffold uploads
82
+ its sprite tiles to `$2000` (`gg_load_tiles(0x2000, …)`) the
83
+ default and the scaffolds match, so sprites Just Show Up.
84
+
85
+ Watch the bit: 0xFB has SA13 **CLEAR** = sprite tiles at $0000
86
+ (sharing the BG bank). If you ever set R6=0xFB you MUST also upload
87
+ your sprite tiles to $0000, or the VDP reads the empty/BG bank and
88
+ every sprite is invisible — the classic GG/SMS "my sprites don't
89
+ show up" trap.
89
90
 
90
91
  The `sprites({op:'inspect'})` tool's `spriteTileDataBase` field reports the
91
92
  address the VDP is actually reading from — trust that over any
@@ -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/GG 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 (gg_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/GG 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
  * gg_vdp_display_on();
@@ -33,7 +35,7 @@ void gg_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 */
@@ -106,7 +106,7 @@ exactly this.
106
106
  - `palette({source:'live'})` — V9938 9-bit GRB (or TMS9918 fixed) 16 entries.
107
107
  - `sprites({op:'inspect'})` — VRAM sprite-attribute table, up to 32 sprites.
108
108
  - `symbols({op:'map', map})` — pass the sdld `.map` (the `symbols` field from
109
- buildSourceWithDebug) to see where SDCC placed your variables/code, grouped by
109
+ build({output:'romWithDebug'})) to see where SDCC placed your variables/code, grouped by
110
110
  region (bios / cart_rom / work_ram).
111
111
  - `audioDebug({op:'inspect', chip: "ay8910"})` — the AY-3-8910 PSG: 3 square-wave
112
112
  channels (tone period→Hz, amplitude, tone/noise enable) + a shared noise
@@ -140,7 +140,7 @@ Overflow it and there's no error — your globals quietly collide with
140
140
  the stack or shadow OAM → corrupted state, sprites that flicker to
141
141
  garbage, random crashes.
142
142
 
143
- **Check the `ramUsage` field in the buildSource/runSource response** —
143
+ **Check the `ramUsage` field in the build response** —
144
144
  it lists your BSS / DATA / ZEROPAGE segment sizes from the linker map.
145
145
  If BSS+DATA is approaching the config's RAM region, shrink your state:
146
146
  prefer `uint8_t` over `int`, bit-pack flags, use small fixed arrays,
@@ -56,6 +56,7 @@ volatile uint8_t nmi_counter = 0;
56
56
  * (so OAM segment placement at $0200 is linker-enforced). oam_index
57
57
  * tracks the next free slot for oam_spr(). */
58
58
  static uint8_t oam_index = 0;
59
+ static void oam_hide_unused(void); /* fwd decl — used by ppu_wait_nmi (NES-1) */
59
60
 
60
61
  /* ── VRAM write queue ─────────────────────────────────────────────
61
62
  * Each entry is { hi, lo, byte }. NMI walks the queue, writes
@@ -130,7 +131,12 @@ void ppu_wait_vblank(void) {
130
131
  }
131
132
 
132
133
  void ppu_wait_nmi(void) {
133
- uint8_t target = (uint8_t)(nmi_counter + 1);
134
+ uint8_t target;
135
+ /* Hide last frame's now-unused sprite slots BEFORE waiting, so the buffer
136
+ * the NMI's OAM-DMA copies is fully staged (live slots written by oam_spr,
137
+ * stale slots parked off-screen) — never a half-cleared buffer (NES-1). */
138
+ oam_hide_unused();
139
+ target = (uint8_t)(nmi_counter + 1);
134
140
  while (nmi_counter != target) { /* spin */ }
135
141
  }
136
142
 
@@ -159,15 +165,31 @@ void palette_load(const uint8_t *pal32) {
159
165
 
160
166
  /* ── OAM ──────────────────────────────────────────────────────── */
161
167
 
168
+ /* High-water mark: the largest oam_index reached last frame. Lets us blank
169
+ * ONLY the slots a frame stopped using, instead of the whole 256-byte buffer
170
+ * every frame. */
171
+ static uint8_t oam_high = 0;
172
+
162
173
  void oam_clear(void) {
174
+ /* NES-1 FIX: do NOT blank the whole shadow buffer here. The old full clear
175
+ * wrote slot 0's Y=$FF FIRST and took ~hundreds of cycles; if the NMI's
176
+ * OAM-DMA fired mid-clear it copied a HALF-CLEARED buffer → the live sprite
177
+ * vanished every other frame (the classic "sprite flickers to black"
178
+ * sprite-light scaffold bug). Instead we just reset the staging index here;
179
+ * ppu_wait_nmi() hides the slots this frame stopped using, so the DMA only
180
+ * ever sees a fully-staged buffer. */
181
+ oam_index = 0;
182
+ }
183
+
184
+ /* Hide slots [oam_index .. oam_high] (the ones used last frame but not this
185
+ * frame) by parking their Y off-screen. Called from ppu_wait_nmi AFTER the
186
+ * game has staged its live sprites, so live slots are never blanked. */
187
+ static void oam_hide_unused(void) {
163
188
  uint16_t i;
164
- for (i = 0; i < 256; i += 4) {
189
+ for (i = oam_index; i < (uint16_t)oam_high + 4 && i < 256; i += 4) {
165
190
  shadow_oam[i] = 0xFF; /* Y off-screen */
166
- shadow_oam[i + 1] = 0; /* tile */
167
- shadow_oam[i + 2] = 0; /* attr */
168
- shadow_oam[i + 3] = 0; /* X */
169
191
  }
170
- oam_index = 0;
192
+ oam_high = oam_index;
171
193
  }
172
194
 
173
195
  void oam_spr(uint8_t x, uint8_t y, uint8_t tile, uint8_t attr) {
@@ -90,7 +90,7 @@ screen. Keep at least one (2+ byte) global. See TROUBLESHOOTING.md.
90
90
  - `background({view:'renderState'})` — VDC R5 screen-enable, BG scroll, SATB source.
91
91
  - `palette({source:'live'})` — VCE 512-entry 9-bit GRB (area:'bg'|'sprite').
92
92
  - `sprites({op:'inspect'})` — SATB 64 sprites (x/y/tile/palette/size/flip).
93
- - `symbols({op:'map'})` — where cc65 placed your variables (after buildSourceWithDebug).
93
+ - `symbols({op:'map'})` — where cc65 placed your variables (after build({output:'romWithDebug'})).
94
94
  - `audioDebug({op:'inspect', chip: "pce"})` — the HuC6280 PSG: 6 wavetable channels
95
95
  (per-channel freq/volume/wave; channels 4-5 can also do noise) + main amplitude
96
96
  + LFO.
@@ -96,6 +96,7 @@ void vdc_set_reg(u8 reg, u16 val); /* select reg, write 16-bit valu
96
96
  void vram_set_write_addr(u16 addr); /* point MAWR + arm VWR streaming */
97
97
  void vram_write(u16 addr, const u16 *data, u16 n); /* upload n words to VRAM[addr] */
98
98
  void vce_set_color(u16 idx, u16 grb); /* set VCE palette entry (0..511) */
99
+ void vdc_init(void); /* program VDC display timing (256x224 NTSC); auto-run by *_enable */
99
100
  void bg_enable(void); /* VDC R5: background on + VBlank IRQ (so waitvsync works) */
100
101
  void spr_enable(void); /* VDC R5: sprites on + VBlank IRQ */
101
102
  void disp_enable(void); /* VDC R5: BG + SPR + VBlank IRQ on at once */
@@ -70,17 +70,43 @@ void vblank_irq_enable(void) {
70
70
  vdc_set_reg(VDC_CR, _pce_cr);
71
71
  }
72
72
 
73
+ /* Program the VDC display-timing registers for a standard NTSC 256x224 (H32)
74
+ * screen. WITHOUT this the geargrafx core falls back to power-on register
75
+ * defaults that composite the 32-row BAT into the display DOUBLED (the scene
76
+ * drawn twice, top + bottom halves, with a black right margin) — the PCE-1
77
+ * "doubled picture" bug. Values match cc65's pce.lib / standard PCE homebrew:
78
+ * MWR R9 = 32x32 virtual screen, 256px-wide BAT
79
+ * HSR R10 / HDR R11 = 256px (32 char) horizontal display
80
+ * VPR R12 / VDW R13 / VCR R14 = 224-line vertical window
81
+ * Called automatically the first time the display is enabled (idempotent). */
82
+ static u8 _pce_vdc_inited = 0;
83
+ void vdc_init(void) {
84
+ if (_pce_vdc_inited) return;
85
+ _pce_vdc_inited = 1;
86
+ vdc_set_reg(VDC_MWR, 0x0010); /* 32x32 virtual map, 256px BAT */
87
+ vdc_set_reg(VDC_BXR, 0x0000); /* BG X scroll = 0 */
88
+ vdc_set_reg(VDC_BYR, 0x0000); /* BG Y scroll = 0 */
89
+ vdc_set_reg(VDC_HSR, 0x0202); /* horizontal sync width/start */
90
+ vdc_set_reg(VDC_HDR, 0x031F); /* horizontal display = 32 chars (256px) */
91
+ vdc_set_reg(VDC_VPR, 0x0F02); /* vertical sync */
92
+ vdc_set_reg(VDC_VDW, 0x00DF); /* vertical display = 224 lines */
93
+ vdc_set_reg(VDC_VCR, 0x00EE); /* vertical display end */
94
+ }
95
+
73
96
  void bg_enable(void) {
97
+ vdc_init();
74
98
  _pce_cr |= (VDC_CR_BG_ON | VDC_CR_VBLANK_IRQ);
75
99
  vdc_set_reg(VDC_CR, _pce_cr);
76
100
  }
77
101
 
78
102
  void spr_enable(void) {
103
+ vdc_init();
79
104
  _pce_cr |= (VDC_CR_SPR_ON | VDC_CR_VBLANK_IRQ);
80
105
  vdc_set_reg(VDC_CR, _pce_cr);
81
106
  }
82
107
 
83
108
  void disp_enable(void) {
109
+ vdc_init();
84
110
  _pce_cr |= (VDC_CR_BG_ON | VDC_CR_SPR_ON | VDC_CR_VBLANK_IRQ);
85
111
  vdc_set_reg(VDC_CR, _pce_cr);
86
112
  }
@@ -78,7 +78,7 @@ R1 = 0x80 display OFF, vblank IRQ off, 192-line
78
78
  R2 = 0xFF name table at $3800
79
79
  R4 = 0xFF BG tile data at $0000
80
80
  R5 = 0xFF sprite attr table at $3F00
81
- R6 = 0xFB sprite tile data at $0000 (NOT $2000 see footgun below)
81
+ R6 = 0xFF sprite tile data at $2000 (own bank; scaffolds upload here)
82
82
  R7 = 0x00 border colour
83
83
  ```
84
84
 
@@ -142,19 +142,19 @@ buffer in WRAM and uploads it to the SAT each vblank.
142
142
  `sprites({op:'inspect'})` shows the live OAM bytes + reports
143
143
  `spriteTileDataBase` — trust it over comments when sprites misbehave.
144
144
 
145
- ### R6 sprite-tile-base default: $0000, NOT $2000
145
+ ### R6 sprite-tile-base: default is $2000 (0xFF)
146
146
 
147
- `sms_vdp_init()` sets R6 = 0xFB. R6 bit 2 is the SA13 select for
148
- sprite tile data — and bit 2 is **CLEAR** in 0xFB. That means
149
- sprite tiles read from `$0000-$1FFF`, **sharing the bank with BG
150
- tiles**. Many references (including older comments in our own
151
- `vdp_init.c` and `load_tiles.c`, since fixed) say "R6=0xFB → sprite
152
- tiles at $2000" that's wrong.
147
+ `sms_vdp_init()` sets R6 = 0xFF. R6 bit 2 is the SA13 select for
148
+ sprite tile data — bit 2 is **SET** in 0xFF, so sprite tiles read
149
+ from `$2000-$3FFF`, their **own bank** separate from BG tiles at
150
+ $0000. This matches every bundled scaffold, which uploads sprite
151
+ tiles to `$2000` (`sms_load_tiles(0x2000, )`) default and
152
+ scaffolds agree, so sprites render.
153
153
 
154
- If you want sprite tiles in their own bank at $2000, set
155
- `vdp_write_reg(6, 0xFF)` AND upload tiles to VRAM $2000. Otherwise
156
- upload sprite tiles to $0000 alongside BG tiles (just make sure
157
- they don't collide).
154
+ Watch the bit: 0xFB has SA13 **CLEAR** = sprite tiles at $0000
155
+ (shared with the BG bank). If you set R6=0xFB you MUST upload your
156
+ sprite tiles to $0000 too, or the VDP reads the empty/BG bank and
157
+ every sprite is invisible.
158
158
 
159
159
  ## Palette (CRAM)
160
160