Files
psxrecomp/accuracy/axis5_gpu.md
T
Matthew Stanley 1d7ae91468 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>
2026-06-26 20:01:59 -07:00

18 KiB
Raw Blame History

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.