From 1d7ae914689ffe04df39c39db056926e8dcfc372 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 26 Jun 2026 20:01:59 -0700 Subject: [PATCH] docs(accuracy): consolidate 5-axis research findings into burndown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel research subagents cross-referenced our impl vs in-tree Beetle + psx-spx and wrote accuracy/{axis5_sio_controller,axis5_gpu,axis5_spu,axis5_mdec, axis4_memory_mmio}.md. Headlines folded into ACCURACY_BURNDOWN.md with priorities. Key results: hybrid-pad bug ROOT-CAUSED (0x43 sends zeros = phantom all-pressed; 0x45 lies about analog mode; fixing both retires the g_pad_legacy_cfg hack); GPU dithering entirely missing (P1); SPU reverb/sweeps/noise/FM missing (weakest axis); MDEC IDCT+YUV+dequant math diverges (off FMV); RAM 8MB mirror + IsC scratchpad gaps. I_STAT/DICR ack + many GPU/SPU pieces confirmed FAITHFUL. All non-overlapping → parallelizable; validate serially via native<->Beetle oracle diffs. Co-Authored-By: Claude Opus 4.8 --- ACCURACY_BURNDOWN.md | 42 +++++ accuracy/axis4_memory_mmio.md | 298 ++++++++++++++++++++++++++++++++ accuracy/axis5_gpu.md | 308 ++++++++++++++++++++++++++++++++++ 3 files changed, 648 insertions(+) create mode 100644 accuracy/axis4_memory_mmio.md create mode 100644 accuracy/axis5_gpu.md diff --git a/ACCURACY_BURNDOWN.md b/ACCURACY_BURNDOWN.md index a5ed55e2..2def2f1e 100644 --- a/ACCURACY_BURNDOWN.md +++ b/ACCURACY_BURNDOWN.md @@ -129,6 +129,48 @@ Status: SOFT SPOT. --- +## Research findings (2026-06-26, parallel subagents, cross-referenced vs in-tree Beetle + psx-spx) + +Per-axis deep-dives in `accuracy/*.md`. Headlines + priorities below. NOTE much is +already faithful — these are the GAPS. Implement serially (one branch each, code to +just-before-build, pull in + validate on the oracle one at a time). NONE of these +overlap the cycle axis or each other (different files), so they parallelize. + +- **`accuracy/axis5_sio_controller.md` — HYBRID PAD BUG ROOT-CAUSED.** P0: cmd `0x43` + must transmit the LIVE button frame (like `0x42`) + use the trailing byte to toggle + config; we send all-zeros → active-low → "all buttons pressed" phantom input every + `0x43` frame. P0: `0x45` status must report the LIVE analog/digital mode, not always + analog-ON (→ frame-length misparse after a flip). Fixing both lets the + `g_pad_legacy_cfg` Tomba hack be DELETED. P1: analog-mode lock (`0x44`); P2: `0x4D` + rumble echo + cycle-paced pad ACK. Validate: `sio_trace` diff on `0x43`/`0x45`. +- **`accuracy/axis5_gpu.md`.** P1: dithering ENTIRELY MISSING in both renderers (decoded + but never forwarded) — largest systematic banding divergence. P2: SW rasterizer uses + float + inclusive spans vs HW fixed-point half-open DDA w/ top-left bias → shared-edge + double-draw + 1px over-fill. P3: sprite FlipX/FlipY unmodeled; mono-rect size mask + wrong. FAITHFUL: all 4 semi-transparency modes, mask set/check, texture-window, CLUT + 4/8/15, texel-0, ×2 modulation, GP0/GP1 coverage. Validate: VRAM byte-diff + GPU test ROMs. +- **`accuracy/axis5_spu.md` — weakest subsystem.** P0: reverb ENTIRELY MISSING; P0: + volume sweeps missing + ~2× gain-scale error; P1: noise gen + pitch-modulation absent; + P1/P2: no SPU IRQ address-match, no capture buffers, SPUSTAT hardcoded; P1: host-audio- + pull timing not guest-768-cycle clock. FAITHFUL: ADPCM decode + ADSR envelope (verbatim + Beetle ports). Recommend: port Beetle PS_SPU on a guest-clock timeline. Validate: audio + sample-stream diff. +- **`accuracy/axis5_mdec.md` — explains the slightly-off FMV.** HIGH: IDCT rounding/clamp + diverges (contrast/ringing); HIGH: YUV→RGB lacks HW green-precision truncation (off-hue + greens); HIGH: dequant domain/bias/clamp wrong (DC banding). MED: 4bpp output broken + (textures, not FMV); MED: one-shot decode vs FIFO/block state machine. Validate: + per-block decode diff + live FMV pixel-diff. +- **`accuracy/axis4_memory_mmio.md`.** GOOD: I_STAT/I_MASK/DICR ack semantics CORRECT. + P1: RAM mirror wrong (we gate `<2MB`; HW aliases 2MB DRAM across an 8MB window 4× — + mirror accesses silently read 0 / drop writes). P2: `IsC` over-broadly drops scratchpad + writes (scratchpad is the D-cache, must stay addressable). P3: level-IRQ relatch; P4: + per-segment addr masking; P5: open-bus high bits on I_STAT readback. Validate: MMIO + trace diff + RAM-mirror sentinel probe. + +Cross-cutting: every validation is a native(4500)↔Beetle(4382) ring-buffer diff on the +relevant state surface — the same oracle methodology as the cycle axis. Several need the +test-ROM harness (axis 1/GPU) which is worth building early. + ## Phasing 1. NOW: cycle axis (axis 2) Stage-2 on Tomba 2 (FAITHFUL_TIMING_PLAN.md). diff --git a/accuracy/axis4_memory_mmio.md b/accuracy/axis4_memory_mmio.md new file mode 100644 index 00000000..9b0ee5fc --- /dev/null +++ b/accuracy/axis4_memory_mmio.md @@ -0,0 +1,298 @@ +# Axis 4 — Memory Map & MMIO Register Semantics: Accuracy Findings + +Scope: SEMANTIC correctness (values + side-effects) of the address-space map and +MMIO register dispatch. Cycle-timing accuracy is explicitly OUT of scope (Axis 2). + +Sources cross-referenced: +- **Our impl:** `runtime/src/memory.c` (map + dispatch), `dma.c` (DICR), `timers.c`, + `sio.c`. +- **Oracle (Beetle/mednafen-psx):** `beetle-psx/libretro.cpp` `MemRW<>()` (the memory + map), `mednafen/psx/irq.cpp` (I_STAT/I_MASK), `mednafen/psx/dma.cpp` (DICR), + `mednafen/psx/timer.cpp`, `mednafen/psx/cpu.cpp` (`addr_mask[8]`). +- **psx-spx** "Memory Map" / "I/O Map" / "Interrupts" / "Timers". + +All file:line citations are against the trees above as of 2026-06-26. + +--- + +## 1. What our implementation does + +### 1.1 Address-space translation (`memory.c`) +Every access computes `phys = addr & 0x1FFFFFFF` unconditionally +(`psx_read_word` L695, `psx_read_half` L781, `psx_read_byte` L837, and the write +mirrors), with ONE pre-translation special-case: `0xFFFE0130` (KSEG2 cache/BIU +control) handled before masking (`psx_read_word` L693, `psx_write_word` L734). +This collapses KUSEG (`0x00..`), KSEG0 (`0x80..`), KSEG1 (`0xA0..`) onto the same +29-bit physical space — the intended PS1 mirroring. + +### 1.2 Physical region decode (after masking) +- `phys < RAM_SIZE` (0x00000000..0x001FFFFF, **2 MB**) → `ram[]` (L697, L742). +- `0x1F000000..0x1F7FFFFF` → Expansion 1; reads return `0xFFFFFFFF`/`0xFFFF`/`0xFF` + (open bus), writes ignored (L708, L786, L842, L754). +- `0x1F800000..0x1F8003FF` (**1 KB**) → `scratchpad[]` (L711, L755). Reads/writes + are NOT gated by IsC; see §2.6. +- `0x1F801000..0x1F803FFF` → MMIO dispatch (`mmio_read*`/`mmio_write*`). +- `0x1FC00000..0x1FC7FFFF` (**512 KB**) → `bios_rom[]` read-only (L721, writes + silently dropped L773). +- Anything else → `unmapped_fatal()` which **returns 0** and does not abort + (L364-370, deliberately tolerant for BIOS RAM-size/expansion probing). + +### 1.3 MMIO dispatch (`mmio_read32/16/8`, `mmio_write32/16/8`) +Width-specific dispatchers route by address range: +- 0x1F801000..0x1F80103C — memory-control regs, raw `mem_ctrl[16]` array (L377, + L433); 0x1F801060 — `ram_size_reg` (L385, L443). +- 0x1F801040..0x1F80105F — `sio_read`/`sio_write` (L381, L438). +- 0x1F801070 / 0x1F801074 — I_STAT / I_MASK (`interrupt_write_stat_masked`, + `interrupt_write_mask_masked`). +- 0x1F801080..0x1F8010FF — DMA (`dma_read`/`dma_write[_masked]`). +- 0x1F801100..0x1F80112F — timers. +- 0x1F801800..0x1F801803 — CDROM; 0x1F801810/14 — GPU; 0x1F801820/24 — MDEC; + 0x1F801C00..0x1F801FFF — SPU; 0x1F802000..0x1F802FFF — EXP2/POST (ignored). +- Unhandled MMIO addr → `mmio_fatal()` which **aborts the process** + (`psx_fatal_halt`, L343-351). + +### 1.4 Interrupt-controller semantics (`memory.c`) +- I_STAT write: `interrupt_write_stat_masked(val, mask)` → + `i_stat = (i_stat & ~ack_mask) | (i_stat & val & ack_mask)` with + `ack_mask = mask & 0x7FF` (L266-271). Within the addressed lanes this is + `i_stat &= val`. **This is the correct write-acknowledge model** (write-0-to-a-bit + clears it; write-1 keeps it). Subsystems set bits with `i_stat |= (1<Read(A & 0x1FFFFF)` (L814) — the 2 MB DRAM + **mirrors 4 times** across 0x000000..0x7FFFFF in each KUSEG/KSEG segment. (Retail + consoles have 2 MB; the upper 6 MB are aliases of the same chips.) +- **Ours:** `memory.c` L697/742 gate on `phys < RAM_SIZE` (`RAM_SIZE = 2 MB`, + L25). Physical 0x00200000..0x007FFFFF falls through every region test to + `unmapped_fatal()` → **returns 0 on read, silently drops writes** (L728/777). +- **Impact:** A guest (or relocated kernel code) touching a RAM mirror at + 0x00200000+ / 0x80200000+ reads 0 instead of the aliased DRAM byte, and its + write is lost. Most code stays within 2 MB, but mirror access is legal and the + BIOS RAM-size routine writes a sentinel at an aliased offset to probe installed + RAM. Divergence is silent (no fatal), so it can corrupt state without a crash. + +### 2.2 Address masking is unconditional `& 0x1FFFFFFF`; hardware masks per-segment — DIVERGENCE (mostly benign, one real edge) +- **Beetle:** `cpu.cpp` L69-76 `addr_mask[8]` indexed by `A >> 29`: + KUSEG idx0-3 = `0xFFFFFFFF` (no strip), KSEG0 idx4 = `0x7FFFFFFF`, + KSEG1 idx5 = `0x1FFFFFFF`, KSEG2 idx6-7 = `0xFFFFFFFF`. The masked address is + then region-decoded; KSEG2 `0xFFFE0130` survives unmasked to reach the BIU. +- **Ours:** unconditional `addr & 0x1FFFFFFF` for all segments except the single + `0xFFFE0130` special-case. +- **Impact:** + - KSEG0/KSEG1/KUSEG-low → equivalent in practice (after stripping, both reach + the same 29-bit phys). + - **KSEG2 (0xC0000000..0xFFFFFFFF):** hardware does NOT strip the high bits; + only `0xFFFE0130` is a real register, everything else is "unknown + write/read=0" in Beetle (L1093-1101). Ours masks 0xC0000000 down into the + 0x00.. RAM/MMIO space and may hit RAM or a device. Any KSEG2 access other than + the BIU is mis-decoded. (Low practical exposure — games rarely touch KSEG2 — + but it is incorrect.) + - KUSEG addresses ≥ 0x20000000 on hardware would be left unmasked (and become + "unknown"); ours masks them into a mirror. + +### 2.3 I_STAT / I_MASK upper bits read back differently — DIVERGENCE +- **Beetle:** `irq.cpp` IRQ_Read L101-114 returns `Status` (or `Mask`) **OR'd with + `0x1F800000`** before the lane shift — i.e. the unused upper bits of the 32-bit + register read back as **open-bus/garbage `0x1F80xxxx`**, not 0. `Status`/`Mask` + are stored as `uint16_t` (L22-24), so bits 11-15 are also live storage. +- **Ours:** I_STAT/I_MASK masked to `0x7FF` on both write and read; bits 11+ read + back as **0**, and bits 11-15 cannot be stored at all. +- **Impact:** A guest reading I_STAT/I_MASK as a full word and comparing/masking + the high bits sees `0x00000xxx` from us vs `0x1F80_0xxx` from hardware. Real PS1 + has 11 IRQ sources (bits 0-10), so the *functional* IRQ bits are correct, but + any code that ANDs the raw register against a wide mask diverges. Low-risk but a + faithfulness gap; trivially fixable by OR-ing the open-bus constant on read. + +### 2.4 I_STAT write-ack uses `&val` but ignores Beetle's level-reassert nuance — MINOR +- Our ack (`i_stat &= val` within lanes) is correct write-0-clears semantics and + matches `irq.cpp` IRQ_Write L93 (`Status &= V`). **No discrepancy in the ack + itself.** The architectural difference is in *re-latching*: Beetle drives IRQ + lines through `IRQ_Assert(which, level)` (irq.cpp L57-79) with edge detection + `Status |= (old_Asserted ^ Asserted) & Asserted`, i.e. a **level-triggered** + source that re-latches Status on a 0→1 line transition even after the guest + acks. Our subsystems set `i_stat |= bit` only at discrete fire points + (timers `timer_fire_irq`, DMA master-edge), so a device that *holds* its line + high (e.g. SPU IRQ, CDROM) will not re-raise I_STAT after the guest clears it + the way hardware does. Semantics correct for edge-style sources (timers/DMA), + potentially wrong for level-held sources. Flagged for IRQ axis cross-ref. + +### 2.5 SIO/serial port region decode merges two devices — DIVERGENCE (low impact) +- **Beetle:** splits `0x1F801040..0x1F80104F` → **FIO/JOY** (controller+memcard, + `PSX_FIO`, L958) from `0x1F801050..0x1F80105F` → **SIO** (the unused serial + port, `SIO_Read/Write`, L970). Two distinct register files. +- **Ours:** routes the entire `0x1F801040..0x1F80105F` to `sio_read`/`sio_write` + (memory.c L381/438), which only decodes 0x1040..0x104E and `return 0` / + no-op for the default (sio.c L1356, L1317). So 0x1050..0x105F reads return 0 and + writes are dropped, whereas Beetle maintains real SIO_STAT/MODE/CTRL/BAUD there. +- **Impact:** The serial SIO at 0x1050 is essentially unused by retail games, so + near-zero practical exposure — but it is a structural inaccuracy (one handler for + two devices) and 0x1050 reads return 0 vs Beetle's modeled values. + +### 2.6 Scratchpad write under IsC (Isolate Cache) is dropped — likely WRONG +- **Ours:** `psx_write_*` early-out `if (sr_ptr && (*sr_ptr & 0x10000u)) return;` + (L738, L803, L882) drops **all** RAM *and scratchpad* writes while COP0 SR.IsC is + set. The scratchpad (0x1F800000) **is** the D-cache data array; on hardware + cache-isolated writes are exactly how the scratchpad/cache is manipulated, and + scratchpad reads/writes should still land. By dropping scratchpad writes during + IsC we can lose legitimate scratchpad stores done while the cache is isolated. +- **Beetle:** the IsC/cache-isolation handling lives in cpu.cpp's load/store path + (the D-cache-as-scratchpad model); scratchpad at 0x1F800000 is normal addressable + memory (`MDFNMP_AddRAM(1024, 0x1F800000, ScratchRAM.data8)`, libretro.cpp L2133) + and not gated off by IsC in the MemRW data path. +- **Impact:** Edge case (most IsC use is the BIOS cache-flush dance writing to RAM + addresses, which *should* be dropped), but dropping 0x1F800000-range writes under + IsC is over-broad. Needs verification against the exact BIOS cache routine before + changing. Flagged as suspect, not confirmed-wrong. + +### 2.7 `unmapped_fatal` read returns 0; hardware/Beetle return open-bus — MINOR +- **Ours:** truly-unmapped reads return 0 (L364-370, L729). Expansion-1 correctly + returns `0xFFFFFFFF` (L709), matching Beetle's `V = ~0U` (L1056). +- **Beetle:** unknown reads set `V = 0` too (L1099) — so for the *unknown* class we + agree. The divergence is only the RAM-mirror hole (§2.1) reaching this path. + +### 2.8 EXP2/POST and memory-control registers are write-through with no masking — MINOR +- **Ours:** `mem_ctrl[16]` stores raw values (L434); EXP2 0x1F802000..2FFF returns + 0 / ignores (L413, L484). +- **Beetle:** memory-control (`SysControl`) applies per-register **write masks** + `SysControl_Mask[9]` and **read OR** `SysControl_OR[9]` (libretro.cpp L561-567, + L948/952) — e.g. EXP1 base reads back with `0x1F000000` forced, writes masked to + `0x00FFFFFF`. Ours returns the raw last-written value with no mask/OR. +- **Impact:** A guest that reads back a memory-control register and checks the + fixed high byte (BIOS does light-touch validation here) sees our raw value vs + Beetle's masked/OR'd value. Low practical impact; the BIOS mostly writes known + constants. Faithfulness gap. + +### 2.9 Timer mode-read clears flags (correct) but read-count has a nondeterministic hack — MIXED +- Mode read clearing bits 11/12 (`MODE_TARGET_FLAG | MODE_OVERFLOW_FLAG`, + timers.c L296-298) **matches** psx-spx ("Cleared on mode/status read") and Beetle. +- **But** count read (`case 0x00`) under `!PSX_ENABLE_BLOCK_CYCLES` does + `counter += 8` (or +1) as a "simulate continuous counting" hack (L280-291) — this + injects a synthetic value not derived from the guest cycle counter, so the count + read is **nondeterministic vs Beetle**, which derives the counter from + `TIMER_Update(timestamp)` (timer.cpp). This is the Axis-2 cycle issue surfacing in + a semantic read; flagged because it changes the *value* a guest observes, not just + timing. (In `PSX_ENABLE_BLOCK_CYCLES` builds this hack is compiled out.) +- Timer `mode` storage masks writes to `& 0x03FF` (L318) and force-sets + `MODE_IRQ_REQUEST` (bit10). psx-spx: bit10 (IRQ request, inverted) reads 1 when no + IRQ pending — consistent. Acceptable. + +### 2.10 24-bit (`lwl`/`lwr` partial) access not modeled in MMIO path — INFORMATIONAL +- Beetle has explicit `Access24` paths (`PSX_MemRead24`/`Write24`, libretro.cpp + L1114/L1142) for RAM and ROM. Ours has only 8/16/32 entry points + (`psx_read_word/half/byte`); 24-bit unaligned accesses are synthesized by the + recompiler as byte/half combos against RAM, so the MMIO path never sees a 24-bit + access. Not a divergence for MMIO (no real device is accessed 24-bit), noted for + completeness. + +--- + +## 3. Prioritized fix list + +**P1 — RAM mirror (§2.1).** Change the RAM decode from `phys < RAM_SIZE` to the +hardware 8 MB window with 2 MB wrap: gate on `phys < 0x00800000` and index +`ram[phys & 0x1FFFFF]` in all six `psx_read_*`/`psx_write_*` paths. This is the +highest-impact correctness fix: it is silent today (returns 0 / drops writes) so it +can corrupt state with no crash, and it is the only path that currently routes a +legal RAM access to `unmapped_fatal`. Faithful, class-level, benefits every title. + +**P2 — IsC scratchpad gating (§2.6).** Confirm against the BIOS cache-flush routine +(docs/psx_bios_disasm.txt + Ghidra), then narrow the IsC write-drop to RAM +(`phys < 0x00800000`) and STOP dropping scratchpad (0x1F800000) writes. Verify no +boot regression via the oracle before/after. + +**P3 — Level-triggered IRQ re-latch (§2.4).** Cross-reference with the IRQ axis: +route SPU/CDROM (line-held) IRQ sources through an `IRQ_Assert(which, level)`-style +edge detector so they re-raise I_STAT correctly after a guest ack, matching +irq.cpp L57-79. Do NOT regress the edge-style timers/DMA path. + +**P4 — KSEG2 / per-segment masking (§2.2).** Replace the blanket `& 0x1FFFFFFF` +with Beetle's `addr_mask[A >> 29]` table so KSEG2 (except 0xFFFE0130) is treated as +unknown rather than mis-decoded into RAM/MMIO. Low practical exposure but removes a +whole class of silent mis-decodes. + +**P5 — I_STAT/I_MASK open-bus high bits (§2.3).** On read, OR the unused upper bits +with the `0x1F800000` open-bus constant (and widen storage to 16 bits if any title +is found relying on bits 11-15). Faithfulness; trivial. + +**P6 — Memory-control mask/OR (§2.8) + SIO/serial split (§2.5).** Apply +`SysControl_Mask`/`SysControl_OR` to the memory-control registers, and split the +0x1F801050..105F serial-SIO into its own (even if minimal) register file rather than +folding it into the JOY handler. Lowest priority — near-zero game exposure. + +**Out of scope (Axis 2, noted):** timer count-read `+8` hack (§2.9) and the +RAM-read wait-cycle wrappers (§1.6) are cycle-axis items; they alter observed values +only because counters are not yet derived on-demand from the global guest-cycle +counter. Fold into the FAITHFUL_TIMING_PLAN timer-on-demand work, not here. + +--- + +## 4. Validation method (ring-buffer-first, two-process) + +Per CLAUDE.md §16: query the always-on rings on both ports, never arm-and-capture. +psx-runtime = port 4370 (BIOS) / 4500 (build-t2 Tomba2); psx-beetle = port 4380 +(per the Tomba2 memory, beetle dev port 4382). Adjust ports to the live binaries. + +1. **MMIO read/write trace diff.** The MMIO trace ring already exists + (`debug_server_trace_mmio_read`/`_write`, wired through `mmio_read*`/`mmio_write*` + in memory.c). Add the matching always-on ring on the Beetle side (hook + `PSX_MemRead*/PSX_MemWrite*` in libretro.cpp, or `MemRW` directly) keyed by + address + width + value. Free-run both to the same scripted input, then query + `[start_idx, end_idx]` on each and diff the (addr, width, value) tuples. Focus + ranges: 0x1F801070/74 (I_STAT/I_MASK), 0x1F8010F4 (DICR), 0x1F801100-112F + (timers). A value mismatch at the same logical access = a semantic bug. + +2. **RAM-mirror diff probe.** Specifically targets §2.1: write a sentinel to + 0x00200000 (a mirror offset) via the guest on both cores, then read 0x00000000 + on both. Hardware/Beetle: the sentinel appears at 0x00000000 (aliased); ours: + 0x00000000 unchanged and the 0x00200000 write was dropped. This is the + pass/fail oracle for the P1 fix. Use the existing `read_ram`/`emu_read_ram` + commands; no new tooling needed beyond the mirror-offset read. + +3. **I_STAT/I_MASK readback diff.** Read 0x1F801070 / 0x1F801074 as a full word on + both cores at a quiescent point; compare. Expected pre-fix divergence: ours + `0x0000_0xxx` vs Beetle `0x1F80_0xxx` (validates §2.3 / P5). + +4. **DICR ack pinning test.** Drive a DMA completion, read DICR (expect bit31 + + channel flag set), write-1-to-clear the channel flag, re-read. Compare the + computed bit31 transition and the I_STAT bit3 latch behavior against Beetle's + `DMAIntControl`/`DMAIntStatus`/`RecalcIRQOut` (dma.cpp L86-95, L577-583). This is + a full pinning test, not a one-shot — capture from the rings on both sides. + +5. **Regression gate.** Run the diff against BIOS boot + Tomba/Tomba2 title + + gameplay on both cores after each P-fix; the MMIO trace diff must not introduce + NEW divergences. Per global rule: regen all banks / run all tests / build all + configs when the memory.c change lands (it is a runtime-only change — no regen — + but rebuild psx-runtime AND psx-beetle and re-run the full diff suite). + +--- + +### Summary of correctness verdicts +- I_STAT write-ack (write-0-clears): **CORRECT** (matches `Status &= V`). +- I_MASK: **CORRECT** (functional 11 bits); high-bit readback **WRONG** (reads 0 + not open-bus `0x1F80xxxx`). +- DICR write-1-to-clear + computed bit31 + master-edge I_STAT latch: **CORRECT**. +- Timer mode-read flag clear: **CORRECT**; count-read `+8`: cycle-axis nondeterminism. +- Byte/halfword lane handling for 32-bit regs: **CORRECT**. +- RAM mirroring (2 MB only, no 4× alias): **WRONG (P1)**. +- Per-segment address masking (KSEG2): **WRONG but low-exposure (P4)**. +- IsC dropping scratchpad writes: **SUSPECT (P2)**. diff --git a/accuracy/axis5_gpu.md b/accuracy/axis5_gpu.md new file mode 100644 index 00000000..d1428d04 --- /dev/null +++ b/accuracy/axis5_gpu.md @@ -0,0 +1,308 @@ +# Axis 5 — GPU Accuracy Findings & Fix Plan + +Cross-reference of the psxrecomp GPU implementation against the in-tree Beetle/ +Mednafen-PSX oracle (`beetle-psx/mednafen/psx/gpu*.cpp`, the byte-exact SW +renderer) and nocash psx-spx. READ-ONLY research; no source modified. + +Oracle note: in Beetle the **software renderer is the hardware oracle**; the +`rsx_intf_*` GL/Vulkan paths are separate approximations and are NOT the +comparison target. At `upscale_shift==0`, no PGXP, `line_render_mode==0`, +Beetle SW = pure mednafen-PSX behavior. + +Our files: +- `runtime/src/gpu.c` — GPU register/command front end (GPUSTAT, GP0/GP1 + decode, VRAM transfers, display config, widescreen machinery). +- `runtime/src/gpu_sw_renderer.c` — software rasterizer (the default/oracle- + comparable backend; also drives 24bpp display decode for the GL path). +- `runtime/src/gpu_gl_renderer.c` — OpenGL backend (FBO RGBA8 hi-res + + R16UI native mirror + stencil mask). + +--- + +## 1. What our implementation does + +### 1.1 Front end — `gpu.c` +- **GPUSTAT** assembled bit-for-bit (`gpu_read_gpustat`, gpu.c:1216-1290): + bits 0-3 texpage X, 4 texpage Y, 5-6 abr, 7-8 texmode, 9 dither, 10 dfe, + 11 mask-set, 12 mask-check, 13 interlace field, 14 reverse, 15 tex-disable, + 16-22 display mode, 23 display-off, 24 IRQ1, 25 DMA-req, 26 cmd-ready, + 27 VRAM-send-ready, 28 DMA-block-ready, 29-30 DMA dir, 31 LCF. +- **GP0 command set** fully decoded (gpu.c:1585-2620): mono/shaded/textured + tri+quad (0x20-0x3F), mono/shaded line + polyline (0x40-0x5F), variable + rect / 1x1 dot / 8x8 / 16x16 sprites (0x60-0x7F), FBCopy/FBWrite/FBRead + (0x80-0xDF), and env regs E1-E6. Word counts in `gp0_command_word_count` + (gpu.c:2285). +- **GP1** reset/display-config commands; **GPUREAD** packs two 16-bit texels + per word from the active VRAM→CPU transfer (gpu.c:1294-1329). +- Quads are split into two triangles with winding **(0,1,2)+(2,1,3)** + (gpu.c:1610-1611, 1663-1665). Textured quads detect an axis-aligned + rectangle and route to the faster rect rasterizer (gpu.c:1755-1778). +- Large body of **widescreen** machinery (squash / native-wide / MMX6 tile + reveal) layered on top; identity at 4:3 by design. + +### 1.2 Software rasterizer — `gpu_sw_renderer.c` +- 1024×512 `uint16_t` VRAM (15-bit + mask bit). Optional SSAA mirror `g_hr` + and a native-wide compositor surface. +- **Semi-transparency**: all 4 modes in `blend_pixels` (sw:211-246). Textured + prims blend only when texel bit15 set; untextured always blend when the + command's semi flag is set (`put_opaque`/`put_textured`, sw:253-313). +- **Mask bit**: set (force bit15) + check (skip if dest bit15) in the pixel + writers and in `sw_copy_rect` (sw:260,286,654-657). Fill ignores mask. +- **Texture window**: psx-spx AND/OR formula (sw:324-327). CLUT 4/8/15-bpp + fetch (`texel_fetch`, sw:319-357). Texel `0x0000` = transparent (skipped). +- **Triangles**: scanline rasterizer with **float** edge + attribute + interpolation (`raster_flat_triangle` sw:682, `raster_gouraud_triangle` + sw:746, textured sw:864, shaded-textured sw:989). Color interp in 5-bit + space. Optional bilinear (`texel_fetch_bilinear`, opacity-weighted, Beetle- + style) — opt-in, non-hardware. +- **Lines**: Bresenham (sw:1382, 1403), both endpoints plotted. +- **Display**: 15-bit→ARGB with 5→8 bit replication `(c<<3)|(c>>2)` + (`rgb555_to_argb`, sw:1503). + +### 1.3 GL backend — `gpu_gl_renderer.c` +- Authoritative color = RGBA8 hi-res FBO; native R16UI mirror holds raw 1555 + for texture sampling + CPU readback; 8-bit stencil mirrors bit15. +- **Semi-transparency**: all 4 modes via fixed-function blend + (`apply_psx_blend`, gl:697-716; mode 2 = `GL_FUNC_REVERSE_SUBTRACT`). + Correct STP-bit gating via two-pass discard for textured prims. +- **Mask bit**: implemented via stencil + alpha channel (set + check). +- **Texture**: in-shader CLUT against VRAM usampler; texture window exact; + texel-0 transparent honored; nearest default + opt-in bilinear. +- **CPU readback / GPUREAD**: PACK shader → R16UI → glReadPixels; 15-bit exact. +- **24bpp display**: delegated to the SW decode path. + +--- + +## 2. Specific discrepancies vs Beetle + psx-spx + +Ordered roughly by player-visible impact. Citations: `gpu_*.c:line` (ours) +vs `beetle-psx/mednafen/psx/:line` (oracle). + +### D1 — Dithering is completely absent (both renderers) — and the enable bit is decoded but dropped +- **Oracle**: 4×4 signed dither matrix `{{-4,0,-3,1},{2,-2,3,-1},{-3,1,-4,0}, + {3,-1,2,-2}}` (gpu.cpp:79-85), applied (then `>>3` + clamp to 5-bit) to + Gouraud polygons, modulated-textured polygons, and lines whenever + `DitherEnabled` (`dtd` = GP0 E1 bit 9) (gpu.cpp:644-665; gpu_polygon.cpp: + 184-211; gpu_line.cpp:145-154). Sprites & raw copies are never dithered. +- **Ours**: `dither_enabled` is parsed from GP0(E1) (gpu.c:2071) and surfaced + in GPUSTAT bit 9 (gpu.c:1244) **but is never forwarded to either renderer.** + `gpu_sw_renderer.c` has no dither term anywhere; Gouraud/modulated colors + are truncated `>>3` with no matrix add. The GL backend likewise has no + dither (confirmed; documented "the software path doesn't dither either"). +- **Effect**: visible color banding on Gouraud-shaded surfaces and gradients + (skies, lighting ramps, shadows) versus hardware; every dithered frame + byte-diffs against the oracle. This is the single largest systematic SW/GL + vs hardware divergence. + +### D2 — SW triangle rasterizer uses float interpolation + inclusive spans, not the PSX fixed-point half-open fill rule +- **Oracle**: 64-bit fixed-point edges (`MakePolyXFP` adds `(1<<32) - (1<<11)` + — the sub-pixel bias that yields the PSX top-left rule; gpu_polygon.cpp: + 24-43). X span is **half-open** `[x_start, x_bound)` (the `do { } while(--w>0)` + loop, gpu_polygon.cpp:165-218), right clip inclusive of ClipX1 + (`(x+w) > clipx1+1`, :143-144). Y spans are top-inclusive/bottom-exclusive + (`while(yi < yb)`, :460). Zero-height tri dropped (:272). Oversize reject: + `|Δx|>=1024 || |Δy|>=512` drops the primitive (:704-728). +- **Ours**: `raster_*_triangle` sort by Y, then interpolate edges and + attributes in **float** and fill scanlines with **inclusive** bounds + `for (x = sx; x <= ex; x++)` (sw:719, 822, 935, 1079); Y loop is inclusive + `for (y = y0; y <= y2; y++)` (sw:693 etc.). No top-left bias, no half-open + right/bottom edge, no oversize rejection (only a `area2==0` degenerate + guard, sw:953). +- **Effect**: shared triangle edges are filled by **both** triangles → seam + double-draw (visible with semi-transparency: doubled-darkened/brightened + edges) and ±1px coverage drift versus hardware on every polygon edge. The + inclusive right/bottom edge is the classic "one pixel too wide" PSX + rasterizer error. Float interpolation also rounds differently than the + fixed-point DDA, shifting interior texel/color sampling. + +### D3 — Gouraud / textured attribute interpolation is per-scanline float, not the PSX divided-difference fixed-point DDA +- **Oracle**: attributes carried as fixed-point with a `+0.5` seed bias at the + core vertex (`COORD_MF_INT(v.r) + (1<<(COORD_FBS-1))`, gpu_polygon.cpp: + 311-313) and stepped by integer `AddIDeltas_DX`; read out `>>24` + (:167-169). Texture U/V identical scheme (:294-295). +- **Ours**: `float` barycentric-ish interpolation along edges then along the + span (sw:795-833 gouraud, sw:937-942 textured). UV truncated `(int)fu & 0xFF` + (sw:942). No `+0.5` seed, no fixed-point step. +- **Effect**: color/texel values drift by up to a few LSB across a polygon vs + hardware; combined with D1 (no dither) gradients look both banded and + slightly mis-sloped. Texture sampling can pick a neighboring texel at span + edges. Lower impact than D1/D2 but contributes to per-pixel diffs. + +### D4 — SW Gouraud blend math is at 5-bit (correct direction) but mode-0 average lacks the oracle's low-bit truncation semantics; GL blends at 8-bit +- **Oracle mode 0** (B/2+F/2): `((F + B) - ((F^B)&0x0421)) >> 1` — strips the + per-channel low bit before halving = floor, no rounding (gpu_common.h:28-31). + Modes 1/2/3 use branch-free saturating 15-bit math (gpu_common.h:34-69), + mode 3 = `(F>>2)&0x1CE7` for F/4. +- **Ours (SW)**: `blend_pixels` does straightforward per-channel `(b+f)>>1`, + `min(31)`, `max(0)`, `b+(f>>2)` (sw:211-246). For mode 0 `(b+f)>>1` equals + the oracle's floor only when the discarded low bits don't change the channel + sum carry — generally equal at 5-bit per channel, **but** our halving is on + the summed 5-bit channels directly (matches), so SW mode-0 is effectively + the same value. Modes 1/2/3 match. Net: SW blend is close-to-exact. +- **Ours (GL)**: blends in RGBA8 with `CONSTANT_ALPHA` 0.5/0.25 then + re-quantizes `>>3` at PACK — sub-LSB differences from hardware's 5-bit + halving (gl audit). Minor but non-zero. +- **Effect**: SW essentially faithful; GL has occasional ±1/31 channel error + on blended pixels. Low priority relative to D1/D2. + +### D5 — Mono-quad / textured-quad triangle split winding may flip shared-edge fill vs hardware scan order +- **Oracle**: draws the quad as two tris but with its own core-vertex / Y-sort + per tri and half-open edges, so the shared diagonal is covered exactly once. +- **Ours**: split `(0,1,2)` + `(2,1,3)` (gpu.c:1610-1611). Combined with the + inclusive-span bug (D2) the shared edge is drawn by both halves. For + Gouraud quads the diagonal interpolation can also differ because each half + re-sorts vertices independently in float. +- **Effect**: visible diagonal seam on semi-transparent quads; couples with D2. + Fixing D2 (half-open spans) largely resolves this. + +### D6 — Sprite (rect) flip + texture-window interaction not modeled +- **Oracle**: sprites honor GP0(E1) bits 0x3000 SpriteFlip — FlipX sets + `u_inc=-1` and forces `u|=1`; FlipY `v_inc=-1` (gpu_sprite.cpp:29-40), with + a documented low-bit-of-u quirk. Sprites clip inclusively (`ClipX1+1`, + half-open loop) and are never dithered (use the zero dither cell). +- **Ours**: textured rects/sprites step U/V forward only (sw:1207-1233, + `u + col/s`); no flip handling, and texture-window is applied via + `texel_fetch` (OK) but the sprite path computes its own uv limits for + filtering. The 8x8/16x16 sprite handlers always step forward. +- **Effect**: games using hardware sprite-flip render mirrored sprites + incorrectly (wrong texels / shifted by the `u|=1` quirk). Many 2D titles + flip sprites for character facing; this is a real correctness gap. Lower + frequency than D1/D2 but visible where used. + +### D7 — Rectangle/sprite size & coordinate clamping differs slightly +- **Oracle**: variable-rect size `w = cb & 0x3FF`, `h = (cb>>16)&0x1FF`; + coords sign-extended to 11 bits then offset added (gpu_sprite.cpp:147-173). +- **Ours mono rect**: `w = cmd & 0xFFFF` then clamp to 1023 / 511 + (gpu.c:1898-1901) — should mask `&0x3FF`/`&0x1FF` like the textured-rect + path (gpu.c:1919-1920) and the oracle, not clamp a full 16-bit value. +- **Effect**: edge case; a rect width in 0x400-0xFFFF would be clamped to 1023 + instead of wrapped/masked. Rare. Low priority. + +### D8 — GPUSTAT bit 25 (DMA-request) and bit 26/28 (ready) are constant, not FIFO-derived +- **Oracle**: bit 26 = `InCmd==NONE && DrawTimeAvail>=0 && FIFO empty`; bit 28 = + `CalcFIFOReadyBit()`; bit 25 depends on the actual FIFO state and DMA dir + (gpu.cpp:522-540, 1278-1324). +- **Ours**: bit 26 always 1, bit 28 always 1, bit 25 = 1 for dir 1/2/3 + unconditionally (gpu.c:1266-1281). We process commands instantly so there is + no FIFO occupancy to report. +- **Effect**: code that polls GPUSTAT for FIFO-full/busy before pushing more + words never stalls — usually benign (we are "infinitely fast"), but games + that gate timing on bit 26/28 transitions see no transition. Tied to the + broader timing-axis work; not a pixel-accuracy issue. + +### D9 — LCF / vblank pacing via a poll-count fallback, not cycle-derived +- **Ours**: `gpu_read_gpustat` flips LCF and raises VBLANK after 1000 polls + (gpu.c:1228-1236, `GPUSTAT_POLL_VBLANK_THRESHOLD`). This is an approximation + to break BIOS spin loops, separate from the cycle-paced vblank. +- **Effect**: interlace-field bit 31 and vblank timing are not hardware-faithful + (a heuristic). Belongs to the timing axis (FAITHFUL_TIMING_PLAN.md), flagged + here for completeness. + +### D10 — Display path: 5→8 bit expansion differs from oracle scanout +- **Oracle scanout** (15bpp): `(c & 0x1F) << 3` — low bits zero, NO replication + (gpu.cpp:1373-1383). 24bpp packs 3 bytes across two words (:1351-1369). +- **Ours**: `rgb555_to_argb` replicates `(c5<<3)|(c5>>2)` (sw:1503-1509). +- **Effect**: our screen is marginally brighter in the low bits than the + oracle's exact scanout. This is a *present-time* cosmetic choice (arguably + better-looking) but means present-time RGB diffs vs the oracle's framebuffer + output are non-zero. VRAM contents themselves are unaffected. Low priority; + document as an intentional present-time deviation or gate behind the existing + `PSX_SCREEN` LUT. + +### D11 — No oversize-primitive rejection +- **Oracle**: drops any tri/line whose vertex span exceeds 1024×512 + (gpu_polygon.cpp:704-728; gpu_line.cpp:222-226). +- **Ours**: no such guard; a malformed/huge primitive rasterizes fully (slow, + and can scribble across VRAM where hardware would have dropped it). +- **Effect**: rare; mainly a robustness/timing divergence. Low priority. + +### Items that are FAITHFUL (no action) +- Semi-transparency 4-mode selection + STP-bit gating (SW and GL). +- Mask set/check including in copies (SW and GL). +- Texture window AND/OR formula; CLUT 4/8/15-bpp addressing; texel-0 + transparency. +- Color modulation ×2-around-0x80 rule. +- GP0/GP1 command coverage and word counts match the oracle table. +- GL VRAM readback is 15-bit exact. + +--- + +## 3. Prioritized fix list (player-visible impact first) + +**P1 — Implement dithering (D1).** Add the 4×4 signed matrix and the +`dtd`-gated apply (Gouraud + modulated-textured + lines; not sprites/copies) +to BOTH renderers. Plumb `dither_enabled` from `gp0_exec_draw_mode` into +`gr_set_*` (it is already decoded; just unused). SW: add `matrix[y&3][x&3]` +before the `>>3` truncation in the gouraud/textured span writers. GL: add the +matrix in the fragment shader before the 5-bit pack. Highest visual payoff; +removes the largest systematic oracle diff. + +**P2 — Fix SW rasterizer fill convention (D2 + D5 + D3).** Replace the float +scanline rasterizer with the PSX fixed-point edge DDA: half-open X spans +(`x < x_bound`), top-inclusive/bottom-exclusive Y, the `MakePolyXFP` +`-(1<<11)` top-left bias, and the `+0.5` attribute seed. This removes +shared-edge double-draw (visible on semi-transparent quads/triangles), +the +1px right/bottom over-fill, and brings interior sampling in line with the +oracle. This is the core "faithful rasterizer" item — biggest correctness win +after dithering. (GL coverage stays ±1px by nature of the HW rasterizer; +accept as documented, or move to a compute/SW-on-GPU rasterizer later.) + +**P3 — Sprite flip + exact sprite stepping (D6).** Honor GP0(E1) FlipX/FlipY +(`u_inc=±1`, the `u|=1` FlipX quirk, `v_inc=±1`) in the SW sprite/rect path and +GL. Needed for correct 2D character facing in sprite-heavy titles. + +**P4 — Oversize-primitive rejection (D11).** Add the `|Δx|>=1024 || |Δy|>=512` +drop in the SW (and GL) primitive entry points to match the oracle and avoid +runaway VRAM writes. + +**P5 — GPUSTAT FIFO/ready bits + LCF pacing (D8, D9).** Derive bits 25/26/28 +and LCF/vblank from the cycle/FIFO model rather than constants/poll-count. +Cross-listed with the timing axis (FAITHFUL_TIMING_PLAN.md); do as part of that +front, not in isolation. + +**P6 — Cleanups (D7, D4-GL, D10).** Mask mono-rect size to `0x3FF`/`0x1FF` +(D7). Do GL mode-0/3 halving at 5-bit to drop the sub-LSB blend error (D4). +Decide D10 (5→8 expansion): either match the oracle's `<<3` scanout for +diffability or keep replication as an explicit present-time option. + +--- + +## 4. Validation method + +Two independent processes share the JSON debug protocol (CLAUDE.md §16): +`psx-runtime` (native, TCP **4500** on this wt/tomba2 worktree per build-t2; +4370 on master) and `psx-beetle` (oracle, TCP **4382**/4380). Validate per the +ring-buffer-first rule — query always-on state, never arm-then-capture. + +1. **VRAM byte-diff (primary).** Drive both processes to the same scene + (same input script, same frame count). Pull VRAM from each via + `vram_transfer_out` / `emu_read_ram` (or `glb_vram_diff` / + `gl_renderer_fbo_peek` on the GL side) and byte-compare the 1024×512×2 + region. A faithful renderer is byte-identical to the Beetle SW oracle in + the drawn region (mask bit included). Expect D1 (dither) and D2 (edges) to + dominate the initial diff map; success = those regions collapse to zero + after P1/P2. Diff per-primitive by single-stepping the GP0 stream if a + whole-frame diff is too noisy. + +2. **Targeted GPU test ROMs.** Run hardware GPU conformance ROMs on both: + - Dithering on/off gradient tests (validates D1 matrix values + dtd gate). + - Triangle/quad edge & fill-rule tests (validates D2 half-open + top-left). + - Semi-transparency 4-mode swatches over a known backdrop (validates blend + math, especially mode-0 floor and mode-2 clamp). + - Mask-bit set/check tests (draw, set mask, overdraw, verify rejection). + - Texture-window wrap + CLUT 4/8/15-bpp sampling grids. + - Sprite FlipX/FlipY tests (validates D6). + Byte-diff the resulting VRAM against Beetle for each ROM. + +3. **Per-primitive oracle agreement harness.** Extend the existing GP0 capture + (shaded-quad capture already exists, gpu.c:1632) into a general "replay one + GP0 command into a scratch VRAM region on both backends and diff" tool, so + each rule (edge, dither, blend, mask, flip) is pinned by a minimal + regression case rather than a whole-scene screenshot. Per Rule 15, fix the + tool if a diff can't be localized — do not infer correctness from two + renderers agreeing. + +4. **Regression breadth.** After each fix, re-run the diff harness across + Tomba, Tomba2, MMX6 (and the BIOS shell) since the renderer is shared + framework code; a faithful-rasterizer change must be validated on every + title, not just the one under test.