mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
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:
@@ -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).
|
||||
|
||||
@@ -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)**.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user