docs(accuracy): consolidate 5-axis research findings into burndown

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 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-26 20:01:59 -07:00
parent edf3641a12
commit 1d7ae91468
3 changed files with 648 additions and 0 deletions
+42
View File
@@ -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).
+298
View File
@@ -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<<n)`.
- I_MASK write: `i_mask = ((i_mask & ~mask) | (val & mask)) & 0x7FF` (L275).
- I_STAT/I_MASK are kept as **11-bit** values (`& 0x7FF`).
- Byte/halfword stores are handled lane-correctly via shift+mask (L543-551,
L632-641).
### 1.5 DMA DICR (`dma.c`)
- DICR write (`dma_write_masked` L920-936): `DICR_WRITE_MASK = 0x00FF807F` for
RW bits; `DICR_RESET_MASK = 0x7F000000` (bits 24-30) handled
**write-1-to-clear** (`dicr &= ~(val & reset_mask)`). Bit 31 is computed on read
(`dicr_master_flag`, L182). I_STAT bit3 latched only on the master-flag 0→1 edge
(`raise_dma_irq_on_master_edge`, L191-198). **This matches hardware/Beetle.**
### 1.6 Guest-read cycle wrappers
`psx_guest_read_*` add +6 wait cycles for main-RAM targets (Axis 2 territory,
noted for completeness, L856-879).
---
## 2. Discrepancies vs Beetle + psx-spx
### 2.1 RAM mirror window is 2 MB, hardware decodes 8 MB (4× mirror) — REAL DIVERGENCE
- **Beetle:** `libretro.cpp` MemRW L789 decodes `if (A < 0x00800000)` (an **8 MB**
window) then indexes `MainRAM->Read<T>(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)**.
+308
View File
@@ -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/<file>: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.