Files
psxrecomp/docs/ENHANCEMENTS.md

94 KiB
Raw Permalink Blame History

PSXRecomp — Enhancement-Tier Work (framework-wide)

Faithfulness is the foundation (CLAUDE.md Rule -1); this file tracks the enhancement layer built on top of it: renderers beyond the software reference, widescreen, load acceleration, etc. Per-game enhancement ideas live in each game repo's ENHANCEMENTS.md. Framework bugs referenced here are tracked in Beads (epic beads-eio.3); older ones are still written up in the game repos' ISSUES.md where they were first found.


R1 — OpenGL renderer (2nd backend): PLAYABLE, flicker root-caused + fixed

Status as of 2026-07-03 (branch feat/renderer-finish, working tree, uncommitted):

  • The long-standing intermittent black-frame flicker (MegaManX6Recomp ISSUES.md #7 — the reason MMX6 shipped with the software renderer) is root-caused and fixed. Mechanism (proven via the new present ring + gl_coh_ring correlation): flush_cpu_upload() merged all pending CPU→VRAM writes into ONE union bounding box; a frame with two disjoint uploads produced a union spanning the display framebuffers, which the flush painted from the stale CPU VRAM mirror (the FBO is authoritative under GL) — stomping live frames with black. Two black presents per incident (one per double-buffer parity). Software renderer is immune (CPU array is authoritative there), which is why it was the safe default.
  • Fix: exact pending-rect list (16 rects; merge only when zero uncovered pixels are added; wrap-aware GP0(A0) transfers split into up to 4 exact rects; overflow → order-preserving flush-all). Merge rule proven by a 20k-randomized- rect host unit test (0 stale / 0 missing painted pixels).
  • New always-on observability: gl_present_ring (every SwapWindow site records path taken, src/letterbox rects, glGetError, wall-ms, backbuffer + blit-source pixel samples) — the instrument that made the 1:1 black-capture correlation possible. Plus a debug-server fix: send_fmt silently truncated >64KB responses into unparseable JSON (broke big ring dumps); now heap-formats exactly.
  • Validation: ~18-minute MMX6 GL attract soak, ~1600 window captures across 3+ full attract cycles — zero isolated black frames, zero GL errors, no other visual anomalies. Tomba1 build-gl rebuilt with the fix, boots clean (full Tomba1 attract soak still owed).

Validation COMPLETE (2026-07-03, both titles):

  • MMX6: ~18-min attract soak, ~1600 window captures, zero isolated black frames (agent, ring-correlated).
  • Tomba1: 24-iteration/720-capture attract soak (2 flags, both multi-frame FMV content cuts) + the definitive pass: 23,807 CONSECUTIVE presents (gapless, seq-verified) over a full attract cycle via gl_present_ring — ZERO isolated dark presents, zero GL errors. The capture-level flags do not exist at the swap level.

Remaining to close R1:

  1. USER final validation at the MMX6 Rainy Turtloid standing-still spot (the original repro; MMX6 build-modern settings.toml is left on renderer="opengl").
  2. Flip MMX6's shipping default software→opengl + close ISSUES.md #7.
  3. The same union-upload bug exists in the Vulkan backend (see R2 item 1).

R1b — Native-wide (16:9) GL: perf collapse ROOT-CAUSED + FIXED, band flicker FIXED

Status 2026-07-03 (branch feat/renderer-finish, commits f5362f4..8b819eb):

  • The 16:9 perf collapse (Tomba2 3D attract 60→12fps, MMX6 2D attract dips) was never a GPU problem. Stack-sampled (devkitPro gdb) in the wedge: the main thread lived in ws_backdrop_site_kind ← exec_one — under native-wide the dirty-RAM interpreter classified EVERY executed instruction as a possible backdrop rewrite site; the classifier rescans ±512 bytes on cache miss and its 256-slot direct-mapped cache (2 hot PCs 1 KB apart collide) thrashed on overlay working sets. Squash mode gates the whole path off — that is why ws_nw on=0 restored 60fps while every GPU theory (mirror FBO ping-pong, extra prims, present path) failed. The earlier "60ms scene GPU / 70us per prim" numbers were CPU-starvation-inflated GPU-timestamp gaps (the GPU idles between CPU-paced submissions inside the bracket) — treat GL timer numbers on a CPU-bound frame as suspect.
  • Fixes (dbe7812 + 8b819eb): opcode pre-filter (only addu/or/addi/addiu can be rewrite sites) + 8192-slot full-PC-tagged caches + g_dirty_ram_code_gen invalidation (memory.c) + a per-entry SITE-WORD tag (revalidates the cached verdict against the live instruction word — plain-CPU-store overlay reloads never hit the page-marking hooks, and a stale verdict fires a GPR rewrite at the wrong instruction = guest corruption).
  • Numbers: Tomba2 GL 16:9 attract (heavy scene, ~640-1100 prims) 72-95ms/frame → 17-21ms (p50 17.1ms, ~52-58fps); MMX6 GL 16:9 attract locked 16.7ms through demo stages (worst 10s window avg 25ms at stage-load transitions).
  • Top/bottom band flicker (MMX6 16:9, user-visible) FIXED (a0b5843): the GL mirror pass scissored the FULL wide surface; the SW reference (rt_wide) only widens X and keeps the draw-area Y clip. Under MMX6's vertical double buffer (draw area alternates y=0/y=240, both bands in ONE wide surface) mirror draws bled across the band boundary and presented a frame late as edge flicker. Scissor is now full-width X / draw-area Y. Validated with 0.15s-interval capture bursts: top/bottom 16-row inter-frame instability is 0.26x/0.21x the scrolling middle band (edges quieter than content).
  • Wide surfaces now carry a DEPTH24_STENCIL8 attachment (cfa79bb): the stencil-less wide FBO was a spec-gray target for the stencil-enabled mask fixup passes AND left the PSX mask-bit mirror silently no-op on the wide surface. (Its per-pass GPU "cost" measurements that motivated it were later shown starvation-inflated; the attachment stays for mask correctness.)
  • New permanent instrumentation: frame_perf mirror split (GL_TIMESTAMP pairs per wide pass: mirror_gpu_ms / canon_gpu_ms / mirror_pass_us), CPU-side attribution (cpu_flush_ms, cpu_wide_ms, batches, wide target sets, wide FBO creations per frame), and gl_ws_ablate mode=0..3 (skip mirror / state-only / no-FBO-rebind ablations) — the toolchain that exonerated the GPU and named the CPU producer.
  • MMX6 wedge incident (RESOLVED to one mechanism, poisoned overlay shards): during the session MMX6 hit fatal wedges — twice in the 16:9 attract (wild dispatch 0x21010001 / 0x0C008096) and then DETERMINISTICALLY at boot frame 2518 (unknown dispatch 0x80095098) at BOTH aspects. Timeline nailed it: the overlay self-heal wrote a fresh 001EA000 shard batch at 20:53, exactly when attract wedge #1 hit (shards hot-loaded mid-run); every boot after loads them and wedges at 2518; quarantining the batch (cache/.../cg4_0cec55ab.quarantine) restored clean boots + attract. Most plausible poison source: the INTERIM stale-verdict build (dbe7812 before the 8b819eb word tag) corrupted guest RAM at 16:9, and autocapture snapshotted the corrupted overlay bytes into the captures the shards were compiled from. Self-heal recaptures with the hardened build. NOT a ws-stack or renderer defect. Tomba2 16:9 soaked clean throughout.

R2 — Vulkan renderer (3rd backend): RENDERS GAMEPLAY AT SPEED, gaps cataloged

Status as of 2026-07-03 (same branch/tree; -DPSX_ENABLE_VULKAN=ON, SDK 1.4.341.1; feat/vulkan-renderer turned out to be already merged into master — only a 26-line build-guard needed salvaging from the retired _wt-vulkan worktree):

  • Three bring-up bugs root-caused and fixed this session:
    1. Boot wedge: per-pixel GP0 uploads did 2 vkAllocateMemory + 2 vkQueueSubmit each (driver churn → minutes-long stall, watchdog abort). Fixed with GL-style deferred batched uploads.
    2. Shredded 3D: draws raced CPU rewrites of the persistently-mapped vertex buffer (69.5% pixel divergence vs software at the same guest frame). Fixed with sub-allocation cursors + firstVertex bases.
    3. Semi-transparency order violations (59.5% divergence): VK still had GL's retired whole-batch STP split; ported GL's current two-pass model (ordered color pass + color-masked stencil fixup; semi prims isolated).
  • Verified (guest-frame-aligned VRAM diffs via the new frameshot.py tool + window captures): title pixel-identical to software; attract within 0.62% of the GL oracle; 60.5 fps sustained; vk_perf steady-state ~0-2 allocs and ≤6 submits/frame (was thousands). 24-bit FMV present path written (old one was provably black) but NOT yet verified on-screen.

Gap catalog (ranked, low→high effort):

  1. Port the exact-rect pending-upload fix from GL DONE 2026-07-03: exact-rect list ported + VK-specific COALESCED flush (one staging pair + two submits per flush regardless of rect count; the naive per-rect port re-created the submit churn at 0.7 fps — VK pays per-submit where GL pays per-glTexSubImage2D). UP_RECTS_MAX=64 on VK so MDEC row-coalesced FMV frames fit in one flush. Verified: attract renders correctly at ~51 fps, vk_perf mostly-idle frames.
  2. Verify FMV on-screen DONE 2026-07-03: Whoopee logo + intro CG movie render correctly on VK (window captures). NOTE Tomba2 movies are 15-bit MDEC->VRAM (upload path), NOT depth24 — the depth24 compose path remains no-regression- verified only; validate on a 24-bit title (MMX6 opening) later.
  3. Cache the FMV present staging image DONE 2026-07-03: persistent image + mapped staging keyed by (w,h), freed on resize/shutdown (cpres cache).
  4. DS barrier DONE 2026-07-03: explicit stencil-aspect self-barrier (late-tests write -> early-tests read|write) at every begin_geo_pass; layouts verified against the init transition chain + render pass (attachment-optimal throughout). 4b. NEW (minor): flush_cpu_upload allocates 2 stagings per flush — ~16/frame during MDEC FMV streaming only (~0 in gameplay). A sync-aware staging ring would zero it; low priority.
  5. Native-wide (16:9) compositor DONE 2026-07-03 (0b23ea3): per-base_x wide surfaces (RGBA8 color + OWN stencil image + framebuffer on the SHARED render pass — every pipeline works unchanged, and the mask-bit stencil mirror is real on the wide surface from day one). The mirror is a second render pass appended to the SAME one-shot CB as each flushed batch (no extra submits); u_xoff/u_xhalf push constants (pre-plumbed in the shaders) carry the translation/wider clip. Mirror scissor = full-width X / DRAW-AREA Y (the GL band-bleed lesson applied from day one). wide_clear via ClearAttachments (color + stencil=bit15); full-screen overlay rects suppress the batch mirror and draw one full-wide-width rect (margins dim/fade). GPU-direct present blits the displayed band letterboxed at (4wide_w : 3native_w); vkb_render_wide_display readback backs the facade + debug dump. vk_perf gains wide/wclr counters. VALIDATED (Tomba2 build-vk, PSX_WS_FORCE_2D=1 — master Tomba2 has no sprite-tag hooks): 16:9 attract presents full-width (mine-cart demo captures), locks 60fps on normal scenes (frame_period p50 16.68ms), ~51-57fps on the heavy semi-prim scene (29 wide passes/frame); 4:3 unregressed (p50 18.1ms on the 452-flush semi-isolation scene — the pre-existing item-7 profile, wide counters 0). Margins show 4:3-culled geometry only — full margin content needs the cull-widened overlay cache (cg4_0cec55ab.ws-experiment, not installed on master).
  6. SSAA scale >1 unvalidated on VK.
  7. Semi-prim isolation perf (one draw per semi triangle; same cost as GL today).

Validation targets: MMX6 + Tomba2, agent does initial (window-capture series

  • frame-aligned cross-backend diffs), user does final.

R3 — Validation sweep + merge (2026-07-03, USER-DRIVEN)

Full 3-title x 3-config playthrough validation (agent launched each config windowed

  • confirmed on-screen via window_capture; user drove input + gave the verdict):
Title OpenGL 4:3 Vulkan 4:3 OpenGL 16:9
Tomba1 PASS YELLOW PASS
MMX6 PASS YELLOW YELLOW
Tomba2 PASS* PASS* FAIL*

* Tomba2 = experimental / not production-ready; not merge-blocking. (Vulkan 16:9 was intentionally skipped — user directive: all 16:9 in OpenGL only.)

Findings (tracked for the next work cycle):

  • OpenGL 4:3 is the strong, shippable path on all titles. This is the win.
  • Vulkan 4:3 = YELLOW everywhere (why it ships hidden/experimental):
    • Tomba2: sluggish FMV, minor speech-audio fidelity loss, in-game slowdown (possibly progressive), text boxes don't dismiss, HUD weapon icon renders in all 3 slots (only center should show).
    • Tomba1: minor horizontal bar artifact at top of the load screen; dwarf village sluggish (the known overlay-heavy scene; perf only, renders correct).
    • MMX6: significant slowdown in Rainy Turtloid's RAIN AREA even at 4:3 — VULKAN-SPECIFIC (on GL 4:3 it is subtle at most). Renders correct; perf only.
  • OpenGL 16:9: Tomba1 clean PASS. MMX6 YELLOW (rain-area lag evident at 16:9; same Rainy Turtloid perf hot-spot). Tomba2 FAIL — widescreen does NOT engage (renders 4:3 pillarboxed in the wide window + slow); expected, master Tomba2 has no sprite-tag ws hooks (ws work parked at 4:3).

Policy gates confirmed already in-tree (no code change needed for the merge):

  • Vulkan is hidden by default: the shared launcher offers only Software<->OpenGL; PSX_ENABLE_VULKAN defaults OFF (runtime.cmake); runtime downgrades renderer=vulkan -> opengl when not compiled (main.cpp:2463). Vulkan stays a dev/CLI-only backend (--renderer vulkan on a VK-enabled build).
  • Widescreen carries an EXPERIMENTAL tag in the launcher.

MERGED to master 2026-07-03 (runtime-only; codegen hash unchanged, no regen). Game pins bumped to the new psxrecomp master. No release builds cut (user directive).

Open follow-ups (next cycle): VK perf (MMX6 rain-area, dwarf village, Tomba2 in-game — likely the CPU-bound dirty-RAM ws classifier path and/or VK per-submit cost); VK correctness (Tomba2 persistent text boxes + 3-slot HUD icon; Tomba1 load bar artifact); Tomba2 widescreen sprite-tag hooks; MMX6 Rainy Turtloid perf even on GL 16:9; and the pending MMX6 shipping-default flip software->opengl to close MegaManX6Recomp ISSUES.md #7 (GL validated this session).


W1 — Tomba2 16:9: USER-FLAGGED visible issues queue (2026-07-06, NOT STARTED)

User-prioritized alongside the P0 crash work (ISSUES.md #8). None of these have been investigated yet — this section is the work queue + every known lead. All Tomba2, GL 16:9, worktree _wt-tomba2-ipr / Tomba2Recomp build-t2.

W1.1 — Black background columns in 16:9 (P3; incl. beach area)

  • Symptom: huge black regions where the 2D far backdrop should fill the wide margins (user Image 1: large black sky scene); also a ~24px black column in beach-village at game-x 85–109.
  • Class: 2D far-backdrop doesn't cover the wide FBO edges.
  • Prior art / leads:
    • [widescreen.bg2d] config (recompiler/src/config_loader.cpp:699).
    • Memory ws_backdrop_preload.md (Tomba1): far-backdrop void = early sprite-tagged 0x65 tile grid; fix = centre-stretch gated preload.
    • Memory ws_draw_census_8c.md (Tomba1 8C scene): void was GTE-3D driver FUN_8004db3c; fixed via depth-gated un-squash. The 8C draw-census ring is the attribution instrument (always-on; query, don't arm).
  • Next step: per-scene attribution — which producer draws the sky strips, and why the wide margins get no tiles. Attract may cover some scenes; the beach needs navigation (user drive or save).

W1.2 — 4:3 object culling visible at wide edges (P2 cull widening)

  • Symptom: objects pop in/out at the 4:3 boundary in 16:9 (world objects culled by game code against 4:3 screen extents).
  • Ghidra cull sites already identified (tomba2_ram.bin; overlay addresses — validate per scene variant before wiring):
    • FUN_8003e030: sltiu 0x140 @ 0x8003E228
    • FUN_80069b6c: addiu +0xE6 @ 0x80069B84 + sltiu 0x1CD @ 0x80069B8C
    • 0x80110A08: addiu +0x80 / sltiu 0x101
  • Fix shape: [widescreen.cull] bias_sites/range_sites per-game config (enhancement-tier per-game shims are legitimate here). Prior art: Ape bring-up used per-game cull imms (0x181) + signed idioms (slti/bltz) (memory ape_widescreen_bringup.md).

W1.3 — Dialogue-box text tearing in 16:9 (P4)

  • Symptom: full-width dialogue text splits with gaps (user Image 2: "Water cam…e out from th…e faucet").
  • Cause known: gpu.c ws_nw_hud thirds — left/right-third sprites are anchored apart for HUD proportion; full-width dialogue glyphs tear at the third boundaries. Needs a smarter anchor (e.g. detect wide text rows / dialogue-box association) rather than blanket thirds.

W1.4 — Beach-area framerate (P1 perf; also user-flagged)

  • 2D isometric scenes run 0.42–0.70× (beach/village worst). The convergence blockers are FIXED (autocapture futility backoff + entry-based coverage in tools/compile_overlays.py — see handoff 2026-07-06); the campaign was interrupted by ISSUES.md #8 and should resume after it: expect 2D scenes to climb past 0.85 as coverage converges. Residual axes if short of ~1.0: per-block pump overhead (psx_check_interrupts ~2.6M calls/s), attract cycling. Measure ONLY with two freeze_check snapshots over a known wall interval (executed throughput = d(psx_cycle_count) − d(cycles_skipped)); phase_profile is a ring read and returns instantly.
  • CAUTION: any pace numbers taken in diff mode before ISSUES.md #9's redesign lands are garbage (the wedged shadow silently disabled all native dispatch).

L1 — Load-time-toward-0 burndown (2026-07-14, ACTIVE — guinea pig: Tomba 1)

Full analysis + gates/kill criteria: docs/LOAD_TIME_ZERO.md (this branch, spike/load-time-zero). ChatGPT consult merged (thread "PSXrecomp workspace"). Already-settled items are NOT re-tried: turbo_loads (shipped, ~2x), disc_speed divisor 4x/instant (proven unsafe — MMX6 VSync-callback wedge), yield pumps r1/r2 (proven fatal — green-thread corruption), BIOS CD HLE (rejected — no landmine, no host win). The frame: wall = guest_time x host_cost_per_guest_sec; under turbo the window is emulation-throughput-bound, so the safe axis is host cost (multiplies with turbo), the risky axis is guest time.

Prioritized burndown (most agnostic + most likely beneficial first):

  • L1.0 — E0 load_probe_v2: load-window decomposition on Tomba 1. 100% agnostic, zero risk, prices every bet below. Split a real pig-load window into guest time (seek / sector cadence / per-sector processing / explicit waits) and host time (native code / decompressors / interp / CD-event machinery / SPU / GPU / pump). Existing rings first (freeze_check, cdrom_bursts, dirty_ram_stats per_pc, phase_profile); extend rings only where attribution is blind. Decisive question: why only ~2x during a presentation-suppressed window? Thresholds: decomp ≥~40% host → shards serious; ≤~8% → kill shards; CD/event/SPU machinery dominates → L1.2; wall-clock limiter found → fix that first.
  • L1.1 — Turbo hardening. (a) re-validate SDL pump under a live burst (fix appears in-tree: pump precedes the turbo early-return); (b) audio at the HOST SINK only (drop excess samples, crossfade on exit; never guest state); (c) root-cause MMX5 dev-tools+turbo 0xE10 boot wedge (foundation timing bug).
  • L1.2 — Event-horizon acceleration + batched device ticking. Provably side-effect-free poll/idle regions jump to the next scheduled observable event with exact cycle credit + identical event ordering; devices advance to deadlines instead of per-block ticks. Attacks the ~2x ceiling directly; class-level, all titles inherit. Gate set from L1.0's poll/idle share. Shipped: deadline-based device servicing, six Tomba wait sites, and proof-gated generic idle skipping. Default-off cross-game smoke validation passed on MMX5 and MMX6. Kill: <10% gain or ONE event-order divergence.
  • L1.3 — Load-path overlay coverage (killed by gate). L1.0 measured zero in-window interpreter instructions, so there is no coverage win to buy.
  • L1.4 — Data shards (rejected/quarantined): verify-only SHADOW mode, then replay. Gated on L1.0 (decomp ≥~20-25% host share). Correctness bar: temporal write visibility — replay sound only if IRQs-off across the window OR duration < next observable event.
  • L1.5 — Authentic drive backlog (killed as acceleration). Passive deadline-vs-exposure probe measured 1,304/1,304 data sectors available on their exact scheduled cycle, then the intentional fixed 5,000-cycle INT1 presentation delay. Zero early/late sectors, holds, pending/lost INT1s, or overwrites: there is no artificial lateness for backlog/catch-up to remove.
  • L1.6 — Seek-only latency probe (killed on Tomba). The measured New Game window issued zero seek commands. Read-start latency was only 7,676,928 / 298,130,657 cycles (2.57%); pause latency was 8.11% but is an authentic CPU-visible ordering contract, not a safe seek-speedup target.
  • L1.7 — Phase-2 doors (open only with cause): per-title read speedup with XA/CDDA/MDEC exclusions; decompressor HLE (only via L1.4 failing for a named reason); load-transition state cache (the only true near-zero; needs thousands-of-frames differential validation).

Live status / decision ledger (Tomba 1, 2026-07-14):

Item Verdict Evidence / measured result
L1.0 decomposition DONE 761 sectors, ~9.5 s baseline window; zero in-window interp; host/static execution dominated.
L1.1 turbo hardening IMPLEMENTED ON TOMBA; CROSS-GAME SMOKE PASSED SDL pump remains before every turbo return; 4-frame engage + 6-frame release debounce passed live QA. Opt-in host-audio sink advances canonical SPU state while discarding only accelerated host output; Tomba listening QA passed after 1,100,752 discarded SPU frames. MMX5 and MMX6 debug-tools builds booted through loads into live gameplay with normal visuals/audio; the historic intermittent MMX5 0xE10 root cause remains a separate long-run investigation.
L1.2 event horizon IMPLEMENTED; CROSS-GAME SMOKE PASSED Production cycle advancement already batches device service at event/MMIO deadlines. Six configured PsyQ CD-wait sites delivered ~27% + ~9% stages. Generic idle-loop skip then cut warm bursts 0.53->0.34 s and 2.19->1.73 s, with 4,599 skips / 765M guest cycles and zero CD overwrites. Strictly per-game opt-in; MMX5/MMX6 exercised the default-off compatibility path successfully.
L1.3 overlay coverage KILLED Interpreter share was zero in the measured load window.
L1.4 asset/data replay REJECTED FOR NOW FUN_8003EF50 replay produced title/game texture corruption despite zero verifier failures: v1 temporal verifier is unsound. Data shards default off and artifacts removed.
Configurable warm CD routes (L1.7 read-speed branch) ACCEPTED, STRICTLY PER-GAME OPT-IN Framework accepts up to 16 strict LBA routes with mismatch fallback and consumer-paced IRQ/DMA. Only data-read cadence accelerates; XA/CDDA, seek, and motor timing remain authentic. Tomba multi-route regression: 3 matches, 1,944 accelerated sectors, zero overwrites. Legacy singular config is deprecated.
L1.5 authentic backlog DONE / KILLED AS ACCELERATION 1,304/1,304 sectors exact-deadline; INT1 exposure exactly +5,000 cycles; zero holds, pending/lost, or overwrites.
L1.6 seek-only probe DONE / KILLED ON TOMBA Automated New Game: 0 seeks across 792 data sectors. Read-start latency was 2.57% of the data span, below the 10% gate. Pause was 8.11% but remains authentic because early completion is a known race/wedge class.
L1.7 state cache / broader HLE DEFERRED User excludes savestates; decompressor replay failed correctness.

Method, every experiment: measure first via always-on rings; start flag-gated; one per session; kill criterion written before code. idle_skip, warm CD routes, and the turbo host-audio sink are all strictly per-game opt-in. MMX6 remains the next deeper corpus/soak target after its successful boot/load/gameplay smoke.


W2 — Tomba 1 (SCUS-94236) 16:9: HUD at the true wide corners (DEFERRED 2026-07-10)

User ask: in native-wide 16:9, re-anchor the HUD to the wide corners (repositioned, never stretched). First attempt shipped and was REVERTED the same day (game.toml nw_hud_corners back to false; the framework machinery stays, inert + A/B-able). What was learned, so the next attempt starts ahead:

  • Mechanism reused: [widescreen] nw_hud_corners (gpu.c ws_nw_hud_shift thirds translate, from the MMX4/5 campaign) + a new tag-title scoping: polys and lines never shift (world/characters), rect-family prims shift only when UNTAGGED. TCP ws_hud_mode {"tag_rects":0|1} A/Bs the tagged-rect gate live.
  • What worked: vitality gauge + life-counter (untagged rects, fully inside one outer third) anchored flush to the wide corners, world untouched.
  • Failure 1 — composite tear (same class as W1.3): the in-world dialogue box ("It's locked...") is a composite of untagged rects SPANNING zone boundaries: its left/right end caps sit in the outer thirds and get pulled to opposite screen edges while the text stays centred — the box visibly splits. A blanket per-prim thirds rule cannot ship; it needs composite-group awareness (e.g. group prims drawn adjacently in the packet arena / same OT bucket, shift a group only if the WHOLE group fits in one zone).
  • Failure 2 — AP counter immobile: the AP composite (0x65/0x67 rects, x≈220-292, y≈8-12) renders through the TAGGED sprite funnel (0x8005E08C), so the untagged-only gate skips it. Census now records a tagged column (gpu.c WsCensusEntry) — verify with ws_census. Lifting the gate via ws_hud_mode shifts it, but then tagged world-anchored rects (collectible sprites) near edges would shift too — needs the same group/HUD-band discrimination as Failure 1 or a per-title HUD packet arena range (nw_left_hud_packet exists for exactly this; needs Tomba's HUD arena addresses from a census session: HUD prims live in the 0x000Bxxxx/0x000Cxxxx double-buffered packet arenas alongside everything else, so the range must come from finer addresses).
  • Groundwork landed on feat/ws-2d-scene-pillarbox: census tagged column, ws_hud_mode live A/B, and the untagged-rect scoping that makes nw_hud_corners safe to experiment with on tag titles.

G1 — Sub-pixel vertex precision + perspective-correct textures (issue #92)

PS1 polygon jitter ("wobble", "bouncing lines") and warped floor/wall textures have one root cause each, both in the fixed-point geometry pipeline:

  • Jitter. The GTE computes the projected screen position in 16.16, then saturates it to an integer pixel when it pushes SXY. The fraction is thrown away. A slowly moving mesh therefore snaps its vertices between whole pixels and the model shimmers.
  • Texture warp. The GPU interpolates UV affinely across a triangle, with no 1/z term, so a large floor or wall polygon's texture swims as the camera moves.

Both are addressed as opt-in, visual-only enhancements. The PS1-visible GTE SXY FIFO stays integer and fully faithful — a game's own post-projection screen-bounds culls and any SXY readback see exactly what hardware produces. Nothing about guest state changes; the correction lives entirely on the host render path.

Configuration

[video]
geometry_correction   = true   # sub-pixel vertex precision (kills the wobble)
perspective_texturing = true   # perspective-correct UVs on world polygons
supersampling         = 2      # REQUIRED for geometry_correction to be visible

Both default false (the faithful floor). They are independent — a title may want stable geometry without changing texture mapping. Settable per-game in game.toml. (Until 2026-10-24 also per-player in settings.toml and a launcher row; since then the player's switch is the PGXP Precision mod, see S1 below.)

geometry_correction needs an internal scale of 2x or more (720p and up at 240 lines; the title's internal_resolution/supersampling, or the Internal Resolution mod): at native resolution the corrected position rounds back to the pixel it started on, so there is nothing to see. The runtime prints a note at startup when it is on at scale 1.

How it works

The recompiler emits GTE commands as calls to a single runtime entry point (gte_execute), which both the compiled backend and the dirty-RAM interpreter share — so unlike an interpreter/dynarec emulator there is no dispatch hook to add, just one funnel to instrument.

  1. runtime/src/gte.cpp — RTPS/RTPT keep the discarded 16.16 fraction in a side cache keyed by the packed SXY word it rounded to (geom_note). Saturated (off-screen) projections are rejected: they carry no usable sub-pixel information.
  2. SWC2 provenance — the recompiler, strict translator, dirty-RAM interp, overlay ABI (v14) and fallback interp all call gte_precision_store_word(addr, reg) when a projection register is stored to guest RAM, recording which RAM address a projection landed at. Perspective texturing only fires when all three of a triangle's position words came from such a store at that exact DMA packet address — which preserves the association through ordering-table reordering and rejects CPU-built UI and 2D sprites outright. A plain sw to a tracked address invalidates it.
  3. runtime/src/gpu.c — prepare_precise_triangle() / prepare_texture_triangle() look the packet up per triangle and hand the result to the renderer facade as sideband state for the next draw (gr_set_precise_triangle / gr_set_perspective_triangle).
  4. All three renderers consume it. Software uses the fractional positions in its supersampled mirror; OpenGL and Vulkan take them as float vertex positions directly. For perspective UVs both GPU backends carry a per-vertex a_q weight and emit clip coordinates pre-multiplied by w = 1/q, so the hardware's own perspective divide interpolates a smooth UV varying while the affine noperspective one stays available. a_q == 0 (the default) makes w exactly 1.0 and selects the affine varying — the pre-feature pipeline, unchanged.

Save states and speculative native-validation passes drop host-only provenance (gte_precision_timeline_invalidate, gte_precision_speculative_begin/end) so a rewind can never resurrect a stale projection.

Perspective-correct world polygons retain their authored UV coordinates and inclusive atlas bounds. The shared gpu_uv.h sampling policy applies mirrored sprite compensation only to affine primitives. Integer screen rounding can otherwise make one half of a world quad appear axis-aligned and shift its UVs by one texel while the other half stays put. This caused diagonal breaks and camera-dependent flicker in MediEvil II's Museum doorway trim. OpenGL/Vulkan use the shared policy; software uses the same stable world bounds. The gpu_uv_test fixture covers the captured doorway packets, camera rounding, shared edges, mirrored/forward sprites and texture-page wrapping.

Validation story

By construction this feature diverges from stock hardware output, so the Beetle oracle cannot be the judge of the corrected frame. What the oracle still pins is the part that must not move: with both flags off the output is byte-identical to the pre-feature build, and guest-visible GTE state is identical either way (the SXY FIFO is untouched in both). That reduces validation to (a) an off/off pixel-identity check against the oracle, and (b) human A/B of the on/off frames on a 3D title.

gte_geometry_correction_hits() and gpu_texture_correction_hits() report how many vertices/triangles were actually corrected — the "is this doing anything on this title" counter, and the thing to check first when a title shows no visible change.

Provenance

The GTE side cache, SWC2 provenance tracking, GP0 triangle preparation and the software-renderer consumption path were contributed by Kareem Olim (kem0x) in PR #14 and parked in commit 2ceaf5a (see docs/internal/upstream/kem0x-pr14-projection-perspective.md), disabled pending generic setters. This work adds the opt-in configuration, the renderer-facade seam, and OpenGL + Vulkan support.

Status / next

  • Done: config plumbing (game.toml + settings.toml + launcher seed round-trip), renderer-facade sideband, software / OpenGL / Vulkan consumption, [video] plumbing unit test.
  • Open: per-title A/B validation. Ape Escape is the obvious first 3D subject (Tomba 1/2 and MMX5/6 are largely 2D, where neither knob does much).
  • Open: launcher (recomp-ui) toggles. The keys round-trip through settings.toml today, but there is no UI row yet — that lives in the recomp-ui repo.

G1.1 — MEASURED REGRESSION: partial coverage cracks meshes (2026-08-05)

User verdict on Ape Escape: "little lines jittering everywhere — visually this is worse." Confirmed and root-caused. geometry_correction must not be presented as usable in its current form.

Measured on Ape Escape (OpenGL, supersampling 2, 213-frame window, via the new geom_correction TCP command):

per frame
GP0 draw commands ~316 (≈400–600 triangles)
vertices given sub-pixel positions ~114 (≈38 triangles)
triangles given perspective UVs ~1.9

Under 10% of the scene is corrected. prepare_precise_triangle is all-or-nothing per triangle, so every boundary between a corrected triangle and an uncorrected neighbour is a seam: one edge moved sub-pixel, the other stayed on the integer grid. Because the geometry cache is direct-mapped and keyed on the rounded position, which triangles win changes frame to frame — so the seams move. That is exactly the reported jitter.

G1.2 — What the reference implementations actually do

Both vendored emulators (beetle-psx/pgxp/, duckstation/src/core/cpu_pgxp.cpp) implement PGXP the same way, and it is not what is parked here:

  1. A complete shadow of the dataflow. A PGXP_value {x,y,z,flags,value} per 32-bit word of RAM + scratchpad (Beetle mirrors all 2 MB; DuckStation the same), plus a shadow per CPU GPR and per GTE register.
  2. Propagation through every instruction. Beetle registers 47 CPU hooks — LW/LH/LB/LWL/LWR, SW/SH/SB/SWL/SWR, ADD(I)(U)/SUB(U)/AND/OR/XOR/NOR/SLT(U), SLL/SRL/SRA(+V), MULT(U)/DIV(U), MFHI/MTHI/MFLO/MTLO, LUI. DuckStation carries the same set. High precision therefore survives any route the game takes from GTE output to the GP0 packet.
  3. Validate(value). Every shadow read checks the tracked value against the actual current word and drops the shadow on mismatch. This is what stops stale precision from corrupting geometry.
  4. The value-keyed cache is only a LAST-RESORT FALLBACK, and even then it is fully direct-indexed — Beetle vertexCache[0x800*2][0x800*2], DuckStation 2048×2048 — so distinct screen positions never collide; it is gated on an ambiguity flag (gFlags == 1, "only one value was recorded at this position") and it disables perspective (valid_w = 0) because its w is untrustworthy.

The parked implementation is only item 4, degraded: an 8192-entry hashed table (unrelated positions collide) with no ambiguity check and no primary path. A vertex can therefore inherit a different vertex's fraction. That is the design defect, not a wiring bug.

G1.3 — What matching them costs in a static recompiler

The emit mechanism already exists — gte_precision_store_word(addr, reg) is emitted at SWC2 sites today — so this is an extension, not new machinery:

  • per-word shadow of RAM + scratchpad (~12 MB), per-GPR and per-GTE-reg shadows;
  • propagation hooks at ~47 instruction classes, emitted in code_generator.cpp and strict_translator.cpp, mirrored in dirty_ram_interp.c and psx_interpreter.c, and forwarded through the overlay ABI (another bump);
  • Validate() on every shadow read;
  • GPU-side lookup keyed on the packet word with a value == check, with the corrected fallback cache last.

The static-recompiler-specific cost: in an interpreter these hooks are a runtime branch. Here they are emitted C on the hot path, so they bloat generated code and cost speed even with the feature off unless they are gated at CODEGEN time — i.e. a separate generated flavour, which touches the build matrix and every title's regen. That is the real decision, and it is why this cannot be a runtime-only toggle like the rest of the [video] block.

G1.4 — DECISIVE: position-keyed lookup cannot work (measured, 2026-08-05)

The cheap fix was tried and measured to be insufficient, which settles the direction. The hashed table was replaced with the references' exact direct-indexed one (one slot per reachable SXY, no collisions) plus their ambiguity gate. Ape Escape attract demo, cumulative:

outcome count share
lookups attempted 4,860,057 —
hit 253,679 5.2%
miss — never recorded at that position 82,253 1.7%
miss — ambiguous (several DIFFERENT projections rounded to that pixel) 4,524,125 93.1%

93% ambiguous, 1.7% unrecorded. The tracking is not failing to reach the vertices — it reaches almost all of them. The rounded screen position simply is not a unique key: in a dense 3D scene most pixels have several distinct vertices projecting onto them, so no position-keyed lookup can tell which sub-pixel fraction belongs to the packet being drawn. That share is irreducible; a bigger, faster or smarter table cannot move it.

It also explains why the first attempt looked so bad. Without the ambiguity gate those 93% were not misses — they were silently answered with another vertex's fraction. The gate makes the feature safe (wrong fractions are no longer applied) but drops honest coverage to ~5%, so meshes still mix corrected and uncorrected triangles and still crack. Visually confirmed on Ape Escape: thin dark seams tracking across characters and floor in the intro/attract.

Conclusion. Only PGXP's primary path — precision travelling with the data through the CPU dataflow, so a GP0 word's provenance is known exactly rather than guessed from where it landed — produces a clean result. A coverage gate or a better cache is ruled out by measurement, not by argument. geometry_correction must not ship until value propagation exists.

G1.5 — CORRECTION to G1.4: the ambiguity gate fixed the cracking

G1.4's closing claim ("meshes still mix ... and still crack, visually confirmed") was wrong, and was not verified. User observation on the exact- table + ambiguity-gate build, at window resolution: "in game is looking pretty nice ... whatever is up doesn't have the lines issue." Same scene that was visibly torn before. The seams are gone.

Why the wrong claim was made — the instrument was blind. Verification used the TCP screenshot, which resolves native 15-bit VRAM. Geometry correction exists ONLY in the supersampled mirror (that is why it needs `supersampling

= 2), so it is erased before a native capture is taken. Those captures showed clean frames no matter what the player saw, and the conclusion then leaned on counters instead. Fixed by adding screenshot_hires, which routes the same present path as the window. **Anything that lives in the hi-res mirror must be verified with screenshot_hires`; a native screenshot cannot see it.**

What this means technically. The visible defect was ambiguity, not coverage:

  • A wrongly-attributed fraction displaces a vertex by up to a full pixel in an arbitrary direction — a large, obvious tear that moves as winners change.
  • Missing coverage only leaves a sub-pixel (<1px) mismatch where a corrected triangle meets an uncorrected one — not visually objectionable.

So the ambiguity gate removed the harm. Coverage (~5% of lookups) now bounds the benefit, not the damage: the feature is safe but only lightly effective. Full value propagation remains the path to a large improvement — it would take coverage toward total — but it is no longer a prerequisite for shipping something that does not hurt.

Still to verify first-hand with screenshot_hires, before any of this is called done: an A/B of the same frame with the knob off vs on, at supersampling >= 2.

G1.6 — G1.5 RETRACTED: both titles crack. Ambiguity gating is not enough.

User verification on the exact-table + ambiguity-gate build, Ape Escape AND Tomba 2: thin seams across meshes in both. G1.5 claimed the gate had fixed the cracking; it had not. G1.4's original conclusion stands.

What went wrong in the analysis, twice: each conclusion was drawn from a SINGLE observation instead of a controlled A/B — first from native screenshots that could not show the defect at all (G1.5), then from one favourable in-game frame that happened not to expose it. A scene where the corrected ~5% of triangles do not border a visible silhouette looks clean; that is not evidence the seams are gone.

Settled position. Ambiguity gating removed one failure mode (vertices inheriting a neighbour's fraction) but coverage of ~5% still mixes corrected and uncorrected triangles within a mesh, and those seams ARE visible. Partial coverage is not shippable at any ratio short of near-total, because the crack is a property of the boundary, not of the magnitude of the error.

geometry_correction therefore stays OFF and must not be offered as usable until precision propagates with the data (G1.2/G1.3). The launcher rows, the [video] plumbing, the renderer-facade seam, geom_correction and screenshot_hires all remain valid groundwork for that work.

Method rule for the next attempt: no conclusion about this feature from a single frame. Same frame, same scene, off vs on, captured with screenshot_hires, on at least two titles.

G1.7 — ⚠ G1.6's MECHANISM IS UNCONFIRMED. Read this before trusting G1.1-G1.6.

Two problems with everything above, both found only after G1.6 was written.

1. The line signature does not match the stated mechanism. G1.4/G1.6 explain the artifact as cracks between corrected and uncorrected triangles. Such cracks would be SHORT seams tracing mesh silhouettes. The reported artifacts on both titles are long, straight, scene-spanning lines — cyan diagonals crossing Tomba 2's terrain, dark diagonals crossing Ape's. That is the signature of a vertex landing far from where it belongs (a stretched/degenerate primitive), or of stray line primitives — NOT of sub-pixel boundary mismatch. The coverage numbers in G1.4 are real, but they were fitted to the wrong picture.

2. THE CONTROL WAS NEVER RUN. At no point was it confirmed that these lines are ABSENT with both toggles off on these builds. Every conclusion in G1.1-G1.6 assumed the feature caused them. Two other large changes landed underneath this work and are equally plausible causes:

  • the framework jumped 92 commits (Ape's pin 3c67a52 -> current master);
  • recomp-ui jumped 64 commits (bb62af1 -> origin/master), forced because the old pin could not compile against current framework master at all.

Do this first, before any further analysis: same scene, same spot, both toggles false, supersampling = 2, captured with screenshot_hires. If the lines persist, this is not the geometry feature and G1.1-G1.6 are describing something that was never happening.

Candidate to check if the feature IS implicated, given the signature: a stale sub-pixel override surviving to a later primitive. glb_draw_*_triangle calls precise_consumed() only on the GPU path — the !s_raster_ok software-fallback branch returns EARLY without clearing s_pc_valid, so an override could be applied to a primitive it was never computed for. That would displace a vertex arbitrarily and produce exactly these long stretched lines.

G1.8 — RESOLVED by isolation: perspective textures SHIP, geometry correction DOES NOT

The control run and the two isolation runs were finally done, on Ape Escape, OpenGL, supersampling 2, same scene each time:

geometry_correction perspective_texturing result
off off clean (the control — establishes the framework/UI jumps are NOT the cause)
on off lines
off on clean

perspective_texturing is good and should ship. It is unaffected by the coverage problem: it only alters UV interpolation inside a polygon whose provenance is fully proven, so a polygon either gets perspective UVs or keeps the PS1's affine ones. Neither outcome moves a vertex, so adjacent polygons cannot disagree about a shared edge and nothing can crack.

geometry_correction must not be offered. It moves vertices, and at ~5% coverage a corrected triangle meets an uncorrected neighbour along a shared edge — the seams in the isolation run trace polygon boundaries, confirming G1.4's mechanism (and retiring the "long scene-spanning lines" reading in G1.7, which misjudged the artifact). It cannot be fixed by tuning: it needs the full PGXP dataflow of G1.2/G1.3 to reach coverage where meshes move as one.

Recommended disposition: keep the perspective_texturing setting and its launcher row; withdraw the geometry_correction row until value propagation lands (leave the setting readable from game.toml/settings.toml so the work stays testable, but do not present it to players).

G1.9 — Disposition EXECUTED (2026-08-05): this is the shippable line

G1.8's recommendation was carried out. This branch (feat/pgxp-perspective-textures, renamed from feat/pgxp-geometry-correction) is the line intended for review and eventual merge.

setting default player-reachable? verdict
perspective_texturing false yes — the PGXP Precision mod (was a Display row until S1) ships
geometry_correction false no — game.toml only (settings.toml until S1) known broken, hidden

geometry_correction is deliberately still compiled in. It was not excised, because the setting is only worth keeping if the code behind it still runs, and G1.8 asked for it to stay testable. What was withdrawn is the launcher row — the only surface through which a player could have reached it. Combined with the false default, there is no path by which someone who is not editing a toml by hand can turn on a feature we know cracks meshes.

Standing constraint for future sessions. Do not add a geometry_correction control to any settings surface, and do not flip its default, until the coverage problem is actually solved — meaning the census reports something far better than the measured 5.2% hit / 93.1% ambiguous, on a real title, with the OFF control run first. Partial coverage is not a partial feature here; it is a visible defect, which is the whole finding of G1.1–G1.8.

The withdrawn row, the full post-mortem, the mechanism that would fix it, and the re-test protocol are preserved on park/pgxp-geometry-correction (see its G1.9).

G1.10 — PGXP value-propagation engine LANDED, phases 0+1 (2026-08-15)

The G1.2/G1.3 mechanism — precision travelling WITH the data — now exists (feat/pgxp-dataflow, tracked as beads-eio.3.48). Clean-room: the vendored duckstation/ tree is CC BY-NC-ND (NoDerivatives) and beetle-psx/ is GPL, both incompatible with this project's PolyForm Noncommercial license, so NO code was ported from either; they remain black-box behavioral oracles only. The engine implements the publicly documented technique from psx-spx + our own G1 analysis.

What exists now:

  • runtime/src/pgxp.cpp + runtime/include/pgxp.h: per-word RAM+scratchpad shadows (~10 MB, lazily allocated, fail-closed), per-GPR (+HI/LO) and per-GTE-data-reg shadows. Each PGXPValue records the sub-pixel 16.16 screen X/Y, the projected SZ depth, per-half validity flags, and the exact guest word it describes. The one safety invariant: a shadow is only believed after validating that word against reality. Overwrite-and- validate only — no side-effect modelling, so DMA/memcpy/untracked writers need no hooks at all (their stale shadows fail validation at use). The plain-store gte_precision_invalidate_word calls in memory.c are removed as redundant under this model (a small store-path win).
  • Hook funnel (runtime/include/pgxp_hooks.h): 5 entry points (psx_pgxp_load/store/alu/muldiv/cop2) taking the raw instruction word. Generated code will call them via PGXP_*() macros that expand to nothing unless compiled with -DPSX_PGXP=1 — the G1.3 codegen-time gate, as a preprocessor define over ONE generated tree (no flavour drift, base objects byte-identical). OverlayCallbacks gained an appended-last PGXPHooks* table (NULL-tolerant); the ABI bump arrives with the Phase-2 emitter change.
  • Both interpreters (dirty_ram_interp.c, psx_interpreter.c) call the hooks directly on loads/stores/COP2 transfers and the precision-carrying arithmetic (moves, add/sub, or-merge, 16-bit shifts, LUI, HI/LO). Ops that merely destroy precision are deliberately NOT hooked — validation already drops their stale shadows.
  • pgxp_cpu_mode (tier-2 arithmetic propagation, default off, matching the references' default) and pgxp_tolerance (sub-pixel clamp, default off) are new [video] keys and live-tunable via the TCP pgxp verb, which also live-toggles geometry/texture for same-scene A/B (the G1.6 method rule no longer needs a restart between arms).
  • GPU consumption is now PER-VERTEX (pgxp_get_precise_vertex): dataflow shadow first (validated against the actual packet word), the G1.4 exact ambiguity-gated position cache second (never carries depth), native integers last. Two safeguards on every precise vertex: truncation agreement (integer part must equal the native 11-bit parse) and the tolerance clamp. Mixed precise/native triangles are correct — a native vertex sits exactly where the uncorrected pipeline put it, bounding any seam to < 1px of sub-pixel fraction (the G1.1 cracks came from wrong-vertex substitution and all-or-nothing triangles, both gone). Geometry and perspective UVs now share one provenance source and compose per triangle.
  • Census: geom_correction grew a pgxp object (dataflow_hit / fallback_hit / native / value_mismatch / trunc_reject / tolerance_reject / w_valid). The G1.9 gate now reads: dataflow-hit share must be dramatically above the 5.2% position-cache ceiling before any launcher surface returns.
  • Tests: pgxp_test (roundtrips, half-word semantics, validate-on-read, DMA-overwrite rejection, repack arithmetic, suppression bracket, safeguards) + gte_register_access_test updated to the precise invalidation contract. 33/33 enabled runtime tests green.

Not yet (Phase 2+): emitter macros at the ~47 sites, the -DPSX_PGXP dual link targets + flavor-namespaced shard caches + ABI bump, BIOS regen, and the Ape/Crash/Tomba2 census + screenshot_hires A/B campaign. Until the emitter lands, compiled code feeds the engine only through the v14 swc2 sites, so dataflow coverage on a running title comes from interpreted code paths; the full-coverage census gate is a Phase-2/3 measurement.

Incidental fixes while landing this: the per-target psx_game_version.txt file(GENERATE) collision that broke every fresh single-config configure (runtime.cmake once-only stamp), and overlay_capture_retry_test not compiling under the SDL3 default (SDL_SetMainReady gone + inverted SDL_Init check — it now uses psx_sdl_init).

First real-title census (Ape Escape, 2026-08-15) — the G1.9 gate is PASSED. Worktree build against this branch (_wt-ape-pgxp, -DPSX_PGXP_VARIANT=ON -DPSX_DEBUG_TOOLS=ON, game C regenerated), memory-mode only (pgxp_cpu_mode off), boot → title → attract, per-10s windows: 79–90% dataflow hits (vs the 5.2% position-cache ceiling of G1.4), fallback ≤0.4%, value_mismatch 0, trunc_reject ~0, and every dataflow hit carried a usable depth. The residual native share is 2D HUD/sprite content, which is exactly what must stay native. Still to run before any ship surface: the windowed screenshot_hires off/on A/B (headless has NO hi-res mirror — scale:1 fallback, the same blind-instrument trap G1.5 documented), on Ape + Crash with Tomba 2 as the 2D negative control; the hook-overhead FPS measure (pgxp build, feature off, vs base); and pgxp-flavour (_f2) overlay-shard autocompile validation. Validation-build notes: title Release builds default PSX_DEBUG_TOOLS=OFF (no TCP), and a title launched without --game binds the framework default port 4370.

Interactive visual isolation, USER-VALIDATED (Ape Escape, in-game, 2026-08-15). Same scene, one toggle at a time, player observing live: OFF = baseline vertex jiggle; geometry ON unclamped = jiggle largely gone but sparse hairline background-bleed seams (a <1px disagreement along a long shared edge between a corrected triangle and an uncorrected neighbour — the THPS2 line class, NOT wild vertices; both reported artifacts vanished with the toggle off); geometry ON with pgxp_tolerance = 0.5 = seams gone, only sub-half-pixel misalignment remains. 0.5 is therefore the shipped default (config_loader.h), live-tunable via the pgxp TCP verb. Known tooling defect found on the way: screenshot_hires produces a tiled/black PNG at 768x480 scale-2 windowed (row-pitch bug in the hires readback) — the census + the player's own captures carried the session; fix it before the formal Crash/Tomba2 A/B.

G1.11 — PGXP as a title's default: hook build, mod at session start, dataflow only, exact projection (2026-10-01)

Ridge Racer Type 4 turns PGXP on by default. Getting there took framework changes within the elective PGXP enhancement. With PGXP off, nothing changes for any title. With PGXP on, its correctness fixes apply without a new option: the mod arms PGXP (item 2), hook builds default to dataflow-only precision without a tolerance clamp (item 3), and fully precise axis-aligned quads take the triangle path (item 4).

What R4 got from G1.10 as shipped: nothing. Base flavor, PGXP mod on: 0% dataflow hits, 3.6% position-cache hits, 0 of 282k textured triangles with perspective UVs. R4's course renderer copies SXY out of the GTE with mfc2/sw/lw/sw, and only swc2 is shadowed in the base flavor. The hook flavor already tracks all of it, with no R4-specific hooks: R4's near polygon subdivision interpolates in 3D on the GTE and projects every sub-vertex with RTPT.

  1. psxrecomp_add_game_runtime(... PGXP) builds the title's one runtime as the hook flavor (-DPSX_PGXP=1, overlay flavor 2, no _pgxp suffix, no clone). Generated C already carries the PGXP_*() sites, so a committed generated/ does not change. The option is parsed, so it can no longer be swallowed by an earlier multi-value argument. Test: game_runtime_pgxp_test (the real function body under cmake -P).

  2. The PGXP mod arms at session start. psx.enhancement.pgxp's activation used to arm the corrections, and main.cpp's renderer setup, which runs after activation, then re-applied the [video] baseline and switched them off. The activation now records a request (pgxp_mod_request), and the renderer setup's session arming (psx_pgxp_session_arm, runtime/src/pgxp_session.cpp) takes it, reads and clears it, and arms [video] OR the request. The PSX_GEOMETRY_CORRECTION / PSX_PERSPECTIVE_TEXTURING / PSX_PGXP_CPU_MODE / PSX_PGXP_CULLING env overrides still win. Because the arming takes the request, a later session whose plan is empty (netplay clears every mod, default-on ones included) cannot inherit it. reset_mod_owned_presentation() also clears it. A title that wants PGXP on by default overrides the builtin manifest at the same id and version with default_enabled = true (MOD_PACKAGES.md). It must not use [video] geometry_correction for that, because netplay does not clear it (see pgxp_mod_only, G1.12). Tests: pgxp_session_test runs the real arming against the real mod runtime, the builtin plugin and the builtin's own manifest (default-on override, netplay clear with and without the reset, the off switch, options, env overrides). R4's r4_pgxp_boot boots the built runtime and fails if main.cpp stops calling the arming.

  3. Automatic hook-build defaults and [video] overrides (game.toml; live over the TCP pgxp verb). A runtime built with PSX_PGXP=1 selects pgxp_tolerance = -1.0 and pgxp_position_fallback = false when those keys are absent. The runtime and overlay ABI enforce the matching hook flavor. Base builds keep 0.5 and true for their limited dataflow coverage. Explicit title tuning wins independently for each key, including Ape's validated clamp. PGXP remains elective; exact-projection shadows remain separate:

    [video]
    pgxp_tolerance = -1.0            # hook-build default: no clamp
    pgxp_position_fallback = false   # hook-build default: dataflow only
    pgxp_preserve_projection = true  # default false
    
    • pgxp_position_fallback = false ("dataflow only", the reference implementations' default) skips the G1.4 position cache and stops geom_note filling it. At hook-build coverage the cache only hands unrelated 3D fractions to CPU-built 2D polygons: on R4 (tolerance off, cache on), 30% of the HUD tachometer needle's vertices (rms 0.86 px).
    • pgxp_preserve_projection = true shadows the exact projection of each vertex, from the unshifted MACs and a true divide (pgxp_project_precise), instead of the GTE's integer IR1/IR2/SZ3 path. Only sf=1 vertices with no IR, SZ3 or divide saturation qualify; the rest keep the IR path. Guest SXY, MAC0 and FLAG are untouched (the GTE oracle test runs RTPS/RTPT with it on). The exact position can sit below the guest integer or more than a pixel above it, so truncation agreement becomes the window -4 < precise - native < 5 px. A packet half at or beyond the GTE saturation limit (-1024 / 1023) must still agree exactly. RTPS applies the same window: an exact projection outside it is not shadowed, and the vertex keeps the IR path's shadow, which always agrees, so it still draws precise instead of being rejected to native (ppp_window_fallback counts these; pgxp_ppp_accept). The exact math is skipped while the hooks record nothing (speculative passes). The tolerance clamp now measures the offset both ways. One non-visual consumer: [widescreen] precise_nclip takes its branch sign from the shadows, so in a title that sets both, that sign follows the exact projection too.
    • Keep pgxp_tolerance at its 0.5 default only for low-coverage titles (the G1.10 Ape result). At hook-build coverage the clamp is harmful: on R4 it keeps 26% of vertices, leaves 52% of drawn triangles mixed (a 4K capture shows a hairline across the road) and shakes more than stock (tables below). The old universal default stayed 0.5 to avoid changing every title that already arms PGXP through [video]. This historical default is now retained only in base builds; hook builds automatically disable the clamp unless a title supplies an explicit value.
  4. Fully precise axis-aligned textured quads skip the 2D rectangle shortcut. gp0_exec_textured_quad draws a quad whose integer vertices and UVs form an axis-aligned rectangle with gr_draw_textured_rect, at the native position. A world quad seen straight on (R4's building facades) lands there too, and against its precise neighbours that opened an L-shaped background seam at 4K. When PGXP is correcting and all four vertices are dataflow-precise (pgxp_probe_precise_vertex), the quad is drawn as its two triangles (rect_bypass). A rectangle with only some corners precise, such as a CPU-built sprite with one corner copied from SXY, keeps the shortcut and counts as rect_partial. Drawing it as triangles would mix precise and native vertices. R4 has none: 0 in every run below.

Observability: geom_correction's pgxp object reports the switches, ppp_produced / ppp_window_fallback, geometry-corrected triangles as tri_precise / tri_mixed / tri_native (the G1.1 crack exposure), rect_bypass / rect_partial, and the NCLIP counters of G1.12.

What tri_mixed can and cannot see. It counts drawn triangles with one or two precise vertices: a shared edge whose vertex is precise in one triangle and native in its neighbour. Under preserve-projection that disagreement is up to the window, several pixels, not a fraction. A vertex is decided per packet word, so two triangles that share a projected vertex make the same decision. tri_mixed is blind to the other crack classes: an all-precise triangle beside a game-built 2D one, and a gap where the game culled a face (G1.12). Zero mixed triangles is a statement about those runs, not a proof of no seams.

R4 measurements. Helter Skelter Grand Prix from a grid savestate, Cross held, 60-frame lead, 360-frame window, native internal resolution, macOS arm64, every run 0 dispatch and 0 segment misses. "Dataflow" is the share of GPU vertex lookups that took a validated shadow.

build / settings dataflow position cache drawn triangles mixed perspective UVs
base flavor, mod on, framework defaults (R4 before) 0% 3.6% 8.9% 0 / 282k
hook flavor, framework defaults (tol 0.5, cache, IR path) 25.7% 0.002% 51.6% (152k / 295k) 100%
hook flavor, tol off, dataflow only, exact projection 99.88% 0 0 / 312k 100%
R4 shipped (the above plus precise culling, G1.12) 99.91% 0 0 / 389k 100%

The vertices left native are the HUD needle (CPU-built from a sin/cos table) and vertices at the GTE saturation limit far off-screen, which draw where the hardware puts them. Other content with R4's shipped settings:

run dataflow drawn triangles mixed truncation rejects
boot through the menus, Grand Prix setup and car select 96.6% (rest 2D UI) 0 / 1.83M 0
attract demo, 3000 frames 95.2% (rest 2D UI) 8 / 1.49M 12
2P VS, pad 1 driving, 360 frames 100% 0 / 467k 0
2P VS, widescreen Fit, both pads driving, 600 frames 99.992% 149 / 730k (0.02%) 174

Every truncation-rejected vertex in the per-vertex dump of the last run (208 census vertices) has a packet half at the saturation limit. Of those, 94 come from the effect emitter (store PC 0x800173F0). The rest come from the course and near-subdivision stores (0x80064284..90, 0x80065B00..08). No in-range vertex was rejected, so the 149 mixed triangles each have a corner clamped far off-screen. The exact-minus-native offsets of shadowed 1P vertices measured -1.87 .. +2.60 px (R4: H = 290).

Frame-to-frame vertex wobble (dE: rms change of drawn-minus-exact between consecutive frames along tracked vertices, native px; x9 = 4K output px; the reference is the exact projection; slow start from the grid, 152 frames, 43k tracked steps; near = SZ3 < 1000):

mode err dE dE near dE at 4K
native (PGXP off) 0.834 0.321 0.460 2.89
hook flavor, tol 0.5, IR path 0.807 0.374 0.545 3.36
tol off, IR path 0.075 0.025 0.052 0.23

At speed (360-frame lead): native 0.417, tol 0.5 0.504, IR path 0.032. With preserve-projection the drawn position is the reference itself, so its row would read 0 by construction, not by measurement. It only shows that no drawn vertex fell back to the IR path or native in that window. What is established independently is that the exact projection is right: the pgxp_test and gte_register_access_test oracles check it against the GTE inputs.

Visible changes. Polygons stop wobbling and road, wall and sign textures stop bending (visible at every scale for textures, above native for geometry). Far thin features draw at their true size: distant lane dashes and the start line appear in the mirror, and the START!! board at the far end of the straight looks smaller. The first frame or two after a savestate load draw without PGXP (shadows are dropped on load, R4 builds packets a frame ahead). Before G1.12, the far road beyond the start gantry and over crests drew as strips with sky and backdrop between them, at 4K and in 2P. That was a culling gap, not texture minification.

Not covered. Semi-transparent mono quads still take their own rectangle path (UI boxes; none seen in R4's 3D); a PGXP depth buffer; savestates of the hook and base flavors are not interchangeable; above native resolution the GL native pack comes from the corrected high-resolution surface, so a title that reads VRAM back to the CPU would see it (R4 issued no GP0 C0 read from boot through a 2P race). Vigilante 8: 2nd Offense does: in battle it reads one VRAM pixel with GP0 C0 about every third frame (c0_history, now an always-on ring), and the reads that land in the drawn frame return the corrected pixels, so PGXP on and off write different RAM values (same PC path and cycles; measured over 3000 frames from one savestate). Netplay therefore draws its CPU-authoritative VRAM faithfully (sw_set_faithful_authority: no PGXP override, no texture filter), so peers agree whatever their shadows or Display filter.

G1.12 — Precise culling, the mod as a title's one switch (2026-10-01)

The crack G1.11 did not see. With every drawn vertex precise, R4's far road still broke into a ladder of strips with sky between them, beyond the start gantry and over crests, at 4K and in 2P. Same frame, PGXP off: solid road. R4's road emitter (0x80066268) projects a row with RTPT, runs NCLIP on both triangles (0x8006630C, 0x8006636C) and skips the row when neither is front-facing (slt/xor/beqz at 0x800663AC). Far rows whose native height rounds to 0 have MAC0 = 0 and are skipped. Natively their neighbours meet at the same integer y, so nothing is missing. With PGXP the neighbours sit at their exact y, 0.7 px apart at Helter Skelter's crest, and the skipped row is now a visible gap. tri_mixed could not see it: every drawn vertex was precise, and the missing face draws no triangle at all.

The fix: NCLIP takes the sign PGXP draws. A third PGXP option, culling, belongs to the psx.enhancement.pgxp package and is off in the builtin. A title's override turns it on, and the player can switch it off on the Mods page. While geometry correction is armed, gte_nclip computes the exact determinant of the three SXY register shadows (pgxp_gte_nclip_precise). It uses them only when all three are live, describe the current register words, and pass the same truncation agreement and tolerance the GPU applies to them. When that sign is nonzero and differs from the integer MAC0's, NCLIP returns the exact sign, with magnitude equal to the rounded exact doubled area, at least 1. FLAG keeps the integer result's overflow bits; matching signs leave MAC0 untouched. This is DuckStation's "PGXP culling" with a minimal footprint.

This changes guest-visible MAC0, and so the game's control flow, which is why it is opt-in at three levels:

  • only the mod arms it, and there is deliberately no [video] key for it;
  • a netplay session never arms it (PSXPgxpSessionConfig.netplay), even when the published netplay plan carries the mod (content negotiation, MOD_PACKAGES.md) and even from PSX_PGXP_CULLING: the shadows it reads are host-only, and a rollback load drops them on one peer only;
  • it needs geometry correction (psx_pgxp_session_resolve);
  • it is held off in every pass that is compared against another execution: the speculative and replay passes, which record no shadows, and the whole overlay shadow diff (psx_overlay_shadow_diff_active), including its interpreter pass, so both passes see the integer MAC0.

[widescreen] precise_nclip (gte_nclip_precise_bltz) compares against the MAC0 the guest read, so it stays consistent. Live toggle: TCP pgxp {"culling":0|1}; validation override: PSX_PGXP_CULLING.

Counters, kept whenever geometry correction is armed, culling on or off:

  • nclip_precise: NCLIPs with an exact determinant;
  • nclip_disagree: of those, the exact sign is nonzero and differs from the integer one. Each is a face the game culls or keeps on a winding the drawn face does not have, the crack class tri_mixed misses;
  • nclip_corrected: of those, MAC0 replaced.

On R4 (1P window above), 178,896 of 623,784 NCLIPs disagree (28.7%). With culling the game emits 24.5% more triangles (389k against 312k); in 2P the figure is 21.6%.

Measured on R4 (same-frame 4K A/B from the research frame gate, Helter Skelter start straight frame 200, crest frame 420, 2P VS start frame 150): the ladder and the backdrop blocks are gone, and the far road is continuous with its lane dashes. In places it reads better than PGXP off, whose integer rows smear. Thin lateral features that the integer test dropped now draw at their true size: start-grid lines across the road, the namco plate under the START!! board.

  • Packet headroom. R4 builds packets bottom-up in two buffers of 141,176 bytes (0x800ADD10 / 0x800D0488, set at 0x80021064, no overflow check). Sampling every second frame over 40 s drives, the high-water mark was 62.6 → 74.3 KB in 1P and 63.8 → 83.4 KB at the 2P VS start (PGXP without → with culling), against 137.9 KB usable below the buffer's top-down region (60% at peak). A title with longer draw distance must re-measure.

  • Guest identity. fp_identity, 12000 frames with no input (boot, menus, attract race), cold overlay cache, fresh memory cards. The stock build (framework before this work, base flavor), the hook build with the mod disabled, and the hook build with PGXP on but culling off are IDENTICAL pairwise, with no tolerance. With culling on, the run diverges at frame 10319, the first frame of the attract race, in RAM and scratchpad writes only (sp, sc, wc, ws): the packets differ. cyc, mmio and mc agree through frame 12000, so the game's register traffic, including its flips and DMA kicks, is unchanged. 2P VS from the start, both pads driving: one flip every 2.0 VBlanks with culling and without.

  • Cost. CPU ms per emulated frame (headless, turbo, 1P, native, 600-frame samples, three interleaved rounds, load average about 7):

    build median vs stock
    stock (base flavor) 4.37
    hook flavor, PGXP off 4.66 +7%
    hook flavor, PGXP on, culling off 5.02 +15%
    hook flavor, R4 shipped 5.07 +16%

    In real time under R4's default stack (internal resolution Match display: 10x, 4800 lines; widescreen Fit; frame rate at the 120 Hz display, interpolated; three interleaved 8 s samples, load average 6 to 12), guest VBlank/s and frame time:

    PGXP off PGXP, no culling R4 shipped
    2P VS, both pads driving 59.3 (16.84 ms) 58.8 (17.12 ms) 56.6 (18.42 ms)
    1P race 59.3 (17.00 ms) 56.1 (18.64 ms) 57.0 (18.14 ms)

    Most of the 2P cost is the extra triangles, about 1 ms of GL scene time at that scale: the GL renderer's cost is per primitive. In 1P the two PGXP arms are within noise. On this branch 2P draws no interpolated frames, so it was not measured with them.

[video] pgxp_mod_only (game.toml, default false). For a title that ships PGXP through the mod (R4): the mod becomes the one switch. The [video] geometry_correction / perspective_texturing / pgxp_cpu_mode values, game.toml's and the player's settings.toml, are not applied, and the launcher hides its Perspective textures row (has_geometry_precision = 0). Without it, that row showed "off" while the default-on mod corrected textures, unticking it did nothing, and ticking it kept texture correction on after the player switched the mod off. Netplay then runs with PGXP fully off.

G1.13 — CPU-built vertices: the GTE as a multiplier, and word copies (2026-10-06)

Symptom. Ape Escape with the PGXP mod: thin light/dark lines along the ground's grid edges at high internal resolution (bead beads-eio.3.274; present on the shipped f7f0ad10 pin, not a regression). Isolation on one savestate: PGXP off, or geometry off with texture correction on, removes them; texture correction off, nearest filtering, frame smoothing off do not. The pgxp_tri_ring showed whole ground cells drawn native next to precise neighbours and decals (0.9 px apart on the same projected vertex).

Two producers dropped precision. The pgxp_store_ring pinned both:

  1. Subdivided near cells (0x8001DB74). Edge midpoints are the projection of the 3D midpoint, computed on the CPU: CTC2 packs two tracked X (or Y) halves into a light-matrix row, MVMVA weights them by the vertex depths, MFC2 reads MAC, ADDU adds a rounding bias, DIV by the depth sum, MFLO, SH. CTC2 / CFC2 had no hook and MULT/DIV recorded imprecise results.
  2. The terrain grid (0x80044B00) copies projected corners with the unaligned-copy idiom lwl/lwr + swl/swr on aligned addresses, which the engine treated as untrackable.

Fix (cpu-mode). A scalar tier: registers carry an optional full-range precise value beside their half shadows. CTC2 / CFC2 are hooked (codegen 18; the BIOS emitter fingerprint now covers pgxp_hook_emitter), MVMVA recomputes its rows from the precise matrix halves / IR / translation and leaves scalars on MAC1-3 / IR1-3, and MFC2, ADD/SUB(I)(U), shifts, MULT/DIV, MF/MT HI/LO and OR-moves carry them; SH / SW turn a scalar that fits back into a coordinate half. The guest rounded that arithmetic its own way, so such halves are marked derived (PGXP_F_DX/DY): the GPU believes them within (-1, +2) px of the guest integer and the tolerance clamp ignores the guest's own rounding pixel. LWL at byte 3 / LWR at byte 0 (and SWL / SWR likewise) move the whole word and copy its shadow. Result in the Fossil Field scene: every ground triangle dataflow-precise, tri_mixed from ~10% of triangles to 474 of 2.05M, seams gone (test_pgxp pins the sequence).

G1.14 — PGXP renderer: depth buffer, perspective-correct colour, seam expansion (2026-10-06)

Audit against the references. Read from DuckStation's published source (github.com/stenzek/duckstation, CC BY-NC-ND 4.0, so behaviour only, no code: src/core/cpu_pgxp.cpp, src/core/gpu.cpp precise polygon path, src/core/gpu_hw.cpp DrawPrecisePolygon / SetBatchDepthBuffer / CheckForDepthClear / IsPossibleSpritePolygon, src/core/gpu_hw_shadergen.cpp vertex depth, src/core/settings.cpp defaults). GooseStation (tehrzky/goosestation_nx, GPL-2.0) is a patch set over a pinned DuckStation (skip engine, runahead PGXP state); its PGXP rendering is DuckStation's.

Feature DuckStation psxrecomp before now
Geometry correction (sub-pixel vertices) dataflow shadows, GetPreciseVertex yes (G1.10/G1.11, hook flavor 99.9% on R4) same
Culling correction PGXPCulling, default on yes, mod option (G1.12) same
Texture correction perspective UV via w yes same
Colour correction PGXPColorCorrection, default off no (Gouraud affine) yes, pgxp_color_correction, key F10
Vertex cache PGXPVertexCache, default off position cache (pgxp_position_fallback) same
CPU mode PGXPCPU, default off pgxp_cpu_mode (tier-2) same, live key F12
Preserve projection precision PGXPPreserveProjFP exact projection (G1.11) same
Tolerance PGXPTolerance -1 pgxp_tolerance same
Depth buffer PGXPDepthBuffer: per-vertex w as depth, LEQUAL, only for polygons whose w differ (3D) and opaque unless PGXPTransparentDepthTest; cleared on drawing-area change and when average z rises by PGXPDepthThreshold (4096) no yes, pgxp_depth_buffer, key 9
2D polygons sprite mode for non-3D precise polygons; PGXPDisableOn2DPolygons draws invalid-w polygons native unproven vertices native per vertex; precise axis-aligned quads bypass the rect path (G1.11) same; 2D never tests/writes depth
T-junction / seam handling none for polygons (line expansion only) none seam expansion, pgxp_seam, key F11

Depth buffer. gpu.c passes each triangle's SZ when all three vertices are dataflow-precise (gr_set_depth_triangle). The GL backend draws an opaque one with a LEQUAL test and write; depth is 1 - 2*256/(SZ+256) in NDC, linear in 1/z, so a plane's depth interpolates exactly in screen space. 2D, unproven, semi-transparent polygons, lines and rectangles neither test nor write. Depth mode is a batch key of both GL batches and rides in every high-resolution-window / native-wide replay command. Clears are a colourless depth-only draw through the flat batch (so every surface clears in painter order): before the first depth triangle after a drawing-area change or a fill, and when the average SZ rises by pgxp_depth_threshold (4096, as DuckStation). Two bugs found on R4 while landing it: a clear at window depth 1.0 can be clipped by the far plane (now inside it), and R4's one-native-pixel line quads joined a depth batch and wrote near depth along every road and wall edge (lines now always break the batch). On R4 the threshold clears ~25 times a frame; without it far beams vanish behind the previous view's depth.

Decals keep painter order. A plain LEQUAL depth (DuckStation's choice, no bias) cut R4's lane markings into the road: they are separate polygons drawn after it, a hair off its plane once SZ is quantised per vertex. A 3D batch is now two passes: the colour pass tests with its depth pulled toward the camera by 2% of the distance (PSX_PGXP_DEPTH_TOL) and writes no depth; a colourless pass then writes the true depth. A later surface within the tolerance wins as in painter order; anything clearly behind stays occluded. Inside one batch the order is painter's. On slots 1-3 at 10x the image matches depth-off within 96 / 36 px (tunnel) and the markings are whole. A near lane dash was still cut at the bottom of the screen (behind the tachometer, owner report): near the camera SZ quantisation and R4's near subdivision disagree by more than the tolerance. Triangles with any vertex nearer than SZ 1024 (PSX_PGXP_DEPTH_NEAR) now stay out of the depth buffer (painter order), the tolerance gains an absolute 48 SZ, and every displayed frame starts with a cleared depth buffer. Slots 1-4 at 10x: depth on vs off differ by 801 / 92 / 25 / 0 px, none in the tachometer area.

Perspective-correct colour. Gouraud colour on 3D triangles interpolates with 1/SZ (textured triangles with perspective UVs share their w).

Seam expansion. At internal scale > 1 a vertex that lies on its neighbour's edge only to the PS1's precision leaves a hairline onto the background (T-junctions of R4's subdivided near polygons; tunnel walls). Opaque 3D triangles move each edge outward (mitred, limited at sharp corners): fine = 1 output px (PSX_PGXP_SEAM_PX tunes it), wide = half a native px. UVs, colour, q and SZ are extrapolated with the barycentric coordinates of the new corners (perspective-correct where the attribute is), so textures do not slide. wide closes larger gaps but smears edge texels and thickens silhouettes (beam undersides at the R4 tunnel entrance); fine is the recommended setting. Both widths apply only above 1x, and only to triangles the depth buffer tests: with pgxp_depth_buffer off, and for near-camera triangles kept in painter order, a widened edge would draw over its neighbour, so those never expand. gl_frame_gen_test pins both (seam without depth draws exactly like no features; at 1x depth+seam like depth).

R4 (hook flavor, 10x headless-opengl, Helter Skelter tunnel, savestates at the entrance and inside). Thin-feature pixel counts (features narrower than half a native pixel against both neighbours, HUD excluded): entrance base 1524 / PGXP 3560 / +depth+colour 2070 / +seam fine 1410-1518; inside base 2323 / PGXP 539 / +depth+colour 540 / +seam fine 541-547. The remaining counts are mostly texture detail; the visible base cracks along the tunnel walls are gone with PGXP and the residual short ones with fine. Dataflow 99.86%, mixed triangles 0, 0 dispatch / segment misses; guest pace equal with the features on and off. Diagnostic: PSX_PGXP_TRI_LOG=<file> logs every triangle (depth mode, x y SZ) while <file>.on exists.

Off costs nothing; Smooth motion matches. gpu.c sends gr_set_depth_triangle only while a PGXP renderer feature is on (gl_renderer_pgxp_render_wanted), so with them off the render thread records no extra RTH_DEPTH per triangle. The SZ rides in the textured vertex's unused colour alpha (negative when present), so the vertex stays 26 floats for every title. The setters sync with a live render thread (GL_RT_SYNC) like their neighbours. A depth-mode change ends a textured batch as its own reason (batch_diag entry 8). Smooth-motion in-between frames replay RTH_DEPTH and test depth like the real frame, from their own clear: gl_frame_gen_test draws overlapping PGXP triangles out of painter order and requires the in-between frame at phase 1 to equal the real one, with each feature (depth, colour) alone and together; each changes the real image there.

S1 — Enhancement settings become built-in mods (2026-10-24, beads-eio.3.398)

The launcher's Settings page keeps PC/system controls only (window size, fullscreen, renderer, VSync, audio volume, hotkeys, BIOS, multitap; recomp-ui docs/SETTINGS_STANDARD.md). The game's look moved to five framework built-in mods, all default-off, all game_id = "*", all host-only ([[plugin]] netplay = "host_only": never in the plan fingerprint, the player's own in netplay):

Mod Replaces Options (Game default = the title's game.toml value)
CRT Filter (psx.enhancement.crt-filter) Screen model, Scanlines rows Screen model, Scanlines, Scanline strength
Image Filtering (psx.enhancement.image-filtering) Antialiasing (linear present), Texture filtering, FMV filtering rows Smoothing, Texture filtering, FMV filtering
Enhanced Audio (psx.enhancement.enhanced-audio) High-quality SPU row (on = high-quality SPU)
Rewind (psx.feature.rewind) Rewind / buffer / interval rows Rewind buffer, Rewind interval
Internal Resolution (psx.enhancement.internal-resolution) Internal resolution row (launcher and in-game), Dynamic resolution / Lowest resolution rows Internal resolution: Game default, Native … 8K, Match display, Auto

The title's [video] / [audio] keys stay its baseline: mod off is exactly what the title ships. Activation only records the request (host_enhancements.h); the renderer setup resolves it over the baseline after activation, the G1.11 lesson. Perspective textures left Settings for the PGXP Precision mod; the Dynamic resolution checkbox and Lowest resolution row became the Internal Resolution mod's Auto (Match display with dynamic resolution, the title's floor). Its option changes apply live through the same gl_renderer_set_internal_scale_live path the debug server's video_set uses, and never during a netplay match. The player's settings.toml no longer has internal_resolution, supersampling or supersample. The audio Sample rate row left too: the device opens at the runtime's built-in 44.1 kHz (old [audio] frequency keys are ignored); Volume stays. Graphics presets ([quality.*], hardware detection, Re-detect) were removed outright. A mod that presents with vsync off declares [[feature]] vsync = "off" and the launcher shows its VSync row overridden.

IR1 — Internal resolution presets (Native … 8K) and the GL scale ceiling (2026-09-26)

What the player gets. Internal resolution presets: Native, 720p, 1080p, 1440p, 4K, 5K, 8K, Match display (and, since S1, Auto). A preset is a target height and resolves to an integer scale over the title's reference height ([video] resolution_reference_lines, 240 by default): 3x, 5x (1200 lines, area-resolved to 1080), 6x, 9x, 12x, 18x. It was a Settings row until S1 (2026-10-24); it is now the built-in Internal Resolution mod over the title's internal_resolution. It is presentation-only, so it also applies in netplay (each peer its own). Keys and precedence: docs/config_schema.md.

Why the old ceiling was 4. The software renderer keeps a CPU mirror of 1 MiB·S², so it caps at 4, and every backend inherited that cap. OpenGL keeps the hr surface on the GPU; its real limits are the driver's texture, renderbuffer and viewport sizes (the surface is 1024·S × 512·S) and memory. The GL backend now clamps at context init to those limits and a 2 GiB budget, logs the clamp, and steps down instead of failing (a failure used to drop the whole backend to software). Measured on an Apple M4 (4.1 Metal - 91.7): all three limits are 16384, so the full-VRAM surface stops at 16x.

Fixes that only bite above 1x (all gated on S > 1, so native is byte-identical):

  • Lines: glLineWidth(S) is capped at 1 on core profiles (macOS reports GL_ALIASED_LINE_WIDTH_RANGE 1..1), so every line was one hr pixel thick. Lines are now quads one native pixel thick that include both endpoints, like the software rasterizer.
  • Present: a supersampled source more than 1.25x the output is area-resolved (bilinear taps over the pixel's footprint) instead of one tap, and the UV inset is half a texel, not half a native pixel (which cropped (S-1)/2 texels per edge).
  • The copy scratch is sized to the largest copy and the mask stencil is rebuilt in tiles over the primitive bbox union, so a large S does not cost another full-size surface.
  • The CPU present path under GL presents at 1x (its readout is native) and its staging buffer no longer grows with S².
  • The macOS game window gets a high-pixel-density drawable when the player picks anything above native; without it the drawable is in points and the compositor stretches it, throwing half the resolution away.

Verification. gl_scale_invariance_test renders one GP0 scene on a hidden real GL context at 1, 2, 3, 5 and 9x and requires identical guest-visible VRAM (the pack of the hr surface) at every scale, one-native-pixel lines at internal resolution, and a 32x request that stays on GL at the driver clamp. In a running game, video_info reports the requested and effective scale and the drawable; screenshot_hires reads the hr FBO at full size.

IR2 — True 8K past a 16384 texture limit: the high-resolution window (2026-09-26)

8K is 18x at the usual 240-line reference, and a full-VRAM surface at 18x is 18432 px wide: over the 16384 limit of Apple's GL (and Intel, MoltenVK). The M4 returns GL_INVALID_VALUE for it. A title only needs the displayed frame at 18x, so past that limit the GL backend splits the job:

  • The authoritative VRAM stays the ordinary hr surface at 1x. It is the native renderer unchanged, so pack, CPU readback, render-to-texture sampling and VRAM copies are exactly the native results.
  • A presentation-only surface at S covers the displayed columns, W = [x0, x1) × all 512 rows (R4: x 0..319, 5760×9216 at 18x, 405 MiB). Every GPU write that touches W is mirrored into it: textured and flat batches, lines (as quads), fills, uploads and the depth24 clear, and VRAM copies (hi→hi when the source lies in W, otherwise the 1x source upscaled). It has its own stencil, rebuilt from alpha like the hr surface.
  • W starts empty and grows, rounded to 64 columns, the first time a display rectangle outside it is presented, seeded by upscaling the 1x content. The present, hold-last, interpolation capture, screenshot_hires and the native-wide centre read W.
  • When the union of the displayed rectangles no longer fits one surface (side-by-side 512-wide buffers at x 0 and 512 need 18432 px at 18x), W becomes up to four tiles, each its own surface over a column range (there: one per buffer, 9216×9216 and 648 MiB each). Every mirrored write goes to each tile it touches, so a game flipping between horizontally adjacent buffers presents every frame at S instead of alternating with 1x. A display that no tile can hold within the GPU limit, the memory budget (all tiles together) or the four-tile cap presents at 1x, with a log line. Everything drawn inside a tile, and axis-aligned rects, fills, copies and uploads across a tile edge, match the single surface byte for byte; a sloped primitive that crosses a tile edge is clipped there by GL, which can move its interpolated colour by one step or its coverage by a subpixel along it.
  • A VRAM copy into W stages its S-scaled source in a scratch of its own, in column chunks that fit the GPU limit (910 columns at 18x on a 16384 GPU), walked in memmove order so an overlapping copy still reads pre-copy pixels. Staging textures record a new size only after the driver accepted it; a request past the limit is refused with a log line.
  • The window's copies of textured, flat and line draws are queued and replayed into it in one pass at the next sync point: anything that changes what they sample (a pack of the raw mirror, an upload, the depth24 clear), any other write to the window (fill, copy, upload, stencil rebuild), and every read of it (present, capture, growth). Mirroring each batch as it was flushed switched framebuffers twice per batch; on Apple's GL (Metal underneath) each switch ends a render pass, which measured ~80 us, 10 ms per R4 frame (8K ran at 21 fps). Queued, the flush CPU cost is 0.6 ms per frame and 8K holds R4's 60 Hz present cadence on an M4.

It engages only when the full-VRAM surface cannot hold the requested scale; below that (up to 16x on the M4) nothing changes. PSX_GL_HIRES_WINDOW=0/1 disables or forces it. PSX_GL_MAX_DIM=N lowers the GPU limit the backend plans with (never raises it), to check a layout on a smaller GPU.

Evidence. gl_scale_invariance_test forces the window at 2, 3, 5 and 9x and requires the frame at internal resolution to be byte-identical to the full-VRAM surface at the same scale (copies inside, into and across the window edge, fills and uploads across it, mask set/check, all four blend modes), and the guest-visible VRAM to be identical to 1x; it also runs the window at 18x. Its side-by-side runs flip two 512-wide buffers: one surface at 5x and two tiles at 9x under PSX_GL_MAX_DIM=8192 must equal the full-VRAM surface, including copies between the buffers and 1000-column copies staged in chunks; at 18x both buffers must read back at S with two tiles. In R4 on an M4, the 8K preset reports effective_scale 18, internal_lines 4320, hr_scale 1, hires_fbo 5760x9216, and screenshot_hires in a race is 5760×4320 with the rear-view mirror present.

DD1 — Opt-in draw-distance clamps (2026-10-01)

[[draw_distance.clamp]] (docs/config_schema.md) lists a title's ordering-table range guards. While a trusted mod has called psx_mod_set_draw_distance_clamp(1), the guard's depth index is clamped to the last safe slot before the guard runs, so far geometry the game would drop stays in the farthest bucket (drawn first, under everything nearer) instead of popping in later. Raising the limit instead can push a biased primitive past the end of the table. Off by default and at every session start; identity when off; main executable only (overlay code keeps its own code); native code and the dirty-RAM interpreter agree (draw_distance_codegen_test, draw_distance_interp_test). First user: R4's course renderers (RidgeRacerType4Recomp, Max Detail).

Default camera-plane clipping for native-wide textured worlds

psx_mod_set_native_wide_projection_correction(1) automatically enables both horizontal projection recovery and camera-plane clipping. A large face can remain visible while a corner crosses the camera plane: unsigned SZ, the capped H/Z divider and saturated SXY cannot represent that face. PGXP carries the signed homogeneous projection from RTPS/RTPT through exact full-word copies. CPU arithmetic, partial writes, stale words and timeline invalidation discard the association; sandbox rollback restores it with the rest of the precision shadow.

Native-wide OpenGL sessions with projection correction clip proven textured faces at depth 1 and the visible bounds before perspective division. UV and color attributes follow each intersection, and ordinary GPU batches, painter order, masking, texture filtering and canonical/wide readback remain in use. The existing guest GTE registers, gameplay state and title culling branches do not change. Software/CPU-authoritative rendering, 4:3 and missing provenance use the original path. The larger PGXP value increases the full 8 MiB RAM shadow from 40 to 72 MiB; projection calculations run only while projection correction is enabled. Session start resets that mode before the new plan's activation. The old psx_mod_set_native_wide_near_clip entry point remains for source/ABI compatibility, but its argument no longer changes clipping. The enhanced view is elective; clipping its proven geometry correctly needs no separate opt-in.

MediEvil II's title hallway provided the camera-crossing reproduction. The GL authority/order fixture passes 197 checks at 1x and 4x; provenance tests cover negative depth, stale packets, identical partial writes, rollback and mode guards. Live captures remove the previously observed triangular holes; the owner confirmed the hallway walls stay intact. Broader map coverage remains separate from this reproduction.

Precision in expanded primitive buffers (2026-10-07)

The draw-distance enhancements can move primitive storage to the allocated GPU-DMA aperture (0x80800000 CPU pointers, 0x00800000 packet keys). PGXP now tracks those words separately from main RAM, with sparse stable pages created only when a tracked store writes them. Reads refuse unallocated ranges, invalid aliases and stale words. Position, depth and projection receipts retain the aperture address; checkpoint journaling restores their old shadows without copying whole arrays or aliasing the low main-RAM word.

THPS2 exposed the gap: correction was enabled, but every recorded texture-depth lookup missed the expanded primitive buffers and no textured triangle received perspective correction. The focused packet-shadow fixture covers allocation bounds, CPU/DMA aliases, separation from RAM, stale words, generation changes, relocated receipts and rollback including a page created during a checkpoint. The existing PGXP propagation fixture still passes. The rebuilt title now records actual dataflow hits and perspective-corrected triangles. These are coverage checks; broader visible texture/geometry quality remains owner-reviewed.