romdevtools 0.43.0 → 0.56.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +322 -0
  2. package/examples/n64/platformer/main.c +158 -0
  3. package/examples/n64/puzzle/main.c +117 -0
  4. package/examples/n64/racing/main.c +147 -0
  5. package/examples/n64/shmup/main.c +122 -0
  6. package/examples/n64/sports/main.c +127 -0
  7. package/examples/ps1/platformer/main.c +158 -0
  8. package/examples/ps1/puzzle/main.c +125 -0
  9. package/examples/ps1/racing/main.c +147 -0
  10. package/examples/ps1/shmup/main.c +192 -0
  11. package/examples/ps1/sports/main.c +127 -0
  12. package/examples/ps1/sprite_move/main.c +38 -0
  13. package/package.json +9 -2
  14. package/src/analysis/analyze.js +169 -29
  15. package/src/analysis/decompile.js +4 -0
  16. package/src/analysis/decompiler/sleigh/mips.ldefs +25 -0
  17. package/src/analysis/decompiler/sleigh/mips32.pspec +78 -0
  18. package/src/analysis/decompiler/sleigh/mips32be.cspec +107 -0
  19. package/src/analysis/decompiler/sleigh/mips32be.sla +211162 -0
  20. package/src/analysis/decompiler/sleigh/mips32le.cspec +106 -0
  21. package/src/analysis/decompiler/sleigh/mips32le.sla +210624 -0
  22. package/src/analysis/rizin.js +22 -1
  23. package/src/cores/capabilities.js +90 -2
  24. package/src/cores/registry.js +12 -0
  25. package/src/host/LibretroGL.js +270 -0
  26. package/src/host/LibretroGLBridge.js +836 -0
  27. package/src/host/LibretroHost.js +144 -3
  28. package/src/host/callbacks.js +18 -2
  29. package/src/host/coreLoader.js +49 -2
  30. package/src/host/cpu-state.js +27 -0
  31. package/src/host/framebuffer.js +14 -1
  32. package/src/host/glOptionalDep.js +60 -0
  33. package/src/host/n64-ai-state.js +43 -0
  34. package/src/host/ps1-spu-state.js +65 -0
  35. package/src/host/retroConstants.js +14 -0
  36. package/src/mcp/tools/cheats.js +73 -4
  37. package/src/mcp/tools/disasm.js +2 -0
  38. package/src/mcp/tools/frame.js +18 -9
  39. package/src/mcp/tools/index.js +12 -2
  40. package/src/mcp/tools/lifecycle.js +1 -1
  41. package/src/mcp/tools/platform-tools.js +20 -4
  42. package/src/mcp/tools/platforms.js +38 -4
  43. package/src/mcp/tools/project.js +100 -0
  44. package/src/mcp/tools/rendering-context.js +2 -1
  45. package/src/mcp/tools/toolchain.js +1 -1
  46. package/src/mcp/tools/watch-memory.js +93 -20
  47. package/src/platforms/n64/lib/c/n64.c +196 -0
  48. package/src/platforms/n64/lib/c/n64.h +68 -0
  49. package/src/platforms/ps1/lib/c/psx.c +200 -0
  50. package/src/platforms/ps1/lib/c/psx.h +83 -0
  51. package/src/toolchains/cc65/cc65.js +11 -0
  52. package/src/toolchains/index.js +35 -0
  53. package/src/toolchains/mips-c/lib/be/libc.a +0 -0
  54. package/src/toolchains/mips-c/lib/be/libgcc.a +0 -0
  55. package/src/toolchains/mips-c/lib/be/libm.a +0 -0
  56. package/src/toolchains/mips-c/lib/el/libc.a +0 -0
  57. package/src/toolchains/mips-c/lib/el/libm.a +0 -0
  58. package/src/toolchains/mips-c/lib/n64-crt0.s +21 -0
  59. package/src/toolchains/mips-c/lib/n64-ipl3.s +30 -0
  60. package/src/toolchains/mips-c/lib/n64.ld +15 -0
  61. package/src/toolchains/mips-c/lib/ps1-crt0.s +20 -0
  62. package/src/toolchains/mips-c/lib/ps1.ld +15 -0
  63. package/src/toolchains/mips-c/lib/softint.c +37 -0
  64. package/src/toolchains/mips-c/mips-c.js +155 -0
  65. package/src/toolchains/mips-elf-gcc/gcc.js +130 -0
@@ -206,6 +206,26 @@ export function makePressDriver(host, presses) {
206
206
  };
207
207
  }
208
208
 
209
+ /**
210
+ * Flush any held-input shadow before a driven run. Back-to-back `pressDuring`
211
+ * runs on the SAME live host can leak the PRIOR run's held button into the next
212
+ * run's frame 0 — the game latches the pad into its own RAM each frame, and the
213
+ * new run's frame-0 logic reads that stale chord before the new input propagates.
214
+ * That makes a negative control (hold A to prove A does NOT reach a B-only branch)
215
+ * FALSE-POSITIVE on frame 1. (v0.41.0 feedback 213831 #1.) Calling this first sets
216
+ * the pad neutral and steps `n` frames so the game's input shadow settles to
217
+ * neutral; then the schedule drives a clean run. Only steps when a press schedule
218
+ * is actually given (no-op otherwise, so it never disturbs an inherited pad).
219
+ * @param {import("../../host/index.js").LibretroHost} host
220
+ * @param {number} n neutral frames to settle (0 = skip)
221
+ * @param {boolean} driven whether this run has a pressDuring schedule
222
+ */
223
+ function settleHeldInput(host, n, driven) {
224
+ if (!driven || !n || n <= 0) return;
225
+ host.setInput({ ports: [{}, {}] });
226
+ host.stepFrames(n | 0);
227
+ }
228
+
209
229
  // Single source of truth: the same canonical region vocabulary readMemory uses
210
230
  // (host/types.js). Derived from the host map so new regions flow through
211
231
  // automatically and the two tools never disagree. Used by the ONE primary
@@ -598,7 +618,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
598
618
 
599
619
  // breakpoint({on:write|read|pc}) STOP-on-first. on:write precision:exact=bpFindWriter
600
620
  // (core watchpoint, true PC under IRQ), precision:sampled=bpRunUntilWrite (frame PC).
601
- async function bpFindWriter({ address, maxFrames = 600, pressDuring, abortIf, condition, conditionValue }) {
621
+ async function bpFindWriter({ address, maxFrames = 600, pressDuring, settleFrames = 0, abortIf, condition, conditionValue }) {
602
622
  const host = getHost(sessionKey);
603
623
  if (!host.watchpointSupported || !host.watchpointSupported()) {
604
624
  return jsonContent({
@@ -616,10 +636,14 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
616
636
  // last write of the frame. Core support is feature-detected; if the loaded
617
637
  // core build predates condition support, we fall back to a host-side
618
638
  // 'equals' filter on the reported value (inc/dec need the core's old byte).
639
+ const presses0 = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
640
+ // Flush a prior run's held input BEFORE arming, so a back-to-back driven run
641
+ // starts from a neutral pad (see settleHeldInput / 213831 #1).
642
+ settleHeldInput(host, settleFrames, presses0.length > 0);
619
643
  const wantCond = condition != null;
620
644
  const coreCond = host.setWatchpoint(address, true, wantCond ? { condition, value: conditionValue } : undefined);
621
645
  const coreHandledCond = wantCond && coreCond && coreCond.conditionApplied === true;
622
- const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
646
+ const presses = presses0;
623
647
  const pressDriver = makePressDriver(host, presses);
624
648
  // Abort-guard: sample caller-named "still valid?" bytes each frame; if any
625
649
  // changes, a driven run that DERAILED (player died → title screen, scene
@@ -761,7 +785,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
761
785
  });
762
786
  }
763
787
 
764
- async function bpRunUntilPC({ address, maxFrames = 600, pressDuring, captureMemory }) {
788
+ async function bpRunUntilPC({ address, maxFrames = 600, pressDuring, settleFrames = 0, captureMemory }) {
765
789
  const host = getHost(sessionKey);
766
790
  if (!host.pcBreakSupported || !host.pcBreakSupported()) {
767
791
  return jsonContent({
@@ -771,6 +795,10 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
771
795
  });
772
796
  }
773
797
  const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
798
+ // Flush a prior run's held-button shadow BEFORE arming (so the settle frames
799
+ // don't trip the breakpoint) — prevents a back-to-back negative control from
800
+ // false-positiving on frame 0. See settleHeldInput.
801
+ settleHeldInput(host, settleFrames, presses.length > 0);
774
802
  const pressDriver = makePressDriver(host, presses);
775
803
  host.setPCBreak(address, true, false);
776
804
  let hit = false, framesRun = 0, last = null;
@@ -797,9 +825,13 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
797
825
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
798
826
  ...(pcNow != null ? { pcNow: "$" + pcNow.toString(16).toUpperCase() } : {}),
799
827
  note: (drove
800
- ? "PC never reached that address within maxFrames EVEN WITH the scheduled input so this is " +
801
- "likely the WRONG ADDRESS for the path that actually ran (a different routine handles it), " +
802
- "or the address isn't an instruction boundary (mid-instruction never matches REG_PC). "
828
+ ? "PC never reached that address within maxFrames EVEN WITH the scheduled input. Either (a) this is " +
829
+ "the WRONG ADDRESS for the path that actually ran (a different routine handles it), (b) the address " +
830
+ "isn't an instruction boundary (mid-instruction never matches REG_PC), OR (c) the condition " +
831
+ "LEGITIMATELY did not occur — i.e. this hit:false is the DESIRED result of a negative control " +
832
+ "(you're proving input X does NOT reach this branch; e.g. an A-vs-B discriminator where only the " +
833
+ "other button should hit). If you confirmed the address is reachable on the positive run, (c) is " +
834
+ "the expected outcome, not a failure. "
803
835
  : "PC never reached that address within maxFrames. Either the code path didn't execute (drive " +
804
836
  "it with pressDuring to reach the right game state), or the address isn't an instruction " +
805
837
  "boundary (mid-instruction never matches REG_PC). ") +
@@ -1091,6 +1123,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1091
1123
  port: z.number().int().min(0).max(3).default(0),
1092
1124
  holdFrames: z.number().int().min(1).default(2),
1093
1125
  })).optional().describe("Schedule input while waiting (drive the game to the state that triggers the condition). If OMITTED, this run inherits whatever input({op:'set'}) last held — same as frame({op:'step'}). If GIVEN, the schedule OWNS the pad for the whole run (a prior input({op:'set'}) is ignored); use it to drive the watched window itself. Entries with OVERLAPPING windows on the same port are OR'd into a chord (e.g. b+right held while a fires mid-window), not overwritten."),
1126
+ settleFrames: z.number().int().min(0).max(120).default(0).describe("on:'pc'/'write' with pressDuring — release the pad to NEUTRAL and step this many frames BEFORE the run, so the PRIOR run's held-button shadow (the game latches the pad into its own RAM each frame) doesn't bleed into this run's frame 0. Set ~10-30 for back-to-back A/B-discriminator / negative-control runs on the same live host (hold A to prove A does NOT reach a B-only branch) — without it the stale chord can false-positive on frame 1. No-op without pressDuring."),
1094
1127
  abortIf: z.array(z.object({
1095
1128
  region: regionStr("memory region (default system_ram)").optional(),
1096
1129
  offset: z.number().int().min(0).describe("byte offset within the region"),
@@ -1295,7 +1328,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1295
1328
 
1296
1329
  // ── Range watch + coverage trace (item 2, discovery) ────────────────────────
1297
1330
 
1298
- async function wRange({ start, end, kind = "both", frames = 120, pressDuring, limit = 200, fromState, fromStatePath }) {
1331
+ async function wRange({ start, end, kind = "both", frames = 120, pressDuring, limit = 200, dedupe = false, distinctPCsOnly = false, fromState, fromStatePath }) {
1299
1332
  const host = getHost(sessionKey);
1300
1333
  if (!host.rangeWatchSupported || !host.rangeWatchSupported()) {
1301
1334
  return jsonContent({ notSupported: true, events: [],
@@ -1313,20 +1346,59 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1313
1346
  if (presses.length) pressDriver.applyForFrame(0);
1314
1347
  const r = host.watchRange(start, end, kind, frames);
1315
1348
  pressDriver.finish();
1316
- const events = r.events.slice(0, limit).map((e) => ({
1317
- pc: "$" + e.pc.toString(16).toUpperCase(),
1318
- address: "$" + e.address.toString(16).toUpperCase(),
1319
- value: "0x" + e.value.toString(16).toUpperCase().padStart(2, "0"),
1320
- }));
1321
- // distinct PCs are the actionable summary
1322
- const distinctPCs = [...new Set(r.events.map((e) => e.pc))].slice(0, 64)
1323
- .map((p) => "$" + p.toString(16).toUpperCase());
1349
+ const hx = (n, w = 0) => "$" + n.toString(16).toUpperCase().padStart(w, "0");
1350
+ const hxv = (n) => "0x" + n.toString(16).toUpperCase().padStart(2, "0");
1351
+
1352
+ // Per-PC digest — the actionable "which routines touch this range" answer.
1353
+ // Each writer's hit count + a sample address/value, sorted by frequency. This
1354
+ // is what a "who writes here?" query actually needs; the per-event flood (a
1355
+ // per-frame counter inc'd at one PC → hundreds of near-identical rows) is
1356
+ // ~95% wasted tokens for that question. (v0.41.0 feedback 133737 N1.)
1357
+ const byPC = new Map();
1358
+ for (const e of r.events) {
1359
+ let g = byPC.get(e.pc);
1360
+ if (!g) { g = { pc: e.pc, count: 0, sampleAddress: e.address, sampleValue: e.value }; byPC.set(e.pc, g); }
1361
+ g.count++;
1362
+ }
1363
+ const byPCList = [...byPC.values()].sort((a, b) => b.count - a.count).slice(0, 64)
1364
+ .map((g) => ({ pc: hx(g.pc), count: g.count, sampleAddress: hx(g.sampleAddress), sampleValue: hxv(g.sampleValue) }));
1365
+ const distinctPCs = byPCList.map((g) => g.pc);
1366
+
1367
+ // distinctPCsOnly → return JUST the digest, suppress the raw event list (the
1368
+ // common "discover the writers" use; no per-event tokens spent).
1369
+ if (distinctPCsOnly) {
1370
+ return attachObserverFrame(jsonContent({
1371
+ range: hx(start) + ".." + hx(end), kind, total: r.total, truncated: r.truncated,
1372
+ ...(stateInfo ? { restoredFrom: stateInfo } : {}),
1373
+ distinctPCs, byPC: byPCList,
1374
+ note: "distinctPCsOnly: per-PC digest only (raw events suppressed). Each PC is a routine that touches the range; `count` is how often it fired, `sampleAddress`/`sampleValue` a representative hit. disasm({target:'rom'}) a PC to identify it. Drop distinctPCsOnly (or set dedupe:true) for the events." +
1375
+ (r.truncated ? " TRUNCATED: more events than the buffer held — narrow start..end/frames." : ""),
1376
+ }), host);
1377
+ }
1378
+
1379
+ // dedupe → collapse identical (pc,address,value) rows to one with an
1380
+ // `occurrences` count, so a byte written the same way every frame is 1 row
1381
+ // not hundreds (parity with on:'dma's dedupe).
1382
+ let events;
1383
+ if (dedupe) {
1384
+ const seen = new Map();
1385
+ for (const e of r.events) {
1386
+ const k = `${e.pc}|${e.address}|${e.value}`;
1387
+ const g = seen.get(k);
1388
+ if (g) g.occurrences++;
1389
+ else seen.set(k, { pc: hx(e.pc), address: hx(e.address), value: hxv(e.value), occurrences: 1 });
1390
+ }
1391
+ events = [...seen.values()].sort((a, b) => b.occurrences - a.occurrences).slice(0, limit);
1392
+ } else {
1393
+ events = r.events.slice(0, limit).map((e) => ({ pc: hx(e.pc), address: hx(e.address), value: hxv(e.value) }));
1394
+ }
1324
1395
  return attachObserverFrame(jsonContent({
1325
- range: "$" + start.toString(16).toUpperCase() + "..$" + end.toString(16).toUpperCase(),
1396
+ range: hx(start) + ".." + hx(end),
1326
1397
  kind, total: r.total, returned: events.length, truncated: r.truncated,
1398
+ ...(dedupe ? { deduped: true, uniqueEvents: events.length } : {}),
1327
1399
  ...(stateInfo ? { restoredFrom: stateInfo } : {}),
1328
- distinctPCs, events,
1329
- note: "distinctPCs is the actionable summary — each is a routine that touches this range; disasm({target:'rom'}) one to identify the renderer/reader. " +
1400
+ distinctPCs, byPC: byPCList, events,
1401
+ note: "distinctPCs/byPC is the actionable summary — each PC is a routine that touches this range; disasm({target:'rom'}) one to identify the renderer/reader. For a 'who writes here?' query, distinctPCsOnly:true returns JUST the digest (no per-event flood); dedupe:true collapses per-frame churn to unique (pc,address,value) rows with `occurrences`. " +
1330
1402
  (r.truncated ? "TRUNCATED: more events than the buffer held — narrow `start..end` or `frames` for the full set." : ""),
1331
1403
  }), host);
1332
1404
  }
@@ -1361,7 +1433,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1361
1433
  "• 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}. " +
1362
1434
  "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. " +
1363
1435
  "**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" +
1364
- "• 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" +
1436
+ "• 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 + a per-PC digest (byPC). For a pure 'who writes here?' query, `distinctPCsOnly:true` returns JUST the digest (no per-event flood — a per-frame counter inc'd at one PC otherwise floods hundreds of near-identical rows); `dedupe:true` collapses identical (pc,address,value) events to one row with `occurrences`. (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" +
1365
1437
  "• 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" +
1366
1438
  "• 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). `perFrame:true` switches to FEEL/PERF MODE: a per-frame timeline of VDP-DMA WORK ({frame,dmas,bytes,romBytes,ramBytes} + peakFrame + `spikes`) — the cheap 'why does horizontal movement feel choppy?' diagnostic (a per-frame byte spike = too much VDP work in the loop, e.g. a tilemap rewrite). On non-Genesis cores returns `notSupported`.\n" +
1367
1439
  "• on:'copy' — ALL 14 PLATFORMS: log every write landing in a VRAM/dest address window [start,end] with the EXECUTING instruction's PC — the generic answer to 'this tile/nametable/portrait on screen: which routine uploads it?'. Port-based video memory (NES $2007, SNES $2118/19 — incl. the DMA path, PCE VWR, MSX/SMS/GG VDP data port, Genesis data port) is hooked INSIDE the core, so `start`/`end` are VRAM addresses (NES PPU $0000-$3FFF; SNES VRAM byte addr; PCE VRAM word addr; MSX/SMS/GG VRAM addr). Direct-mapped platforms (GB/GBC $8000-$9FFF, GBA 0x06000000+, C64/Lynx/7800 RAM framebuffers) route through the CPU-address range log automatically — pass CPU addresses there. Follow up with breakpoint({on:'pc', address: pc}) to get registersAtHit at the uploader.",
@@ -1385,6 +1457,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1385
1457
  stopOnFirst: z.boolean().default(false).describe("on:'mem' — stop on the first filter-passing change instead of running the full duration. (For a true stop-on-first breakpoint, prefer the `breakpoint` tool.)"),
1386
1458
  // on:'range'
1387
1459
  kind: z.enum(["read", "write", "both"]).default("both").describe("on:'range' — watch reads, writes, or both."),
1460
+ distinctPCsOnly: z.boolean().default(false).describe("on:'range' — return JUST the per-PC digest (distinctPCs + byPC[{pc,count,sampleAddress,sampleValue}]) and SUPPRESS the raw event list. The token-cheap form of the common 'which routines touch this range?' query — a per-frame counter inc'd at one PC floods hundreds of near-identical events otherwise."),
1388
1461
  // on:'range' / on:'pc' window
1389
1462
  start: z.number().int().min(0).optional().describe("on:'range'/'pc' — low CPU address of the window."),
1390
1463
  end: z.number().int().min(0).optional().describe("on:'range'/'pc' — high CPU address (inclusive)."),
@@ -1397,7 +1470,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1397
1470
  precision: z.enum(["exact", "sampled"]).default("exact").describe("on:'dma' — exact=per-DMA core log with VRAM dest + ROM source (catches same-frame DMAs); sampled=frame-sampled source-register read (cheaper, may miss two DMAs in one frame, dest-agnostic). Ignored when perFrame:true."),
1398
1471
  vramDest: z.number().int().min(0).optional().describe("on:'dma' precision:'exact' — keep only DMAs whose VRAM destination is within ±`destWindow` of this address."),
1399
1472
  destWindow: z.number().int().min(0).default(0x40).describe("on:'dma' precision:'exact' — match window around vramDest (default 64 bytes ≈ 1 tile)."),
1400
- dedupe: z.boolean().default(true).describe("on:'dma' precision:'exact' — collapse identical DMAs (same dest+source+length+code) to one entry with an `occurrences` count (default on)."),
1473
+ dedupe: z.boolean().optional().describe("Collapse identical events to one entry with an `occurrences` count. on:'dma' precision:'exact' — identical DMAs (same dest+source+length+code), DEFAULT ON. on:'range' identical (pc,address,value) writes, DEFAULT OFF (turns per-frame churn from hundreds of rows into a few)."),
1401
1474
  sourceFilter: z.enum(["all", "rom-only", "ram-only"]).default("all").describe("on:'dma' precision:'exact' — 'rom-only' drops the RAM→VRAM per-frame refresh noise; 'ram-only' keeps only it."),
1402
1475
  romPreviewBytes: z.number().int().min(0).max(64).default(0).describe("on:'dma' — bytes of the ROM source to preview per DMA (exact default 0; sampled default 16)."),
1403
1476
  minLengthBytes: z.number().int().min(0).max(65536).default(0).describe("on:'dma' precision:'sampled' — ignore DMAs shorter than this many bytes (filters tiny scroll/sprite updates so graphic uploads stand out)."),
@@ -0,0 +1,196 @@
1
+ /* n64.c — N64 helpers + software 3D pipeline (see n64.h). Framebuffer backend. */
2
+ #include "n64.h"
3
+
4
+ /* Two 320x240x16bpp framebuffers in RDRAM (double-buffered), cached kseg0. */
5
+ #define FB0 0xA0100000u /* uncached kseg1 — writes go straight to RDRAM */
6
+ #define FB1 0xA0120000u
7
+ static volatile unsigned short *fb; /* the back buffer we draw into */
8
+ static unsigned int front;
9
+
10
+ /* VI registers (0xA4400000). */
11
+ #define VI(n) (*(volatile unsigned int*)(0xA4400000u + (n)*4))
12
+ #define VI_STATUS 0
13
+ #define VI_ORIGIN 1
14
+ #define VI_WIDTH 2
15
+ #define VI_V_CURRENT 4
16
+ #define VI_H_START 9
17
+ #define VI_V_START 10
18
+ #define VI_X_SCALE 12
19
+ #define VI_Y_SCALE 13
20
+
21
+ void n64_init(void)
22
+ {
23
+ /* NTSC 320x240 16bpp — register indices per the N64 VI map:
24
+ 0=STATUS 1=ORIGIN 2=WIDTH 3=V_INTR 4=CURRENT 5=BURST 6=V_SYNC 7=H_SYNC
25
+ 8=LEAP 9=H_START 10=V_START 11=V_BURST 12=X_SCALE 13=Y_SCALE. */
26
+ VI(0) = 0x0000320E; /* STATUS: 16bpp(type=2) + AA + dither + pixel_advance */
27
+ VI(2) = 320; /* WIDTH */
28
+ VI(3) = 2; /* V_INTR */
29
+ VI(4) = 0; /* CURRENT */
30
+ VI(5) = 0x03E52239; /* BURST (NTSC) */
31
+ VI(6) = 0x0000020D; /* V_SYNC = 525 */
32
+ VI(7) = 0x00000C15; /* H_SYNC */
33
+ VI(8) = 0x0C150C15; /* LEAP */
34
+ VI(9) = 0x006C02EC; /* H_START: 108..748 */
35
+ VI(10) = 0x002501FF; /* V_START: 37..511 */
36
+ VI(11) = 0x000E0204; /* V_BURST */
37
+ VI(12) = 0x00000200; /* X_SCALE (320) */
38
+ VI(13) = 0x00000400; /* Y_SCALE (240) */
39
+ front = FB0;
40
+ fb = (volatile unsigned short *)FB1;
41
+ VI(1) = FB0 & 0x1FFFFFFF; /* ORIGIN */
42
+ }
43
+
44
+ static inline void putpx(int x, int y, unsigned short c)
45
+ {
46
+ if ((unsigned)x < SCREEN_W && (unsigned)y < SCREEN_H) fb[y * SCREEN_W + x] = c;
47
+ }
48
+
49
+ void n64_clear(unsigned short col)
50
+ {
51
+ int i; for (i = 0; i < SCREEN_W * SCREEN_H; i++) fb[i] = col;
52
+ }
53
+
54
+ void n64_rect(int x, int y, int w, int h, unsigned short col)
55
+ {
56
+ int r, c;
57
+ for (r = 0; r < h; r++) for (c = 0; c < w; c++) putpx(x + c, y + r, col);
58
+ }
59
+
60
+ /* edge function for the rasterizer */
61
+ static inline int edge(int ax, int ay, int bx, int by, int cx, int cy)
62
+ { return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); }
63
+
64
+ void n64_tri2d(int x0,int y0,int x1,int y1,int x2,int y2,unsigned short col)
65
+ {
66
+ int minx = x0, maxx = x0, miny = y0, maxy = y0, px, py, w0, w1, w2, area;
67
+ if (x1 < minx) minx = x1; if (x1 > maxx) maxx = x1;
68
+ if (x2 < minx) minx = x2; if (x2 > maxx) maxx = x2;
69
+ if (y1 < miny) miny = y1; if (y1 > maxy) maxy = y1;
70
+ if (y2 < miny) miny = y2; if (y2 > maxy) maxy = y2;
71
+ if (minx < 0) minx = 0; if (miny < 0) miny = 0;
72
+ if (maxx >= SCREEN_W) maxx = SCREEN_W - 1; if (maxy >= SCREEN_H) maxy = SCREEN_H - 1;
73
+ area = edge(x0, y0, x1, y1, x2, y2);
74
+ if (area == 0) return;
75
+ for (py = miny; py <= maxy; py++) {
76
+ for (px = minx; px <= maxx; px++) {
77
+ w0 = edge(x1, y1, x2, y2, px, py);
78
+ w1 = edge(x2, y2, x0, y0, px, py);
79
+ w2 = edge(x0, y0, x1, y1, px, py);
80
+ if (area > 0) { if (w0 >= 0 && w1 >= 0 && w2 >= 0) fb[py * SCREEN_W + px] = col; }
81
+ else { if (w0 <= 0 && w1 <= 0 && w2 <= 0) fb[py * SCREEN_W + px] = col; }
82
+ }
83
+ }
84
+ }
85
+
86
+ void n64_flip(void)
87
+ {
88
+ /* swap buffers + point VI at the freshly drawn one, then wait for a frame. */
89
+ unsigned int newfront = (unsigned int)(unsigned long)fb;
90
+ VI(1) = newfront & 0x1FFFFFFF;
91
+ fb = (volatile unsigned short *)(front);
92
+ front = newfront;
93
+ { volatile int i; for (i = 0; i < 200000; i++) { } }
94
+ }
95
+
96
+ /* ── input: read controller port 0 via the SI/PIF. The PIF command 0x01 polls
97
+ the pad; the 16-bit button word is at PIF RAM. We use the simple JoyBus poll. ── */
98
+ static unsigned int read_pad(void)
99
+ {
100
+ volatile unsigned int *pif = (volatile unsigned int *)0xBFC007C0; /* PIF RAM */
101
+ volatile unsigned int *si = (volatile unsigned int *)0xA4800000;
102
+ unsigned int buttons;
103
+ /* command block: read controller 0 (1 byte cmd 0x01, 1 byte send, ...) */
104
+ pif[0] = 0xFF010401; pif[1] = 0xFFFFFFFF; pif[2] = 0xFFFFFFFF;
105
+ pif[3] = 0xFE000000; pif[4] = 0; pif[5] = 0; pif[6] = 0; pif[7] = 1;
106
+ si[1] = 0x1FC007C0; /* SI_PIF_ADDR_RD64B: kick the PIF read */
107
+ { volatile int t; for (t = 0; t < 5000; t++) { } }
108
+ buttons = pif[1] >> 16; /* the button half-word */
109
+ return (~0u) & buttons; /* JoyBus buttons are active-high already */
110
+ }
111
+ unsigned int n64_pad(void) { return read_pad(); }
112
+ int n64_pressed(unsigned int mask) { return (n64_pad() & mask) ? 1 : 0; }
113
+
114
+ /* ── trig (shared with PS1 lib: 256-step binary angle) ── */
115
+ static const short SINTAB[64] = {
116
+ 0,804,1608,2410,3212,4011,4808,5602,6393,7179,7962,8739,9512,10278,11039,11793,
117
+ 12539,13279,14010,14732,15446,16151,16846,17530,18204,18868,19519,20159,20787,21403,
118
+ 22005,22594,23170,23731,24279,24811,25329,25832,26319,26790,27245,27683,28105,28510,
119
+ 28898,29268,29621,29956,30273,30571,30852,31113,31356,31580,31785,31971,32137,32285,
120
+ 32412,32521,32609,32678,32728,32757 };
121
+ static fix sin_lut(int idx) {
122
+ int q = (idx >> 6) & 3, p = idx & 63; int v;
123
+ if (q == 0) v = SINTAB[p]; else if (q == 1) v = SINTAB[63 - p];
124
+ else if (q == 2) v = -SINTAB[p]; else v = -SINTAB[63 - p];
125
+ return ((fix)v << 16) / 32768;
126
+ }
127
+ fix n64_sin(fix a) { return sin_lut((F2I(a)) & 255); }
128
+ fix n64_cos(fix a) { return sin_lut((F2I(a) + 64) & 255); }
129
+
130
+ /* ── camera + model (identical to the PS1 lib) ── */
131
+ static fix cam_x, cam_y, cam_z, cam_cy, cam_sy, cam_cp, cam_sp;
132
+ static fix mdl_x, mdl_y, mdl_z, mdl_cy, mdl_sy;
133
+ void n64_camera(fix ex, fix ey, fix ez, fix yaw, fix pitch)
134
+ { cam_x=ex;cam_y=ey;cam_z=ez; cam_cy=n64_cos(yaw);cam_sy=n64_sin(yaw); cam_cp=n64_cos(pitch);cam_sp=n64_sin(pitch); }
135
+ void n64_model(fix tx, fix ty, fix tz, fix yaw)
136
+ { mdl_x=tx;mdl_y=ty;mdl_z=tz; mdl_cy=n64_cos(yaw);mdl_sy=n64_sin(yaw); }
137
+
138
+ static Vec3 to_cam(Vec3 v)
139
+ {
140
+ fix mx = FMUL(v.x, mdl_cy) + FMUL(v.z, mdl_sy);
141
+ fix mz = -FMUL(v.x, mdl_sy) + FMUL(v.z, mdl_cy);
142
+ fix wx = mx + mdl_x, wy = v.y + mdl_y, wz = mz + mdl_z;
143
+ fix rx = wx - cam_x, ry = wy - cam_y, rz = wz - cam_z;
144
+ fix cx = FMUL(rx, cam_cy) - FMUL(rz, cam_sy);
145
+ fix cz = FMUL(rx, cam_sy) + FMUL(rz, cam_cy);
146
+ fix cy = FMUL(ry, cam_cp) - FMUL(cz, cam_sp);
147
+ fix cz2 = FMUL(ry, cam_sp) + FMUL(cz, cam_cp);
148
+ Vec3 o; o.x = cx; o.y = cy; o.z = cz2; return o;
149
+ }
150
+ #define NEAR FIXF(0.5f)
151
+ #define FOV FIX(220)
152
+ static int project(Vec3 c, int *sx, int *sy)
153
+ {
154
+ if (c.z <= NEAR) return 0;
155
+ *sx = (SCREEN_W / 2) + F2I(FDIV(FMUL(c.x, FOV), c.z));
156
+ *sy = (SCREEN_H / 2) - F2I(FDIV(FMUL(c.y, FOV), c.z));
157
+ return 1;
158
+ }
159
+ void n64_tri3d(Vec3 a, Vec3 b, Vec3 c, unsigned short col)
160
+ {
161
+ Vec3 ca=to_cam(a), cb=to_cam(b), cc=to_cam(c);
162
+ int x0,y0,x1,y1,x2,y2;
163
+ if (!project(ca,&x0,&y0)||!project(cb,&x1,&y1)||!project(cc,&x2,&y2)) return;
164
+ if ((x1-x0)*(y2-y0)-(x2-x0)*(y1-y0) <= 0) return; /* back-face cull */
165
+ n64_tri2d(x0,y0,x1,y1,x2,y2,col);
166
+ }
167
+ void n64_quad3d(Vec3 a, Vec3 b, Vec3 c, Vec3 d, unsigned short col)
168
+ { n64_tri3d(a,b,c,col); n64_tri3d(a,c,d,col); }
169
+ void n64_tri3d_nc(Vec3 a, Vec3 b, Vec3 c, unsigned short col)
170
+ {
171
+ Vec3 ca=to_cam(a), cb=to_cam(b), cc=to_cam(c);
172
+ int x0,y0,x1,y1,x2,y2;
173
+ if (!project(ca,&x0,&y0)||!project(cb,&x1,&y1)||!project(cc,&x2,&y2)) return;
174
+ n64_tri2d(x0,y0,x1,y1,x2,y2,col);
175
+ }
176
+ void n64_quad3d_nc(Vec3 a, Vec3 b, Vec3 c, Vec3 d, unsigned short col)
177
+ { n64_tri3d_nc(a,b,c,col); n64_tri3d_nc(a,c,d,col); }
178
+
179
+ /* ── RNG ── */
180
+ static unsigned int rng = 0x12345678u;
181
+ void n64_srand(unsigned int s) { rng = s ? s : 1; }
182
+ unsigned int n64_rand(void) { unsigned int x=rng; x^=x<<13; x^=x>>17; x^=x<<5; return rng=x; }
183
+
184
+ /* ── HUD number (3x5 cells scaled) ── */
185
+ static const unsigned char DIG[10][5] = {
186
+ {0x7,0x5,0x5,0x5,0x7},{0x2,0x6,0x2,0x2,0x7},{0x7,0x1,0x7,0x4,0x7},{0x7,0x1,0x3,0x1,0x7},
187
+ {0x5,0x5,0x7,0x1,0x1},{0x7,0x4,0x7,0x1,0x7},{0x7,0x4,0x7,0x5,0x7},{0x7,0x1,0x1,0x1,0x1},
188
+ {0x7,0x5,0x7,0x5,0x7},{0x7,0x5,0x7,0x1,0x7} };
189
+ static void digit(int x,int y,int d,unsigned short col)
190
+ { int r,c; for(r=0;r<5;r++)for(c=0;c<3;c++) if(DIG[d][r]&(1<<(2-c))) n64_rect(x+c*3,y+r*3,3,3,col); }
191
+ void n64_number(int x, int y, unsigned int value, unsigned short col)
192
+ {
193
+ char buf[10]; int n=0,i;
194
+ if(!value)buf[n++]=0; while(value&&n<10){buf[n++]=value%10;value/=10;}
195
+ for(i=0;i<n;i++) digit(x+(n-1-i)*12,y,buf[i],col);
196
+ }
@@ -0,0 +1,68 @@
1
+ /* n64.h — N64 (R4300) helper lib with a software 3D pipeline, for romdev.
2
+ *
3
+ * The N64 is a 3D machine; this is a real software 3D engine (identical fixed-point
4
+ * math to the PS1 lib, so games port directly) with an N64 framebuffer backend: a
5
+ * 320x240 16-bit (RGBA5551) framebuffer in RDRAM that the Video Interface scans out,
6
+ * and a software triangle rasterizer (the RDP is far more complex; direct FB writes
7
+ * are portable + reliable). Big-endian.
8
+ *
9
+ * Build: build({ platform:"n64", language:"c" }) → a self-booting .z64 (our clean
10
+ * IPL3 DMAs the game to RDRAM and jumps in). main() loops forever.
11
+ */
12
+ #ifndef ROMDEV_N64_H
13
+ #define ROMDEV_N64_H
14
+
15
+ typedef int fix; /* 16.16 */
16
+ #define FIX(n) ((fix)((n) << 16))
17
+ #define FIXF(f) ((fix)((f) * 65536.0f))
18
+ #define FMUL(a,b) ((fix)(((long long)(a) * (b)) >> 16))
19
+ #define FDIV(a,b) ((fix)(((long long)(a) << 16) / (b)))
20
+ #define F2I(a) ((a) >> 16)
21
+
22
+ typedef struct { fix x, y, z; } Vec3;
23
+
24
+ #define SCREEN_W 320
25
+ #define SCREEN_H 240
26
+
27
+ /* RGBA5551 color (the VI framebuffer format): 5-5-5-1. */
28
+ #define RGB(r,g,b) ((unsigned short)((((r)>>3)<<11)|(((g)>>3)<<6)|(((b)>>3)<<1)|1))
29
+
30
+ /* controller digital buttons (active-high after read). */
31
+ #define PAD_A 0x8000
32
+ #define PAD_B 0x4000
33
+ #define PAD_Z 0x2000
34
+ #define PAD_START 0x1000
35
+ #define PAD_UP 0x0800
36
+ #define PAD_DOWN 0x0400
37
+ #define PAD_LEFT 0x0200
38
+ #define PAD_RIGHT 0x0100
39
+ #define PAD_L 0x0020
40
+ #define PAD_R 0x0010
41
+
42
+ /* ── framebuffer / 2D ── */
43
+ void n64_init(void);
44
+ void n64_clear(unsigned short col);
45
+ void n64_rect(int x, int y, int w, int h, unsigned short col);
46
+ void n64_tri2d(int x0,int y0,int x1,int y1,int x2,int y2,unsigned short col);
47
+ void n64_flip(void); /* present the framebuffer (swap + wait vblank) */
48
+
49
+ /* ── input ── */
50
+ unsigned int n64_pad(void);
51
+ int n64_pressed(unsigned int mask);
52
+
53
+ /* ── 3D pipeline (same API as the PS1 lib) ── */
54
+ void n64_camera(fix ex, fix ey, fix ez, fix yaw, fix pitch);
55
+ void n64_model(fix tx, fix ty, fix tz, fix yaw);
56
+ void n64_tri3d(Vec3 a, Vec3 b, Vec3 c, unsigned short col);
57
+ void n64_quad3d(Vec3 a, Vec3 b, Vec3 c, Vec3 d, unsigned short col);
58
+ void n64_tri3d_nc(Vec3 a, Vec3 b, Vec3 c, unsigned short col);
59
+ void n64_quad3d_nc(Vec3 a, Vec3 b, Vec3 c, Vec3 d, unsigned short col);
60
+
61
+ /* ── misc ── */
62
+ fix n64_sin(fix a);
63
+ fix n64_cos(fix a);
64
+ unsigned int n64_rand(void);
65
+ void n64_srand(unsigned int seed);
66
+ void n64_number(int x, int y, unsigned int value, unsigned short col);
67
+
68
+ #endif