Commit Graph

479 Commits

Author SHA1 Message Date
Alex Vanderveen 87699f5ab9 bump 2026-08-23 15:44:58 -04:00
Alex Vanderveen 84901d3131 Merge PR #156 (feat/ws-exact-nclip) into feat/rbengine
Widescreen precision: exact-sign NCLIP, GTE clamp rescue,
backend-invariant entry hooks — plus perf round 1, pacing fixes,
self-profiler, and IRQ-redirect recompiler fixes from the branch.

Conflict resolutions:
- runtime/src/gpu_gl_renderer.c (bezel_tiles_col): combine both bezel
  mark-sizing fixes — height-anchored size with PSX_BEZEL_SCALE
  (rbengine, fixes ultrawide growth) floored by the caller's
  window-proportioned mark_px (PR, fixes thin-margin shrink); an
  explicit env override wins in both directions.
- runtime/src/main.cpp: keep both additions — netplay_resolve_link_mode
  (rbengine) and the PSX_SELF_PROFILE sampler (PR).
- runtime/tests/test_fps_telemetry_opt_in.py: re-pin the structural
  guard's format needles to the PR's dual speed readout strings
  (VBlank/Render/Display); the opt-in guards are unchanged.

Verified: psxrecomp-game rebuilds; gpu_gl_renderer.c compiles clean;
ctest failure set identical to pre-merge HEAD (no regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdzpVVH912V9ktt4n4kntj
2026-08-20 14:48:29 -04:00
Alex Vanderveen 3cb2a60dc3 bump 2026-08-19 22:50:13 -04:00
tetrisgm 8ebfdb4a3d perf: profile-guided round 1 — tex_pack prim bookkeeping, xlate scan, PGXP ALU gating
First real attribution (in-process 1 kHz IP sampler, 75k samples at
oc900, play build). Top sinks and their fixes:

- tex_pack_on_textured_prim (7.4%): per-prim mutex + CLUT hash + linear
  scan over up to 8192 uploads — entirely matched-set/dump census, a
  debug/authoring surface with no reader in play builds (substitution
  lives in tex_pack_lookup_replacement). Gated out of play builds
  unless dumping. Confirmed gone in the follow-up profile.
- text_xlate_on_dispatch (4.2% -> 0.8%): the a0..a3 record scan serves
  capture (off in release) and STRING substitution; a fixes-only load
  (vram patches / glyph labels) has an empty string table, so the scan
  could never act. Early-out on empty table; the throttled patch blocks
  still run.
- psx_pgxp_alu/muldiv (3.6%): ALU/MULDIV shadows feed only tier-2
  cpu_mode and the correction consumers' chains; the exact-sign NCLIP
  arm is LOAD/STORE/COP2 only. Hook bodies now gate on
  (cpu_mode || full_hooks) — full_hooks maintained by
  gpu_pgxp_rederive_enable — and the remaining per-instruction CALL
  cost is removed by an inline g_pgxp_alu_armed check in the
  PGXP_ALU/PGXP_MULDIV macros themselves. Correctness: a stale shadow
  from the gated period is dropped by pv_validate at every consumer
  (the engine's single safety invariant, verified at the MTC2 path).

Measured effect of the round at oc900 (before the macro gate):
sustained 0.73-0.80x -> 0.87-0.91x; streaming-window dip 0.35 -> 0.50;
post-window touching 1.00x. Fresh profile after: xlate and tex_pack
prim hook out of the top-15.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 17:06:49 -07:00
tetrisgm 3b6986827e recompiler: image-edge fallthrough must tail-transfer — the WipEout 3 static-bake boot exit root cause
A region-truncated final block that runs off the end of its function fell
off the C function body when no in-image fallthrough function existed:
the entry-switch prologue had consumed cpu->pc to 0, so the dispatcher
returned "handled" with pc==0 and the top-level trampoline read it as
"program ended". Witnessed live in the WipEout 3 ntsc120full8 static
bake: the 0xD5000 region variant's block_800D7FC0 ends at the region
edge (continuation 0x800D8004 lies outside the image); the moment CD
streaming loaded content matching that variant's identity ranges the
dispatcher routed 0x800D7FC0 into the truncated block and the run died —
the deterministic "execution completed, PC=0" at ~10 s into boot, ~frame
1221, byte-for-byte reproducible.

Both code_generator tail sites (plain functions and alias hosts) now
emit `cpu->pc = <end+4>; return;` when the last live block runs off the
image edge — the CPS tail-transfer the full_function_emitter already
emits for in-image fallthroughs. The dispatcher routes the published
continuation to whoever owns it (a neighboring region variant, AOT
text, or the interpreter). Faithful and class-level: no per-game
anything.

Diagnosis chain that cornered it (all landed as permanent tooling):
- pc0 escape journal site 14 (trampoline top-level exit) named the last
  dispatched target: 0x800D7FC0.
- site 15 (dirty-dispatch handled-with-pc0 tripwire, now journaled with
  a route tag) proved the producer was route 3 = the static overlay
  dispatcher, first occurrence, not in exception context.
- Return-shape census of every candidate variant body showed no
  return-with-pc==0 path — forcing the discovery that the dispatch case
  for 0x800D7FC0 routes FIRST to the 0xD5000 variant, whose truncated
  tail block falls off the end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:55:50 -07:00
tetrisgm ada331a401 pgxp/gte: exact-sign NCLIP hardening — validate-on-read, shadow-silent replay, idempotent enable
Root-causes the transient 1-frame sign-corner flicker at wide aspects
(sign faces blink when the exact-sign NCLIP path silently loses its
shadows for one frame):

- gte_nclip was the only shadow consumer skipping the engine's single
  safety invariant (packed-word validation). A stale FIFO shadow (e.g.
  after an SXYP MTC2 shifts the real FIFO but not shadows 12/13) could
  invent a silently WRONG winding sign. New pgxp_get_gte_sxy_checked
  validates the shadow's word against the live SXY register; staleness
  now demotes to the honest integer fallback.
- Overlay shadow-diff REPLAY passes ran the pgxp memory hooks live
  (record pass's stale FIFO shadows re-validated against different
  vertices -> flags dropped, live slots overwritten -> arena-wide
  provenance kill = one-frame integer-sign blink, plus false
  SHDIFF_GTE_DATA divergences permanently demoting shards to interp).
  gte_replay_side_effects_begin/end now bracket pgxp_suppress so replay
  is shadow-silent, like the speculative validation passes. The nclip
  precise path is additionally gated off inside the sandbox and
  speculative passes.
- pgxp_set_enabled is now idempotent: a redundant re-derive (config
  apply, correction toggle, debug A/B) no longer costs a generation
  bump, which killed every live shadow and blinked one frame of
  exact-sign coverage.
- Observability: free-running NCLIP precise/fallback/corrected counters
  and a PGXPStats.invalidations counter (bumped at the generation-bump
  site), both exposed in the geom_correction debug reply.

All changes are inert at 4:3 (ws_precise_nclip gate) and touch
host-only state exclusively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:39:10 -07:00
tetrisgm d9f31e1efc tex_pack: config-owned exclusions ([video] hd_texture_exclude)
The pack directory's exclude.txt is game content (gitignored), so it
silently disappears whenever the pack is regenerated - which re-enabled
HD substitution of WipEout 3's four font atlases and produced the
doubled-glyph defect (sub-texel HD strokes under the game's two-pass
shadow draw; coverage.json proved atlas 4cb2ec4e substituting). The
config now carries the exclusions durably; they merge with any
pack-local exclude.txt, and semantics are unchanged (texel-source
exclusion only, applied at tex_pack_lookup_replacement).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:07:30 -07:00
Alex Vanderveen b7f3642725 psx-dual-link wipeout support 2026-08-19 09:12:06 -04:00
tetrisgm ae7925c1b5 sio: make the Ape Escape card-IRQ unstick pump opt-in ([runtime] ape_card_unstick)
The unstick pump - a nest repair for Ape Escape LOAD that force-re-edges
I_MASK.7/IRQ7 after a LOAD-style probe abort - ran enabled by default for
every game. On WipEout 3 SE under the NTSC x2 CRTC its arming detector
misfires during the boot memcard check, and the forced IRQ storm keeps the
kernel card ISR acking idle bytes forever (traced: 40k+ card_ack 0x00 at
~1.3k-cycle spacing, all Sw/HwCARD completion events stuck ACTIVE, zero
delivered), so the game waits on the memcard screen indefinitely. A/B with
PSX_APE_CARD_UNSTICK=0 boots straight through.

Per-game hacks are config-owned: default OFF, enable with [runtime]
ape_card_unstick = true (Ape Escape's config should set it), and the
PSX_APE_CARD_UNSTICK env var still overrides in either direction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 02:18:27 -07:00
tetrisgm 4fc71ddf68 Present-skip pacing gate: query live instead of caching; floor bezel mark size
The swap-paces gate from the previous commit was a flag cached from
apply_present_cadence(), which can run before the GL renderer is live -
the gate never engaged and the unpaced-skip hole survived. Replace it
with psx_present_swap_paces(), evaluated by the renderer at every skip
decision, so no cached copy can go stale across init order or cadence
changes. The setter is gone.

Bezel: floor the tiled mark size at 20% of the window's short dimension.
A thin letterbox band no longer shrinks the marks into busy little rows -
they stay window-proportioned and the band crops them symmetrically, so
bands read like the side columns do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 01:37:45 -07:00
tetrisgm 708ae95ae1 Present: never skip the swap when vsync owns cadence; bezel "mounted screen" treatment
Pacing: when driver vsync owns the frame cadence the blocking Swap IS the
game's clock, but the unchanged-frame present skip bypassed it - every
skipped swap silently unpaced that frame. WipEout 3 at 30 Hz sim renders
every other vblank, so half the frames ran unpaced: the game sped to a
fluctuating ~2x with pitched-up CD audio and visible frameskip, EXCEPT while
the FPS readout was on, whose persistent OSD line forced a swap per frame
and masked the hole ("the moment I do fps readout off it goes stupid fast").
gl_renderer_set_swap_paces(), derived in apply_present_cadence(), disables
the skip whenever the swap paces. Real hardware scans out every vblank
regardless of change.

Bezel: replace the flat tiling with a mounted-screen treatment - deep-tinted
ground, the tiled marks at watermark subtlety (0.22), a distance-field shade
(rounded-rect SDF against the live viewport rect) running near-black beside
the game to a faint pattern at the outer edges, and a resolution-scaled
hairline seam terminating the viewport. The 200 ms fade-in from black on
viewport-rect changes folds into the shade endpoints. Game framebuffer
untouched; all of it derives procedurally from the viewport rect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 01:27:59 -07:00
tetrisgm d31953fe6b Fix overlay autocompile: missing cull-widen ABI symbols; PGXP hooks swallowed by #endif
Two bugs that together kept every mod-patched text page interpreted forever
(the WipEout 3 framerate mod's 74 divergent pages -> ~0.5M interpreted block
dispatches/sec in play):

- The [[widescreen.cull.widen]] emitter writes psx_ws_cull_slt_widen /
  psx_ws_cull_slti_lower at configured sites, including in overlay-resident
  code, but the overlay dispatch preamble never defined them: every shard
  containing a widen site failed to link (undefined reference) and the
  autocompile pipeline reported compile-error for the whole region. Mirror
  both helpers into the preamble following the documented self-contained,
  byte-identical convention.

- append_pgxp_hooks joins the hook onto the translated statement with a
  space; the block-cycles MULT/DIV/MFLO/MFHI translations end in `#endif`,
  and the preprocessor discards tokens after #endif (the "extra tokens"
  warning class) - so those hooks silently never ran. Give the hook its own
  line when the translation ends in a directive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 00:30:29 -07:00
Alex Vanderveen 355605ffc0 psx_link 'shm': two-process serial link — full-speed link play
Each console is a complete single-console runtime in its own process (own
core, own window, own pad); the SIO1 cable is a pair of SPSC rings in a
shared-memory segment, paced by the bounded-skew barrier the fiber dual
mode validated. In-process dual halves the frame rate by construction; the
threads-in-one-process alternative needs ~590 device statics and 66 config
setters made per-machine. Two processes get each console a core for free
and upgrade to two PCs later by swapping the transport.

Design points (psx_link_shm.h):
- LINK-TIME: processes boot at different wall times, so due-cycles run on
  local-cycles-minus-epoch, epoch latched when the pair forms.
- Ops stay pure (psx_link.h determinism contract); wall clock only in the
  barrier poll (peer-death detection -> unplugged-cable semantics).
- Lookahead defaults to ~one PAL frame: below that, each side's ordinary
  pacer idle (clock frozen up to 8 ms/frame) stalls the peer -- measured
  0.57x at a quarter frame, 0.30x at the fiber-tuned 8192. Pair speed is
  min(A,B) by design; menus/races pair at 1.0x, phase-shifted intro FMV
  dips compound and that is correct lockstep behaviour.
- PSX_SIO1_BACKEND=shm PSX_LINK_SHM_ROLE=a|b selects; PSX_LINK_SHM_NAME,
  _LOOKAHEAD, _LATENCY tune. [runtime.link] backend="shm" also accepted.
- Savestate during link play serializes an empty wire (in-flight chars
  lost; handshakes retransmit). Cross-process co-checkpointing is future
  work.

Verified: two instances pair, exchange lines state, hold skew within the
bound, and run at min-of-pair speed with ~3s total barrier wait per 45s of
phase-shifted intro FMV; two UNLINKED instances hold 1.00x simultaneously
(16-core host), isolating all coupling cost to lockstep semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 20:19:33 -04:00
Alex Vanderveen 85d167d559 Merge PR6 psxrecomp head (e27eac7) into feat/rbengine
Conflict resolutions:
- boot_state.h: section-tag collision at 0x11 — master shipped BS_SEC_SIO1
  there (all v5 states + dual-console switch blobs), so the branch's
  BS_SEC_TEXPACK/BS_SEC_MODSET renumber to 0x12/0x13. PR-branch dev states
  are the only casualty.
- boot_state.c: adopt the branch's backpatched true section count (placeholder
  + write_section_raw counter), which subsumes master's constant-minus-
  exclusions arithmetic; keep master's section-exclude guards on CPU/RAM
  writes and the branch's MODSET-first mod-plan guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 17:33:50 -04:00
Alex Vanderveen 12cac92d4b pgxp emitter newline invariant; dual CPU banking + SR/CAUSE re-registration; PSX_DUAL_CONSOLE=0 veto fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 17:31:21 -04:00
tetrisgm e27eac7366 diagnostics: per-prim PGXP census, texcorr-miss census, live overclock; xlate fixes-only files
- pgxp_census / pgxp_texcensus (debug server): per-primitive resolution
  classes and UV-correction miss reasons with screen bboxes for the last
  complete frame, double-buffered by frame parity. These attributed the
  swimming-wall and flickering-ship classes down to individual packets.
- overclock command: live CPU overclock set/get for anchored same-scene
  A/Bs (the 100%-vs-900% pacing measurements).
- text_xlate: load translation files that carry only vram_patch/glyph
  entries (previously gated on a non-empty "entry" array), enabling
  data-fix-only files like the WipEout 3 palette-gap patch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 13:54:01 -07:00
tetrisgm 18272616a1 savestate: dynamic section count; refuse cross-mod-config loads; tex_pack section
The file header's section count was HARDCODED to 16, so ANY appended
section made the loader stop early and poisoned every later section - the
long-standing "adding a section breaks a later one" trap. BsOut now counts
sections and backpatches the header (file via fseek, memory in place).

New BS_SEC_MODSET rides FIRST: the resolved mod-plan fingerprint. A load
whose fingerprint differs from the running plan is refused before any state
is applied, with a message naming both plans - previously such loads
half-applied and died at a null PC. Old saves without the section still
load (best effort), matching the skip-unknown policy.

BS_SEC_TEXPACK (dormant since the count trap) is enabled: tracked-upload
rects+hashes persist, pixels rebuilt from restored VRAM and hash-verified,
so HD texture substitution survives savestate loads. Full save->load->run
roundtrip verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 13:53:46 -07:00
tetrisgm c2c426424c pgxp: size the shadow to live RAM on every enable; carry depth across rebuilds
Two coverage holes measured live on WipEout 3 SE:

- The shadow was allocated lazily ONCE; when the first enable ran before the
  8 MB mod expanded RAM, a 2 MB-wrapped shadow aliased every extended-RAM
  packet for the whole session (the player ship's packets resolved fully
  native - flickering ship textures, dark track dashes). Re-size on any
  enable whose live RAM size disagrees.

- Every half-rebuild recovery path (SH pair, LH/LHU, SLL/SRL/SRA by 16,
  OR repack, ADD/SUB screen translate) preserved positions but dropped the
  vertex depth, disarming perspective UVs on every packet the game builds
  from halves ("swimming" wall panels). Depth now rides along whenever the
  contributing sources agree; the truncation-agreement gate still owns
  mixed-source rebuilds.

Adds pgxp_debug_shadow_class: which rung of the load ladder fails for a
word, for the texcensus diagnostic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 13:53:23 -07:00
Alex Vanderveen 774865aad9 wipeout mod support 2026-08-18 12:07:30 -04:00
Alex Vanderveen cb3bd493fc chore: sync 2026-08-18 08:49:24 -04:00
tetrisgm dc00e5a1e8 tex_pack: position-exact dedup; savestate-section groundwork (dormant)
DEDUP: a re-upload of identical content at a DIFFERENT rect was deduped by
hash+size, keeping the stale rect -- origins computed from it were off by the
position drift. Dedup now requires the same position; the origin/src handed to
the renderer come from the live containing upload. NOTE: this did NOT cure the
strip vertical offset (top row cut, bottom duplicated) -- that symptom persists
on screen and its root cause is still unidentified. The change stands on its
own correctness, not as that fix.

SAVESTATE SECTION (BS_SEC_TEXPACK): written and applied correctly in isolation
(telemetry showed 1360 entries rebuilt from the state's own VRAM), but with the
section present the overall state load FAILS in a LATER section, cause not yet
isolated -- and old builds hard-reject unknown tags (`default: return 0`), so
the section also breaks backward compatibility. The boot_state wiring is
REVERTED; tex_pack_state_bytes/write/apply remain, dormant, for whoever picks
this up with the loader diagnosis. Until then: after loading a savestate,
textures the game does not re-upload stay low-res -- known limitation again.

The first-save defer fix verified working in passing: "deferring slot 4 -- no
safe resume PC (hint=0x00000000)" followed by a save at a real PC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:58:03 -07:00
tetrisgm 27c18f8f8f cpu: scale only CPU charges, not device-service ones
Charges made while servicing a device are the device's own time, not CPU
instruction retirement. Scaling those too compressed DMA and peripheral work by
the same factor, which is not an overclock but a speed hack on the whole
machine. DuckStation overclocks the CPU alone and device delays keep their
real-world duration; same intent here.

Correct, but NOT the boot failure. Measured after the change, with 8 MB on:
100% and 300% boot; 400/600/800/900/1000% all reach frames but never enable the
display. So the threshold sits between 300% and 400%, far below the >900% that
hueponik's pal100full8 requires.

What the failure is not:
  - not performance. Frames advance steadily at ~39/s throughout (455 -> 2574
    over 54s) while the display stays off, on a host that runs DuckStation at
    900% fine;
  - not device-service scaling, per this commit;
  - not a hang. The guest keeps running, it just never turns the display on.

Prime remaining suspect is the deferred-charge batching: g_psx_cyc_batch
accumulates RAW cycles against g_psx_cyc_batch_limit, but the charge is scaled
only at publish, so the batch limit and the scaled deadline comparison are in
different units. Devices would then be serviced progressively later as the
factor grows, which fits a threshold rather than a clean break. Untested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:33:39 -07:00
tetrisgm 30652ac334 cpu: overclock by scaling the per-instruction cycle charge
The guest runs as native code, so "faster CPU" cannot mean "execute quicker" --
it means CHARGE LESS for the same work. Every device schedules off
psx_cycle_count and the CRTC fires VBlank on a fixed cycle period, so scaling
the per-instruction charge down by N lets the CPU complete N times the work
inside one frame while timers, SPU, CDROM and the refresh rate keep their
real-world rates. Scaling the VBlank period instead would slow everything
EXCEPT the CPU, which is backwards.

Q16 fixed point with a carried remainder, not a divide: this is the hottest path
in the runtime and integer division of a 1-2 cycle instruction cost would floor
to zero and lose all time. The accumulator keeps the total charge exact instead
of drifting by the truncation on every call. Stock (65536) short-circuits, so a
non-overclocked build pays one predictable compare.

[video] cpu_overclock, percent, 100 = stock. Needed because hueponik's
pal100full8 requires >900%.

STATUS, measured, not assumed: 100% and 300% boot and run. 1000% does NOT reach
a display -- and it is neither hung nor host-bound, frames advance steadily at
~39/s (455 -> 2574 over 54s) while the guest never enables the display. So the
guest's own boot path stalls at that ratio; the mechanism moves time correctly
but something in the boot sequence does not survive the CPU running 10x ahead of
its devices. Not yet isolated, and it is the blocker for pal100full8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:28:23 -07:00
tetrisgm 4210c764b1 present: bezel artwork behind the frame ([video] bezel)
A still image drawn behind the game, filling whatever the letterbox or
pillarbox leaves over -- the trick vertical shmups use on horizontal displays,
where the dead space carries artwork so it reads as designed rather than as a
limitation. MAME calls the same idea bezel artwork.

Drawn across the whole drawable before the game quad, which is simpler and
safer than filling two margin strips: no margin arithmetic, it covers pillarbox
and letterbox alike, and the frame paints over the centre.

The reason this is the right shape and present_edge_fill was not: edge fill
stretches one edge COLUMN of the live frame across the margin. That suits a flat
menu backdrop and turns track geometry into horizontal smears, so it needs to
know whether the current frame is a menu -- a question it answers with
force_4_3, which is only ever set by the widescreen path. A still image has no
such question. It looks the same in a race and in a menu, which is the point.

Loaded after the GL context exists (the first attempt sat next to tex_pack_init,
which runs before the window, so g_gl_active was false and it silently never
loaded). Decoded with stb_image, already linked for the HD texture pack. A
relative path resolves against the disc directory, matching the pack convention,
so artwork can sit beside the disc.

Verified on a 7680-wide maximised window with 4:3 content: margins that were
pure black now carry the image on both sides, with the frame untouched in the
middle.

Known limits: the image is stretched to the drawable, so an aspect that does not
match the window will distort (cover-crop is a small follow-up), and this has
only been exercised in 4:3 pillarbox, not with the widescreen mod active.

For WipEout 3 the team wallpapers by MotorcycleEmptiness
(awesome-wipeout.github.io) suit this exactly; the shipped default is a
placeholder gradient used to verify the path, not final art.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:45:54 -07:00
Alex Vanderveen db5f401af3 chore: sync 2026-08-17 23:03:33 -04:00
Alex Vanderveen 816fb08fe2 bump + sio 2026-08-17 22:25:49 -04:00
tetrisgm d0ead48928 Merge remote-tracking branch 'origin/feat/rbengine' into feat/ws-cull-widen
# Conflicts:
#	runtime/src/debug_server.c
#	runtime/src/gpu.c
#	runtime/src/gpu_gl_renderer.c
#	runtime/src/tex_pack.cpp
2026-08-17 17:42:40 -07:00
tetrisgm 774b326fca pgxp: declare the depth API and wire [video] pgxp_depth through config
The header contract and config plumbing for the depth work; the implementation
landed in the preceding commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:38:25 -07:00
Alex Vanderveen 37ef57e667 Merge origin/feat/rbengine into pr/ws-ui-groups.
Keep ws_ui_groups diagnostic root and feat/rbengine's ram-mapped src_addr.
Keep feat/rbengine's recomp-net pointer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 20:27:34 -04:00
tetrisgm ab04da86e3 pgxp: depth from the absolute GTE screen Z, not the normalised perspective weight
PGXP depth shattered the scene, and the reason was a category error rather than
a tuning problem. It derived depth from a_q, which is q/qmax -- a perspective
weight normalised WITHIN one triangle. Every triangle therefore got its own
private 0..1 range, so the value said nothing about where one triangle sat
relative to another, which is the only thing a depth buffer is for.

The real depth was already in hand and being thrown away. prepare_texture_triangle
reads a 16-bit GTE screen Z per vertex via gte_precision_load_word, then
normalises it away on the very next line because UV correction only needs
relative weights. That Z is a single global scale, so it is exactly what a depth
buffer wants.

Adds gr_set_depth_triangle alongside gr_set_perspective_triangle rather than
widening it: the two carry different quantities for different purposes, and a
null-safe hook leaves the software backend untouched. The GL backend takes it as
its own vertex attribute (a_z, location 10) and maps it straight to NDC.

Verified in 3D attract-mode gameplay: with pgxp_depth on the scene renders
intact -- track, perspective-correct texturing and effects all correct -- where
before it shattered. On the 2D title screen the on/off frames are bit-identical,
because no primitive there carries GTE provenance, so a_z is 0 and depth stays
off. Still default-off in config; this makes the feature viable, it does not
turn it on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:49:15 -07:00
tetrisgm e1061c6458 tex_pack: read the fragment's real palette index instead of guessing the ink
Hardens the palette-agnostic fallback against defects an adversarial review of
the first cut turned up. The feature is dormant on this title's title screen
(every draw hits its exact key), so none of this changed a pixel here -- it is
the difference between a mechanism that happens not to fire and one that is
correct when it does.

The recolour shader guessed: it scanned the CLUT for "the first non-zero entry
after index 0" and called that the ink. That assumed the hole is always index
0, applied one colour to the whole primitive regardless of which entry a given
fragment actually addresses, and at 8bpp could run 255 dependent texelFetches
per fragment. It now reads the index the hardware would have read -- one fetch,
the same arithmetic as TEX_FS's fetch_texel, but with the index and the CLUT
lookup split, because HD ink overhanging a hole texel must resolve a different
entry than the one under it. Falls back to the image's own ink index for those
overhang pixels (which is every glyph edge, since the pack shape is finer than
the texel grid), and discards only when the ink itself has been blanked -- which
is what vanilla does there too.

Colour is now rescaled by the pack pixel's intensity against the image's full-ink
maximum. Under the mono gate that is exactly 1.0 in the interior, so it is a
no-op there; at an edge it carries LINEAR's ramp onto the live colour, so the
fallback antialiases like the exact-key path instead of hardening into a dilated
silhouette.

Four more, each a real defect:
- The donor was whichever filename directory_iterator yielded first, and that
  order is unspecified -- which variant donated a texture's shape could differ
  between runs and machines. Now the lowest key in the sorted set.
- No depth gate: a 15bpp draw has pal_n == 0 so its exact key never matches, and
  it would have been served a 4bpp-dumped image whose src_w is 4x wrong.
- The non-mask path did `return 0`, abandoning the whole upload scan, while the
  neighbouring key and exclude tests both `continue`. A newer non-mask upload
  could therefore block an older one that matched on its exact key.
- The recolour flag was not cleared on set_replacement's early returns, so a
  cleared slot left it armed for whichever primitive got a replacement next.

Also gates on the image having been analysed at this draw's texel depth and
grid, and unbinds the VRAM mirror from unit 1 after the draw -- pack_flush
renders into that same texture, and leaving it bound for reading is undefined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:33:41 -07:00
tetrisgm e1a55da286 tex_pack: palette-agnostic substitution with runtime CLUT recolour
A pack entry is keyed (texture, palette), so a draw whose CLUT ships no file
falls back to the original low-res texture even when the pack plainly has the
right IMAGE -- the texture hash identifies it, only the colour is unknown.

On an exact-key miss we now borrow any palette variant of the same texture for
its shape and take the colour from the live CLUT in the shader. REPL_FS gains
the VRAM usampler (on unit 1; unit 0 already holds the replacement image, and
sharing it would have the sampler read the pack PNG as if it were VRAM) and
reads the first non-zero palette entry as the ink. STP comes from that live
entry too, since a different CLUT may set the bit differently and reading it
off the stale image would blend the glyph wrongly. Alpha still decides the
cutout: that is the SHAPE, and the pack is the authority on it.

The gate is a property of the DATA, not a list of texture hashes we happen to
know are fonts: recolour only where the pack image has a single ink colour, so
there is no colour structure to flatten. image_is_mask computes it once at
decode. Multi-colour images decline and fall through unchanged, counted in
repl_multicol.

Also adds repl_calls / repl_nocontain / repl_notex, because "hit + miss" could
not distinguish "the pack has no image for this texture" from "no upload backs
this primitive", and those want opposite fixes. That distinction is what showed
the fallback does not fire on the WipEout 3 title screen at all -- every draw
there already hits its exact key -- so the substitution path is byte-identical
where it does not apply, and the blocky title-screen text has some other cause.
In gameplay the misses are overwhelmingly repl_notex: textures with no pack
entry of any palette, which recolour cannot help.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:20:59 -07:00
Alex Vanderveen b423486563 toolchain, texture pack update 2026-08-17 18:55:34 -04:00
tetrisgm 0bdf338571 tex_pack: substitute HD replacement textures at draw time
Completes the feature tex_pack.h has always described. Until now it identified
replacement textures and never swapped them: it opened files only for writing,
and its known-set was built by parsing filenames, so a pack could report
hundreds of "matched" entries while changing nothing on screen.

Decode a matched entry lazily via stb_image (declarations only; the
implementation is compiled in psx_window_icon.cpp, whose STBI_NO_STDIO must be
matched here, hence reading the file ourselves), cache it per key, and hand the
draw path the image plus the mapping from texture-page UVs into it:

    s = (u - origin_u) / src_w     t = (v - origin_v) / src_h

so the replacement's own resolution never appears at the call site — a 16x
atlas and a 1:1 image are addressed identically.

The GL side draws a replaced prim through its own program (REPL_FS, sharing
TEX_VS so positions, the perspective divide and the native-wide transforms stay
bit-identical) and its own draw, leaving the batch path untouched. Two things
that cost real debugging time and must not be re-derived:

  - frag.a is the PS1 MASK BIT, not opacity, and blend_factor's alpha is the
    dual-source destination factor. Writing either as coverage sets the mask
    bit on every replaced pixel and makes later masked draws vanish.
  - The two-pass mask/stencil protocol is NOT duplicated. tex_batch_draw_passes
    is parameterised on the program's uniform locations and the mask flag, and
    both paths call it. A hand-written second copy dropped the s_mask_check
    fixup and the semi == 4 dual-source case.

RECTS AND SPRITES ARE TRACKED BUT NOT SUBSTITUTED. The backend consumes a
replacement in its textured-triangle path only; rect prims take their own route
and never sample it, so arming one produced primitives that drew nothing. On
WipEout 3 that is the whole 2D layer — HUD meters, boxes, pickup indicators,
and every glyph, since the font atlases are addressed by sprites — which is why
enabling a pack erased the interface while the 3D scene rendered correctly.
Identity tracking still runs, so the dumper, the match set and the UV census
are unaffected; only application is withheld until that path is wired.

Also adds the instrumentation that found this after four wrong guesses:
"tex_pack repl=0/1" toggles substitution live (an A/B needing a rebuild does
not get run), and "tex_pack sub=armed" reports WHICH textures were substituted
and where they landed on screen, which is what distinguished "the HUD is being
replaced by the wrong image" from "replacement corrupts state the HUD needs".

Measured in-race at 32:9 with princepeter64's Wip3out pack: 292 entries
indexed, 7 decoded, zero decode failures, 8006 replaced draws, full HUD intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:18:24 -07:00
Alex Vanderveen a643fab71d Make the FMV present filter a real setting
The reconstruction added in the previous commit was env-only (PSX_FMV_FILTER),
which is fine for trying it out and no good for shipping it. Promote it to
[video] fmv_filter in both game.toml and settings.toml, spelled
"nearest" | "bilinear" | "sharp" | "bicubic" (default bicubic), and push the
resolved value to the renderer instead of having the renderer read getenv.

PSX_FMV_FILTER still overrides for a single run without touching the user's
settings, which is what an env knob is actually for.

Also bridges the value to recomp-ui's launcher settings in both directions so
the Display settings row round-trips: recomp-ui stores it 1-based (0 = unset,
so a host predating the field gets the default rather than Nearest), the
config enum is 0-based, and the conversion is confined to two helpers at the
boundary.

Verified end to end: with fmv_filter = "bilinear" in settings.toml the
presented pixels match the bilinear reference exactly (mean |dx| 0.930,
hard-step fraction 0.14%), where the default build produces the bicubic
numbers (1.038 / 0.34%).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 16:34:23 -04:00
tetrisgm d4e7c25a9b Fill the pillarbox margins of 4:3-pinned presents with the frame's edges
WipEout 3 gates widescreen to GTE-active frames (game.toml gte_game_mode), so
the title, menus and loading screens present at native 4:3 by design. On a 32:9
display that leaves them as a small island with black over most of the screen.

Extend the frame's own leftmost and rightmost columns across those margins.
PRESENT_VS interpolates v_uv.x as mix(u_uv_rect.x, u_uv_rect.z, p.x), so
passing the same u for both ends makes a quad sample exactly one texture
column -- clamp-to-edge, which the present texture is already set to. The 4:3
content is untouched: same position, same scale, no distortion. Only the
margins change, which is why this beats stretching (2.67x-wide text at 32:9)
and cropping (cuts off menu rows you have to read).

Both GL present paths need it. gl_renderer_present is the CPU-readback path;
the FBO path (GL_PRES_VRAM, via present_target_quad) is the one that actually
runs at internal scale > 1, which is every configuration this matters for --
patching only the first looked like the feature silently doing nothing.

Not applied to MDEC video (depth24), whose edge pixels are real picture
content and would smear, nor to the depth24 trailing-margin crop, which leaves
black on the right deliberately. Live A/B via the ws_menu_edge_fill command
rather than a rebuild, since this is a look and not a correctness fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:24:19 -07:00
Alex Vanderveen 6a9002032f memory optimizations for 8mb wipeout, performance in general, turbo++ 2026-08-17 14:32:01 -04:00
tetrisgm 572b9210d8 auto_ui_squash: join on submission order; instrument the silent gates
Three changes, one fix and two pieces of visibility.

1. ws_ui_group_assign gains a third join: consecutive in the ordering-table
walk AND touching. WipEout 3's speed/shield readout ends at x=288 and the
meter bar it belongs to starts at x=306 with a different CLUT, so neither
shared columns nor a matching key could join them. The readout stood alone,
its bbox midpoint 256.5 landed in the middle third against a screen centre of
254, and it stayed pinned near centre while its own meters went to the right
edge -- a 2 px gap in 4:3 opening to 159 framebuffer pixels at 32:9.

No anchor threshold can fix that: the genuinely centred "pit ... check"
message sits FARTHER RIGHT (midpoint 300.5) than the mis-anchored readout, so
any threshold reaching the readout drags the message with it. Submission order
is the only signal that separates them, and the array already carries it.

Simulated against seven captures spanning three aspects and two code states,
this forms three unions and changes exactly one anchor: [225,288] centre
becomes [225,486] right. The other two unions ([208,240]+[240,361],
[364,447]+[450,473]) are within an anchor. Confirmed in-race at 32:9: 7 runs,
lap readout left, speed readout and meters together on the right.

2. ws_ui_groups reports why primitives were NOT admitted to the partition.
Each gate in ws_ui_prepass_add is a silent return, so a primitive left at its
raw 4:3 X while its neighbours are squashed -- which is how a HUD mark ends up
stranded mid-screen -- was indistinguishable from one never drawn. In-race at
32:9: opcode 367, not_axis 410, everything else 0.

3. geom_correction reports perspective-UV arming with a real denominator.
perspective_triangles could previously only be compared against gp0_draw,
which counts untextured primitives that are correctly never armed, so it read
as a coverage figure without being one. texcorr.attempts counts exactly the
textured triangles reaching the predicate. In-race at 32:9: 199599 submitted,
96.15% armed, 3.85% rejected for a missing per-vertex Z, 0% for correction
disabled or a missing packet address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 03:03:33 -07:00
tetrisgm 72196d3dc9 auto_ui_squash: group HUD primitives that are one visual element
The thirds anchor is chosen per union-find run, and runs only joined when the
group key matched within JOIN_GAP. That key folds CLUT, texpage, a 24px Y band
and the poly-vs-rect family together, so a digit and the box it is drawn on
could never land in the same run. They got independent anchors and were
squashed about different points, which pulls one piece of a readout toward a
screen edge and leaves the rest behind -- worse the wider the frame.

On WipEout 3's race HUD at 32:9 the lap readout draws its digits at
y=[219,227] x=[137,202] and the box they label at y=[228,244] x=[138,202].
The digits anchored left, the box anchored centre, and the box landed about
500 screen pixels from the digits it belongs to.

Join on being one visual element as well as on the key: share screen columns
and touch vertically within STACK_GAP. Both halves are needed. Column overlap
alone merged the top-left lap counter with the bottom-left lap timer 200
scanlines below, and since union is transitive that chained all 71 HUD
primitives into one run spanning the full width, which anchors centre and
drags the corners inward. A strict row intersection missed the stacked case
above, whose seam is one pixel.

WsUiGroupItem carries y/height for this; the anchor stays horizontal. After:
9 runs, bottom-left readout whole and anchored left, timer and energy bar
right, speed centre -- matching the 4:3 layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 02:31:31 -07:00
tetrisgm fd625f0996 Interpreter parity for cull.widen; raise the GL supersampling ceiling
Two follow-ups to [[widescreen.cull.widen]], both found by testing it.

INTERPRETER PARITY. The widen site type was recompiler-only: the AOT image
evaluated the widened compare while the dirty-RAM interpreter, and any overlay
shard built without the config, still evaluated the vanilla one. The same site
would then cull differently depending on which backend happened to run it.
Adds a runtime site registry mirroring psx_ws_cull_keep_site, wires it from the
config at startup, and consults it from the interpreter's SLT and SLTI paths
before the keep lookup so a config migrated from keep to widen cannot be served
the pinned answer by a stale entry.

GL SUPERSAMPLING CEILING 4x -> 16x. SW_MAX_INTERNAL_SCALE was applied to every
backend, which is the wrong constraint for GL. It exists because the software
path allocates a VRAM-sized hi-res MIRROR costing 1 MB * scale^2 (256 MB at
16x); under GL that mirror stays at 1x (glb_set_scale) and the cost is an FBO
instead. Capping GL at 4x limited it to about 2048x960 internal on hardware
that handles far more.

GL_MAX_INTERNAL_SCALE moves to gpu_render.h (main.cpp needs it to pick the
ceiling before a context exists), main.cpp selects the ceiling per backend, and
the config range becomes 1..16. init_gpu_raster now also clamps to the driver's
real GL_MAX_TEXTURE_SIZE / GL_MAX_RENDERBUFFER_SIZE and says so: previously an
over-large scale reached glTexImage2D, failed silently, and surfaced only as
"GL FBO incomplete" with no hint that the scale caused it.

Measured on WipEout 3 SE (PAL, NVIDIA, Release build + debug tools), sustained
present rate in-race, same track:

    scale  internal     fps
      1    508x256      50.0
      6    3048x1536    49.8
      9    4572x2304    50.2
     16    8128x4096    50.0

Flat across the range - the title is guest-bound, so 8K internal is free on this
hardware. 50 fps is full speed for a PAL disc.
2026-08-17 01:41:58 -07:00
tetrisgm a05ad08651 Add [[widescreen.cull.widen]]: move the cull bound instead of pinning it
[[widescreen.cull.keep]] forces a comparison's verdict while wide. That is
correct for a separately proven binary decision, and config_schema.md already
says as much -- "keep has no queue policy and is appropriate only for a
separately proven binary verdict", and prefer an aspect cone "when the original
predicate is a camera-frustum test".

At a clip-code packer it is actively wrong. Pinning the classifier tells the
clipper that nothing crosses the screen edge, so polygons that do are never
subdivided; they are submitted whole with coordinates outside the GPU's legal
primitive range and the hardware discards the entire primitive. The visible
result is geometry disappearing as the camera moves -- widening the view draws
LESS, not more.

Measured on a WipEout 3 game repo, same track and camera path: ~323 primitives
emitted with the pinned sites active against ~761 with the margin at 0. Live
A/B via the ws_margin debug command also showed inverted ship steering and
broken AI with the pins active, both of which cleared at margin 0 while the
16:9 image was retained.

This adds the widening alternative:

    [[widescreen.cull.widen]]
    address  = "0x8017032C"
    expected = "0x28850000"
    mode     = "imm_lower"

Modes name which operand carries the bound and which way it travels:
  imm_upper  SLTI/SLTIU  coord <  imm + m
  imm_lower  SLTI/SLTIU  coord <  imm - m
  bound_rt   SLT/SLTU    rs    <  rt  + m
  bound_rs   SLT/SLTU    rs + m <  rt

Every mode reduces to the vanilla comparison at margin 0, so 4:3 output stays
bit-for-bit identical -- the same guarantee keep sites give. The immediate modes
reuse the existing psx_ws_cull_slti / psx_ws_cull_slti_lower helpers; the
register-bound modes need psx_ws_cull_slt_widen, added here.

The parser rejects a mode that does not match the instruction shape (an
immediate mode on a register compare would widen an operand that does not
exist), rejects duplicate addresses, and full-word guards the site as keep does
so an overlay variant at the same VA is left alone. Widen sites contribute to
overlay cache identity, otherwise migrating a site from keep to widen would
silently reuse the pinned overlay.

No game config uses this yet; migrating a title's sites is a separate change
that needs each site's operand roles established first.
2026-08-17 00:42:30 -07:00
tetrisgm 6de1ad4a05 Add ws_ui_groups: expose the auto_ui_squash partition
auto_ui_squash squashes each spatial run of UI primitives about that run's own
anchor, so a HUD element split across two runs gets two anchors and comes apart
as the frame widens -- elements drifting toward opposite edges, glyphs sliding
off the background box they sit on.

Diagnosing that required knowing which run each primitive landed in, and nothing
exposed it. The two things an observer could reach were both insufficient:
group.key is a hash of CLUT/texpage/Y-band/family, so unequal keys say two prims
differ without saying WHICH component differed; and the anchor takes only three
values (left/centre/right thirds), so equal anchors do not prove two prims
actually co-grouped rather than coincidentally landing on the same third.

ws_ui_groups dumps, for the last prepass, each primitive's op / key / raw key
inputs (y, h, and the derived 24px band and poly-vs-rect family) / union-find
root / final anchor, plus the frame-level active, squash, dense, rank, disp_x,
disp_w and join_gap. Root plus raw inputs together answer "did these two merge,
and if not, what split them".

Carries two small support changes:
- WsUiGroupItem gains a diagnostic `root`, written by ws_ui_group_assign in all
  three paths (dense, alloc-failure fallback, and the union-find path). Never
  read by the transform path.
- WsUiPrepassItem retains y/h/op, which the key otherwise folds away.

Read-only and query-driven, per the ring-buffer rule: capture is continuous and
the observer asks for a window later.
2026-08-17 00:27:36 -07:00
tetrisgm 6bddaab930 Add ws_ui_groups: expose the auto_ui_squash partition
auto_ui_squash squashes each spatial run of UI primitives about that run's own
anchor, so a HUD element split across two runs gets two anchors and comes apart
as the frame widens -- elements drifting toward opposite edges, glyphs sliding
off the background box they sit on.

Diagnosing that required knowing which run each primitive landed in, and nothing
exposed it. The two things an observer could reach were both insufficient:
group.key is a hash of CLUT/texpage/Y-band/family, so unequal keys say two prims
differ without saying WHICH component differed; and the anchor takes only three
values (left/centre/right thirds), so equal anchors do not prove two prims
actually co-grouped rather than coincidentally landing on the same third.

ws_ui_groups dumps, for the last prepass, each primitive's op / key / raw key
inputs (y, h, and the derived 24px band and poly-vs-rect family) / union-find
root / final anchor, plus the frame-level active, squash, dense, rank, disp_x,
disp_w and join_gap. Root plus raw inputs together answer "did these two merge,
and if not, what split them".

Carries two small support changes:
- WsUiGroupItem gains a diagnostic `root`, written by ws_ui_group_assign in all
  three paths (dense, alloc-failure fallback, and the union-find path). Never
  read by the transform path.
- WsUiPrepassItem retains y/h/op, which the key otherwise folds away.

Read-only and query-driven, per the ring-buffer rule: capture is continuous and
the observer asks for a window later.
2026-08-16 23:02:17 -07:00
Alex Vanderveen aa208a9100 loader optimizations pushing for 8mb performance 2026-08-17 00:42:48 -04:00
Alex Vanderveen 84ea64d613 texture pack support 2026-08-16 21:55:51 -04:00
Alex Vanderveen cf742f3190 Treat RAM below a high-loading boot EXE as overlay region
The overlay-region model assumed the boot EXE sits at the bottom of RAM:
[KERNEL_END, text_end) is main-EXE text, [text_end, RAM) is runtime-loaded
overlay code. That holds for a load_address of 0x80010000, where the text
base equals the kernel-window end and the region below the text is empty.

It is false for a boot EXE that loads HIGH and streams its gameplay code
into the RAM beneath itself. Klonoa loads a 46 KB stub at 0x80180000 and
runs its overlays from 0x10000-0x130000, so the floor landed at 0x18B000
and the entire overlay region fell below it:

  - dirty_ram_clear_image_baseline() wiped the dirty bits across
    [0x10000, 0x18B000) believing it was the clean compiled text image.
  - the dirty_ram_dispatch_inner admit heuristic requires phys >= floor,
    so a JALR into an overlay page the CD had DMA'd there (never touched
    by the CPU write hooks, so never marked dirty) was refused by the
    interpreter, fell through to psx_unknown_dispatch and fail-fast
    exit(1) — Klonoa, frame 687, target 0x80123D00.

Pin the text BASE (g_text_image_lo) alongside the floor and route the
gates through one predicate: the overlay region is both sides of the text
image, not just above it. Street Fighter Alpha 3 (0x80113B00) and
Bomberman Fantasy Race (0x8003004C) load high too.

No-op for bottom-loading games: g_text_image_lo stays at its 0x10000
default, the below-text clause is empty, and the baseline clear starts at
the same page as before.

Klonoa now runs 6874+ frames at a locked 60 fps with fail-fast on and
zero unknown dispatches; it previously died at frame 687.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 21:21:28 -04:00
Alex Vanderveen 76945d3a65 8mb ram patches + toolchain windows hotfix 2026-08-16 21:19:05 -04:00
Alex Vanderveen 16bfa4144f 8mb support 2026-08-16 17:59:20 -04:00
Alex Vanderveen 1be8f88839 Merge origin/master into feat/rbengine.
Keep the 8 MB RAM builtin alongside PGXP Precision after the master merge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 14:11:58 -04:00
Alex Vanderveen 8fb75061b4 8mb mod for wipeout and recomp-net bump, rb engine bump, mods in netplay enabled. 2026-08-16 13:35:48 -04:00