Overlay DLL linking, variant-scoped captures, PGXP shared-edge correctness, and guarded LTO - #169
Open
tetrisgm wants to merge 126 commits into
Open
Overlay DLL linking, variant-scoped captures, PGXP shared-edge correctness, and guarded LTO#169tetrisgm wants to merge 126 commits into
tetrisgm wants to merge 126 commits into
Conversation
Keep the 8 MB RAM builtin alongside PGXP Precision after the master merge. Co-authored-by: Cursor <cursoragent@cursor.com>
Accidental gitlink broke recursive submodule CI with no .gitmodules URL. Co-authored-by: Cursor <cursoragent@cursor.com>
…to feat/rbengine
screenshot_hires passed the pixel width as out_pitch, but the resolves (sw_render_display / sw_render_display_hires) step rows in BYTES, as the present path does (main.cpp: sw * sizeof(uint32_t)). Each row advanced a quarter of a row, tiling the frame 4x across and leaving the bottom three quarters black. Verified on WipEout 3: content rows 65/256 -> 256/256. The same handler could also emit untouched allocation: gr_scale() reports the GL backend's internal scale, but sw_render_display_hires falls back to the native resolve when the CPU hi-res mirror does not exist, filling w*h of an ow*oh buffer while still returning non-zero. The buffer is now calloc'd and the resolve must report FULL cover or the capture redoes itself honestly at native size. Add present_shot / present_shot_seq: a capture of the COMPOSED present surface. Every existing capture resolves the display buffer BEFORE the backend fits it to the window, so on a 508x256 display in a 4:3 window they answer 508x256 while the player sees 640x480. That is correct for faithfulness work but wrong for anything aspect-shaped -- a widescreen change moves the GTE squash and the present fit, which is exactly the stage those captures skip. Fulfilled by whichever backend owns the present: SDL_RenderReadPixels on the software path, glReadPixels before SwapWindow on GL (with the bottom-up flip). Notes on the contract: - GL fulfilment can run on the frame-interpolation thread (interp_present -> gl_swap_with_osd), not just the emu thread, so the path buffer and pending flag are mutex-guarded and the counters are atomic. present_shot_take() claims the request under the lock, so two present paths cannot fulfil the same shot. - A staged shot is OVERWRITTEN by a later request rather than refused, as savestate's request_save_inner does. A present that never arrives therefore cannot wedge the command. - Vulkan presents through its own swapchain and has no readback hook, so the request is refused there instead of being accepted and never fulfilled (CLAUDE.md rule 15). - present_shot_seq advances on success AND failure so a polling client always terminates; its `wrote` field reports whether a PNG landed. - The SDL2 branch sizes its buffer from SDL_GetRendererOutputSize rather than a viewport*scale reconstruction, which could be smaller than the region SDL_RenderReadPixels fills. Also: debug_client gains positional-path screenshot_file/present_shot and turbo/turbo_state mappings (they previously fell through to the raw key=value handler); TCP_COMMANDS.md marks screenshot_hires as native (it is registered on the native server and the blank N column read as Beetle-only), documents the two new commands, and the generated index is regenerated via tools/gen_tcp_commands.py. Tested on WipEout 3 SE (PAL, OpenGL, RelWithDebInfo + PSX_DEBUG_TOOLS=ON): capture matches the on-screen window at 1280x960; seq/wrote reports 1 on success and 0 for an unwritable path; back-to-back staging is accepted. The SDL software path, the SDL2 branches and Vulkan refusal are compile-verified only.
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>
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.
[[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.
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.
The group array is a local copy; only .anchor was copied back, so the root the command reported was the stale insert-time index. Every primitive therefore looked like its own run even when runs had formed -- which is precisely the question the command exists to answer. Caught by the reported anchors contradicting the reported roots: primitives at an identical x=[319,340] showed anchor 508 and 254, and ws_ui_anchor_for_bounds is a pure function of (x, width, display_width), so identical inputs cannot differ unless those items actually merged into runs of different extent.
screenshot_hires passed the pixel width as out_pitch, but the resolves (sw_render_display / sw_render_display_hires) step rows in BYTES, as the present path does (main.cpp: sw * sizeof(uint32_t)). Each row advanced a quarter of a row, tiling the frame 4x across and leaving the bottom three quarters black. Verified on WipEout 3: content rows 65/256 -> 256/256. The same handler could also emit untouched allocation: gr_scale() reports the GL backend's internal scale, but sw_render_display_hires falls back to the native resolve when the CPU hi-res mirror does not exist, filling w*h of an ow*oh buffer while still returning non-zero. The buffer is now calloc'd and the resolve must report FULL cover or the capture redoes itself honestly at native size. Add present_shot / present_shot_seq: a capture of the COMPOSED present surface. Every existing capture resolves the display buffer BEFORE the backend fits it to the window, so on a 508x256 display in a 4:3 window they answer 508x256 while the player sees 640x480. That is correct for faithfulness work but wrong for anything aspect-shaped -- a widescreen change moves the GTE squash and the present fit, which is exactly the stage those captures skip. Fulfilled by whichever backend owns the present: SDL_RenderReadPixels on the software path, glReadPixels before SwapWindow on GL (with the bottom-up flip). Notes on the contract: - GL fulfilment can run on the frame-interpolation thread (interp_present -> gl_swap_with_osd), not just the emu thread, so the path buffer and pending flag are mutex-guarded and the counters are atomic. present_shot_take() claims the request under the lock, so two present paths cannot fulfil the same shot. - A staged shot is OVERWRITTEN by a later request rather than refused, as savestate's request_save_inner does. A present that never arrives therefore cannot wedge the command. - Vulkan presents through its own swapchain and has no readback hook, so the request is refused there instead of being accepted and never fulfilled (CLAUDE.md rule 15). - present_shot_seq advances on success AND failure so a polling client always terminates; its `wrote` field reports whether a PNG landed. - The SDL2 branch sizes its buffer from SDL_GetRendererOutputSize rather than a viewport*scale reconstruction, which could be smaller than the region SDL_RenderReadPixels fills. Also: debug_client gains positional-path screenshot_file/present_shot and turbo/turbo_state mappings (they previously fell through to the raw key=value handler); TCP_COMMANDS.md marks screenshot_hires as native (it is registered on the native server and the blank N column read as Beetle-only), documents the two new commands, and the generated index is regenerated via tools/gen_tcp_commands.py. Tested on WipEout 3 SE (PAL, OpenGL, RelWithDebInfo + PSX_DEBUG_TOOLS=ON): capture matches the on-screen window at 1280x960; seq/wrote reports 1 on success and 0 for an unwritable path; back-to-back staging is accepted. The SDL software path, the SDL2 branches and Vulkan refusal are compile-verified only.
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>
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>
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>
…pack section The file header's section count was HARDCODED to 16, so ANY appended section made the loader stop early and poisoned every later section - the long-standing "adding a section breaks a later one" trap. BsOut now counts sections and backpatches the header (file via fseek, memory in place). New BS_SEC_MODSET rides FIRST: the resolved mod-plan fingerprint. A load whose fingerprint differs from the running plan is refused before any state is applied, with a message naming both plans - previously such loads half-applied and died at a null PC. Old saves without the section still load (best effort), matching the skip-unknown policy. BS_SEC_TEXPACK (dormant since the count trap) is enabled: tracked-upload rects+hashes persist, pixels rebuilt from restored VRAM and hash-verified, so HD texture substitution survives savestate loads. Full save->load->run roundtrip verified live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…k; xlate fixes-only files - pgxp_census / pgxp_texcensus (debug server): per-primitive resolution classes and UV-correction miss reasons with screen bboxes for the last complete frame, double-buffered by frame parity. These attributed the swimming-wall and flickering-ship classes down to individual packets. - overclock command: live CPU overclock set/get for anchored same-scene A/Bs (the 100%-vs-900% pacing measurements). - text_xlate: load translation files that carry only vram_patch/glyph entries (previously gated on a non-empty "entry" array), enabling data-fix-only files like the WipEout 3 palette-gap patch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…variant entry hooks Three changes that ride the existing PGXP dataflow engine: - Arm the PGXP engine whenever a precision consumer needs it, not only when a correction feature is on. gpu_pgxp_rederive_enable() derives the arm from texture correction OR geometry correction OR the widescreen precise-NCLIP opt-in, and every setter re-derives. Previously disabling both corrections silently disabled the shadows precise_nclip depends on. - gte_nclip: when the sub-pixel shadow of all three SXY FIFO entries is live, take the winding SIGN from the exact double cross product and keep the hardware integer magnitude. The X-squash scales areas by a positive factor, so only integer rounding ever flips a sign; this removes both the vanished trackside signs and the per-frame tile flicker at wide aspects without any epsilon tolerance. The producer clamps shadow coords to +/-4096 px so a projection blowup cannot wrap the 16.16 int32. - Clamp rescue (prepare_precise_triangle): a vertex parked exactly on a GTE saturation rail (+/-1024) is hardware-mangled; when the validated dataflow shadow knows its true position, rasterize with that instead. Kills the "polygon spike" streak class that 4:3 framing plus CRT overscan hid. Every in-range vertex stays native, so normal geometry is bit-identical. - dirty_ram_interp: fire psx_mod_function_entry on interp dispatch so function-entry mod hooks do not depend on which backend executes a mod-patched page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…wallowed by #endif Two bugs that together kept every mod-patched text page interpreted forever (the WipEout 3 framerate mod's 74 divergent pages -> ~0.5M interpreted block dispatches/sec in play): - The [[widescreen.cull.widen]] emitter writes psx_ws_cull_slt_widen / psx_ws_cull_slti_lower at configured sites, including in overlay-resident code, but the overlay dispatch preamble never defined them: every shard containing a widen site failed to link (undefined reference) and the autocompile pipeline reported compile-error for the whole region. Mirror both helpers into the preamble following the documented self-contained, byte-identical convention. - append_pgxp_hooks joins the hook onto the translated statement with a space; the block-cycles MULT/DIV/MFLO/MFHI translations end in `#endif`, and the preprocessor discards tokens after #endif (the "extra tokens" warning class) - so those hooks silently never ran. Give the hook its own line when the translation ends in a directive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…columns present_bezel only drew the left/right margins and explicitly ignored the vertical geometry, so a window taller than the content aspect (32:9 content on a 16:9 display) framed the game with bezel art on the sides and raw black bars above and below. Add bezel_draw_band -- the transpose of bezel_draw_rect: one logo spans the band height with the same air factor, repeating across at authored aspect -- and clear/draw the top and bottom bands between the side columns so no pixel is painted twice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two refinements that keep the game image the focal point and make the padding read as environmental texture instead of a second UI layer: - Padding shade: a per-rect gradient quad (new tiny fragment program) drawn over the bezel backdrop+tiles, derived from the live viewport rectangle. The padding is near-black (alpha 0.94) immediately beside the game and eases out to a faint pattern (alpha 0.72) at the outer screen edges, smoothstepped across the full padding span - wide and subtle, never a narrow shadow. Columns shade along X, bands along Y, so it holds for any viewport aspect. The game rect itself is never touched. - Transition fade: when the viewport rect moves (menu aspect contraction, window resize) the padding restarts from solid black and reveals the resting gradient over ~200 ms (smoothstepped), so the eye follows the contracting game image instead of a full-intensity pattern popping in on frame one. The fade folds into the gradient endpoints (affine), so it is one draw either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Manual fast-forward engage/release and turbo-loads engage/release now print one [pace] line each with the host timestamp. Any unpaced or present-skip mode flipping mid-play is exactly the class users report as "the speed is busted", and the existing transition ring is only reachable through the debug server - absent from play builds, which is where players actually live. One line per transition self-documents the next repro. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…screen" treatment
Pacing: when driver vsync owns the frame cadence the blocking Swap IS the
game's clock, but the unchanged-frame present skip bypassed it - every
skipped swap silently unpaced that frame. WipEout 3 at 30 Hz sim renders
every other vblank, so half the frames ran unpaced: the game sped to a
fluctuating ~2x with pitched-up CD audio and visible frameskip, EXCEPT while
the FPS readout was on, whose persistent OSD line forced a swap per frame
and masked the hole ("the moment I do fps readout off it goes stupid fast").
gl_renderer_set_swap_paces(), derived in apply_present_cadence(), disables
the skip whenever the swap paces. Real hardware scans out every vblank
regardless of change.
Bezel: replace the flat tiling with a mounted-screen treatment - deep-tinted
ground, the tiled marks at watermark subtlety (0.22), a distance-field shade
(rounded-rect SDF against the live viewport rect) running near-black beside
the game to a faint pattern at the outer edges, and a resolution-scaled
hairline seam terminating the viewport. The 200 ms fade-in from black on
viewport-rect changes folds into the shade endpoints. Game framebuffer
untouched; all of it derives procedurally from the viewport rect.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mark size The swap-paces gate from the previous commit was a flag cached from apply_present_cadence(), which can run before the GL renderer is live - the gate never engaged and the unpaced-skip hole survived. Replace it with psx_present_swap_paces(), evaluated by the renderer at every skip decision, so no cached copy can go stale across init order or cadence changes. The setter is gone. Bezel: floor the tiled mark size at 20% of the window's short dimension. A thin letterbox band no longer shrinks the marks into busy little rows - they stay window-proportioned and the band crops them symmetrically, so bands read like the side columns do. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…card_unstick) The unstick pump - a nest repair for Ape Escape LOAD that force-re-edges I_MASK.7/IRQ7 after a LOAD-style probe abort - ran enabled by default for every game. On WipEout 3 SE under the NTSC x2 CRTC its arming detector misfires during the boot memcard check, and the forced IRQ storm keeps the kernel card ISR acking idle bytes forever (traced: 40k+ card_ack 0x00 at ~1.3k-cycle spacing, all Sw/HwCARD completion events stuck ACTIVE, zero delivered), so the game waits on the memcard screen indefinitely. A/B with PSX_APE_CARD_UNSTICK=0 boots straight through. Per-game hacks are config-owned: default OFF, enable with [runtime] ape_card_unstick = true (Ape Escape's config should set it), and the PSX_APE_CARD_UNSTICK env var still overrides in either direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Y_FLAVOR) The runtime pins the cache dir and captures path into the spawned compile's environment so the cache can never drift from where the loader reads - but not the FLAVOR. compile_overlays.py's --flavor defaults to 0 (play), so an instrumented (flavor 2) runtime spawned compiles whose shards its own loader then rejected: the overlay_loader_status message said it plainly - "this build reads ..._f2 but shards exist under ..._f0 -> ALL overlays run INTERPRETED". On mod-heavy titles (WipEout 3's framerate packages patch most of the game text) that is the difference between full speed and ~0.2x, misread as general slowness for weeks. The runtime now exports PSX_OVERLAY_FLAVOR alongside the other pins, and the tool honors it over --flavor, so each build population self-heals. Measured after the fix (instrumented, WipEout 3 ntsc120full8): shard loads 0 -> 8 (45 funcs), interpreted insns 6.6M/s -> 1.0M/s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Titles running the 8MB enhancement stream code into the extended banks
(0x200000..0x7FFFFF). The vault's 2 MB mask corrupted those capture
addresses and its bound rejected legitimate regions ("capture exceeds
PSX RAM"), which blocked compacting WipEout 3's merged capture history.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A sweep of vblank-period-derived windows under the NTSC x2 CRTC (audit
2026-08-19) confirmed the high-stakes paths - SPU/XA/CDDA pitch and
cadence, guest timers, rumble, pacing - are guest-cycle-anchored and
correct at x2. Two windows that cover CYCLE-fixed guest work were
period-relative and silently halved:
- sio.c card ChangeThread-defer tail ("~2 VBlank periods" over the
libcard A6C10/B4E38 handshake): now stock-period relative.
- cdrom.c savestate CD boost window ("~3s at 60Hz", counted per sim
vblank): now scaled by the multiplier.
Remaining audited-but-unchanged exposures are opt-in QoL paths (FMV
auto-skip hold, turbo-load hysteresis, audio mute hangover, rewind
interval, pacer catch-up window) documented in the audit; and one latent
trap: the non-block-cycles fire_vblank_edge path steps timers/cdrom per
vblank edge and would run devices at 2x under the multiplier (shipping
builds compile it out). mdec.c's "cycles_per_frame = 338688" comment is
mislabeled (that is 10 ms, not a frame) - pre-existing, cycle-anchored,
unaffected by the multiplier.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…depth; exit-reg diagnostics pc=0 from the same-thread-restore epilogue means "the interrupted native frame is still on the host stack below - return and it continues". At dispatch depth <= 0 no such frame can exist and the 0 reaches the main loop as a bogus GUEST_EXIT, so prefer the compiled interrupt resume PC there (the same remedy the scheduler top-level-resume case already uses). Honest scope note: this closes a real hole by construction, but does NOT fix the observed WipEout 3 static-bake boot exit (deterministic "execution completed, PC=0", ra=0x800D7FC0, epc=0x8015FAEC) - that death reproduces unchanged, so its escape route is elsewhere; investigation continues. Also: the top-level exit path now prints the exit registers (ra/sp/epc/sr/func/last_store_pc/v0/a0/t9), which is how the crash was attributed on play builds without debug tools. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pack directory's exclude.txt is game content (gitignored), so it silently disappears whenever the pack is regenerated - which re-enabled HD substitution of WipEout 3's four font atlases and produced the doubled-glyph defect (sub-texel HD strokes under the game's two-pass shadow draw; coverage.json proved atlas 4cb2ec4e substituting). The config now carries the exclusions durably; they merge with any pack-local exclude.txt, and semantics are unchanged (texel-source exclusion only, applied at tex_pack_lookup_replacement). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t replay, idempotent enable Root-causes the transient 1-frame sign-corner flicker at wide aspects (sign faces blink when the exact-sign NCLIP path silently loses its shadows for one frame): - gte_nclip was the only shadow consumer skipping the engine's single safety invariant (packed-word validation). A stale FIFO shadow (e.g. after an SXYP MTC2 shifts the real FIFO but not shadows 12/13) could invent a silently WRONG winding sign. New pgxp_get_gte_sxy_checked validates the shadow's word against the live SXY register; staleness now demotes to the honest integer fallback. - Overlay shadow-diff REPLAY passes ran the pgxp memory hooks live (record pass's stale FIFO shadows re-validated against different vertices -> flags dropped, live slots overwritten -> arena-wide provenance kill = one-frame integer-sign blink, plus false SHDIFF_GTE_DATA divergences permanently demoting shards to interp). gte_replay_side_effects_begin/end now bracket pgxp_suppress so replay is shadow-silent, like the speculative validation passes. The nclip precise path is additionally gated off inside the sandbox and speculative passes. - pgxp_set_enabled is now idempotent: a redundant re-derive (config apply, correction toggle, debug A/B) no longer costs a generation bump, which killed every live shadow and blinked one frame of exact-sign coverage. - Observability: free-running NCLIP precise/fallback/corrected counters and a PGXPStats.invalidations counter (bumped at the generation-bump site), both exposed in the geom_correction debug reply. All changes are inert at 4:3 (ws_precise_nclip gate) and touch host-only state exclusively. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sync-RFE latch; pc0 escape journal
Fixes the deterministic top-level "execution completed, PC=0" ~10 s
into WipEout 3 boot with the static bake linked (the 120 Hz core bug).
Root cause (CPS-contract audit, H1): every emitted block-leader IRQ
check was `psx_check_interrupts_at(cpu, X); cpu->pc = X;` — whatever
resume PC the exception epilogue published (guest RFE to a non-EPC
target: longjmp-style card-ISR handlers, SetCustomExitFromException,
thread switch) was unconditionally overwritten by the static transfer.
The dirty interpreter honors these redirects ("Handler resumed
elsewhere — surface to dispatch"); the static bake moved the IRQ
take-points from interp pumps onto compiled leaders, where the redirect
was dropped — the guest's continuation vanished, downstream state was
never established, and a later jr through a null pointer surfaced as a
silent top-level PC=0 exit.
- code_generator/full_function_emitter: both check emitters (and the
install-slot hook) now emit the compiled analogue of the interp
contract: `if (cpu->pc != 0 && ((cpu->pc ^ X) & 0x1FFFFFFF)) return;`
after the check, surfacing the redirect to the trampoline. Applies to
branch/jr/jalr/jal/split sites in the AOT text, the recompiled BIOS,
and every overlay shard/static bake on regeneration.
- interrupts.c: the async-RFE resume latch (g_async_rfe_resume_pc,
"Tomba 2 frame-1997 fix") was documented but NEVER ASSIGNED — the
whole rescue in the traps/dirty sentinel gates was inert (audit H3).
Latch it at every exception entry that installs a real EPC.
- pc0 escape journal (always-on, play builds): every runtime site that
publishes/rescues a null PC records {site, frame, dispatch depth, ra,
epc, extras} in a 64-entry ring, dumped at the abnormal-exit print.
The play build's exit trace was blind (fntrace is debug-only:
fntrace_seq=0 in the death artifact); publishes are rare so the
always-on cost is a few stores.
- compile_overlays.py: PSX_STATIC_NO_ISOLATED=1 A/B guard to skip the
isolated-fragment pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first guard shape (6ead85c) surfaced redirects but left cpu->pc nonzero when the exception epilogue benignly resumed at the site's own PC (the offline mode-1 restore path publishes cpu->pc = real_epc and never zeroes it). At a goto-type check site the stale pc then survived into the NEXT check site, where it read as a foreign redirect and spuriously unwound the frame mid-function — measured live on WipEout 3: the top-level PC=0 exit moved earlier (frame ~639, exit ra=0) instead of dying at the original site. The trampoline's own return-boundary helper already consumes the same-site publish (pc==stop -> pc=0); the guard now does the same: surface a foreign resume, consume a same-site one, fall through untouched only when pc==0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… static-bake boot exit root cause A region-truncated final block that runs off the end of its function fell off the C function body when no in-image fallthrough function existed: the entry-switch prologue had consumed cpu->pc to 0, so the dispatcher returned "handled" with pc==0 and the top-level trampoline read it as "program ended". Witnessed live in the WipEout 3 ntsc120full8 static bake: the 0xD5000 region variant's block_800D7FC0 ends at the region edge (continuation 0x800D8004 lies outside the image); the moment CD streaming loaded content matching that variant's identity ranges the dispatcher routed 0x800D7FC0 into the truncated block and the run died — the deterministic "execution completed, PC=0" at ~10 s into boot, ~frame 1221, byte-for-byte reproducible. Both code_generator tail sites (plain functions and alias hosts) now emit `cpu->pc = <end+4>; return;` when the last live block runs off the image edge — the CPS tail-transfer the full_function_emitter already emits for in-image fallthroughs. The dispatcher routes the published continuation to whoever owns it (a neighboring region variant, AOT text, or the interpreter). Faithful and class-level: no per-game anything. Diagnosis chain that cornered it (all landed as permanent tooling): - pc0 escape journal site 14 (trampoline top-level exit) named the last dispatched target: 0x800D7FC0. - site 15 (dirty-dispatch handled-with-pc0 tripwire, now journaled with a route tag) proved the producer was route 3 = the static overlay dispatcher, first occurrence, not in exception context. - Return-shape census of every candidate variant body showed no return-with-pc==0 path — forcing the discovery that the dispatch case for 0x800D7FC0 routes FIRST to the 0xD5000 variant, whose truncated tail block falls off the end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t the function tail 3b69868 patched the two function-tail sites, but the truncated tail is emitted by the block-level fallthrough branch: a block with exit type None and no successor whose next PC is not a known function emitted the interrupt check and nothing else — execution fell off the C function with pc==0 (verified: the re-baked overlays_static.c still carried the truncated block_800D7FC0 tail; zero image-edge transfers were emitted). Publish the continuation there too. The function-tail guards stay as belt-and-braces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1. Rewind degraded-mode fix: a capture deferred past its interval (previous snapshot still compressing at capture time) retried with no PBO prearm and fell into the synchronous full-pipeline VRAM readback (8-17 ms on the emu thread). Load-heavy windows delay the compression pump, making the degraded mode self-selecting exactly where frames are already long — the 0.4x dip class. Arm the async PBO whenever a capture is pending and no compression is in flight. 2. Native-vblank-rate mods no longer force vsync off for fps > 60. The present-cadence policy is already an XOR (swap interval resolves to the configured vsync only when the panel matches the CRTC rate within 2%, else 0 with the wall pacer engaged), so the force was pure over-defense that locked the 120 Hz mod onto the wall pacer even on a matching 120.0 Hz panel — where driver vsync absorbs the sub-frame jitter the pacer converts into debt/catch-up oscillation (measured 99.7-122.5 fps seesaw at 0.83-1.02x). Non-matching panels behave exactly as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-cache hit From the steady-state dispatch-cost audit of the WipEout 3 static bake (80,847 baked variants; ~1M dispatches/s): 1. DOMINANT: psx_overlay_static_code_matches used a shared 4096-slot cache with no eviction — the first ~4096 tuples dispatched owned it forever, and every other variant paid a full 4096-entry linear probe PLUS a full CRC32 of its code bytes on EVERY dispatch, with the result uncached (entry NULL when the table is full). Boot-order dependence of which tuples win slots explains the session-dependent 0.83-1.02x seesaw. New psx_overlay_static_code_matches_memo takes a per-variant state[2] emitted by the generator next to each ranges array: repeat calls are the page-gen sum plus one compare; the CRC runs only on first sight or after a real write to a covered page. Identical invalidation semantics; unbounded capacity; no probing. compile_overlays.py emits the state and calls the memo variant. 2. fntrace dispatch-tail + SP-domain rings gated out of play builds (PSX_NO_DEBUG_TOOLS): their per-dispatch writes (incl. a psx_get_cycle_count() call each) had no reader — the TCP server that dumps them is compiled out. Abnormal-exit forensics in play builds is carried by the pc0 escape journal. Debug builds unchanged. 3. Per-instruction psx_icache_fetch was an out-of-line call whose hit path is 3 branches + a tag compare; generated code now uses the inline tag-hit (psx_icache_fetch_interp) in-process, keeping the exported slow entry for DLL shards (identical cache evolution — the inline's miss path IS that entry). Both emitters. Also converges the image-edge fallthrough publish with upstream PR mstan#161 (cps_enabled_-gated; non-CPS keeps the legacy check-only fall-through). Upstream PR mstan#158 (xlate capture default off) is already superseded here by the release-build gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cro in both preambles - Env-gated self-profiler: samples the main (emu) thread's RIP at 1 kHz for N seconds, histograms exe-relative 64-byte buckets, dumps the top 120 to psx_self_profile.txt (llvm-addr2line VA = 0x140000000+offset). Play-build hot-spot attribution without ETL symbolization; zero cost when unset. Motivation: internal-scale (16x vs 8x), cadence (vsync vs pacer), and identity-memoization A/Bs all measured within noise, while halving cpu_overclock nearly closed the fps gap - the cost is in guest instruction throughput and needs real attribution, not hypotheses. - Mixed-emitter files (FFE preamble + code_generator bodies) missed the PSX_ICACHE_FETCH macro; both FFE preambles now define it too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… PGXP ALU gating First real attribution (in-process 1 kHz IP sampler, 75k samples at oc900, play build). Top sinks and their fixes: - tex_pack_on_textured_prim (7.4%): per-prim mutex + CLUT hash + linear scan over up to 8192 uploads — entirely matched-set/dump census, a debug/authoring surface with no reader in play builds (substitution lives in tex_pack_lookup_replacement). Gated out of play builds unless dumping. Confirmed gone in the follow-up profile. - text_xlate_on_dispatch (4.2% -> 0.8%): the a0..a3 record scan serves capture (off in release) and STRING substitution; a fixes-only load (vram patches / glyph labels) has an empty string table, so the scan could never act. Early-out on empty table; the throttled patch blocks still run. - psx_pgxp_alu/muldiv (3.6%): ALU/MULDIV shadows feed only tier-2 cpu_mode and the correction consumers' chains; the exact-sign NCLIP arm is LOAD/STORE/COP2 only. Hook bodies now gate on (cpu_mode || full_hooks) — full_hooks maintained by gpu_pgxp_rederive_enable — and the remaining per-instruction CALL cost is removed by an inline g_pgxp_alu_armed check in the PGXP_ALU/PGXP_MULDIV macros themselves. Correctness: a stale shadow from the gated period is dropped by pv_validate at every consumer (the engine's single safety invariant, verified at the MTC2 path). Measured effect of the round at oc900 (before the macro gate): sustained 0.73-0.80x -> 0.87-0.91x; streaming-window dip 0.35 -> 0.50; post-window touching 1.00x. Fresh profile after: xlate and tex_pack prim hook out of the top-15. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…xt-ok memo; tex_pack lookup memo; bezel seam removed - ADAPTIVE OVERCLOCK: the configured cpu_overclock is now a ceiling. A proportional controller on an EMA of the wall vblank period trades the effective overclock down (floor 200%, PSX_ADAPTIVE_OC env) when the emu thread cannot retire the scaled work, and back up when there is headroom — so the GUEST TIMELINE never dilates. Measured live before: guest time at 0.4x wall in races (CD audio at 0.4x pitch, SIO pad exchange lagging, race clock slow) while per-vblank game logic ran fast on the vblanks that did arrive. After: 0.94-0.98x sustained through scenes that previously dropped to 0.3-0.5x, CDDA at wall rate. Frozen across intentional unpaced windows (turbo loads, HLE boot). - dirty_ram_text_native_ok memo made page-granular: a diverging guarded write now bumps only its page's generation; the memo keys on the sum over a function's own range pages. CD streaming (hundreds of dirty transitions per frame in races) no longer flushes every verdict (was 6.8% of the emu thread with the memo never hitting). - tex_pack_lookup_replacement memo with lazy upload validation: keyed on (uv limits, clut, texpage); kills/repl growth invalidate hard, upload ADDS are checked lazily against the prim rect and CLUT row so continuous race streaming does not self-flush the memo (was 9.3%). - Bezel: seam hairline removed (user request); the near-black gradient and wide falloff stay exactly as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tive overclock removed - Adaptive overclock REMOVED (user decision, and correctly so): trading overclock down preserved vblank cadence but starved the game's render loop (~30-50 real frames/s under a 110+ vblank counter) and modulated the guest-time ratio continuously — an audible CDDA warble. The DuckStation-standard model stands: static user overclock; a too-slow host slows everything uniformly and audio underruns, never pitch-shifts. - SDL3 audio: psx_sdl_audio_open reported the DEVICE format instead of the stream's INPUT spec. SDL3 converts fed data FROM the requested spec; telling the DRC host_rate=48000 while SDL consumed it as 44100 was a constant ~0.92x pitch-down plus perpetual ring overfill (overflow_drops clicks) on every SDL3 build. Report the input spec — the DRC stays the only resampler. (Audio-clocking audit confirmed the bridge already implements the DuckStation contract: guest-clocked production at 768 cycles/sample, device-rate pull, +-0.5% trim, hold+fade on underrun; production stages must not be touched — netplay/savestate invariants at main.cpp:3009/3071.) - Present-aspect scene switch: with the widescreen mod engaged, menus/2D (scene gate inactive, 45-frame hysteresis in gpu.c) present at the game's own 4:3 so its NATURAL black bars stay, framed by the bezels; gameplay presents at the mod aspect. New export psx_ws_scene_wide_active(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…u aspect switch reverted - Internal rendered-frame counter: GP1(05h) display-origin CHANGES (the game finishing a frame and flipping buffers — the standard emulator "internal FPS"). Shown beside the vblank rate in the title, OSD, and [FPS] telemetry: "VBlank N/s X.XXx | Render M fps". The two diverge under load and users could not tell which one they were seeing (measured: races render exactly one frame per vblank; menus idle far below their vblank rate). - The per-scene 4:3 menu present (previous commit) is reverted per user direction: uniform mod aspect everywhere; the game's own image is never re-shaped between menu and gameplay. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ptures, PGXP shared edges Framework fixes surfaced while bringing up a title that stresses overlays, HD texture replacement and PGXP at a doubled CRTC rate. All are general; the title's own configuration stays in its game repository. CORRECTNESS * Overlay DLL shim was deleted by the preprocessor. compile_overlays.py injected overlay_dispatch_preamble.c.inc after the syntactically LAST #include. A conditional i-cache include moved that anchor inside an #else, and DLL builds define PSX_OVERLAY_DLL_BUILD, so the callback definitions preprocessed away and every overlay DLL failed to link. Any title using overlays would silently fall back to per-instruction interpretation. The shim is now anchored to the unconditional #include "psx_runtime.h" and raises if that anchor is absent. * Overlay capture kept stale PC bitmaps when executed code later became data, so one variant's PC set could be compiled against another variant's bytes. overlay_watch_note_write now retires the whole page's execution/dispatch epoch when any executed word is overwritten; static demand is keyed by (load_addr, size, bytes) and resolved only against matching range identities. * VRAM-to-VRAM copies only invalidated the destination and never registered it as a replacement candidate, so textures arriving by copy could not be dumped or replaced at all. gr_copy_rect now calls tex_pack_on_copy with the same (texture,palette) key semantics as CPU uploads. * PGXP shared-edge seams: a per-primitive all-or-nothing gate cannot stop two adjacent primitives disagreeing about a shared endpoint, so the edge is submitted at two sub-pixel positions. Adds a frame-stamped canonical endpoint cache keyed by final integer screen position: the first use fixes the coordinates and every later occurrence reuses them. * PGXP provenance: PGXPValue carries per-component X/Y/Z projection ids, so a value assembled from different projections can no longer arm correction. Destructive AND/XOR/NOR/SLT, link and COP0 writes now invalidate shadows, and the SXYP shadow FIFO shifts with guest SXY. * Precise NCLIP no longer replaces guest MAC0. It is telemetry only, so face visibility stays bit-exact with the integer GTE. * Removed two counters that were reported as correctness evidence but were never incremented anywhere. PERFORMANCE * PSX_ENABLE_LTO plus a noinline guard on the shared cycle wrappers. Unguarded ThinLTO clones hot interlock bodies and REGRESSES the hot path; guarded, it becomes a win. Adds runtime/tests/bench_psx_runtime_hotpath.c, which verifies variant equivalence by digest so codegen changes are measurable. * Cycle accounting: emitters select specialized arity-0/1/2/3 helpers at generation time instead of a generic dependency-mask loop; main-RAM loads combine base/wait/completion into one exact charge. Exactness is covered by pipeline-state, load-state, deadline-crossing and cross-device causality tests. * HD texture invalidation replaced a linear scan of up to 8192 upload records per VRAM write with a tile-refcount index; out-of-domain writes early-out. * Translation/text-substitution dispatch is gated behind a monotonic mutation epoch instead of running a callback on every dispatch. * card_data_writes_check no longer runs on every RAM write. CAPABILITY * fixed_outer_aspect: the outer presentation aspect becomes a function of user configuration only. Scene classification selects content transforms, never the outer viewport, so menus/FMV stay centred undistorted inside a wide canvas. VERIFICATION AND LIMITS docs/UPSTREAM_2026-08-20.md audits each change against the diff, marks unsupported claims UNKNOWN, and lists a suggested PR split and review focus. Unit and structural tests accompany each item, but this body of work has NOT been validated end-to-end against a fixed reproducible workload -- reviewers should treat the timing-exactness and PGXP changes as the highest-risk areas. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Framework fixes and instrumentation surfaced while bringing up a title that stresses overlays, HD texture replacement and PGXP at a doubled CRTC rate. Everything here is general; the title's own configuration stays in its game repository.
Full per-change audit, with root causes at
file:line, covering tests, risk review and a suggested split:docs/UPSTREAM_2026-08-20.md.The headline bug
Overlay DLLs never linked, so overlays silently fell back to per-instruction interpretation.
compile_overlays.pyinjectedoverlay_dispatch_preamble.c.incafter the syntactically last#include. A conditional i-cache include moved that anchor inside an#else, and DLL builds definePSX_OVERLAY_DLL_BUILD— so the callback definitions preprocessed away and every shard failed to link on ~20 runtime symbols. The runtime's own exit report showeddisp_native = 0alongside millions of interpreted dispatches, withautocompile_degraded = 1naming the cause.This affects any title using overlays, and it fails quietly — the game still runs, just far slower. The shim is now anchored to the unconditional
#include "psx_runtime.h"and raises if that anchor is ever absent, with a regression test asserting the shim precedes the DLL conditional.Correctness
overlay_watch_note_writenow retires the whole page's execution/dispatch epoch when any executed word is overwritten; static demand is keyed by(load_addr, size, bytes).gr_copy_rectnow callstex_pack_on_copywith the same key semantics as CPU uploads.PGXPValuenow carries per-component X/Y/Z projection ids, so a value assembled from two different projections cannot arm correction. DestructiveAND/XOR/NOR/SLT, link and COP0 writes invalidate shadows; the SXYP shadow FIFO shifts with guest SXY.Performance
PSX_ENABLE_LTO, plus anoinlineguard on the shared cycle wrappers. This matters: unguarded ThinLTO clones hot interlock bodies and regresses the hot path substantially. Guarded, it becomes a solid win. Addsruntime/tests/bench_psx_runtime_hotpath.c, which verifies variant equivalence by digest, so codegen changes are measurable rather than argued about.card_data_writes_checkno longer runs on every RAM write.Capability
fixed_outer_aspect(default off): the outer presentation aspect becomes a function of user configuration only. Scene classification chooses content transforms, never the outer viewport, so menus and FMV stay centred and undistorted inside a wide canvas instead of switching the viewport per frame.Verification, and what is not verified
Each item ships with unit or structural tests, and
docs/UPSTREAM_2026-08-20.mddeliberately marks claims UNKNOWN where the committed delta contains no result transcript. Measured numbers from the originating session are recorded in that document's appendix as single-machine observations, not a portable benchmark.This body of work has not been validated end-to-end against a fixed reproducible workload. The timing-exactness and PGXP changes are the highest-risk areas and deserve the closest review — the audit's RISK section names the specific invariants each must preserve.
This is a large PR (126 commits). The audit proposes an ordered split if you would rather take it in pieces; I am happy to break it up along those lines.