romdevtools 0.85.0 → 0.87.1

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.
@@ -1190,6 +1190,59 @@ function renderBuildMd({ platform, romPath, regions, blobs, build, verifiable, n
1190
1190
  * The default endian follows the CPU (Genesis/m68k → BE; everything else → LE),
1191
1191
  * overridable via `endian`.
1192
1192
  */
1193
+ /**
1194
+ * target='source' — return a cart's high-level SOURCE instead of a disassembly, for
1195
+ * platforms where the "cart" IS source (not machine code). Today: PICO-8 (.p8 = Lua +
1196
+ * data sections; .p8.png = the same cart embedded in a label PNG). This is the honest
1197
+ * "understand this cart" path for a Lua VM — there's no machine code to disassemble, so
1198
+ * we hand back the actual Lua the game runs, plus a map of the other sections.
1199
+ */
1200
+ async function readCartSourceCore(args) {
1201
+ const romPath = requireRomPath(args);
1202
+ const fs = await import("node:fs/promises");
1203
+ const lower = romPath.toLowerCase();
1204
+ const isPng = lower.endsWith(".p8.png");
1205
+ const isP8 = lower.endsWith(".p8");
1206
+ if (!isP8 && !isPng && args.platform !== "pico8") {
1207
+ throw new Error(
1208
+ `disasm({target:'source'}) is for source-form carts (PICO-8 .p8/.p8.png). '${romPath}' isn't one. ` +
1209
+ `For machine-code ROMs use target:'rom'/'project'/'decompile'.`,
1210
+ );
1211
+ }
1212
+ if (isPng) {
1213
+ // A .p8.png embeds the cart in the PNG's low bits — decoding needs the FAKE-08 core.
1214
+ throw new Error(
1215
+ "disasm({target:'source'}): .p8.png carts store the source steganographically in the PNG — " +
1216
+ "load it (loadMedia({platform:'pico8'})) to run it; export the .p8 text form to read the Lua. " +
1217
+ "Pass a plain-text .p8 here to read its source directly.",
1218
+ );
1219
+ }
1220
+ const text = await fs.readFile(romPath, "utf8");
1221
+ // Split on __section__ markers, preserving order.
1222
+ const parts = text.split(/(^__[a-z]+__$)/m);
1223
+ const sections = {};
1224
+ let current = "(header)";
1225
+ let buf = [];
1226
+ const flush = () => { if (buf.length) sections[current] = buf.join("\n").replace(/^\n+|\n+$/g, ""); buf = []; };
1227
+ for (const chunk of parts) {
1228
+ const m = chunk.match(/^__([a-z]+)__$/);
1229
+ if (m) { flush(); current = m[1]; } else { buf.push(chunk); }
1230
+ }
1231
+ flush();
1232
+ return {
1233
+ platform: "pico8",
1234
+ kind: "source",
1235
+ format: ".p8",
1236
+ note: "PICO-8 carts are SOURCE, not machine code — this is the Lua the game runs (+ its data sections). There is no disassembly to do.",
1237
+ lua: sections.lua ?? "",
1238
+ sections: Object.keys(sections),
1239
+ // Include non-lua data sections so callers can see the full cart shape (gfx/map/sfx/music/gff/label).
1240
+ dataSections: Object.fromEntries(
1241
+ Object.entries(sections).filter(([k]) => k !== "lua" && k !== "(header)"),
1242
+ ),
1243
+ };
1244
+ }
1245
+
1193
1246
  async function pointerTableCore(args) {
1194
1247
  const { platform, loBase, hiBase, count, convention = "direct", reverseHandler, bank } = args;
1195
1248
  if (loBase == null) throw new Error("disasm({target:'pointerTable'}): loBase (the table / low-byte base) is required.");
@@ -1485,11 +1538,11 @@ export function registerDisasmTools(server, z) {
1485
1538
  "LLM folds it. `address` for all four comes from target:'functions' (a CPU/virtual address; the file-offset " +
1486
1539
  "mapping is handled for you).",
1487
1540
  {
1488
- target: z.enum(["bytes", "rom", "project", "references", "cfg", "xrefs", "functions", "decompile", "resolveJumptable", "pointerTable", "recompile"]).describe("bytes = raw chunk; rom = mapper-aware ROM; project = full rebuildable disasm; references = flat da65 operand-refs to an address; functions/cfg/xrefs = Rizin RE engine (function list / control-flow graph / deep graph xrefs); decompile = Ghidra C pseudocode; resolveJumptable = recover a computed-jump dispatcher's targets (LIVE — redirects to breakpoint({on:'jumptable'}), which runs the emulator and records the real switch arms a static decompiler can't follow); recompile = EMIT backend — statically recompile a NES ROM's reset routine to SNES 65816 asar source that builds + boots (phase 1: NROM, 6502→65816 emulation mode, PPU/APU seam STUBBED). See the tool description for the RE loop + the decompile altitude rule + per-CPU quality (all 14 platforms)."),
1541
+ target: z.enum(["bytes", "rom", "project", "references", "cfg", "xrefs", "functions", "decompile", "source", "resolveJumptable", "pointerTable", "recompile"]).describe("bytes = raw chunk; rom = mapper-aware ROM; project = full rebuildable disasm; references = flat da65 operand-refs to an address; functions/cfg/xrefs = Rizin RE engine (function list / control-flow graph / deep graph xrefs); decompile = Ghidra C pseudocode; resolveJumptable = recover a computed-jump dispatcher's targets (LIVE — redirects to breakpoint({on:'jumptable'}), which runs the emulator and records the real switch arms a static decompiler can't follow); recompile = EMIT backend — statically recompile a NES ROM's reset routine to SNES 65816 asar source that builds + boots (phase 1: NROM, 6502→65816 emulation mode, PPU/APU seam STUBBED). See the tool description for the RE loop + the decompile altitude rule + per-CPU quality (all 14 platforms)."),
1489
1542
  // shared
1490
1543
  path: z.string().optional().describe("target=bytes: raw binary path. target=rom/project/references: ROM file path."),
1491
1544
  base64: z.string().optional().describe("target=bytes: base64 of the bytes (OR `path`)."),
1492
- platform: z.enum(["nes", "snes", "sms", "gg", "gb", "gbc", "atari2600", "atari7800", "c64", "genesis", "gba", "pce", "msx", "lynx"]).optional().describe("target=rom/project/references: override platform (else sniffed from extension)."),
1545
+ platform: z.enum(["nes", "snes", "sms", "gg", "gb", "gbc", "atari2600", "atari7800", "c64", "genesis", "gba", "pce", "msx", "lynx", "pico8"]).optional().describe("target=rom/project/references: override platform (else sniffed from extension). target=source: pico8 (.p8 carts are Lua source)."),
1493
1546
  startAddress: z.number().int().min(0).max(0xffffffff).default(0x8000).describe("target=bytes/rom: address of the first byte (GBA auto-bumped to 0x08000000)."),
1494
1547
  length: z.number().int().min(1).max(65536).optional().describe("target=rom: bytes to disassemble (default 256; mutually exclusive with endAddress)."),
1495
1548
  addOrigin: z.boolean().default(true).describe("target=bytes/rom: prepend `.org` so the asm re-assembles through ca65."),
@@ -1540,6 +1593,7 @@ export function registerDisasmTools(server, z) {
1540
1593
  case "xrefs": return jsonContent(await analyzeXrefs(requireRomPath(args), args.address, args.platform));
1541
1594
  case "functions": return jsonContent(await analyzeFunctions(requireRomPath(args), args.platform));
1542
1595
  case "decompile": return jsonContent(await analyzeDecompile(requireRomPath(args), args.address, args.platform));
1596
+ case "source": return jsonContent(await readCartSourceCore(args));
1543
1597
  case "recompile": return await recompileCore(args);
1544
1598
  case "pointerTable": return jsonContent(await pointerTableCore(args));
1545
1599
  case "resolveJumptable":
@@ -662,6 +662,33 @@ const TEMPLATES = {
662
662
  mechanics: ["direct framebuffer paint (clear + solid rects)", "recognizable test pattern"],
663
663
  techniques: ["PowerVR2 framebuffer bring-up (FB_R_CTRL/FB_R_SIZE/FB_R_SOF1 + SPG)", "640x480 RGB565 at VRAM 0xA5000000", "SH-4 bare crt0 (stack + .bss + main)", "boots on Flycast reios HLE — no firmware"] },
664
664
  },
665
+ // PICO-8 (FAKE-08): the "source" IS a complete .p8 cart (Lua + __gfx__/__sfx__/__music__
666
+ // sections), so there's no crt0/runtime/linker — fork copies the .p8 and
667
+ // build({platform:'pico8', source:<the .p8 text>}) packages it. Each is a real playable
668
+ // game: hand-authored pixel-art sprites, looping music + SFX, title/play/over states,
669
+ // full genre mechanics. Fork one and reshape a single thing at a time.
670
+ pico8: {
671
+ shmup: { main: "templates/shmup.p8", runtime: [], lang: "Lua (FAKE-08)", ext: ".p8",
672
+ describe: "STAR SWEEPER — a vertical shooter. Ship sprite w/ thruster animation + i-frame blink, two enemy sprite types, projectile pool, ramping wave spawner, AABB collision, explosion sprites, score + persistent hi-score, looping title/play music that switches on start. The full shmup contract in ~130 lines of Lua.",
673
+ players: "1", mechanics: ["projectile pool", "wave spawner", "AABB collision", "i-frames", "title/play/over state machine"],
674
+ techniques: ["hand-authored __gfx__ sprites drawn with spr()", "__music__ + __sfx__ banks (music switches per state)", "sprite animation via frame flip", "scrolling starfield"] },
675
+ platformer: { main: "templates/platformer.p8", runtime: [], lang: "Lua (FAKE-08)", ext: ".p8",
676
+ describe: "HOP QUEST — a platformer. Hero sprite with idle/walk/jump frames (h-flipped by facing), gravity + solid-box collision, variable-height jump, animated spinning-coin sprites to collect, a goal flag, parallax hills, best-time tracking. Fork the movement/level and go.",
677
+ players: "1", mechanics: ["gravity + solid-box collision", "variable jump", "coin pickups", "goal + win state", "respawn on fall"],
678
+ techniques: ["hand-authored hero + coin sprites", "spr() h-flip by facing", "walk-cycle animation", "__music__ loop + jump/coin SFX", "parallax backdrop"] },
679
+ puzzle: { main: "templates/puzzle.p8", runtime: [], lang: "Lua (FAKE-08)", ext: ".p8",
680
+ describe: "COLOR DROP — a falling-gem match puzzle. Five faceted gem sprites, an 8×12 well, slide + soft/hard drop, horizontal match-3 line clears with a flash then gravity-settle + combo scoring, next-gem preview, game-over on stack-out. Looping music + drop/slide/match SFX.",
681
+ players: "1", mechanics: ["grid + falling piece", "match-3 clears", "gravity settle", "combo scoring", "next preview", "stack-out game over"],
682
+ techniques: ["hand-authored gem sprites", "flash-then-clear timing (state machine on a flash queue)", "column gravity compaction", "__music__ loop + SFX"] },
683
+ sports: { main: "templates/sports.p8", runtime: [], lang: "Lua (FAKE-08)", ext: ".p8",
684
+ describe: "RALLY VOLLEY — a 2-player paddle sports game. Paddle + ball sprites, angle-off-paddle physics, ball speed-up per rally, 1P-vs-CPU (tracking AI) or 2P couch mode toggled on the title, first-to-5 scoring. Looping music + wall/paddle/point SFX. The classic 2P scaffold.",
685
+ players: "1-2 (vs CPU or couch 2P)", mechanics: ["paddle + ball physics", "angle off paddle", "rally speed-up", "AI opponent", "first-to-5 match"],
686
+ techniques: ["hand-authored paddle/ball sprites", "mode toggle on title screen", "simple tracking AI", "__music__ loop + bounce/point SFX"] },
687
+ racing: { main: "templates/racing.p8", runtime: [], lang: "Lua (FAKE-08)", ext: ".p8",
688
+ describe: "LANE RUNNER — a top-down endless racer. Player + rival car sprites, 3-lane snap-steer dodging, scrolling road with animated lane dashes + shoulders, speed that ramps with distance, screen-shake on crash, distance score + best. Looping engine music + swerve/crash SFX.",
689
+ players: "1", mechanics: ["lane-snap steering", "rival spawner", "speed ramp", "crash + game over", "distance score"],
690
+ techniques: ["hand-authored car sprites", "scrolling-road illusion (moving dashes)", "camera() screen-shake", "__music__ loop + SFX"] },
691
+ },
665
692
  };
666
693
  // R37: GBC has its own scaffold tree at examples/gbc/templates/ +
667
694
  // src/platforms/gbc/lib/c/. Same runtime files as GB (the APU + Z80 +
@@ -1042,8 +1042,46 @@ export async function buildForPlatform(args) {
1042
1042
  };
1043
1043
  }
1044
1044
 
1045
+ if (args.platform === "pico8") {
1046
+ // PICO-8 "build" = PACKAGE a .p8 cart (plain-text sections), NOT compile to machine
1047
+ // code — the Lua is source. Accept bare Lua (`source`/`lua`) wrapped into a valid .p8,
1048
+ // or a complete .p8 passed through. Optional data sections (gfx/map/sfx/music) via
1049
+ // `sections`. The "binary" IS the .p8 text bytes, which FAKE-08 loads + runs directly.
1050
+ const { packP8 } = await import("./pico8/pack.js");
1051
+ try {
1052
+ const lua = args.lua ?? args.source ?? args.code;
1053
+ const { text, bytes, warnings } = packP8({
1054
+ lua,
1055
+ p8: args.p8,
1056
+ version: args.p8Version,
1057
+ sections: args.sections ?? {},
1058
+ });
1059
+ return {
1060
+ ok: true,
1061
+ binary: bytes,
1062
+ listing: text, // the readable .p8 IS the listing
1063
+ symbols: "",
1064
+ log: warnings.length ? warnings.map((w) => `[pico8] ${w}`).join("\n") : "[pico8] packaged .p8 cart",
1065
+ issues: [],
1066
+ exitCode: 0,
1067
+ toolchain: "pico8-pack",
1068
+ };
1069
+ } catch (e) {
1070
+ return {
1071
+ ok: false,
1072
+ binary: null,
1073
+ listing: "",
1074
+ symbols: "",
1075
+ log: String(e.message ?? e),
1076
+ issues: [],
1077
+ exitCode: 1,
1078
+ toolchain: "pico8-pack",
1079
+ };
1080
+ }
1081
+ }
1082
+
1045
1083
  throw new Error(
1046
- `no bundled toolchain for platform '${args.platform}'. Supported: atari2600 (dasm), nes/c64/atari7800/lynx (cc65), snes (C via tcc-65816+wla+PVSnesLib, or asm via asar), genesis (C via m68k-gcc+SGDK, or asm via vasm68k), gba (C via arm-gcc+libtonc/libgba), gb/gbc (sdcc sm83 / rgbds), sms/gg (sdcc). Call listPlatforms for the live matrix.`,
1084
+ `no bundled toolchain for platform '${args.platform}'. Supported: atari2600 (dasm), nes/c64/atari7800/lynx (cc65), snes (C via tcc-65816+wla+PVSnesLib, or asm via asar), genesis (C via m68k-gcc+SGDK, or asm via vasm68k), gba (C via arm-gcc+libtonc/libgba), gb/gbc (sdcc sm83 / rgbds), sms/gg (sdcc), pico8 (.p8 cart packager). Call listPlatforms for the live matrix.`,
1047
1085
  );
1048
1086
  }
1049
1087
 
@@ -0,0 +1,67 @@
1
+ // PICO-8 .p8 cart "builder" — a PACKAGER, not a compiler. PICO-8 carts are plain-text
2
+ // files with labeled sections (__lua__, __gfx__, __gff__, __label__, __map__, __sfx__,
3
+ // __music__); the Lua is source, not machine code. So "build" here = assemble a valid
4
+ // .p8 from the given Lua (+ optional data sections) that FAKE-08 can load and run.
5
+ //
6
+ // Accepts either a full .p8 already (passed through / validated) or bare Lua source
7
+ // (wrapped into a minimal but valid .p8). Data sections can be supplied verbatim.
8
+
9
+ const P8_HEADER = "pico-8 cartridge // http://www.pico-8.com";
10
+ const KNOWN_SECTIONS = ["lua", "gfx", "gff", "label", "map", "sfx", "music"];
11
+
12
+ /** Is this text already a full .p8 cart (has the header + a __lua__ section)? */
13
+ export function isP8Cart(text) {
14
+ return /^pico-8 cartridge/i.test(text.trimStart()) && /(^|\n)__lua__/.test(text);
15
+ }
16
+
17
+ /**
18
+ * Assemble a .p8 cart.
19
+ * @param {object} opts
20
+ * @param {string} opts.lua Lua source for the __lua__ section (required unless `p8` given).
21
+ * @param {string} [opts.p8] A complete .p8 already — validated + passed through.
22
+ * @param {number} [opts.version] PICO-8 format version line (default 18).
23
+ * @param {Object<string,string>} [opts.sections] Extra sections by name → body text
24
+ * (e.g. { gfx: "...", map: "...", sfx: "...", music: "..." }). Bodies are the raw
25
+ * hex/number rows PICO-8 uses; passed through verbatim.
26
+ * @returns {{ text: string, bytes: Uint8Array, warnings: string[] }}
27
+ */
28
+ export function packP8({ lua, p8, version = 18, sections = {} } = {}) {
29
+ const warnings = [];
30
+
31
+ if (p8 != null) {
32
+ if (!isP8Cart(p8)) {
33
+ throw new Error("pico8 build: `p8` was given but doesn't look like a .p8 cart (missing 'pico-8 cartridge' header or __lua__ section).");
34
+ }
35
+ const text = p8.endsWith("\n") ? p8 : p8 + "\n";
36
+ return { text, bytes: new TextEncoder().encode(text), warnings };
37
+ }
38
+
39
+ if (typeof lua !== "string" || lua.trim() === "") {
40
+ throw new Error("pico8 build: provide `lua` (the cart's Lua source) or a full `p8`.");
41
+ }
42
+
43
+ // Validate any extra section names.
44
+ for (const name of Object.keys(sections)) {
45
+ if (!KNOWN_SECTIONS.includes(name)) {
46
+ warnings.push(`unknown .p8 section '__${name}__' — passing through, but PICO-8/FAKE-08 may ignore it.`);
47
+ }
48
+ }
49
+
50
+ const parts = [P8_HEADER, `version ${version}`, "__lua__", lua.replace(/\n$/, "")];
51
+ // Emit data sections in canonical order (skip __lua__, handled above).
52
+ for (const name of KNOWN_SECTIONS) {
53
+ if (name === "lua") continue;
54
+ if (sections[name] != null) {
55
+ parts.push(`__${name}__`, String(sections[name]).replace(/\n$/, ""));
56
+ }
57
+ }
58
+ // Pass through any unknown sections last (so nothing the caller sent is dropped).
59
+ for (const [name, body] of Object.entries(sections)) {
60
+ if (!KNOWN_SECTIONS.includes(name)) {
61
+ parts.push(`__${name}__`, String(body).replace(/\n$/, ""));
62
+ }
63
+ }
64
+
65
+ const text = parts.join("\n") + "\n";
66
+ return { text, bytes: new TextEncoder().encode(text), warnings };
67
+ }