THE MECHANISM, measured on the palette load with guest cycle stamps:
0 SECTOR 125109 pend=0 lost=0
225,792 SECTOR 125110 pend=0 lost=0
236,023 CMD Setloc(125111) <- guest asks 10k cycles after 110
237,956 CMD ReadN
689,540 SECTOR 125111 pend=1 lost=1
915,332 SECTOR 125112 pend=1 lost=1
2,273,594 CMD Setloc(125111) <- the game times out and retries
2,275,306 CMD ReadN
2,726,890 SECTOR 125111 pend=0 lost=0
Sector 111 arrives while the guest has not yet acked the ReadN's INT3,
so its data-ready pends. Sector 112 arrives 225,792 cycles later and
REPLACES that pend, destroying 111's notification outright. The guest is
never told 111 arrived, times out after ~1.6M cycles, and re-issues
Setloc+ReadN. That retry is the entire 2.5M-cycle gap that appears
before every individually requested sector, and it is why the palette
sector is not drained on the first pass.
gap_anatomy attributed 100% of the gap to after the guest asked; the
guest issues its command within ~10,000 cycles every time. The delay was
never guest-side.
With the ring, both sectors are already safe in their own slots, so
there is nothing to drop: the newer sector's announcement now WAITS and
is raised once the guest drains the one already announced
(cdrom_announce_next_held, delayed and guarded like the ack path).
Announcements stay in arrival order and none is lost. int1_lost must now
stay 0; int1_deferred counts the sectors held.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on the palette load: sectors the game individually Setlocs
arrive ~2,500,000 guest cycles (4.4 frames) after the previous one,
while continuations arrive 225,792 -- exactly the 2x cadence. due ==
buffer on every record, so the drive is never late; the READ IS ARMED
late. DuckStation moves the same 14 sectors in 2 guest frames.
Three things can live in that gap and they need different fixes: the
guest not asking yet, the command sitting queued because
try_execute_queued_command() only runs when irq_flag == 0, or the
451,584-cycle read start. Frame numbers cannot tell them apart -- all
three collapse into "one frame" -- so the CD command history now carries
a guest cycle stamp alongside the frame.
gap_anatomy.py attributes each requested sector's gap to silence
(before the guest asked) versus after-ask latency, and names which side
owns the majority.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Strips the ring probe (ring_starved stays as an O(1) tripwire, since a
stranded slot once starved 1.1M drains) and fixes the timing the ring
exposed.
With the ring in place the regression is gone -- lost=0, one record per
LBA, no retried Setlocs -- but the buffers still fill 109,111,111,113,113
where DuckStation fills 109,110,111,112,113. The game only Setlocs the
odd sectors; it expects the evens as continuations. Native reads every
even sector (record [D], never lost) and still misses them.
The reason is when the guest is TOLD. present_pending_dataready() ran
synchronously inside the guest's IRQ-ack store, so the moment the ISR
cleared sector 110's interrupt, 111's response FIFO and read slot were
installed -- before the ISR had set up its DMA. That DMA then drained
111 into the buffer meant for 110, and every later pair inherited the
shift.
DuckStation guards exactly this window in QueueDeliverAsyncInterrupt:
after an ack the elapsed time is 0, so it always schedules rather than
delivering inline -- "give it enough time to read the response out ...
the real console does something similar anyway, the INT1 task won't run
immediately after the INT3 is cleared."
The ack now arms pending_present_due (500 cycles) and cdrom_advance
releases it once elapsed and irq_flag is genuinely clear. The deadline
joins cdrom_cycles_to_irq so the event slicer cannot step over it, is
cancelled wherever the pend is cancelled, and rides the savestate as a
relative remaining like read_delay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Not a fix. The game is expected to still render wrong; this answers one
question that three ring attempts could not, because all three differed
only in the notification path and all three failed with byte-identical
numbers (lost=70, pended=80, the same per-LBA repeat counts). Identical
results across different notification code proves the notification was
never the cause.
The remaining suspect is deliver_read_sector_without_irq(). It raises NO
INT1 by design -- it exists purely to refill the buffer so an in-flight
multi-sector DMA keeps draining. With one buffer the refill lands in the
buffer the DMA is reading and the transfer flows seamlessly. With a ring
it lands in a NEW slot, and nothing moves the read pointer there, so the
DMA starves mid-transfer and the guest retries: exactly the duplicated
Setlocs and 2-7 repeated sector reads observed.
ring_starved counts drains that found their slot exhausted while another
slot held unread data. Non-zero confirms it outright and the fix follows
(the no-IRQ refill must advance the read pointer: it is a continuation of
a drain in progress, not a new notification). Zero kills the hypothesis
and the ring line of attack stops.
ring_norq_refill and ring_read_moves give the supporting picture.
ring_probe.py reads the verdict; a build without the counters reports
no-instrumentation rather than a misleading zero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
THE BUG. cd_diff proved the two emulators' CD request streams are
byte-identical through the palette load (125054, 125071, 125088,
125105..109, 125111, 125113 on both), and cd_verify proved every request
was executed exactly: delivered == requested, data heads matching the
ISO, lost=0. Identical asks, identical answers, different result -- so
the divergence was in the BUFFERING between them.
psx-runtime had ONE 2340-byte sector buffer. Real hardware has eight
rotating ones (nocash; DuckStation NUM_SECTOR_BUFFERS = 8). The game's
loader drains TWO sectors per Setloc+ReadN cycle -- the requested sector,
then its continuation via the following data-ready -- and with a single
buffer the continuation overwrote the buffer mid-drain, so the second
drain re-read stale bytes. The palette load shifted by one sector and
raw file data was rendered as vertex colours: the hard-edged wedges.
Implements the ring faithfully:
- a delivered sector fills the NEXT slot, never the one being drained;
- the INT1 that announces a sector latches the read slot (DS: read =
write at DataReady delivery);
- a drain that empties a slot re-announces a sector already waiting in
the newest slot, matching DS's redeliver-missed-sector behaviour --
without it a game draining two per cycle never hears about the second;
- multi-sector DMA steps across slot boundaries inline, preserving the
seamless-refill shape but without clobbering;
- drive-state changes reset the whole ring (DS ClearSectorBuffers);
- the savestate carries all eight slots plus both indices; the section
is length-checked, so older states are rejected rather than misparsed.
New counters ring_dropped (guest a full ring behind -- hardware drops
too) and int1_redelivered (missed sectors recovered) in cdrom_state.
Builds and links clean in build-release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The command history settled the request stream: near the file tail the
game switches to per-sector Setloc+ReadN (105..109 in one frame, INT1s
pended behind the command INT3s -- normal), then requests 111 and 113,
having consumed 110 and 112 as ReadN continuations. So 'the game asks
for the wrong sector' was wrong too: the request stream is a normal
streaming pattern, and the question is which DELIVERED sector each DMA
actually drained -- the table ends with 113's bytes where 112's belong.
The DMA log only stamped the game's last SetLoc, which cannot answer
that. Each entry now also records the sector actually in the buffer at
DMA time (continuations differ from the SetLoc stamp), the frame, and
the first 8 bytes of the transferred data for ISO matching -- ground
truth for dest <- which sector, one replay to capture.
Trace-ring lesson recorded: per-byte FIFO reads flood the 64K register
ring to ~1.5 frames of history during loads; the command-history ring
(473 entries, flood-immune) is what survived to answer the request-side
question.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flow-control fix did not change the symptom, and the reason is in
game.toml: no disc_speed is configured, so the divisor is 1 and
accelerated_read_active() is never true. The sector is being lost at
AUTHENTIC cadence -- the guest has a full sector period and still misses
-- which makes this a timing divergence, not a pacing-enhancement bug.
The flow-control commit stands (it is correct for accelerated configs);
the theory that it explained THIS failure was wrong.
Also learned by reading: cd_read_log stamps entries with the game's last
SetLoc (s_setloc_lba updates only in the SetLoc handler), so
'125111 -> 125113' means the GAME issued those two requests itself.
Earlier pairs in the log share one LBA across two destinations (one
SetLoc, multi-sector read); the failing pair does not. Either an INT1
loss desynced the game's own sector bookkeeping, or its request stream
diverged for another reason -- the per-sector delivery records decide.
cdrom_timing_dump exposes the existing L1.5 ring per record: LBA, due /
buffer / irq-arm / INTC cycle stamps, data/dma/pended/lost flags,
filterable by LBA. cd_verify.py arms it, waits for the table load,
and reports all three layers from one run: the request stream and its
gaps, delivery records around LBA 125112 (read at all? pended? lost?),
and whether the table ended up correct.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
THE BUG, measured end to end. Legend of Mana's land-creation effect
renders as hard-edged full-screen wedges because ONE sector of a
multi-sector scene load never arrives: cd_read_log shows LBA 125111 ->
0x0E1F18 followed by LBA 125113 -> 0x0E2718 -- destinations one sector
apart, LBAs two apart. Sector 125112, which the ISO shows holds the
effect's 5-colour per-vertex palette (0x0888F8 = (248,136,8), 0xB0F8F8 =
(248,248,176)), was skipped; sector 125113's raw file bytes landed at
its address and were faithfully rendered as 216 distinct vertex colours.
DuckStation places 125112 correctly and draws the clean bloom.
The skip is manufactured by accelerated pacing. At authentic cadence the
guest has a full sector period (~6.6 ms at 2x) to ack each data-ready
INT1; the one-deep pend-then-lose below is Beetle-faithful, and a loss
there means the game truly was too slow. With the speed divisor or
bounded-instant mode active, sectors arrive in a fraction of that
period, so a guest that is merely BUSY -- here, mid scene-load
decompression -- gets a sector clobbered that hardware would have
delivered. s_int1_lost counted it; the stream corrupted silently.
Fix, class-level per RULE -1: when running FASTER than hardware, never
overwrite a sector the guest has not consumed -- hold delivery, capped
at the authentic sector period, then fall through to faithful clobber
semantics. Accelerated mode is thus never slower than hardware and never
lossier than hardware; loads stay near-instant while the guest keeps up
and degrade toward guest speed when it does not. XA/streaming modes are
excluded by the same gates as apply_read_speed and keep authentic
timing. Warm routes keep their existing indefinite handshake.
New counters accel_consumer_waits/_wait_cycles in cdrom_state: nonzero
is healthy (the enhancement waited); int1_lost rising while the divisor
!= 1 is the regression tripwire.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.