romdevtools 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +17 -10
- package/CHANGELOG.md +63 -0
- 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/mcp/tools/project.js +33 -22
- package/src/mcp/tools/toolchain.js +183 -19
- package/src/observer/livestream.html +34 -4
- 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/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 });
|
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
#status { font-size: 11px; color: #888; margin-left: auto; }
|
|
29
29
|
#status.connected { color: #4caf50; }
|
|
30
30
|
#status.disconnected { color: #f44336; }
|
|
31
|
+
#log-session {
|
|
32
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
33
|
+
font-size: 12px; color: #6cb6ff; padding: 2px 0 8px;
|
|
34
|
+
word-break: break-all;
|
|
35
|
+
}
|
|
31
36
|
#tabs {
|
|
32
37
|
display: flex; flex-wrap: wrap; gap: 4px;
|
|
33
38
|
padding: 6px 12px; background: #111; border-bottom: 1px solid #333;
|
|
@@ -189,6 +194,7 @@
|
|
|
189
194
|
</div>
|
|
190
195
|
<div id="log-pane">
|
|
191
196
|
<h2>Activity log</h2>
|
|
197
|
+
<div id="log-session"></div>
|
|
192
198
|
<div id="log-list"></div>
|
|
193
199
|
</div>
|
|
194
200
|
</div>
|
|
@@ -201,6 +207,7 @@
|
|
|
201
207
|
const tabsEl = document.getElementById("tabs");
|
|
202
208
|
const imageListEl = document.getElementById("image-list");
|
|
203
209
|
const logListEl = document.getElementById("log-list");
|
|
210
|
+
const logSessionEl = document.getElementById("log-session");
|
|
204
211
|
const emptyEl = document.getElementById("empty");
|
|
205
212
|
|
|
206
213
|
// Per-session state.
|
|
@@ -351,10 +358,22 @@
|
|
|
351
358
|
tab.className = "tab" + (k === activeSessionKey ? " active" : "")
|
|
352
359
|
+ (!s.connected ? " disconnected" : "");
|
|
353
360
|
tab.onclick = () => selectTab(k);
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
361
|
+
// Show the FULL session id — agents pick descriptive ids (e.g.
|
|
362
|
+
// "scaffold-nes-platformer") and truncating to 8 chars collapsed distinct
|
|
363
|
+
// tasks into identical-looking tabs ("scaffold" × N). Cap very long ids in
|
|
364
|
+
// the middle so the meaningful head+tail both show; full id in the tooltip.
|
|
365
|
+
const label = k.length > 28 ? k.slice(0, 18) + "…" + k.slice(-8) : k;
|
|
366
|
+
const labelSpan = document.createElement("span");
|
|
367
|
+
labelSpan.textContent = label;
|
|
368
|
+
tab.appendChild(labelSpan);
|
|
369
|
+
if (s.unseen > 0 && k !== activeSessionKey) {
|
|
370
|
+
const badge = document.createElement("span");
|
|
371
|
+
badge.className = "badge";
|
|
372
|
+
badge.textContent = String(s.unseen);
|
|
373
|
+
tab.appendChild(document.createTextNode(" "));
|
|
374
|
+
tab.appendChild(badge);
|
|
375
|
+
}
|
|
376
|
+
tab.title = s.connected ? k : `${k} (session closed)`;
|
|
358
377
|
tabsEl.appendChild(tab);
|
|
359
378
|
}
|
|
360
379
|
}
|
|
@@ -504,6 +523,17 @@
|
|
|
504
523
|
|
|
505
524
|
function renderLog() {
|
|
506
525
|
logListEl.innerHTML = "";
|
|
526
|
+
// Full session id above the log — the tab label can be middle-truncated, so
|
|
527
|
+
// this is the one place that always shows the EXACT id you're looking at.
|
|
528
|
+
if (logSessionEl) {
|
|
529
|
+
if (activeSessionKey) {
|
|
530
|
+
logSessionEl.textContent = "session: " + activeSessionKey;
|
|
531
|
+
logSessionEl.style.display = "";
|
|
532
|
+
} else {
|
|
533
|
+
logSessionEl.textContent = "";
|
|
534
|
+
logSessionEl.style.display = "none";
|
|
535
|
+
}
|
|
536
|
+
}
|
|
507
537
|
if (!activeSessionKey) return;
|
|
508
538
|
const s = sessions.get(activeSessionKey);
|
|
509
539
|
// Newest first — easier to scan when working live.
|
|
@@ -73,19 +73,20 @@ check the live OAM Y bytes for $D0 in a slot before them. That's
|
|
|
73
73
|
still the diagnosis; the runtime just doesn't create the problem
|
|
74
74
|
on its own anymore.
|
|
75
75
|
|
|
76
|
-
### R6 sprite-tile-base default
|
|
77
|
-
|
|
78
|
-
`gg_vdp_init()` sets R6 =
|
|
79
|
-
sprite tile data —
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
76
|
+
### R6 sprite-tile-base: default is $2000 (0xFF)
|
|
77
|
+
|
|
78
|
+
`gg_vdp_init()` sets R6 = 0xFF. R6 bit 2 is the SA13 select for
|
|
79
|
+
sprite tile data — bit 2 is **SET** in 0xFF, so sprite tiles read
|
|
80
|
+
from `$2000-$3FFF`, in their **own bank** separate from BG tiles at
|
|
81
|
+
$0000. This is the baseline because every bundled scaffold uploads
|
|
82
|
+
its sprite tiles to `$2000` (`gg_load_tiles(0x2000, …)`) — the
|
|
83
|
+
default and the scaffolds match, so sprites Just Show Up.
|
|
84
|
+
|
|
85
|
+
Watch the bit: 0xFB has SA13 **CLEAR** = sprite tiles at $0000
|
|
86
|
+
(sharing the BG bank). If you ever set R6=0xFB you MUST also upload
|
|
87
|
+
your sprite tiles to $0000, or the VDP reads the empty/BG bank and
|
|
88
|
+
every sprite is invisible — the classic GG/SMS "my sprites don't
|
|
89
|
+
show up" trap.
|
|
89
90
|
|
|
90
91
|
The `sprites({op:'inspect'})` tool's `spriteTileDataBase` field reports the
|
|
91
92
|
address the VDP is actually reading from — trust that over any
|
|
@@ -3,15 +3,17 @@
|
|
|
3
3
|
* Writes the 11 mode-4 registers to a sane baseline:
|
|
4
4
|
* display OFF, vblank IRQ off, 192-line mode 4, name table at $3800,
|
|
5
5
|
* BG tile data at $0000, sprite attr table at $3F00, sprite tile data
|
|
6
|
-
* at $
|
|
6
|
+
* at $2000 (R6=0xFF → SA13 set → tiles read from $2000-$3FFF). Call
|
|
7
7
|
* once after reset before uploading palette/tiles/map.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* sprite tiles
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* Sprite-tile base: R6 bit 2 (SA13) selects $0000 (clear) vs $2000 (set).
|
|
10
|
+
* We default to R6=0xFF ($2000) because EVERY bundled scaffold uploads its
|
|
11
|
+
* sprite tiles to $2000 (gg_load_tiles(0x2000, ...)) — so the baseline must
|
|
12
|
+
* match what consumers do, or sprites read from the empty/BG bank and render
|
|
13
|
+
* invisible. (Many SMS/GG references say "R6=0xFB → $2000", which is backwards:
|
|
14
|
+
* 0xFB has SA13 CLEAR = $0000.) If you instead keep sprite tiles in the BG
|
|
15
|
+
* bank at $0000, set R6=0xFB. sprites({op:'inspect'}) → spriteTileDataBase
|
|
16
|
+
* shows the address the VDP is actually reading from.
|
|
15
17
|
*
|
|
16
18
|
* After loading assets, enable display by re-writing R1 with bit 6 set:
|
|
17
19
|
* gg_vdp_display_on();
|
|
@@ -33,7 +35,7 @@ void gg_vdp_init(void) {
|
|
|
33
35
|
0xFF, /* R3: color table (ignored in M4) */
|
|
34
36
|
0xFF, /* R4: BG tile data at $0000 */
|
|
35
37
|
0xFF, /* R5: sprite attr table at $3F00 */
|
|
36
|
-
|
|
38
|
+
0xFF, /* R6: sprite tile data at $2000 (SA13 set; scaffolds upload here) */
|
|
37
39
|
0x00, /* R7: border = sprite palette entry 0 */
|
|
38
40
|
0x00, /* R8: BG X scroll */
|
|
39
41
|
0x00, /* R9: BG Y scroll */
|
|
@@ -106,7 +106,7 @@ exactly this.
|
|
|
106
106
|
- `palette({source:'live'})` — V9938 9-bit GRB (or TMS9918 fixed) 16 entries.
|
|
107
107
|
- `sprites({op:'inspect'})` — VRAM sprite-attribute table, up to 32 sprites.
|
|
108
108
|
- `symbols({op:'map', map})` — pass the sdld `.map` (the `symbols` field from
|
|
109
|
-
|
|
109
|
+
build({output:'romWithDebug'})) to see where SDCC placed your variables/code, grouped by
|
|
110
110
|
region (bios / cart_rom / work_ram).
|
|
111
111
|
- `audioDebug({op:'inspect', chip: "ay8910"})` — the AY-3-8910 PSG: 3 square-wave
|
|
112
112
|
channels (tone period→Hz, amplitude, tone/noise enable) + a shared noise
|
|
@@ -140,7 +140,7 @@ Overflow it and there's no error — your globals quietly collide with
|
|
|
140
140
|
the stack or shadow OAM → corrupted state, sprites that flicker to
|
|
141
141
|
garbage, random crashes.
|
|
142
142
|
|
|
143
|
-
**Check the `ramUsage` field in the
|
|
143
|
+
**Check the `ramUsage` field in the build response** —
|
|
144
144
|
it lists your BSS / DATA / ZEROPAGE segment sizes from the linker map.
|
|
145
145
|
If BSS+DATA is approaching the config's RAM region, shrink your state:
|
|
146
146
|
prefer `uint8_t` over `int`, bit-pack flags, use small fixed arrays,
|
|
@@ -56,6 +56,7 @@ volatile uint8_t nmi_counter = 0;
|
|
|
56
56
|
* (so OAM segment placement at $0200 is linker-enforced). oam_index
|
|
57
57
|
* tracks the next free slot for oam_spr(). */
|
|
58
58
|
static uint8_t oam_index = 0;
|
|
59
|
+
static void oam_hide_unused(void); /* fwd decl — used by ppu_wait_nmi (NES-1) */
|
|
59
60
|
|
|
60
61
|
/* ── VRAM write queue ─────────────────────────────────────────────
|
|
61
62
|
* Each entry is { hi, lo, byte }. NMI walks the queue, writes
|
|
@@ -130,7 +131,12 @@ void ppu_wait_vblank(void) {
|
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
void ppu_wait_nmi(void) {
|
|
133
|
-
uint8_t target
|
|
134
|
+
uint8_t target;
|
|
135
|
+
/* Hide last frame's now-unused sprite slots BEFORE waiting, so the buffer
|
|
136
|
+
* the NMI's OAM-DMA copies is fully staged (live slots written by oam_spr,
|
|
137
|
+
* stale slots parked off-screen) — never a half-cleared buffer (NES-1). */
|
|
138
|
+
oam_hide_unused();
|
|
139
|
+
target = (uint8_t)(nmi_counter + 1);
|
|
134
140
|
while (nmi_counter != target) { /* spin */ }
|
|
135
141
|
}
|
|
136
142
|
|
|
@@ -159,15 +165,31 @@ void palette_load(const uint8_t *pal32) {
|
|
|
159
165
|
|
|
160
166
|
/* ── OAM ──────────────────────────────────────────────────────── */
|
|
161
167
|
|
|
168
|
+
/* High-water mark: the largest oam_index reached last frame. Lets us blank
|
|
169
|
+
* ONLY the slots a frame stopped using, instead of the whole 256-byte buffer
|
|
170
|
+
* every frame. */
|
|
171
|
+
static uint8_t oam_high = 0;
|
|
172
|
+
|
|
162
173
|
void oam_clear(void) {
|
|
174
|
+
/* NES-1 FIX: do NOT blank the whole shadow buffer here. The old full clear
|
|
175
|
+
* wrote slot 0's Y=$FF FIRST and took ~hundreds of cycles; if the NMI's
|
|
176
|
+
* OAM-DMA fired mid-clear it copied a HALF-CLEARED buffer → the live sprite
|
|
177
|
+
* vanished every other frame (the classic "sprite flickers to black"
|
|
178
|
+
* sprite-light scaffold bug). Instead we just reset the staging index here;
|
|
179
|
+
* ppu_wait_nmi() hides the slots this frame stopped using, so the DMA only
|
|
180
|
+
* ever sees a fully-staged buffer. */
|
|
181
|
+
oam_index = 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* Hide slots [oam_index .. oam_high] (the ones used last frame but not this
|
|
185
|
+
* frame) by parking their Y off-screen. Called from ppu_wait_nmi AFTER the
|
|
186
|
+
* game has staged its live sprites, so live slots are never blanked. */
|
|
187
|
+
static void oam_hide_unused(void) {
|
|
163
188
|
uint16_t i;
|
|
164
|
-
for (i =
|
|
189
|
+
for (i = oam_index; i < (uint16_t)oam_high + 4 && i < 256; i += 4) {
|
|
165
190
|
shadow_oam[i] = 0xFF; /* Y off-screen */
|
|
166
|
-
shadow_oam[i + 1] = 0; /* tile */
|
|
167
|
-
shadow_oam[i + 2] = 0; /* attr */
|
|
168
|
-
shadow_oam[i + 3] = 0; /* X */
|
|
169
191
|
}
|
|
170
|
-
|
|
192
|
+
oam_high = oam_index;
|
|
171
193
|
}
|
|
172
194
|
|
|
173
195
|
void oam_spr(uint8_t x, uint8_t y, uint8_t tile, uint8_t attr) {
|
|
@@ -90,7 +90,7 @@ screen. Keep at least one (2+ byte) global. See TROUBLESHOOTING.md.
|
|
|
90
90
|
- `background({view:'renderState'})` — VDC R5 screen-enable, BG scroll, SATB source.
|
|
91
91
|
- `palette({source:'live'})` — VCE 512-entry 9-bit GRB (area:'bg'|'sprite').
|
|
92
92
|
- `sprites({op:'inspect'})` — SATB 64 sprites (x/y/tile/palette/size/flip).
|
|
93
|
-
- `symbols({op:'map'})` — where cc65 placed your variables (after
|
|
93
|
+
- `symbols({op:'map'})` — where cc65 placed your variables (after build({output:'romWithDebug'})).
|
|
94
94
|
- `audioDebug({op:'inspect', chip: "pce"})` — the HuC6280 PSG: 6 wavetable channels
|
|
95
95
|
(per-channel freq/volume/wave; channels 4-5 can also do noise) + main amplitude
|
|
96
96
|
+ LFO.
|
|
@@ -96,6 +96,7 @@ void vdc_set_reg(u8 reg, u16 val); /* select reg, write 16-bit valu
|
|
|
96
96
|
void vram_set_write_addr(u16 addr); /* point MAWR + arm VWR streaming */
|
|
97
97
|
void vram_write(u16 addr, const u16 *data, u16 n); /* upload n words to VRAM[addr] */
|
|
98
98
|
void vce_set_color(u16 idx, u16 grb); /* set VCE palette entry (0..511) */
|
|
99
|
+
void vdc_init(void); /* program VDC display timing (256x224 NTSC); auto-run by *_enable */
|
|
99
100
|
void bg_enable(void); /* VDC R5: background on + VBlank IRQ (so waitvsync works) */
|
|
100
101
|
void spr_enable(void); /* VDC R5: sprites on + VBlank IRQ */
|
|
101
102
|
void disp_enable(void); /* VDC R5: BG + SPR + VBlank IRQ on at once */
|
|
@@ -70,17 +70,43 @@ void vblank_irq_enable(void) {
|
|
|
70
70
|
vdc_set_reg(VDC_CR, _pce_cr);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/* Program the VDC display-timing registers for a standard NTSC 256x224 (H32)
|
|
74
|
+
* screen. WITHOUT this the geargrafx core falls back to power-on register
|
|
75
|
+
* defaults that composite the 32-row BAT into the display DOUBLED (the scene
|
|
76
|
+
* drawn twice, top + bottom halves, with a black right margin) — the PCE-1
|
|
77
|
+
* "doubled picture" bug. Values match cc65's pce.lib / standard PCE homebrew:
|
|
78
|
+
* MWR R9 = 32x32 virtual screen, 256px-wide BAT
|
|
79
|
+
* HSR R10 / HDR R11 = 256px (32 char) horizontal display
|
|
80
|
+
* VPR R12 / VDW R13 / VCR R14 = 224-line vertical window
|
|
81
|
+
* Called automatically the first time the display is enabled (idempotent). */
|
|
82
|
+
static u8 _pce_vdc_inited = 0;
|
|
83
|
+
void vdc_init(void) {
|
|
84
|
+
if (_pce_vdc_inited) return;
|
|
85
|
+
_pce_vdc_inited = 1;
|
|
86
|
+
vdc_set_reg(VDC_MWR, 0x0010); /* 32x32 virtual map, 256px BAT */
|
|
87
|
+
vdc_set_reg(VDC_BXR, 0x0000); /* BG X scroll = 0 */
|
|
88
|
+
vdc_set_reg(VDC_BYR, 0x0000); /* BG Y scroll = 0 */
|
|
89
|
+
vdc_set_reg(VDC_HSR, 0x0202); /* horizontal sync width/start */
|
|
90
|
+
vdc_set_reg(VDC_HDR, 0x031F); /* horizontal display = 32 chars (256px) */
|
|
91
|
+
vdc_set_reg(VDC_VPR, 0x0F02); /* vertical sync */
|
|
92
|
+
vdc_set_reg(VDC_VDW, 0x00DF); /* vertical display = 224 lines */
|
|
93
|
+
vdc_set_reg(VDC_VCR, 0x00EE); /* vertical display end */
|
|
94
|
+
}
|
|
95
|
+
|
|
73
96
|
void bg_enable(void) {
|
|
97
|
+
vdc_init();
|
|
74
98
|
_pce_cr |= (VDC_CR_BG_ON | VDC_CR_VBLANK_IRQ);
|
|
75
99
|
vdc_set_reg(VDC_CR, _pce_cr);
|
|
76
100
|
}
|
|
77
101
|
|
|
78
102
|
void spr_enable(void) {
|
|
103
|
+
vdc_init();
|
|
79
104
|
_pce_cr |= (VDC_CR_SPR_ON | VDC_CR_VBLANK_IRQ);
|
|
80
105
|
vdc_set_reg(VDC_CR, _pce_cr);
|
|
81
106
|
}
|
|
82
107
|
|
|
83
108
|
void disp_enable(void) {
|
|
109
|
+
vdc_init();
|
|
84
110
|
_pce_cr |= (VDC_CR_BG_ON | VDC_CR_SPR_ON | VDC_CR_VBLANK_IRQ);
|
|
85
111
|
vdc_set_reg(VDC_CR, _pce_cr);
|
|
86
112
|
}
|
|
@@ -78,7 +78,7 @@ R1 = 0x80 display OFF, vblank IRQ off, 192-line
|
|
|
78
78
|
R2 = 0xFF name table at $3800
|
|
79
79
|
R4 = 0xFF BG tile data at $0000
|
|
80
80
|
R5 = 0xFF sprite attr table at $3F00
|
|
81
|
-
R6 =
|
|
81
|
+
R6 = 0xFF sprite tile data at $2000 (own bank; scaffolds upload here)
|
|
82
82
|
R7 = 0x00 border colour
|
|
83
83
|
```
|
|
84
84
|
|
|
@@ -142,19 +142,19 @@ buffer in WRAM and uploads it to the SAT each vblank.
|
|
|
142
142
|
`sprites({op:'inspect'})` shows the live OAM bytes + reports
|
|
143
143
|
`spriteTileDataBase` — trust it over comments when sprites misbehave.
|
|
144
144
|
|
|
145
|
-
### R6 sprite-tile-base default
|
|
145
|
+
### R6 sprite-tile-base: default is $2000 (0xFF)
|
|
146
146
|
|
|
147
|
-
`sms_vdp_init()` sets R6 =
|
|
148
|
-
sprite tile data —
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
147
|
+
`sms_vdp_init()` sets R6 = 0xFF. R6 bit 2 is the SA13 select for
|
|
148
|
+
sprite tile data — bit 2 is **SET** in 0xFF, so sprite tiles read
|
|
149
|
+
from `$2000-$3FFF`, their **own bank** separate from BG tiles at
|
|
150
|
+
$0000. This matches every bundled scaffold, which uploads sprite
|
|
151
|
+
tiles to `$2000` (`sms_load_tiles(0x2000, …)`) — default and
|
|
152
|
+
scaffolds agree, so sprites render.
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
Watch the bit: 0xFB has SA13 **CLEAR** = sprite tiles at $0000
|
|
155
|
+
(shared with the BG bank). If you set R6=0xFB you MUST upload your
|
|
156
|
+
sprite tiles to $0000 too, or the VDP reads the empty/BG bank and
|
|
157
|
+
every sprite is invisible.
|
|
158
158
|
|
|
159
159
|
## Palette (CRAM)
|
|
160
160
|
|