Commit Graph

1025 Commits

Author SHA1 Message Date
Alex Vanderveen 29bbabb800 bump 2026-08-19 23:40:24 -04:00
Alex Vanderveen 3cb2a60dc3 bump 2026-08-19 22:50:13 -04:00
Alex Vanderveen 4076b26dd3 bump 2026-08-19 22:03:29 -04:00
Alex Vanderveen 7bf316d330 bump 2026-08-19 21:46:38 -04:00
Alex Vanderveen b7f3642725 psx-dual-link wipeout support 2026-08-19 09:12:06 -04:00
Alex Vanderveen 3172bd1f7a psx_link_shm: dead-side reclaim, exit release, generation re-pairing, no wall escape
- A claimed side whose owner's heartbeat is stale is a corpse: reclaim it
  and bump pair_gen so a surviving peer re-latches its epoch against the
  new process instead of the dead one's timeline. Fixes both launches being
  refused after earlier killed runs left present[] set (user-observed).
- atexit release marks our side free on clean quit; crashes fall back to
  the heartbeat reclaim.
- A fresh claim with no live peer resets rings/lines/pairing so every
  session starts on a clean wire.
- The nap loop's 3 s wall-clock give-up is gone: a live-but-stalled peer
  (loads, pause) is exactly when lockstep must wait; the escape let one
  side run 4M+ cycles past the bound. Peer death remains the only exit.

Headless secondary: PSX_HEADLESS=1 on the B instance (existing CI mode --
no window/audio, full guest) pairs and runs; validated with corpse-reclaim
scenario end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 20:29:43 -04: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 b9b30a8ed4 bezel: fixed logo size anchored to window height, not margin width
One logo used to span 78% of the margin's width, so the mark grew with
every extra pixel of pillarbox -- enormous on ultrawide, a sliver on a
barely-wider-than-4:3 window. Anchor the tile to the window height
(default 18%, PSX_BEZEL_SCALE overrides), clamped to 78% of the margin so
narrow margins keep the old air-either-side look. A wider window now shows
more breathing room around the same-size column of marks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 19:49:48 -04:00
Alex Vanderveen a6f5dc1737 tex_pack: idle the upload tracker when replacement has no pack entries
hd_textures=true with no pack installed still paid per upload: mutex,
overlap invalidation, CRC32 of the rect, and a stored pixel copy. FMV is a
stream of MDEC macroblock uploads, so the empty-pack tracker showed up in
the stock FMV profile (tex_pack_on_upload -> invalidate_locked -> free).
tex_pack_active() now requires entries (or dump mode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 19:38:58 -04:00
Alex Vanderveen b05075a534 text_xlate: keep the dispatch hook out of the hot path for fixes-only translation sets
g_apply_armed covers every apply mechanism, but the dispatch hook's only
apply work is string swapping + throttled in-place RAM patches; vram_patch
blocks apply through the VRAM upload hook. WipEout 3 ships a fixes-only set
(track palette gap), which left the hook armed with an empty string table --
the a0..a3 record scan hashed four registers per dispatch against a table
that could never hit, measured at 20% of a stock FMV run (top profile entry).
New g_table_n atomic + early-out when no dispatch-relevant work exists.
Stock intro: mean speed 0.83x -> 0.96x, half-speed samples 44 -> 23.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 19:15:13 -04:00
Alex Vanderveen 5c77c40550 depth24 bars: clear the row bitmap on the MDEC idle->streaming edge
The pre-movie publisher screens are 24-bit STILLS in the same depth24
session as the movie, so the depth-transition clear never separates them and
their rows leaked into the movie's letterbox bars (SCEA text strip
alternating with black under the Psygnosis FMV). A still does not stream
MDEC; a movie does — on the idle->streaming edge (10-vblank hysteresis so
low-fps movies do not flap) the written-row bitmap resets, turning
everything a still left behind into black bars while the movie re-marks its
own rows with every decoded frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 19:00:44 -04:00
Alex Vanderveen 1f02ca1855 depth24: present never-written band rows as black — kills the pre-FMV splash flicker
ensure_cpu at depth24 entry made the letterbox bars TRUTHFUL, but the truth
is leftover 15-bit screen bytes (the pre-movie SCEA splash), and a
double-buffered movie holds that leftover at different rows in its two bands
— so the bars alternate content every couple of frames. Track which rows
have received a CPU->VRAM rect since the current depth24 session began
(s_d24_row_written, cleared at every depth transition) and present unwritten
rows as black in gpu_depth24_present_row. Savestate restore marks all rows
(restored VRAM is authoritative); reset clears.

First frame caveat: a title that uploads frame 1 before flipping GP1 depth24
shows one black movie-frame interval instead of the splash remnant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 18:57:23 -04:00
Alex Vanderveen 4982e44421 Skip FMVs: settings-owned, offered in recomp-ui/retcomm-launcher
skip_fmv_offered flips to true: [video] auto_skip_fmv in settings.toml now
actually drives g_auto_skip_fmv (the clamp that zeroed it is retained only
for a future offered=false profile). recomp-ui's Skip FMVs row and
retcomm-launcher's checkbox already read/write the key end to end — the
runtime was the only link refusing the value.

The builtin psx.skip_fmv activation-plugin mod (and its
psx.enhancement.skip-fmv package) is removed: one feature, one control. A
state.toml still naming the package resolves it as unknown and is skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 18:51:41 -04:00
Alex Vanderveen 37a6e19df8 stock smoothness: cached vblank period, PAL-on-60Hz auto frame interpolation, PSX_FMV_INTERP
- gpu_vblank_period_cycles() is consulted per basic-block edge and profiled at
  ~3% exclusive recomputing a constant; now cached, refreshed at its four cold
  writers (GP1 display mode, CRTC multiplier, period override, snapshot parse).
- PAL content through 60 Hz driver vsync duplicates ten frames a second; when
  the user has not set frame_interpolation, default it ON for PAL discs on a
  ~60 Hz panel. PSX_FRAME_INTERP=0|1 overrides for A/B.
- PSX_FMV_INTERP=1 keeps interpolation running through FMV/MDEC phases (the
  unconditional suspension is what makes 25 fps PAL video judder).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 18:24:57 -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 bd3f900213 ws_ui_group: temporal anchor hysteresis for blinking HUD widgets
A run's anchor derives from its current members' bounds, so a widget whose
parts blink (WipEout 3's sub-10s countdown hides its digits every other
beat but keeps drawing "check") changes runs frame to frame and lurches
between thirds anchors at the blink rate. Remember the anchor each prim was
squashed about, keyed by texture key and quantized position, and let any
remembered member pin its whole run to last frame's anchor. Entries age out
after ~2.5 s and the identity folds in display_width, so moved elements,
relayouts and window resizes re-derive fresh anchors instead of pinning
stale ones.

Verified frame-exact on the race countdown: digits steady through both
blink phases, off-phase label fixed where the on-phase draws it.

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 24fb4a2f0f bump 2026-08-18 15:29:44 -04:00
Alex Vanderveen 5b1f53984d chore: sync 2026-08-18 12:44:35 -04: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
Alex Vanderveen 365662d823 chore: sync 2026-08-18 08:47:06 -04:00
tetrisgm 71e77f91cd boot_state: skip unknown sections instead of failing the whole load
`default: return 0` made every savestate written by a build with one extra
section a poison pill for every build without it. Worse than unloadable: by the
time the apply loop hit the unknown tag it had already restored RAM, VRAM and
CPU state, so the refusal left a HALF-RESTORED machine that wedged or died at
PC=0 -- which presented as "loading a save crashed the whole thing", with the
restored-but-untracked VRAM showing low-res in the wreckage.

A sectioned state format exists precisely so readers can step over what they do
not know. Known-but-malformed sections still fail as before; only genuinely
unknown tags are skipped.

This also makes the states saved during the short-lived BS_SEC_TEXPACK window
load cleanly again (the extra section is now ignored), and it is the
forward-compatibility prerequisite for ever adding that section back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:00:07 -07: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 1469550ce5 Two root causes: preserve mod state across rebuilds; never save a fabricated PC
STAGING (runtime.cmake): the framework catalog staging ran rm -rf on the whole
build-dir mods/ tree before recopying. But both catalogs own only a packages/
subtree -- mods/state.toml is USER STATE written at runtime, recording which
mods are enabled with which options. Every rebuild therefore silently disabled
every mod: 8 MB, widescreen, framerate reverting off again and again, which
presented as "the mods regressed" (aspect ratios stopped working, the game
fail-fasted on 0xB0 without its 8 MB). Scope the clear to mods/packages; the
deleted-mods-don't-survive guarantee is intact because that is all the catalogs
ship.

SAVESTATE (savestate.c): a save polled with resume_pc hint 0 substituted a
candidate from a chain ending in sticky-BB latches and $ra -- values that pass
the sanity check while being the wrong place to resume. The first save of a
session (hint not yet published) wrote such a state; it loaded, ran minutes off
the rails, and died at PC=0. Poison that looks valid at save time. hint==0 now
routes into the existing defer machinery: the save completes at the next poll
with a published block-leader PC, or fails loudly after the timeout. Verified:
first save of a fresh session now records pc=0x8015FAEC, no fallback line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:26:39 -07:00
tetrisgm 686ccd3c00 Revert the hold-capture throttle: it never helped
Measured properly this time, by disabling the capture outright rather than
comparing two runs:

    hold capture ON   present 18.49 ms
    hold capture OFF  present 18.86 ms

Within noise. The full-drawable copy is not the present cost, so throttling it
bought nothing and only made the held frame staler.

The earlier commit claimed "present_gpu_ms_avg 18.192 -> 3.853, a 4.7x cut".
That was not a fix. The two numbers came from different screens -- a busy one
before and a quiet one after -- and I attributed the difference to my change
without isolating it. Capturing every frame is restored.

Whatever the ~18 ms in present actually is remains unidentified. It is not the
hold capture and it is not scene rendering (5-9 ms separately accounted).

Also registers runtime/tests/test_host_refresh_sanitize.py, which arrived
unregistered in the feat/rbengine merge and blocked cmake configure via the
test-registration guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:56:36 -07:00
tetrisgm d7e074a11d gl: stop re-capturing the whole drawable every frame
hold_capture_drawable did a full-drawable glCopyTexSubImage2D on the present
path, every frame. At 7680x4039 that is ~31M pixels, roughly 124 MB of copy per
frame, and it measured as most of the present cost at 8K internal.

It buys almost nothing at that rate. The held frame is only ever shown while the
emulator is STALLED or PAUSED -- the stall tick and the rewind pause are its
only two consumers -- where it is a still image. Capturing every 8th frame
leaves it at worst 8 frames stale, tens of milliseconds on a frozen picture, and
cuts the cost by 8.

Measured on the same scene, 8K internal, 7680-wide window:
    present_gpu_ms_avg   18.192  ->  3.853

The remaining frame cost is elsewhere: emu_cpu 42.3ms at 900% overclock and
scene_gpu 38.5ms at 8K. Those are the real walls, not the present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:50:57 -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 f931893336 present: standardise the bezel gap across every mark
Padding was 10% of each axis, so the vertical gap scaled with each logo's own
height. These marks run from 1024x479 to 1024x997, which made the spacing
visibly different per team -- tight under the tall ones, loose under the wide
ones.

Both axes now pad by the same fraction of the WIDTH. Tiles are scaled so one
spans a fixed share of the margin width, so a pad measured in width units lands
as the same on-screen gap horizontally and vertically, and the same gap for
every mark regardless of its proportions. The logo is never distorted -- it is
centred in a larger transparent cell, and the tiling uses the padded cell's
aspect.

Doubled to 10% per side (20% between neighbours) per preference.

Verified with piranha.png (1024x509, the widest): vertical spacing now matches
horizontal, where before it was noticeably tighter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:12:34 -07:00
tetrisgm b2f504cd19 present: 10% spacing between bezel tiles
Baked into the texture rather than applied at draw time: GL_REPEAT tiles edge to
edge and has no notion of spacing, so the gap has to exist in the image. 5%
transparent margin on each side leaves 10% between neighbours, horizontally and
vertically. Padding both axes by the same fraction preserves the aspect, so the
tiling maths is unchanged.

Falls back to the unpadded image if the allocation fails, rather than dropping
the bezel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:10:10 -07:00
tetrisgm c33d576537 present: fix bezel tile aspect, and give the margins a backdrop
Two defects in the tiling from the previous commit.

ASPECT. The vertical repeat count DIVIDED by the image aspect where it needed to
multiply. A tile is (vw/tile_w) wide on screen, so it must be (vw/tile_w)/img
tall and the count is vh over that -- i.e. img * tile_w * vh/vw. Getting it
backwards squashed every mark by img^2, about 1.7x on these 1024x778 logos,
which is the stretched look. Circles are round again.

BACKDROP. The art is RGBA with a transparent field, so the gaps between tiles
showed whatever the drawable had been cleared to: black, which read as a hole
punched in the screen rather than as part of the design. Each margin is now
cleared to a colour derived from the art -- the alpha-weighted mean of its
opaque pixels, darkened -- and the mark is alpha-blended over it, so every team
brings its own colour. Alpha weighting keeps soft edges from dragging the mean
toward whatever the file pads its transparent regions with.

Verified at 7680x4039: "random" picked feisar.png, both margins carry round
tiled marks on a dark teal field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:08:30 -07:00
tetrisgm 2c6de285d0 present: tile the bezel per margin, with team selection and a random default
Follows the earlier bezel work. Two changes to how the art is used:

TILING, per margin. A margin is tall and narrow; stretching one logo to fill it
is grotesque and cover-cropping shows a sliver. Tiling keeps every copy at its
authored proportions and fills any margin size, which is what a repeating mark
is for. The count derives from the margin WIDTH, so the logo reads at the same
size regardless of resolution or window shape, and GL_REPEAT does the work.

SELECTION. [video] bezel now takes:
    "random"   one of the bundled team logos, chosen once per launch and kept
               for the session -- a mark that changed mid-run would read as a
               glitch, so the choice is made at load and held;
    "qirex"    that team, by name;
    a path     your own image, relative to the disc directory;
    "off"      disabled.
Bundled art resolves next to the executable, so a stock install works without
touching the disc folder.

SVG was the obvious choice for crisp marks at 8K, but rasterising it needs
cairo, which is a native dependency a normal install will not have. The 1024px
PNGs are ample for a tiled mark and keep this working everywhere with nothing
to install.

Verified on a 7680-wide maximised window: "random" selected icaras.png and both
margins carry the tiled mark with the frame untouched between them.

Artwork: toolboxio (deviantart.com/toolboxio), re-vectored from in-game images
and released publicly; no formal licence attached. Collected via
awesome-wipeout.github.io. Credits ship alongside the images.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:00:04 -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
tetrisgm 471ad4bf0e Revert "gl: fill pillarbox margins on ANY pillarboxed present"
The force_4_3 gate was not redundant. It is the proxy for "this frame is a menu
or backdrop", and present_edge_fill only makes sense there: it stretches ONE
edge column across the whole margin, which is fine for a flat backdrop and
disastrous for busy content. The function's own comment says so -- "wrong for
content whose edge pixels are busy" -- and I removed the gate anyway on the
grounds that the early-outs made it redundant. They gate on the feature flag and
on margins EXISTING, not on the frame being suitable.

In a race the edge columns are track geometry, so the margins filled with
horizontal smears of stretched scenery down both sides. Strictly worse than the
black bars it replaced.

The underlying complaint stands: in plain 4:3 with no widescreen mod, menu
backgrounds do not fill a wide window, because force_4_3 is only ever set by the
widescreen path. Fixing that properly needs a real "is this frame a flat
backdrop" test -- e.g. the edge column being near-uniform -- rather than
removing the only thing currently distinguishing menus from gameplay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:25:42 -07:00
tetrisgm cb6c2926b9 gl: fill pillarbox margins on ANY pillarboxed present, not just 4:3-pinned ones
present_edge_fill was called only under `if (force_4_3)`, which means "a
widescreen game is pinning this frame to 4:3". A plain 4:3 game in a wide window
never satisfies it, so the margins stayed black while the feature reported
itself armed -- ws_menu_edge_fill answered on:1 and did nothing.

The condition was redundant as well as wrong: present_edge_fill already returns
early unless the feature flag is set AND there are margins to fill, so dropping
the gate cannot make it run anywhere it would not have been wanted.

Measured on a 7680-wide maximised window with 4:3 content: margins were
1152 px of pure black on each side, now carry the frame's own edge colour
across the full width.

This got much more visible once the window started maximising, because a 1280
window on an 8K panel had barely any margin to notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:23:28 -07:00
tetrisgm d6d0946703 sdl: maximise the window instead of computing the frame size
clamp_window_aspect fits the CLIENT area to the usable bounds, but a window is
client plus title bar and borders. On a display whose usable height it exactly
matched, the result was a window taller than the screen: client 5504x4128,
window 5550x4263, positioned at y = -112 and hanging off the top.

Deriving the decoration size first does not work either. SDL_GetWindowBordersSize
reports nothing useful before the window has been shown, so a post-creation
correction based on it silently did not apply -- the window stayed exactly as
wrong, which is worse than not trying.

The window manager already solves this exactly, so ask it: SDL_MaximizeWindow
fits the work area with decorations and taskbar included. Measured on a
7680x4320 panel, client area is now 7680x4039 against 5504x4128 before.

Only when the width was not explicitly chosen -- an explicit window_width is a
deliberate decision and is left alone.

Suggested by the user after two failed attempts at doing the arithmetic here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:19:57 -07:00
tetrisgm 236778ed7b sdl: declare per-monitor DPI awareness before SDL_Init
Without it Windows virtualises everything the process sees. On a 7680x4320
panel at 400% scaling SDL_GetDisplayUsableBounds reports 1920x1032, so
clamp_window_aspect fits the window to roughly 1376 LOGICAL pixels and it opens
as a small box that the desktop compositor then upscales.

The internal render resolution is unaffected, which is what makes this
expensive rather than cosmetic: the game renders at supersampling 16 and the
result is thrown away scaling a 1376-wide window up to an 8K display. Choosing a
high internal resolution and getting a small blurry window is the exact opposite
of the intent.

permonitorv2 makes SDL report physical pixels, so the window sizes against the
real panel and the drawable matches it 1:1. Set before both SDL_Init call sites,
guarded on the hints existing so it builds against older SDL.

Also changes g_video_win_w's default from a hardcoded 1280 to 0, meaning "fit
the display", clamped to the usable bounds and the configured aspect. An
explicit width still wins. 1280 was a reasonable default when a window was a
window; it is not one on a 4K or 8K panel.

Diagnosed after reporting the display as 1920x1080 from a non-DPI-aware query
and telling the user their panel could not be larger. It is 7680x4320; the
measurement was virtualised, not the display.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:13:41 -07:00
Alex Vanderveen db5f401af3 chore: sync 2026-08-17 23:03:33 -04:00
tetrisgm 35442249ba tex_pack: in-race textures are never TRACKED -- containment was a red herring
Adds a nocontain census (tex_pack sub=nocontain): the sample rects that no
tracked upload contained, each with the upload that overlapped it most. Purely
diagnostic -- it records in the failure path and changes no decision the
renderer makes.

The result overturns the previous commit's reading. Every failing rect has
ov = 0: no tracked upload overlaps it AT ALL. Not partial coverage, not a rect
spanning several uploads -- there is simply no record there.

    sample rect        best overlap   ov   hits
    688,0,8,32         none            0     44
    560,160,8,32       none            0     39
    784,0,15,62        none            0      8
    768,416,32,16      none            0      4
    ... 64 distinct rects, every one ov = 0

The addresses say why: x = 768..832, y = 0..416, the right-hand side of VRAM.
Track and craft textures arrive there by DMA or VRAM-to-VRAM copy, not by the
GP0(A0) CPU->VRAM transfer that tex_pack_on_upload hooks. Menu and font
textures DO come through A0, which is exactly why the pack works on menus and
does nothing in a race.

So containment is only the last gate to fail, not the cause, and relaxing it
would have achieved nothing while risking the font path -- the one place a
looser rule has already erased every glyph in the game once. The real gap is
upstream: tex_pack observes one of the several ways VRAM is written.

Making the pack work in-race means tracking DMA and copy destinations as
texture sources too, hashing them the same way. That is a real piece of work
with a real question attached (a copy's source content may itself be
un-tracked), not a tweak to the lookup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:54:21 -07:00
tetrisgm 1e525bd180 mods: add Skip FMVs -- the capability existed with no way to reach it
auto_skip_fmv has been in the runtime for a long time, but the config key is
deliberately inert on PSX: main.cpp holds skip_fmv_offered at constexpr false
and clears g_auto_skip_fmv immediately after config resolution, logging "Skip
FMVs is mod-owned on PSX; ignoring the legacy Settings value". That design is
right -- it is a per-user choice with a real behavioural effect, so it belongs
in the mod plan where it can be toggled and recorded, not in a config a game
ships. What was missing was the mod, so the feature was unreachable.

Adds the package and the activation plugin, modelled on psx.enhancement.pgxp.
Activation runs after the final mod-plan commit, which is exactly why this
works where the config key cannot: the guard that clears the legacy value has
already run by then.

Framework-owned and game_id "*", because a streaming FMV is XA audio plus MDEC
output -- a documented signature of the emulated CD/MDEC/SPU path, not a frame
count or an address for one disc. Off by default, matching the faithful floor.

Verified rather than assumed: enabled in mods/state.toml, the runtime logs
"mod enabled automatic FMV skipping", and the game reaches a drawable screen in
0.1s where it previously sat in FMV for ~45s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:42:34 -07:00
tetrisgm 684db9cbdd debug: add zbuf -- read the real depth buffer, not the zdebug colour view
The distinction matters and cost several wrong readings. zdebug paints every
fragment its own computed depth, including semi-transparent ones that set
glDepthMask(GL_FALSE) and never contribute. Sampling that view by colour cannot
separate a mis-projected hull triangle from the craft's own glow over empty sky,
which is exactly the ambiguity the previous two commits got stuck on. The depth
buffer contains only what was actually WRITTEN, so it can.

Dumped as raw float32 rather than PNG: the consumer is a histogram, and
quantising to 8 or 16 bits discards the resolution this exists to measure.

KNOWN LIMITATION, stated because the first run tripped over it: screenshot_hires
and zbuf are separate commands, so the game advances frames between them and the
depth read can land after a later frame's clear. The first attempt duly reported
1.0 over the entire craft -- the cleared far value -- which contradicts the
measured LEQUAL-vs-ALWAYS difference and is therefore a timing artifact, not a
finding. Using this properly needs both captured at the same point in one frame,
which means staging the depth read alongside the colour read rather than issuing
it separately.

No conclusion drawn from it yet. The tool is the deliverable here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:30:02 -07:00
Alex Vanderveen 816fb08fe2 bump + sio 2026-08-17 22:25:49 -04:00
tetrisgm 9b3c38a379 pgxp: prove the depth TEST is what rejects the ship, and find why
Adds a GL_ALWAYS override (pgxp_depth always=1) that keeps depth WRITES but
never rejects a fragment. That separates "the test is discarding this geometry"
from "this geometry never got drawn", which no amount of staring at a depth
readout settles.

Ship coverage over the same six sampled frames:
    depth OFF            mean 1.41
    depth ON, LEQUAL     mean 0.52
    depth ON, GL_ALWAYS  mean 1.40
GL_ALWAYS restores the ship exactly to the depth-off result, so the geometry is
drawn and the depth is written; only the comparison differs. Something writes
NEARER depth at the ship's own pixels before it draws.

The cause is the interpolation, not the values. These vertices are already in
screen space, so gl_Position carries w = 1, and the rasteriser therefore
interpolates z LINEARLY across the triangle in screen space. Perspective depth
is hyperbolic. On a ground-plane triangle at a glancing angle -- exactly what a
race track is -- the linearly-interpolated interior reads far nearer than it
really is and swallows anything standing on it. This also explains why the
per-vertex numbers looked right all along: at znear=64 the ship's median depth
is 219 against 227 for the track around it, correctly nearer, because the
VERTICES are fine and only the triangle interior is wrong.

The fix is to give each vertex a real w -- the view-space Z the census already
proves we have -- and emit gl_Position = vec4(xyz * w, w), so the hardware
interpolates 1/w and depth comes out hyperbolic. That is the view-space Z
reconstruction this needed from the start. It is not a drop-in: the perspective-
UV path does its own manual correction via a_q precisely BECAUSE w is 1 today,
so both have to move together or UVs get corrected twice.

Depth stays OFF until that lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:11:56 -07:00
tetrisgm b0feee09cc gl: clear the native-wide surface's depth buffer, per frame and on allocation
The wide mirror carries its own depth attachment, but depth was cleared in
exactly one place in this file -- hr_begin, which only ever touches the hr FBO.
So with PGXP depth on, the wide surface tested against depth written before its
allocation and never cleared since: margins accumulate depth forever and
progressively reject their own geometry, which is the same failure mode as the
s_frame_count bug and just as invisible to a single-frame A/B.

Cleared at allocation alongside colour and stencil, and again per frame in
wide_target_begin, armed at Swap exactly like the hr clear.

Latent rather than observed: the widescreen mod is off in the harness used for
the depth measurements, so g_wide_cur is 0 and the mirror pass never runs there.
It would bite any widescreen player the moment depth was enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:07:25 -07:00
tetrisgm a29bb7cddb pgxp: measure the Z distribution instead of guessing znear
Adds a per-frame census of the Z the title actually emits (min/max/mean plus
log2 buckets), read over the wire via pgxp_depth. This should have existed
before any znear was chosen; two wrong values shipped because it did not.

What it says for WipEout 3, on the demo savestate: min 125, max 28443, mean
5761, with the mass spread across 2^9..2^13. That immediately falsifies both
earlier choices. znear=1 maps the whole scene into NDC [0.984, 0.99993] --
0.8% of the depth range, so the test between any two objects is decided by
float noise, which is exactly what the depth readout showed (ship 254-255,
scene 254-255). And znear=128 does NOT "swallow the near field" as the previous
commit message claimed: only 0.3% of vertices sit below 128.

Sweeping znear at 32/64/100 against ship visibility gives 0.52/0.55/0.52 mean
coverage versus 1.38 with depth off -- identical within noise. So the mapping is
not the discriminator at all, and the reciprocal-vs-linear and znear work, while
individually correct, were never the reason the ship vanished.

Where it actually stands: at znear=64 the ship's median depth is 219 against 227
for the track around it, i.e. correctly NEARER. But the surrounding sample bottoms
out at 0 -- fragments claiming the very near plane, which under GL_LEQUAL punch
holes straight through the ship. Since the census says no vertex is below 125,
those cannot come from real geometry, so the next step is validating which
primitives get armed rather than tuning the projection any further.

Depth stays OFF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:05:13 -07:00
Alex Vanderveen 09703a7d57 bump 2026-08-17 20:51:46 -04:00