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>
18 KiB
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_tVRAM (15-bit + mask bit). Optional SSAA mirrorg_hrand 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). Texel0x0000= transparent (skipped). - Triangles: scanline rasterizer with float edge + attribute
interpolation (
raster_flat_trianglesw:682,raster_gouraud_trianglesw: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 wheneverDitherEnabled(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_enabledis 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.chas no dither term anywhere; Gouraud/modulated colors are truncated>>3with 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 (
MakePolyXFPadds(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)(thedo { } 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|>=512drops the primitive (:704-728). - Ours:
raster_*_trianglesort by Y, then interpolate edges and attributes in float and fill scanlines with inclusive boundsfor (x = sx; x <= ex; x++)(sw:719, 822, 935, 1079); Y loop is inclusivefor (y = y0; y <= y2; y++)(sw:693 etc.). No top-left bias, no half-open right/bottom edge, no oversize rejection (only aarea2==0degenerate 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.5seed bias at the core vertex (COORD_MF_INT(v.r) + (1<<(COORD_FBS-1)), gpu_polygon.cpp: 311-313) and stepped by integerAddIDeltas_DX; read out>>24(:167-169). Texture U/V identical scheme (:294-295). - Ours:
floatbarycentric-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.5seed, 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)&0x1CE7for F/4. - Ours (SW):
blend_pixelsdoes straightforward per-channel(b+f)>>1,min(31),max(0),b+(f>>2)(sw:211-246). For mode 0(b+f)>>1equals 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_ALPHA0.5/0.25 then re-quantizes>>3at 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=-1and forcesu|=1; FlipYv_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 viatexel_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|=1quirk). 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 & 0xFFFFthen clamp to 1023 / 511 (gpu.c:1898-1901) — should mask&0x3FF/&0x1FFlike 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_gpustatflips 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_argbreplicates(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_SCREENLUT.
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.
-
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(orglb_vram_diff/gl_renderer_fbo_peekon 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. -
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.
-
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.
-
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.