romdevtools 0.14.0 → 0.15.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 (96) hide show
  1. package/AGENTS.md +17 -10
  2. package/CHANGELOG.md +63 -0
  3. package/examples/atari2600/main.asm +1 -1
  4. package/examples/atari2600/templates/default.asm +1 -1
  5. package/examples/atari2600/templates/paddle.asm +59 -47
  6. package/examples/atari7800/main.c +1 -1
  7. package/examples/atari7800/templates/default.c +1 -1
  8. package/examples/atari7800/templates/music_demo.c +1 -1
  9. package/examples/c64/main.c +1 -1
  10. package/examples/c64/templates/platformer.c +2 -2
  11. package/examples/c64/templates/puzzle.c +1 -1
  12. package/examples/c64/templates/racing.c +3 -3
  13. package/examples/c64/templates/shmup.c +6 -5
  14. package/examples/c64/templates/sports.c +4 -4
  15. package/examples/gb/main.asm +1 -1
  16. package/examples/gb/main.c +1 -1
  17. package/examples/gb/templates/puzzle.c +1 -1
  18. package/examples/gb/templates/racing.c +1 -1
  19. package/examples/gb/templates/shmup.c +1 -1
  20. package/examples/gba/templates/gba_hello.c +1 -1
  21. package/examples/gba/templates/maxmod_demo.c +1 -1
  22. package/examples/gba/templates/puzzle.c +17 -3
  23. package/examples/gba/templates/racing.c +16 -2
  24. package/examples/gba/templates/shmup.c +23 -4
  25. package/examples/gba/templates/tonc_hello.c +6 -4
  26. package/examples/gbc/main.asm +1 -1
  27. package/examples/gbc/templates/puzzle.c +1 -1
  28. package/examples/gbc/templates/racing.c +1 -1
  29. package/examples/gbc/templates/shmup.c +1 -1
  30. package/examples/genesis/main.s +1 -1
  31. package/examples/genesis/templates/puzzle.c +1 -1
  32. package/examples/genesis/templates/racing.c +45 -1
  33. package/examples/genesis/templates/shmup.c +12 -3
  34. package/examples/genesis/templates/shmup_2p.c +2 -2
  35. package/examples/genesis/templates/sports.c +39 -0
  36. package/examples/gg/templates/hello_sprite.c +38 -23
  37. package/examples/gg/templates/music_demo.c +11 -8
  38. package/examples/gg/templates/platformer.c +37 -15
  39. package/examples/gg/templates/racing.c +25 -12
  40. package/examples/gg/templates/shmup.c +12 -6
  41. package/examples/gg/templates/sports.c +30 -16
  42. package/examples/gg/templates/tile_engine.c +24 -10
  43. package/examples/lynx/templates/platformer.c +7 -1
  44. package/examples/lynx/templates/puzzle.c +8 -2
  45. package/examples/lynx/templates/racing.c +7 -1
  46. package/examples/lynx/templates/sports.c +7 -1
  47. package/examples/nes/main.c +2 -2
  48. package/examples/nes/space-shooter/nes_runtime.h +1 -1
  49. package/examples/nes/templates/default.c +4 -1
  50. package/examples/nes/templates/racing.c +50 -1
  51. package/examples/pce/main.c +1 -1
  52. package/examples/sms/templates/hello_sprite.c +1 -1
  53. package/examples/sms/templates/music_demo.c +1 -1
  54. package/examples/sms/templates/puzzle.c +1 -1
  55. package/examples/sms/templates/racing.c +1 -1
  56. package/examples/sms/templates/shmup.c +1 -1
  57. package/examples/sms/templates/shmup_2p.c +2 -2
  58. package/examples/snes/main.asm +1 -1
  59. package/examples/snes/templates/c-hello-data.asm +309 -14
  60. package/examples/snes/templates/c-hello.c +13 -2
  61. package/examples/snes/templates/default.c +1 -1
  62. package/examples/snes/templates/hello_sprite-data.asm +300 -2
  63. package/examples/snes/templates/hello_sprite.c +10 -1
  64. package/examples/snes/templates/music_demo-data.asm +300 -2
  65. package/examples/snes/templates/music_demo.c +10 -1
  66. package/examples/snes/templates/platformer-data.asm +300 -2
  67. package/examples/snes/templates/platformer.c +10 -1
  68. package/examples/snes/templates/puzzle-data.asm +300 -2
  69. package/examples/snes/templates/puzzle.c +11 -1
  70. package/examples/snes/templates/racing-data.asm +300 -2
  71. package/examples/snes/templates/racing.c +40 -4
  72. package/examples/snes/templates/shmup-data.asm +299 -6
  73. package/examples/snes/templates/shmup.c +11 -7
  74. package/examples/snes/templates/sports-data.asm +300 -2
  75. package/examples/snes/templates/sports.c +40 -5
  76. package/package.json +1 -1
  77. package/src/mcp/tools/project.js +33 -22
  78. package/src/mcp/tools/toolchain.js +183 -19
  79. package/src/observer/livestream.html +34 -4
  80. package/src/platforms/gg/MENTAL_MODEL.md +14 -13
  81. package/src/platforms/gg/lib/c/vdp_init.c +10 -8
  82. package/src/platforms/msx/MENTAL_MODEL.md +1 -1
  83. package/src/platforms/nes/TROUBLESHOOTING.md +1 -1
  84. package/src/platforms/nes/lib/c/nes_runtime.c +28 -6
  85. package/src/platforms/pce/MENTAL_MODEL.md +1 -1
  86. package/src/platforms/pce/lib/c/pce_hw.h +1 -0
  87. package/src/platforms/pce/lib/c/pce_video.c +26 -0
  88. package/src/platforms/sms/MENTAL_MODEL.md +12 -12
  89. package/src/platforms/sms/lib/c/vdp_init.c +10 -8
  90. package/src/platforms/sms/lib/vdp_init.s +1 -1
  91. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +1 -1
  92. package/src/toolchains/cc65/presets/nes/chr-ram.cfg +1 -1
  93. package/src/toolchains/cc65/presets/nes/chr-ram.crt0.s +1 -1
  94. package/src/toolchains/genesis-c/README.md +1 -1
  95. package/src/toolchains/sdcc/preflight-lint.js +47 -7
  96. package/src/toolchains/snes-c/snes-c.js +3 -7
@@ -293,7 +293,7 @@ export function registerToolchainTools(server, z, sessionKey) {
293
293
  codeLoc,
294
294
  dataLoc,
295
295
  });
296
- logBuildResult("buildSource", platform, result);
296
+ logBuildResult("build:rom", platform, result);
297
297
  // lint:"strict" — if any lint warning fired, fail the build with
298
298
  // stage:"lint" so the agent must fix patterns before iterating.
299
299
  // We mutate the result rather than re-running because the lint
@@ -396,11 +396,40 @@ export function registerToolchainTools(server, z, sessionKey) {
396
396
  return jsonContent(payload);
397
397
  }
398
398
 
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 }) {
399
+ 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
400
  const { buildForPlatform } = await import("../../toolchains/index.js");
401
401
  const resolved = resolveCore(platform);
402
402
  if (!resolved) throw new Error(`no core available for platform '${platform}'`);
403
403
 
404
+ // PROJECT-DIR run: `build({output:'run', path})` with no explicit sources →
405
+ // read the scaffolded dir via the per-platform recipe (same as
406
+ // output:'project'), then run it. This is the documented "iterate on a dir"
407
+ // happy path; without it, output:'run' + path errored ("requires source").
408
+ const noExplicitSources = source == null && sourcePath == null && sources == null && sourcesPaths == null;
409
+ if (projPath && noExplicitSources) {
410
+ const r = await readProjectDir(projPath, platform);
411
+ includes = { ...(includes ?? {}), ...r.includes };
412
+ binaryIncludes = { ...(binaryIncludes ?? {}), ...r.binaryIncludes };
413
+ if (r.crt0 != null) crt0 = r.crt0;
414
+ if (r.codeLoc != null) codeLoc = r.codeLoc;
415
+ if (r.linkerConfig != null && linkerConfig == null) linkerConfig = r.linkerConfig;
416
+ if (r.runtime != null && runtime == null) runtime = r.runtime;
417
+ if (r.maxmod != null && maxmod == null) maxmod = r.maxmod;
418
+ // Single-source toolchains (dasm/atari2600, vasm/asm) need `source`, not
419
+ // a `sources` map. Collapse a lone source so those targets build via the
420
+ // dir path too. (resolveLinkerConfig support-sources, if any, force the
421
+ // map form below.)
422
+ const srcNames = Object.keys(r.sources);
423
+ if (srcNames.length === 1 && r.crt0 == null && r.linkerConfig == null) {
424
+ // Leave `source` null and set sourcePath — runSourceImpl reads it +
425
+ // derives sourceName (extension) for language inference. Single-source
426
+ // targets (dasm/vasm) require this form, not a `sources` map.
427
+ sourcePath = path.join(projPath, srcNames[0]);
428
+ } else {
429
+ sources = r.sources;
430
+ }
431
+ }
432
+
404
433
  if (source != null && sourcePath != null) {
405
434
  throw new Error("build({output:'run'}): pass either `source` OR `sourcePath`, not both.");
406
435
  }
@@ -474,7 +503,7 @@ export function registerToolchainTools(server, z, sessionKey) {
474
503
  codeLoc,
475
504
  dataLoc,
476
505
  });
477
- logBuildResult("runSource", platform, build);
506
+ logBuildResult("build:run", platform, build);
478
507
  if (!build.ok || !build.binary) {
479
508
  // runSource builds in-memory (no ROM path), so a large failure log
480
509
  // has nowhere to land — gate it to a tail + size rather than dumping
@@ -521,10 +550,10 @@ export function registerToolchainTools(server, z, sessionKey) {
521
550
  if (!isPlaytestRunning(sessionKey) && !playtestHintGiven.has(sessionKey)) {
522
551
  playtestHintGiven.add(sessionKey);
523
552
  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).";
553
+ "`playtest({op:\"open\"})` so they can play this ROM live while you " +
554
+ "keep iterating with build({output:\"run\"}) (rebuilds update the " +
555
+ "live game in place). Skip if this session is headless " +
556
+ "(CI / batch / automated).";
528
557
  }
529
558
 
530
559
  const summary = {
@@ -648,13 +677,107 @@ export function registerToolchainTools(server, z, sessionKey) {
648
677
  * a jsonContent payload (the router calls it via safeTool, which turns a throw —
649
678
  * e.g. no entry point — into an {isError:true} result).
650
679
  */
651
- export async function buildProjectCore({ path: projPath, platform, outputPath }) {
680
+ /**
681
+ * Per-platform recipe for building a SCAFFOLDED project directory. Given the
682
+ * platform + the list of filenames present, decide which file (if any) is a crt0
683
+ * that must be routed via `crt0`/`codeLoc` (not linked as a plain TU), which
684
+ * linker preset to apply, which SDK runtime to select, and which files to SKIP
685
+ * (preset-supplied crt0s, SDK intermediates). This is the single source of truth
686
+ * that makes `build({output:'project'|'run', path})` match what the scaffold
687
+ * README's hand-written build call does.
688
+ * @param {string} platform
689
+ * @param {string[]} names filenames present in the project dir
690
+ */
691
+ export function projectBuildRecipe(platform, names) {
692
+ const has = (n) => names.includes(n);
693
+ /** @type {{crt0File:string|null, codeLoc:number|undefined, linkerConfig:string|undefined, runtime:string|undefined, maxmod:boolean|undefined, skip:Set<string>, includeAsC:Set<string>}} */
694
+ const r = { crt0File: null, codeLoc: undefined, linkerConfig: undefined, runtime: undefined, maxmod: undefined, skip: new Set(), includeAsC: new Set() };
695
+
696
+ // Reference/upstream sources ship for grepping, not compiling (e.g. GB
697
+ // music_demo's hUGEDriver.upstream.asm — the .c port is what builds). Skip
698
+ // any *.upstream.* on every platform.
699
+ for (const n of names) if (/\.upstream\./i.test(n)) r.skip.add(n);
700
+
701
+ if (platform === "gb" || platform === "gbc") {
702
+ // GB/GBC ship gb_crt0.s — it MUST go via crt0+codeLoc:0x150, never as a
703
+ // source (SDCC emits its own gsinit → "Multiple definition of gsinit").
704
+ if (has("gb_crt0.s")) { r.crt0File = "gb_crt0.s"; r.codeLoc = 0x150; }
705
+ } else if (platform === "nes") {
706
+ // A SCAFFOLDED NES project ships nes_runtime.c + a crt0 + a .cfg and needs
707
+ // the chr-ram-runtime preset (it defines the OAM/CHARS segments + a NMI with
708
+ // OAM-DMA; without it: "Missing memory area 'OAM'"). The preset SUPPLIES its
709
+ // own crt0 + expects nes_runtime.c, so skip the scaffold's crt0/.cfg (the
710
+ // preset replaces them). A BARE hand-rolled NES dir (no scaffold crt0/.cfg)
711
+ // is left alone — forcing the preset there would demand runtime symbols it
712
+ // doesn't have. Detect "scaffolded" by the presence of a crt0 or .cfg.
713
+ const looksScaffolded = names.some((n) => /crt0.*\.s$/i.test(n) || /\.cfg$/i.test(n));
714
+ if (looksScaffolded) {
715
+ r.linkerConfig = "chr-ram-runtime";
716
+ for (const n of names) {
717
+ if (/crt0.*\.s$/i.test(n) || /\.cfg$/i.test(n)) r.skip.add(n);
718
+ }
719
+ }
720
+ } else if (platform === "sms" || platform === "gg") {
721
+ // SMS/GG auto-inject their bundled crt0 inside buildForPlatform — so the
722
+ // scaffold's own *_crt0.s would be a DUPLICATE. Skip it.
723
+ for (const n of names) if (/_crt0\.s$/i.test(n)) r.skip.add(n);
724
+ } else if (platform === "genesis" || platform === "megadrive" || platform === "md") {
725
+ // SGDK supplies sega startup + rom header. The scaffold dir may contain
726
+ // generated intermediates (sega.s, sega.preprocessed.s, rom_header.*, and an
727
+ // out/ build dir) that must NOT be recompiled — sega.preprocessed.s refs a
728
+ // missing out/rom_header.bin and aborts the build.
729
+ for (const n of names) {
730
+ if (/^sega(\.preprocessed)?\.s$/i.test(n) || /^rom_header\./i.test(n) || /\.preprocessed\.s$/i.test(n)) r.skip.add(n);
731
+ }
732
+ } else if (platform === "snes") {
733
+ const asmEntry = has("main.asm") && !has("main.c"); // asar asm template
734
+ if (asmEntry) {
735
+ // SNES asar asm template: main.asm `.include`s its siblings
736
+ // (lorom_header/reset_init/cgram_upload.asm). asar takes ONE source +
737
+ // resolves .include from the includes mount — so route non-main .asm as
738
+ // includes, leaving main.asm the single source.
739
+ for (const n of names) {
740
+ if (/\.asm$/i.test(n) && n !== "main.asm") r.includeAsC.add(n);
741
+ }
742
+ } else {
743
+ // SNES (PVSnesLib/tcc) C scaffolds combine C via `#include "snes_sfx.c"`
744
+ // from main.c — a single TU. A non-main .c is an INCLUDE (tcc must find it
745
+ // for the #include), NOT a separate source TU (which would double-define).
746
+ // (data.asm / snes_sfx_data.asm stay real wla sources, compiled + linked.)
747
+ for (const n of names) {
748
+ if (/\.c$/i.test(n) && n !== "main.c") r.includeAsC.add(n);
749
+ }
750
+ }
751
+ // The SPC700 audio driver sources (spc_driver.asm, apu_blob.asm) are 65816-
752
+ // INCOMPATIBLE SPC700 asm used OFFLINE to regenerate apu_blob.bin — the
753
+ // scaffold already ships the built .bin (incbin'd by snes_sfx_data.asm).
754
+ // Compiling them as 65816 sources fails ("Cannot process spc700"). Skip them.
755
+ for (const n of names) {
756
+ if (n === "spc_driver.asm" || n === "apu_blob.asm") r.skip.add(n);
757
+ }
758
+ } else if (platform === "gba") {
759
+ // GBA: default runtime is libtonc; a soundbank.bin means the maxmod path.
760
+ // The libgba-vs-libtonc choice needs the source CONTENT (which header it
761
+ // includes), so buildProjectCore refines r.runtime after reading main.c.
762
+ r.runtime = "libtonc";
763
+ if (has("soundbank.bin")) r.maxmod = true;
764
+ }
765
+ return r;
766
+ }
767
+
768
+ /**
769
+ * Read a scaffolded project DIRECTORY into the build inputs, applying the
770
+ * per-platform recipe (crt0 routing, linker preset, runtime, skip-list) and
771
+ * the GBA runtime content-sniff. The SINGLE source of truth shared by
772
+ * build({output:'project'}) and build({output:'run', path}) — so the two paths
773
+ * can never drift. Returns crt0 as RAW source text (callers assemble it).
774
+ * @param {string} projPath
775
+ * @param {string} platform
776
+ */
777
+ export async function readProjectDir(projPath, platform) {
652
778
  const entries = await readdir(projPath, { withFileTypes: true });
653
779
  const files = entries.filter((e) => e.isFile());
654
780
 
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
781
  const hasC = files.some((f) => f.name === "main.c");
659
782
  const hasAsm = files.some((f) => f.name === "main.s" || f.name === "main.asm");
660
783
  if (!hasC && !hasAsm) {
@@ -664,40 +787,81 @@ export async function buildProjectCore({ path: projPath, platform, outputPath })
664
787
  );
665
788
  }
666
789
 
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.
790
+ // Per-platform PROJECT RECIPE see projectBuildRecipe. Globbing every file as
791
+ // a source is what broke GB (gsinit double-def), NES (no OAM/CHARS), Genesis
792
+ // (sega.preprocessed.s). The recipe routes crt0 / preset / runtime / skips so
793
+ // the dir build matches the hand-written build({output:'run'}) call.
794
+ const recipe = projectBuildRecipe(platform, files.map((f) => f.name));
795
+
670
796
  /** @type {Record<string,string>} */ const sources = {};
671
797
  /** @type {Record<string,string>} */ const includes = {};
672
798
  /** @type {Record<string,string>} */ const binaryIncludes = {};
799
+ let crt0 = null;
673
800
  for (const f of files) {
674
801
  const n = f.name;
802
+ if (recipe.skip.has(n)) continue;
803
+ if (recipe.crt0File === n) { crt0 = await readFile(path.join(projPath, n), "utf-8"); continue; }
804
+ // includeAsC: a .c that's `#include`d by another TU (e.g. SNES main.c
805
+ // includes snes_sfx.c) — make it an include, NOT a separate source TU.
806
+ if (recipe.includeAsC.has(n)) { includes[n] = await readFile(path.join(projPath, n), "utf-8"); continue; }
675
807
  if (/\.(c|s|asm)$/i.test(n)) sources[n] = await readFile(path.join(projPath, n), "utf-8");
676
808
  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)) {
809
+ 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)) {
810
+ // Any binary asset an .incbin might reference. (.xgc = compiled XGM2 blob,
811
+ // .vgz/.esf/etc = music driver inputs.) Missing one = "file not found: X".
678
812
  binaryIncludes[n] = (await readFile(path.join(projPath, n))).toString("base64");
679
813
  }
680
814
  }
681
815
 
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);
816
+ // GBA runtime refinement: libgba if the entry includes <gba.h>, else the
817
+ // libtonc default the recipe set.
818
+ let runtime = recipe.runtime;
819
+ if (platform === "gba" && sources["main.c"] && /#\s*include\s*[<"]gba\.h[>"]/.test(sources["main.c"])) {
820
+ runtime = "libgba";
821
+ }
822
+
823
+ return { sources, includes, binaryIncludes, crt0, codeLoc: recipe.codeLoc, linkerConfig: recipe.linkerConfig, runtime, maxmod: recipe.maxmod };
824
+ }
825
+
826
+ export async function buildProjectCore({ path: projPath, platform, outputPath }) {
827
+ const { sources, includes, binaryIncludes, crt0, codeLoc, linkerConfig, runtime, maxmod } = await readProjectDir(projPath, platform);
828
+
829
+ // Linker preset: the recipe names it (e.g. NES 'chr-ram-runtime', which ships
830
+ // the OAM/CHARS segments + its own crt0). resolveLinkerConfig also returns any
831
+ // preset support sources (the preset crt0) — those merge into the sources.
832
+ const { cfg: resolvedLinkerConfig, supportSources } = await resolveLinkerConfig(platform, linkerConfig);
685
833
  const mergedSources = Object.keys(supportSources).length ? { ...supportSources, ...sources } : sources;
686
834
 
835
+ // Assemble a routed crt0 (SDCC sm83/z80) into a .rel, exactly like the
836
+ // output:'rom'/'run' path — passing it as `crt0` (NOT a source TU) is what
837
+ // avoids the gsinit double-definition on GB/GBC.
838
+ let crt0Rel;
839
+ if (crt0) {
840
+ const isSm83 = platform === "gb" || platform === "gbc";
841
+ const { runSdasgb, runSdasz80 } = await import("../../toolchains/sdcc/sdcc.js");
842
+ const asm = isSm83 ? await runSdasgb({ source: crt0 }) : await runSdasz80({ source: crt0 });
843
+ if (!asm.rel) throw new Error(`crt0 assembly failed:\n${asm.log}`);
844
+ crt0Rel = asm.rel;
845
+ }
846
+
687
847
  // Single-source toolchains (dasm/atari2600, vasm/asm) take `source`, not the
688
848
  // multi-TU `sources` map. When the dir has exactly one source file and no
689
849
  // preset support sources, pass it as `source` so those targets still build
690
850
  // (the original asm-only buildProject behavior).
691
851
  const srcNames = Object.keys(sources);
692
- const singleSource = srcNames.length === 1 && Object.keys(supportSources).length === 0;
852
+ const singleSource = srcNames.length === 1 && Object.keys(supportSources).length === 0 && !crt0Rel;
693
853
  const result = await buildForPlatform({
694
854
  platform,
855
+ runtime,
856
+ maxmod,
695
857
  ...(singleSource
696
858
  ? { source: sources[srcNames[0]], sourceName: srcNames[0] }
697
859
  : { sources: mergedSources }),
698
860
  includes: Object.keys(includes).length ? includes : undefined,
699
861
  binaryIncludes: Object.keys(binaryIncludes).length ? binaryIncludes : undefined,
700
862
  linkerConfig: resolvedLinkerConfig,
863
+ crt0: crt0Rel,
864
+ codeLoc,
701
865
  });
702
866
  if (outputPath && result.binary) {
703
867
  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