Files
psxrecomp/accuracy/axis5_gpu.md
Matthew Stanley 85876ec7cb gpu: textured polygons update draw-mode state from their texpage word
Real GPU copies the texpage attribute word carried inside every textured
polygon (GP0 0x24-0x3F, texture bit set) into the draw-mode state
(GPUSTAT bits 0-8), exactly like GP0(E1) bits 0-8 — Beetle runs
SetTPage(CB[4 + ((cc>>4)&1)] >> 16) on every such command
(beetle-psx mednafen/psx/gpu.cpp:1070). So the polygon's OWN word (not
the last E1) selects its semi-transparency mode, and later rect/sprite
prims (which carry no texpage word) consume the state the polygon left
behind. We fetched texels from the prim's page but blended with the
stale E1 global, and never updated the page state rects depend on.

Effect: additive glows blended with whatever mode the last E1 happened
to set, flipping bright light halos into dark/black blobs intermittently
(OT bucket order decides which prim precedes, hence camera/distance
dependence). Proven on Ape Escape attract frame 60052: nine op-0x2E
firework quads at the artifact coordinates carry prim mode 1 (B+F) while
the last E1 set mode 0 (B/2+F/2) — rendered as dark maroon blobs;
Beetle renders the same beat as bright fireworks. Post-fix sweep shows
siren-hat glow halos, MONKEY PARK marquee bloom, and lava glow restored
across two full attract cycles.

Poly words do not touch dither/draw-to-display (E1-only bits 9-10) nor
texture-disable (bit 11 latches only under GP1(09h) allow-change).
All backends inherit the fix via gr_set_semi_transparency /
current_texpage(). Faithful-core fix for all titles; documented as D12
in accuracy/axis5_gpu.md (the audit had marked semi-transparency
FAITHFUL — the modes were, their state source was not).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:44:29 -07:00

328 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
### D12 — Textured-polygon texpage attribute did not update GPU draw-mode state — FIXED 2026-07-05
- **Oracle**: every textured polygon's texpage word is copied into the GPU
draw-mode state before drawing — `SetTPage(CB[4 + ((cc>>4)&1)] >> 16)` runs
for all GP0 0x20-0x3F with the texture bit (gpu.cpp:1070-1072). The poly's
own word (not the last E1) selects its semi-transparency mode, and later
rect/sprite prims (no texpage word) consume the state the poly left behind.
Poly words cannot alter dither/draw-to-display (E1-only bits 9-10) nor
texture-disable (bit 11 latches only under GP1(09h) TexDisableAllowChange).
- **Ours (pre-fix)**: textured polys fetched texels from their own tpage but
blended with the stale E1-set global `semi_transparency`, and never updated
`texpage_x/y/colors` for subsequent rects.
- **Effect**: additive glows (mode 1 in the prim word) blended with whatever
mode the last E1 set — Ape Escape's headlamp halos / siren flares / firework
bursts rendered as dark blobs or black starbursts, intermittently (depends
on OT bucket order, hence camera/distance). Proven on Ape attract frame
60052: nine op-0x2E quads at the artifact coords carried prim mode 1 vs E1
mode 0. Fixed in `gpu.c set_tpage_from_poly()`, called by all four textured
poly handlers; all backends inherit via `gr_set_semi_transparency`.
### 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.