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.
- package/AGENTS.md +21 -14
- package/CHANGELOG.md +125 -1
- package/README.md +13 -8
- package/examples/atari2600/main.asm +1 -1
- package/examples/atari2600/templates/default.asm +1 -1
- package/examples/atari2600/templates/paddle.asm +59 -47
- package/examples/atari7800/main.c +1 -1
- package/examples/atari7800/templates/default.c +1 -1
- package/examples/atari7800/templates/music_demo.c +1 -1
- package/examples/c64/main.c +1 -1
- package/examples/c64/templates/platformer.c +2 -2
- package/examples/c64/templates/puzzle.c +1 -1
- package/examples/c64/templates/racing.c +3 -3
- package/examples/c64/templates/shmup.c +6 -5
- package/examples/c64/templates/sports.c +4 -4
- package/examples/gb/main.asm +1 -1
- package/examples/gb/main.c +1 -1
- package/examples/gb/templates/puzzle.c +1 -1
- package/examples/gb/templates/racing.c +1 -1
- package/examples/gb/templates/shmup.c +1 -1
- package/examples/gba/templates/gba_hello.c +1 -1
- package/examples/gba/templates/maxmod_demo.c +1 -1
- package/examples/gba/templates/puzzle.c +17 -3
- package/examples/gba/templates/racing.c +16 -2
- package/examples/gba/templates/shmup.c +23 -4
- package/examples/gba/templates/tonc_hello.c +6 -4
- package/examples/gbc/main.asm +1 -1
- package/examples/gbc/templates/puzzle.c +1 -1
- package/examples/gbc/templates/racing.c +1 -1
- package/examples/gbc/templates/shmup.c +1 -1
- package/examples/genesis/main.s +1 -1
- package/examples/genesis/templates/puzzle.c +1 -1
- package/examples/genesis/templates/racing.c +45 -1
- package/examples/genesis/templates/shmup.c +12 -3
- package/examples/genesis/templates/shmup_2p.c +2 -2
- package/examples/genesis/templates/sports.c +39 -0
- package/examples/gg/templates/hello_sprite.c +38 -23
- package/examples/gg/templates/music_demo.c +11 -8
- package/examples/gg/templates/platformer.c +37 -15
- package/examples/gg/templates/racing.c +25 -12
- package/examples/gg/templates/shmup.c +12 -6
- package/examples/gg/templates/sports.c +30 -16
- package/examples/gg/templates/tile_engine.c +24 -10
- package/examples/lynx/templates/platformer.c +7 -1
- package/examples/lynx/templates/puzzle.c +8 -2
- package/examples/lynx/templates/racing.c +7 -1
- package/examples/lynx/templates/sports.c +7 -1
- package/examples/nes/main.c +2 -2
- package/examples/nes/space-shooter/nes_runtime.h +1 -1
- package/examples/nes/templates/default.c +4 -1
- package/examples/nes/templates/racing.c +50 -1
- package/examples/pce/main.c +1 -1
- package/examples/sms/templates/hello_sprite.c +1 -1
- package/examples/sms/templates/music_demo.c +1 -1
- package/examples/sms/templates/puzzle.c +1 -1
- package/examples/sms/templates/racing.c +1 -1
- package/examples/sms/templates/shmup.c +1 -1
- package/examples/sms/templates/shmup_2p.c +2 -2
- package/examples/snes/main.asm +1 -1
- package/examples/snes/templates/c-hello-data.asm +309 -14
- package/examples/snes/templates/c-hello.c +13 -2
- package/examples/snes/templates/default.c +1 -1
- package/examples/snes/templates/hello_sprite-data.asm +300 -2
- package/examples/snes/templates/hello_sprite.c +10 -1
- package/examples/snes/templates/music_demo-data.asm +300 -2
- package/examples/snes/templates/music_demo.c +10 -1
- package/examples/snes/templates/platformer-data.asm +300 -2
- package/examples/snes/templates/platformer.c +10 -1
- package/examples/snes/templates/puzzle-data.asm +300 -2
- package/examples/snes/templates/puzzle.c +11 -1
- package/examples/snes/templates/racing-data.asm +300 -2
- package/examples/snes/templates/racing.c +40 -4
- package/examples/snes/templates/shmup-data.asm +299 -6
- package/examples/snes/templates/shmup.c +11 -7
- package/examples/snes/templates/sports-data.asm +300 -2
- package/examples/snes/templates/sports.c +40 -5
- package/package.json +1 -1
- package/src/cheats/lookup.js +39 -18
- package/src/http/routes.js +58 -33
- package/src/http/skill-doc.js +10 -9
- package/src/http/swagger.js +1 -1
- package/src/http/tool-registry.js +72 -5
- package/src/mcp/server.js +6 -5
- package/src/mcp/state.js +8 -6
- package/src/mcp/tool-manifest.js +7 -7
- package/src/mcp/tools/cheats.js +4 -3
- package/src/mcp/tools/index.js +18 -2
- package/src/mcp/tools/playtest.js +48 -35
- package/src/mcp/tools/project.js +39 -73
- package/src/mcp/tools/rom-id.js +49 -4
- package/src/mcp/tools/tile-inspect.js +1 -1
- package/src/mcp/tools/toolchain.js +183 -19
- package/src/mcp/tools/trace-vram-source.js +3 -3
- package/src/mcp/tools/watch-memory.js +27 -46
- package/src/observer/livestream.html +41 -5
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +5 -5
- package/src/platforms/gb/MENTAL_MODEL.md +3 -3
- package/src/platforms/gb/TROUBLESHOOTING.md +1 -1
- package/src/platforms/gb/UPSTREAM_SOURCES.md +1 -1
- package/src/platforms/gb/lib/c/README.md +2 -2
- package/src/platforms/gb/lib/c/SDCC_GOTCHAS.md +1 -1
- package/src/platforms/gbc/MENTAL_MODEL.md +3 -3
- package/src/platforms/gbc/TROUBLESHOOTING.md +5 -5
- package/src/platforms/gbc/UPSTREAM_SOURCES.md +2 -2
- package/src/platforms/gbc/lib/c/README.md +2 -2
- package/src/platforms/gbc/lib/c/SDCC_GOTCHAS.md +1 -1
- package/src/platforms/gg/MENTAL_MODEL.md +14 -13
- package/src/platforms/gg/lib/c/vdp_init.c +10 -8
- package/src/platforms/msx/MENTAL_MODEL.md +1 -1
- package/src/platforms/nes/TROUBLESHOOTING.md +1 -1
- package/src/platforms/nes/lib/c/nes_runtime.c +28 -6
- package/src/platforms/pce/MENTAL_MODEL.md +1 -1
- package/src/platforms/pce/lib/c/pce_hw.h +1 -0
- package/src/platforms/pce/lib/c/pce_video.c +26 -0
- package/src/platforms/sms/MENTAL_MODEL.md +12 -12
- package/src/platforms/sms/lib/c/vdp_init.c +10 -8
- package/src/platforms/sms/lib/vdp_init.s +1 -1
- package/src/playtest/playtest.js +25 -0
- package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +1 -1
- package/src/toolchains/cc65/presets/nes/chr-ram.cfg +1 -1
- package/src/toolchains/cc65/presets/nes/chr-ram.crt0.s +1 -1
- package/src/toolchains/genesis-c/README.md +1 -1
- package/src/toolchains/sdcc/preflight-lint.js +47 -7
- 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("
|
|
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("
|
|
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
|
-
"`
|
|
525
|
-
"
|
|
526
|
-
"
|
|
527
|
-
"
|
|
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
|
-
|
|
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
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
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
|
-
//
|
|
683
|
-
//
|
|
684
|
-
|
|
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 →
|
|
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
|
|
80
|
-
// `
|
|
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
|
-
// ──
|
|
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: "
|
|
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
|
-
|
|
999
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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**, `
|
|
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
|
-
- **`
|
|
202
|
-
and the ROM SOURCE it came from. The targeted version of `
|
|
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) | `
|
|
242
|
-
| Where did a VRAM graphic come from (Genesis) | `
|
|
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 `
|
|
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 `
|
|
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
|
-
`
|
|
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 `
|
|
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
|
-
`
|
|
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 `
|
|
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
|
-
- `
|
|
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 `
|
|
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
|
|