romdevtools 0.13.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 (124) hide show
  1. package/AGENTS.md +21 -14
  2. package/CHANGELOG.md +125 -1
  3. package/README.md +13 -8
  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/cheats/lookup.js +39 -18
  79. package/src/http/routes.js +58 -33
  80. package/src/http/skill-doc.js +10 -9
  81. package/src/http/swagger.js +1 -1
  82. package/src/http/tool-registry.js +72 -5
  83. package/src/mcp/server.js +6 -5
  84. package/src/mcp/state.js +8 -6
  85. package/src/mcp/tool-manifest.js +7 -7
  86. package/src/mcp/tools/cheats.js +4 -3
  87. package/src/mcp/tools/index.js +18 -2
  88. package/src/mcp/tools/playtest.js +48 -35
  89. package/src/mcp/tools/project.js +39 -73
  90. package/src/mcp/tools/rom-id.js +49 -4
  91. package/src/mcp/tools/tile-inspect.js +1 -1
  92. package/src/mcp/tools/toolchain.js +183 -19
  93. package/src/mcp/tools/trace-vram-source.js +3 -3
  94. package/src/mcp/tools/watch-memory.js +27 -46
  95. package/src/observer/livestream.html +41 -5
  96. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +5 -5
  97. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  98. package/src/platforms/gb/TROUBLESHOOTING.md +1 -1
  99. package/src/platforms/gb/UPSTREAM_SOURCES.md +1 -1
  100. package/src/platforms/gb/lib/c/README.md +2 -2
  101. package/src/platforms/gb/lib/c/SDCC_GOTCHAS.md +1 -1
  102. package/src/platforms/gbc/MENTAL_MODEL.md +3 -3
  103. package/src/platforms/gbc/TROUBLESHOOTING.md +5 -5
  104. package/src/platforms/gbc/UPSTREAM_SOURCES.md +2 -2
  105. package/src/platforms/gbc/lib/c/README.md +2 -2
  106. package/src/platforms/gbc/lib/c/SDCC_GOTCHAS.md +1 -1
  107. package/src/platforms/gg/MENTAL_MODEL.md +14 -13
  108. package/src/platforms/gg/lib/c/vdp_init.c +10 -8
  109. package/src/platforms/msx/MENTAL_MODEL.md +1 -1
  110. package/src/platforms/nes/TROUBLESHOOTING.md +1 -1
  111. package/src/platforms/nes/lib/c/nes_runtime.c +28 -6
  112. package/src/platforms/pce/MENTAL_MODEL.md +1 -1
  113. package/src/platforms/pce/lib/c/pce_hw.h +1 -0
  114. package/src/platforms/pce/lib/c/pce_video.c +26 -0
  115. package/src/platforms/sms/MENTAL_MODEL.md +12 -12
  116. package/src/platforms/sms/lib/c/vdp_init.c +10 -8
  117. package/src/platforms/sms/lib/vdp_init.s +1 -1
  118. package/src/playtest/playtest.js +25 -0
  119. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +1 -1
  120. package/src/toolchains/cc65/presets/nes/chr-ram.cfg +1 -1
  121. package/src/toolchains/cc65/presets/nes/chr-ram.crt0.s +1 -1
  122. package/src/toolchains/genesis-c/README.md +1 -1
  123. package/src/toolchains/sdcc/preflight-lint.js +47 -7
  124. 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 });
@@ -19,7 +19,7 @@ import { jsonContent } from "../util.js";
19
19
  import { decodeDMASource } from "../../platforms/genesis/vdp.js";
20
20
  import { makePressDriver } from "./watch-memory.js";
21
21
 
22
- // traceVramSource → dmaTrace({precision:'sampled'}) (router in watch-memory.js).
22
+ // traceVramSource → watch({on:'dma', precision:'sampled'}) (router in watch-memory.js).
23
23
  // Exported core; the router passes its own sessionKey.
24
24
  export async function traceVramSourceCore({ frames = 120, pressDuring, romPreviewBytes = 16, minLengthBytes = 0, sessionKey }) {
25
25
  const host = getHost(sessionKey);
@@ -76,6 +76,6 @@ export async function traceVramSourceCore({ frames = 120, pressDuring, romPrevie
76
76
  });
77
77
  }
78
78
 
79
- // traceVramSource is registered as dmaTrace({precision:'sampled'}) by the
80
- // `dmaTrace` router in watch-memory.js (which imports traceVramSourceCore).
79
+ // traceVramSource is reached via watch({on:'dma', precision:'sampled'}) by the
80
+ // `watch` tool in watch-memory.js (which imports traceVramSourceCore).
81
81
  export function registerTraceVramSourceTools() {}
@@ -881,10 +881,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
881
881
  "Extras: `ranges:[{region,offset,length,label}]` watches MANY disjoint regions in ONE pass (identical frames); `onChange:'reset'|'increase'|'decrease'|'any'` edge filter (reset = counter-reload = the note-onset signal); `valueFilter:{min,max}`; `format:'series'` = compact columnar value-vs-frame curve (~10× smaller for a ramp); `sampleEvery`; `groupByPC` (collapse by sampled PC); `cheatLabels` (auto-name addresses from the cheat DB); `outputPath` streams all events as NDJSON; `stopOnFirst` exits on the first match. " +
882
882
  "**CAVEAT: frame-level, not instruction-level (last value per frame); the sampled `pc` is a frame-boundary sample — for ISR-driven writes use breakpoint({on:'write', precision:'exact'}) for the real writer.**\n" +
883
883
  "• on:'range' — DISCOVERY: log EVERY instruction that reads or writes ANYWHERE in [start,end]. The fix for 'I don't know which PC touches this'. Returns {pc,address,value}[] + the actionable distinctPCs. (Ring-buffered: `truncated:true` if it overflows.)\n" +
884
- "• on:'pc' — DISCOVERY (coverage trace): record every DISTINCT PC executed within [start,end] — 'what code runs here?'. Log execution in the bank where you suspect the renderer lives during the moment it draws, then disassemble the PCs.",
884
+ "• on:'pc' — DISCOVERY (coverage trace): record every DISTINCT PC executed within [start,end] — 'what code runs here?'. Log execution in the bank where you suspect the renderer lives during the moment it draws, then disassemble the PCs.\n" +
885
+ "• on:'dma' — GENESIS ONLY: trace mem→VDP DMAs (the answer to 'this name/portrait/logo is a pre-rendered bitmap DMA'd into VRAM — WHERE in ROM?', which on:'write' can't catch). `precision:'exact'` (default) logs every mem→VDP DMA with its VRAM DESTINATION + ROM SOURCE + length (filter by `vramDest`±`destWindow`; `dedupe` collapses the per-frame refresh; `sourceFilter:'rom-only'` drops RAM→VRAM noise; catches a same-frame second DMA). `precision:'sampled'` is the cheap frame-sampled source-register read (may miss two DMAs in one frame, dest-agnostic). On non-Genesis cores returns `notSupported`.",
885
886
  {
886
- on: z.enum(["mem", "range", "pc"])
887
- .describe("mem=watch a RAM byte/ranges for value changes over frames (the power tool); range=log every read/write PC in [start,end]; pc=coverage trace of distinct PCs executed in [start,end]."),
887
+ on: z.enum(["mem", "range", "pc", "dma"])
888
+ .describe("mem=watch a RAM byte/ranges for value changes over frames (the power tool); range=log every read/write PC in [start,end]; pc=coverage trace of distinct PCs executed in [start,end]; dma=Genesis-only mem→VDP DMA source/dest trace."),
888
889
  // on:'mem'
889
890
  region: z.enum(MEMORY_REGIONS).optional().describe("on:'mem' single-range — the region to watch (same canonical set memory uses, incl. nes_apu_regs, genesis_ym2612, c64_sid_regs). Omit when using `ranges`."),
890
891
  offset: z.number().int().min(0).default(0).describe("on:'mem' single-range — first byte of the watched range."),
@@ -907,12 +908,20 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
907
908
  frames: z.number().int().min(1).max(1_000_000).default(600).describe("Frames to run while logging (default 600). on:'range'/'pc' windows are usually short (~120) — pass a smaller value to keep the ring buffer from overflowing."),
908
909
  limit: z.number().int().min(1).max(4000).default(200).describe("on:'range'/'pc' — max events/PCs returned (default 200; full count is in `total`)."),
909
910
  outputPath: z.string().optional().describe("on:'mem' — stream every filter-passing event to this path as NDJSON + return a compact summary. Use for long watches so the full log never enters your context."),
911
+ // on:'dma' (Genesis VDP DMA trace)
912
+ precision: z.enum(["exact", "sampled"]).default("exact").describe("on:'dma' — exact=per-DMA core log with VRAM dest + ROM source (catches same-frame DMAs); sampled=frame-sampled source-register read (cheaper, may miss two DMAs in one frame, dest-agnostic)."),
913
+ vramDest: z.number().int().min(0).optional().describe("on:'dma' precision:'exact' — keep only DMAs whose VRAM destination is within ±`destWindow` of this address."),
914
+ destWindow: z.number().int().min(0).default(0x40).describe("on:'dma' precision:'exact' — match window around vramDest (default 64 bytes ≈ 1 tile)."),
915
+ dedupe: z.boolean().default(true).describe("on:'dma' precision:'exact' — collapse identical DMAs (same dest+source+length+code) to one entry with an `occurrences` count (default on)."),
916
+ sourceFilter: z.enum(["all", "rom-only", "ram-only"]).default("all").describe("on:'dma' precision:'exact' — 'rom-only' drops the RAM→VRAM per-frame refresh noise; 'ram-only' keeps only it."),
917
+ romPreviewBytes: z.number().int().min(0).max(64).default(0).describe("on:'dma' — bytes of the ROM source to preview per DMA (exact default 0; sampled default 16)."),
918
+ minLengthBytes: z.number().int().min(0).max(65536).default(0).describe("on:'dma' precision:'sampled' — ignore DMAs shorter than this many bytes (filters tiny scroll/sprite updates so graphic uploads stand out)."),
910
919
  pressDuring: z.array(z.object({
911
920
  frame: z.number().int().min(0),
912
921
  button: z.string(),
913
922
  port: z.number().int().min(0).max(3).default(0),
914
923
  holdFrames: z.number().int().min(1).default(2),
915
- })).optional().describe("Schedule input while watching (drive the game to the state that touches the watched bytes/range)."),
924
+ })).optional().describe("Schedule input while watching (drive the game to the state that touches the watched bytes/range, or uploads the graphic for on:'dma')."),
916
925
  },
917
926
  safeTool(async (args) => {
918
927
  switch (args.on) {
@@ -925,19 +934,27 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
925
934
  if (args.start == null || args.end == null) throw new Error("watch({on:'pc'}): `start` and `end` are required.");
926
935
  return await wLogPC({ ...args, frames: args.frames ?? 120, limit: args.limit ?? 512 });
927
936
  }
937
+ case "dma": {
938
+ const a = { ...args, frames: args.frames ?? 120, limit: args.limit ?? 200 };
939
+ if (a.precision === "sampled") {
940
+ return await traceVramSourceCore({ ...a, romPreviewBytes: a.romPreviewBytes || 16, sessionKey });
941
+ }
942
+ return await dmaExact(a);
943
+ }
928
944
  default: throw new Error(`watch: unknown on '${args.on}'`);
929
945
  }
930
946
  }),
931
947
  );
932
948
 
933
- // ── dmaTrace (item 3, Genesis only) ─────────────────────────────────────────
949
+ // ── watch({on:'dma'}) helpers (Genesis only) ────────────────────────────────
934
950
  // precision:exact = dmaExact (watchDma, per-DMA core log), precision:sampled =
935
- // traceVramSourceCore (frame-sampled, dest-agnostic).
951
+ // traceVramSourceCore (frame-sampled, dest-agnostic). Routed by the `watch`
952
+ // tool's switch (case 'dma'); folded in from the old standalone dmaTrace tool.
936
953
  async function dmaExact({ frames = 120, vramDest, destWindow = 0x40, dedupe = true, sourceFilter = "all", pressDuring, romPreviewBytes = 0, limit = 200 }) {
937
954
  const host = getHost(sessionKey);
938
955
  if (!host.dmaWatchSupported || !host.dmaWatchSupported()) {
939
956
  return jsonContent({ notSupported: true, dmas: [],
940
- note: "dmaTrace is Genesis-only (VDP DMA). On other platforms use breakpoint({on:'write'}) (CPU writes) or the platform's source tracer." });
957
+ note: "watch({on:'dma'}) is Genesis-only (VDP DMA). On other platforms use breakpoint({on:'write'}) (CPU writes) or the platform's source tracer." });
941
958
  }
942
959
  const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
943
960
  const pressDriver = makePressDriver(host, presses);
@@ -994,43 +1011,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
994
1011
  (totalDistinct > limit ? `Showing ${out.length}/${totalDistinct} distinct — raise limit or narrow vramDest.` : ""),
995
1012
  }), host);
996
1013
  }
997
-
998
- server.tool(
999
- "dmaTrace",
1000
- "GENESIS ONLY — trace mem→VDP DMAs (the answer to 'this name/portrait/logo is a pre-rendered bitmap DMA'd into VRAM — " +
1001
- "WHERE in ROM?', which breakpoint({on:'write'}) can't catch). Keyed by `precision`.\n" +
1002
- "• precision:'exact' (default) — log every mem→VDP DMA with its VRAM DESTINATION, ROM SOURCE, length, and code. " +
1003
- "Filter by `vramDest` (±`destWindow`) to find the exact source of a specific tile. `dedupe` collapses the per-frame " +
1004
- "refresh (7000 events → a handful); `sourceFilter:'rom-only'` drops the RAM→VRAM sprite/scroll noise. " +
1005
- "**Catches a second DMA in the same frame that the sampled mode misses.**\n" +
1006
- "• precision:'sampled' — the cheap frame-sampled tracer: reads the VDP DMA source registers ($15-$17) once per frame " +
1007
- "and logs each DISTINCT mem→VRAM source as a ROM byte offset. **HONEST LIMIT: two DMAs in the SAME frame may show only " +
1008
- "one source — narrow the window around when the graphic appears. dest-agnostic (no vramDest filter).**",
1009
- {
1010
- precision: z.enum(["exact", "sampled"]).default("exact")
1011
- .describe("exact=per-DMA core log with VRAM dest + ROM source (catches same-frame DMAs); sampled=frame-sampled source-register read (cheaper, may miss two DMAs in one frame, dest-agnostic)."),
1012
- frames: z.number().int().min(1).max(6000).default(120).describe("Frames to step while tracing (default 120 ≈ 2s)."),
1013
- pressDuring: z.array(z.object({
1014
- frame: z.number().int().min(0),
1015
- button: z.string(),
1016
- port: z.number().int().min(0).max(3).default(0),
1017
- holdFrames: z.number().int().min(1).default(2),
1018
- })).optional().describe("Drive input to the screen that uploads the graphic."),
1019
- romPreviewBytes: z.number().int().min(0).max(64).default(0).describe("Bytes of the ROM source to preview per DMA (exact default 0; sampled default 16)."),
1020
- // precision:'exact' only
1021
- vramDest: z.number().int().min(0).optional().describe("precision:'exact' — keep only DMAs whose VRAM destination is within ±`destWindow` of this address."),
1022
- destWindow: z.number().int().min(0).default(0x40).describe("precision:'exact' — match window around vramDest (default 64 bytes ≈ 1 tile)."),
1023
- dedupe: z.boolean().default(true).describe("precision:'exact' — collapse identical DMAs (same dest+source+length+code) to one entry with an `occurrences` count (default on)."),
1024
- sourceFilter: z.enum(["all", "rom-only", "ram-only"]).default("all").describe("precision:'exact' — 'rom-only' drops the RAM→VRAM per-frame refresh noise; 'ram-only' keeps only it."),
1025
- limit: z.number().int().min(1).max(2000).default(200).describe("precision:'exact' — max DMA entries to return (after dedupe/filter)."),
1026
- // precision:'sampled' only
1027
- minLengthBytes: z.number().int().min(0).max(65536).default(0).describe("precision:'sampled' — ignore DMAs shorter than this many bytes (filters tiny scroll/sprite updates so graphic uploads stand out)."),
1028
- },
1029
- safeTool(async (args) => {
1030
- if (args.precision === "sampled") {
1031
- return await traceVramSourceCore({ ...args, romPreviewBytes: args.romPreviewBytes || 16, sessionKey });
1032
- }
1033
- return await dmaExact(args);
1034
- }),
1035
- );
1014
+ // dmaExact + traceVramSourceCore are reached via watch({on:'dma'}) above —
1015
+ // dmaTrace was folded into `watch` (it's a log-all VDP-DMA trace, same family
1016
+ // as on:'mem'/'range'/'pc'), so there's no separate top-level tool.
1036
1017
  }
@@ -20,9 +20,19 @@
20
20
  }
21
21
  header h1 { margin: 0; font-size: 14px; font-weight: 600; }
22
22
  #version { font-size: 11px; font-weight: 400; color: #888; }
23
- #status { font-size: 11px; color: #888; }
23
+ header a.doclink {
24
+ font-size: 11px; color: #6cb6ff; text-decoration: none;
25
+ border: 1px solid #2a3f55; border-radius: 3px; padding: 2px 7px;
26
+ }
27
+ header a.doclink:hover { background: #14202e; text-decoration: underline; }
28
+ #status { font-size: 11px; color: #888; margin-left: auto; }
24
29
  #status.connected { color: #4caf50; }
25
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
+ }
26
36
  #tabs {
27
37
  display: flex; flex-wrap: wrap; gap: 4px;
28
38
  padding: 6px 12px; background: #111; border-bottom: 1px solid #333;
@@ -172,6 +182,7 @@
172
182
  <body>
173
183
  <header>
174
184
  <h1>romdev /livestream <span id="version">__ROMDEV_VERSION__</span></h1>
185
+ <a class="doclink" href="/documentation" target="_blank" rel="noopener">API docs ↗</a>
175
186
  <span id="status" class="disconnected">disconnected</span>
176
187
  </header>
177
188
  <div id="tabs"></div>
@@ -183,6 +194,7 @@
183
194
  </div>
184
195
  <div id="log-pane">
185
196
  <h2>Activity log</h2>
197
+ <div id="log-session"></div>
186
198
  <div id="log-list"></div>
187
199
  </div>
188
200
  </div>
@@ -195,6 +207,7 @@
195
207
  const tabsEl = document.getElementById("tabs");
196
208
  const imageListEl = document.getElementById("image-list");
197
209
  const logListEl = document.getElementById("log-list");
210
+ const logSessionEl = document.getElementById("log-session");
198
211
  const emptyEl = document.getElementById("empty");
199
212
 
200
213
  // Per-session state.
@@ -345,10 +358,22 @@
345
358
  tab.className = "tab" + (k === activeSessionKey ? " active" : "")
346
359
  + (!s.connected ? " disconnected" : "");
347
360
  tab.onclick = () => selectTab(k);
348
- tab.innerHTML = `<span>${k.slice(0, 8)}</span>`
349
- + (s.unseen > 0 && k !== activeSessionKey
350
- ? ` <span class="badge">${s.unseen}</span>` : "");
351
- 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)`;
352
377
  tabsEl.appendChild(tab);
353
378
  }
354
379
  }
@@ -498,6 +523,17 @@
498
523
 
499
524
  function renderLog() {
500
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
+ }
501
537
  if (!activeSessionKey) return;
502
538
  const s = sessions.get(activeSessionKey);
503
539
  // Newest first — easier to scan when working live.
@@ -56,7 +56,7 @@ font-rendered from an ASCII string. Patching the ASCII string then does nothing.
56
56
  string. Do not patch any ASCII string you found; it isn't the source.
57
57
  2. If it IS font-rendered, find the string with `text({op:'find'})` /
58
58
  `text({op:'encode'})` and patch that.
59
- 3. To find where a graphic/text was sourced from: on **Genesis**, `dmaTrace({precision:'sampled'})`
59
+ 3. To find where a graphic/text was sourced from: on **Genesis**, `watch({on:'dma', precision:'sampled'})`
60
60
  — drive to the screen that shows the graphic, and it reports the ROM offset(s)
61
61
  the tiles were DMA'd from (decoded from the VDP DMA registers). Edit the tile
62
62
  bitmaps at that offset, not any string. (Elsewhere: if `breakpoint({on:'write'})` on the VRAM
@@ -198,8 +198,8 @@ Breakpoints are great once you KNOW the address. To FIND it:
198
198
  - **`watch({on:'pc', start, end, frames})`** — coverage trace: every DISTINCT PC that
199
199
  EXECUTED in an address window. "What code runs in this bank during the scoreboard
200
200
  draw?" → `disasm({target:'rom'})` the PCs it returns.
201
- - **`dmaTrace({precision:'exact', vramDest})`** (Genesis) — which DMA wrote the tile at a VRAM dest,
202
- and the ROM SOURCE it came from. The targeted version of `dmaTrace({precision:'sampled'})`; the
201
+ - **`watch({on:'dma', precision:'exact', vramDest})`** (Genesis) — which DMA wrote the tile at a VRAM dest,
202
+ and the ROM SOURCE it came from. The targeted version of `watch({on:'dma', precision:'sampled'})`; the
203
203
  way to catch a DMA'd (not CPU-written) name/portrait bitmap `breakpoint({on:'write'})` can't see.
204
204
 
205
205
  ---
@@ -238,8 +238,8 @@ transition.
238
238
  | Re-inject edited bytes the game accepts | `romPatch({op:'makeStored'})` (verbatim-expand block) → `romPatch({op:'findFree'})` → `romPatch({op:'relocate'})` |
239
239
  | Find the pointer that loads an asset | `romPatch({op:'findPointer', romOffset})` |
240
240
  | FIND the unknown routine touching X | `watch({on:'range', start,end})` (all hits) / `watch({on:'pc'})` (coverage) |
241
- | Which DMA wrote a VRAM tile + its source (Genesis) | `dmaTrace({precision:'exact', vramDest})` |
242
- | Where did a VRAM graphic come from (Genesis) | `dmaTrace({precision:'sampled'})` (ROM offset of the DMA source) |
241
+ | Which DMA wrote a VRAM tile + its source (Genesis) | `watch({on:'dma', precision:'exact', vramDest})` |
242
+ | Where did a VRAM graphic come from (Genesis) | `watch({on:'dma', precision:'sampled'})` (ROM offset of the DMA source) |
243
243
  | Drive a menu fast | `input({op:'navigate'})` (advances on screen change) |
244
244
  | Free RAM map for a known game | `cheats({op:'lookup'})` / `cheats({op:'search'})` |
245
245
  | Safe patch | `romPatch({op:'write'})`/`romPatch({op:'writeMany'})` with `expect` |
@@ -20,7 +20,7 @@ check these first. All five have shipped fixes in the bundled runtime
20
20
  classic white-screen: a stray $FF pad there trips CGB mode on a DMG
21
21
  ROM so BGP/OBP* writes are ignored. Because the build sets it from the
22
22
  platform you chose, a freshly built ROM is correct with **no manual
23
- step**. Call `patchGbHeader` only to fix up an existing/external ROM
23
+ step**. Call `romPatch({op:'gbHeader'})` only to fix up an existing/external ROM
24
24
  or override a field (title, cart type, ROM/RAM size, CGB flag).
25
25
 
26
26
  2. **OAM shadow buffer must be page-aligned.** OAM DMA copies 160 bytes
@@ -122,7 +122,7 @@ running a CGB-aware ROM, the DMG registers are ignored.
122
122
  You normally don't touch this byte by hand: `build({output:'rom'})` / `build({output:'run'})`
123
123
  set it from the platform you build for ($00 for `platform:"gb"`, $80/$C0
124
124
  for `platform:"gbc"`). To force a value, set it in your `gb_crt0.s`
125
- header section, or call `patchGbHeader({path, cgb:true})` on the built
125
+ header section, or call `romPatch({op:'gbHeader', path, cgb:true})` on the built
126
126
  ROM (it auto-detects the `.gbc` extension; the standalone
127
127
  `patch-header.js` script does the same).
128
128
 
@@ -201,7 +201,7 @@ Build calls explicitly reference these files via `sourcesPaths` /
201
201
  `includePaths` / `crt0Path` + `codeLoc: 0x150`. `build({output:'rom'})` /
202
202
  `build({output:'run'})` then fix up the cart header automatically (logo, checksums,
203
203
  CGB flag), so the ROM loads under gambatte with no extra step. Use
204
- `patchGbHeader({path})` (MCP tool) or `node patch-header.js <rom>` (CLI)
204
+ `romPatch({op:'gbHeader', path})` (romdev tool) or `node patch-header.js <rom>` (CLI)
205
205
  only on a ROM the build pipeline didn't produce. See your project's
206
206
  README for the exact incantation.
207
207
 
@@ -92,7 +92,7 @@ the cross-platform note: [[sdcc-uint8-loop-bound-trap]].
92
92
  `platform:"gb"` and it stays $00 (DMG). So if colors are wrong, first
93
93
  check you didn't build this as a `.gb` ROM — rebuild with
94
94
  `platform:"gbc"`. (To force a value on an existing ROM: set it in your
95
- `gb_crt0.s` header section, run `patchGbHeader({path:"out.gbc"})`, or
95
+ `gb_crt0.s` header section, run `romPatch({op:'gbHeader', path:"out.gbc"})`, or
96
96
  run `node patch-header.js out.gbc`.) Verify:
97
97
  ```sh
98
98
  xxd -s 0x143 -l 1 out.gbc # expect: 80
@@ -36,7 +36,7 @@ upstream README. Songs are exported from hUGETracker
36
36
  - "OAM DMA wedges sprites" → see `MENTAL_MODEL.md` § R26 footguns +
37
37
  `gb_runtime.c` `oam_dma_copy` implementation
38
38
  - "BGP write does nothing" → check $0143 (CGB flag) via
39
- `patchGbHeader` + Pan Docs § "The Cartridge Header"
39
+ `romPatch({op:'gbHeader'})` + Pan Docs § "The Cartridge Header"
40
40
  - "How does hUGEDriver process a song row?" → `hUGEDriver.c`
41
41
  `hUGE_dosound` body — fully readable
42
42
  - "Why is gambatte refusing my ROM?" → check the header, then
@@ -23,12 +23,12 @@ run rgbfix on the linked GB/GBC ROM — valid Nintendo logo at $0104,
23
23
  header checksum at $014D, global checksum at $014E, cartridge-type /
24
24
  RAM-size bytes, and the CGB flag at $0143 ($00 for `.gb`, $80/$C0 for
25
25
  `.gbc`). A freshly built ROM boots on hardware and strict cores with
26
- **no extra step** — you do not call `patchGbHeader` after a normal build.
26
+ **no extra step** — you do not call `romPatch({op:'gbHeader'})` after a normal build.
27
27
 
28
28
  Reach for header tooling only when working with a ROM the build pipeline
29
29
  didn't produce, or to override a field:
30
30
 
31
- - `patchGbHeader({path: "out.gb"})` — MCP tool.
31
+ - `romPatch({op:'gbHeader', path: "out.gb"})` — romdev tool.
32
32
  Fixes up / overrides the header of an existing ROM on disk (title, cart
33
33
  type, ROM/RAM size, CGB flag, etc.).
34
34
  - `node patch-header.js out.gb` — standalone Node script, copied into
@@ -110,7 +110,7 @@ If you need to write a custom VRAM block-copy:
110
110
 
111
111
  This is independent of the R26 OAM-alignment fix (`shadow_oam __at
112
112
  (0xC100)`) and the header CGB-flag fix (now applied automatically by
113
- `build({output:'rom'})` / `build({output:'run'})`, not a manual `patchGbHeader` step). All
113
+ `build({output:'rom'})` / `build({output:'run'})`, not a manual `romPatch({op:'gbHeader'})` step). All
114
114
  three are silent-failure bugs that look like "did my changes even
115
115
  land?" and need different fixes.
116
116