Continues backporting onto this branch rather than moving to feat/rbengine,
keeping the known-good race performance while recovering fixes that landed
after the branch point.
pc=0 cluster (the mid-race 'crash' was a silent top-level GUEST_EXIT):
- 6ead85c9 honor IRQ redirects at compiled leaders + pc0 escape journal. THE
root cause: every emitted block-leader IRQ check overwrote whatever resume
PC the exception epilogue published, so a guest RFE to a non-EPC target
(card-ISR handlers, SetCustomExitFromException, thread switch) lost its
continuation and a later jr through a null pointer surfaced as PC=0.
Its own message names this title and the static bake.
- 21a8457e the IRQ-redirect guard must CONSUME the benign same-site resume.
- (2edce1b6 and the async-RFE latch hunks landed in the previous commit.)
- cpu_state.h pc0-journal declarations, which the cherry-picks reference.
Overlay/autocompile correctness:
- d31953fe missing cull-widen ABI symbols; PGXP hooks swallowed by an #endif.
- 45f9d0f8 pin the overlay flavor through the spawn env — the same
--flavor-defaults-to-0 trap that silently fills a cache this PGXP build
cannot load.
- 8b323ffe coverage_vault 8 MB dev-RAM aware bounds.
Deliberately NOT taken: 249057f9 / ada331a4 (exact-sign NCLIP + GTE clamp
rescue) — broadens PGXP arming and adds a per-vertex RTPS push, and is the
leading suspect for the race-FPS regression this branch exists to avoid; and
3b698682 / 95a56e23 (image-edge tail-transfer), which 4532ad2f later gated to
CPS-only — this game emits non-CPS, where the legacy behaviour this branch
already has is the correct one.
Emitters rebuilt and game+BIOS regenerated (56 shards changed). Verified: 8 MB
mod plan applies, 60 Hz NTSC at 1.00x, no PC=0 exit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSNLFj7kvarYHEV9PCZpV
The title's bezel activation plugin (src/mods/wipeout3_bezel_plugin.c) calls
psx_mod_set_bezel(), which does not exist on this branch, so any build of the
game repo against it dies with 'call to undeclared function psx_mod_set_bezel'.
The renderer-side bezel plumbing and g_bezel_path already exist here; only the
mod-facing setter was missing. Straight backport of the setter and its header
declaration — no renderer changes, and the bounds checks are kept so a plugin
cannot hand an unbounded path to the loader.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSNLFj7kvarYHEV9PCZpV
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>
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>
- 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>
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>
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>
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>
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>
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>
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 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>
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>
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>
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>
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>
The thirds anchor is chosen per union-find run, and runs only joined when the
group key matched within JOIN_GAP. That key folds CLUT, texpage, a 24px Y band
and the poly-vs-rect family together, so a digit and the box it is drawn on
could never land in the same run. They got independent anchors and were
squashed about different points, which pulls one piece of a readout toward a
screen edge and leaves the rest behind -- worse the wider the frame.
On WipEout 3's race HUD at 32:9 the lap readout draws its digits at
y=[219,227] x=[137,202] and the box they label at y=[228,244] x=[138,202].
The digits anchored left, the box anchored centre, and the box landed about
500 screen pixels from the digits it belongs to.
Join on being one visual element as well as on the key: share screen columns
and touch vertically within STACK_GAP. Both halves are needed. Column overlap
alone merged the top-left lap counter with the bottom-left lap timer 200
scanlines below, and since union is transitive that chained all 71 HUD
primitives into one run spanning the full width, which anchors centre and
drags the corners inward. A strict row intersection missed the stacked case
above, whose seam is one pixel.
WsUiGroupItem carries y/height for this; the anchor stays horizontal. After:
9 runs, bottom-left readout whole and anchored left, timer and energy bar
right, speed centre -- matching the 4:3 layout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two follow-ups to [[widescreen.cull.widen]], both found by testing it.
INTERPRETER PARITY. The widen site type was recompiler-only: the AOT image
evaluated the widened compare while the dirty-RAM interpreter, and any overlay
shard built without the config, still evaluated the vanilla one. The same site
would then cull differently depending on which backend happened to run it.
Adds a runtime site registry mirroring psx_ws_cull_keep_site, wires it from the
config at startup, and consults it from the interpreter's SLT and SLTI paths
before the keep lookup so a config migrated from keep to widen cannot be served
the pinned answer by a stale entry.
GL SUPERSAMPLING CEILING 4x -> 16x. SW_MAX_INTERNAL_SCALE was applied to every
backend, which is the wrong constraint for GL. It exists because the software
path allocates a VRAM-sized hi-res MIRROR costing 1 MB * scale^2 (256 MB at
16x); under GL that mirror stays at 1x (glb_set_scale) and the cost is an FBO
instead. Capping GL at 4x limited it to about 2048x960 internal on hardware
that handles far more.
GL_MAX_INTERNAL_SCALE moves to gpu_render.h (main.cpp needs it to pick the
ceiling before a context exists), main.cpp selects the ceiling per backend, and
the config range becomes 1..16. init_gpu_raster now also clamps to the driver's
real GL_MAX_TEXTURE_SIZE / GL_MAX_RENDERBUFFER_SIZE and says so: previously an
over-large scale reached glTexImage2D, failed silently, and surfaced only as
"GL FBO incomplete" with no hint that the scale caused it.
Measured on WipEout 3 SE (PAL, NVIDIA, Release build + debug tools), sustained
present rate in-race, same track:
scale internal fps
1 508x256 50.0
6 3048x1536 49.8
9 4572x2304 50.2
16 8128x4096 50.0
Flat across the range - the title is guest-bound, so 8K internal is free on this
hardware. 50 fps is full speed for a PAL disc.
[[widescreen.cull.keep]] forces a comparison's verdict while wide. That is
correct for a separately proven binary decision, and config_schema.md already
says as much -- "keep has no queue policy and is appropriate only for a
separately proven binary verdict", and prefer an aspect cone "when the original
predicate is a camera-frustum test".
At a clip-code packer it is actively wrong. Pinning the classifier tells the
clipper that nothing crosses the screen edge, so polygons that do are never
subdivided; they are submitted whole with coordinates outside the GPU's legal
primitive range and the hardware discards the entire primitive. The visible
result is geometry disappearing as the camera moves -- widening the view draws
LESS, not more.
Measured on a WipEout 3 game repo, same track and camera path: ~323 primitives
emitted with the pinned sites active against ~761 with the margin at 0. Live
A/B via the ws_margin debug command also showed inverted ship steering and
broken AI with the pins active, both of which cleared at margin 0 while the
16:9 image was retained.
This adds the widening alternative:
[[widescreen.cull.widen]]
address = "0x8017032C"
expected = "0x28850000"
mode = "imm_lower"
Modes name which operand carries the bound and which way it travels:
imm_upper SLTI/SLTIU coord < imm + m
imm_lower SLTI/SLTIU coord < imm - m
bound_rt SLT/SLTU rs < rt + m
bound_rs SLT/SLTU rs + m < rt
Every mode reduces to the vanilla comparison at margin 0, so 4:3 output stays
bit-for-bit identical -- the same guarantee keep sites give. The immediate modes
reuse the existing psx_ws_cull_slti / psx_ws_cull_slti_lower helpers; the
register-bound modes need psx_ws_cull_slt_widen, added here.
The parser rejects a mode that does not match the instruction shape (an
immediate mode on a register compare would widen an operand that does not
exist), rejects duplicate addresses, and full-word guards the site as keep does
so an overlay variant at the same VA is left alone. Widen sites contribute to
overlay cache identity, otherwise migrating a site from keep to widen would
silently reuse the pinned overlay.
No game config uses this yet; migrating a title's sites is a separate change
that needs each site's operand roles established first.
auto_ui_squash squashes each spatial run of UI primitives about that run's own
anchor, so a HUD element split across two runs gets two anchors and comes apart
as the frame widens -- elements drifting toward opposite edges, glyphs sliding
off the background box they sit on.
Diagnosing that required knowing which run each primitive landed in, and nothing
exposed it. The two things an observer could reach were both insufficient:
group.key is a hash of CLUT/texpage/Y-band/family, so unequal keys say two prims
differ without saying WHICH component differed; and the anchor takes only three
values (left/centre/right thirds), so equal anchors do not prove two prims
actually co-grouped rather than coincidentally landing on the same third.
ws_ui_groups dumps, for the last prepass, each primitive's op / key / raw key
inputs (y, h, and the derived 24px band and poly-vs-rect family) / union-find
root / final anchor, plus the frame-level active, squash, dense, rank, disp_x,
disp_w and join_gap. Root plus raw inputs together answer "did these two merge,
and if not, what split them".
Carries two small support changes:
- WsUiGroupItem gains a diagnostic `root`, written by ws_ui_group_assign in all
three paths (dense, alloc-failure fallback, and the union-find path). Never
read by the transform path.
- WsUiPrepassItem retains y/h/op, which the key otherwise folds away.
Read-only and query-driven, per the ring-buffer rule: capture is continuous and
the observer asks for a window later.
auto_ui_squash squashes each spatial run of UI primitives about that run's own
anchor, so a HUD element split across two runs gets two anchors and comes apart
as the frame widens -- elements drifting toward opposite edges, glyphs sliding
off the background box they sit on.
Diagnosing that required knowing which run each primitive landed in, and nothing
exposed it. The two things an observer could reach were both insufficient:
group.key is a hash of CLUT/texpage/Y-band/family, so unequal keys say two prims
differ without saying WHICH component differed; and the anchor takes only three
values (left/centre/right thirds), so equal anchors do not prove two prims
actually co-grouped rather than coincidentally landing on the same third.
ws_ui_groups dumps, for the last prepass, each primitive's op / key / raw key
inputs (y, h, and the derived 24px band and poly-vs-rect family) / union-find
root / final anchor, plus the frame-level active, squash, dense, rank, disp_x,
disp_w and join_gap. Root plus raw inputs together answer "did these two merge,
and if not, what split them".
Carries two small support changes:
- WsUiGroupItem gains a diagnostic `root`, written by ws_ui_group_assign in all
three paths (dense, alloc-failure fallback, and the union-find path). Never
read by the transform path.
- WsUiPrepassItem retains y/h/op, which the key otherwise folds away.
Read-only and query-driven, per the ring-buffer rule: capture is continuous and
the observer asks for a window later.
The overlay-region model assumed the boot EXE sits at the bottom of RAM:
[KERNEL_END, text_end) is main-EXE text, [text_end, RAM) is runtime-loaded
overlay code. That holds for a load_address of 0x80010000, where the text
base equals the kernel-window end and the region below the text is empty.
It is false for a boot EXE that loads HIGH and streams its gameplay code
into the RAM beneath itself. Klonoa loads a 46 KB stub at 0x80180000 and
runs its overlays from 0x10000-0x130000, so the floor landed at 0x18B000
and the entire overlay region fell below it:
- dirty_ram_clear_image_baseline() wiped the dirty bits across
[0x10000, 0x18B000) believing it was the clean compiled text image.
- the dirty_ram_dispatch_inner admit heuristic requires phys >= floor,
so a JALR into an overlay page the CD had DMA'd there (never touched
by the CPU write hooks, so never marked dirty) was refused by the
interpreter, fell through to psx_unknown_dispatch and fail-fast
exit(1) — Klonoa, frame 687, target 0x80123D00.
Pin the text BASE (g_text_image_lo) alongside the floor and route the
gates through one predicate: the overlay region is both sides of the text
image, not just above it. Street Fighter Alpha 3 (0x80113B00) and
Bomberman Fantasy Race (0x8003004C) load high too.
No-op for bottom-loading games: g_text_image_lo stays at its 0x10000
default, the below-text clause is empty, and the baseline clear starts at
the same page as before.
Klonoa now runs 6874+ frames at a locked 60 fps with fail-fast on and
zero unknown dispatches; it previously died at frame 687.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Emitter side of the value-propagation engine (ENHANCEMENTS.md G1.10):
- recompiler/src/pgxp_hook_emitter.{h,cpp} (new, in the codegen-hash
surface): ONE hook grammar shared by BOTH emitters, wrapping loads/
stores/COP2 transfers and precision-carrying ALU ops (moves, add/sub,
or-merge, shifts, LUI, HI/LO, muldiv) with PGXP_*() macros. Clobberable
sources are captured into locals before the statement. Precision-
destroying ops (AND/XOR/SLT/...) are deliberately unhooked - validate-
on-read drops their stale shadows for free.
- code_generator.cpp: central append in translate_instruction; LWC2/SWC2
emissions capture the RAW loaded/stored word (a masked GTE register
write must validate against the word as loaded).
- strict_translator.cpp: translate() wraps translate_impl()'s c_code with
the shared grammar; deferred load-delay variants hook the psx_ldd temp.
- BIOS regenerated, fingerprints verified CHANGED (SCPH1001 + OpenBIOS;
42.6k/12.2k macro sites). Base runtime + 33/33 tests green against it.
- Gating: macros expand only under -DPSX_PGXP=1. runtime.cmake grows a
PGXP option on psxrecomp_add_runtime_target (defines PSX_PGXP=1 +
PSX_OVERLAY_FLAVOR=2, _pgxp exe suffix) and PSX_PGXP_VARIANT to build
the sibling target from the same generated tree (framework runtime +
game wrapper). No ABI version bump needed: the appended PGXPHooks
table is NULL-tolerant and the flavor half of the ABI tag rejects any
host/DLL flavor mix.
- Shard caches: flavor folded into the cache tag (cg<V>_<hash>_gc<hash>
_f<flavor>) in overlay_loader.c + compile_overlays.py so base and pgxp
caches never share a directory; compile_overlays.py passes
-DPSX_PGXP=1 when flavor bit 2 is set; overlay preamble forwards
psx_pgxp_* through the table. PSX_OVERLAY_CODEGEN_VER 9 -> 10.
- Env overrides PSX_GEOMETRY_CORRECTION / PSX_PERSPECTIVE_TEXTURING /
PSX_PGXP_CPU_MODE (validation path: free-running headless boots arm
from the first vertex); census grew produced/swc2_stores and the pgxp
verb reports suppress/active gate state.
Validated: PSXRecomp_pgxp.exe boots the recompiled BIOS with hooks armed
(engine active, per-vertex lookups flowing; the BIOS shell has no RTPS
content so produced=0 there is correct - coverage measurement moves to a
3D title in phase 3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>