romdevtools 0.16.0 → 0.21.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 +60 -12
- package/CHANGELOG.md +258 -0
- package/examples/README.md +2 -0
- package/examples/atari2600/templates/platformer.asm +460 -0
- package/examples/atari2600/templates/racing.asm +463 -0
- package/examples/atari2600/templates/shmup.asm +386 -0
- package/examples/atari2600/templates/sports.asm +362 -0
- package/examples/atari7800/templates/default.c +49 -5
- package/examples/atari7800/templates/platformer.c +43 -4
- package/examples/atari7800/templates/puzzle.c +39 -4
- package/examples/atari7800/templates/racing.c +39 -4
- package/examples/atari7800/templates/shmup.c +40 -2
- package/examples/atari7800/templates/sports.c +36 -5
- package/examples/c64/templates/platformer.c +19 -5
- package/examples/c64/templates/puzzle.c +32 -2
- package/examples/c64/templates/shmup.c +28 -2
- package/examples/c64/templates/sports.c +30 -2
- package/examples/gb/templates/default.c +110 -16
- package/examples/gb/templates/platformer.c +25 -4
- package/examples/gb/templates/puzzle.c +32 -2
- package/examples/gb/templates/racing.c +72 -8
- package/examples/gb/templates/shmup.c +38 -1
- package/examples/gb/templates/sports.c +48 -1
- package/examples/gba/templates/gba_hello.c +29 -11
- package/examples/gba/templates/puzzle.c +15 -3
- package/examples/gba/templates/racing.c +65 -3
- package/examples/gba/templates/shmup.c +41 -4
- package/examples/gba/templates/sports.c +36 -2
- package/examples/gba/templates/tonc_hello.c +41 -5
- package/examples/gbc/templates/default.c +103 -26
- package/examples/gbc/templates/platformer.c +25 -4
- package/examples/gbc/templates/puzzle.c +32 -2
- package/examples/gbc/templates/racing.c +85 -19
- package/examples/gbc/templates/shmup.c +34 -1
- package/examples/gbc/templates/sports.c +45 -1
- package/examples/genesis/templates/puzzle.c +37 -3
- package/examples/genesis/templates/racing.c +44 -11
- package/examples/genesis/templates/sgdk_hello.c +34 -1
- package/examples/genesis/templates/shmup.c +31 -1
- package/examples/gg/templates/default.c +56 -18
- package/examples/gg/templates/platformer.c +18 -12
- package/examples/gg/templates/puzzle.c +38 -7
- package/examples/gg/templates/racing.c +51 -5
- package/examples/gg/templates/shmup.c +47 -3
- package/examples/gg/templates/sports.c +46 -3
- package/examples/lynx/templates/default.c +39 -8
- package/examples/lynx/templates/puzzle.c +28 -1
- package/examples/lynx/templates/racing.c +34 -7
- package/examples/lynx/templates/shmup.c +42 -3
- package/examples/lynx/templates/sports.c +29 -2
- package/examples/msx/platformer/main.c +213 -0
- package/examples/msx/puzzle/main.c +250 -0
- package/examples/msx/racing/main.c +249 -0
- package/examples/msx/shmup/main.c +288 -0
- package/examples/msx/sports/main.c +182 -0
- package/examples/nes/templates/default.c +67 -19
- package/examples/nes/templates/platformer.c +65 -6
- package/examples/nes/templates/puzzle.c +67 -6
- package/examples/nes/templates/racing.c +45 -13
- package/examples/nes/templates/shmup.c +51 -2
- package/examples/nes/templates/sports.c +51 -6
- package/examples/pce/platformer/main.c +283 -0
- package/examples/pce/puzzle/main.c +304 -0
- package/examples/pce/racing/main.c +304 -0
- package/examples/pce/shmup/main.c +346 -0
- package/examples/pce/sports/main.c +254 -0
- package/examples/sms/main.c +35 -6
- package/examples/sms/templates/puzzle.c +34 -5
- package/examples/sms/templates/racing.c +39 -2
- package/examples/sms/templates/shmup.c +41 -2
- package/examples/sms/templates/sports.c +43 -2
- package/examples/snes/templates/default.c +50 -28
- package/examples/snes/templates/platformer-data.asm +22 -0
- package/examples/snes/templates/platformer.c +16 -1
- package/examples/snes/templates/puzzle-data.asm +22 -0
- package/examples/snes/templates/puzzle.c +17 -1
- package/examples/snes/templates/racing-data.asm +22 -0
- package/examples/snes/templates/racing.c +17 -1
- package/examples/snes/templates/shmup-data.asm +22 -0
- package/examples/snes/templates/shmup.c +20 -1
- package/examples/snes/templates/sports-data.asm +22 -0
- package/examples/snes/templates/sports.c +16 -1
- package/package.json +1 -1
- package/src/cores/wasm/vice_x64_libretro.js +1 -1
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +122 -1
- package/src/host/callbacks.js +9 -1
- package/src/host/types.js +15 -8
- package/src/http/tool-registry.js +26 -1
- package/src/mcp/tools/cart-parts.js +75 -3
- package/src/mcp/tools/disasm-rebuild.js +507 -0
- package/src/mcp/tools/disasm.js +95 -6
- package/src/mcp/tools/frame.js +168 -3
- package/src/mcp/tools/lifecycle.js +4 -2
- package/src/mcp/tools/project.js +54 -9
- package/src/mcp/tools/state.js +201 -14
- package/src/mcp/tools/toolchain.js +76 -3
- package/src/mcp/tools/watch-memory.js +125 -14
- package/src/platforms/c64/MENTAL_MODEL.md +45 -1
- package/src/platforms/c64/d64.js +281 -0
- package/src/platforms/gb/MENTAL_MODEL.md +10 -0
- package/src/platforms/msx/MENTAL_MODEL.md +10 -6
- package/src/platforms/nes/MENTAL_MODEL.md +63 -2
- package/src/platforms/pce/MENTAL_MODEL.md +9 -4
- package/src/platforms/pce/lib/c/pce_video.c +1 -1
- package/src/rom-id/identifier.js +15 -0
- package/src/toolchains/cc65/ines.js +145 -0
- package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
- package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
- package/src/toolchains/common/reassemble.js +10 -2
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { TOOLCHAINS } from "../../toolchains/registry.js";
|
|
5
5
|
import { buildForPlatform } from "../../toolchains/index.js";
|
|
6
6
|
import { resolveLinkerConfig } from "../../toolchains/cc65/preset-resolver.js";
|
|
7
|
+
import { inesHeaderSource, charsSource, nromFlatCfg } from "../../toolchains/cc65/ines.js";
|
|
7
8
|
import { resolveCore } from "../../cores/registry.js";
|
|
8
9
|
import { resetHost, getDisclosure } from "../state.js";
|
|
9
10
|
import { PLATFORM_VIRTUAL_EXT } from "../../host/LibretroHost.js";
|
|
@@ -25,6 +26,47 @@ function logBuildResult(verb, platform, result) {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Apply the `inesHeader` NES NROM-rebuild convenience: synthesize the iNES HEADER
|
|
31
|
+
* segment (+ CHARS segment that .incbins the CHR blob when chrBanks>0) into the
|
|
32
|
+
* sources map and set a flat NROM linker .cfg. The agent supplies only the PRG
|
|
33
|
+
* disassembly + the CHR blob — no glue .s/.cfg, no hand-derived header bytes.
|
|
34
|
+
* Shared by build({output:'rom'|'run'}). See toolchains/cc65/ines.js.
|
|
35
|
+
*
|
|
36
|
+
* @param {{platform:string, inesHeader:any, sources:Record<string,string>|null|undefined, source:string|null|undefined, linkerConfig:string|undefined, mergedBinaryIncludes:Record<string,string>}} a
|
|
37
|
+
* @returns {{sources:Record<string,string>, source:null, linkerConfig:string}}
|
|
38
|
+
*/
|
|
39
|
+
function applyInesHeader({ platform, inesHeader, sources, source, linkerConfig, mergedBinaryIncludes }) {
|
|
40
|
+
if (platform !== "nes") {
|
|
41
|
+
throw new Error(`inesHeader is NES-only (iNES is the NES cartridge format); platform was '${platform}'.`);
|
|
42
|
+
}
|
|
43
|
+
if (linkerConfig) {
|
|
44
|
+
throw new Error("Pass either `inesHeader` (auto-generates the NROM .cfg) OR `linkerConfig`, not both.");
|
|
45
|
+
}
|
|
46
|
+
const chr = inesHeader.chrBanks ?? 0;
|
|
47
|
+
// A rebuild is always multi-source (PRG disassembly + synthesized header), so
|
|
48
|
+
// normalize a lone `source` into the sources map.
|
|
49
|
+
const out = sources == null
|
|
50
|
+
? (source != null ? { "main.s": source } : {})
|
|
51
|
+
: { ...sources };
|
|
52
|
+
if (out["ines_header.s"] == null) out["ines_header.s"] = inesHeaderSource(inesHeader);
|
|
53
|
+
if (chr > 0) {
|
|
54
|
+
const binNames = Object.keys(mergedBinaryIncludes);
|
|
55
|
+
const chrName = inesHeader.chrIncbin ?? (binNames.length === 1 ? binNames[0] : undefined);
|
|
56
|
+
if (!chrName) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`inesHeader.chrBanks=${chr} needs the CHR-ROM blob: pass it via binaryIncludePaths ` +
|
|
59
|
+
`(e.g. {"chr.bin":"/path/chr.bin"}). With more than one binary include, set inesHeader.chrIncbin to its name.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (mergedBinaryIncludes[chrName] == null) {
|
|
63
|
+
throw new Error(`inesHeader.chrIncbin '${chrName}' is not among the binary includes (${binNames.join(", ") || "none"}).`);
|
|
64
|
+
}
|
|
65
|
+
if (out["ines_chars.s"] == null) out["ines_chars.s"] = charsSource(chrName);
|
|
66
|
+
}
|
|
67
|
+
return { sources: out, source: null, linkerConfig: nromFlatCfg(inesHeader) };
|
|
68
|
+
}
|
|
69
|
+
|
|
28
70
|
// One-shot "open playtest" hint state — per MCP session, set after the
|
|
29
71
|
// hint has been delivered once so we don't keep nagging legitimate
|
|
30
72
|
// headless flows (CI, automated tests, batch RE work). Keyed by the
|
|
@@ -205,7 +247,7 @@ export function installToolchainCore({ id }) {
|
|
|
205
247
|
}
|
|
206
248
|
|
|
207
249
|
export function registerToolchainTools(server, z, sessionKey) {
|
|
208
|
-
async function buildSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, crt0, crt0Path, codeLoc, dataLoc, options, linkerConfig, outputPath, inline = false, includeSymbols = false, lint = "advisory", runtime, maxmod, rebuildSdk }) {
|
|
250
|
+
async function buildSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, crt0, crt0Path, codeLoc, dataLoc, options, linkerConfig, inesHeader, outputPath, inline = false, includeSymbols = false, lint = "advisory", runtime, maxmod, rebuildSdk }) {
|
|
209
251
|
// Reject conflicting inline vs path args — fail loud, not silent.
|
|
210
252
|
if (source != null && sourcePath != null) {
|
|
211
253
|
throw new Error("build({output:'rom'}): pass either `source` OR `sourcePath`, not both.");
|
|
@@ -252,6 +294,12 @@ export function registerToolchainTools(server, z, sessionKey) {
|
|
|
252
294
|
mergedBinaryIncludes[name] = bytes.toString("base64");
|
|
253
295
|
}
|
|
254
296
|
}
|
|
297
|
+
// inesHeader — NES NROM rebuild convenience (see applyInesHeader).
|
|
298
|
+
if (inesHeader) {
|
|
299
|
+
({ sources, source, linkerConfig } = applyInesHeader({
|
|
300
|
+
platform, inesHeader, sources, source, linkerConfig, mergedBinaryIncludes,
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
255
303
|
const { cfg: resolvedLinkerConfig, supportSources } = await resolveLinkerConfig(platform, linkerConfig);
|
|
256
304
|
// Splice preset support sources (e.g. custom crt0) into the project.
|
|
257
305
|
// User sources take precedence — never overwrite a source the agent
|
|
@@ -397,7 +445,7 @@ export function registerToolchainTools(server, z, sessionKey) {
|
|
|
397
445
|
return jsonContent(payload);
|
|
398
446
|
}
|
|
399
447
|
|
|
400
|
-
async function runSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, runtime, maxmod, rebuildSdk, crt0, crt0Path, codeLoc, dataLoc, linkerConfig, path: projPath, frames = 60, holdInputs, screenshotPath, projectName }) {
|
|
448
|
+
async function runSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, runtime, maxmod, rebuildSdk, crt0, crt0Path, codeLoc, dataLoc, linkerConfig, inesHeader, path: projPath, frames = 60, holdInputs, screenshotPath, projectName }) {
|
|
401
449
|
const { buildForPlatform } = await import("../../toolchains/index.js");
|
|
402
450
|
const resolved = resolveCore(platform);
|
|
403
451
|
if (!resolved) throw new Error(`no core available for platform '${platform}'`);
|
|
@@ -470,6 +518,12 @@ export function registerToolchainTools(server, z, sessionKey) {
|
|
|
470
518
|
mergedBinaryIncludes[name] = bytes.toString("base64");
|
|
471
519
|
}
|
|
472
520
|
}
|
|
521
|
+
// inesHeader — NES NROM rebuild convenience (see applyInesHeader).
|
|
522
|
+
if (inesHeader) {
|
|
523
|
+
({ sources, source, linkerConfig } = applyInesHeader({
|
|
524
|
+
platform, inesHeader, sources, source, linkerConfig, mergedBinaryIncludes,
|
|
525
|
+
}));
|
|
526
|
+
}
|
|
473
527
|
const { cfg: resolvedLinkerConfig2, supportSources: supportSources2 } = await resolveLinkerConfig(platform, linkerConfig);
|
|
474
528
|
const mergedSources2 = sources
|
|
475
529
|
? { ...supportSources2, ...sources }
|
|
@@ -639,7 +693,15 @@ export function registerToolchainTools(server, z, sessionKey) {
|
|
|
639
693
|
codeLoc: z.coerce.number().int().optional().describe("SDCC — _CODE load address (default $0000; GB/GBC bundled crt0 wants 0x150)."),
|
|
640
694
|
dataLoc: z.coerce.number().int().optional().describe("SDCC — _DATA (WRAM) load address (default $C000 on Z80). NOT read by output:'romWithDebug'."),
|
|
641
695
|
options: z.array(z.string()).optional().describe("output:'rom' — extra toolchain CLI options."),
|
|
642
|
-
linkerConfig: z.string().optional().describe("ld65 linker config (cc65). NES
|
|
696
|
+
linkerConfig: z.string().optional().describe("ld65 linker config (cc65). NES presets: 'chr-ram-runtime' (RECOMMENDED for homebrew C — full crt0 + iNES header + NMI w/ OAM DMA + `_shadow_oam` at $0200), 'chr-ram' (bare nmi:rti stub), 'chr-rom' (cc65-C with FIXED CHR-ROM art — segment split + CHARS segment; supply CHR via binaryIncludePaths into a CHARS source + the header via `inesHeader`). Or full .cfg contents. Preset NAMES only resolve on output:'rom'/'run'; output:'romWithDebug' takes raw .cfg contents only. **For rebuilding a commercial NROM game from its disassembly, prefer `inesHeader` over a raw .cfg.**"),
|
|
697
|
+
inesHeader: z.object({
|
|
698
|
+
prgBanks: z.coerce.number().int().min(1).max(255).describe("16KB PRG-ROM banks (1 = NROM-128, 2 = NROM-256)."),
|
|
699
|
+
chrBanks: z.coerce.number().int().min(0).max(255).optional().describe("8KB CHR-ROM banks (0 = CHR-RAM, no CHARS segment). Default 0."),
|
|
700
|
+
mapper: z.coerce.number().int().min(0).max(255).optional().describe("iNES mapper number. Default 0 (NROM)."),
|
|
701
|
+
mirroring: z.enum(["horizontal", "vertical"]).optional().describe("Nametable mirroring. Default 'horizontal'."),
|
|
702
|
+
battery: z.boolean().optional().describe("PRG-RAM battery (flags6 bit 1). Default false."),
|
|
703
|
+
chrIncbin: z.string().optional().describe("Name of the binaryInclude holding the CHR-ROM blob to .incbin (only needed when chrBanks>0 AND there's more than one binary include; else the sole include is used)."),
|
|
704
|
+
}).optional().describe("NES iNES-header + NROM-rebuild convenience. Auto-emits the 16-byte iNES HEADER segment + (for chrBanks>0) a CHARS segment that .incbins the CHR blob (from binaryIncludePaths), and sets a flat NROM linker .cfg (HEADER+PRG+CHARS). The agent supplies ONLY the PRG disassembly source(s) + the CHR blob — no glue .s/.cfg files, no hand-derived header bytes. THE shape for rebuilding an NROM commercial game from `disasm({target:'project'})`. Mutually exclusive with `linkerConfig`."),
|
|
643
705
|
runtime: z.string().optional().describe("GBA — runtime: 'libtonc' (default), 'libgba', or 'none'."),
|
|
644
706
|
maxmod: z.boolean().optional().describe("GBA — link maxmod for music (libmm.a). You still call mmInit/mmStart + hook mmVBlank."),
|
|
645
707
|
rebuildSdk: z.boolean().optional().describe("GBA + Genesis — rebuild the bundled SDK (libtonc/libgba/maxmod/SGDK) from vendored source instead of the prebuilt seed (~20-40s). Only if you edited SDK source (else an `sdkEditIgnored` warning fires)."),
|
|
@@ -729,6 +791,17 @@ export function projectBuildRecipe(platform, names) {
|
|
|
729
791
|
if (/crt0.*\.s$/i.test(n) || /\.cfg$/i.test(n)) r.skip.add(n);
|
|
730
792
|
}
|
|
731
793
|
}
|
|
794
|
+
} else if (platform === "msx") {
|
|
795
|
+
// MSX ships msx_crt0.s — it MUST be passed AS the crt0 (replacing the stock
|
|
796
|
+
// SDCC z80 crt0.rel), NOT compiled as a plain source. The stock crt0 is a
|
|
797
|
+
// CP/M-style $0000 runtime with no MSX cartridge header; if it links, IT
|
|
798
|
+
// provides the $4010 entry (an SDCC gsinit stub = `nop nop nop ret`) and our
|
|
799
|
+
// msx_crt0.s _HEADER ("AB" + INIT pointer) gets dropped. The toolchain then
|
|
800
|
+
// synthesizes a header pointing INIT at $4010 = that stub, so the BIOS CALLs
|
|
801
|
+
// a no-op that returns immediately → "No cartridge found" (proven: real
|
|
802
|
+
// commercial ROMs boot in the same host; only our scaffolds failed). Routing
|
|
803
|
+
// msx_crt0.s through crt0 makes ITS header + init the cartridge entry.
|
|
804
|
+
if (has("msx_crt0.s")) { r.crt0File = "msx_crt0.s"; r.codeLoc = 0x4010; }
|
|
732
805
|
} else if (platform === "sms" || platform === "gg") {
|
|
733
806
|
// SMS/GG auto-inject their bundled crt0 inside buildForPlatform — so the
|
|
734
807
|
// scaffold's own *_crt0.s would be a DUPLICATE. Skip it.
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// touching this byte and what does the screen look like after," not a complete
|
|
13
13
|
// CPU trace. Instruction-level tracing would need core-side breakpoint hooks.
|
|
14
14
|
|
|
15
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
15
|
+
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { getHost } from "../state.js";
|
|
18
18
|
import { jsonContent, safeTool } from "../util.js";
|
|
@@ -21,6 +21,30 @@ import { MemoryRegionToRetro } from "../../host/types.js";
|
|
|
21
21
|
import { resolveButtonAlias } from "./input.js";
|
|
22
22
|
import { getCPUStateCore } from "./platform-tools.js";
|
|
23
23
|
import { traceVramSourceCore } from "./trace-vram-source.js";
|
|
24
|
+
import { resolveStatePath } from "./state.js";
|
|
25
|
+
|
|
26
|
+
// Restore a savestate (in-memory slot `fromState` OR disk file `fromStatePath`)
|
|
27
|
+
// before a trace, so on:'range'/'pc' run from a known moment. Returns a small
|
|
28
|
+
// {slot|path} descriptor for the response, or null if neither was given.
|
|
29
|
+
// Throws a clear error if both are given or the restore fails.
|
|
30
|
+
async function maybeRestoreState(host, fromState, fromStatePath) {
|
|
31
|
+
if (fromState && fromStatePath) {
|
|
32
|
+
throw new Error("watch: provide `fromState` (slot) OR `fromStatePath` (file), not both.");
|
|
33
|
+
}
|
|
34
|
+
if (fromStatePath) {
|
|
35
|
+
const resolved = resolveStatePath(fromStatePath, host);
|
|
36
|
+
let blob;
|
|
37
|
+
try { blob = new Uint8Array(await readFile(resolved)); }
|
|
38
|
+
catch (e) { throw new Error(`watch: can't read fromStatePath '${resolved}': ${e.message}`); }
|
|
39
|
+
host.unserializeState(blob);
|
|
40
|
+
return { path: resolved };
|
|
41
|
+
}
|
|
42
|
+
if (fromState) {
|
|
43
|
+
host.loadState(fromState); // throws if the slot doesn't exist
|
|
44
|
+
return { slot: fromState };
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
24
48
|
|
|
25
49
|
// Let a human watching /livestream (or a playtest window) SEE what a
|
|
26
50
|
// breakpoint/watch tool just did — the frozen breakpoint frame, the state when a
|
|
@@ -32,7 +56,7 @@ import { traceVramSourceCore } from "./trace-vram-source.js";
|
|
|
32
56
|
// observer wrapper encodes it ASYNCHRONOUSLY, after the agent's response has
|
|
33
57
|
// already gone out. The provider is stripped from the agent-visible result. The
|
|
34
58
|
// frame is captured by reference now (correct frozen state) but rasterized later.
|
|
35
|
-
function attachObserverFrame(json, host) {
|
|
59
|
+
export function attachObserverFrame(json, host) {
|
|
36
60
|
json._observerFrameProvider = () => {
|
|
37
61
|
try {
|
|
38
62
|
const shot = host.screenshot(); // { pngBase64, width, height }
|
|
@@ -104,6 +128,61 @@ export function makePressDriver(host, presses) {
|
|
|
104
128
|
// never disagree again.
|
|
105
129
|
const MEMORY_REGIONS = /** @type {[string, ...string[]]} */ (Object.keys(MemoryRegionToRetro));
|
|
106
130
|
|
|
131
|
+
// Abort-guard for input-driven watchpoint runs: sample caller-named bytes each
|
|
132
|
+
// frame; the FIRST one to change stops the run with {label,addr,before,after}.
|
|
133
|
+
// Lets a derailed driven scenario (player died, scene flipped) return immediately
|
|
134
|
+
// with WHY, instead of burning all maxFrames on a meaningless miss.
|
|
135
|
+
function makeAbortGuard(host, abortIf) {
|
|
136
|
+
const specs = Array.isArray(abortIf) ? abortIf : [];
|
|
137
|
+
const watched = specs.map((s, i) => {
|
|
138
|
+
const region = s.region ?? "system_ram";
|
|
139
|
+
const offset = s.offset ?? 0;
|
|
140
|
+
let before;
|
|
141
|
+
try { before = host.readMemory(region, offset, 1)[0]; } catch { before = null; }
|
|
142
|
+
const addr = "$" + (offset >>> 0).toString(16).toUpperCase();
|
|
143
|
+
return { region, offset, addr, label: s.label ?? `${region}${addr}`, before };
|
|
144
|
+
}).filter((w) => w.before != null);
|
|
145
|
+
return {
|
|
146
|
+
count: watched.length,
|
|
147
|
+
check() {
|
|
148
|
+
for (const w of watched) {
|
|
149
|
+
let now;
|
|
150
|
+
try { now = host.readMemory(w.region, w.offset, 1)[0]; } catch { continue; }
|
|
151
|
+
if (now !== w.before) {
|
|
152
|
+
return {
|
|
153
|
+
label: w.label, addr: w.addr,
|
|
154
|
+
before: "0x" + w.before.toString(16).padStart(2, "0").toUpperCase(),
|
|
155
|
+
after: "0x" + now.toString(16).padStart(2, "0").toUpperCase(),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// No-hit note for bpFindWriter. The full "two reasons" explainer (~100 tokens)
|
|
165
|
+
// is useful ONCE; as a repeated payload it's pure overhead (v0.15.0 feedback
|
|
166
|
+
// #2b). Emit the long form only on the first miss per MCP session, a one-liner
|
|
167
|
+
// after.
|
|
168
|
+
const _bpNoHitSeen = new Set();
|
|
169
|
+
function noHitNote(sessionKey) {
|
|
170
|
+
const short = "No per-byte CPU write to that address within maxFrames. Either the event didn't fire " +
|
|
171
|
+
"(raise maxFrames / drive it with pressDuring; add abortIf to stop early if the scenario derails), " +
|
|
172
|
+
"OR the region is rebuilt as a BLOCK (OAM/display-list/VRAM bulk-copy or DMA) so no single instruction " +
|
|
173
|
+
"writes it — watch the SOURCE struct the copy reads from instead.";
|
|
174
|
+
if (_bpNoHitSeen.has(sessionKey)) return short;
|
|
175
|
+
_bpNoHitSeen.add(sessionKey);
|
|
176
|
+
return "No per-byte CPU write to that address within maxFrames. Two common reasons: " +
|
|
177
|
+
"(1) the event didn't fire — increase maxFrames or drive the game with pressDuring to trigger it " +
|
|
178
|
+
"(and pass `abortIf` to abort early + say why if a driven run derails, e.g. the player dies). " +
|
|
179
|
+
"(2) this region is rebuilt as a BLOCK rather than written field-by-field — sprite/OAM shadow tables, " +
|
|
180
|
+
"display lists, and VRAM are typically bulk-copied (memcpy/loop) or DMA'd from a SOURCE struct elsewhere, " +
|
|
181
|
+
"so no single instruction writes this exact byte. In that case the address you want is the SOURCE: watch " +
|
|
182
|
+
"the struct the copy reads from (find it with searchValue on the live value), or for graphics trace the " +
|
|
183
|
+
"DMA/copy source (Genesis VRAM DMA source is in VDP regs). 'Address is wrong' is usually case (2), not a bad address.";
|
|
184
|
+
}
|
|
185
|
+
|
|
107
186
|
function tryGetPC(host) {
|
|
108
187
|
try {
|
|
109
188
|
const platform = host.status?.platform;
|
|
@@ -420,7 +499,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
420
499
|
|
|
421
500
|
// breakpoint({on:write|read|pc}) STOP-on-first. on:write precision:exact=bpFindWriter
|
|
422
501
|
// (core watchpoint, true PC under IRQ), precision:sampled=bpRunUntilWrite (frame PC).
|
|
423
|
-
async function bpFindWriter({ address, maxFrames = 600, pressDuring }) {
|
|
502
|
+
async function bpFindWriter({ address, maxFrames = 600, pressDuring, abortIf }) {
|
|
424
503
|
const host = getHost(sessionKey);
|
|
425
504
|
if (!host.watchpointSupported || !host.watchpointSupported()) {
|
|
426
505
|
return jsonContent({
|
|
@@ -432,26 +511,44 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
432
511
|
host.setWatchpoint(address, true);
|
|
433
512
|
const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
|
|
434
513
|
const pressDriver = makePressDriver(host, presses);
|
|
514
|
+
// Abort-guard: sample caller-named "still valid?" bytes each frame; if any
|
|
515
|
+
// changes, a driven run that DERAILED (player died → title screen, scene
|
|
516
|
+
// flipped, …) stops immediately instead of burning all maxFrames and
|
|
517
|
+
// returning a meaningless found:false. (v0.15.0 feedback #2.)
|
|
518
|
+
const guard = makeAbortGuard(host, abortIf);
|
|
435
519
|
let result = null;
|
|
520
|
+
let aborted = null;
|
|
436
521
|
for (let i = 0; i < maxFrames; i++) {
|
|
437
522
|
pressDriver.applyForFrame(i);
|
|
438
523
|
host.stepFrames(1);
|
|
439
524
|
const w = host.getWatchpoint();
|
|
440
525
|
if (w.hits > 0) { result = { ...w, framesStepped: i + 1 }; break; }
|
|
526
|
+
const ab = guard.check();
|
|
527
|
+
if (ab) { aborted = { ...ab, framesStepped: i + 1 }; break; }
|
|
441
528
|
}
|
|
442
529
|
pressDriver.finish();
|
|
443
530
|
host.setWatchpoint(address, false); // disarm
|
|
531
|
+
if (aborted) {
|
|
532
|
+
return jsonContent({
|
|
533
|
+
found: false, aborted: true, abortedBy: aborted.label,
|
|
534
|
+
abortAddress: aborted.addr, before: aborted.before, after: aborted.after,
|
|
535
|
+
framesStepped: aborted.framesStepped,
|
|
536
|
+
...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
|
|
537
|
+
note: `Run aborted early: the watched abort byte ${aborted.label} (${aborted.addr}) changed ` +
|
|
538
|
+
`${aborted.before}→${aborted.after} at frame ${aborted.framesStepped}, so the driven scenario left the ` +
|
|
539
|
+
`expected state (e.g. player died / scene changed) before the write fired. The found:false is NOT a real ` +
|
|
540
|
+
`miss — fix the input plan or pick a different start state, then re-run.`,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
444
543
|
if (!result) {
|
|
445
544
|
return jsonContent({
|
|
446
545
|
found: false, address: "$" + address.toString(16).toUpperCase(), framesStepped: maxFrames,
|
|
447
546
|
...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
"the struct the copy reads from (find it with searchValue on the live value), or for graphics trace the " +
|
|
454
|
-
"DMA/copy source (Genesis VRAM DMA source is in VDP regs). 'Address is wrong' is usually case (2), not a bad address.",
|
|
547
|
+
...(abortIf && abortIf.length ? { abortIfArmed: guard.count } : {}),
|
|
548
|
+
// One-line hint by default; the full "two reasons" explainer is verbose
|
|
549
|
+
// boilerplate as a repeated payload (v0.15.0 feedback #2b) — gated to the
|
|
550
|
+
// FIRST miss per session.
|
|
551
|
+
note: noHitNote(sessionKey),
|
|
455
552
|
});
|
|
456
553
|
}
|
|
457
554
|
// When the core reports a PRG-ROM offset for the PC (fceumm/NES), it
|
|
@@ -655,6 +752,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
655
752
|
port: z.number().int().min(0).max(3).default(0),
|
|
656
753
|
holdFrames: z.number().int().min(1).default(2),
|
|
657
754
|
})).optional().describe("Schedule input while waiting (drive the game to the state that triggers the condition)."),
|
|
755
|
+
abortIf: z.array(z.object({
|
|
756
|
+
region: z.enum(MEMORY_REGIONS).optional().describe("memory region (default system_ram)"),
|
|
757
|
+
offset: z.number().int().min(0).describe("byte offset within the region"),
|
|
758
|
+
label: z.string().optional().describe("human name for this guard byte"),
|
|
759
|
+
})).optional().describe("on:'write' exact — ABORT GUARD for a pressDuring run: caller-named 'is this scenario still valid?' bytes (e.g. the area/scene id, the player object-active flag). If ANY changes mid-run the watchpoint stops IMMEDIATELY and returns {aborted:true, abortedBy, before, after} — so a driven scenario that derailed (player died → title screen) doesn't burn all maxFrames and return a meaningless found:false. Each is sampled once per frame (cheap)."),
|
|
658
760
|
},
|
|
659
761
|
safeTool(async (args) => {
|
|
660
762
|
switch (args.on) {
|
|
@@ -821,13 +923,17 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
821
923
|
|
|
822
924
|
// ── Range watch + coverage trace (item 2, discovery) ────────────────────────
|
|
823
925
|
|
|
824
|
-
async function wRange({ start, end, kind = "both", frames = 120, pressDuring, limit = 200 }) {
|
|
926
|
+
async function wRange({ start, end, kind = "both", frames = 120, pressDuring, limit = 200, fromState, fromStatePath }) {
|
|
825
927
|
const host = getHost(sessionKey);
|
|
826
928
|
if (!host.rangeWatchSupported || !host.rangeWatchSupported()) {
|
|
827
929
|
return jsonContent({ notSupported: true, events: [],
|
|
828
930
|
note: "This core build has no range watch (shipped on all 14 platforms as of 0.6.0 — update the core package). Use breakpoint({on:'write'/'read'}) for a single address." });
|
|
829
931
|
}
|
|
830
932
|
if (end < start) throw new Error("watch({on:'range'}): end must be >= start.");
|
|
933
|
+
// Optionally restore a savestate FIRST, so the trace runs from a known
|
|
934
|
+
// moment (the deterministic "jump to the boss fight, then see what writes
|
|
935
|
+
// HP" loop) instead of from wherever the live session happens to be.
|
|
936
|
+
const stateInfo = await maybeRestoreState(host, fromState, fromStatePath);
|
|
831
937
|
// pressDuring is driven inside the frame loop; watchRange's host method owns
|
|
832
938
|
// stepping, so for now apply presses up front if any (simple: hold for the run).
|
|
833
939
|
const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
|
|
@@ -846,19 +952,21 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
846
952
|
return attachObserverFrame(jsonContent({
|
|
847
953
|
range: "$" + start.toString(16).toUpperCase() + "..$" + end.toString(16).toUpperCase(),
|
|
848
954
|
kind, total: r.total, returned: events.length, truncated: r.truncated,
|
|
955
|
+
...(stateInfo ? { restoredFrom: stateInfo } : {}),
|
|
849
956
|
distinctPCs, events,
|
|
850
957
|
note: "distinctPCs is the actionable summary — each is a routine that touches this range; disasm({target:'rom'}) one to identify the renderer/reader. " +
|
|
851
958
|
(r.truncated ? "TRUNCATED: more events than the buffer held — narrow `start..end` or `frames` for the full set." : ""),
|
|
852
959
|
}), host);
|
|
853
960
|
}
|
|
854
961
|
|
|
855
|
-
async function wLogPC({ start, end, frames = 120, pressDuring, limit = 512 }) {
|
|
962
|
+
async function wLogPC({ start, end, frames = 120, pressDuring, limit = 512, fromState, fromStatePath }) {
|
|
856
963
|
const host = getHost(sessionKey);
|
|
857
964
|
if (!host.rangeWatchSupported || !host.rangeWatchSupported()) {
|
|
858
965
|
return jsonContent({ notSupported: true, pcs: [],
|
|
859
966
|
note: "This core build has no coverage trace (shipped on all 14 platforms as of 0.6.0 — update the core package)." });
|
|
860
967
|
}
|
|
861
968
|
if (end < start) throw new Error("watch({on:'pc'}): end must be >= start.");
|
|
969
|
+
const stateInfo = await maybeRestoreState(host, fromState, fromStatePath);
|
|
862
970
|
const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
|
|
863
971
|
const pressDriver = makePressDriver(host, presses);
|
|
864
972
|
if (presses.length) pressDriver.applyForFrame(0);
|
|
@@ -868,6 +976,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
868
976
|
return attachObserverFrame(jsonContent({
|
|
869
977
|
window: "$" + start.toString(16).toUpperCase() + "..$" + end.toString(16).toUpperCase(),
|
|
870
978
|
distinct: r.distinct, total: r.total, returned: pcs.length, truncated: r.truncated,
|
|
979
|
+
...(stateInfo ? { restoredFrom: stateInfo } : {}),
|
|
871
980
|
pcs,
|
|
872
981
|
note: "Each PC is code that EXECUTED in this window. disasm({target:'rom'}) them to find the routine you're hunting. " +
|
|
873
982
|
(r.truncated ? "TRUNCATED — narrow the window for the full distinct set." : ""),
|
|
@@ -880,8 +989,8 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
880
989
|
"• on:'mem' — the power tool: answer 'what code is touching this RAM byte?' OR extract a frame-accurate event timeline (music-driver note onsets, physics arcs). Reports every frame that changed a watched byte as {frame,offset,before,after,pc}. " +
|
|
881
990
|
"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
991
|
"**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
|
-
"• 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.)
|
|
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" +
|
|
992
|
+
"• 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.) `fromState`/`fromStatePath` restores a savestate FIRST so the trace runs from a known moment (jump to the boss, then see what writes HP) — deterministic + repeatable.\n" +
|
|
993
|
+
"• 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. Also takes `fromState`/`fromStatePath` to trace from a restored moment.\n" +
|
|
885
994
|
"• 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`.",
|
|
886
995
|
{
|
|
887
996
|
on: z.enum(["mem", "range", "pc", "dma"])
|
|
@@ -922,6 +1031,8 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
922
1031
|
port: z.number().int().min(0).max(3).default(0),
|
|
923
1032
|
holdFrames: z.number().int().min(1).default(2),
|
|
924
1033
|
})).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')."),
|
|
1034
|
+
fromState: z.string().optional().describe("on:'range'/'pc' — restore an in-memory savestate SLOT (from state({op:'save', name})) BEFORE tracing, so the log runs from a known moment (jump to the boss fight, then see what writes HP). Deterministic + repeatable."),
|
|
1035
|
+
fromStatePath: z.string().optional().describe("on:'range'/'pc' — like fromState but restore from a savestate FILE on disk (state({op:'save', path})). Relative path resolves against the loaded ROM's dir."),
|
|
925
1036
|
},
|
|
926
1037
|
safeTool(async (args) => {
|
|
927
1038
|
switch (args.on) {
|
|
@@ -145,6 +145,48 @@ stub that does a `SYS` to the C entry point.
|
|
|
145
145
|
For game ROMs you typically just let the user load the .prg into the
|
|
146
146
|
emulator and the rest takes care of itself.
|
|
147
147
|
|
|
148
|
+
## Disk images (.d64) — loading real games & distributing yours
|
|
149
|
+
|
|
150
|
+
A bare `.prg` is fine for dev iteration, but the real C64 world — the new
|
|
151
|
+
Commodore 64 Ultimate / C64C Ultimate FPGA hardware and the homebrew/demo
|
|
152
|
+
scene — ships and loads games as **`.d64` disk images** (and `.crt` carts /
|
|
153
|
+
`.tap` tapes). romdev handles them:
|
|
154
|
+
|
|
155
|
+
- **Load & run a disk/cart/tape:** `loadMedia({platform:'c64', path:'game.d64'})`
|
|
156
|
+
(also `.t64 .tap .crt .g64`). VICE attaches it to drive 8 and **autostarts**
|
|
157
|
+
it (equivalent to `LOAD"*",8,1 : RUN`) under warp — give it a few hundred
|
|
158
|
+
frames to finish the emulated 1541 load, then it's running. `mediaKind` in
|
|
159
|
+
status reports `disk` / `tape` / `cartridge` / `program` so you know what you
|
|
160
|
+
loaded.
|
|
161
|
+
- **Distribute YOUR game as a disk:** build your `.prg` as usual, then
|
|
162
|
+
`cart({op:'packDisk', prgPath:'game.prg'})` → an autostart-able `game.d64`
|
|
163
|
+
in the exact format the Ultimate hardware and the scene load. (`cart({op:
|
|
164
|
+
'extract', path:'x.d64'})` lists a disk's files; add `name:` to pull one out.)
|
|
165
|
+
|
|
166
|
+
## Disk SAVES (the C64 save medium)
|
|
167
|
+
|
|
168
|
+
The C64 has no battery SRAM — games save by **writing files to the floppy**. The
|
|
169
|
+
disk IS the save, so romdev exposes the LIVE mounted `.d64` for save/restore
|
|
170
|
+
(the C64 analogue of SRAM `exportSram`/`importSram`):
|
|
171
|
+
|
|
172
|
+
- **Snapshot the disk** (captures any files the game wrote): `state({op:
|
|
173
|
+
'exportDisk', path:'save.d64'})`. Re-load it later with `loadMedia` (autostarts)
|
|
174
|
+
or push it back into a running session with `state({op:'importDisk', path})`.
|
|
175
|
+
- **Inject a save file** a player made elsewhere, straight into the running disk:
|
|
176
|
+
`state({op:'putDiskFile', path:'progress.prg', name:'PROGRESS'})` writes one PRG
|
|
177
|
+
file via the drive. Read it back with `exportDisk` or `cart({op:'extract'})`.
|
|
178
|
+
|
|
179
|
+
These work on the standard 35-track 1541 `.d64` (174848 bytes).
|
|
180
|
+
|
|
181
|
+
**A game's OWN in-emulator `SAVE` works too.** When a running program does a
|
|
182
|
+
KERNAL `SAVE` to drive 8, VICE commits it into the live disk image (true-drive
|
|
183
|
+
GCR write-back) — so after the game saves, `state({op:'exportDisk', path})`
|
|
184
|
+
captures a `.d64` that includes the new file, and you can re-load it later to
|
|
185
|
+
resume. (The on-disk filename is stored in PETSCII; romdev's reader decodes it.)
|
|
186
|
+
So the normal flow is just: run the game, let it save, `exportDisk` to persist.
|
|
187
|
+
`putDiskFile`/`importDisk` are for *injecting* a save from outside (a save a
|
|
188
|
+
player made elsewhere), not a requirement for the game's own saves.
|
|
189
|
+
|
|
148
190
|
## Frame heartbeat
|
|
149
191
|
|
|
150
192
|
The C64 has no dedicated vblank interrupt by default. Two approaches:
|
|
@@ -166,7 +208,9 @@ When you call `build({output:'rom', platform:"c64", language:"c"})`:
|
|
|
166
208
|
3. ld65 links + the bundled c64.cfg → `.prg` with a 2-byte load-address
|
|
167
209
|
header.
|
|
168
210
|
|
|
169
|
-
Loadable via vice_x64 (`loadMedia`).
|
|
211
|
+
Loadable via vice_x64 (`loadMedia`). To ship it the way the scene/hardware
|
|
212
|
+
loads games, wrap the `.prg` into a `.d64`: `cart({op:'packDisk', prgPath})`
|
|
213
|
+
(see "Disk images" above).
|
|
170
214
|
|
|
171
215
|
## Horizontal scrolling (for side-scrollers)
|
|
172
216
|
|