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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
comparable between objects
Two real fixes, and an honest negative result.
The per-frame depth clear was keyed on the debug server's s_frame_count, which
is incremented from two different places depending on whether PSX_DEBUG_TOOLS is
compiled in. A Release build could therefore clear the depth buffer once and
never again, letting depth accumulate until nearly all geometry is rejected --
the ground vanishing mid-race. It is now armed at Swap by this file, which is
the real frame boundary and needs nobody else's counter. This class of bug is
invisible to a savestate A/B, because that only ever compares single frames.
znear was 128, chosen by a sweep that scored "distinct depth levels used". That
metric was exactly backwards: the map clamps with max(a_z, zn), so a large zn
collapses the near field onto one plane and CONCENTRATES the remainder into more
visible levels -- the broken value scored best. At 128 the track under the camera
was swallowed and then occluded the ship entirely. Now 1, which clamps nothing.
But depth stays OFF, because that did not fix it. Measured over 12s of motion,
sampling the ship's own livery pixels: off gives 5.05/0.23/1.16/1.34/0.82/...,
on gives 2.78/0.22/0.03/0.57/0.01/... -- consistently lower and twice near zero.
With clamping gone the mapping is no longer the suspect; the recovered Z simply
is not comparable BETWEEN objects. SZ is the GTE's post-RTPS projected depth,
and two objects projected with different h or shift factors do not share a
scale, so the ship can sit "behind" track it is plainly in front of. Resolving
that means reconstructing view-space Z rather than reading SZ, which is what
PGXP proper does.
The depth readout (pgxp_depth zdebug=1) and the wire-tunable znear stay, since
they are what turned this from guesswork into measurement.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fragmentation was the mapping, not the data. A linear map has to pick a far
value and CLAMP past it, so everything beyond collapsed onto a single plane with
no ordering left among it -- which is most of a race track, and why the ship's
fuselage was being chopped apart by geometry behind it. 1 - 2*zn/z is monotonic
over the whole range with no clamp and spends its precision near the camera
where overlaps actually happen, exactly as a hardware perspective depth buffer
does.
znear 128 from a sweep against a savestate, measured on the depth readout rather
than picked: it resolves 135 distinct depth levels where 2 and 8 resolve 10 and
16.
Verified on two independent savestates at 6x internal resolution, same-frame
A/B (savestate replay is deterministic to the pixel -- the control is a 0-pixel
diff, so any difference is the setting under test). Both render the ship intact
and correctly ordered against the track, where the previous linear mapping left
it fragmented or missing entirely.
Default ON, here and in the framework. This is the point of the exercise: the
ordering table sorts one bucket per primitive and cannot resolve intersecting
geometry at all, so a recovered per-vertex depth is a real improvement over what
the hardware could do, not a compatibility risk to be hidden behind a flag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Taking src_w/src_h/origin_u/origin_v from the live draw is more correct in
principle -- origin is relative to the CURRENT texture page, and a cached one
goes stale as soon as an entry serves a draw from a different page. But it
visibly brought back the double white lines through the F500 title text that the
texel-centre fix removed, so the two are coupled: the cached origin and the
current sampling agree with each other, and changing one alone breaks that.
Reverted rather than left in, because a real user-visible regression outweighs a
latent correctness issue that has not been shown to bite. The "top row clipped,
bottom row duplicated" symptom it was meant to fix therefore still stands and
has some other cause.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Repl caches src_w/src_h/origin_u/origin_v on its first decode, but origin is
relative to the CURRENT primitive's texture page. As soon as one entry serves a
draw from a different page, a stale origin shifts the whole sample rect. On a
font that reads exactly as reported: every glyph sampled about a texel too low,
the top row clipped off and the bottom row duplicated by the clamp -- "FORMAT"
and "PSYGNOSIS" cut off on top with an extra line underneath.
Also switches PGXP depth from a linear map to a reciprocal one. Linear has to
pick a far value and CLAMP past it, and everything beyond collapses onto the far
plane with no ordering left among it -- which is most of a race track. 1 - 2*zn/z
is monotonic over the whole range with no clamp and spends its precision near
the camera where overlaps happen, as a hardware perspective depth buffer does.
Swept against a savestate: znear 128 resolves 135 distinct depth levels where 2
and 8 resolve 10 and 16.
PGXP depth remains default-off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three findings, each from measurement rather than inference.
A depth readout (pgxp_depth zdebug=1) paints window-space depth as greyscale.
It lives in the FRAGMENT shaders deliberately: TEX_VS is shared by two programs,
and a uniform added there is unset for whichever one forgets it, which silently
divides by zero -- a trap this change fell into once already. gl_FragCoord.z
needs no new varying.
What it showed: 95.3% of the frame sat in the first 6% of the depth range, and
the remaining 4.7% at exactly 0.5. Dividing by 65535 was wrong -- that is the
SZ register's range, but this title emits values under ~4000, so every projected
vertex was crushed onto the near plane and all depth resolution was lost. The
0.5 population was unarmed geometry: zc = 0 reads as "no depth" but after the
divide it is NDC 0, the MIDDLE of the range, so unprojected primitives sat at
0.5 and lost to projected ones nearer than that. That is why the ship vanished
behind the track.
So: scale is now a tunable (default 4096, wire-settable via pgxp_depth
zscale=N) because the right value is a property of a title's projection
distance rather than a constant, and unarmed vertices pin to the near plane,
which is the painter's order they had before the feature existed. Swept against
a savestate: 512 uses 115 distinct depth levels where 256 and 8192 use 21 and
17.
Still not correct, and still default-off. At 512 the ship is largely visible
where it was almost entirely culled, but armed triangles still fragment against
the track. The likely reason is structural: SZ is the GTE's PROJECTED screen Z,
so two objects projected with different h are not comparable on it. Fixing that
means reconstructing view-space Z, which is what PGXP proper does.
Verification here used savestates: load, toggle, capture. Replay is
deterministic to the pixel -- the control (same state, same settings, twice) is
a 0-pixel diff -- so any difference is attributable to the setting under test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
Added speculatively alongside the texel-centre fix, on the theory that
minification aliasing was shredding thin strokes. It was not: the seams were
entirely the half-texel sampling offset, and that fix stands on its own.
Mipmapping an atlas is only safe if its cells are padded, and the pack's sans
font packs glyphs touching, with no separator column. Even capped at the
original texel grid, the filter kernel then reaches half a source pixel into the
neighbouring glyph, so every letter picked up a faint ghost of its neighbours --
visible as smudges beside the sans text that were not there before. The F500
atlases have a one-pixel gap and so were unaffected, which is what made the
regression look font-specific rather than structural.
Plain LINEAR, as before. Minification aliasing is a real but secondary concern,
and the correct fix for it (per-cell padded mip chains) is not something we can
apply to a third-party pack.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three fixes to the HD texture replacement path, all found by looking at the
same artifact: faint vertical hairlines along every glyph boundary in the
replaced font, plus a stray fragment floating above the loading screen's
cursor glyph.
REPL_FS mapped a PS1 texel index straight to a normalised coordinate:
(uv - origin) / src. An integer u addresses texel u, whose span in the
replacement is [u, u+1), so that lands on the texel's left EDGE, half a texel
short of its centre. On the PS1 path NEAREST still resolves to the right texel
and the omission is invisible; this path is LINEAR (the whole point is detail
the texel grid does not carry), and there the shift blends in whatever sits one
texel over. On a font atlas the neighbour is the adjacent glyph's cell, so
every glyph quad drew a hairline of its neighbour along its own edge --
background-coloured slivers through letters, ink-coloured ones in the gaps, and
at the cursor cell a whole fragment of the glyph above it. Sample at +0.5 and
clamp to v_limits.zw rather than +0.999, so the far edge lands on the last
texel's centre instead of past it.
Replacement uploads are also mipmapped now. A pack image is an integer upscale
of a source the game draws at or near native texel size, so on screen it is
minified; unmipmapped LINEAR samples roughly one texel in N and drops the rest,
which shreds thin hard-edged strokes. The deepest mip a draw can select sits
around the original texel grid, where a level is the box-average of one source
pixel -- so this is never worse than vanilla and sharpens as internal
resolution rises.
Both of those were diagnosed at 508x256, because screenshot_hires on the GL
backend was ensure_cpu() + sw_render_display_hires(): ensure_cpu() packs the
FBO down to the 1x 15-bit CPU mirror, so every supersampled pixel was averaged
away BEFORE the "hires" resolve ran. It then filled dw*dh of a (dw*S)*(dh*S)
buffer and the caller honestly downgraded to "scale 1". The command silently
could not see anything that exists only at high internal resolution -- exactly
the class of work it was written to verify. Read the display rect out of the hr
FBO directly instead. Verified: 3048x1536 at scale 6, correct colours and
orientation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root cause of every "enabling a pack erases the UI" failure, and it is not any
of the things that were blamed for it.
Uniforms are per-PROGRAM. s_repl_prog shares TEX_VS's source with s_tex_prog
but not its uniform state, and startup only initialises s_geo_prog and
s_tex_prog — with a comment saying exactly why that matters:
/* Default the new uniforms to the no-op (1.0 scale, 0 centre) -- GLSL
* would otherwise zero them, collapsing all x to 0. */
So every replaced primitive ran with u_xscale = 0, collapsing all x to
u_xcenter, and u_xhalf = 0, dividing by zero on top. Degenerate, off-screen,
rasterizing nothing. No replaced primitive had EVER drawn — including the 3D
ones that were previously reported as working; that sharpness was perspective
texturing, not the pack.
Initialise the replacement program's copies to the same no-op defaults, and
give the native-wide mirror inside draw_repl_prim this program's uniform
locations instead of s_tex_*'s, which would otherwise write the wide transform
into the other program while this one kept its defaults.
Rects are armed again. gpu_textured_rect decomposes into gpu_textured_triangle,
so the 2D layer was always routed correctly; withholding it was a mitigation
for this bug, not a fix for a missing path.
Measured in-race at 32:9 with princepeter64's Wip3out pack: 292 entries
indexed, 79 decoded, zero failures, 33215 replaced draws, and the complete HUD
present — lap counter, pickup indicators, timer, lap readout, speed and
acceleration bars.
The magenta probe (tex_pack repl_debug=1) is kept. Forcing replaced fragments
to a flat colour with no discard is what separated "the draw never lands" from
"it lands and samples nothing"; four hypotheses were tested and discarded
before that, each costing a rebuild.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
24-bit FMV was pinned to nearest, so a 320x192 movie was point-sampled up to
the whole window: hard pixel edges, and at a non-integer scale the source
pixels land unevenly (some 3 window pixels wide, their neighbours 4).
The reason for the pin was that plain GL_LINEAR fringed the right edge of
low-res 24-bit scanouts into adjacent texels — but gl_renderer_present has
since grown a half-texel UV inset and a content_w crop for the depth24
margin, which is that bleed. Reading back the drawable confirms it: the
right-edge jump is 0.00 with filtering on.
So FMV now follows the video AA setting like everything else, and the
present shader reconstructs the source rather than smearing it.
PSX_FMV_FILTER picks how, since this is a taste call on a blocky source:
nearest previous behaviour (also what video AA = off gives)
bilinear plain GL_LINEAR
sharp sharp-bilinear, ramp confined to a one-output-pixel band
bicubic Catmull-Rom (default)
Measured on the intro at 1280x960 — fraction of adjacent pixel pairs
differing by >=24 luma (visible staircase) vs mean |dx| (overall sharpness):
nearest 1.00% 1.028
sharp 0.87% 1.008
bicubic 0.34% 1.038
bilinear 0.14% 0.930
Bicubic is the default because it removes two thirds of the staircase while
holding gradient at the nearest level; bilinear removes the most but costs
10% of it, which reads as blur.
s_present_prog is shared with both VRAM/FBO quad paths and program uniforms
persist, so every user now states its mode explicitly; the rasterized paths
pass 0 (they render at internal scale and have no low-res source to
reconstruct) and are byte-identical to before. No FMV cost (56.3 vs 55.4
fps) and no in-race change (57.0/57.6 vs 56.6/56.4).
Also extends the present dump with PSX_PRESENT_DUMP_OUT=1, which reads the
drawn result back off the drawable — the source-side dump cannot show what a
present filter did, and this is what produced the numbers above.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In depth24 the frame is presented from the CPU mirror and MDEC writes only
the movie's own rows. A letterboxed movie never touches the bars, so they
scan out whatever the mirror already held — and anything the game drew as
a GPU PRIMITIVE (its pre-movie clear of exactly those bars) lives only in
the FBO and never reached the mirror.
WipEout 3's intro is 320x192 written at +32 inside a 320x240 band, double
buffered (rows 32-223 shown as band 0-239, rows 288-479 shown as band
256-495). The uncleared bars kept the previous SCEA screen, and since the
two buffers hold it at different rows it alternated between the top and
bottom bar every couple of frames — a flicker rather than a static smear.
ensure_cpu() once at the 24-bit entry transition fixes it: verified by
dumping the presented pixels, peak brightness in the bar rows goes 255 -> 0
on every sampled frame, early and late in the movie. Cost is one full-VRAM
readback per entry (a handful per movie), and it is a no-op when the FBO is
already clean.
Also adds the two diagnostics this was found with, both env-gated and free
when unset: PSX_PRESENT_DIAG logs present-path/display-mode transitions,
and PSX_PRESENT_DUMP writes the actual presented pixels as PNGs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An 8 MB snapshot is ~10 MiB raw and do_capture streams its deflate at
RW_ZSLICE (1 MiB) per vblank, so one capture takes ~10 vblanks to drain
and any capture requested while s_zpend is active is skipped. Asking for
a capture every 4 frames therefore never achieves 4 — it just pins the
compressor at 100% duty on the emu thread for the whole movie, buying
rewind granularity the pump cannot actually deliver.
Measured on the WipEout 3 ntscfull8 intro: 17% of FMV frame rate.
Give the FMV interval the same 8 MB floor RW_DEF_INTERVAL_8MB already
gives the normal one. PSX_REWIND_FMV_INTERVAL still overrides (its clamp
grows to 60 so the new default is reachable).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
text_xlate_on_dispatch runs at every dispatch. With capture on it scans
a0..a3, classifies each argument as text-ish, reads the record and hashes
it — for every call, forever. The capture inventory is an authoring tool
(it enumerates source strings for a translator); applying a shipped
translation is a separate flag and is unaffected.
On the WipEout 3 ntscfull8 mod (~33k dispatches/frame) it was the single
largest CPU consumer in the intro FMV: gprofng attributed 18.6% of samples
to it, and turning it off measured +22% fps over guest frames 800-2200.
Default it to the build flavour that owns the rest of the authoring
tooling: on under debug tools, off in release. PSX_XLATE_CAPTURE now
overrides in both directions rather than only being able to disable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Authoring a replacement for an ATLAS needs its cell layout, and a proportional
font atlas does not carry one. Rows are packed with no separating scanline,
every glyph is a different width, and in WipEout 3's Arial-ish atlases adjacent
glyphs touch outright -- one ink run there spans 84 px and covers many
characters. Reading cells back out of the bitmap is guesswork.
The game states them exactly, once per draw, and tex_pack_on_textured_prim
already sees every one. Record the distinct rects per <texhash>-<palhash>, in
texel coordinates relative to the texture page, alongside the upload's origin
in that page so they map 1:1 onto the dumped PNG. Exposed as
"tex_pack sub=uvs".
Only populated while dumping: the dedup scan is linear and this is an authoring
tool, not something a play session should carry. Capped at 4096 rects; a font
atlas needs a couple of hundred.
Cross-checked against the F500 atlas, where glyphs happen to be separated by
exactly one blank column so the bitmap can be segmented independently: the two
agree on every observed cell, with cell = ink_start-1 .. ink_end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
tex_pack.cpp has carried tex_pack_debug_json since it was written, but nothing
registered it as a command, so "is the HD pack actually matching?" could only
be answered by staring at the screen. That is the one question that decides
whether a Beetle-authored pack is compatible at all, and "missing" -- pack
entries no draw has claimed -- is the authoring aid for building a new one.
Wiring it up surfaced a second defect: the stats object printed pack_dir and
dump_dir with a raw %s of a filesystem path. On Windows every separator in
"C:\Users\..." reads as a JSON escape and "\U" is not a legal one, so the
response could not be parsed at all. Escape the strings at the emitter, where
the path becomes JSON, rather than at each caller.
Measured with princepeter64's Wip3out pack installed as
<disc stem>-texture-replacements: 292 entries indexed, 80 matched in a single
race, 186452 textured primitives and 50951 palette hashes observed. The
framework's hashing is Beetle-compatible in practice, not just by intent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ws_ui_prepass_add admitted only the TEXTURED rectangle/sprite families, and
only the textured draw paths carried a widescreen transform. Every flat
-coloured HUD mark was therefore left at its raw 4:3 X while the textured
primitives of the same widget were squashed toward their anchor -- so at a
wide aspect it is stranded in open screen, to the left of the cluster it
belongs to. In 4:3 it sits on its neighbour's edge and reads as part of it,
which is why this is invisible until the frame widens.
Admit the whole GP0 rectangle range instead, decoding size from bits 4-3 and
textured from bit 2, and wire the matching transform into gp0_exec_mono_rect,
gp0_exec_mono_dot, gp0_exec_mono_8x8 and the inline mono 16x16. A GP0
rectangle is always screen-space and never carries GTE output, so this cannot
reach world geometry. The polygon families are deliberately NOT admitted here:
a mono or gouraud quad CAN be projected world geometry.
ws_ui_groups also now reports the geometry of the primitives the max_rank
filter discards, not just how many. That distinguishes "stray world geometry,
fine to drop" from "HUD mark whose cluster is being squashed without it". In
race at 32:9 the drops are all small far-rank scenery (op 2c/3c, 2-9 px, ranks
0-576 against a max of 1073), which rules the filter out as a source of
stranded HUD marks.
Confirmed in-race at 32:9: HUD unchanged at 7 runs with the same anchors, so
admitting the untextured families disturbs no existing grouping.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>