diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 8a3de9b8..e8ca6019 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -538,6 +538,22 @@ frames. Trusted callbacks receive only the narrow C services exposed by `runtime/include/mod_plugins.h`. Games should continue to use declarative patches and overlays when those operations are sufficient. +An implementation may also register **function overrides** under a plugin id +(`psx_mod_register_function_override`): hand-written C that replaces or wraps +a guest function at a given address, with an optional prologue-word residency +guard. Registration only queues the override; it is ARMED into the dispatcher +tier when the resolved plan selects that plugin — the same gating as the other +callback kinds, so an override-only plugin id counts as available to the +resolver. Every registration states a required guest-cycle `credit` (a fixed +per-handled-call charge, `0` for a mod with no hardware analog, or +`FO_CREDIT_SELF` when the body — or a wrapped original — accounts for its own +time). The full execution contract (guest ABI, decline semantics, +`func_override_call_original` wrap primitive, `func_override_guest_call`, +determinism and cycle-accounting policy) is documented in +`runtime/include/func_override.h`. Overrides registered directly through +`func_override_add` (game `EXTRAS_SOURCES` constructors, the progressive- +decompilation idiom) bypass package gating and are always active; packages +are the right home for anything a player should be able to toggle. ### Session starts and mod-owned state What a plugin sets up should last only for a session whose resolved plan @@ -1166,3 +1182,10 @@ existing post-commit path, not the preparation worker. The `prepare_resources` method is separate from read-only `resolve`, so editing launcher settings does not trigger conversions. Runtime activation still sees only fully verified immutable resources. + +Function-override package arming is transactional and runs before activation +callbacks. Address collisions or capacity failures reject the launch through +`mod_runtime_activation_error()` and remove all package entries, retaining +always-on direct implementations. Every session rearms its selected plan. +Replacement overrides cannot be selected as netplay local-view plugins: they +change shared execution rather than only sandboxed presentation. diff --git a/docs/POST_EFFECTS.md b/docs/POST_EFFECTS.md index 33d7def6..7cc42d94 100644 --- a/docs/POST_EFFECTS.md +++ b/docs/POST_EFFECTS.md @@ -92,6 +92,19 @@ Bloom uses the mask at both ends: only lit pixels feed its bright pass, and only lit pixels take the glow back. HUD pixels are therefore never changed, even next to a lamp. +## Depth, history, time and live uniforms + +- `input depth`: view depth `sz` (GTE screen z) of the presented frame, R32F at window + size, rebuilt from the surfaces' PGXP depth (`zn = 1 - 512/(sz + 256)`); 0 where nothing 3D drew. + Only built while a loaded chain reads it. +- `input history`: this pass's own target from the last real frame (trails, temporal + filters), including the final window pass. Generated frames do not update it. History starts empty after loading or resizing. +- `u_time` (host seconds since the chain first drew, reset on reload) and `u_proj` (latest GTE projection: `240/H`, screen + centre in uv, aspect) are declared in every pass. +- Live uniforms: a title sets a preset `uniform` from game state with + `psx_host_set_post_uniform(name, x, y, z, w)` (`host_overlay.h`), e.g. a light source projected + to the screen; the preset value is the default until the first call. + ## Tests - `postfx_chain_test`: the format, built-in presets, errors and file import. @@ -102,3 +115,7 @@ even next to a lamp. thread and on the render thread. - `gl_post_aa_test`: FXAA on the pipeline. Its output is byte-identical to the former fixed pass. + +The projection is a coherent latest CPU value. It is not associated with a +queued frame or an individual replay view; shaders requiring an exact camera +for a delayed or stereo image need corresponding frame/view metadata. diff --git a/docs/PRODUCTION_INPUT_REPLAY.md b/docs/PRODUCTION_INPUT_REPLAY.md deleted file mode 100644 index 47408793..00000000 --- a/docs/PRODUCTION_INPUT_REPLAY.md +++ /dev/null @@ -1,49 +0,0 @@ -# Production input replay - -`PSX_INPUT_REPLAY=/absolute/path/route.txt` supplies recorded active-low digital -pad words in every build, including `PSX_DEBUG_TOOLS=OFF`. Each non-comment -line is `frame_count hexadecimal_buttons`, for example `60 bfff`. Counts must -be positive; pad words must fit 16 bits. The route starts at the first VBlank -after a successful disk savestate load, advances by guest frames, and returns -neutral input after its end. A failed requested state load is fatal. Netplay -and file replay cannot run together. - -Use the exact state paired with the recording. This is a host input source; -it does not bypass savestate compatibility checks or change guest timing. - -Optional bounded benchmark controls: - -- `PSX_INPUT_REPLAY_STOP=2700`: request normal shutdown ten frames after this - zero-based route offset. -- `PSX_INPUT_REPLAY_SECONDS=60`: instead of STOP, run a wall-time window of - 1..60 seconds beginning at route offset 900. Stop at the first guest frame - after its deadline, then request shutdown ten frames later. -- `PSX_INPUT_REPLAY_REPORT=/absolute/path/frames.csv`: export frame stamps and - cumulative guest, pacer and render-queue times at shutdown. Enable existing - `PSX_RUNTIME_PERF_DIAG=1` for guest/pacer counters. Times use microseconds; - differences between adjacent rows describe the intervening guest interval. - A companion `.spans.csv` exports the bounded host attribution ring. - `.render.csv` exports GL work counts. With `PSX_GL_CPU_PERF=1` it also - reports inclusive CPU wall spans (calls, total and maximum) for depth - restoration, queue replay, VRAM copies, interpolation captures/intervals and - centre splices. These spans overlap; do not sum them as exclusive costs. - Collection is off by default and its queue drain/export happens after the - measured route window. Use separate profiling and acceptance runs. - `PSX_GL_CPU_PERF=2` additionally finishes queued GPU work before and after - queue replay, VRAM copies, captures and centre splices. These spans include - GPU completion; `_prior_drain` rows measure earlier queued work separately. - This serializes the pipeline and is attribution only. Totals cover the - whole replay, including warmup. `.rth.json` records queue waits and recent - context-acquisition reasons before the report's own drain. - `PSX_GL_CPU_PERF=3` instead samples one in 128 high-resolution textured - depth batches: `colour_gpu_sample` and `depth_gpu_sample` finish GPU work - around the same batch's colour and depth draws. Sampling perturbs cadence; - these completion spans are not acceptance frame times. - `PSX_GL_PERF=1` additionally enables GPU queries in production and exports - rolling GPU aggregates to `.gpu.csv`; use this for separate attribution - trials, since queries can perturb the measured workload. -- `PSX_INPUT_REPLAY_SHOTS=/absolute/path/prefix`: request screenshots at route - offsets 800 and STOP+2, outside the usual measured interval 900..2700. - -The caller chooses new output paths and provides a process timeout. Reports -measure guest VBlanks, not distinct rendered images or display scanout. diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 3b34ff7d..74bbacf1 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -196,18 +196,6 @@ example. ## Testing without a window -`render_pass_stats.timing` contains cumulative milliseconds and a transaction -count. Subtract two receipts and divide each time delta by the count delta for -window means; `avg_*` fields are exponential estimates, not window averages. -`gl_begin_ms` includes GPU backup and optional VRAM verification readback; -`checkpoint_ms` is the CPU/device checkpoint; `verify_before_ms` is the optional -pre-draw state hash. `gl_end_ms` includes GPU flush, image capture, restore and -optional VRAM readback; `restore_ms` restores the CPU/device checkpoint; -`verify_after_ms` includes the post-restore state hash. All buckets include host -waits and scheduling, so they are wall time, not GPU execution time. Measure -production cost with `PSX_RENDER_PASS_VERIFY=0`, and test restoration separately -with verification enabled. Counts include discarded and aborted transactions. - `--hidden-window` runs the full windowed runtime (presenter, interpolation, render passes) in a window that is never shown and never opens a modal or a file picker. `--headless` skips the presenter, so plans there are always diff --git a/docs/RENDER_THREAD.md b/docs/RENDER_THREAD.md index 60675ed9..63d189bf 100644 --- a/docs/RENDER_THREAD.md +++ b/docs/RENDER_THREAD.md @@ -184,8 +184,8 @@ resolution (below), not of this layer. |---|---| | savestates / rewind | load and save are sync points; the frame stays synchronous, the next frame boundary releases | | netplay / rollback resimulation | `psx_netplay_active()` or dual-raster makes every frame ineligible: the emulation thread holds the context (synchronous path). Not started when netplay is configured at boot | -| render passes | a transaction is a sync point; the rest of that frame is synchronous. An allocation-mode refusal needs no queue drain | -| frame interpolation | eligible; temporal composition uses the renderer's existing context. The guest keeps its own deadline cap while the context is on the render thread | +| render passes | first `gl_renderer_pass_*` call in a frame is a sync point; the rest of that frame is synchronous. Recommended: leave `render_thread` off with the frame-rate mod | +| frame interpolation | ineligible (held) while enabled; not started when it is on at boot | | HD texture replacements / texture dumping | eligible; held only from a GP0(A0) header to its commit | | OpenXR | ineligible while a session is active | | Smooth motion (frame generation) | `[video] frame_generation`: in-between frames drawn by the render thread from recorded lists, docs/FRAME_GENERATION.md | @@ -405,13 +405,6 @@ means), `guest_bound`, the step counters and `up_blocked`. ## Debug server -Drawable pixel dimensions are queried on the window's owning thread and -published as one atomic width/height pair before each frame handoff. Render -and present workers read that pair; they do not call SDL3's main-thread-only -`SDL_GetWindowSizeInPixels`. Synchronous rendering still refreshes directly. -The hardware fixture verifies two resizes from a worker as well as image, -guest-readback and stencil equivalence with temporal presentation enabled. - `{"cmd":"render_thread"}` — active, held, max_frames, records, bytes, frames produced/consumed, presents and `presents_stale` (skipped), acquires (sync points) with the 16 most recent reasons and wait times, diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index c81a2eeb..e65796f4 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -493,9 +493,9 @@ between Play and TCP availability. ## Complete command index (generated) -**386 commands registered** — 373 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**387 commands registered** — 374 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -85 of 386 have prose above; **301 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +85 of 387 have prose above; **302 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -630,6 +630,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `frame_trace` | ✓ | | | | `frame_wall_ring` | ✓ | | | | `freeze_check` | ✓ | | | +| `func_override` | ✓ | | | | `game_options` | ✓ | | | | `gcp_ring` | ✓ | | ✓ | | `geom_correction` | ✓ | | ✓ | @@ -736,6 +737,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `pc_probe_arm` | ✓ | | | | `pc_probe_clear` | ✓ | | | | `pc_probe_dump` | ✓ | | | +| `perf` | ✓ | | | | `pgxp` | ✓ | | ✓ | | `pgxp_miss_ring` | ✓ | | | | `pgxp_shadow` | ✓ | | | diff --git a/docs/WIDESCREEN.md b/docs/WIDESCREEN.md index 7f7d35b5..710624cb 100644 --- a/docs/WIDESCREEN.md +++ b/docs/WIDESCREEN.md @@ -299,13 +299,14 @@ are non-empty, so titles that do not use them keep their caches. `screen_x_sites` is the explicit form of the `auto_screen_x` compare, for a title whose width is not in `screen_w_imms` or whose function has no paired -height compare. It takes four instructions: +height compare. It takes five instructions: | Instruction at the site | While widened | |---|---| | `sltiu rt, SX, W` | `rt = -m <= SX < W + m` | | `lui rt, W` (a screen edge kept as `W << 16`) | `rt = (W + m) << 16`; a negative `W` moves left instead | | `bltz x, outside` where `x` is `SX << 16` | taken while `SX < -m` | +| `slti rt, x, 0` where `x` is `SX << 16` | `rt = SX < -m` (the `bltz` test as a flag) | | `sltu rd, SX, W` with `W` in a register | `rd = -4m <= (int16)SX < W` | The `sltu` form covers a renderer that compares a 16-bit screen X @@ -343,11 +344,57 @@ screen_x_sites = [ `screen_x_sites` is applied when code is generated (native code and overlay shards) and, at runtime, by the dirty-RAM interpreter: code that runs from RAM through the interpreter (including under `PSX_FORCE_INTERP=1`) widens the -same four instruction forms at the listed addresses, with the same helpers and +same five instruction forms at the listed addresses, with the same helpers and the same identity at 4:3. A listed `bltz` follows the precedence `bltz_sites`, then `screen_x_sites`, then the `auto_screen_x` classifier. In the main EXE a listed address holding another non-branch instruction is a hard build error; -a branch other than `bltz` at a listed address is left as it is. +a branch other than `bltz` at a listed address is left as it is, and a +`slti` with a nonzero immediate is a build error (it is not this form). + +### Packed-SXY all-vertices reject (`[[widescreen.cull.sxy]]`) + +Some renderers trivially reject a whole triangle or quad by ANDing the packed +GTE `SXY` words of its vertices (Y in the high half, X in the low half) with a +sign mask and branching on the result: + +``` +and at, t0, s1 ; s1 = 0x80008000 +and at, s4, at +and at, t3, at ; (and at, t1, at for a quad) +bne at, zero, reject ; bit 31: all above, bit 15: all left of the screen +``` + +`screen_x_sites`, `bltz_sites` and the packed-coordinate helpers test one +value at a time and cannot widen this fold. Listing it widens only the left +reject: each AND of the fold becomes `psx_ws_cull_sxy_and(acc, sxy)`, the +vanilla AND except that bit 15 also needs this vertex's `X < -m`. Bit 31 (the +vertical reject) and every other bit are the vanilla AND, the guest's own +branch decides, and right/bottom rejects (a separate chain) are untouched. +At 4:3 (`m = 0`) every AND produces the vanilla word. + +```toml +[[widescreen.cull.sxy]] +kind = "tri" # or "quad" (4 vertices) +vertex_regs = [8, 20, 11] # the SXY operand of each AND +fold_addresses = ["0x8001A7F0", "0x8001A7F8", "0x8001A800"] +fold_expected = ["0x01110824", "0x02810824", "0x01610824"] +consumer_address = "0x8001A808" +consumer_expected = "0x14200034" # bne at, zero, reject +``` + +The loader derives the shape from the words and refuses anything else: one +AND per vertex, in increasing address order within 16 instructions, all into +one register `r`; the first combines `vertex_regs[0]` with a separate mask +register, each later one combines its vertex with `r`; the consumer is +`beq`/`bne r, zero` within four instructions after the last AND. Every word +of the fold and the consumer is part of the identity: the main-EXE build +fails on any mismatch (checked against the image up front and again when the +AND is emitted), and a captured overlay or interpreted variant with any other +word in the fold is left vanilla. Native code, overlay shards and the +dirty-RAM interpreter use the same helper and the same whole-fold check. +Changing a site changes the overlay-cache identity; regenerate after a change. +Wild 9 (SLUS-00425) is the first user: its triangle and quad rejects use this +shape, and its shifted-X lower edge is the `slti` form of `screen_x_sites`. --- diff --git a/docs/config_schema.md b/docs/config_schema.md index b2989de5..1abc6908 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -262,6 +262,29 @@ result = 1 semantics. - Regenerate main/overlay native code after changing the list. +### Packed-SXY all-vertices rejects + +```toml +[[widescreen.cull.sxy]] +kind = "quad" +vertex_regs = [8, 20, 11, 9] +fold_addresses = ["0x8001A9B4", "0x8001A9B8", "0x8001A9BC", "0x8001A9C0"] +fold_expected = ["0x01110824", "0x02810824", "0x01610824", "0x01210824"] +consumer_address = "0x8001A9C4" +consumer_expected = "0x14200049" +``` + +- `kind` is `"tri"` (3 vertices) or `"quad"` (4); `vertex_regs`, + `fold_addresses` and `fold_expected` have one entry per vertex. +- Every `fold_expected` is `and r, ...` into the same `r != $zero`; the first + reads `vertex_regs[0]` and a separate mask register, each later one reads + its vertex and `r`. `consumer_expected` is `beq`/`bne r, zero`. +- Addresses are word-aligned, in one segment, strictly increasing, the fold + within 16 instructions and the consumer within 4 after it; no address is + shared with another sxy site. At most 64 sites. +- Identity at 4:3; while widened only the X-sign bit of each AND changes. See + `docs/WIDESCREEN.md`. + Prefer an aspect-derived cone over `keep` when the original predicate is a camera-frustum test. `keep` has no queue policy and is appropriate only for a separately proven binary verdict. diff --git a/docs/internal/FAITHFUL_TIMING_PLAN.md b/docs/internal/FAITHFUL_TIMING_PLAN.md index a71aa36e..bc8ef56d 100644 --- a/docs/internal/FAITHFUL_TIMING_PLAN.md +++ b/docs/internal/FAITHFUL_TIMING_PLAN.md @@ -213,58 +213,16 @@ on a fixed region -> next. ## 5. Status / Log (update every session) -- **2026-10-21 (queued vertex uploads):** Pack replay vertices at offsets - aligned to each layout and upload once per used VBO per flush, reusing the - data across canonical tiles and wide mirrors. Native/readback/hires/wide - and stencil hashes match the per-batch upload control, 1x/4x, both allocation - modes and renderer thread off/on. R4's 30-second Ultra probe improves to - 55.684 guest Hz / p99 busy 63.3%, still below the owner's acceptance bar. - Broader bloom fusion passes the same matrix for mask-set and translucent - sources; production timing is pending. - -- **2026-10-21 (render coverage and driver waits):** Enabled temporal - presentation on the existing renderer context/thread. Queued wide mirrors - retain bloom classification; eligible opaque marking is fused with colour, - colourless passes omit RGB-only filtering, and depth restoration derives - the preceding draw's enable state instead of querying the threaded driver. - Final-flush centre shading is elided only before the authoritative splice. - Native/readback/hires/wide and both stencil-bit hashes match all independent - controls at 1x/4x with threads off/on, native and forced-window hires. - Default-Ultra production acceptance remains open; R4's slowdown report - records failed route trials and profiling limitations. No guest time changes. - -- **2026-10-21 (Windows sleep concurrency):** Concurrent guest/render pacers - exposed a shared auto-reset waitable timer: one caller cancels/consumes the - other's wake. Replaced it with FLS-owned timers and synchronized QPC setup. - Eight-sleeper regression fails old source at 10 s and passes the fix in - 0.49 s, including timer-handle cleanup. No guest time model changed. - -- **2026-10-21 (production R4 follow-up):** Removed debug-only PGXP receipt - writes from no-debug-tools builds; numeric PGXP and word-shadow suites pass. - Added disk-state-anchored applied-input replay and bounded frame/work/span - exports for production measurements. Five focused framework CTests and 26 - R4 checks pass on Windows. The owner's stricter default-Ultra acceptance bar - remains open; evidence is in R4 `analysis/render-60fps/SLOWDOWNS.md`. No guest - timing or projection arithmetic changed. - -- **2026-10-21 (Windows continuation, `feat/render-60fps`):** Added cumulative - render-transaction wall-time buckets, separating checkpoint copying from - verification hashing. After the owner's moving-course correction, R4 - same-state headless/turbo off/on: 2,701 guest fingerprints match (no temporal - passes in headless). Recorded driving route, 30-second 60 Hz windows: 51 - normal passes average 11.060 ms (max 16.202); 17 verification passes average - 18.227 ms, zero restoration mismatches. Earlier straight-acceleration timing - is excluded. Pass admission remains sparse; sustained 60 fps is unproven. - Windows build, nine R4 CTests, sandbox and - watchdog/abort tests pass. No faithful timing behavior changed. - -- **2026-10-21 (`feat/render-60fps`):** Admit opt-in render passes with HD - CPU-authoritative textures and give opted-in netplay temporal passes the - restored own-view scope. Sandbox tests and hidden GL pass fixture pass. - R4 delay-netplay: 120 common core digests identical with frame-rate on only - on one peer; 14 temporal passes, no restore mismatches or dispatch misses. - R4 performance/visual acceptance remains open; see its - `analysis/render-60fps/REPORT.md`. +- **2026-10-24 — scoped render-pass qualification.** The sandbox now compares + shared RAM/register/clock/interrupt digests before and after an actual + opted-in local temporal pass, discard, device-read abort, rollback refusal, + and a nonparticipating peer. The HD GL fixture separately opens a pass under + native authority. Consuming-title and matched-performance evidence remains + required for upstream promotion. +- **2026-10-24 — render-pass HD/local-view scope.** Re-scope the fork's + permission change onto current master without replay controls or host tracing. + Title-level positive HD/local-view digest and abort qualification remains + required before upstream promotion. - **2026-10-23 — VR upstream split.** Precise projective replay reconstructed on current master; six focused runtime regressions and the GPU-history guard pass. Prior fork/headset acceptance remains historical. Other VR features are @@ -2044,30 +2002,3 @@ Guest-frame lookup merged multiple extra presentations and could not verify HUD stability. Added duplicate-frame/freeze/eviction regression coverage and retained legacy frame lookup. Six focused framework checks and all 21 title checks pass on Windows and Linux, and both runtime builds link successfully. - -### 2026-10-21: Production indexed texture decode reuse - -Cache native 1555 indexed-page/CLUT decode results with raw-VRAM tile -generations and deferred-draw layer pinning. No filtering or guest behavior -changes. Hardware fixtures compare five hashes with cache off and forced -eviction, normal/HiW allocation, 1x/4x, render thread off/on; all pass. -R4 recorded-route production probe improves 53.371 to 57.472 guest Hz; -zero-gap acceptance remains open. Matched GPU completion probes also reject -a separate depth shader (no improvement); that experiment was removed. -See title analysis/render-60fps/SLOWDOWNS.md for receipts and limitations. - - -### 2026-10-21: Bounded asynchronous presentation pacing - -Publish drawable sizes from SDL3 pixel-size events instead of querying window -properties per frame. Batch SPSC queue consumption/accounting on separated -producer/consumer cache lines. For HiW hold output at guest rate, move deadline -waits to the existing presenter; its bounded queue paces the guest without a -second independent timer. Queued scheduling preserves early producer anchors. -Guest device timing and quality settings are unchanged. Hardware image/mask, -resize and scheduling controls pass. R4 Release default Ultra recorded drives -cover 60 seconds at 60.000 guest Hz, p99 emulation busy 52.3% idle / 56.4% with -23.61% total host CPU background load; zero presented gaps >25 ms, maxima -17.286/17.567 ms. Internal callback/completion gaps are separately reported and -are not claimed to pass that limit. See title SLOWDOWNS.md for attribution, -receipts, and clock definitions. No release or main-branch landing. diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 8979439c..230331ac 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -86f7329c2692e76133c40fa11cc8cfac214302d8238e05369dc8b263f4dc76fd +d887bd825dee12170e8bf22577543114787a3c7f5f2c9bf5e62b14c990561564 diff --git a/generated/OpenBIOS_dispatch.c b/generated/OpenBIOS_dispatch.c index 53da932f..67f5443a 100644 --- a/generated/OpenBIOS_dispatch.c +++ b/generated/OpenBIOS_dispatch.c @@ -36,6 +36,13 @@ extern int (*g_psx_bios_hle_hook)(CPUState* cpu, uint32_t phys); extern uint32_t g_psx_mod_guest_functions; extern int psx_mod_dispatch_guest_function(CPUState*, uint32_t); +/* Game function-override tier (runtime/include/func_override.h): + * hand-written C registered against a guest address. NULL (the + * default) = no overrides, dispatch identical to a build without + * the tier. Returns 1 when the override handled the call; the + * guest resumes at $ra exactly as if the original ran jr $ra. */ +extern int (*g_psx_func_override_hook)(CPUState* cpu, uint32_t phys); + #ifdef PSX_HAS_GAME_DISPATCH extern int psx_game_address_in_text(uint32_t addr); #endif @@ -9111,6 +9118,18 @@ static void psx_dispatch_impl(CPUState* cpu, uint32_t addr, uint32_t stop_addr) /* Byte-guarded A0/B0/C0 call-vector tail stubs. */ if (!found && psx_bios_try_native_call_stub(cpu, addr)) found = 1; + /* Function-override tier: AFTER the BIOS tiers (an override + * can never shadow a kernel service vector), BEFORE every + * game code backend, so one address-keyed hook covers the + * static EXE, runtime-loaded overlays and dirty RAM alike. + * Handled (rc 1) => the override completed against guest + * state; resume at $ra via the trampoline's normal + * return/tail contract. rc 0 => fall through untouched. */ + if (!found && g_psx_func_override_hook && + g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu)) { + cpu->pc = cpu->gpr[31]; + found = 1; + } #ifdef PSX_HAS_GAME_DISPATCH /* Game EXEs can overlap the BIOS shell copy window at * physical 0x30000-0x5AFFF. If the target belongs to the diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 41abcd0b..09cd598e 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -1477,6 +1477,11 @@ if(BUILD_TESTING AND Python3_Interpreter_FOUND AND CMAKE_C_COMPILER_ID STREQUAL COMMAND ${Python3_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_syscall_change_thread_self.py" --cc ${CMAKE_C_COMPILER}) + # CloseTh(current); ChangeTh(other) still saves the outgoing registers. + add_test(NAME syscall_closed_thread_context + COMMAND ${Python3_EXECUTABLE} + "${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_syscall_closed_thread_context.py" + --cc ${CMAKE_C_COMPILER}) endif() # Presentation capture ring: distinct-presentation dedupe and bounded slots. diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index 7a824f34..0896727a 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -177,6 +177,8 @@ struct CodeGenConfig { // Exact, full-word-guarded comparison sites whose result is forced only // while widescreen reveals extra world. 4:3 evaluates the original compare. std::vector ws_cull_keep_sites; + // [[widescreen.cull.sxy]] packed-SXY AND folds (left edge widened). + std::vector ws_cull_sxy_sites; std::vector ws_cull_masked_reject_sites; std::vector ws_cull_scale_sites; diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 19833abc..6fa5a161 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -1143,6 +1143,55 @@ std::string CodeGenerator::translate_instruction_core(uint32_t addr, uint32_t in site.y_reg, config_.ws_aspect_cone.y_reg)), comment); } + // [[widescreen.cull.sxy]]: one AND of a packed-SXY all-vertices reject + // fold. Emitted only when every AND word of the fold and the consumer + // branch word read back exactly from this image (in this instruction's + // segment); the AND keeps its vanilla value except that the X-sign bit + // also needs this vertex left of the revealed edge (identity at 4:3). + // An overlay variant with other words anywhere in the fold is untouched. + if (opcode == 0x00 && funct == 0x24 && !config_.ws_cull_sxy_sites.empty()) { + for (const auto& site : config_.ws_cull_sxy_sites) { + size_t step = site.vertices; + for (size_t i = 0; i < site.vertices; i++) + if ((site.fold_addresses[i] & 0x1FFFFFFFu) == (addr & 0x1FFFFFFFu)) + step = i; + if (step == site.vertices) continue; + const uint32_t segment = addr & ~0x1FFFFFFFu; + uint32_t bad_addr = 0, bad_want = 0, bad_got = 0; + bool ok = true; + auto verify = [&](uint32_t site_addr, uint32_t want) { + if (!ok) return; + const uint32_t a = segment | (site_addr & 0x1FFFFFFFu); + uint32_t got = ~want; + if (a == addr) got = instr; + else if (const auto w = exe_.read_word(a)) got = *w; + if (got != want) { + ok = false; + bad_addr = a; bad_want = want; bad_got = got; + } + }; + for (size_t i = 0; i < site.vertices; i++) + verify(site.fold_addresses[i], site.fold_expected[i]); + verify(site.consumer_address, site.consumer_expected); + if (!ok) { + if (config_.overlay_mode) continue; + fmt::print(stderr, + "ERROR: [[widescreen.cull.sxy]] expected 0x{:08X} at " + "0x{:08X}, found 0x{:08X}\n", + bad_want, bad_addr, bad_got); + std::exit(1); + } + const uint32_t vertex = site.vertex_regs[step]; + const uint32_t other = + get_rs(instr) == vertex ? get_rt(instr) : get_rs(instr); + return fmt::format( + "{} = psx_ws_cull_sxy_and({}, {});" + " /* ws SXY fold (left edge widened) */{}", + reg_name(get_rd(instr)), reg_name(other), reg_name(vertex), + comment); + } + } + // Full-word-guarded object/model participation compares. At 4:3 the // helper returns the comparison's vanilla value; when widescreen reveals // extra world it returns the configured keep verdict. Overlay variants @@ -1507,9 +1556,17 @@ std::string CodeGenerator::translate_instruction_core(uint32_t addr, uint32_t in return fmt::format("{} = psx_ws_cull_lui_hi(0x{:04X}u);" " /* ws explicit screen-x edge (high half) */{}", reg_name(rt), imm, comment); + } else if (opcode == 0x0A && get_imm16_u(instr) == 0 && get_rt(instr) != 0) { + // slti rt,x,0 on `SX << 16`: the bltz sign test as a flag. Set + // only while SX < -margin, exactly like the bltz form. + return fmt::format("{} = (uint32_t)psx_ws_cull_bltz_hi({});" + " /* ws explicit screen-x left edge (high half) */{}", + reg_name(get_rt(instr)), reg_name(get_rs(instr)), + comment); } else if (!config_.overlay_mode) { fmt::print(stderr, "ERROR: [widescreen.cull] screen_x site 0x{:08X} " - "is not sltiu, sltu, lui or bltz (0x{:08X})\n", addr, instr); + "is not sltiu, sltu, lui, slti rt,rs,0 or bltz (0x{:08X})\n", + addr, instr); std::exit(1); } // Overlay variant: the configured main-EXE PC holds different code. @@ -3702,6 +3759,9 @@ void CodeGenerator::emit_runtime_externs(std::ostream& ss) const { ss << "extern int psx_ws_cull_bltz_hi(uint32_t v); /* ws cull left edge, X in the high half (gpu.c) */\n"; ss << "extern int psx_ws_cull_sltu_band(uint32_t sx, uint32_t bound); /* ws screen-x band, W in a register (gpu.c) */\n"; } + // Only for a config that lists [[widescreen.cull.sxy]] sites. + if (!config_.ws_cull_sxy_sites.empty()) + ss << "extern uint32_t psx_ws_cull_sxy_and(uint32_t acc, uint32_t sxy); /* ws SXY reject fold, left edge (gpu.c) */\n"; ss << "extern int psx_ws_cull_vxrange(uint32_t x, uint32_t imm); /* ws masked-u16 X window */\n"; ss << "extern int32_t psx_ws_depth_bound(int32_t imm); /* ws aspect-scaled far bound */\n"; ss << "extern int32_t psx_ws_plane_nx(int32_t nx); /* ws side-plane normal-X scale (gpu.c) */\n"; diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 3524e13a..dd69e470 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -166,6 +166,27 @@ uint32_t overlay_codegen_config_hash(const GameConfig& c) { h.u32(site.result); } + // Appended only when used, so every other title's hash is unchanged. + if (!c.ws_cull_sxy_sites.empty()) { + std::vector sxy_sites = c.ws_cull_sxy_sites; + std::sort(sxy_sites.begin(), sxy_sites.end(), + [](const auto& a, const auto& b) { + return a.fold_addresses.front() < b.fold_addresses.front(); + }); + h.tag("cull_sxy"); + h.u32((uint32_t)sxy_sites.size()); + for (const auto& site : sxy_sites) { + h.u32(site.vertices); + for (size_t i = 0; i < site.vertices; i++) { + h.u32(site.fold_addresses[i]); + h.u32(site.fold_expected[i]); + h.u32(site.vertex_regs[i]); + } + h.u32(site.consumer_address); + h.u32(site.consumer_expected); + } + } + std::vector scale_sites = c.ws_cull_scale_sites; std::sort(scale_sites.begin(), scale_sites.end(), [](const auto& a, const auto& b) { return a.address < b.address; }); @@ -313,6 +334,110 @@ static uint32_t parse_hex(const std::string& s, const std::string& field) { } } +// One [[widescreen.cull.sxy]] entry. The fold and its consumer are identified +// by their exact instruction words; everything the runtime relies on is +// derived from those words and must agree with the declared registers: +// fold[0] = and r, v0, mask (operands in either order) +// fold[k] = and r, vk, r (k >= 1, operands in either order) +// consumer = beq/bne r, zero (within 4 instructions after the last AND) +// `seen` holds the physical addresses claimed by earlier entries. +static WidescreenSxyCullSite parse_sxy_cull_site(const toml::value& item, + const std::string& path, + std::set& seen) { + auto fail = [&](const std::string& why) { + return std::runtime_error(fmt::format( + "{}: [[widescreen.cull.sxy]] {}", path, why)); + }; + WidescreenSxyCullSite site; + const std::string kind = toml::find(item, "kind"); + if (kind == "tri") site.vertices = 3; + else if (kind == "quad") site.vertices = 4; + else throw fail("kind must be \"tri\" or \"quad\""); + + for (int reg : toml::find>(item, "vertex_regs")) { + if (reg < 1 || reg > 31) + throw fail(fmt::format("vertex_regs: {} is not a GPR 1..31", reg)); + site.vertex_regs.push_back((uint32_t)reg); + } + for (const auto& a : toml::find>(item, "fold_addresses")) + site.fold_addresses.push_back(parse_hex(a, "widescreen.cull.sxy.fold_addresses")); + for (const auto& e : toml::find>(item, "fold_expected")) + site.fold_expected.push_back(parse_hex(e, "widescreen.cull.sxy.fold_expected")); + site.consumer_address = parse_hex(toml::find(item, "consumer_address"), + "widescreen.cull.sxy.consumer_address"); + site.consumer_expected = parse_hex(toml::find(item, "consumer_expected"), + "widescreen.cull.sxy.consumer_expected"); + + const size_t n = site.vertices; + if (site.vertex_regs.size() != n || site.fold_addresses.size() != n || + site.fold_expected.size() != n) + throw fail(fmt::format("{} needs exactly {} vertex_regs, fold_addresses " + "and fold_expected", kind, n)); + for (size_t i = 0; i < n; i++) + for (size_t j = i + 1; j < n; j++) + if (site.vertex_regs[i] == site.vertex_regs[j]) + throw fail("vertex_regs must be distinct"); + + // Addresses: aligned, one segment, strictly increasing, and short. + const uint32_t segment = site.fold_addresses[0] & 0xE0000000u; + uint32_t prev = 0; + for (size_t i = 0; i <= n; i++) { + const uint32_t a = i < n ? site.fold_addresses[i] : site.consumer_address; + if ((a & 3u) != 0 || (a & 0xE0000000u) != segment) + throw fail(fmt::format("0x{:08X} must be word-aligned and in the " + "segment of the first AND", a)); + if (i > 0 && a <= prev) + throw fail(fmt::format("0x{:08X} must follow 0x{:08X}", a, prev)); + if (!seen.insert(a & 0x1FFFFFFFu).second) + throw fail(fmt::format("address 0x{:08X} is already used by " + "another sxy site", a)); + prev = a; + } + if (site.fold_addresses[n - 1] - site.fold_addresses[0] > 4u * 15u) + throw fail("the AND fold spans more than 16 instructions"); + if (site.consumer_address - site.fold_addresses[n - 1] > 16u) + throw fail("consumer_address must be within 4 instructions of the last AND"); + + // The words: one AND chain into a single register r. + auto rs_of = [](uint32_t w) { return (w >> 21) & 31u; }; + auto rt_of = [](uint32_t w) { return (w >> 16) & 31u; }; + const uint32_t r = (site.fold_expected[0] >> 11) & 31u; + if (r == 0) throw fail("the AND fold must not write $zero"); + for (size_t i = 0; i < n; i++) { + const uint32_t w = site.fold_expected[i]; + const uint32_t v = site.vertex_regs[i]; + if ((w >> 26) != 0u || (w & 0x7FFu) != 0x24u || ((w >> 11) & 31u) != r) + throw fail(fmt::format("fold_expected 0x{:08X} must be `and r, ...` " + "with the same destination as the first AND", w)); + if (v == r) + throw fail(fmt::format("vertex register {} is the fold register", v)); + const uint32_t other = rs_of(w) == v ? rt_of(w) + : rt_of(w) == v ? rs_of(w) : 32u; + if (other == 32u) + throw fail(fmt::format("fold_expected 0x{:08X} does not read " + "vertex register {}", w, v)); + if (i == 0) { + // The seed operand is the sign mask: a register of its own. + if (other == 0 || other == r || + std::find(site.vertex_regs.begin(), site.vertex_regs.end(), + other) != site.vertex_regs.end()) + throw fail(fmt::format("first AND 0x{:08X} must combine vertex " + "register {} with a separate mask " + "register", w, v)); + } else if (other != r) { + throw fail(fmt::format("fold_expected 0x{:08X} must combine vertex " + "register {} with the fold register {}", w, v, r)); + } + } + const uint32_t c = site.consumer_expected; + const uint32_t c_op = c >> 26; + if ((c_op != 0x04u && c_op != 0x05u) || + !((rs_of(c) == r && rt_of(c) == 0) || (rs_of(c) == 0 && rt_of(c) == r))) + throw fail(fmt::format("consumer_expected 0x{:08X} must be beq/bne of the " + "fold register {} against $zero", c, r)); + return site; +} + // Parse a display aspect "W:H" string (e.g. "4:3", "16:9", "21:9"). Accepts // only aspects between native 4:3 and 32:9 — narrower than 4:3 has no // widescreen meaning and ultra-extreme values would squash the GTE X axis @@ -2362,6 +2487,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { std::vector ws_cull_clip_edge_x_load_sites; uint32_t ws_cull_clip_edge_width = 0; std::vector ws_cull_keep_sites; + std::vector ws_cull_sxy_sites; std::vector ws_cull_masked_reject_sites; std::vector ws_cull_scale_sites; std::vector ws_cull_angle_sites; @@ -2462,6 +2588,17 @@ GameConfig load_game_config(const fs::path& config_path_in) { ws_cull_keep_sites.push_back(site); } } + if (cull.contains("sxy")) { + std::set seen; + for (const auto& item : toml::find(cull, "sxy")) { + ws_cull_sxy_sites.push_back(parse_sxy_cull_site( + item, config_path.string(), seen)); + if (ws_cull_sxy_sites.size() > 64) + throw std::runtime_error(fmt::format( + "{}: [[widescreen.cull.sxy]] supports at most 64 sites", + config_path.string())); + } + } if (cull.contains("scale")) { std::set seen; for (const auto& item : toml::find(cull, "scale")) { @@ -2904,6 +3041,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*ws_cull_nclip_exact_sites*/ ws_cull_nclip_exact_sites, /*ws_cull_branch_keep_sites*/ ws_cull_branch_keep_sites, /*ws_cull_keep_sites*/ ws_cull_keep_sites, + /*ws_cull_sxy_sites*/ ws_cull_sxy_sites, /*ws_cull_scale_sites*/ ws_cull_scale_sites, /*ws_cull_angle_sites*/ ws_cull_angle_sites, /*ws_aspect_cone*/ ws_aspect_cone, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 626ceb65..584a08a4 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -99,6 +99,21 @@ struct WidescreenCullKeepSite { uint32_t result = 0; // forced comparison result (0 or 1) }; +// One packed-SXY all-vertices reject ([[widescreen.cull.sxy]]): an AND fold +// over the projected SXY of every vertex of a triangle or quad, seeded with a +// sign mask and consumed by `beq/bne r, zero`. Each AND is widened only in +// its X-sign bit (ws_cull_edge.h psx_ws_sxy_and_value); the guest's own +// branch still decides. Every word of the fold and of the consumer is part of +// the identity: an overlay variant whose words differ anywhere is untouched. +struct WidescreenSxyCullSite { + uint32_t vertices = 0; // 3 (kind = "tri") or 4 ("quad") + std::vector fold_addresses; // one AND per vertex, chain order + std::vector fold_expected; // the exact AND words + std::vector vertex_regs; // the SXY operand of each AND + uint32_t consumer_address = 0; // beq/bne r, zero after the fold + uint32_t consumer_expected = 0; +}; + // Aspect-scaled 12-bit angular half-extent. These sites load a positive angle // constant with `addi[u] rt,zero,imm`; the runtime widens tan(angle) by the // live horizontal reveal factor. Full-word guards prevent overlay-address @@ -1276,6 +1291,8 @@ struct GameConfig { // where maximal overdraw is preferable to range guessing. Each entry is // guarded by the complete MIPS word; 4:3 executes the vanilla comparison. std::vector ws_cull_keep_sites; + // Packed-SXY all-vertices rejects widened only on the left edge. + std::vector ws_cull_sxy_sites; // Register-register frustum-edge compares whose bound operand scales // with the live reveal ([[widescreen.cull.scale]]). std::vector ws_cull_scale_sites; diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index 515ded2d..b51d524b 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -1839,6 +1839,12 @@ void FullFunctionEmitter::emit_dispatch( out += "extern int (*g_psx_bios_hle_hook)(CPUState* cpu, uint32_t phys);\n\n"; out += "extern uint32_t g_psx_mod_guest_functions;\n"; out += "extern int psx_mod_dispatch_guest_function(CPUState*, uint32_t);\n\n"; + out += "/* Game function-override tier (runtime/include/func_override.h):\n"; + out += " * hand-written C registered against a guest address. NULL (the\n"; + out += " * default) = no overrides, dispatch identical to a build without\n"; + out += " * the tier. Returns 1 when the override handled the call; the\n"; + out += " * guest resumes at $ra exactly as if the original ran jr $ra. */\n"; + out += "extern int (*g_psx_func_override_hook)(CPUState* cpu, uint32_t phys);\n\n"; out += "#ifdef PSX_HAS_GAME_DISPATCH\n"; out += "extern int psx_game_address_in_text(uint32_t addr);\n"; out += "#endif\n\n"; @@ -2285,6 +2291,18 @@ void FullFunctionEmitter::emit_dispatch( out += " /* Byte-guarded A0/B0/C0 call-vector tail stubs. */\n"; out += " if (!found && psx_bios_try_native_call_stub(cpu, addr))\n"; out += " found = 1;\n"; + out += " /* Function-override tier: AFTER the BIOS tiers (an override\n"; + out += " * can never shadow a kernel service vector), BEFORE every\n"; + out += " * game code backend, so one address-keyed hook covers the\n"; + out += " * static EXE, runtime-loaded overlays and dirty RAM alike.\n"; + out += " * Handled (rc 1) => the override completed against guest\n"; + out += " * state; resume at $ra via the trampoline's normal\n"; + out += " * return/tail contract. rc 0 => fall through untouched. */\n"; + out += " if (!found && g_psx_func_override_hook &&\n"; + out += " g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu)) {\n"; + out += " cpu->pc = cpu->gpr[31];\n"; + out += " found = 1;\n"; + out += " }\n"; out += "#ifdef PSX_HAS_GAME_DISPATCH\n"; out += " /* Game EXEs can overlap the BIOS shell copy window at\n"; out += " * physical 0x30000-0x5AFFF. If the target belongs to the\n"; diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index f7b87e7c..e5a4d36c 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -245,6 +245,7 @@ static int psxrecomp_game_main(int argc, char** argv) { std::set ws_cull_clip_edge_x_load; // [widescreen.cull] clip_edge_x_load_sites uint32_t ws_cull_clip_edge_width = 0; // [widescreen.cull] clip_edge_width std::vector ws_cull_keep; + std::vector ws_cull_sxy; std::vector ws_cull_scale; std::vector ws_cull_angle; std::vector draw_distance_clamps; // [[draw_distance.clamp]] @@ -324,6 +325,7 @@ static int psxrecomp_game_main(int argc, char** argv) { if (!cfg.ws_cull_clip_edge_x_load_sites.empty()) ws_cull_clip_edge_width = PSXRecompV4::ws_cull_clip_edge_width(cfg); ws_cull_keep = cfg.ws_cull_keep_sites; + ws_cull_sxy = cfg.ws_cull_sxy_sites; ws_cull_masked_reject = cfg.ws_cull_masked_reject_sites; ws_cull_scale = cfg.ws_cull_scale_sites; ws_cull_angle = cfg.ws_cull_angle_sites; @@ -442,6 +444,7 @@ static int psxrecomp_game_main(int argc, char** argv) { if (!wscfg.ws_cull_clip_edge_x_load_sites.empty() && !ws_cull_clip_edge_width) ws_cull_clip_edge_width = PSXRecompV4::ws_cull_clip_edge_width(wscfg); if (ws_cull_keep.empty()) ws_cull_keep = wscfg.ws_cull_keep_sites; + if (ws_cull_sxy.empty()) ws_cull_sxy = wscfg.ws_cull_sxy_sites; if (ws_cull_masked_reject.empty()) ws_cull_masked_reject = wscfg.ws_cull_masked_reject_sites; if (ws_cull_scale.empty()) ws_cull_scale = wscfg.ws_cull_scale_sites; if (ws_cull_angle.empty()) ws_cull_angle = wscfg.ws_cull_angle_sites; @@ -672,6 +675,30 @@ static int psxrecomp_game_main(int argc, char** argv) { check("widescreen.bg2d.bufbase_site", ws_bg2d_bufbase_site); check("widescreen.bg2d.cap_site", ws_bg2d_cap_site); check("widescreen.bg2d.init_func", ws_bg2d_init_func); + // [[widescreen.cull.sxy]] sites are matched physically, and every + // word of each fold and its consumer is part of the identity: check + // them all against the image up front, so a stale address fails the + // build even when no discovered function reaches the fold. + for (const auto& site : ws_cull_sxy) { + for (size_t i = 0; i <= site.vertices; i++) { + const bool consumer = i == site.vertices; + const uint32_t phys = (consumer ? site.consumer_address + : site.fold_addresses[i]) & + PSXRecomp::kPhysMask; + const uint32_t want = consumer ? site.consumer_expected + : site.fold_expected[i]; + const uint32_t a = exe->link_segment() | phys; + if (!exe->contains_phys(a)) continue; + const auto got = exe->read_word(a); + if (!got || *got != want) { + fmt::print(stderr, + "ERROR: [[widescreen.cull.sxy]] expected 0x{:08X} at " + "0x{:08X}, found 0x{:08X}\n", + want, a, got ? *got : 0u); + return 1; + } + } + } if (!foreign.empty()) { fmt::print(stderr, "ERROR: {} config code site(s) name this image's bytes in another " @@ -1550,6 +1577,7 @@ static int psxrecomp_game_main(int argc, char** argv) { else if (!ws_cull_w_imms.empty()) codegen_config.ws_cull_clip_edge_width = ws_cull_w_imms.front(); codegen_config.ws_cull_keep_sites = ws_cull_keep; + codegen_config.ws_cull_sxy_sites = ws_cull_sxy; codegen_config.ws_cull_scale_sites = ws_cull_scale; codegen_config.ws_cull_angle_sites = ws_cull_angle; codegen_config.draw_distance_clamp_sites = draw_distance_clamps; diff --git a/recompiler/src/segment_variants.cpp b/recompiler/src/segment_variants.cpp index 39a7ed0c..f7b9158e 100644 --- a/recompiler/src/segment_variants.cpp +++ b/recompiler/src/segment_variants.cpp @@ -190,8 +190,8 @@ CodeGenConfig move_exact_config_sites(const CodeGenConfig& cfg, At at) { v.ws_bg2d_bufbase_site = at(v.ws_bg2d_bufbase_site); v.ws_bg2d_cap_site = at(v.ws_bg2d_cap_site); // Physically matched, kept as written: ws_signed_x_bound_sites, - // ws_cull_keep_sites, ws_cull_angle_sites, ws_aspect_cone.sites, - // draw_distance_clamp_sites. + // ws_cull_keep_sites, ws_cull_sxy_sites, ws_cull_angle_sites, + // ws_aspect_cone.sites, draw_distance_clamp_sites. return v; } diff --git a/recompiler/tests/ws_cull_edge_codegen_test.cpp b/recompiler/tests/ws_cull_edge_codegen_test.cpp index c0655eec..a3e6c937 100644 --- a/recompiler/tests/ws_cull_edge_codegen_test.cpp +++ b/recompiler/tests/ws_cull_edge_codegen_test.cpp @@ -415,6 +415,278 @@ reject_mask = "0xFFFF0000" "overlay with another instruction stays vanilla"); } +// ---- [[widescreen.cull.sxy]] ---------------------------------------------- +// The Wild 9 (SLUS-00425) shapes, relocated to kBase. Tri: the fold has GTE +// reads and stores between its ANDs and one instruction before the consumer. +// +00 and at,t0,s1 +04 mfc2 t3,$14 +08 and at,s4,at +0C sw t0,8(a0) +// +10 and at,t3,at +14 sub t0,s0,t0 +18 bne at,zero,+1 ; nop ; jr ra ; nop +// Quad: four contiguous ANDs, then the consumer. +const std::vector kTriWords = { + 0x01110824u, 0x480B7000u, 0x02810824u, 0xAC880008u, 0x01610824u, + 0x02084022u, 0x14200001u, 0x00000000u, 0x03E00008u, 0x00000000u}; +const std::vector kQuadWords = { + 0x01110824u, 0x02810824u, 0x01610824u, 0x01210824u, 0x14200001u, + 0x00000000u, 0x03E00008u, 0x00000000u}; + +PSXRecomp::GeneratedFunction generate_words(const std::vector& words, + const PSXRecomp::CodeGenConfig& config) { + PSXRecomp::PS1Executable exe{}; + exe.header.load_address = kBase; + exe.header.initial_pc = kBase; + exe.header.file_size = (uint32_t)(4 * words.size()); + for (uint32_t w : words) append_word(exe.code_data, w); + PSXRecomp::Function function{}; + function.start_addr = kBase; + function.end_addr = kBase + 4u * (uint32_t)words.size(); + function.size = 4u * (uint32_t)words.size(); + function.name = "ws_cull_sxy_test"; + PSXRecomp::ControlFlowAnalyzer analyzer(exe); + const auto cfg = analyzer.analyze_function(function); + PSXRecomp::CodeGenerator generator(exe, config); + return generator.generate_function(function, cfg); +} + +struct SxyToml { + std::string kind = "tri"; + std::string vertex_regs = "[8, 20, 11]"; + std::string fold_addresses = R"(["0x80010000", "0x80010008", "0x80010010"])"; + std::string fold_expected = R"(["0x01110824", "0x02810824", "0x01610824"])"; + std::string consumer_address = "0x80010018"; + std::string consumer_expected = "0x14200001"; + std::string str() const { + return "\n[[widescreen.cull.sxy]]\nkind = \"" + kind + "\"\n" + + "vertex_regs = " + vertex_regs + "\n" + + "fold_addresses = " + fold_addresses + "\n" + + "fold_expected = " + fold_expected + "\n" + + "consumer_address = \"" + consumer_address + "\"\n" + + "consumer_expected = \"" + consumer_expected + "\"\n"; + } +}; + +SxyToml quad_toml() { + SxyToml q; + q.kind = "quad"; + q.vertex_regs = "[8, 20, 11, 9]"; + q.fold_addresses = R"(["0x80010000", "0x80010004", "0x80010008", "0x8001000C"])"; + q.fold_expected = R"(["0x01110824", "0x02810824", "0x01610824", "0x01210824"])"; + q.consumer_address = "0x80010010"; + return q; +} + +void sxy_loader() { + const auto tri = load(base_config() + SxyToml{}.str()); + check(tri.ws_cull_sxy_sites.size() == 1, "loader reads a tri sxy site"); + const auto& t = tri.ws_cull_sxy_sites.at(0); + check(t.vertices == 3 && t.vertex_regs == std::vector({8, 20, 11}) && + t.fold_addresses == std::vector({0x80010000u, 0x80010008u, 0x80010010u}) && + t.fold_expected == std::vector({0x01110824u, 0x02810824u, 0x01610824u}) && + t.consumer_address == 0x80010018u && t.consumer_expected == 0x14200001u, + "tri sxy site fields"); + const auto quad = load(base_config() + quad_toml().str()); + check(quad.ws_cull_sxy_sites.size() == 1 && quad.ws_cull_sxy_sites[0].vertices == 4, + "loader reads a quad sxy site"); + check(load(base_config()).ws_cull_sxy_sites.empty(), "sxy defaults to empty"); + + // Operands may be written in either order; beq is a consumer as well. + SxyToml swapped; + swapped.fold_expected = R"(["0x02280824", "0x00340824", "0x002B0824"])"; + swapped.consumer_expected = "0x10010001"; + check(load(base_config() + swapped.str()).ws_cull_sxy_sites.size() == 1, + "swapped AND operands and a beq zero,at consumer are accepted"); + + auto bad = [&](SxyToml site, const char* needle, const char* what) { + check(load_throws_with(base_config() + site.str(), needle), what); + }; + SxyToml s; + s = {}; s.kind = "pent"; + bad(s, "kind must be", "unknown kind"); + s = {}; s.vertex_regs = "[8, 20]"; + bad(s, "needs exactly 3", "tri with two vertex_regs"); + s = quad_toml(); s.fold_expected = R"(["0x01110824", "0x02810824", "0x01610824"])"; + bad(s, "needs exactly 4", "quad with three AND words"); + s = {}; s.vertex_regs = "[8, 8, 11]"; + bad(s, "must be distinct", "repeated vertex register"); + s = {}; s.vertex_regs = "[8, 20, 0]"; + bad(s, "not a GPR", "$zero vertex register"); + s = {}; s.vertex_regs = "[8, 20, 1]"; + bad(s, "is the fold register", "vertex register equal to the fold register"); + s = {}; s.vertex_regs = "[8, 20, 12]"; + bad(s, "does not read vertex register 12", "AND does not read its vertex"); + s = {}; s.vertex_regs = "[8, 20, 17]"; + s.fold_expected = R"(["0x01140824", "0x02810824", "0x02210824"])"; + bad(s, "separate mask register", "first AND combines two vertices"); + s = {}; s.fold_expected = R"(["0x01110824", "0x02811024", "0x01610824"])"; + bad(s, "same destination", "AND into another register"); + s = {}; s.fold_expected = R"(["0x01110825", "0x02810824", "0x01610824"])"; + bad(s, "must be `and r", "OR in the fold"); + s = {}; s.fold_expected = R"(["0x01110824", "0x02910824", "0x01610824"])"; + bad(s, "with the fold register", "later AND not chained through the fold register"); + s = {}; s.fold_expected = R"(["0x01100024", "0x02800024", "0x01600024"])"; + bad(s, "must not write $zero", "fold into $zero"); + s = {}; s.consumer_expected = "0x14220001"; + bad(s, "beq/bne of the fold register", "consumer compares with another register"); + s = {}; s.consumer_expected = "0x04200001"; + bad(s, "beq/bne of the fold register", "consumer is bltz"); + s = {}; s.consumer_address = "0x8001002C"; + bad(s, "within 4 instructions", "consumer too far from the fold"); + s = {}; s.fold_addresses = R"(["0x80010008", "0x80010000", "0x80010010"])"; + bad(s, "must follow", "fold addresses out of order"); + s = {}; s.consumer_address = "0x80010010"; + bad(s, "must follow", "consumer on the last AND"); + s = {}; s.fold_addresses = R"(["0x80010000", "0xA0010008", "0x80010010"])"; + bad(s, "segment of the first AND", "fold crossing segments"); + s = {}; s.fold_addresses = R"(["0x80010000", "0x8001000A", "0x80010010"])"; + bad(s, "word-aligned", "misaligned AND"); + check(load_throws_with(base_config() + SxyToml{}.str() + quad_toml().str(), + "already used by another sxy site"), + "two sites sharing a physical address"); +} + +void sxy_hash_identity() { + const auto plain = load(base_config()); + const auto with = load(base_config() + SxyToml{}.str()); + check(PSXRecompV4::overlay_codegen_config_hash(plain) != + PSXRecompV4::overlay_codegen_config_hash(with), + "an sxy site changes the overlay-cache identity"); + auto changed = with; + changed.ws_cull_sxy_sites[0].consumer_expected = 0x10200001u; + check(PSXRecompV4::overlay_codegen_config_hash(changed) != + PSXRecompV4::overlay_codegen_config_hash(with), + "the consumer word is part of the overlay-cache identity"); + changed = with; + changed.ws_cull_sxy_sites[0].vertex_regs[2] = 12u; + check(PSXRecompV4::overlay_codegen_config_hash(changed) != + PSXRecompV4::overlay_codegen_config_hash(with), + "the vertex registers are part of the overlay-cache identity"); +} + +bool has_text(const std::string& code, const char* needle) { + return code.find(needle) != std::string::npos; +} + +void sxy_codegen() { + PSXRecomp::CodeGenConfig config{}; + const auto vanilla_tri = generate_words(kTriWords, config).full_code; + check(!has_text(vanilla_tri, "psx_ws_cull_sxy_and"), "no sxy site: vanilla ANDs"); + + config.ws_cull_sxy_sites = load(base_config() + SxyToml{}.str()).ws_cull_sxy_sites; + const auto tri = generate_words(kTriWords, config).full_code; + check(has_text(tri, "cpu->gpr[1] = psx_ws_cull_sxy_and(cpu->gpr[17], cpu->gpr[8]);"), + "tri AND 0 seeds from the mask with vertex t0"); + check(has_text(tri, "cpu->gpr[1] = psx_ws_cull_sxy_and(cpu->gpr[1], cpu->gpr[20]);"), + "tri AND 1 folds vertex s4"); + check(has_text(tri, "cpu->gpr[1] = psx_ws_cull_sxy_and(cpu->gpr[1], cpu->gpr[11]);"), + "tri AND 2 folds vertex t3"); + check(!has_text(tri, "cpu->gpr[1] = cpu->gpr[8] & cpu->gpr[17];"), + "tri vanilla AND is replaced"); + check(!exits_with_failure([&] { generate_words(kTriWords, config); }), + "a matching tri fold generates"); + + PSXRecomp::CodeGenConfig qconfig{}; + qconfig.ws_cull_sxy_sites = load(base_config() + quad_toml().str()).ws_cull_sxy_sites; + const auto quad = generate_words(kQuadWords, qconfig).full_code; + check(has_text(quad, "cpu->gpr[1] = psx_ws_cull_sxy_and(cpu->gpr[17], cpu->gpr[8]);") && + has_text(quad, "cpu->gpr[1] = psx_ws_cull_sxy_and(cpu->gpr[1], cpu->gpr[20]);") && + has_text(quad, "cpu->gpr[1] = psx_ws_cull_sxy_and(cpu->gpr[1], cpu->gpr[11]);") && + has_text(quad, "cpu->gpr[1] = psx_ws_cull_sxy_and(cpu->gpr[1], cpu->gpr[9]);"), + "quad folds all four vertices"); + + // The guest's own consumer branch is untouched. + check(has_text(quad, "cpu->gpr[1] != cpu->gpr[0]") || has_text(quad, "cpu->gpr[1] != 0"), + "the consumer bne is the vanilla branch"); + + // Any word of the fold or the consumer that differs: main EXE fails, + // an overlay variant keeps every AND vanilla. + for (size_t i : {size_t(1), size_t(2), size_t(6)}) { + auto words = kTriWords; + if (i == 1) words[2] = 0x02810825u; // the second AND is an OR + if (i == 2) words[4] = 0x01710824u; // the last AND reads s1 + if (i == 6) words[6] = 0x10200001u; // beq instead of bne + check(exits_with_failure([&] { generate_words(words, config); }), + "a mismatching tri fold fails main-EXE generation"); + auto overlay = config; + overlay.overlay_mode = true; + const auto code = generate_words(words, overlay).full_code; + check(!has_text(code, "psx_ws_cull_sxy_and"), + "an overlay variant with any mismatching word stays vanilla"); + } + auto overlay = config; + overlay.overlay_mode = true; + check(has_text(generate_words(kTriWords, overlay).full_code, + "psx_ws_cull_sxy_and(cpu->gpr[17], cpu->gpr[8])"), + "an overlay with the exact fold emits the helper"); + + // Declared only for a config that lists sxy sites. + PSXRecomp::PS1Executable exe{}; + exe.header.load_address = kBase; + std::vector functions; + check(!has_text(PSXRecomp::CodeGenerator(exe).build_shared_decls_header(functions), + "psx_ws_cull_sxy_and"), + "a config without sxy sites does not declare the helper"); + check(has_text(PSXRecomp::CodeGenerator(exe, config).build_shared_decls_header(functions), + "extern uint32_t psx_ws_cull_sxy_and(uint32_t acc, uint32_t sxy);"), + "a config with sxy sites declares the helper"); +} + +uint32_t pack_sxy(int x, int y) { + return ((uint32_t)(uint16_t)(int16_t)y << 16) | (uint16_t)(int16_t)x; +} + +uint32_t sxy_fold(const uint32_t* v, int n, uint32_t mask, int32_t m) { + uint32_t r = psx_ws_sxy_and_value(mask, v[0], m); + for (int k = 1; k < n; k++) r = psx_ws_sxy_and_value(r, v[k], m); + return r; +} + +// Every tri (and a quad sweep) over the edge values: at margin 0 the fold is +// the vanilla AND word for any mask; widened, it differs from vanilla only in +// bit 15, which then means "every X < -m". Bit 31 (all Y above the screen) +// and the right/bottom side (a separate chain in the guest) are unchanged. +void sxy_runtime_math() { + const int xs[] = {-1024, -200, -54, -53, -52, -1, 0, 1, 160, 319, 320, 1023}; + const int ys[] = {-1024, -1, 0, 1, 239, 240}; + std::vector pts; + for (int x : xs) for (int y : ys) pts.push_back(pack_sxy(x, y)); + const uint32_t masks[] = {0x80008000u, 0xFFFFFFFFu, 0x00008000u, 0x80000000u}; + int bad0 = 0, badm = 0; + for (uint32_t mask : masks) + for (uint32_t a : pts) for (uint32_t b : pts) for (uint32_t c : pts) { + const uint32_t v[3] = {a, b, c}; + const uint32_t vanilla = mask & a & b & c; + if (sxy_fold(v, 3, mask, 0) != vanilla) bad0++; + for (int32_t m : {53, 192}) { + bool all_left = true; + for (uint32_t p : v) all_left &= (int16_t)(p & 0xFFFFu) < -m; + const uint32_t want = (vanilla & ~0x8000u) | + (all_left ? (vanilla & 0x8000u) : 0u); + if (sxy_fold(v, 3, mask, m) != want) badm++; + } + } + for (uint32_t a : pts) for (uint32_t d : pts) { + const uint32_t v[4] = {a, pack_sxy(-300, 10), pack_sxy(-60, -5), d}; + const uint32_t vanilla = 0x80008000u & v[0] & v[1] & v[2] & v[3]; + if (sxy_fold(v, 4, 0x80008000u, 0) != vanilla) bad0++; + bool all_left = true; + for (uint32_t p : v) all_left &= (int16_t)(p & 0xFFFFu) < -53; + const uint32_t want = (vanilla & ~0x8000u) | (all_left ? (vanilla & 0x8000u) : 0u); + if (sxy_fold(v, 4, 0x80008000u, 53) != want) badm++; + } + check(bad0 == 0, "the sxy fold is the vanilla AND at margin 0"); + check(badm == 0, "widened, the sxy fold differs only in bit 15 = every X < -m"); + + // Spot checks at 16:9 on 320 wide (margin 53), mask 0x80008000. + const uint32_t mask = 0x80008000u; + uint32_t v[3] = {pack_sxy(-60, 10), pack_sxy(-54, 20), pack_sxy(-100, 30)}; + check(sxy_fold(v, 3, mask, 53) == 0x8000u, "all vertices left of -m: rejected"); + v[1] = pack_sxy(-53, 20); + check(sxy_fold(v, 3, mask, 53) == 0u, "one vertex in the revealed band: kept"); + check(sxy_fold(v, 3, mask, 0) == 0x8000u, "the same triangle is rejected at 4:3"); + uint32_t up[3] = {pack_sxy(-10, -1), pack_sxy(100, -5), pack_sxy(5, -9)}; + check(sxy_fold(up, 3, mask, 53) == 0x80000000u, "all above the screen: still rejected"); + check(psx_ws_sxy_and_value(0x8000u, pack_sxy(-5, 0), -7) == 0x8000u, + "a negative margin is treated as 0"); +} + void runtime_math() { // Identity at 4:3 (margin 0) for every input. for (int32_t v : {-1000, -54, -53, -1, 0, 1, 98, 222, 319, 320, 321, 1000}) { @@ -460,6 +732,10 @@ int main(int argc, char** argv) { shared_decls_include_new_helpers(); runtime_math(); masked_reject_sites(); + sxy_loader(); + sxy_hash_identity(); + sxy_codegen(); + sxy_runtime_math(); if (g_exit_probe >= 0) return 3; // no call had that probe index if (failures != 0) { diff --git a/recompiler/tests/ws_cull_screen_x_hi_codegen_test.cpp b/recompiler/tests/ws_cull_screen_x_hi_codegen_test.cpp index bae857d2..5ea46ef0 100644 --- a/recompiler/tests/ws_cull_screen_x_hi_codegen_test.cpp +++ b/recompiler/tests/ws_cull_screen_x_hi_codegen_test.cpp @@ -117,6 +117,8 @@ constexpr uint32_t kBltzT0 = 0x05000002u; // bltz t0, +2 constexpr uint32_t kBgezT0 = 0x05010002u; // bgez t0, +2 constexpr uint32_t kBltzalT0 = 0x05100002u; // bltzal t0, +2 constexpr uint32_t kAddiuV0 = 0x24820070u; // addiu v0, a0, 0x70 +constexpr uint32_t kSltiAtS6 = 0x2AC10000u; // slti at, s6, 0 (s6 = SX << 16) +constexpr uint32_t kSltiAtS6Imm = 0x2AC10010u; // slti at, s6, 0x10 const char* const kLuiHelper = "cpu->gpr[22] = psx_ws_cull_lui_hi(0x0200u); " @@ -182,6 +184,33 @@ void listed_sltu_emits_the_band_helper() { "a screen_x site on sltu generates"); } +// slti rt,x,0 on `SX << 16` is the bltz sign test as a flag: set only while +// SX < -margin through the same helper. Any other slti immediate is not this +// form and fails the main-EXE build; an overlay variant keeps its code. +void listed_slti_zero_emits_the_high_half_test() { + PSXRecomp::CodeGenConfig config{}; + config.emit_comments = true; + const auto plain = generate_first_instruction(kSltiAtS6, config); + check(!has(plain, "psx_ws_cull_bltz_hi"), "an unlisted slti stays vanilla"); + config.ws_cull_screen_x_sites.insert(kBase); + check(has(generate_first_instruction(kSltiAtS6, config), + "cpu->gpr[1] = (uint32_t)psx_ws_cull_bltz_hi(cpu->gpr[22]); " + "/* ws explicit screen-x left edge (high half) */"), + "a screen_x site on slti rt,rs,0 routes through psx_ws_cull_bltz_hi"); + check(!exits_with_failure([&] { generate_first_instruction(kSltiAtS6, config); }), + "a screen_x site on slti rt,rs,0 generates"); + check(exits_with_failure([&] { generate_first_instruction(kSltiAtS6Imm, config); }), + "a screen_x site on slti with a nonzero immediate fails main-EXE generation"); + config.overlay_mode = true; + check(!has(generate_first_instruction(kSltiAtS6Imm, config), "psx_ws_cull_"), + "an overlay slti with a nonzero immediate at a screen_x site stays vanilla"); + for (int32_t x : {-1000, -54, -53, -1, 0, 1, 319}) { + const uint32_t v = (uint32_t)x << 16; + check(psx_ws_cull_bltz_hi_value(v, 0) == (((int32_t)v < 0) ? 1 : 0), + "the slti form is the vanilla slti at margin 0"); + } +} + // bltz_sites is the low-half form and keeps its meaning when an address is in // both lists. void bltz_sites_wins_over_screen_x_sites() { @@ -301,6 +330,7 @@ int main(int argc, char** argv) { unlisted_instructions_stay_vanilla(); listed_sites_emit_the_helpers(); listed_sltu_emits_the_band_helper(); + listed_slti_zero_emits_the_high_half_test(); bltz_sites_wins_over_screen_x_sites(); main_exe_guard_and_overlay_variants(); declarations_only_when_the_key_is_used(); diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 9d3c4802..59dcc656 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -23,14 +23,16 @@ psxrecomp_add_runtime_target(psx-runtime # (docs/ENHANCEMENTS.md G1.10). Titles get their _pgxp the same way. if(BUILD_TESTING) + add_executable(postfx_publication_test tests/test_postfx_publication.cpp) + target_include_directories(postfx_publication_test PRIVATE include) + add_test(NAME postfx_publication_test COMMAND postfx_publication_test) # Windows UTF-8 text boundary (issue #371): the helpers legacy Windows # relies on, and — with the shipped manifest — a UTF-8 process code page. if(WIN32) add_executable(host_time_threads_test tests/test_host_time_threads.c src/host_time.c) target_include_directories(host_time_threads_test PRIVATE include) add_test(NAME host_time_threads_test COMMAND host_time_threads_test) - # Four waves each have their own 10-second lost-wakeup watchdog. - set_tests_properties(host_time_threads_test PROPERTIES TIMEOUT 45) + set_tests_properties(host_time_threads_test PROPERTIES TIMEOUT 15) add_executable(win_utf8_helpers_test tests/test_win_utf8.cpp) target_include_directories(win_utf8_helpers_test PRIVATE include) add_test(NAME win_utf8_helpers_test COMMAND win_utf8_helpers_test) @@ -366,30 +368,6 @@ if(BUILD_TESTING) --output "${CMAKE_CURRENT_BINARY_DIR}/gl-render-thread-results") set_tests_properties(gl_render_thread_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 300 SKIP_RETURN_CODE 77) - foreach(_interp_mode normal hiw wide_quality hiw_wide_quality hiw_wide_quality_present) - set(_interp_args --interpolation) - if(_interp_mode MATCHES "wide_quality") - list(APPEND _interp_args --wide-present --quality) - endif() - add_test(NAME gl_render_thread_interpolation_${_interp_mode}_test - COMMAND "${Python3_EXECUTABLE}" - "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" - --cc "${CMAKE_C_COMPILER}" - --sdl-include "${_gl_scale_sdl_inc}" - --sdl-library "$" - ${_interp_args} - --output "${CMAKE_CURRENT_BINARY_DIR}/gl-rth-interp-${_interp_mode}") - set_tests_properties(gl_render_thread_interpolation_${_interp_mode}_test - PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 300 SKIP_RETURN_CODE 77) - if(_interp_mode MATCHES "hiw") - set_tests_properties(gl_render_thread_interpolation_${_interp_mode}_test - PROPERTIES ENVIRONMENT "PSX_GL_HIRES_WINDOW=1") - endif() - if(_interp_mode MATCHES "_present$") - set_tests_properties(gl_render_thread_interpolation_${_interp_mode}_test - PROPERTIES ENVIRONMENT "PSX_GL_HIRES_WINDOW=1;PSX_GL_TEST_PRESENT_THREAD=1") - endif() - endforeach() # Frame generation ([video] frame_generation): the real presented # images are identical with it off and on (VRAM and native-wide # presents), generated frames are presented, and an in-between frame @@ -966,11 +944,6 @@ if(BUILD_TESTING) target_include_directories(pgxp_test PRIVATE include) add_test(NAME pgxp_test COMMAND pgxp_test) - add_executable(pgxp_production_test tests/test_pgxp.cpp src/pgxp.cpp src/psx_ram_geometry.c) - target_include_directories(pgxp_production_test PRIVATE include) - target_compile_definitions(pgxp_production_test PRIVATE PSX_NO_DEBUG_TOOLS) - add_test(NAME pgxp_production_test COMMAND pgxp_production_test) - add_executable(pgxp_word_shadow_test tests/test_pgxp_word_shadow.cpp src/pgxp.cpp src/psx_ram_geometry.c) target_include_directories(pgxp_word_shadow_test PRIVATE include) @@ -1021,7 +994,7 @@ if(BUILD_TESTING) endforeach() foreach(_pgxp_isolated_test - gte_register_access_test gte_hle_contract_test pgxp_test pgxp_production_test pgxp_session_test pgxp_session_hooks_test) + gte_register_access_test gte_hle_contract_test pgxp_test pgxp_session_test pgxp_session_hooks_test) target_sources(${_pgxp_isolated_test} PRIVATE tests/pgxp_test_mod_memory.cpp) endforeach() @@ -1312,6 +1285,16 @@ if(BUILD_TESTING) target_include_directories(bios_hle_plan_test PRIVATE include) add_test(NAME bios_hle_plan_test COMMAND bios_hle_plan_test) + # Every func_override failure mode is silent at runtime: a registration + # that is never consulted, a package override that survives clear-mods + # (a netplay divergence), a residency guard that corrupts instead of + # declining. Drives the real g_psx_func_override_hook pointer. + add_executable(func_override_test + tests/test_func_override.c + src/func_override.c) + target_include_directories(func_override_test PRIVATE include) + add_test(NAME func_override_test COMMAND func_override_test) + # A cue, conventional raw image, or Steam .car image is a valid pick; each # must mount the same disc and identify off the same data track. add_executable(sbi_setup_test tests/test_sbi_setup.cpp src/psx_sha256.c) @@ -1441,6 +1424,7 @@ if(BUILD_TESTING) src/mod_immediate.c src/host_overlay.c tests/test_mod_runtime.cpp + src/func_override.c src/iso_reader_c.cpp src/host_span_ring.c src/mod_runtime.cpp @@ -1727,10 +1711,6 @@ if(BUILD_TESTING) add_test(NAME frame_interpolation_schedule_test COMMAND frame_interpolation_schedule_test) - add_executable(input_replay_test tests/test_input_replay.c src/input_replay.c) - target_include_directories(input_replay_test PRIVATE include) - add_test(NAME input_replay_test COMMAND input_replay_test "${CMAKE_CURRENT_BINARY_DIR}/input_replay_fixture.txt") - add_executable(render_pass_plan_test tests/test_render_pass_plan.c src/render_pass_plan.c) diff --git a/runtime/include/duckstation_texture_pack.h b/runtime/include/duckstation_texture_pack.h index eb0cab4e..d0569139 100644 --- a/runtime/include/duckstation_texture_pack.h +++ b/runtime/include/duckstation_texture_pack.h @@ -170,6 +170,12 @@ int duck_texture_pack_match_parts(DuckTexturePack* pack, * Release the lease even after pack destruction. */ void duck_texture_pack_set_decode_budget(DuckTexturePack* pack, size_t bytes); int duck_texture_pack_request_decode(DuckTexturePack* pack, uint64_t entry_id); +/* Elective full preload with the hd_texture_pack_preload contract: bounded + * aggregate decoded bytes and concurrency, pinned images outside the decode + * budget, everything not preloaded left to the asynchronous decode path. + * Returns 1 complete, 0 partial (see out), -1 on error. */ +int duck_texture_pack_preload(DuckTexturePack* pack, size_t max_bytes, + unsigned max_threads, HdTexturePreloadResult* out); int duck_texture_pack_acquire_decoded(DuckTexturePack* pack, uint64_t entry_id, DuckTexturePixels* out_pixels); void duck_texture_pixels_release(DuckTexturePixels* pixels); diff --git a/runtime/include/frame_interpolation.h b/runtime/include/frame_interpolation.h index 57b92049..4e80efc5 100644 --- a/runtime/include/frame_interpolation.h +++ b/runtime/include/frame_interpolation.h @@ -17,9 +17,6 @@ typedef struct FrameInterpolationSchedule { * plans the whole 0..1 window, which is the historical behaviour. */ double phase_lo; double phase_hi; - /* Bounded asynchronous producer: preparing several frames ahead is normal, - * so an early arrival must not reset the presentation clock. */ - int queued; } FrameInterpolationSchedule; /* Plan one stock guest-frame interval. Presentation deadlines remain anchored diff --git a/runtime/include/func_override.h b/runtime/include/func_override.h new file mode 100644 index 00000000..2e114219 --- /dev/null +++ b/runtime/include/func_override.h @@ -0,0 +1,241 @@ +/* func_override.h — replace a guest function with hand-written C. + * + * WHY THIS EXISTS + * + * A recomp turns the game into C, but that C is a build artifact: regenerated + * from the player's own disc, never hand-edited (CLAUDE.md rule 4). So there + * is no supported way to say "this function should do something else" — which + * is the single most important thing a port needs to grow beyond a faithful + * replay of the original. + * + * This is that mechanism. Register hand-written C against a guest address and + * the dispatcher calls yours instead of the recompiled original. + * + * NO SIZE LIMIT. This is not a ROM patch; there is no slot to fit inside, no + * code cave, nothing to shift. Your replacement is native code in a native + * binary — five instructions or five thousand lines. That freedom is the + * entire point of recompiling rather than hacking bytes. + * + * WHERE IT HOOKS, AND WHY IT MATTERS + * + * The hook is consulted in psx_dispatch_impl after the BIOS tiers (so an + * override can never shadow a kernel service vector) and BEFORE every game + * code backend. That placement is deliberate and load-bearing: one + * address-keyed hook covers the STATIC EXE, runtime-loaded OVERLAYS and + * DIRTY RAM alike. The interpreter's JAL/JALR call-resolution tiers consult + * it on the SAME terms — before enter-compiled, before overlay-native and + * before the local pc-chain (the reserved CRES_OVERRIDE slot) — so no call + * the interpreter resolves itself can bypass an override. Ordering there is + * not cosmetic: interp_enter_compiled calls psx_dispatch_game_compiled -> + * entry->fn(cpu) directly and never re-enters psx_dispatch_impl, so a hook + * placed after it is unreachable for any override on a statically-compiled + * function reached from interpreted code, while registration and the + * func_override inventory both still look healthy. + * + * SCOPE: overrides are CALL-site keyed. A guest tail transfer (j / jr) into + * an overridden address from the dirty-RAM block loop is a TRANSFER, not a + * call, and does not consult the hook — see dirty_ram_interp.c's compiled + * handoff sites. Registering on a function only ever entered by tail + * transfer will show calls == 0. + * + * THE CONTRACT + * + * Your function receives the CPUState and must obey the guest ABI: + * + * int my_impl(CPUState *cpu) { + * uint32_t a0 = cpu->gpr[4], a1 = cpu->gpr[5]; // arguments + * ... + * cpu->gpr[2] = result; // $v0 = return value + * return 1; + * } + * + * Return 1 and the guest resumes at $ra exactly as if the original had run + * `jr $ra`. Return 0 and the original recompiled or interpreted code runs + * untouched, so an override can decline case by case (a conditional + * pre-hook: mutate state, return 0, original runs). + * + * POINTERS MUST BE GUEST ADDRESSES. The caller is recompiled too: when it + * dereferences your return value it performs a guest load. A host pointer + * will read garbage. If you need to hand back a buffer, write it into guest + * RAM (psx_write_byte and friends, or psx_mod_alloc_guest_memory) and return + * that address. + * + * DETERMINISM. Keep ALL mutable state in guest RAM. Rollback/rewind and + * netplay snapshot guest state only; host-side statics in an override + * desync replays. Reading host config that never changes mid-session is + * fine. + * + * CYCLE ACCOUNTING — REQUIRED AT REGISTRATION. + * On hardware the replaced function took guest time, and every device in + * the machine schedules against that clock; a replacement that takes zero + * cycles shifts IRQ phase for the rest of the session. So every + * registration MUST state its timing intent via the `credit` argument — + * there is no default to inherit. Two forms: + * + * credit >= 0 The tier charges exactly `credit` guest cycles + * (psx_advance_cycles) on every HANDLED call, after + * the body runs. Declines charge nothing — the + * original runs and accrues its own cycles. 0 is + * legal and means "deliberately free": right for a + * player-toggled mod whose behavior has no hardware + * analog, indefensible for an always-on + * reimplementation claiming to be indistinguishable. + * + * FO_CREDIT_SELF The tier charges nothing; the BODY owns its timing + * and calls psx_advance_cycles() itself. Use this + * when the original's cost is data-dependent + * (charge base + per_iteration * n, the bios_hle.c + * TIMING NOTE pattern), and ALWAYS for wraps: the + * original re-dispatched by func_override_call_original + * accrues its exact cycles by executing, so a fixed + * nonzero credit on a wrap double-counts. Guest code + * invoked via func_override_guest_call likewise + * self-charges — credit only the code you REPLACED. + * + * Credits are approximations of the original's dynamic instruction count + * unless measured; LLE remains the timing oracle (run with the override + * declined and sample the cycle delta across the call boundary — the + * callret ring — to measure a real distribution). See FAITHFUL_TIMING_PLAN. + * + * WHAT IT DOES NOT DO + * + * It does not lift the game's own assumptions. If the caller copies your + * result into a fixed-size field, that field is still fixed. The override + * removes the code limit, not every limit. + */ + +#ifndef PSXRECOMP_FUNC_OVERRIDE_H +#define PSXRECOMP_FUNC_OVERRIDE_H + +#include +#include + +struct CPUState; + +#ifdef __cplusplus +extern "C" { +#endif + +#define FO_MAX_OVERRIDES 128 +#define FO_MAX_ID 64 +#define FO_MAX_GUARD_WORDS 4 + +/* `credit` value: the override body states its own timing by calling + * psx_advance_cycles() itself (mandatory for wraps — see CYCLE ACCOUNTING + * above). Any other negative value is FO_ERR_ARGS. */ +#define FO_CREDIT_SELF (-1) + +/* func_override_add return codes. */ +#define FO_OK 0 +#define FO_ERR_FULL 1 +#define FO_ERR_ARGS 2 +#define FO_ERR_DUPLICATE 3 + +/* An override implementation. Reads arguments from cpu->gpr[4..7], writes + * the return value to cpu->gpr[2]. Returning nonzero means "handled"; + * returning 0 declines this call and the original code runs instead. */ +typedef int (*FuncOverrideFn)(struct CPUState *cpu); + +/* Register `fn` for guest address `addr` (KSEG or physical — normalised). + * `id` is copied, used for diagnostics, and may be NULL. Registering the + * same address twice is an error rather than a silent last-wins, because a + * silent shadow is impossible to debug. `credit` is the guest-cycle policy + * (CYCLE ACCOUNTING above): >= 0 charged per handled call, or + * FO_CREDIT_SELF when the body charges its own. */ +int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn, + int32_t credit); + +/* Like func_override_add, plus a residency guard: before each consult the + * first `n_words` guest words at `addr` are compared against + * `expected_words`; on mismatch the override silently declines (the guard + * miss is counted, see func_override_get_ex). Use this when the address + * belongs to overlay/dirty RAM that other code may occupy at other times — + * the guard makes "wrong code resident" a decline instead of a corruption. + * n_words must be 1..FO_MAX_GUARD_WORDS. */ +int func_override_add_guarded(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, + int32_t credit); + +/* Call a guest function from inside an override (or any native code on the + * dispatch thread): args already placed in cpu->gpr[4..7], returns after the + * callee completes; result in cpu->gpr[2]. `site_ra` is the return address + * the callee should observe in $ra — pass the original call site's return + * address when re-creating a call the original code would have made, so + * callees, fntrace and crash forensics see authentic values. $ra is + * restored around the call. */ +void func_override_guest_call(struct CPUState *cpu, uint32_t target, + uint32_t site_ra); + +/* Run the ORIGINAL code of the function currently being overridden — the + * wrap primitive. Callable only from inside an executing override; arms a + * one-shot bypass for this address, re-dispatches it, and returns after the + * original completes (result in cpu->gpr[2]). Typical post-hook: + * + * int wrap(CPUState *cpu) { + * func_override_call_original(cpu); // original runs fully + * ...inspect / adjust results... + * return 1; // we handled the call + * } + * + * The bypass is one-shot per invocation: if the original recursively calls + * itself, the recursive calls consult the override again (matching what a + * guest-level wrap would observe). */ +void func_override_call_original(struct CPUState *cpu); + +/* Register on behalf of a mod package plan. Identical to the two calls above + * (pass n_words 0 for unguarded) except the entry is tagged package-armed, so + * func_override_reset_package_armed can drop it when the plan is cleared. + * The mod runtime uses this; game code registering its own always-on + * reimplementations should use func_override_add/_add_guarded. */ +int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, + int32_t credit); +/* Scoped package variant. The owner supplies a stable plan context and + * invokes fn inside that plugin's resource/completion context. The plan must + * disarm package entries before replacing that context. */ +typedef int (*FuncOverrideInvoker)(struct CPUState *cpu, FuncOverrideFn fn, + const void *context); +int func_override_add_package_scoped(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, int32_t credit, + FuncOverrideInvoker invoke, const void *context); +/* Nonzero while a native override stack is live, including nested guest + * calls. Save/load and rewind must defer until this returns to zero. */ +int func_override_active(void); + +/* Install the dispatcher hook. Call once at startup AFTER registering (the + * mod runtime calls it again after arming package-gated overrides — safe). + * Cheap with nothing registered — it leaves the hook NULL, so dispatch is + * byte-identical to a build without the tier. */ +void func_override_install(void); + +/* Drop every package-armed override, keep direct registrations, re-install + * (so the hook goes back to NULL if the table empties). Returns how many were + * dropped. THIS IS REQUIRED FOR CORRECTNESS, not a convenience: a cleared mod + * plan stops activation/vblank callbacks simply by no longer being iterated, + * but an armed override lives in this module's own table, so without an + * explicit reset it keeps firing after the plan is gone — including after + * netplay's clear-mods announces a vanilla session, which is a peer + * divergence. Direct registrations are deliberately kept: they are the game's + * own always-on faithful reimplementations, present identically in both + * peers' builds and not player-selectable. */ +int func_override_reset_package_armed(void); + +/* Introspection, surfaced by the `func_override` TCP command. An override + * whose `calls` stays 0 was never reached: wrong address, or that code path + * never ran. `guard_misses` counts consults declined by the residency + * guard. `credit_out` reports the declared cycle policy (FO_CREDIT_SELF or + * the fixed per-call charge), so every armed override's timing intent is + * inspectable, not just documented. */ +int func_override_count(void); +int func_override_get(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out); +int func_override_get_ex(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out, + uint64_t *guard_misses_out, int *guarded_out, + int32_t *credit_out); + +#ifdef __cplusplus +} +#endif + +#endif /* PSXRECOMP_FUNC_OVERRIDE_H */ diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index 9e6289b2..08550135 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -346,6 +346,17 @@ void gpu_ws_set_clip_edge_x_load_sites(const uint32_t *sites, int nsites, void gpu_ws_set_cull_keep_sites(const uint32_t *addresses, const uint32_t *expected, const uint32_t *results, int nsites); +/* [[widescreen.cull.sxy]]: packed-SXY all-vertices reject folds. Per site i, + * vertices[i] (3 or 4) ANDs at fold_addresses[i*4 + k] with words + * fold_expected[i*4 + k] and SXY operand vertex_regs[i*4 + k], then the + * beq/bne consumer. The recompiler config loader has validated the shape. */ +#define PSX_WS_SXY_CULL_SITES_MAX 64 +void gpu_ws_set_sxy_cull_sites(const uint32_t *vertices, + const uint32_t *fold_addresses, + const uint32_t *fold_expected, + const uint32_t *vertex_regs, + const uint32_t *consumer_addresses, + const uint32_t *consumer_expected, int nsites); void gpu_ws_set_angle_sites(const uint32_t *addresses, const uint32_t *expected, int nsites); /* [[widescreen.cull.scale]]: full-word-guarded SLT/SLTU sites whose rs (0) or @@ -399,6 +410,14 @@ uint32_t psx_ws_clip_edge_width(void); uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced); int psx_ws_cull_keep_site(uint32_t pc, uint32_t instr, uint32_t vanilla, uint32_t *out); +/* One AND of a [[widescreen.cull.sxy]] fold (ws_cull_edge.h); identity at 4:3. */ +uint32_t psx_ws_cull_sxy_and(uint32_t acc, uint32_t sxy); +/* Interpreter lookup: the SXY operand register (1..31) when `instr` at `pc` + * is an AND of a configured fold and every fold and consumer word, read + * through `read_word` (physical address), matches; else 0. */ +extern int g_psx_ws_sxy_cull_n; +uint32_t psx_ws_sxy_cull_vertex_reg(uint32_t pc, uint32_t instr, + uint32_t (*read_word)(uint32_t phys)); uint32_t psx_ws_angle_widen(uint32_t vanilla); int psx_ws_angle_site(uint32_t pc, uint32_t instr, uint32_t *out); /* bound * (half_extent + margin) / half_extent; identity at 4:3. */ diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index fd40b8c3..cfa24c22 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -157,8 +157,6 @@ void gl_renderer_stereo_diag(GLRenderStereoDiag *out); uint64_t gl_renderer_perf_ticks(void); uint64_t gl_renderer_perf_frequency(void); int gl_renderer_interpolation_owns_cadence(void); -/* Nonblocking, atomically published readiness for bounded queued pacing. */ -int gl_renderer_interpolation_async_cadence(void); void gl_renderer_interpolation_diag(int *enabled, int *suspended, int *history_frames, double *host_hz, double *target_hz, @@ -201,6 +199,8 @@ float gl_renderer_get_bloom(void); * compile is logged and skipped. */ void gl_renderer_set_post_shader(const char *path); int gl_renderer_post_shader_loaded(void); +/* Debug: path, last load outcome and live uniforms of the user chain (JSON members). */ +int gl_renderer_post_shader_debug(char *out, int cap); typedef struct { float strength, threshold; uint64_t passes, us_n; @@ -570,20 +570,6 @@ int gl_renderer_pres_get(uint64_t seq, GlPresEvent *out); * sets/frame avg, [17]=wide FBO creations/frame avg. GPU phases are true * GL_TIME_ELAPSED times (CPU-overhead independent). Returns the count. */ int gl_renderer_perf_aggregate(int wide_filter, double out[18]); -/* Bounded benchmark attribution. CPU spans require PSX_GL_CPU_PERF=1; - * reading drains the render queue, so read outside the measured interval. */ -typedef struct { - uint64_t counts[10]; - uint64_t calls[8], ticks[8], max_ticks[8], frequency; - uint64_t prior_ticks[8], prior_max_ticks[8]; -} GlRenderWorkStats; -void gl_renderer_work_stats(GlRenderWorkStats *out); -/* Opt-in replay receipt: actual SwapWindow return times, process-clock us. - * Query outside measurement: drains the render/present queues. */ -#define GL_SWAP_TIMING_CAP 65536 -typedef uint64_t (*GlSwapClockFn)(void); -void gl_renderer_swap_timing_start(GlSwapClockFn clock); -int gl_renderer_swap_timestamps(uint64_t *out, int cap); /* Native-wide mirror ablation (perf attribution, debug cmd gl_ws_ablate): * 0 = normal, 1 = skip the whole mirror pass (incl. wide_clear), 2 = full mirror diff --git a/runtime/include/gte_postfx_projection.h b/runtime/include/gte_postfx_projection.h new file mode 100644 index 00000000..1ff42d9f --- /dev/null +++ b/runtime/include/gte_postfx_projection.h @@ -0,0 +1,12 @@ +#pragma once +#include +#ifdef __cplusplus +extern "C" { +#endif +typedef struct GtePostfxProjection { int32_t h, ofx, ofy; } GtePostfxProjection; +/* Coherent latest CPU projection, not a frame/view identity. One CPU producer. */ +void gte_postfx_projection_enable(int enabled); +int gte_postfx_projection_read(GtePostfxProjection *out); +#ifdef __cplusplus +} +#endif diff --git a/runtime/include/gte_projection_publication.hpp b/runtime/include/gte_projection_publication.hpp new file mode 100644 index 00000000..b51907bd --- /dev/null +++ b/runtime/include/gte_projection_publication.hpp @@ -0,0 +1,31 @@ +#pragma once +#include "gte_postfx_projection.h" +#include +/* CPU owns cached fields; renderer owns enable(). Atomic payloads plus an SC + * version guard make concurrent reads defined and reject mixed tuples. */ +class GteProjectionPublication { + std::atomic control{0}, version{0}, published{0}; + std::atomic h{0}, x{0}, y{0}; + uint32_t seen=0; GtePostfxProjection cached{}; +public: + void enable(bool on) { + const auto c=control.load(); + if(bool(c&1)!=on) control.store(((c+2)&~1u)|uint32_t(on)); + } + void publish(int32_t ph,int32_t px,int32_t py) { + const auto c=control.load(std::memory_order_relaxed); + if(!(c&1))return; + if(c==seen && ph==cached.h && px==cached.ofx && py==cached.ofy)return; + version.fetch_add(1); h.store(ph); x.store(px); y.store(py); published.store(c); + version.fetch_add(1); seen=c; cached={ph,px,py}; + } + bool read(GtePostfxProjection& out) const { + const auto c=control.load(); if(!(c&1))return false; + for(unsigned i=0;i<3;++i) { + const auto v=version.load(); if(v&1)continue; + const auto p=published.load(); GtePostfxProjection value{h.load(),x.load(),y.load()}; + if(v==version.load() && p==c && c==control.load()) {out=value;return true;} + } + return false; + } +}; diff --git a/runtime/include/hd_texture_pack.h b/runtime/include/hd_texture_pack.h index af389ffe..25591505 100644 --- a/runtime/include/hd_texture_pack.h +++ b/runtime/include/hd_texture_pack.h @@ -25,6 +25,13 @@ enum HdTextureLookupStatus { HD_TEXTURE_LOOKUP_AMBIGUOUS = 2 }; +/* Palette identity of an indexed replacement, named TTTTTTTT-index.png: its + * red channel holds CLUT indices (alpha 0 = transparent) and the renderer + * resolves them through the live CLUT of each draw, so one image serves every + * palette the game draws the texture with (highlights, fades). An exact + * TTTTTTTT-PPPPPPPP image still wins for its own palette. */ +#define HD_TEXTURE_PALETTE_INDEXED 0xFFFFFFFFu + typedef struct HdTexturePackInfo { const char* asset_root; /* valid until hd_texture_pack_destroy */ const char* replacement_root; /* valid until hd_texture_pack_destroy */ @@ -109,6 +116,8 @@ uint32_t hd_texture_hash_clut(const uint16_t* vram, /* Upload bytes are row-major logical upload words, independent of destination * wrapping. Tracking invalidates/splits every intersected older residency. * Width <= 1024 and height <= 512; X/Y may wrap. */ +int hd_texture_pack_debug_uploads_at(const HdTexturePack* pack, unsigned x, unsigned y, + char* out, size_t cap); int hd_texture_pack_track_upload(HdTexturePack* pack, uint16_t x, uint16_t y, @@ -180,6 +189,27 @@ typedef struct HdTexturePixels { void hd_texture_pack_set_decode_budget(HdTexturePack* pack, size_t budget_bytes); +/* Elective full preload: decode unambiguous replacements up front and pin + * them, bounded by max_bytes of decoded RGBA in aggregate and by max_threads + * concurrent decodes (the caller's thread included). Pinned images are kept + * outside the on-demand decode budget, which stays as configured. Images not + * preloaded (over the cap, unreadable, malformed, a worker failure) are left + * to the normal asynchronous, budgeted decode path; nothing is marked failed + * by the preload. Returns 1 when every image was preloaded, 0 for a partial + * preload (see out), -1 on error. Repeated calls count already pinned images + * without reading/decoding them again; max_bytes caps the pack's total pinned + * residency. A cap smaller than existing residency is rejected with -1 and + * the existing residency reported; it does not discard live pinned images. */ +typedef struct HdTexturePreloadResult { + int requested; /* unambiguous replacements considered */ + int loaded; /* resident and pinned, including earlier calls */ + int over_budget; /* skipped: the aggregate max_bytes was reached */ + int failed; /* unreadable, malformed, too large, or worker error */ + int worker_errors; /* of failed: a decode that threw (caught) */ + size_t bytes; /* total decoded RGBA pinned by this pack */ +} HdTexturePreloadResult; +int hd_texture_pack_preload(HdTexturePack* pack, size_t max_bytes, + unsigned max_threads, HdTexturePreloadResult* out); int hd_texture_pack_request_decode(HdTexturePack* pack, uint32_t texture_hash, uint32_t palette_hash); diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h index 9064e382..b736165b 100644 --- a/runtime/include/host_overlay.h +++ b/runtime/include/host_overlay.h @@ -56,6 +56,13 @@ void psx_host_overlay_draw(int width, int height); /* renderer side */ void psx_host_set_hud_draw_cb(PsxHostOverlayDrawFn fn, void *ctx); void psx_host_hud_draw(int width, int height); /* renderer side */ +/* ---- Post-effect live uniforms ------------------------------------------ + * Sets the value of a `uniform ...` line of the loaded post-effect chain + * ([video] post_shader, postfx_chain.h) from game state, e.g. a light source + * projected to the screen. Any thread; effective at the next present; the + * preset default applies until the first call. Up to 16 names. */ +void psx_host_set_post_uniform(const char *name, float x, float y, float z, float w); + /* ---- P3: input sink ----------------------------------------------------- * The sink sees every host SDL event first (main thread), in the normal event * drain and in the host pause loop. Returning non-zero consumes the event. diff --git a/runtime/include/host_span_ring.h b/runtime/include/host_span_ring.h index 3469e494..4026a07b 100644 --- a/runtime/include/host_span_ring.h +++ b/runtime/include/host_span_ring.h @@ -45,10 +45,6 @@ typedef enum HostSpanKind { HSPAN_MOD_WORK, /* emu: resident pack / mod host work */ HSPAN_FRAME_PRESENT, /* emu: frontend present for the frame */ HSPAN_DISC_READ, /* emu: one sector read from the disc image (arg lba) */ - HSPAN_HOST_EVENTS, /* emu: pump and dispatch host window events */ - HSPAN_REWIND_CAPTURE, /* emu: snapshot, store and thumbnail */ - HSPAN_DISPLAY_PROBE, /* emu: host refresh/mode query */ - HSPAN_AUDIO_UPDATE, /* emu: host audio pump */ HSPAN_KIND_COUNT } HostSpanKind; diff --git a/runtime/include/input_replay.h b/runtime/include/input_replay.h deleted file mode 100644 index 74afdc85..00000000 --- a/runtime/include/input_replay.h +++ /dev/null @@ -1,17 +0,0 @@ -#ifndef PSX_INPUT_REPLAY_H -#define PSX_INPUT_REPLAY_H -#include -#ifdef __cplusplus -extern "C" { -#endif -/* Optional host-input replay in every build. Text rows: frame count, hex pad - * word (active low). Starts at the first VBlank after a disk state load. - * No guest state is stored here; repeated reads of a frame are idempotent. */ -int input_replay_load(const char *path); -void input_replay_on_load(uint64_t next_frame); -int input_replay_buttons(uint64_t frame); -uint64_t input_replay_frame(uint64_t frame); /* UINT64_MAX while inactive */ -#ifdef __cplusplus -} -#endif -#endif diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 9f489169..2501ffac 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -627,6 +627,7 @@ bool mod_register_function_filter_plugin(const std::string& id, uint32_t address bool mod_register_guest_function_plugin(const std::string& id, uint32_t address, PSXModFunctionEntryCallback callback); bool mod_plugin_registered(const std::string& id); +bool mod_register_function_override_marker(const std::string& id); void mod_invoke_activation_plugin(const std::string& id); void mod_invoke_vblank_plugin(const std::string& id); struct ModFunctionEntryHook { diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index d77acc18..671b41fe 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -2,6 +2,8 @@ #include +#include "func_override.h" /* FO_CREDIT_SELF for the credit argument below */ + #ifdef __cplusplus extern "C" { #endif @@ -135,6 +137,37 @@ int psx_mod_function_entry_active(void); void psx_mod_set_guest_cycle_scale_gate(int open); /* Live scale: the configured one while every gate is open, else 1. */ uint32_t psx_mod_guest_cycle_scale(void); + +/* + * Package-gated function override (see runtime/include/func_override.h for + * the full contract). Registration queues the override under the plugin id; + * it is ARMED into the dispatcher tier only when a resolved package plan + * selects that plugin — the same gating as vblank/activation callbacks. The + * callback obeys the guest ABI (args cpu->gpr[4..7], result cpu->gpr[2]); + * return 1 = handled (guest resumes at $ra), 0 = decline (original runs). + * The owning plugin resource context is active throughout the callback and + * restored across nested callbacks. Snapshots wait until its native stack + * unwinds. Wrap semantics via func_override_call_original(); optional residency + * guard via expected_words (NULL/0 = unguarded). + * + * `id` may carry an optional ":label" suffix ("pkg.feature:aim") that names + * the override in the `func_override` TCP inventory; gating and manifest + * matching use only the part before the ':', so several overrides can sit + * under one [[plugin]] entry and still read apart in diagnostics. + * + * `credit` is the REQUIRED guest-cycle policy (func_override.h, CYCLE + * ACCOUNTING): >= 0 cycles charged per handled call, or FO_CREDIT_SELF when + * the body charges its own. For a mod whose behavior has no hardware analog + * an explicit 0 is the honest value; a wrap that runs the original via + * func_override_call_original must pass FO_CREDIT_SELF (the original + * self-charges by executing — a fixed credit would double-count). + */ +typedef int (*PSXModFunctionOverrideFn)(struct CPUState* cpu); +int psx_mod_register_function_override(const char* id, uint32_t address, + PSXModFunctionOverrideFn fn, + const uint32_t* expected_words, + int n_words, int32_t credit); + /* Narrow guest services available to trusted plugin callbacks. */ int psx_mod_game_started(void); /* Read an original mounted-disc file without changing guest CD state/timing. diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index f6af2899..52d60175 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -113,6 +113,8 @@ void mod_runtime_set_progress_hook(void (*hook)(void)); /* Invokes activation callbacks for the committed plan. Call after the final * launcher commit and before renderer/window initialization. */ void mod_runtime_activate_plugins(void); +/* NULL on success; a non-NULL arming failure requires refusing this launch. */ +const char* mod_runtime_activation_error(void); void mod_runtime_on_vblank(void); /* Host VBlank query for the active title's optional disc-request provider. */ int mod_runtime_disc_request(int *ready); diff --git a/runtime/include/overlay_dispatch_preamble.c.inc b/runtime/include/overlay_dispatch_preamble.c.inc index 0473b4bf..16e056d6 100644 --- a/runtime/include/overlay_dispatch_preamble.c.inc +++ b/runtime/include/overlay_dispatch_preamble.c.inc @@ -293,6 +293,10 @@ int32_t psx_ws_plane_nx(int32_t nx) { uint32_t psx_ws_xclip_bound(uint32_t vanilla) { return g_cbs.ws_xclip_bound ? g_cbs.ws_xclip_bound(vanilla) : vanilla; } +/* [[widescreen.cull.sxy]] fold AND (ws_cull_edge.h). MUST match gpu.c. */ +uint32_t psx_ws_cull_sxy_and(uint32_t acc, uint32_t sxy) { + return psx_ws_sxy_and_value(acc, sxy, psx_ws_x_margin()); +} uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced) { return g_cbs.ws_cull_keep_result ? g_cbs.ws_cull_keep_result(vanilla, forced) diff --git a/runtime/include/postfx_chain.h b/runtime/include/postfx_chain.h index b19759a3..0c87a321 100644 --- a/runtime/include/postfx_chain.h +++ b/runtime/include/postfx_chain.h @@ -9,7 +9,10 @@ * pfx 1 # format version (required first) * pass # starts a pass; the last pass writes the window * shader # fragment body (see below) - * input > + * input > + * # depth: PGXP view depth sz (GTE screen z, R32F; + * # 0 where nothing 3D drew); history: this pass's + * # own target from the last real frame (not last pass) * # source_down: the image scaled to this pass's * # target (one bilinear blit, no full-size copy) * scale # target size, fraction of the game rect (default 1) @@ -27,6 +30,7 @@ * uniform vec2 u_rcp; // 1 / this pass's target size in px * uniform vec2 u_size; // this pass's target size in px * uniform ivec2 u_org; // window origin of the game rect (gl_FragCoord - u_org = px in it) + * uniform float u_time; // seconds since the chain loaded (real and generated frames) * plus one `uniform sampler2D ;` per input. v_uv is 0..1 over the * game rect, bottom-up like the window. */ #ifndef PSX_POSTFX_CHAIN_H @@ -38,7 +42,8 @@ extern "C" { enum { PFX_MAX_PASSES = 16, PFX_MAX_INPUTS = 4, PFX_MAX_UNIFORMS = 8, PFX_NAME = 32 }; /* Input sources; a value >= 0 is an earlier pass's index. */ -enum { PFX_IN_SOURCE = -1, PFX_IN_MASK = -2, PFX_IN_SOURCE_DOWN = -3 }; +enum { PFX_IN_SOURCE = -1, PFX_IN_MASK = -2, PFX_IN_SOURCE_DOWN = -3, PFX_IN_DEPTH = -4, + PFX_IN_HISTORY = -5 }; enum { PFX_BLEND_REPLACE = 0, PFX_BLEND_SCREEN = 1 }; enum { PFX_FMT_RGBA8 = 0, PFX_FMT_RGBA16F = 1 }; @@ -69,6 +74,7 @@ typedef struct { char name[PFX_NAME]; int npasses; int uses_mask; /* some pass reads `mask` */ + int uses_depth; /* some pass reads `depth` */ PfxPass pass[PFX_MAX_PASSES]; } PfxChain; diff --git a/runtime/include/postfx_debug_json.h b/runtime/include/postfx_debug_json.h new file mode 100644 index 00000000..e63f0e28 --- /dev/null +++ b/runtime/include/postfx_debug_json.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include +#include +typedef struct PfxDebugLive { char name[32]; float v[4]; } PfxDebugLive; +typedef struct PfxJsonWriter { char *out; size_t cap,used; int ok; } PfxJsonWriter; +static inline void pfx_json_raw(PfxJsonWriter *w,const char *s) { + const size_t n=strlen(s); + if(!w->ok || n>=w->cap-w->used){w->ok=0;return;} + memcpy(w->out+w->used,s,n+1);w->used+=n; +} +static inline void pfx_json_quote(PfxJsonWriter *w,const char *s) { + pfx_json_raw(w,"\""); + for(const unsigned char *p=(const unsigned char*)s;*p;++p) { + char b[7]; + if(*p=='"'||*p=='\\'){b[0]='\\';b[1]=(char)*p;b[2]=0;} + else if(*p<32)snprintf(b,sizeof b,"\\u%04x",(unsigned)*p); + else {b[0]=(char)*p;b[1]=0;} + pfx_json_raw(w,b); + } + pfx_json_raw(w,"\""); +} +/* Returns 0 with an empty buffer on insufficient space, never partial JSON. */ +static inline int pfx_debug_json(char *out,int cap,const char *path,const char *status, + const PfxDebugLive *live,int count) { + if(!out||cap<=0)return 0; + out[0]=0;PfxJsonWriter w={out,(size_t)cap,0,1}; + pfx_json_raw(&w,"\"path\":");pfx_json_quote(&w,path); + pfx_json_raw(&w,",\"status\":");pfx_json_quote(&w,status); + pfx_json_raw(&w,",\"uniforms\":{"); + for(int i=0;i +#include +#include +#include +#include +#include +#include + +namespace psx_texpreload { + +/* Test-only fault injection. 1: the first item's decode throws + * std::bad_alloc. 2: creating the second pool thread throws std::system_error. + * 3: publication fails after the first pinned image. */ +inline std::atomic& fault_mode() { + static std::atomic mode{0}; + return mode; +} +inline void after_publish(size_t count) { + if (count == 1 && fault_mode().load() == 3) throw std::bad_alloc(); +} + +/* Aggregate decoded bytes, reserved before an image is decoded. */ +class Budget { +public: + explicit Budget(size_t cap) : cap_(cap) {} + bool reserve(size_t bytes) { + size_t cur = used_.load(); + do { + if (bytes > cap_ || cur > cap_ - bytes) return false; + } while (!used_.compare_exchange_weak(cur, cur + bytes)); + return true; + } + void release(size_t bytes) { used_.fetch_sub(bytes); } +private: + size_t cap_; + std::atomic used_{0}; +}; + +/* Per-item outcome, written by exactly one worker. */ +enum ItemStatus : unsigned char { kPending = 0, kLoaded, kFailed, kOverBudget }; + +/* Joins every started thread when the pool goes out of scope, including while + * unwinding from a failed std::thread construction. */ +struct Joiner { + std::vector& threads; + ~Joiner() { + for (auto& t : threads) + if (t.joinable()) t.join(); + } +}; + +/* Runs item(k) for every k < n on at most max_threads threads, the caller + * being one of them. An exception from item(k) is caught and counted, never + * propagated out of a thread entry. A failure to start a thread only lowers + * concurrency. Returns the number of items whose item(k) threw. */ +template +size_t run(size_t n, unsigned max_threads, F&& item) { + std::atomic next{0}, errors{0}; + auto work = [&]() noexcept { + for (size_t k; (k = next.fetch_add(1)) < n;) { + try { + if (k == 0 && fault_mode().load() == 1) throw std::bad_alloc(); + item(k); + } catch (...) { + errors.fetch_add(1); + } + } + }; + unsigned hw = std::thread::hardware_concurrency(); + if (hw == 0) hw = 1; + const unsigned threads = std::max(1u, std::min(max_threads ? max_threads : 1u, hw)); + std::vector pool; + { + Joiner joiner{pool}; + for (unsigned t = 1; t < threads && t < n; ++t) { + try { + if (t == 1 && fault_mode().load() == 2) + throw std::system_error( + std::make_error_code(std::errc::resource_unavailable_try_again)); + pool.emplace_back(work); + } catch (...) { + break; + } + } + work(); + } + return errors.load(); +} + +} // namespace psx_texpreload + +#endif diff --git a/runtime/include/video_telemetry.h b/runtime/include/video_telemetry.h index 55ae2753..4b9e6cc4 100644 --- a/runtime/include/video_telemetry.h +++ b/runtime/include/video_telemetry.h @@ -30,6 +30,18 @@ void psx_video_telemetry_present(int result); * Completion/error counters remain available in that case. */ void psx_video_telemetry_snapshot(PsxVideoTelemetry *out); +typedef struct PsxPresentCadence { + int valid; + uint32_t count, mean_us, p50_us, p95_us, p99_us, max_us, hitches; + double span_ms, fps; +} PsxPresentCadence; +/* Host API completion intervals, shared by all backends; no monitor-display + * guarantee. Bounded passive snapshot. reset is consumed by the presenter. */ +void psx_video_telemetry_cadence(int window_ms, int reset, PsxPresentCadence *out); +int psx_video_telemetry_cadence_json(int window_ms, int reset, char *out, int capacity); +/* Explicit monotonic host microseconds for adapters and deterministic tests. */ +void psx_video_telemetry_present_at(int result, uint64_t now_us); + /* Emulation-owner settings after runtime mod overrides, sampled every VBlank. * Actual allocated scale/dimensions come from the separate backend snapshot. */ typedef struct PsxVideoConfigTelemetry { diff --git a/runtime/include/ws_cull_edge.h b/runtime/include/ws_cull_edge.h index 43ea28cb..0307f1ab 100644 --- a/runtime/include/ws_cull_edge.h +++ b/runtime/include/ws_cull_edge.h @@ -47,6 +47,26 @@ * The `lui` moves to (W + m) << 16 and the `bltz` is taken only while * SX < -m. bltz_sites does not fit this `bltz`: it compares the whole * register with -m, and every negative `SX << 16` is far below that. + * `slti rt, x, 0` on the same `SX << 16` value is the same sign test as a + * flag instead of a branch, and screen_x_sites widens it identically. + * + * [[widescreen.cull.sxy]] -- the all-vertices trivial reject over packed GTE + * SXY words (Y in the high half, X in the low half), folded with AND: + * + * and r, v0, mask ; mask holds the sign bits 0x80008000 + * and r, v1, r + * and r, v2, r ; (and r, v3, r for a quad) + * bne r, zero, reject + * + * Bit 31 of r survives only if every Y is negative (all above the screen) + * and bit 15 only if every X is negative (all left of it). Each AND of the + * chain becomes psx_ws_sxy_and_value(acc, v, m): the vanilla AND, except that + * bit 15 also needs this vertex's X < -m. The fold then rejects to the left + * only when every vertex is left of the revealed edge. Every other bit, + * including the vertical bit 31, is the vanilla AND, and at m == 0 the whole + * word is the vanilla AND. Each step reads its operands when the guest's + * own AND does, so the chain stays exact whatever the guest does between + * steps. * ========================================================================== */ #include @@ -92,6 +112,17 @@ static inline int psx_ws_cull_bltz_hi_value(uint32_t v, int32_t m) return (int32_t)v < -((m > 0 ? m : 0) * 65536); } +/* One AND of a [[widescreen.cull.sxy]] fold: `acc & sxy`, keeping bit 15 (X + * sign) only while this vertex's X (low half of `sxy`) is left of -m. */ +static inline uint32_t psx_ws_sxy_and_value(uint32_t acc, uint32_t sxy, + int32_t m) +{ + const uint32_t r = acc & sxy; + if (m <= 0 || !(r & 0x8000u)) return r; + if ((int32_t)(int16_t)(uint16_t)sxy < -m) return r; + return r & ~0x8000u; +} + #ifdef __cplusplus } #endif diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 701efde9..29a49786 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -418,7 +418,6 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/render_pass_frame.c ${PSXRECOMP_ROOT}/runtime/src/dynamic_resolution.c ${PSXRECOMP_ROOT}/runtime/src/host_time.c - ${PSXRECOMP_ROOT}/runtime/src/input_replay.c ${PSXRECOMP_ROOT}/runtime/src/quality_tier.c ${PSXRECOMP_ROOT}/runtime/src/quality_probe.c ${PSXRECOMP_ROOT}/runtime/src/quality_presets.cpp @@ -503,6 +502,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/autocompile.c ${PSXRECOMP_ROOT}/runtime/src/code_provider.c ${PSXRECOMP_ROOT}/runtime/src/event_ring.c + ${PSXRECOMP_ROOT}/runtime/src/func_override.c ${PSXRECOMP_ROOT}/runtime/src/game_options.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_speed.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_pgxp.c diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index c776cc2b..abca84bd 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8778,6 +8778,19 @@ static void handle_bloom(int id, const char *json) bi.gen_us_mean, (unsigned long long)bi.reused); } +/* post_shader: {"cmd":"post_shader"} reads the user chain (path, load outcome, live + * uniforms); "path" sets it, "" turns it off, the same path reloads it (shader edits). */ +static void handle_post_shader(int id, const char *json) +{ + char path[512]; + if (json_get_str(json, "path", path, sizeof path)) gl_renderer_set_post_shader(path); + char info[1536]; + if (!gl_renderer_post_shader_debug(info, (int)sizeof info)) { + send_err(id, "post shader diagnostics exceed response capacity"); return; + } + send_fmt("{\"id\":%d,\"ok\":true,\"loaded\":%d,%s}", id, gl_renderer_post_shader_loaded(), info); +} + static void handle_post_aa(int id, const char *json) { int mode = json_get_int(json, "mode", -1); @@ -9013,9 +9026,6 @@ static void handle_render_pass_stats(int id, const char *json) "\"last_pass_ms\":%.3f,\"avg_pass_ms\":%.3f," "\"avg_begin_ms\":%.3f,\"avg_guest_ms\":%.3f," "\"avg_end_ms\":%.3f,\"avg_restore_ms\":%.3f," - "\"timing\":{\"count\":%llu,\"gl_begin_ms\":%.3f,\"checkpoint_ms\":%.3f," - "\"verify_before_ms\":%.3f,\"guest_ms\":%.3f,\"gl_end_ms\":%.3f," - "\"restore_ms\":%.3f,\"verify_after_ms\":%.3f,\"total_ms\":%.3f,\"max_ms\":%.3f}," "\"guest_cycles_last\":%llu,\"disabled\":%d," "\"promotions\":%llu,\"pass_presents\":%llu," "\"blended_presents\":%llu,\"expired\":%llu,\"late_presents\":%llu," @@ -9049,10 +9059,6 @@ static void handle_render_pass_stats(int id, const char *json) (unsigned long long)st.dropped[RENDER_PASS_DROP_OTHER], st.last_pass_ms, st.avg_pass_ms, st.avg_begin_ms, st.avg_guest_ms, st.avg_end_ms, st.avg_restore_ms, - (unsigned long long)st.timed_passes, st.total_gl_begin_ms, - st.total_checkpoint_ms, st.total_verify_before_ms, st.total_guest_ms, - st.total_gl_end_ms, st.total_restore_ms, st.total_verify_after_ms, - st.total_pass_ms, st.max_pass_ms, (unsigned long long)st.guest_cycles_last, st.disabled, (unsigned long long)gd[0], (unsigned long long)gd[1], (unsigned long long)gd[2], (unsigned long long)gd[3], @@ -9454,6 +9460,25 @@ static void handle_ws_aspect(int id, const char *json) } extern int psx_debug_display_aspect(int num, int den, int adaptive); +/* perf: host API completion cadence across the active presentation backend. */ +extern int psx_video_telemetry_cadence_json(int window_ms, int reset, char *out, int cap); +static void handle_perf(int id, const char *json) { + char buf[512]; + psx_video_telemetry_cadence_json(json_get_int(json, "window_ms", 0), json_get_int(json, "reset", 0), + buf, sizeof buf); + send_fmt("{\"id\":%d,\"ok\":true,%s}", id, buf); +} + +/* hd_uploads x= y= -- texture uploads covering that VRAM + * point (newest first): hash, size, fragment, whether the pack replaces it. */ +extern int gpu_hd_textures_debug_uploads_at(int x, int y, char *out, size_t cap); +static void handle_hd_uploads(int id, const char *json) { + static char buf[16384]; + int n = gpu_hd_textures_debug_uploads_at(json_get_int(json, "x", 0), json_get_int(json, "y", 0), + buf, sizeof buf); + send_fmt("{\"id\":%d,\"ok\":true,\"count\":%d,\"uploads\":[%s]}", id, n, buf); +} + static void handle_display_aspect(int id, const char *json) { int num=json_get_int(json,"num",-1), den=json_get_int(json,"den",-1); int adaptive=json_get_int(json,"adaptive",0); @@ -11223,6 +11248,61 @@ static void handle_wide_full(int id, const char *json) id, path, W, H); } +/* Function-override tier introspection (func_override.h): count + per-entry + * {id, addr, calls, guard_misses}. calls == 0 = never reached (wrong address + * or path never ran); guard_misses = consults declined by the residency + * guard. */ +static void handle_func_override(int id, const char *json) +{ + extern int func_override_count(void); + extern int func_override_get_ex(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out, + uint64_t *guard_misses_out, + int *guarded_out, int32_t *credit_out); + (void)json; + char buf[16 * 1024]; + int n = snprintf(buf, sizeof(buf), + "{\"id\":%d,\"ok\":true,\"count\":%d,\"overrides\":[", + id, func_override_count()); + for (int i = 0; i < func_override_count(); i++) { + char oid[64]; + uint32_t addr = 0; + uint64_t calls = 0, misses = 0; + int guarded = 0; + int32_t credit = 0; + char creditstr[16]; + if (!func_override_get_ex(i, oid, sizeof(oid), &addr, &calls, + &misses, &guarded, &credit)) + break; + /* credit: the declared cycle policy — a number (fixed per-call + * charge) or the string "self" (the body charges its own). */ + if (credit < 0) + snprintf(creditstr, sizeof(creditstr), "\"self\""); + else + snprintf(creditstr, sizeof(creditstr), "%d", credit); + /* Clamp before advancing: snprintf returns the length it WOULD have + * written, so on truncation an unclamped n exceeds sizeof(buf) and + * the next sizeof(buf) - n underflows to a huge size_t. The 256-byte + * headroom check below keeps that unreachable at today's caps, but + * raising FO_MAX_ID or FO_MAX_OVERRIDES must not silently turn this + * into an overflow. */ + const int w = snprintf(buf + n, sizeof(buf) - (size_t)n, + "%s{\"id\":\"%s\",\"addr\":\"0x%08X\",\"calls\":%llu," + "\"guard_misses\":%llu,\"guarded\":%d,\"credit\":%s}", + i ? "," : "", oid, addr, + (unsigned long long)calls, (unsigned long long)misses, + guarded, creditstr); + if (w < 0) break; + n += w; + if ((size_t)n >= sizeof(buf) - 256) { + n = (int)(sizeof(buf) - 256); + break; + } + } + snprintf(buf + n, sizeof(buf) - (size_t)n, "]}"); + send_fmt("%s", buf); +} + static void handle_vram_peek(int id, const char *json) { int x = json_get_int(json, "x", 0); @@ -16322,6 +16402,8 @@ static const CmdEntry s_commands[] = { { "kernel_bless", handle_kernel_bless }, { "ws_aspect", handle_ws_aspect }, { "display_aspect", handle_display_aspect }, + { "perf", handle_perf }, + { "hd_uploads", handle_hd_uploads }, { "window_size", handle_window_size }, { "ws_nw", handle_ws_nw }, { "debug_key", handle_debug_key }, @@ -16346,6 +16428,7 @@ static const CmdEntry s_commands[] = { { "mmx6_freshfix", handle_mmx6_freshfix }, { "mem_words", handle_mem_words }, { "vram_peek", handle_vram_peek }, + { "func_override", handle_func_override }, { "gl_coh_ring", handle_gl_coh_ring }, { "gl_present_ring", handle_gl_present_ring }, { "present_ring", handle_present_ring }, @@ -16354,6 +16437,7 @@ static const CmdEntry s_commands[] = { { "frame_perf", handle_frame_perf }, { "batch_sites", handle_batch_sites }, { "post_aa", handle_post_aa }, + { "post_shader", handle_post_shader }, { "fmv_chroma", handle_fmv_chroma }, { "texture_lod", handle_texture_lod }, { "accurate_blending", handle_accurate_blending }, diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 0d8856fa..22179754 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -576,6 +576,12 @@ static inline uint32_t fetch_word(uint32_t phys) { | ((uint32_t)ram[phys + 3] << 24); } +/* Word reader for the [[widescreen.cull.sxy]] whole-fold check: the fold + * words around an interpreted PC are in main RAM like the PC itself. */ +static uint32_t sxy_fold_word(uint32_t phys) { + return phys < 0x00800000u ? fetch_word(phys) : 0xFFFFFFFFu; +} + /* Widescreen render-funnel cull detection for the interpreter ([widescreen.cull] * auto_screen_x). A `sltiu …,0x140/0x141` is widened ONLY when its enclosing * render function also carries a `sltiu …,0xE0/0xF1` (the GTE per-vertex trivial- @@ -1137,7 +1143,13 @@ enum { XSITE_INTERP = 0, XSITE_DD = 1 }; /* XOP_RES path codes (in the `site` field). */ enum { XRES_EC_BAIL = 2, XRES_EC_PC = 3, XRES_EC_CONTRACT = 4, XRES_EC_RET = 5, XRES_OV_BAIL = 6, XRES_OV_PC = 7, XRES_OV_CONTRACT = 8, XRES_OV_RET = 9, - XRES_NONLOCAL = 10, XRES_PCCHAIN = 11, XRES_UNDECODABLE = 12 }; + XRES_NONLOCAL = 10, XRES_PCCHAIN = 11, XRES_UNDECODABLE = 12, + /* Appended, NOT slotted into consult order: unlike CRES (which had a + * reserved gap at 6) this enum is dense, so inserting would renumber + * the codes above and invalidate every previously captured xprobe + * trace. The override tier is consulted FIRST at both call sites; the + * wire value carries no ordering meaning. */ + XRES_OVERRIDE = 13 }; int g_mixed_depth = 0; /* best-effort interp→compiled nesting depth; reset per frame */ @@ -1786,6 +1798,37 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->pc = target; CRET(CRES_PCCHAIN, 1); } + /* Function-override tier (func_override.h): consulted BEFORE + * every game code backend, matching psx_dispatch_impl. It must + * precede interp_enter_compiled, not follow it: that tier calls + * psx_dispatch_game_compiled -> entry->fn(cpu) directly and + * never re-enters psx_dispatch_impl, so a hook consulted after + * it is unreachable for any override on a statically-compiled + * function called from interpreted code. Registration would + * still succeed and the func_override inventory would still + * list the entry, so the miss is invisible — the same + * registration-is-not-coverage failure the overlay-native and + * pc-chain tiers below already had. Handled => the override + * completed against guest state; same call contract as a + * compiled callee. */ + { + extern int (*g_psx_func_override_hook)(CPUState *cpu, + uint32_t phys); + if (g_psx_func_override_hook) { + cpu->pc = 0; + if (g_psx_func_override_hook(cpu, target & 0x1FFFFFFFu)) { + if (g_psx_call_bail) CRET(CRES_OVERRIDE, 1); + if (cpu->pc != 0) CRET(CRES_OVERRIDE, 1); + if (rd == 0 || rd == 31) { + if (psx_call_contract(cpu, return_pc, site_sp)) + CRET(CRES_OVERRIDE, 1); + } + { int _r = dirty_ram_finish_call_return(cpu, return_pc, + next_pc_out); + CRET(CRES_OVERRIDE | (_r ? 0x100u : 0u), _r); } + } + } + } #ifdef PSX_HAS_GAME_DISPATCH cpu->pc = 0; if (interp_enter_compiled(cpu, target)) { @@ -1930,7 +1973,15 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, } case 0x24: { /* AND */ uint32_t a = cpu->gpr[rs], b = cpu->gpr[rt]; - cpu->gpr[rd] = a & b; + /* [[widescreen.cull.sxy]] fold AND: the same helper and the same + * whole-fold word check as the recompiled emit. */ + const uint32_t sxy_v = g_psx_ws_sxy_cull_n > 0 + ? psx_ws_sxy_cull_vertex_reg(pc, insn, sxy_fold_word) : 0u; + if (sxy_v != 0u) + cpu->gpr[rd] = psx_ws_cull_sxy_and(rs == sxy_v ? b : a, + cpu->gpr[sxy_v]); + else + cpu->gpr[rd] = a & b; psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, b); cpu->gpr[0] = 0; return 0; @@ -2017,6 +2068,27 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, /* slice / lockstep-replay / guest span: plain transfer, never execute the * callee (a span's runner calls it with the full trampoline). */ if (g_precise_mode || g_ls_replay_active || span_forced(pc & 0x1FFFFFFFu)) { cpu->pc = target; return 1; } + /* Function-override tier: same placement and contract as the JALR + * site above — BEFORE interp_enter_compiled, so an override on a + * statically-compiled callee is reachable from interpreted code + * (reserved CRES_OVERRIDE slot). */ + { + extern int (*g_psx_func_override_hook)(CPUState *cpu, + uint32_t phys); + if (g_psx_func_override_hook) { + cpu->pc = 0; + if (g_psx_func_override_hook(cpu, target & 0x1FFFFFFFu)) { + if (g_psx_call_bail) { XRES(XRES_OVERRIDE); return 1; } + if (cpu->pc != 0) { XRES(XRES_OVERRIDE); return 1; } + if (psx_call_contract(cpu, return_pc, site_sp)) { + XRES(XRES_OVERRIDE); return 1; + } + XRES(XRES_OVERRIDE); + return dirty_ram_finish_call_return(cpu, return_pc, + next_pc_out); + } + } + } #ifdef PSX_HAS_GAME_DISPATCH cpu->pc = 0; if (interp_enter_compiled(cpu, target)) { @@ -2169,6 +2241,10 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->gpr[rt] = kept; else if (psx_ws_is_cull_depth_site(pc)) cpu->gpr[rt] = ((int32_t)cpu->gpr[rs] < psx_ws_depth_bound(simm)) ? 1u : 0u; + /* screen_x_sites on `slti rt, x, 0` with `x = SX << 16`: the bltz + * sign test as a flag (same order as the recompiled emit). */ + else if (imm == 0u && psx_ws_is_cull_screen_x_site(pc)) + cpu->gpr[rt] = (uint32_t)psx_ws_cull_bltz_hi(cpu->gpr[rs]); /* Widescreen render-funnel RIGHT-edge widen (auto_screen_x) for the * signed min/max funnel idiom (`slti v, minSX, W`) — the paired left * edge is the bltz above. Identity at 4:3 (margin 0). */ diff --git a/runtime/src/duckstation_texture_pack.cpp b/runtime/src/duckstation_texture_pack.cpp index e577638f..3ca48b1b 100644 --- a/runtime/src/duckstation_texture_pack.cpp +++ b/runtime/src/duckstation_texture_pack.cpp @@ -1,5 +1,6 @@ #include "duckstation_texture_pack.h" #include "texture_image_decode.h" +#include "texture_pack_preload.hpp" #define XXH_INLINE_ALL #include "../third_party/xxhash.h" #include "png_write.h" @@ -183,6 +184,7 @@ struct Decode { std::string path; std::shared_ptr image; uint64_t use = 0; + bool pinned = false; /* preloaded: never evicted */ }; struct Dump { fs::path path; std::shared_ptr image; }; struct DumpHistory { std::unordered_set names; std::deque order; }; @@ -231,6 +233,7 @@ struct Worker { size_t dump_bytes = 0; uint64_t tick = 0; uint64_t decoded_images = 0, decode_evictions = 0; + size_t pinned_count = 0; /* preloaded entries: outside the 512-entry cap */ bool stop = false; bool dump_busy = false; std::string dump_error; @@ -243,7 +246,7 @@ struct Worker { while (used + incoming > budget) { auto candidate = decoded.end(); for (auto it = decoded.begin(); it != decoded.end(); ++it) - if (it->second.status == DecodeStatus::Ready && + if (it->second.status == DecodeStatus::Ready && !it->second.pinned && (candidate == decoded.end() || it->second.use < candidate->second.use)) candidate = it; if (candidate == decoded.end()) break; used -= candidate->second.image->rgba.size(); @@ -827,6 +830,29 @@ void duck_texture_pack_invalidate(DuckTexturePack* p, uint16_t x, uint16_t y, ui const unsigned w0 = std::min(unsigned(w), 1024 - px), h0 = std::min(unsigned(h), 512 - py); const std::array regions{{{px,py,w0,h0},{0,py,unsigned(w)-w0,h0}, {px,0,w0,unsigned(h)-h0},{0,0,unsigned(w)-w0,unsigned(h)-h0}}}; + /* Under HD authority every native draw lands here, almost always on the + * frame buffer, which no tracked texture overlaps: leave without touching + * (or allocating) anything when no residency or cache entry intersects. */ + const auto touches = [&](const Rect& x) { + for (const auto& r : regions) if (r.w && r.h && intersects(x,r)) return true; + return false; + }; + bool hit = false; + for (const auto& upload : p->uploads) if (touches(upload.active)) { hit = true; break; } + if (!hit) for (const auto& page : p->pages) { + if (touches(page.source->rect)) { hit = true; break; } + for (const auto& record : page.source->records) + if (record.depth < 2 && touches({record.clut_x,record.clut_y,record.palette_size,1})) { hit = true; break; } + if (hit) break; + } + if (!hit) for (const auto& v : p->rect_cache) if (touches(v.rect)) { hit = true; break; } + if (!hit) for (const auto& c : p->match_cache) { + const auto& q = c.query; + const Rect page{q.page_x,q.page_y,256 / expansion(q.depth),256}; + const Rect clut{q.clut_x,q.clut_y,std::min(q.depth == 0 ? 16u : 256u,1024u-q.clut_x),1}; + if (touches(page) || (q.depth < 2 && touches(clut))) { hit = true; break; } + } + if (!hit) return; ++p->revision; bool released = false; /* a residency record dropped or split */ for (const auto& r : regions) if (r.w && r.h) { @@ -1118,11 +1144,11 @@ int duck_texture_pack_request_decode(DuckTexturePack* p, uint64_t id) { } if (!w.budget) return HD_TEXTURE_LOOKUP_ERROR; if (w.decode_queue.size() >= kMaxDecodeQueue) return HD_TEXTURE_LOOKUP_NONE; - if (w.decoded.size() >= 512) { + if (w.decoded.size() - w.pinned_count >= 512) { auto victim = w.decoded.end(); for (auto item = w.decoded.begin(); item != w.decoded.end(); ++item) if ((item->second.status == DecodeStatus::Ready || item->second.status == DecodeStatus::Failed) && - (victim == w.decoded.end() || item->second.use < victim->second.use)) victim = item; + !item->second.pinned && (victim == w.decoded.end() || item->second.use < victim->second.use)) victim = item; if (victim == w.decoded.end()) return HD_TEXTURE_LOOKUP_NONE; if (victim->second.image) w.used -= victim->second.image->rgba.size(); w.decoded.erase(victim); @@ -1132,6 +1158,105 @@ int duck_texture_pack_request_decode(DuckTexturePack* p, uint64_t id) { w.wake.notify_one(); return HD_TEXTURE_LOOKUP_NONE; } catch (...) { return HD_TEXTURE_LOOKUP_ERROR; } } +int duck_texture_pack_preload(DuckTexturePack* p, size_t max_bytes, unsigned max_threads, + HdTexturePreloadResult* out) { + HdTexturePreloadResult r{}; + if (out) *out = r; + if (!p) return -1; + try { + namespace tp = psx_texpreload; + Worker& w = p->worker; + std::vector ids; + size_t per_image = 0; + { + std::lock_guard lock(w.mutex); + per_image = w.budget; + /* The cap and result describe the pack's total pinned residency, + * including earlier successful calls or publication prefixes. */ + for (const auto& item : w.decoded) + if (item.second.pinned && item.second.image) + r.bytes += item.second.image->rgba.size(); + for (size_t i = 0; i < p->entries.size(); ++i) { + if (p->entries[i].ambiguous) continue; + ++r.requested; + const auto it = w.decoded.find(i + 1); + if (it != w.decoded.end() && it->second.pinned) ++r.loaded; + } + if (r.bytes > max_bytes) { + r.failed = r.requested - r.loaded; + if (out) *out = r; + return -1; /* A smaller cap cannot discard live pinned data. */ + } + for (size_t i = 0; i < p->entries.size(); ++i) { + if (p->entries[i].ambiguous) continue; + const auto it = w.decoded.find(i + 1); + if (it == w.decoded.end() || !it->second.pinned) ids.push_back(i + 1); + } + } + std::vector> images(ids.size()); + std::vector status(ids.size(), tp::kPending); + tp::Budget budget(max_bytes - r.bytes); + r.worker_errors = static_cast(tp::run(ids.size(), max_threads, [&](size_t k) { + status[k] = tp::kFailed; + const std::string& path = p->entries[ids[k] - 1].path; + std::ifstream input(fs::u8path(path), std::ios::binary | std::ios::ate); + const auto length = input ? input.tellg() : std::streampos(-1); + if (length <= 0 || static_cast(length) > kMaxEncoded) return; + std::vector encoded(static_cast(length)); + input.seekg(0); + if (!input.read(reinterpret_cast(encoded.data()), length)) return; + TextureImageProbe probe; std::string error; + if (!texture_image_probe(encoded.data(), encoded.size(), per_image, probe, error)) return; + const size_t need = static_cast(probe.width) * probe.height * 4u; + if (!budget.reserve(need)) { status[k] = tp::kOverBudget; return; } + struct Reservation { + tp::Budget& b; size_t n; bool keep = false; + ~Reservation() { if (!keep) b.release(n); } + } reservation{budget, need}; + DecodedTextureImage decoded; + if (!texture_image_decode(encoded.data(), encoded.size(), need, decoded, error) || + decoded.rgba.size() != need) return; + auto image = std::make_shared(); image->w = decoded.width; image->h = decoded.height; + image->rgba = std::move(decoded.rgba); + images[k] = std::move(image); + reservation.keep = true; + status[k] = tp::kLoaded; + })); + std::lock_guard lock(w.mutex); + for (size_t k = 0; k < ids.size(); ++k) { + if (status[k] == tp::kOverBudget) { ++r.over_budget; continue; } + if (status[k] != tp::kLoaded) continue; + auto it = w.decoded.find(ids[k]); + if (it != w.decoded.end()) { + /* Already pinned, or an on-demand decode in flight: leave it. */ + if (it->second.pinned || it->second.status == DecodeStatus::Queued || + it->second.status == DecodeStatus::Loading) continue; + } + const size_t previous_bytes = it != w.decoded.end() && it->second.image + ? it->second.image->rgba.size() : 0; + Decode entry; + /* Prepare throwing metadata before touching an existing entry. */ + entry.path = p->entries[ids[k] - 1].path; + entry.image = std::move(images[k]); entry.status = DecodeStatus::Ready; + entry.pinned = true; entry.use = ++w.tick; + const size_t bytes = entry.image->rgba.size(); + if (it == w.decoded.end()) w.decoded.emplace(ids[k], std::move(entry)); + else it->second = std::move(entry); + w.used -= previous_bytes; + r.bytes += bytes; + ++w.pinned_count; ++r.loaded; + ++w.decoded_images; + tp::after_publish(r.loaded); + } + r.failed = r.requested - r.loaded - r.over_budget; + if (out) *out = r; + return r.loaded == r.requested ? 1 : 0; + } catch (...) { + r.failed = r.requested - r.loaded - r.over_budget; + if (out) *out = r; /* Account the pinned prefix even on publication failure. */ + return -1; + } +} int duck_texture_pack_acquire_decoded(DuckTexturePack* p, uint64_t id, DuckTexturePixels* out) { if (out) *out = {}; if (!p || !out) return HD_TEXTURE_LOOKUP_ERROR; diff --git a/runtime/src/frame_interpolation.c b/runtime/src/frame_interpolation.c index de760e07..0b4576ed 100644 --- a/runtime/src/frame_interpolation.c +++ b/runtime/src/frame_interpolation.c @@ -45,7 +45,7 @@ int frame_interpolation_schedule_begin_phase(FrameInterpolationSchedule *schedul * Normal guest work lands after the prior deadline but before the next one; * retaining the old anchor makes that work consume the pacing budget. */ if (schedule->source_deadline <= 0.0 || - (!schedule->queued && now_d + source_period < schedule->source_deadline) || + now_d + source_period < schedule->source_deadline || now_d > schedule->source_deadline + source_period * 4.0) { schedule->frame_start = now_d; schedule->source_deadline = now_d + source_period; diff --git a/runtime/src/func_override.c b/runtime/src/func_override.c new file mode 100644 index 00000000..97985f6b --- /dev/null +++ b/runtime/src/func_override.c @@ -0,0 +1,296 @@ +/* func_override.c — replace a guest function with hand-written C. + * See runtime/include/func_override.h. + * + * CLAUDE.md rule 3 (no printf, no logs): this module prints nothing. State + * is exposed through the accessors and surfaced by the `func_override` TCP + * command. Registration errors are returned as codes for the caller to + * report. + */ + +#include "func_override.h" + +#include + +#include "cpu_state.h" +#include "psx_cycles.h" + +/* Residency-guard reads go through the UNTRACED peek, never psx_read_word. + * psx_read_word is a traced accessor (memory.c): under lockstep it feeds + * ls_read_hook, under lockstep replay it RETURNS the replayed value instead + * of RAM, and under DuckStation recording it calls ds_note_read. The guard + * is a host-side residency check, not a guest memory access — routing it + * through the traced path injects phantom reads the oracle side never + * performs (dirtying every divergence comparison) and makes the guard + * compare replayed data rather than resident bytes. The peek keeps the full + * address decode, so a guard on a non-RAM address stays correct. */ +extern uint32_t psx_peek_word_untraced(uint32_t addr); + +/* Set by us, read at the top of every psx_dispatch_impl (emitted by + * recompiler/src/full_function_emitter.cpp) and at the interpreter's + * JAL/JALR call-resolution tiers (dirty_ram_interp.c). NULL until install, + * so a build with no overrides dispatches exactly as before. */ +int (*g_psx_func_override_hook)(CPUState *cpu, uint32_t phys) = NULL; + +typedef struct { + char id[FO_MAX_ID]; + uint32_t phys; /* normalised: KSEG bits masked off */ + FuncOverrideFn fn; + FuncOverrideInvoker invoke; + const void *context; + uint64_t calls; + uint64_t guard_misses; + uint32_t guard[FO_MAX_GUARD_WORDS]; + int n_guard; /* 0 = unguarded */ + int package; /* 1 = armed by a mod package plan */ + int32_t credit; /* >= 0 charged per handled call; + FO_CREDIT_SELF = body self-charges */ +} Entry; + +static Entry s_entries[FO_MAX_OVERRIDES]; +static int s_count = 0; + +/* call_original support: the address whose hook is currently executing + * (for redial) and a one-shot bypass armed by func_override_call_original. + * Dispatch is single-threaded; a nested override (an override whose + * guest_call reaches another overridden function) saves/restores around + * the inner consult, so plain statics are correct here. */ +static uint32_t s_active_phys = 0; +static uint32_t s_bypass_phys = 0; + +static uint32_t normalise(uint32_t addr) { return addr & 0x1FFFFFFFu; } + +static Entry *find(uint32_t phys) +{ + for (int i = 0; i < s_count; i++) + if (s_entries[i].phys == phys) + return &s_entries[i]; + return NULL; +} + +static int add_common(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *guard, int n_guard, int package, + int32_t credit) +{ + if (!fn) return FO_ERR_ARGS; + if (n_guard < 0 || n_guard > FO_MAX_GUARD_WORDS) return FO_ERR_ARGS; + if (n_guard > 0 && !guard) return FO_ERR_ARGS; + /* The credit is a required statement of timing intent (header, CYCLE + * ACCOUNTING): a fixed per-call charge, or FO_CREDIT_SELF. Any other + * negative value is a typo, not a policy. */ + if (credit < FO_CREDIT_SELF) return FO_ERR_ARGS; + if (s_count >= FO_MAX_OVERRIDES) return FO_ERR_FULL; + + const uint32_t phys = normalise(addr); + /* phys 0 is the "not inside an override" sentinel for s_active_phys and + * the "no bypass armed" sentinel for s_bypass_phys, so an override there + * would silently break func_override_call_original. No real function + * lives at guest 0 anyway. */ + if (phys == 0) return FO_ERR_ARGS; + /* Two overrides on one address is always a mistake, and a silent + * last-wins would be untraceable. Refuse it. */ + if (find(phys)) return FO_ERR_DUPLICATE; + + Entry *e = &s_entries[s_count]; + memset(e, 0, sizeof(*e)); + if (id) { + strncpy(e->id, id, FO_MAX_ID - 1); + e->id[FO_MAX_ID - 1] = '\0'; + } + e->phys = phys; + e->fn = fn; + for (int i = 0; i < n_guard; i++) e->guard[i] = guard[i]; + e->n_guard = n_guard; + e->package = package ? 1 : 0; + e->credit = credit; + s_count++; + return FO_OK; +} + +int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn, + int32_t credit) +{ + return add_common(id, addr, fn, NULL, 0, 0, credit); +} + +int func_override_add_guarded(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, + int32_t credit) +{ + if (n_words < 1) return FO_ERR_ARGS; + return add_common(id, addr, fn, expected_words, n_words, 0, credit); +} + +int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, + int32_t credit) +{ + if (n_words < 0 || n_words > FO_MAX_GUARD_WORDS) return FO_ERR_ARGS; + return add_common(id, addr, fn, n_words ? expected_words : NULL, n_words, + 1, credit); +} + +int func_override_add_package_scoped(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, int32_t credit, + FuncOverrideInvoker invoke, const void *context) +{ + if (!invoke || !context) return FO_ERR_ARGS; + const int rc = func_override_add_package(id,addr,fn,expected_words,n_words,credit); + if (rc == FO_OK) { + Entry *entry = &s_entries[s_count-1]; + entry->invoke = invoke; + entry->context = context; + } + return rc; +} + +int func_override_active(void) { return s_active_phys != 0; } + +/* The hook. Runs on EVERY dispatch, so the common path — nothing registered + * for this address — must stay cheap. A linear scan over a handful of + * entries beats a hash for realistic counts and keeps the code honest; + * revisit if a game ever registers hundreds. */ +static int hook(CPUState *cpu, uint32_t phys) +{ + for (int i = 0; i < s_count; i++) { + Entry *e = &s_entries[i]; + if (e->phys != phys) continue; + /* One-shot bypass: func_override_call_original re-dispatched this + * address; let the original backend take it exactly once. */ + if (s_bypass_phys == phys) { + s_bypass_phys = 0; + return 0; + } + /* Residency guard: wrong bytes at the address (overlay swapped, + * page reused) means the function this override targets is not + * resident — decline, never corrupt. */ + if (e->n_guard) { + const uint32_t base = 0x80000000u | phys; + int miss = 0; + for (int w = 0; w < e->n_guard; w++) + if (psx_peek_word_untraced(base + (uint32_t)(w * 4)) != + e->guard[w]) { + miss = 1; + break; + } + if (miss) { + e->guard_misses++; + return 0; + } + } + /* Count the CONSULT, not just the handled case: an override that + * declines still proves the address was reached through a hooked + * path, which is exactly what a diagnostic probe needs (calls == 0 + * means "this call never crosses a hook", the bypass class of bug + * that hid the interp local-chain gap). */ + e->calls++; + { + const uint32_t saved_active = s_active_phys; + s_active_phys = phys; + const int handled = (e->invoke ? e->invoke(cpu,e->fn,e->context) : e->fn(cpu)) ? 1 : 0; + s_active_phys = saved_active; + /* Charge the declared credit only when HANDLED: a decline runs + * the original, which accrues its exact cycles by executing. + * FO_CREDIT_SELF (< 0) and 0 charge nothing here — SELF bodies + * called psx_advance_cycles themselves, and guest work the body + * ran via func_override_guest_call / call_original has already + * self-charged through the normal backends. */ + if (handled && e->credit > 0) + psx_advance_cycles((uint32_t)e->credit); + return handled; + } + } + return 0; +} + +void func_override_guest_call(CPUState *cpu, uint32_t target, uint32_t site_ra) +{ + /* Spoof $ra to the original call site so callees, fntrace and crash + * forensics see authentic values, then restore. psx_dispatch_call runs + * the callee to completion (pc == site_ra with the caller's $sp). */ + extern void psx_dispatch_call(CPUState *cpu, uint32_t addr, + uint32_t return_addr); + const uint32_t saved_ra = cpu->gpr[31]; + cpu->gpr[31] = site_ra; + psx_dispatch_call(cpu, target, site_ra); + cpu->gpr[31] = saved_ra; +} + +void func_override_call_original(CPUState *cpu) +{ + if (!s_active_phys) return; /* not inside an override — nothing to do */ + const uint32_t phys = s_active_phys; + const uint32_t saved = s_bypass_phys; + s_bypass_phys = phys; + /* KSEG0 form: game text dispatches with the 0x8000_0000 view; the hook + * itself keys on the normalised address either way. Return to the + * override's caller frame: the original must observe the same $ra the + * override was entered with. */ + func_override_guest_call(cpu, 0x80000000u | phys, cpu->gpr[31]); + s_bypass_phys = saved; +} + +void func_override_install(void) +{ + /* Leave the hook NULL when empty: dispatch then matches a build without + * the tier exactly, which keeps "overrides cost nothing" literally + * true. */ + g_psx_func_override_hook = (s_count > 0) ? hook : NULL; +} + +int func_override_reset_package_armed(void) +{ + /* Drop every package-armed entry and compact, keeping direct + * registrations. Direct ones are the game's own always-on faithful + * reimplementations: not player-toggleable, identical in both peers' + * builds, so netplay has no reason to disarm them. Package-armed ones + * ARE player-selected, so a cleared plan must leave no trace of them — + * without this the entry survives in the table with the hook still + * installed and keeps firing after the vanilla-session banner prints. + * + * Any in-flight bypass/active marker refers to an entry that may be + * disappearing, so clear both rather than leave a dangling address. */ + int removed = 0; + int w = 0; + for (int i = 0; i < s_count; i++) { + if (s_entries[i].package) { removed++; continue; } + if (w != i) s_entries[w] = s_entries[i]; + w++; + } + s_count = w; + if (removed) { + s_active_phys = 0; + s_bypass_phys = 0; + func_override_install(); /* follows s_count back to NULL if empty */ + } + return removed; +} + +int func_override_count(void) { return s_count; } + +int func_override_get(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out) +{ + return func_override_get_ex(index, id_out, id_cap, addr_out, calls_out, + NULL, NULL, NULL); +} + +int func_override_get_ex(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out, + uint64_t *guard_misses_out, int *guarded_out, + int32_t *credit_out) +{ + if (index < 0 || index >= s_count) return 0; + const Entry *e = &s_entries[index]; + /* Bounded copy: the caller states its buffer size rather than being + * silently required to supply FO_MAX_ID bytes. Always NUL-terminated. */ + if (id_out && id_cap) { + strncpy(id_out, e->id, id_cap - 1); + id_out[id_cap - 1] = '\0'; + } + if (addr_out) *addr_out = e->phys; + if (calls_out) *calls_out = e->calls; + if (guard_misses_out) *guard_misses_out = e->guard_misses; + if (guarded_out) *guarded_out = e->n_guard; + if (credit_out) *credit_out = e->credit; + return 1; +} diff --git a/runtime/src/gl_postfx.inc b/runtime/src/gl_postfx.inc index 2b632835..1e652462 100644 --- a/runtime/src/gl_postfx.inc +++ b/runtime/src/gl_postfx.inc @@ -1,3 +1,8 @@ +#include "gte_postfx_projection.h" +#include "postfx_debug_json.h" +#ifndef PFX_NOW_TICKS +#define PFX_NOW_TICKS() SDL_GetPerformanceCounter() +#endif /* ---- Post-effect pipeline (docs/POST_EFFECTS.md) --------------------------- * One executor for every post effect on the composed game image: chains of * full-screen fragment passes (postfx_chain.h). Three stages, in order, each @@ -36,16 +41,20 @@ typedef struct { GLint fixed[PFX_LOC_FIXED]; GLint in[PFX_MAX_INPUTS]; GLint u[PFX_MAX_UNIFORMS]; + GLint time, proj; } PfxLocs; typedef struct { PfxChain chain; int loaded, failed; + uint64_t started_at; GLuint prog[PFX_MAX_PASSES]; PfxLocs loc[PFX_MAX_PASSES]; GLuint tex[PFX_MAX_PASSES], fbo[PFX_MAX_PASSES]; GLuint dtex[PFX_MAX_PASSES], dfbo[PFX_MAX_PASSES]; /* source_down copies */ int dw[PFX_MAX_PASSES], dh[PFX_MAX_PASSES]; int tw[PFX_MAX_PASSES], th[PFX_MAX_PASSES]; + GLuint htex[PFX_MAX_PASSES], hfbo[PFX_MAX_PASSES]; /* `history` copies */ + int hw[PFX_MAX_PASSES], hh[PFX_MAX_PASSES], hist[PFX_MAX_PASSES]; } PfxStage; enum { PFX_STAGE_BLOOM, PFX_STAGE_USER, PFX_STAGE_AA, PFX_STAGE_HQ, PFX_NSTAGES }; static PfxStage s_pfx[PFX_NSTAGES]; @@ -78,6 +87,49 @@ static GLint s_mmap_uUv = -1, s_mmap_uS = -1, s_mmap_uSrc = -1, s_mmap_uDst = - * depth-stencil blit is needed (one costs the CPU milliseconds on macOS). */ typedef struct { GLuint rb, tex, fbo; int w, h; } PfxSurfMask; static PfxSurfMask s_smask[2]; +/* `depth`: each surface's depth-stencil renderbuffer is blitted into a depth + * texture of its size (dtex/dfbo), then mapped to the window as view depth sz + * (GTE screen z; PGXP writes zn = 1 - 512/(sz + 256)) in an R32F texture. */ +typedef struct { GLuint rb, dtex, dfbo; int w, h; } PfxSurfDepth; +static PfxSurfDepth s_sdepth[2]; +static GLuint s_depth_tex = 0, s_depth_fbo = 0, s_depth_map_prog = 0; +static GLint s_dmap_uUv = -1, s_dmap_uS = -1, s_dmap_uSrc = -1, s_dmap_uDst = -1; +static int s_depth_w = 0, s_depth_h = 0, s_depth_ok = 0; +static int s_pfx_depth_on = 0; /* a loaded chain reads `depth` */ +/* Live values for preset `uniform` lines, set by the title from game state + * (psx_host_set_post_uniform, host_overlay.h): a live value replaces the + * preset default of the same name in every pass that declares it. */ +enum { PFX_LIVE_MAX = 16 }; +typedef PfxDebugLive PfxLive; +static PfxLive s_pfx_live[PFX_LIVE_MAX]; +static int s_pfx_nlive = 0; +static SDL_SpinLock s_pfx_live_lock = 0; +void psx_host_set_post_uniform(const char *name, float x, float y, float z, float w) { + if (!name || !name[0] || strlen(name) >= sizeof s_pfx_live[0].name || + !isfinite(x) || !isfinite(y) || !isfinite(z) || !isfinite(w)) return; + SDL_LockSpinlock(&s_pfx_live_lock); + int i = 0; + while (i < s_pfx_nlive && strcmp(s_pfx_live[i].name, name)) i++; + if (i == s_pfx_nlive && s_pfx_nlive < PFX_LIVE_MAX) { + snprintf(s_pfx_live[i].name, sizeof s_pfx_live[i].name, "%s", name); + s_pfx_nlive++; + } + if (i < s_pfx_nlive) { + s_pfx_live[i].v[0] = x; s_pfx_live[i].v[1] = y; s_pfx_live[i].v[2] = z; s_pfx_live[i].v[3] = w; + } + SDL_UnlockSpinlock(&s_pfx_live_lock); +} +/* Last load outcome of the user chain, for the debug server. */ +static char s_pfx_user_status[192] = ""; +static int pfx_live_get(const char *name, float out[4]) { + int found = 0; + SDL_LockSpinlock(&s_pfx_live_lock); + for (int i = 0; i < s_pfx_nlive; i++) + if (!strcmp(s_pfx_live[i].name, name)) { memcpy(out, s_pfx_live[i].v, sizeof s_pfx_live[i].v); found = 1; break; } + SDL_UnlockSpinlock(&s_pfx_live_lock); + return found; +} + static int s_mask_w = 0, s_mask_h = 0; static int pfx_stage_on(int i) { @@ -102,22 +154,30 @@ static void pfx_stage_unload(PfxStage *st) { if (st->tex[i]) glDeleteTextures(1, &st->tex[i]); if (st->dfbo[i]) p_glDeleteFramebuffers(1, &st->dfbo[i]); if (st->dtex[i]) glDeleteTextures(1, &st->dtex[i]); + if (st->hfbo[i]) p_glDeleteFramebuffers(1, &st->hfbo[i]); + if (st->htex[i]) glDeleteTextures(1, &st->htex[i]); } + st->htex[i] = st->hfbo[i] = 0; st->hw[i] = st->hh[i] = 0; st->hist[i] = 0; st->prog[i] = st->tex[i] = st->fbo[i] = 0; st->tw[i] = st->th[i] = 0; st->dtex[i] = st->dfbo[i] = 0; st->dw[i] = st->dh[i] = 0; } pfx_chain_free(&st->chain); st->loaded = st->failed = 0; + st->started_at = 0; } /* Which stages read the mask decides whether draws keep it. */ static void pfx_mask_wanted_update(void) { + gte_postfx_projection_enable(postfx_active()); int want = 0; for (int i = 0; i < PFX_NSTAGES; i++) { if (!pfx_stage_on(i)) continue; if (i == PFX_STAGE_BLOOM) want = 1; /* the bloom preset reads it */ else if (s_pfx[i].loaded && s_pfx[i].chain.uses_mask) want = 1; } + s_pfx_depth_on = 0; + for (int i = 0; i < PFX_NSTAGES; i++) + if (pfx_stage_on(i) && s_pfx[i].loaded && s_pfx[i].chain.uses_depth) s_pfx_depth_on = 1; if (want != s_bcls_on) { s_bcls_on = want; pgxp_render_wanted_update(); } } @@ -135,26 +195,41 @@ static int pfx_stage_load(int i) { if (!ok) { fprintf(stdout, "psxrecomp: post effect '%s' not loaded: %s\n", i == PFX_STAGE_USER ? s_pfx_user_path : "built-in", err); + if (i == PFX_STAGE_USER) snprintf(s_pfx_user_status, sizeof s_pfx_user_status, "not loaded: %s", err); st->failed = 1; return 0; } st->loaded = 1; - if (i == PFX_STAGE_USER) + for (int p = 0; p < st->chain.npasses; p++) + for (int k = 0; k < st->chain.pass[p].ninputs; k++) + if (st->chain.pass[p].in[k].from == PFX_IN_HISTORY) st->hist[p] = 1; + if (i == PFX_STAGE_USER) { fprintf(stdout, "psxrecomp: post shader '%s' (%d passes%s)\n", s_pfx_user_path, st->chain.npasses, st->chain.uses_mask ? ", mask" : ""); + snprintf(s_pfx_user_status, sizeof s_pfx_user_status, "parsed: %d passes", st->chain.npasses); + } return 1; } static uint64_t s_pfx_builds = 0; /* program builds (tests) */ static GLuint pfx_build_pass(const PfxPass *ps) { s_pfx_builds++; - size_t n = strlen(ps->shader) + 512 + (size_t)ps->ninputs * 64; + size_t n = strlen(ps->shader) + 512 + (size_t)ps->ninputs * 64 + (size_t)ps->nuniforms * 64; char *src = (char *)malloc(n); if (!src) return 0; int k = snprintf(src, n, "#version 330\nin vec2 v_uv; out vec4 o;\n" - "uniform vec2 u_rcp; uniform vec2 u_size; uniform ivec2 u_org;\n"); + "uniform vec2 u_rcp; uniform vec2 u_size; uniform ivec2 u_org;\n" + "uniform float u_time; uniform vec4 u_proj;\n"); for (int i = 0; i < ps->ninputs; i++) k += snprintf(src + k, n - (size_t)k, "uniform sampler2D %s;\n", ps->in[i].sampler); + /* The preset's `uniform` lines are declared here unless the body declares + * them itself (the built-in shaders do). */ + static const char *const kTypes[] = { "float", "vec2", "vec3", "vec4" }; + for (int i = 0; i < ps->nuniforms && (size_t)k + 96 < n; i++) { + char decl[PFX_NAME + 16]; + snprintf(decl, sizeof decl, "%s %s;", kTypes[ps->u[i].count - 1], ps->u[i].name); + if (!strstr(ps->shader, decl)) k += snprintf(src + k, n - (size_t)k, "uniform %s\n", decl); + } snprintf(src + k, n - (size_t)k, "%s", ps->shader); GLuint prog = build_program(PRESENT_VS, src); free(src); @@ -188,7 +263,7 @@ static void pfx_rect_px(GLuint tex, float tex_w, float tex_h, int x, int y, int static void postfx_note_center(GLuint fbo, GLuint tex, float tex_w, float tex_h, int x, int y, int w, int h, int off, int wide_w) { s_mask_ctr_valid = 0; - if (!s_bcls_on || !fbo || !tex || wide_w <= 0) return; + if ((!s_bcls_on && !s_pfx_depth_on) || !fbo || !tex || wide_w <= 0) return; s_mask_ctr_rb = pfx_surface_rb(fbo); if (!s_mask_ctr_rb) return; pfx_rect_px(tex, tex_w, tex_h, x, y, w, h, s_mask_ctr, &s_mask_ctr_tw, &s_mask_ctr_th); @@ -204,7 +279,7 @@ static void postfx_note_source(GLuint fbo, GLuint tex, float tex_w, float tex_h, int x, int y, int w, int h) { s_mask_src_valid = 0; s_mask_ctr_valid = 0; - if (!s_bcls_on || !fbo || !tex || tex_w <= 0.0f || tex_h <= 0.0f) return; + if ((!s_bcls_on && !s_pfx_depth_on) || !fbo || !tex || tex_w <= 0.0f || tex_h <= 0.0f) return; s_mask_src_rb = pfx_surface_rb(fbo); if (!s_mask_src_rb) return; pfx_rect_px(tex, tex_w, tex_h, x, y, w, h, s_mask_src, &s_mask_src_tw, &s_mask_src_th); @@ -217,6 +292,7 @@ static void pfx_tex_alloc(GLuint *t, int w, int h, int fmt, int nearest) { glBindTexture(GL_TEXTURE_2D, *t); if (fmt == PFX_FMT_RGBA16F) glTexImage2D(GL_TEXTURE_2D, 0, 0x881A /*RGBA16F*/, w, h, 0, GL_RGBA, GL_FLOAT, NULL); else if (fmt == -1) glTexImage2D(GL_TEXTURE_2D, 0, 0x8229 /*R8*/, w, h, 0, 0x1903 /*RED*/, GL_UNSIGNED_BYTE, NULL); + else if (fmt == -2) glTexImage2D(GL_TEXTURE_2D, 0, 0x822E /*R32F*/, w, h, 0, 0x1903 /*RED*/, GL_FLOAT, NULL); else glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); @@ -309,6 +385,81 @@ static int pfx_mask_build(int fresh, int lw, int lh) { return s_mask_ok; } +/* Slot k: surface rb (tw x th) blitted into its depth texture. */ +static int pfx_surf_depth(int k, GLuint fbo, GLuint rb, int tw, int th) { + PfxSurfDepth *d = &s_sdepth[k]; + if (d->rb != rb || d->w != tw || d->h != th) { + if (!d->dtex) glGenTextures(1, &d->dtex); + glBindTexture(GL_TEXTURE_2D, d->dtex); + glTexImage2D(GL_TEXTURE_2D, 0, PSXGL_DEPTH24_STENCIL8, tw, th, 0, + 0x84F9 /*DEPTH_STENCIL*/, 0x84FA /*UNSIGNED_INT_24_8*/, NULL); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glBindTexture(GL_TEXTURE_2D, 0); + if (!d->dfbo) p_glGenFramebuffers(1, &d->dfbo); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, d->dfbo); + p_glFramebufferTexture2D(PSXGL_FRAMEBUFFER, PSXGL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, d->dtex, 0); + d->rb = rb; d->w = tw; d->h = th; + } + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, d->dfbo); + p_glBlitFramebuffer(0, 0, tw, th, 0, 0, tw, th, GL_DEPTH_BUFFER_BIT, GL_NEAREST); + return 1; +} +static void pfx_depth_map(int k, const int r[4], int x0, int x1, int lh) { + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_depth_fbo); + glViewport(x0, 0, x1 - x0, lh); + p_glUseProgram(s_depth_map_prog); + p_glActiveTexture(PSXGL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, s_sdepth[k].dtex); + p_glUniform1i(s_dmap_uS, 0); + p_glUniform4i(s_dmap_uSrc, r[0], r[1], r[2], r[3]); + p_glUniform4f(s_dmap_uDst, (float)x0, (float)(x1 - x0), (float)lh, 0.0f); + post_fullscreen(s_depth_map_prog, s_dmap_uUv); + glBindTexture(GL_TEXTURE_2D, 0); +} +/* Window-sized view depth from the presented surface (fresh) or the last. */ +static int pfx_depth_build(int fresh, int lw, int lh) { + if (!s_depth_map_prog) { + s_depth_map_prog = build_program(PRESENT_VS, + "#version 330\nin vec2 v_uv; out vec4 o; uniform sampler2D u_s;\n" + "uniform ivec4 u_src; uniform vec4 u_dst;\n" + "void main(){\n" + " vec2 f = gl_FragCoord.xy;\n" + " int sx = u_src.x + int(floor((f.x - u_dst.x) * float(u_src.z - u_src.x) / u_dst.y));\n" + " int sy = u_src.y + int(floor((u_dst.z - f.y) * float(u_src.w - u_src.y) / u_dst.z));\n" + " float d = texelFetch(u_s, ivec2(sx, sy), 0).r;\n" + " /* zn = 2d - 1 = 1 - 512/(sz + 256); d = 0.5 is the zn = 0 of 2D draws */\n" + " float sz = (d >= 0.999999 || abs(d - 0.5) < 1e-6) ? 0.0 : 256.0 / max(1.0 - d, 1e-6) - 256.0;\n" + " o = vec4(sz);\n" + "}\n"); + if (!s_depth_map_prog) return 0; + s_dmap_uUv = p_glGetUniformLocation(s_depth_map_prog, "u_uv_rect"); + s_dmap_uS = p_glGetUniformLocation(s_depth_map_prog, "u_s"); + s_dmap_uSrc = p_glGetUniformLocation(s_depth_map_prog, "u_src"); + s_dmap_uDst = p_glGetUniformLocation(s_depth_map_prog, "u_dst"); + } + if (s_depth_w != lw || s_depth_h != lh) { + pfx_tex_alloc(&s_depth_tex, lw, lh, -2, 1); + pfx_fbo_attach(&s_depth_fbo, s_depth_tex); + s_depth_w = lw; s_depth_h = lh; s_depth_ok = 0; + } + if (fresh && s_mask_src_valid) { + pfx_surf_depth(0, s_mask_src_fbo, s_mask_src_rb, s_mask_src_tw, s_mask_src_th); + if (s_mask_ctr_valid) + pfx_surf_depth(1, s_mask_ctr_fbo, s_mask_ctr_rb, s_mask_ctr_tw, s_mask_ctr_th); + pfx_depth_map(0, s_mask_src, 0, lw, lh); + if (s_mask_ctr_valid) { + const int c0 = (int)lrintf(s_mask_ctr_x0 * (float)lw), c1 = (int)lrintf(s_mask_ctr_x1 * (float)lw); + if (c1 > c0) pfx_depth_map(1, s_mask_ctr, c0, c1, lh); + } + s_depth_ok = 1; + } + return s_depth_ok; +} + /* Run one stage's chain over the window rect. 0 when it could not run. * generated: a Smooth motion in-between frame; passes marked `generated * reuse` keep the target the last real frame left (when it is there at this @@ -318,6 +469,7 @@ static int pfx_mask_build(int fresh, int lw, int lh) { static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, int lh) { PfxChain *c = &st->chain; int copied = 0; + if (!st->started_at) st->started_at = PFX_NOW_TICKS(); for (int i = 0; i < c->npasses; i++) { const PfxPass *ps = &c->pass[i]; const int last = i == c->npasses - 1; @@ -330,12 +482,16 @@ static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, in L->fixed[PFX_LOC_SIZE] = p_glGetUniformLocation(pg, "u_size"); L->fixed[PFX_LOC_ORG] = p_glGetUniformLocation(pg, "u_org"); L->fixed[PFX_LOC_UV] = p_glGetUniformLocation(pg, "u_uv_rect"); + L->time = p_glGetUniformLocation(pg, "u_time"); + L->proj = p_glGetUniformLocation(pg, "u_proj"); for (int k = 0; k < ps->ninputs; k++) L->in[k] = p_glGetUniformLocation(pg, ps->in[k].sampler); for (int k = 0; k < ps->nuniforms; k++) L->u[k] = p_glGetUniformLocation(pg, ps->u[k].name); } if (!st->prog[i]) { fprintf(stdout, "psxrecomp: post effect '%s' pass '%s' did not compile; effect off\n", c->name, ps->name); + if (st == &s_pfx[PFX_STAGE_USER]) + snprintf(s_pfx_user_status, sizeof s_pfx_user_status, "pass '%s' did not compile", ps->name); st->failed = 1; return 0; } @@ -372,6 +528,17 @@ static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, in p_glBlitFramebuffer(lx, ly, lx + lw, ly + lh, 0, 0, w, h, GL_COLOR_BUFFER_BIT, GL_LINEAR); } } + /* Allocate/reset before sampling, including the final window pass. + * A resize starts an empty history rather than sampling the old size. */ + if (st->hist[i] && (st->hw[i] != w || st->hh[i] != h)) { + pfx_tex_alloc(&st->htex[i], w, h, ps->format, ps->nearest); + pfx_fbo_attach(&st->hfbo[i], st->htex[i]); + st->hw[i] = w; st->hh[i] = h; + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, st->hfbo[i]); + GLfloat clear[4]; glGetFloatv(GL_COLOR_CLEAR_VALUE, clear); + glClearColor(0,0,0,0); glClear(GL_COLOR_BUFFER_BIT); + glClearColor(clear[0],clear[1],clear[2],clear[3]); + } if (last) { p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); glViewport(lx, ly, lw, lh); } else { p_glBindFramebuffer(PSXGL_FRAMEBUFFER, st->fbo[i]); glViewport(0, 0, w, h); } const GLuint prog = st->prog[i]; @@ -381,7 +548,9 @@ static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, in const int from = ps->in[k].from; const GLuint t = from == PFX_IN_SOURCE ? s_post_copy_tex : from == PFX_IN_SOURCE_DOWN ? st->dtex[i] - : from == PFX_IN_MASK ? s_mask_tex : st->tex[from]; + : from == PFX_IN_MASK ? s_mask_tex + : from == PFX_IN_DEPTH ? s_depth_tex + : from == PFX_IN_HISTORY ? st->htex[i] : st->tex[from]; p_glActiveTexture(PSXGL_TEXTURE0 + (GLenum)k); glBindTexture(GL_TEXTURE_2D, t); p_glUniform1i(L->in[k], k); @@ -390,15 +559,31 @@ static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, in p_glUniform2f(L->fixed[PFX_LOC_RCP], 1.0f / (float)w, 1.0f / (float)h); p_glUniform2f(L->fixed[PFX_LOC_SIZE], (float)w, (float)h); p_glUniform2i(L->fixed[PFX_LOC_ORG], last ? lx : 0, last ? ly : 0); + if (L->proj >= 0) { + /* u_proj: x/z = (uv.x - y) * w * x, y/z = (uv.y - z) * x for a view-space + * point at depth sz (240-line reference, same scale on both axes). */ + const float aspect = (float)lw / (float)(lh > 0 ? lh : 1); + GtePostfxProjection projection = {256, 160 * 65536, 120 * 65536}; + (void)gte_postfx_projection_read(&projection); + const float H = projection.h > 0 ? (float)projection.h : 256.0f; + const float ofx = (float)projection.ofx / 65536.0f, ofy = (float)projection.ofy / 65536.0f; + p_glUniform4f(L->proj, 240.0f / H, 0.5f + (ofx - 160.0f) / (240.0f * aspect), + 1.0f - (ofy > 0.0f ? ofy : 120.0f) / 240.0f, aspect); + } + if (L->time >= 0) + p_glUniform1f(L->time, (float)((double)(PFX_NOW_TICKS() - st->started_at) / + (double)SDL_GetPerformanceFrequency())); for (int k = 0; k < ps->nuniforms; k++) { const PfxUniform *u = &ps->u[k]; const GLint loc = L->u[k]; if (loc < 0) continue; + float live[4]; + const float *v = pfx_live_get(u->name, live) ? live : u->v; switch (u->count) { - case 1: p_glUniform1f(loc, u->v[0]); break; - case 2: p_glUniform2f(loc, u->v[0], u->v[1]); break; - case 3: p_glUniform3f(loc, u->v[0], u->v[1], u->v[2]); break; - default: p_glUniform4f(loc, u->v[0], u->v[1], u->v[2], u->v[3]); break; + case 1: p_glUniform1f(loc, v[0]); break; + case 2: p_glUniform2f(loc, v[0], v[1]); break; + case 3: p_glUniform3f(loc, v[0], v[1], v[2]); break; + default: p_glUniform4f(loc, v[0], v[1], v[2], v[3]); break; } } if (ps->blend == PFX_BLEND_SCREEN) { @@ -408,6 +593,13 @@ static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, in } post_fullscreen(prog, L->fixed[PFX_LOC_UV]); if (ps->blend != PFX_BLEND_REPLACE) glDisable(GL_BLEND); + if (!generated && st->hist[i]) { + /* Capture the final window target as well as intermediate FBOs. */ + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, last ? 0 : st->fbo[i]); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, st->hfbo[i]); + const int x = last ? lx : 0, y = last ? ly : 0; + p_glBlitFramebuffer(x, y, x+w, y+h, 0, 0, w, h, GL_COLOR_BUFFER_BIT, GL_NEAREST); + } } for (int k = PFX_MAX_INPUTS - 1; k >= 0; k--) { p_glActiveTexture(PSXGL_TEXTURE0 + (GLenum)k); @@ -453,7 +645,7 @@ static void postfx_present(int fresh, int with_aa, int lx, int ly, int lw, int l PfxGlState saved; pfx_state_save(&saved); glDisable(GL_BLEND); glDisable(GL_DEPTH_TEST); glDisable(GL_SCISSOR_TEST); glDisable(GL_STENCIL_TEST); - int mask_done = 0, mask_ok = 0; + int mask_done = 0, mask_ok = 0, depth_done = 0, depth_ok = 0; for (int i = 0; i < PFX_NSTAGES; i++) { if (!pfx_stage_on(i)) continue; if ((i == PFX_STAGE_AA || i == PFX_STAGE_HQ) && !with_aa) continue; @@ -463,6 +655,10 @@ static void postfx_present(int fresh, int with_aa, int lx, int ly, int lw, int l if (!mask_done) { mask_ok = pfx_mask_build(src_fresh, lw, lh); mask_done = 1; } if (!mask_ok) continue; /* no marks yet */ } + if (st->chain.uses_depth) { + if (!depth_done) { depth_ok = pfx_depth_build(src_fresh, lw, lh); depth_done = 1; } + if (!depth_ok) continue; /* no depth yet */ + } if (i == PFX_STAGE_BLOOM) { const float k = s_bloom; pfx_chain_set_uniform(&st->chain, "u_strength", 1, &k); @@ -505,6 +701,7 @@ static void postfx_present(int fresh, int with_aa, int lx, int ly, int lw, int l /* Objects died with the context. */ static void postfx_release(void) { + gte_postfx_projection_enable(0); for (int i = 0; i < PFX_NSTAGES; i++) { for (int k = 0; k < PFX_MAX_PASSES; k++) { s_pfx[i].prog[k] = s_pfx[i].tex[k] = s_pfx[i].fbo[k] = 0; @@ -515,6 +712,12 @@ static void postfx_release(void) { } s_mask_tex = s_mask_fbo = s_mask_prog = s_mask_map_prog = 0; memset(s_smask, 0, sizeof s_smask); + s_depth_tex = s_depth_fbo = s_depth_map_prog = 0; s_depth_w = s_depth_h = 0; s_depth_ok = 0; + memset(s_sdepth, 0, sizeof s_sdepth); + for (int i = 0; i < PFX_NSTAGES; i++) + for (int k = 0; k < PFX_MAX_PASSES; k++) { + s_pfx[i].htex[k] = s_pfx[i].hfbo[k] = 0; s_pfx[i].hw[k] = s_pfx[i].hh[k] = 0; + } s_mask_w = s_mask_h = 0; s_mask_ok = 0; s_mask_src_valid = 0; } @@ -538,6 +741,16 @@ void gl_renderer_set_post_shader(const char *path) { } pfx_mask_wanted_update(); } +/* Debug: the user chain's last load outcome and the live uniforms, as JSON members. */ +int gl_renderer_post_shader_debug(char *out, int cap) { + GL_RT_SYNC("post_shader_debug"); + PfxLive live[PFX_LIVE_MAX]; int count; + SDL_LockSpinlock(&s_pfx_live_lock); + count = s_pfx_nlive; memcpy(live,s_pfx_live,sizeof live); + SDL_UnlockSpinlock(&s_pfx_live_lock); + return pfx_debug_json(out,cap,s_pfx_user_path,s_pfx_user_status,live,count); +} + int gl_renderer_post_shader_loaded(void) { GL_RT_SYNC("post_shader"); return s_pfx[PFX_STAGE_USER].loaded && !s_pfx[PFX_STAGE_USER].failed; diff --git a/runtime/src/gl_texel_cache.inc b/runtime/src/gl_texel_cache.inc deleted file mode 100644 index 2bc84537..00000000 --- a/runtime/src/gl_texel_cache.inc +++ /dev/null @@ -1,213 +0,0 @@ -/* Exact indexed-page decode cache. Included after TEX_FS and batch helpers. - * Array layers contain native 1555 words, not filtered/display colours. */ -#ifndef GL_TEXTURE_2D_ARRAY -#define GL_TEXTURE_2D_ARRAY 0x8C1A -#endif -typedef void (APIENTRY *TcImage3D)(GLenum, GLint, GLint, GLsizei, GLsizei, GLsizei, - GLint, GLenum, GLenum, const void *); -typedef void (APIENTRY *TcAttachLayer)(GLenum, GLenum, GLuint, GLint, GLint); -#define TC_CAP 256 -typedef struct { - GLuint source, palette; - int x, y, depth, cx, cy, valid, pinned; - uint64_t stamp, used, checked_epoch; - int next; -} TexelCacheEntry; -static TexelCacheEntry s_tc[TC_CAP]; -static uint64_t s_tc_tiles[32][64], s_tc_epoch, s_tc_clock; -static uint64_t s_tc_hits, s_tc_misses, s_tc_evictions; -static GLuint s_tc_array, s_tc_fbo, s_tc_prog; -static GLint s_tc_page, s_tc_clut, s_tc_depth; -static int s_tc_ready = -1, s_tc_capacity = TC_CAP; -static TcAttachLayer s_tc_attach; -static int s_tc_pins[TC_CAP], s_tc_pin_n; -static int s_tc_heads[1024], s_tc_last = -1; -static int texel_cache_lookup_fast(void) { - static int enabled = -1; - if (enabled < 0) { - const char *e = getenv("PSX_GL_TEXEL_LOOKUP_FAST"); - enabled = !(e && e[0] == '0'); - } - return enabled; -} -static unsigned texel_cache_hash(GLuint source, GLuint palette, int x, int y, int depth, int cx, int cy) { - uint32_t h = source * 0x9e3779b9u ^ palette * 0x85ebca6bu; - h ^= (uint32_t)x * 73856093u ^ (uint32_t)y * 19349663u; - h ^= (uint32_t)cx * 83492791u ^ (uint32_t)cy * 2654435761u ^ (uint32_t)depth; - h ^= h >> 16; - return h & 1023u; -} -static int texel_cache_matches(const TexelCacheEntry *c, GLuint source, GLuint palette, - int x, int y, int depth, int cx, int cy) { - return c->valid && c->source==source && c->palette==palette && c->x==x && c->y==y && - c->depth==depth && c->cx==cx && c->cy==cy; -} -static void texel_cache_pin(int slot) { - if (!s_tc[slot].pinned) { s_tc[slot].pinned=1; s_tc_pins[s_tc_pin_n++]=slot; } -} -static void texel_cache_unpin(void) { - for (int i=0; i 1023) x1 = 1023; if (y1 > 511) y1 = 511; - if (x1 < x0 || y1 < y0) return; - uint64_t stamp = ++s_tc_epoch; - for (int y=y0/16; y<=y1/16; ++y) - for (int x=x0/16; x<=x1/16; ++x) s_tc_tiles[y][x] = stamp; -} -static uint64_t texel_cache_region(int x, int y, int w, int h) { - uint64_t stamp = 0; - for (int ty=y/16; ty<=(y+h-1)/16; ++ty) - for (int tx=x/16; tx<=(x+w-1)/16; ++tx) { - uint64_t v = s_tc_tiles[ty & 31][tx & 63]; - if (v > stamp) stamp = v; - } - return stamp; -} -static void texel_cache_reset(void) { - memset(s_tc, 0, sizeof s_tc); - memset(s_tc_heads, 0, sizeof s_tc_heads); - s_tc_last = -1; - memset(s_tc_tiles, 0, sizeof s_tc_tiles); - s_tc_epoch = s_tc_clock = s_tc_hits = s_tc_misses = s_tc_evictions = 0; - s_tc_array = s_tc_fbo = s_tc_prog = 0; - s_tc_ready = -1; - s_tc_pin_n = 0; -} -static int texel_cache_init(void) { - if (s_tc_ready >= 0) return s_tc_ready; - s_tc_ready = 0; - const char *e = getenv("PSX_GL_TEXEL_CACHE"); - if (e && e[0] == '0') return 0; - e = getenv("PSX_GL_TEXEL_CACHE_LAYERS"); - s_tc_capacity = e ? atoi(e) : TC_CAP; - if (s_tc_capacity < 4) s_tc_capacity = 4; - if (s_tc_capacity > TC_CAP) s_tc_capacity = TC_CAP; - TcImage3D image = (TcImage3D)SDL_GL_GetProcAddress("glTexImage3D"); - s_tc_attach = (TcAttachLayer)SDL_GL_GetProcAddress("glFramebufferTextureLayer"); - if (!image || !s_tc_attach) return 0; - /* Reuse the literal native address/decode functions from TEX_FS. The - * decoder has no window or UV bounds: fetch_texel applies those before - * it samples the decoded layer. This avoids a second decode definition. */ - const char *helpers = strstr(TEX_FS, "int vram_at("); - const char *fetch = strstr(TEX_FS, "int fetch_texel("); - const char *native = strstr(TEX_FS, " if (v_depth == 0)"); - const char *end = strstr(TEX_FS, "vec3 col5("); - if (!helpers || !fetch || !native || !end) return 0; - char *fs = malloc(strlen(TEX_FS) + 512); - if (!fs) return 0; - snprintf(fs, strlen(TEX_FS) + 512, - "#version 330\nuniform usampler2D u_vram, u_palette;\n" - "uniform ivec2 v_tpage, v_clut; uniform int v_depth;\n" - "layout(location=0) out uint decoded_word;\n%.*s" - "int decode_native(int u,int v){\n%.*s" - "void main(){decoded_word=uint(decode_native(int(gl_FragCoord.x),int(gl_FragCoord.y)));}\n", - (int)(fetch-helpers), helpers, (int)(end-native), native); - s_tc_prog = build_program(PRESENT_VS, fs); - free(fs); - if (!s_tc_prog) return 0; - glGenTextures(1, &s_tc_array); - p_glActiveTexture(PSXGL_TEXTURE0 + 3); - glBindTexture(GL_TEXTURE_2D_ARRAY, s_tc_array); - glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - p_glBindBuffer(PSXGL_PIXEL_UNPACK_BUFFER, 0); - image(GL_TEXTURE_2D_ARRAY, 0, PSXGL_R16UI, 256, 256, s_tc_capacity, 0, - PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT, NULL); - p_glActiveTexture(PSXGL_TEXTURE0); - if (glGetError() != GL_NO_ERROR) return 0; - p_glGenFramebuffers(1, &s_tc_fbo); - s_tc_page = p_glGetUniformLocation(s_tc_prog, "v_tpage"); - s_tc_clut = p_glGetUniformLocation(s_tc_prog, "v_clut"); - s_tc_depth = p_glGetUniformLocation(s_tc_prog, "v_depth"); - p_glUseProgram(s_tc_prog); - p_glUniform1i(p_glGetUniformLocation(s_tc_prog, "u_vram"), 0); - p_glUniform1i(p_glGetUniformLocation(s_tc_prog, "u_palette"), 1); - s_tc_ready = 1; - return 1; -} -static void texel_cache_bind(void) { - p_glActiveTexture(PSXGL_TEXTURE0 + 3); - glBindTexture(GL_TEXTURE_2D_ARRAY, s_tc_array); - p_glActiveTexture(PSXGL_TEXTURE0); -} -static int texel_cache_get(GLuint source, GLuint palette, int x, int y, int depth, int cx, int cy) { - if (depth >= 2 || !texel_cache_init()) return -1; - const int fast = texel_cache_lookup_fast(); - const unsigned bucket = texel_cache_hash(source,palette,x,y,depth,cx,cy); - if (fast) { - int hit = s_tc_last; - if (hit < 0 || !texel_cache_matches(&s_tc[hit],source,palette,x,y,depth,cx,cy)) { - hit = s_tc_heads[bucket] - 1; - while (hit >= 0 && !texel_cache_matches(&s_tc[hit],source,palette,x,y,depth,cx,cy)) - hit = s_tc[hit].next - 1; - } - if (hit >= 0) { - TexelCacheEntry *c = &s_tc[hit]; - if (c->checked_epoch != s_tc_epoch) { - uint64_t stamp = source == s_raw_tex ? texel_cache_region(x,y,depth ? 128 : 64,256) : 0; - uint64_t ps = palette == s_raw_tex ? texel_cache_region(cx,cy,depth ? 256 : 16,1) : 0; - if (ps > stamp) stamp = ps; - if (c->stamp == stamp) c->checked_epoch = s_tc_epoch; - } - if (c->checked_epoch == s_tc_epoch) { - c->used=++s_tc_clock; texel_cache_pin(hit); s_tc_last=hit; s_tc_hits++; return hit; - } - } - } - uint64_t stamp = source == s_raw_tex ? texel_cache_region(x,y,depth ? 128 : 64,256) : 0; - uint64_t ps = palette == s_raw_tex ? texel_cache_region(cx,cy,depth ? 256 : 16,1) : 0; - if (ps > stamp) stamp = ps; - int slot = -1; - uint64_t oldest = UINT64_MAX; - for (int i=0; istamp == stamp) { c->used=++s_tc_clock; texel_cache_pin(i); s_tc_hits++; return i; } - } - if (!c->valid) { if (oldest != 0) { slot=i; oldest=0; } } - else if (!c->pinned && c->used < oldest) { oldest=c->used; slot=i; } - } - s_tc_misses++; - if (slot < 0) return -1; /* All layers pinned: preserve the original batch. */ - if (s_tc[slot].valid) { - /* Only layers without deferred CPU draw references can be reused. - * GPU readers were already submitted, so command ordering protects - * them. Never split a mask-checked batch to make cache space. */ - s_tc_evictions++; - } - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_tc_fbo); - s_tc_attach(PSXGL_FRAMEBUFFER, PSXGL_COLOR_ATTACHMENT0, s_tc_array, 0, slot); - if (p_glCheckFramebufferStatus(PSXGL_FRAMEBUFFER) != PSXGL_FRAMEBUFFER_COMPLETE) return -1; - glViewport(0,0,256,256); - glDisable(GL_SCISSOR_TEST); glDisable(GL_BLEND); glDisable(GL_DEPTH_TEST); glDisable(GL_STENCIL_TEST); - glColorMask(GL_TRUE,GL_TRUE,GL_TRUE,GL_TRUE); - p_glUseProgram(s_tc_prog); - p_glUniform2i(s_tc_page,x,y); p_glUniform2i(s_tc_clut,cx,cy); p_glUniform1i(s_tc_depth,depth); - p_glActiveTexture(PSXGL_TEXTURE0); glBindTexture(GL_TEXTURE_2D,source); - p_glActiveTexture(PSXGL_TEXTURE0+1); glBindTexture(GL_TEXTURE_2D,palette); - p_glActiveTexture(PSXGL_TEXTURE0+3); glBindTexture(GL_TEXTURE_2D_ARRAY,0); - p_glBindVertexArray(s_empty_vao); glDrawArrays(GL_TRIANGLES,0,3); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER,s_hr_fbo); - p_glActiveTexture(PSXGL_TEXTURE0); - if (s_tc[slot].valid) { - TexelCacheEntry *c = &s_tc[slot]; - unsigned old = texel_cache_hash(c->source,c->palette,c->x,c->y,c->depth,c->cx,c->cy); - int *link = &s_tc_heads[old]; - while (*link && *link != slot+1) link = &s_tc[*link-1].next; - if (*link) *link = c->next; - } - s_tc[slot]=(TexelCacheEntry){.source=source,.palette=palette,.x=x,.y=y,.depth=depth, - .cx=cx,.cy=cy,.valid=1,.stamp=stamp,.used=++s_tc_clock, - .checked_epoch=s_tc_epoch,.next=s_tc_heads[bucket]}; - s_tc_heads[bucket]=slot+1; - s_tc_last=slot; - texel_cache_pin(slot); - return slot; -} diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 0e50a7cd..2b1deaac 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -1086,6 +1086,63 @@ int psx_ws_cull_keep_site(uint32_t pc, uint32_t instr, uint32_t vanilla, return 0; } +typedef struct { + uint32_t vertices; + uint32_t fold_address[4]; /* physical */ + uint32_t fold_expected[4]; + uint32_t vertex_reg[4]; + uint32_t consumer_address; /* physical */ + uint32_t consumer_expected; +} WsSxyCullSite; +static WsSxyCullSite ws_sxy_cull_sites[PSX_WS_SXY_CULL_SITES_MAX]; +int g_psx_ws_sxy_cull_n = 0; +void gpu_ws_set_sxy_cull_sites(const uint32_t *vertices, + const uint32_t *fold_addresses, + const uint32_t *fold_expected, + const uint32_t *vertex_regs, + const uint32_t *consumer_addresses, + const uint32_t *consumer_expected, int nsites) { + int n = 0; + if (nsites > PSX_WS_SXY_CULL_SITES_MAX) nsites = PSX_WS_SXY_CULL_SITES_MAX; + for (int i = 0; i < nsites; i++) { + WsSxyCullSite *site = &ws_sxy_cull_sites[n]; + if (vertices[i] != 3u && vertices[i] != 4u) continue; + site->vertices = vertices[i]; + for (uint32_t k = 0; k < site->vertices; k++) { + site->fold_address[k] = fold_addresses[i * 4 + k] & 0x1FFFFFFFu; + site->fold_expected[k] = fold_expected[i * 4 + k]; + site->vertex_reg[k] = vertex_regs[i * 4 + k] & 31u; + } + site->consumer_address = consumer_addresses[i] & 0x1FFFFFFFu; + site->consumer_expected = consumer_expected[i]; + n++; + } + g_psx_ws_sxy_cull_n = n; +} +uint32_t psx_ws_cull_sxy_and(uint32_t acc, uint32_t sxy) { + return psx_ws_sxy_and_value(acc, sxy, psx_ws_x_margin()); +} +uint32_t psx_ws_sxy_cull_vertex_reg(uint32_t pc, uint32_t instr, + uint32_t (*read_word)(uint32_t phys)) { + const uint32_t phys = pc & 0x1FFFFFFFu; + for (int i = 0; i < g_psx_ws_sxy_cull_n; i++) { + const WsSxyCullSite *site = &ws_sxy_cull_sites[i]; + uint32_t step = site->vertices; + for (uint32_t k = 0; k < site->vertices; k++) + if (site->fold_address[k] == phys && site->fold_expected[k] == instr) + step = k; + if (step == site->vertices) continue; + /* The rest of the fold and the consumer are part of the identity, + * exactly as the recompiler checks them before emitting the helper. */ + int ok = read_word(site->consumer_address) == site->consumer_expected; + for (uint32_t k = 0; ok && k < site->vertices; k++) + if (k != step && read_word(site->fold_address[k]) != site->fold_expected[k]) + ok = 0; + if (ok) return site->vertex_reg[step]; + } + return 0; +} + typedef struct { uint32_t address; uint32_t expected; diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 6fbfbd26..e4e2ce8a 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -117,38 +117,6 @@ #include #include "png_write.h" /* png_write_rgb — present_shot readback */ static uint64_t s_fg_flips; /* frame generation (below): game frames seen */ -static GlRenderWorkStats s_cpu_work; -static int s_cpu_perf_mode = -1; -static int gl_cpu_probe_gpu(int kind) { - return s_cpu_perf_mode == 2 && kind > 0 && kind < 6 && kind != 4; -} -static int gl_cpu_mode(void) { - if (s_cpu_perf_mode < 0) { - const char *e = getenv("PSX_GL_CPU_PERF"); - s_cpu_perf_mode = e && e[0] >= '1' && e[0] <= '3' ? e[0] - '0' : 0; - } - return s_cpu_perf_mode; -} - -static uint64_t gl_cpu_begin(int kind) { - if (!gl_cpu_mode()) return 0; - if (gl_cpu_probe_gpu(kind)) { - uint64_t start = SDL_GetPerformanceCounter(); - glFinish(); - uint64_t ticks = SDL_GetPerformanceCounter() - start; - s_cpu_work.prior_ticks[kind] += ticks; - if (ticks > s_cpu_work.prior_max_ticks[kind]) s_cpu_work.prior_max_ticks[kind] = ticks; - } - return SDL_GetPerformanceCounter(); -} -static void gl_cpu_end(int kind, uint64_t start) { - if (!start) return; - if (gl_cpu_probe_gpu(kind)) glFinish(); - uint64_t ticks = SDL_GetPerformanceCounter() - start; - s_cpu_work.calls[kind]++; - s_cpu_work.ticks[kind] += ticks; - if (ticks > s_cpu_work.max_ticks[kind]) s_cpu_work.max_ticks[kind] = ticks; -} #ifndef GL_BGRA #define GL_BGRA 0x80E1 @@ -230,6 +198,7 @@ typedef void (APIENTRY *PFN_glBlendEquationSeparate)(GLenum, GLenum); typedef void (APIENTRY *PFN_glGenVertexArrays)(GLsizei, GLuint *); typedef void (APIENTRY *PFN_glBindVertexArray)(GLuint); typedef void (APIENTRY *PFN_glActiveTexture)(GLenum); +typedef void (APIENTRY *PFN_glGenerateMipmap)(GLenum); typedef void (APIENTRY *PFN_glGenBuffers)(GLsizei, GLuint *); typedef void (APIENTRY *PFN_glDeleteBuffers)(GLsizei, const GLuint *); typedef void (APIENTRY *PFN_glDeleteVertexArrays)(GLsizei, const GLuint *); @@ -303,6 +272,7 @@ static PFN_glBlendEquationSeparate p_glBlendEquationSeparate; static PFN_glGenVertexArrays p_glGenVertexArrays; static PFN_glBindVertexArray p_glBindVertexArray; static PFN_glActiveTexture p_glActiveTexture; +static PFN_glGenerateMipmap p_glGenerateMipmap; static PFN_glGenBuffers p_glGenBuffers; static PFN_glDeleteBuffers p_glDeleteBuffers; static PFN_glDeleteVertexArrays p_glDeleteVertexArrays; @@ -384,6 +354,7 @@ static int load_modern_gl(void) { LOAD(p_glBlendEquationSeparate, "glBlendEquationSeparate"); LOAD(p_glGenVertexArrays, "glGenVertexArrays"); LOAD(p_glBindVertexArray, "glBindVertexArray"); LOAD(p_glActiveTexture, "glActiveTexture"); LOAD(p_glGenBuffers, "glGenBuffers"); + LOAD(p_glGenerateMipmap, "glGenerateMipmap"); LOAD(p_glDeleteBuffers, "glDeleteBuffers"); LOAD(p_glDeleteVertexArrays, "glDeleteVertexArrays"); LOAD(p_glBindBuffer, "glBindBuffer"); LOAD(p_glBufferData, "glBufferData"); LOAD(p_glVertexAttribPointer, "glVertexAttribPointer"); @@ -427,60 +398,6 @@ static int load_modern_gl(void) { /* ---- state ------------------------------------------------------------- */ static SDL_Window *s_win = NULL; -static uint64_t s_window_owner; -static _Atomic uint64_t s_drawable_extent; -static uint64_t s_drawable_queries; -#if defined(PSX_SDL3) -static _Atomic uint32_t s_drawable_window_id; -static int s_drawable_watch; -static bool SDLCALL drawable_event_watch(void *unused, SDL_Event *event) { - (void)unused; - if (event->type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED && - event->window.windowID == atomic_load_explicit(&s_drawable_window_id, memory_order_relaxed) && - event->window.data1 >= 0 && event->window.data2 >= 0) - atomic_store_explicit(&s_drawable_extent, - ((uint64_t)(uint32_t)event->window.data1 << 32) | (uint32_t)event->window.data2, - memory_order_release); - return true; -} -#endif -static int drawable_events_enabled(void) { -#if defined(PSX_SDL3) - if (!s_drawable_watch) return 0; - static int enabled = -1; - if (enabled < 0) { - const char *e = getenv("PSX_GL_DRAWABLE_EVENTS"); - enabled = !(e && e[0] == '0'); - } - return enabled; -#else - return 0; /* SDL2 resize events are logical, not drawable-pixel extents. */ -#endif -} -static uint64_t window_thread_id(void) { -#if defined(PSX_SDL3) - return (uint64_t)SDL_GetCurrentThreadID(); -#else - return (uint64_t)SDL_ThreadID(); -#endif -} -/* Query only at context creation (or in the legacy control). SDL3 pixel-size - * events publish one consistent pair without taking SDL's window-properties - * lock, which the swapping thread can hold. No worker queries the window. */ -static void gl_drawable_refresh(void) { - if (!s_win || window_thread_id() != s_window_owner) return; - int w = 0, h = 0; - SDL_GL_GetDrawableSize(s_win, &w, &h); - s_drawable_queries++; - atomic_store_explicit(&s_drawable_extent, - ((uint64_t)(uint32_t)w << 32) | (uint32_t)h, memory_order_release); -} -static void gl_drawable_size(int *w, int *h) { - if (!drawable_events_enabled()) gl_drawable_refresh(); - uint64_t extent = atomic_load_explicit(&s_drawable_extent, memory_order_acquire); - if (w) *w = (int)(extent >> 32); - if (h) *h = (int)(uint32_t)extent; -} static SDL_GLContext s_ctx = NULL; static uint16_t *s_vram = NULL; /* CPU VRAM array (gpu.c's storage) */ /* GL_VENDOR names NVIDIA (captured at context creation). */ @@ -746,8 +663,6 @@ static double s_interp_host_hz = 0.0; static double s_interp_target_hz = 0.0; static double s_interp_source_hz = 0.0; static FrameInterpolationSchedule s_interp_schedule; -static uint64_t s_interp_pending_deadline; -static _Atomic int s_interp_async_cadence; /* Blend source (psx_mod_set_frame_interpolation_source). VBLANK (0, the * default) treats every guest VBlank present as a new frame, exactly as * before. FLIP (1) rotates history only on a real flip -- the display origin @@ -833,10 +748,6 @@ static GLuint s_hr_tex = 0, s_hr_fbo = 0, s_hr_rb = 0; static int s_hr_alloc = 0; /* Native raw-1555 sampling mirror + readback source. */ static GLuint s_raw_tex = 0, s_raw_fbo = 0; -static void texel_cache_dirty(int x0, int y0, int x1, int y1); -static void texel_cache_reset(void); -static void texel_cache_bind(void); -static void texel_cache_unpin(void); /* CPU->VRAM upload staging (native RGBA8). */ static GLuint s_up_tex = 0; /* copy_rect staging (hr-sized RGBA8). */ @@ -1387,7 +1298,7 @@ static void hold_capture_drawable(void) { int ww = 0, wh = 0; if (!s_ctx || !s_win) return; - gl_drawable_size(&ww, &wh); + SDL_GL_GetDrawableSize(s_win, &ww, &wh); if (ww < 1 || wh < 1) return; hold_ensure_tex(ww, wh); @@ -1418,7 +1329,7 @@ static void hiw_capture_size(int sw, int sh, int force_4_3, int *cw, int *ch) { int ww = 0, wh = 0, lx, ly, lw, lh; *cw = sw; *ch = sh; if (!s_hiw || !s_win) return; - gl_drawable_size(&ww, &wh); + SDL_GL_GetDrawableSize(s_win, &ww, &wh); if (ww < 1 || wh < 1) return; if (force_4_3) letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); else letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); @@ -1461,8 +1372,6 @@ static void present_target_quad(GLuint tex, float tex_w, float tex_h, int apply_gamma, int src_scale); static void coh_record(int kind, int x0, int y0, int x1, int y1) { - if (kind == GL_COH_PACK || kind == GL_COH_FLUSH || kind == GL_COH_UPLOAD) - texel_cache_dirty(x0,y0,x1,y1); GlCohEvent *e = &s_coh_ring[s_coh_seq % GL_COH_RING_CAP]; e->frame = (uint32_t)s_frame_count; e->kind = (uint8_t)kind; @@ -1500,7 +1409,7 @@ static void postfx_present(int fresh, int with_aa, int lx, int ly, int lw, int l static void pres_record(int path, int dx, int dy, int w, int h, int lx, int ly, int lw, int lh) { int drawable_w = 0, drawable_h = 0; - gl_drawable_size(&drawable_w, &drawable_h); + SDL_GL_GetDrawableSize(s_win, &drawable_w, &drawable_h); psx_video_telemetry_compose(PSX_VIDEO_OPENGL, s_out_scale, w, h, drawable_w, drawable_h, lw, lh); /* The composed game image, before hold-last capture and the OSD: the @@ -1632,7 +1541,7 @@ static int s_post_copy_w = 0, s_post_copy_h = 0; /* Clip [lx,ly,lw,lh] to the drawable; 0 when less than min px remain. */ static int post_clip_rect(int *lx, int *ly, int *lw, int *lh, int min) { int ww = 0, wh = 0; - gl_drawable_size(&ww, &wh); + SDL_GL_GetDrawableSize(s_win, &ww, &wh); if (*lx < 0) { *lw += *lx; *lx = 0; } if (*ly < 0) { *lh += *ly; *ly = 0; } if (*lx + *lw > ww) *lw = ww - *lx; @@ -2031,8 +1940,7 @@ static const char *TEX_FS = PSX_DITHER_GLSL "out vec4 frag; out vec4 blend_factor;\n" "noperspective in float v_b3d;\n" - "uniform int u_bcls; /* 0 colour, 1 bloom mark, 2 bloom clear, 3 depth coverage */\n" - "uniform int u_coverage_fast;\n" + "uniform int u_bcls; /* bloom source pass (gl_postfx.inc): 0 off, 2 clear, 1 mark */\n" "flat in ivec2 v_tpage; /* texture page base, VRAM px */\n" "flat in ivec2 v_clut; /* CLUT base, VRAM px */\n" "flat in int v_depth; /* 0=4bit 1=8bit 2=15bit */\n" @@ -2046,7 +1954,6 @@ static const char *TEX_FS = "uniform sampler2D u_hd_texture;\n" "uniform usampler2D u_vram;\n" "uniform usampler2D u_palette;\n" - "uniform usampler2DArray u_decoded;\n" "uniform int u_semipass; /* 0=all texels, 1=STP=0 only, 2=STP=1 only */\n" "uniform int u_semimode; /* PS1 blend mode; drives dual-source factors */\n" "uniform int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" @@ -2074,7 +1981,6 @@ static const char *TEX_FS = " u = clamp(u, v_limits.x, v_limits.z);\n" " v = clamp(v, v_limits.y, v_limits.w);\n" " }\n" - " if (v_depth >= 4) return int(texelFetch(u_decoded,ivec3(u,v,v_depth-4),0).r);\n" " if (v_depth == 0) {\n" " int px = vram_at(v_tpage.x + (u >> 2), v_tpage.y + v);\n" " return palette_at(v_clut.x + ((px >> ((u & 3) * 4)) & 0xF), v_clut.y);\n" @@ -2108,12 +2014,32 @@ static const char *TEX_FS = " p &= ivec2(255);\n" " ivec4 tw=ivec4(v_twin&31,(v_twin>>5)&31,(v_twin>>10)&31,(v_twin>>15)&31);\n" " if((tw.x|tw.y)!=0) p=(p & ~(tw.xy*8)) | ((tw.zw & tw.xy)*8);\n" - " else p=clamp(p,v_limits.xy,v_limits.zw);\n" + " else {\n" + /* Clamped to the primitive's UV bounds: the sub-texel position goes to + * the clamped edge too. Keeping the fraction of an outside sample + * (u=39.99 -> texel 40 at 0.99) reads the replacement deep inside the + * edge texel, a 1px sliver of the next glyph at the sprite border. */ + " ivec2 q=clamp(p,v_limits.xy,v_limits.zw);\n" + " f=vec2(q.x>p.x?0.0:(q.xp.y?0.0:(q.y>15)&1; ++hit; }\n" + " }\n" + " if(hit*2> 15) & 1;\n" - " rgb = need_color ? xbr_sample(uv, raw, abs(dx) + abs(dy)) : vec3(0.0);\n" + " rgb = xbr_sample(uv, raw, abs(dx) + abs(dy));\n" " } else if (fmode == 2) {\n" " uv+=vec2(u_shift); int raw=stable_texel(ivec2(floor(uv)));\n" " if(raw==0) discard; stp=(raw>>15)&1;\n" - " rgb=vec3(0.0);\n" - " if(need_color){\n" " float lx=length(dx), ly=length(dy);\n" " dx*=min(1.0,8.0/max(lx,0.001)); dy*=min(1.0,8.0/max(ly,0.001));\n" " int nx=clamp(int(ceil(lx)),1,4), ny=clamp(int(ceil(ly)),1,4);\n" @@ -2237,7 +2178,6 @@ static const char *TEX_FS = " sum+=stable_bilinear(uv+offset,stp);\n" " }\n" " rgb=sum.a>0.00001 ? sum.rgb/sum.a : col5(raw);\n" - " }\n" " } else {\n" " /* Bilinear, Beetle-PSX formulation: the NEAREST texel is the base\n" " * (cutout + STP authority), the neighbours lie toward the sub-texel\n" @@ -2266,7 +2206,7 @@ static const char *TEX_FS = " + float((c01 >> 15) & 1) * w01 + float((c11 >> 15) & 1) * w11) / opac;\n" " stp = stpf >= 0.5 ? 1 : 0;\n" " }\n" - " if (need_color && (v_filter & 16) != 0 && v_hd_mode == 0) rgb = lod_sample(uv0, dx0, dy0, stp, rgb);\n" + " if ((v_filter & 16) != 0 && v_hd_mode == 0) rgb = lod_sample(uv0, dx0, dy0, stp, rgb);\n" " if (u_bcls != 0) { /* 3D, or additive (modes 1 and 3) semi texels */\n" " if (u_bcls == 1 && !(v_b3d > 0.999 || ((v_semi == 2 || v_semi == 4) && stp == 1))) discard;\n" " if (u_bcls == 2 && v_semi != 0 && stp == 1) discard; /* see-through: keeps what is under */\n" @@ -2500,18 +2440,6 @@ static void hr_end(void) { * decal problem; this tolerance is our own. Every caller that applies 1 or 2 * calls depth_restore after its draw (tex: textured program bound). */ static GLint s_geo_uZbias = -1, s_tex_uZbias = -1; -static GLint s_tex_uBcls = -1, s_geo_uBcls = -1, s_geo_uBadd = -1; -static uint64_t s_depth_probe_ordinal; -static int s_depth_probe_current, s_hq_replay_active; -static uint64_t s_coverage_depth_draws; -static int coverage_fast_enabled(void) { - static int enabled = -1; - if (enabled < 0) { - const char *e = getenv("PSX_GL_COVERAGE_FAST"); - enabled = !(e && e[0] == '0'); - } - return enabled; -} static float s_pgxp_depth_tol = -1.0f; static float pgxp_depth_tol(void) { if (s_pgxp_depth_tol < 0.0f) { @@ -2538,37 +2466,16 @@ static void depth_apply_ex(int mode, int tex) { } /* After the colour pass of mode 1: the depth-only pass (n vertices of the * bound buffer), then back to the default state. */ -static int depth_state_fast(void) { - static int enabled = -1; - if (enabled < 0) { - const char *e = getenv("PSX_GL_STATE_QUERY_FAST"); - enabled = !(e && e[0] == '0'); - } - return enabled; -} -static void depth_restore_ex_timed_impl(int mode, int tex, int n, int semi, int mask, int first) { +static void depth_restore_ex(int mode, int tex, int n) { if (mode == 0) return; if (mode == 1) { p_glUniform1f(tex ? s_tex_uZbias : s_geo_uZbias, 0.0f); - /* The immediately preceding draw establishes both states. All GEO - * and opaque/isolated TEX branches enable mask stencil; only the - * dual-source TEX branch disables it when set-mask is off. Querying - * the threaded driver here forces a round trip for every batch. */ - GLboolean st = !(tex && semi == 4 && !mask), bl = semi >= 0; - if (!depth_state_fast()) { - st = glIsEnabled(GL_STENCIL_TEST); bl = glIsEnabled(GL_BLEND); - } + GLboolean st = glIsEnabled(GL_STENCIL_TEST), bl = glIsEnabled(GL_BLEND); glDisable(GL_STENCIL_TEST); glDisable(GL_BLEND); glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); glDepthMask(GL_TRUE); if (tex) p_glUniform1i(s_uSemipass, 0); - /* Keep native/HD cutout coverage, without colour-only filtering. */ - if (tex && coverage_fast_enabled()) { - p_glUniform1i(s_tex_uBcls, 3); - s_coverage_depth_draws++; - } - glDrawArrays(GL_TRIANGLES, first, n); - if (tex && coverage_fast_enabled()) p_glUniform1i(s_tex_uBcls, 0); + glDrawArrays(GL_TRIANGLES, 0, n); glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); if (st) glEnable(GL_STENCIL_TEST); if (bl) glEnable(GL_BLEND); @@ -2578,16 +2485,6 @@ static void depth_restore_ex_timed_impl(int mode, int tex, int n, int semi, int if (mode == 2) glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); } -static void depth_restore_ex(int mode, int tex, int n, int semi, int mask, int first) { - uint64_t start = gl_cpu_begin(0); - uint64_t sample = 0; - if (s_depth_probe_current && tex && mode == 1) { glFinish(); sample = SDL_GetPerformanceCounter(); } - depth_restore_ex_timed_impl(mode, tex, n, semi, mask, first); - if (sample) { glFinish(); gl_cpu_end(6, sample); } - s_depth_probe_current = 0; - gl_cpu_end(0, start); -} - /* ---- coherency: CPU -> GPU upload flush --------------------------------- */ /* CPU-side VRAM writes (GP0 A0 transfers, DMA, single pixel pokes) land in * the CPU array immediately and accumulate s_up_rects. Flushing before the @@ -2998,15 +2895,6 @@ static void hiw_mirror_uploads(const DirtyRect *rects, int nrects) { * at the shared scratch origin, upscaled nearest. Two passes by source bit15, * so each tile's stencil tracks the mask. */ static int s_hiw_scratch_refused_logged = 0; -static uint64_t s_hiw_copy_blits; -static int hiw_copy_fast(void) { - static int enabled = -1; - if (enabled < 0) { - const char *e = getenv("PSX_GL_HIW_COPY_FAST"); - enabled = !(e && e[0] == '0'); - } - return enabled; -} static void hiw_mirror_copy_chunk(int a, int b, int shift, int sy, int dx, int dy, int h) { static uint8_t hi_src[VRAM_W]; @@ -3047,28 +2935,6 @@ static void hiw_mirror_copy_chunk(int a, int b, int shift, int sy, int dx, int d int e = c + 1; while (e < d1 && hi_src[e] == hi_src[c]) e++; if (hi_src[c] == from_hi) { - if (hiw_copy_fast() && !s_mask_set && !s_mask_check) { - /* The staged source already has exact RGBA8 colour and - * native bit15 alpha. Copy it once; as for ordinary - * textured draws, materialize mask bit0 only before - * destination masking next reads it. Bloom bit1 and - * depth stay untouched, matching the shader copy. */ - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, - from_hi ? s_hiw_scratch_fbo : s_scratch_fbo); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, T->fbo); - glDisable(GL_SCISSOR_TEST); - int scale = from_hi ? S : 1, origin = from_hi ? a : dx; - p_glBlitFramebuffer((c-origin)*scale, 0, (e-origin)*scale, h*scale, - (c-T->x0)*S, dy*S, (e-T->x0)*S, (dy+h)*S, - GL_COLOR_BUFFER_BIT, GL_NEAREST); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); - rect_add(&s_stencil_stale, c, dy, e-1, dy+h-1); - s_stencil_valid = 0; - s_hiw_copy_blits++; - c = e; - continue; - } if (!began) { if (from_hi) /* texel = frag - chunk origin in the tile */ hiw_blit_begin(T, s_hiw_scratch_tex, 1, -(a - T->x0) * S, @@ -3672,8 +3538,24 @@ static int s_tb_mask = 0, s_tb_filter = 0; static GLuint s_tb_bank_tex; static GLuint s_tb_hd_tex; -#define HD_GL_CACHE_CAP 256 -#define HD_GL_CACHE_BUDGET (128u * 1024u * 1024u) +/* A 4x pack of a whole race scene is several hundred textures and several hundred + * MiB with mips; a cache smaller than one scene re-uploads (and re-mips) every + * frame. Budget: PSX_HD_GL_CACHE_MIB (default 2048). Lookups go through a + * direct-mapped hint table so a draw does not scan every slot. */ +#define HD_GL_CACHE_CAP 4096 +#define HD_GL_HINTS 8192 +static size_t hd_gl_cache_budget(void) { + static size_t budget; + if (!budget) { + const char* e = getenv("PSX_HD_GL_CACHE_MIB"); + long mib = e ? atol(e) : 2048; + if (mib < 64) mib = 64; + if (mib > 16384) mib = 16384; + budget = (size_t)mib * 1024u * 1024u; + } + return budget; +} +#define HD_GL_CACHE_BUDGET hd_gl_cache_budget() typedef struct HdGlTexture { uint64_t generation, key; uint64_t last_use; @@ -3684,6 +3566,24 @@ typedef struct HdGlTexture { static HdGlTexture s_hd_gl_cache[HD_GL_CACHE_CAP]; static size_t s_hd_gl_cache_bytes; static uint64_t s_hd_gl_cache_clock; +static int16_t s_hd_gl_hint[HD_GL_HINTS]; /* slot + 1, 0 = none */ +static unsigned hd_gl_hint_of(uint64_t key, uint64_t generation) { + uint64_t h = (key ^ (generation * 0x9E3779B97F4A7C15ull)) * 0xD6E8FEB86659FD93ull; + return (unsigned)(h >> 51) & (HD_GL_HINTS - 1); +} +static int hd_gl_find(uint64_t key, uint64_t generation) { + const unsigned h = hd_gl_hint_of(key, generation); + const int hinted = s_hd_gl_hint[h] - 1; + if (hinted >= 0 && s_hd_gl_cache[hinted].texture && s_hd_gl_cache[hinted].key == key && + s_hd_gl_cache[hinted].generation == generation) return hinted; + for (int i = 0; i < HD_GL_CACHE_CAP; ++i) { + const HdGlTexture* texture = &s_hd_gl_cache[i]; + if (texture->texture && texture->key == key && texture->generation == generation) { + s_hd_gl_hint[h] = (int16_t)(i + 1); return i; + } + } + return -1; +} void gl_renderer_clear_hd_texture_cache(void) { /* Pack (re)configuration: the render thread must be idle before the @@ -3695,28 +3595,23 @@ void gl_renderer_clear_hd_texture_cache(void) { glDeleteTextures(1, &s_hd_gl_cache[i].texture); } memset(s_hd_gl_cache, 0, sizeof(s_hd_gl_cache)); + memset(s_hd_gl_hint, 0, sizeof(s_hd_gl_hint)); s_hd_gl_cache_bytes = 0; s_tb_hd_tex = 0; } int gl_renderer_hd_texture_cached(uint64_t key, uint64_t generation, uint32_t* width, uint32_t* height) { - for (int i = 0; i < HD_GL_CACHE_CAP; ++i) { - const HdGlTexture* texture = &s_hd_gl_cache[i]; - if (texture->texture && texture->key == key && texture->generation == generation) { - *width = texture->width; *height = texture->height; return 1; - } - } - return 0; + const int i = hd_gl_find(key, generation); + if (i < 0) return 0; + *width = s_hd_gl_cache[i].width; *height = s_hd_gl_cache[i].height; return 1; } static GLuint hd_gl_texture(const GpuHdTextureImage* image) { int free_slot = -1; - for (int i = 0; i < HD_GL_CACHE_CAP; ++i) { - HdGlTexture* texture = &s_hd_gl_cache[i]; - if (texture->texture && texture->key == image->cache_key && - texture->generation == image->generation) { - texture->last_use = ++s_hd_gl_cache_clock; return texture->texture; - } - if (!texture->texture && free_slot < 0) free_slot = i; + const int found = hd_gl_find(image->cache_key, image->generation); + if (found >= 0) { + s_hd_gl_cache[found].last_use = ++s_hd_gl_cache_clock; return s_hd_gl_cache[found].texture; } + for (int i = 0; i < HD_GL_CACHE_CAP && free_slot < 0; ++i) + if (!s_hd_gl_cache[i].texture) free_slot = i; if (!image->rgba || !image->width || !image->height || !image->source_width || !image->source_height || image->stride != (uint64_t)image->width * 4u || image->width > (uint32_t)s_gl_max_dim || image->height > (uint32_t)s_gl_max_dim) @@ -3742,19 +3637,25 @@ static GLuint hd_gl_texture(const GpuHdTextureImage* image) { glGenTextures(1, &texture->texture); p_glActiveTexture(PSXGL_TEXTURE0 + 2); glBindTexture(GL_TEXTURE_2D, texture->texture); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + /* Mip chain + linear filters: the shader samples them only when a texture + * filter is selected (hd_texel_filtered); nearest keeps texelFetch. */ + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + if (s_tex_aniso > 1) + glTexParameterf(GL_TEXTURE_2D, 0x84FE /* GL_TEXTURE_MAX_ANISOTROPY */, (float)s_tex_aniso); glPixelStorei(PSXGL_UNPACK_ROW_LENGTH, 0); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image->width, image->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image->rgba); + if (p_glGenerateMipmap) p_glGenerateMipmap(GL_TEXTURE_2D); p_glActiveTexture(PSXGL_TEXTURE0); if (glGetError() != GL_NO_ERROR) { glDeleteTextures(1, &texture->texture); texture->texture = 0; return 0; } texture->key = image->cache_key; texture->generation = image->generation; texture->last_use = ++s_hd_gl_cache_clock; + s_hd_gl_hint[hd_gl_hint_of(texture->key, texture->generation)] = (int16_t)(free_slot + 1); texture->bytes = bytes; s_hd_gl_cache_bytes += bytes; texture->width = image->width; texture->height = image->height; gpu_hd_textures_note_gl_cache(0); @@ -3769,9 +3670,7 @@ static void bind_textured_resources(GLuint source, GLuint palette, GLuint hd) { p_glActiveTexture(PSXGL_TEXTURE0 + 2); glBindTexture(GL_TEXTURE_2D, hd ? hd : s_up_tex); p_glUniform1i(s_uHdTexture, 2); p_glActiveTexture(PSXGL_TEXTURE0); - texel_cache_bind(); } -#include "gl_texel_cache.inc" static int s_tb_twin[4] = {0, 0, 0, 0}; static uint64_t s_batch_total = 0, s_batch_reason[8]; @@ -3827,6 +3726,7 @@ void gl_renderer_batch_diag(uint64_t out[9]) { * Opaque 2D, sprites and the HUD therefore clear it wherever they draw, * painter ordered like the colour; semi-transparent texels and primitives * leave it as it was. Bloom off: none of this runs and no state changes. */ +static GLint s_tex_uBcls = -1, s_geo_uBcls = -1, s_geo_uBadd = -1; /* No glGet here: on a threaded driver every query is a round trip, and these * run per batch. The passes set what they need; the caller's draw state is * then put back from what the caller knows (bcls_end_geo / tex_bcls_end). @@ -3866,48 +3766,14 @@ static int bcls_passes(int semi, int b3d) { if (b3d == 0) return BCLS_CLEAR; return BCLS_CLEAR | BCLS_SET; } -static uint64_t s_bcls_fused_draws; -static int bcls_fuse_extended(void) { - static int enabled = -1; - if (enabled < 0) { - const char *e = getenv("PSX_GL_BLOOM_FUSE_EXTEND"); - enabled = !(e && e[0] == '0'); - } - return enabled; -} -/* Opaque, uniformly classified batches can write bloom bit 1 with their - * colour, also clearing mask bit 0 as the unfused colour draw does. - * Destination masking / set-mask and mixed - * or translucent batches keep the existing coverage passes. */ -static int bcls_fuse_begin(int semi, int dmode, int mask, int check, int b3d, int tex) { - if (!coverage_fast_enabled() || !s_bcls_on || s_fg_drawing || - dmode == 2 || check || b3d < 0) return 0; - if (bcls_fuse_extended()) { - /* Translucent 3D sources set bloom on every covered fragment, just - * like their separate marking pass. A 2D translucent draw must keep - * the underlying bloom marks, so it retains the old path. */ - if (semi >= 0 && b3d != 1) return 0; - } else if (semi >= 0 || mask) return 0; - glEnable(GL_STENCIL_TEST); - glStencilMask(tex && semi == 4 && !mask ? 0x02 : 0x03); - glStencilFunc(GL_ALWAYS, (b3d ? 0x02 : 0x00) | (mask ? 1 : 0), 0xFF); - glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); - s_bcls_fused_draws++; - return 1; -} /* DRAW (an untextured draw set up with blend `semi`, mask_stencil_ex(mask, * check) and PGXP depth mode `dmode`; b3d as above), then (mask on) its * marking passes. GEO semi modes 1 and 3 add. A semi-transparent primitive * does not clear: what shows through it (a lamp under a fog or shade quad) * keeps glowing. */ #define BCLS_DRAW(semi, dmode, mask, check, b3d, DRAW) do { \ - const int bcls_add_ = bcls_fuse_extended() && ((semi) == 1 || (semi) == 3); \ - const int bcls_fused_ = bcls_fuse_begin((semi), (dmode), (mask), (check), bcls_add_ ? 1 : (b3d), 0); \ DRAW; \ - if (bcls_fused_) mask_stencil_ex((mask), (check)); \ - else if (s_bcls_on && (dmode) != 2 && !s_fg_drawing && \ - !(bcls_fuse_extended() && (semi) >= 0 && (b3d) == 0 && \ - (semi) != 1 && (semi) != 3)) { \ + if (s_bcls_on && (dmode) != 2 && !s_fg_drawing) { \ const int bcls_p_ = bcls_passes((semi), (b3d)); \ bcls_begin(0, (semi) == 1 || (semi) == 3); \ if (bcls_p_ & BCLS_CLEAR) { bcls_pass(0, 2); DRAW; } \ @@ -3921,21 +3787,19 @@ static int bcls_fuse_begin(int semi, int dmode, int mask, int check, int b3d, in /* tb_mask / check are the batch's set-mask and the mask-check state it was * drawn under; track_stencil lets the canonical pass record deferred stencil * (a replay into the high-resolution window must not touch that flag). */ -static void tex_draw_passes_impl(int nverts, int semi, int tb_mask, int check, - int track_stencil, int b3d, int dmode, int first) { - int fused = 0; +static void tex_draw_passes_ex(int nverts, int semi, int tb_mask, int check, + int track_stencil, int b3d) { p_glUniform1i(s_uSemimode, semi < 0 ? 0 : semi); if (semi < 0) { glDisable(GL_BLEND); mask_stencil_ex(tb_mask, check); p_glUniform1i(s_uSemipass, 0); /* all texels, one ordered colour pass */ - fused = bcls_fuse_begin(semi, dmode, tb_mask, check, b3d, 1); - glDrawArrays(GL_TRIANGLES, first, nverts); + glDrawArrays(GL_TRIANGLES, 0, nverts); if (check) { glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); /* stencil-only fixup */ mask_stencil_ex(1, check); p_glUniform1i(s_uSemipass, 2); /* STP=1 texels set the mask bit */ - glDrawArrays(GL_TRIANGLES, first, nverts); + glDrawArrays(GL_TRIANGLES, 0, nverts); glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); } else if (!tb_mask) { /* Alpha is already exact; defer its duplicate stencil encoding @@ -3952,30 +3816,23 @@ static void tex_draw_passes_impl(int nverts, int semi, int tb_mask, int check, p_glBlendFuncSeparate(GL_ONE, PSXGL_SRC1_ALPHA, GL_ONE, GL_ZERO); if (tb_mask) mask_stencil_ex(1, check); else glDisable(GL_STENCIL_TEST); p_glUniform1i(s_uSemipass, 0); - fused = bcls_fuse_begin(semi, dmode, tb_mask, check, b3d, 1); - glDrawArrays(GL_TRIANGLES, first, nverts); + glDrawArrays(GL_TRIANGLES, 0, nverts); if (!tb_mask && track_stencil) s_stencil_valid = 0; } else { glDisable(GL_BLEND); /* Pass 1: STP=0 texels (opaque) */ mask_stencil_ex(tb_mask, check); p_glUniform1i(s_uSemipass, 1); - fused = bcls_fuse_begin(semi, dmode, tb_mask, check, b3d, 1); - glDrawArrays(GL_TRIANGLES, first, nverts); + glDrawArrays(GL_TRIANGLES, 0, nverts); apply_psx_blend(semi); /* Pass 2: STP=1 texels (blended) */ mask_stencil_ex(1, check); p_glUniform1i(s_uSemipass, 2); - fused &= bcls_fuse_begin(semi, dmode, 1, check, b3d, 1); - glDrawArrays(GL_TRIANGLES, first, nverts); + glDrawArrays(GL_TRIANGLES, 0, nverts); } - if (fused) { - if (semi == 4 && !check && !tb_mask) glDisable(GL_STENCIL_TEST); - else mask_stencil_ex(semi < 0 || semi == 4 ? tb_mask : 1, check); - } - else if (s_bcls_on && !s_fg_drawing) { + if (s_bcls_on && !s_fg_drawing) { const int bp = bcls_passes(semi, b3d); bcls_begin(1, 0); - if (bp & BCLS_CLEAR) { bcls_pass(1, 2); glDrawArrays(GL_TRIANGLES, first, nverts); } - if (bp & BCLS_SET) { bcls_pass(1, 1); glDrawArrays(GL_TRIANGLES, first, nverts); } + if (bp & BCLS_CLEAR) { bcls_pass(1, 2); glDrawArrays(GL_TRIANGLES, 0, nverts); } + if (bp & BCLS_SET) { bcls_pass(1, 1); glDrawArrays(GL_TRIANGLES, 0, nverts); } /* Back to the state the branch above ended in. */ p_glUniform1i(s_tex_uBcls, 0); glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); @@ -3996,18 +3853,8 @@ static void tex_draw_passes_impl(int nverts, int semi, int tb_mask, int check, } } } -static void tex_draw_passes_ex(int nverts, int semi, int tb_mask, int check, - int track_stencil, int b3d, int dmode, int first) { - s_depth_probe_current = gl_cpu_mode() == 3 && dmode == 1 && - (s_hr_scale > 1 || (s_hiw && s_hq_replay_active)) && (++s_depth_probe_ordinal % 128u) == 0; - uint64_t sample = 0; - if (s_depth_probe_current) { glFinish(); sample = SDL_GetPerformanceCounter(); } - tex_draw_passes_impl(nverts, semi, tb_mask, check, track_stencil, b3d, dmode, first); - if (sample) { glFinish(); gl_cpu_end(7, sample); } -} - static void tex_batch_draw_passes(int nverts, int semi) { - tex_draw_passes_ex(nverts, semi, s_tb_mask, s_mask_check, 1, s_tb_b3d, s_tb_depth, 0); + tex_draw_passes_ex(nverts, semi, s_tb_mask, s_mask_check, 1, s_tb_b3d); } /* ---- frame_perf CPU attribution (native-wide wedge hunt) ----------------- * @@ -4076,7 +3923,6 @@ typedef struct { int8_t semi; /* tex: batch semi key; geo: blend mode, -1 opaque */ uint8_t mask; /* tex: batch set-mask; geo: stencil write value */ uint8_t check; /* mask-check state at draw time */ - int8_t b3d; /* uniform bloom-source classification, -1 mixed */ GLuint tex; /* sampled texture (bank or raw mirror) */ GLuint palette, hd_tex; int ax0, ay0, ax1, ay1; /* draw area, inclusive */ @@ -4094,55 +3940,18 @@ static int s_hq_n = 0, s_hq_cap = 0; static float *s_hq_v = NULL; static size_t s_hq_vn = 0, s_hq_vcap = 0; static uint64_t s_hq_flushes = 0, s_hq_cmds = 0; -static uint64_t s_hq_uploads, s_hq_saved_uploads; -static int hiw_upload_fast(void) { - static int enabled = -1; - if (enabled < 0) { - const char *e = getenv("PSX_GL_QUEUE_UPLOAD_FAST"); - enabled = !(e && e[0] == '0'); - } - return enabled; -} -/* Each VAO retains its existing vertex layout/buffer. Upload the packed queue - * once to each used buffer, then reuse it for every tile and wide target. - * Entries start at a multiple of their stride, so DrawArrays can select them - * without changing attribute pointers or orphaning a buffer per batch. */ -static void hiw_upload_queue(void) { - if (!hiw_upload_fast()) return; - int used[3] = {0}; - for (int i = 0; i < s_hq_n; ++i) used[s_hq[i].kind] = 1; - for (int kind = HQ_TEX; kind <= HQ_GEO; ++kind) { - if (!used[kind]) continue; - p_glBindBuffer(PSXGL_ARRAY_BUFFER, kind == HQ_TEX ? s_tex_vbo : s_geo_vbo); - p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(s_hq_vn * sizeof(float)), - s_hq_v, PSXGL_STREAM_DRAW); - s_hq_uploads++; - } -} -static int hiw_upload_cmd(const HiCmd *c) { - int stride = c->kind == HQ_TEX ? TEXV : 6; - if (hiw_upload_fast()) { - s_hq_saved_uploads++; - return (int)(c->vfirst / (size_t)stride); - } - p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)((size_t)c->vcount * stride * sizeof(float)), - s_hq_v + c->vfirst, PSXGL_STREAM_DRAW); - s_hq_uploads++; - return 0; -} static HiCmd *hiw_enqueue(int kind, const float *verts, int nverts, int stride) { size_t nf = (size_t)nverts * (size_t)stride; - size_t first = (s_hq_vn + (size_t)stride - 1) / (size_t)stride * (size_t)stride; if (s_hq_n == s_hq_cap) { int cap = s_hq_cap ? s_hq_cap * 2 : 256; HiCmd *q = (HiCmd *)realloc(s_hq, (size_t)cap * sizeof(HiCmd)); if (!q) return NULL; s_hq = q; s_hq_cap = cap; } - if (first + nf > s_hq_vcap) { + if (s_hq_vn + nf > s_hq_vcap) { size_t cap = s_hq_vcap ? s_hq_vcap : 65536; - while (cap < first + nf) cap *= 2; + while (cap < s_hq_vn + nf) cap *= 2; float *v = (float *)realloc(s_hq_v, cap * sizeof(float)); if (!v) return NULL; s_hq_v = v; s_hq_vcap = cap; @@ -4150,11 +3959,10 @@ static HiCmd *hiw_enqueue(int kind, const float *verts, int nverts, int stride) HiCmd *c = &s_hq[s_hq_n++]; memset(c, 0, sizeof *c); c->kind = (uint8_t)kind; - c->vfirst = first; + c->vfirst = s_hq_vn; c->vcount = nverts; - memset(s_hq_v + s_hq_vn, 0, (first - s_hq_vn) * sizeof(float)); - memcpy(s_hq_v + first, verts, nf * sizeof(float)); - s_hq_vn = first + nf; + memcpy(s_hq_v + s_hq_vn, verts, nf * sizeof(float)); + s_hq_vn += nf; c->check = (uint8_t)s_mask_check; c->depth = (uint8_t)s_hq_depth_cur; c->ax0 = s_area_x1; c->ay0 = s_area_y1; c->ax1 = s_area_x2; c->ay1 = s_area_y2; @@ -4184,14 +3992,6 @@ static int wide_queue_live(void) { return s_wide_queue && !s_hiw && g_wide_cur && s_ws_ablate == 0; } static void wide_band_rows(int *sy, int *sh); -static int wide_margin_scissor(void) { - static int enabled = -1; - if (enabled < 0) { - const char *e = getenv("PSX_GL_WIDE_MARGIN_SCISSOR"); - enabled = !(e && e[0] == '0'); - } - return enabled; -} static void hiw_wide_set(HiCmd *c, int gate) { c->wfbo = g_wide_cur; c->wdx = wide_dx(); @@ -4218,7 +4018,6 @@ static int hiw_enqueue_tex(int nverts, int semi, int mirror, int gate) { HiCmd *c = hiw_enqueue(HQ_TEX, s_tb, nverts, TEXV); if (!c) { hiw_flush_queue(); return 0; } c->semi = (int8_t)semi; - c->b3d = coverage_fast_enabled() ? (int8_t)s_tb_b3d : -1; c->mask = (uint8_t)s_tb_mask; c->tex = s_tb_bank_tex ? s_tb_bank_tex : s_raw_tex; c->palette = s_tb_bank_tex && !s_tb_bank_live_clut ? s_tb_bank_tex : s_raw_tex; @@ -4228,41 +4027,17 @@ static int hiw_enqueue_tex(int nverts, int semi, int mirror, int gate) { } static int hiw_enqueue_geo(const float *verts, int nverts, int semi, int mask, - int mirror, int gate, int b3d) { + int mirror, int gate) { int wq = hiw_wide_queue_ok(mirror); if (!wq && !hiw_area_touches()) return 0; HiCmd *c = hiw_enqueue(HQ_GEO, verts, nverts, 6); if (!c) { hiw_flush_queue(); return 0; } c->semi = (int8_t)semi; - c->b3d = coverage_fast_enabled() ? (int8_t)b3d : -1; c->mask = (uint8_t)(mask ? 1 : 0); if (wq) hiw_wide_set(c, gate); return wq; } -/* Prune only the final flush immediately followed by a canonical centre - * splice. A predicate at enqueue time is insufficient: a later shifted view - * or background can require the earlier centre pixels before presentation. */ -static GLuint s_hq_center_overwrite; -static uint64_t s_hq_center_pruned; -static void hiw_flush_for_center(GLuint fbo) { - s_hq_center_overwrite = wide_margin_scissor() && wide_center_is_canonical() && - g_wide_off > 0 && g_wide_w > 2*g_wide_off ? fbo : 0; - hiw_flush_queue(); - s_hq_center_overwrite = 0; -} -static int hiw_wide_scissors(const HiCmd *c, int x[2], int w[2]) { - int end = c->wsx + c->wsw, n = 0; - int cut0 = g_wide_off, cut1 = g_wide_w-g_wide_off; - if (c->wfbo != s_hq_center_overwrite) { x[0] = c->wsx; w[0] = c->wsw; return 1; } - s_hq_center_pruned++; - int left = end < cut0 ? end : cut0; - int right = c->wsx > cut1 ? c->wsx : cut1; - if (left > c->wsx) { x[n] = c->wsx; w[n++] = left-c->wsx; } - if (end > right) { x[n] = right; w[n++] = end-right; } - return n; -} - /* Replay the queued native-wide mirrors, in order, into their wide surfaces: * the state wide_target_begin sets (full-width scissor, the draw area's rows, * the S sample-grid shift) plus each entry's shift and backdrop stretch. */ @@ -4289,7 +4064,7 @@ static void hiw_replay_wide(void) { bound = c->wfbo; cur = 0; } - int sx[2], sw[2], scissors = hiw_wide_scissors(c, sx, sw); + glScissor(c->wsx * S, c->wsy * S, c->wsw * S, c->wsh * S); if (c->kind == HQ_TEX) { if (cur != HQ_TEX) { p_glUseProgram(s_tex_prog); @@ -4302,13 +4077,11 @@ static void hiw_replay_wide(void) { p_glUniform1f(s_tex_uXcenter, c->wcenter); bind_textured_resources(c->tex, c->palette, c->hd_tex); p_glUniform1i(s_uMaskset, c->mask); - int first = hiw_upload_cmd(c); - for (int r=0; rwsy * S, sw[r] * S, c->wsh * S); - depth_apply_ex(c->depth, 1); - tex_draw_passes_ex(c->vcount, c->semi, c->mask, c->check, 0, c->b3d, c->depth, first); - depth_restore_ex(c->depth, 1, c->vcount, c->semi, c->mask, first); - } + p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)((size_t)c->vcount * TEXV * sizeof(float)), + s_hq_v + c->vfirst, PSXGL_STREAM_DRAW); + depth_apply_ex(c->depth, 1); + tex_draw_passes_ex(c->vcount, c->semi, c->mask, c->check, 0, -1); + depth_restore_ex(c->depth, 1, c->vcount); } else { if (cur != HQ_GEO) { p_glUseProgram(s_geo_prog); @@ -4319,15 +4092,13 @@ static void hiw_replay_wide(void) { p_glUniform1f(s_geo_uXoff, (float)c->wdx); p_glUniform1f(s_geo_uXscale, c->wscale); p_glUniform1f(s_geo_uXcenter, c->wcenter); - int first = hiw_upload_cmd(c); - for (int r=0; rwsy * S, sw[r] * S, c->wsh * S); - if (c->semi >= 0) apply_psx_blend(c->semi); else glDisable(GL_BLEND); - mask_stencil_ex(c->mask, c->check); - depth_apply_ex(c->depth, 0); - BCLS_DRAW(c->semi, c->depth, c->mask, c->check, c->b3d, glDrawArrays(GL_TRIANGLES, first, c->vcount)); - depth_restore_ex(c->depth, 0, c->vcount, c->semi, c->mask, first); - } + if (c->semi >= 0) apply_psx_blend(c->semi); else glDisable(GL_BLEND); + mask_stencil_ex(c->mask, c->check); + p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)((size_t)c->vcount * 6 * sizeof(float)), + s_hq_v + c->vfirst, PSXGL_STREAM_DRAW); + depth_apply_ex(c->depth, 0); + BCLS_DRAW(c->semi, c->depth, c->mask, c->check, -1, glDrawArrays(GL_TRIANGLES, 0, c->vcount)); + depth_restore_ex(c->depth, 0, c->vcount); } } if (!any) return; @@ -4344,9 +4115,8 @@ static void hiw_replay_wide(void) { * and each command's draw area (clipped to the tile) as the scissor. A tile no * command touches is not bound. */ static void hiw_flush_tail(void); -static void hiw_flush_queue_timed_impl(void) { +static void hiw_flush_queue(void) { if (s_hq_n == 0) return; - hiw_upload_queue(); if (!hiw_on()) { /* native-wide mirrors only (wide_queue_live) */ s_hq_flushes++; hiw_flush_tail(); @@ -4391,10 +4161,11 @@ static void hiw_flush_queue_timed_impl(void) { } bind_textured_resources(c->tex, c->palette, c->hd_tex); p_glUniform1i(s_uMaskset, c->mask); - int first = hiw_upload_cmd(c); + p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)((size_t)c->vcount * TEXV * sizeof(float)), + s_hq_v + c->vfirst, PSXGL_STREAM_DRAW); depth_apply_ex(c->depth, 1); - tex_draw_passes_ex(c->vcount, c->semi, c->mask, c->check, 0, c->b3d, c->depth, first); - depth_restore_ex(c->depth, 1, c->vcount, c->semi, c->mask, first); + tex_draw_passes_ex(c->vcount, c->semi, c->mask, c->check, 0, -1); + depth_restore_ex(c->depth, 1, c->vcount); } else { if (cur != HQ_GEO) { p_glUseProgram(s_geo_prog); @@ -4404,26 +4175,17 @@ static void hiw_flush_queue_timed_impl(void) { } if (c->semi >= 0) apply_psx_blend(c->semi); else glDisable(GL_BLEND); mask_stencil_ex(c->mask, c->check); - int first = hiw_upload_cmd(c); + p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)((size_t)c->vcount * 6 * sizeof(float)), + s_hq_v + c->vfirst, PSXGL_STREAM_DRAW); depth_apply_ex(c->depth, 0); - BCLS_DRAW(c->semi, c->depth, c->mask, c->check, c->b3d, glDrawArrays(GL_TRIANGLES, first, c->vcount)); - depth_restore_ex(c->depth, 0, c->vcount, c->semi, c->mask, first); + BCLS_DRAW(c->semi, c->depth, c->mask, c->check, -1, glDrawArrays(GL_TRIANGLES, 0, c->vcount)); + depth_restore_ex(c->depth, 0, c->vcount); } } } hiw_flush_tail(); } -static void hiw_flush_queue(void) { - uint64_t start = gl_cpu_begin(1); - int previous = s_hq_replay_active; - s_hq_replay_active = 1; - hiw_flush_queue_timed_impl(); - s_hq_replay_active = previous; - if (!s_tb_n && !s_hq_n) texel_cache_unpin(); - gl_cpu_end(1, start); -} - /* The native-wide mirrors (both modes), then the canonical projection back * on both programs. */ static void hiw_flush_tail(void) { @@ -4464,7 +4226,7 @@ static void flush_tex_batch_impl(void) { depth_apply_ex(dmode, 1); tex_batch_draw_passes(nverts, semi); - depth_restore_ex(dmode, 1, nverts, semi, s_tb_mask, 0); + depth_restore_ex(dmode, 1, nverts); /* Native-wide mirror — skipped for a batch fully inside the 4:3 frame (its * centre content comes from the present-time canonical blit; nothing to add @@ -4493,14 +4255,13 @@ static void flush_tex_batch_impl(void) { wide_set_bd_scale(s_tex_uXscale, s_tex_uXcenter); depth_apply_ex(dmode, 1); if (s_ws_ablate != 2) tex_batch_draw_passes(nverts, semi); - depth_restore_ex(dmode, 1, nverts, semi, s_tb_mask, 0); + depth_restore_ex(dmode, 1, nverts); wide_clear_bd_scale(s_tex_uXscale, s_tex_uXcenter); wide_target_end(s_tex_uXoff, s_tex_uXhalf); gl_perf_mirror_end(); } hr_end(); if (--s_cw_flush_depth == 0) s_cw_flush_ms += cw_ms() - cw_t0; - if (!s_tb_n && !s_hq_n) texel_cache_unpin(); } /* Flat / gouraud GEO batch — MotK title/char-select starfields issue ~30k/s @@ -4606,7 +4367,7 @@ static void flush_flat_batch_impl(void) { depth_apply_ex(dmode, 0); BCLS_DRAW(semi, dmode, mask, s_mask_check, b3d, if (nl) flat_batch_draw_hr_lines(nverts, nl); else glDrawArrays(fmode, 0, nverts)); - depth_restore_ex(dmode, 0, nverts, semi, mask, 0); + depth_restore_ex(dmode, 0, nverts); int mirror = g_wide_cur && !s_wide_suppress && s_ws_ablate != 1 && !(!g_ws_bd_stretch_on && mirror_flat_batch_center_only(nverts)); if (mirror) { @@ -4616,7 +4377,7 @@ static void flush_flat_batch_impl(void) { } s_hq_depth_cur = dmode; if ((hiw_on() || (mirror && fmode == GL_TRIANGLES && wide_queue_live())) && - hiw_enqueue_geo(s_fb, nverts, semi, mask, mirror, s_fb_gate, b3d)) mirror = 0; + hiw_enqueue_geo(s_fb, nverts, semi, mask, mirror, s_fb_gate)) mirror = 0; s_hq_depth_cur = 0; if (mirror) { @@ -4627,7 +4388,7 @@ static void flush_flat_batch_impl(void) { wide_set_bd_scale(s_geo_uXscale, s_geo_uXcenter); depth_apply_ex(dmode, 0); if (s_ws_ablate != 2) BCLS_DRAW(semi, dmode, mask, s_mask_check, b3d, glDrawArrays(fmode, 0, nverts)); - depth_restore_ex(dmode, 0, nverts, semi, mask, 0); + depth_restore_ex(dmode, 0, nverts); wide_clear_bd_scale(s_geo_uXscale, s_geo_uXcenter); wide_target_end(s_geo_uXoff, s_geo_uXhalf); gl_perf_mirror_end(); @@ -5015,7 +4776,7 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, } if ((hiw_on() || (mirror && draw_mode == GL_TRIANGLES && wide_queue_live())) && hiw_enqueue_geo(draw_mode == GL_TRIANGLES && is_line ? quad : verts, - draw_n, semi, s_mask_set, mirror, bd_prim_gate(xs, n, 0), -1)) + draw_n, semi, s_mask_set, mirror, bd_prim_gate(xs, n, 0))) mirror = 0; if (mirror) { int dx = wide_dx(); @@ -5210,13 +4971,6 @@ static void gpu_textured_triangle(const int *xs, const int *ys, flush_tex_batch(); } if (s_tb_n + 3 > TEXBATCH_MAXV) { s_batch_reason[6]++; flush_tex_batch(); } - const int cache_bounds = (twx | twy) || - (lim[0] >= 0 && lim[1] >= 0 && lim[2] <= 255 && lim[3] <= 255 && - lim[0] <= lim[2] && lim[1] <= lim[3]); - int decoded = hd_tex || !cache_bounds ? -1 : texel_cache_get( - s_selected_bank_tex ? s_selected_bank_tex : s_raw_tex, - s_selected_bank_tex && !s_selected_bank_live_clut ? s_selected_bank_tex : s_raw_tex, - base_x,base_y,depth,clut_x,clut_y); if (s_tb_n == 0) { /* opening a batch: capture its keyed state */ s_tb_semi = batch_semi; s_tb_mask = s_mask_set; s_tb_filter = filter; s_tb_gate = gate; s_tb_depth = tdmode; @@ -5237,8 +4991,7 @@ static void gpu_textured_triangle(const int *xs, const int *ys, vp[4] = col[i*3+0]; vp[5] = col[i*3+1]; vp[6] = col[i*3+2]; vp[7] = 1.0f; vp[8] = (float)base_x; vp[9] = (float)base_y; /* a_tpage */ vp[10] = (float)clut_x; vp[11] = (float)clut_y; /* a_clut */ - vp[12] = (float)(decoded < 0 ? depth : decoded + 4); - vp[13] = (float)rawtex; /* a_depth, a_raw */ + vp[12] = (float)depth; vp[13] = (float)rawtex; /* a_depth, a_raw */ vp[14] = (float)lim[0]; vp[15] = (float)lim[1]; /* a_limits */ vp[16] = (float)lim[2]; vp[17] = (float)lim[3]; vp[18] = (semi >= 0 ? (float)(semi + 1) : 0.0f) /* a_semi code */ @@ -5442,38 +5195,8 @@ static void gpu_fill(int x,int y,int w,int h,uint16_t c) { /* VRAM->VRAM copy: blit the source region to the scratch texture (resolves * overlap), then draw it back at the destination through the BLIT program so * mask set/check and the stencil mirror apply, exactly like sw_copy_rect. */ -static uint64_t s_identity_copies; -static int identity_copy_fast(void) { - static int enabled = -1; - if (enabled < 0) { - const char *e = getenv("PSX_GL_IDENTITY_COPY_FAST"); - enabled = !(e && e[0] == '0'); - } - return enabled; -} static void gpu_copy_rect(int sx,int sy,int dx,int dy,int w,int h) { if (w <= 0 || h <= 0) return; - if (identity_copy_fast() && sx == dx && sy == dy && !s_mask_set && - sx >= 0 && sy >= 0 && sx < VRAM_W && sy < VRAM_H) { - /* No pixel can change. Retain the existing primitive batch boundary - * (PGXP's two-pass depth policy is batch-sensitive), but don't drain - * deferred high-resolution scenes or stage identical pixels. - * The guest GPU command and its scheduled device timing still run. */ - flush_flat_batch(); - flush_tex_batch(); - s_identity_copies++; - return; - } - if (gl_cpu_mode()) { - static int seen[32][6], used; - int key[6] = {sx,sy,dx,dy,w,h}, i; - for (i=0; i %d,%d %dx%d mask=%d/%d\n", - sx,sy,dx,dy,w,h,s_mask_set,s_mask_check); - } - } flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); @@ -5793,7 +5516,7 @@ static void glb_fill_rect(int x,int y,int w,int h,uint16_t c){ if (!s_raster_ok) return; gpu_fill(x,y,w,h,c); } -static void glb_copy_rect_timed_impl(int sx,int sy,int dx,int dy,int w,int h){ +static void glb_copy_rect(int sx,int sy,int dx,int dy,int w,int h){ if (pass_refuse_write("copy", dx, dy, w, h)) return; if (cpu_raster_required()) { int faithful = native_draw_begin(); @@ -5803,12 +5526,6 @@ static void glb_copy_rect_timed_impl(int sx,int sy,int dx,int dy,int w,int h){ if (!s_raster_ok) return; gpu_copy_rect(sx,sy,dx,dy,w,h); } - -static void glb_copy_rect(int sx,int sy,int dx,int dy,int w,int h) { - uint64_t start = gl_cpu_begin(2); - glb_copy_rect_timed_impl(sx,sy,dx,dy,w,h); - gl_cpu_end(2, start); -} static void glb_draw_textured_triangle(int x0,int y0,int u0,int v0,int x1,int y1,int u1,int v1,int x2,int y2,int u2,int v2,uint16_t cx,uint16_t cy,uint16_t tp){ const int native_required = cpu_raster_required(); if (native_required && !s_hd_native_authority) @@ -6002,7 +5719,6 @@ static void depth24_clear_skipped_fb(void) { glViewport(0, 0, VRAM_W, VRAM_H); glScissor(x0, y0, rw, rh); glClear(GL_COLOR_BUFFER_BIT); - texel_cache_dirty(x0,y0,x1,y1); } glDisable(GL_SCISSOR_TEST); p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); @@ -6497,9 +6213,6 @@ static int init_gpu_raster(void) { s_uLimits = p_glGetUniformLocation(s_tex_prog, "u_limits"); s_uHdTexture = p_glGetUniformLocation(s_tex_prog, "u_hd_texture"); s_tex_uBcls = p_glGetUniformLocation(s_tex_prog, "u_bcls"); - p_glUseProgram(s_tex_prog); - p_glUniform1i(p_glGetUniformLocation(s_tex_prog, "u_coverage_fast"), coverage_fast_enabled()); - p_glUniform1i(p_glGetUniformLocation(s_tex_prog, "u_decoded"), 3); s_geo_uBcls = p_glGetUniformLocation(s_geo_prog, "u_bcls"); s_geo_uBadd = p_glGetUniformLocation(s_geo_prog, "u_badd"); s_uBlitSrc = p_glGetUniformLocation(s_blit_prog, "u_src"); @@ -6712,7 +6425,7 @@ int gl_renderer_scale_info(GlScaleInfo *out) { int wmax = s_gl_max_dim / VRAM_H; out->max_scale = wmax > GL_MAX_INTERNAL_SCALE ? GL_MAX_INTERNAL_SCALE : wmax; } - if (s_ctx && s_win) gl_drawable_size(&out->drawable_w, &out->drawable_h); + if (s_ctx && s_win) SDL_GL_GetDrawableSize(s_win, &out->drawable_w, &out->drawable_h); return s_raster_ok; } @@ -6821,14 +6534,6 @@ int gl_renderer_select_texture_bank_live_clut(uint16_t id) { int gl_renderer_init_context(SDL_Window *win) { s_win = win; - texel_cache_reset(); - s_window_owner = window_thread_id(); -#if defined(PSX_SDL3) - if (s_drawable_watch) SDL_RemoveEventWatch(drawable_event_watch, NULL); - atomic_store(&s_drawable_window_id, SDL_GetWindowID(win)); - s_drawable_watch = SDL_AddEventWatch(drawable_event_watch, NULL) ? 1 : 0; -#endif - gl_drawable_refresh(); s_present_w = 0; s_present_h = 0; SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); @@ -6950,33 +6655,9 @@ int gl_renderer_get_swap_interval(void) { static void pass_resources_release(void); static void present_image_ring_gl_forget(void); -void gl_renderer_work_stats(GlRenderWorkStats *out) { - if (!out) return; - GL_RT_SYNC("work_stats"); - *out = s_cpu_work; - out->frequency = SDL_GetPerformanceFrequency(); - out->counts[0] = s_hq_uploads; out->counts[1] = s_hq_saved_uploads; - out->counts[2] = s_bcls_fused_draws; out->counts[3] = s_coverage_depth_draws; - out->counts[4] = s_hq_center_pruned; - out->counts[5] = s_tc_hits; out->counts[6] = s_tc_misses; out->counts[7] = s_tc_evictions; - out->counts[8] = s_hiw_copy_blits; - out->counts[9] = s_identity_copies; -} void gl_renderer_shutdown(void) { gl_renderer_render_thread_stop(); /* drains; the context comes back to this thread */ psx_host_set_hud_draw_cb(NULL, NULL); -#if defined(PSX_SDL3) - if (s_drawable_watch) SDL_RemoveEventWatch(drawable_event_watch, NULL); - s_drawable_watch = 0; - atomic_store(&s_drawable_window_id, 0); -#endif - const char *perf = getenv("PSX_RUNTIME_PERF_DIAG"); - if (s_ctx && perf && perf[0] && perf[0] != '0') - fprintf(stdout, "psxrecomp: GL work totals: queue_uploads=%llu replay_draws=%llu " - "bloom_fused=%llu coverage_depth=%llu centre_pruned=%llu\n", - (unsigned long long)s_hq_uploads, (unsigned long long)s_hq_saved_uploads, - (unsigned long long)s_bcls_fused_draws, (unsigned long long)s_coverage_depth_draws, - (unsigned long long)s_hq_center_pruned); /* After the worker has stopped (it presents, and captures, on its own * thread) and before the context dies: forget the ring's GL names. */ present_image_ring_gl_forget(); @@ -7035,7 +6716,7 @@ void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linea GL_RT_SYNC("present"); if (!s_ctx) return; interp_reset_history(); - int ww = 0, wh = 0; gl_drawable_size(&ww, &wh); + int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); glDisable(GL_SCISSOR_TEST); glViewport(0, 0, ww, wh); glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); @@ -7137,7 +6818,7 @@ void gl_renderer_present_blank(void) { GL_RT_SYNC("present_blank"); if (!s_ctx) return; interp_reset_history(); - int ww = 0, wh = 0; gl_drawable_size(&ww, &wh); + int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); glDisable(GL_SCISSOR_TEST); glViewport(0, 0, ww, wh); glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); pres_record(GL_PRES_BLANK, 0, 0, 0, 0, 0, 0, ww, wh); @@ -7876,11 +7557,8 @@ static void gl_perf_yield(void) { static void gl_perf_init(void) { #ifdef PSX_NO_DEBUG_TOOLS - /* Production profiling is explicit: timer queries can perturb driver - * scheduling, so acceptance runs leave them off. No debug server needed. */ - const char *requested = getenv("PSX_GL_PERF"); - if (!requested || requested[0] != '1') return; -#endif + return; +#else /* Timer queries are intentionally available in diagnostics builds, but a * driver may serialize command submission while collecting them. Keep an * escape hatch so frame cadence can be A/B tested without rebuilding or @@ -7904,6 +7582,7 @@ static void gl_perf_init(void) { s_pf_b = 0; s_pf_scene_active = 0; s_pf_count = 0; s_pf_ring_seq = 0; s_pf_last_enter = 0; s_pf_on = 1; +#endif } /* Bracket ONE native-wide mirror pass (called from the wide-mirror draw sites). @@ -8065,7 +7744,6 @@ void gl_renderer_set_ws_ablate(int mode) { int gl_renderer_get_ws_ablate(void) { return s_ws_ablate; } static void interp_reset_history_unlocked(void) { - atomic_store_explicit(&s_interp_async_cadence, 0, memory_order_release); s_interp_valid = 0; s_interp_w = s_interp_h = 0; s_interp_src_w = s_interp_src_h = 0; @@ -8161,10 +7839,6 @@ int gl_renderer_interpolation_owns_cadence(void) { return s_ctx && s_interp_enabled && !interp_suspended_now(); } -int gl_renderer_interpolation_async_cadence(void) { - return atomic_load_explicit(&s_interp_async_cadence, memory_order_acquire); -} - void gl_renderer_interpolation_diag(int *enabled, int *suspended, int *history_frames, double *host_hz, double *target_hz, @@ -8180,7 +7854,7 @@ void gl_renderer_interpolation_diag(int *enabled, int *suspended, /* Copy a stable display image out of the mutable VRAM/wide render target. * Returns true when temporal blending owns this source-frame interval. */ -static int interp_capture_timed_impl(GLuint fbo, int x, int y, int w, int h, +static int interp_capture(GLuint fbo, int x, int y, int w, int h, int linear, int force_4_3, int source_path, int origin_x, int origin_y, int redrawn) { if (!s_interp_enabled || interp_suspended_now() || !fbo || w <= 0 || h <= 0) return 0; @@ -8257,15 +7931,6 @@ static int interp_capture_timed_impl(GLuint fbo, int x, int y, int w, int h, return 1; } -static int interp_capture(GLuint fbo, int x, int y, int w, int h, - int linear, int force_4_3, int source_path, - int origin_x, int origin_y, int redrawn) { - uint64_t start = gl_cpu_begin(3); - int result = interp_capture_timed_impl(fbo,x,y,w,h,linear,force_4_3,source_path,origin_x,origin_y,redrawn); - gl_cpu_end(3, start); - return result; -} - static void interp_draw_textures(GLuint prev_tex, GLuint curr_tex, float alpha, int blend_mode, int lx, int ly, int lw, int lh) { p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); @@ -8301,7 +7966,7 @@ static int interp_present_pair(GLuint a, GLuint b, float t, int blend_mode) { if (!s_ctx || !s_interp_enabled || interp_suspended_now() || s_interp_valid < 1) return 0; uint64_t present_t0 = SDL_GetPerformanceCounter(); - int ww = 0, wh = 0; gl_drawable_size(&ww, &wh); + int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); int lx, ly, lw, lh; if (s_interp_force_4_3) letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); @@ -8339,7 +8004,7 @@ static int interp_present(float alpha) { s_interp_blend_mode); } -static void interp_wait_until_ex(uint64_t deadline, uint64_t frequency, int render_idle) { +static void interp_wait_until(uint64_t deadline, uint64_t frequency) { uint64_t now, start; if (!deadline || !frequency) return; start = SDL_GetPerformanceCounter(); @@ -8347,10 +8012,8 @@ static void interp_wait_until_ex(uint64_t deadline, uint64_t frequency, int rend now = SDL_GetPerformanceCounter(); if (now >= deadline) { /* Idle host time feeds the render-pass budget. */ - if (render_idle) { - (void)s_idle_ticks_accum_fwd(now - start); - if (s_dyn_on) s_dyn_ledger.idle_ticks += now - start; - } + (void)s_idle_ticks_accum_fwd(now - start); + if (s_dyn_on) s_dyn_ledger.idle_ticks += now - start; return; } uint64_t remain = deadline - now; @@ -8363,28 +8026,8 @@ static void interp_wait_until_ex(uint64_t deadline, uint64_t frequency, int rend } } -static void interp_wait_until(uint64_t deadline, uint64_t frequency) { - interp_wait_until_ex(deadline, frequency, 1); -} -static int hiw_async_pacing(void) { - static int enabled = -1; - if (enabled < 0) { - const char *e = getenv("PSX_GL_HIW_ASYNC_PACE"); - enabled = !(e && e[0] == '0'); - } - /* HiW refuses temporal render transactions already. Hold at guest rate - * needs one composed output per VBlank, with its wait on the presenter so - * the renderer can prepare the next scene. Other modes retain their render - * pass/idle-credit schedule unchanged. */ - return enabled && s_hiw && s_interp_hold && s_pt_on && s_rth_on && - rt_on_render_thread() && s_interp_target_hz <= s_interp_source_hz + 1e-6; -} - -static void interp_present_source_interval_timed_impl(void) { +static void interp_present_source_interval(void) { static uint64_t diag_start, diag_swaps, diag_captures; - const int asynchronous = hiw_async_pacing(); - s_interp_schedule.queued = asynchronous; - atomic_store_explicit(&s_interp_async_cadence, asynchronous, memory_order_release); uint64_t frequency = SDL_GetPerformanceFrequency(); uint64_t now = SDL_GetPerformanceCounter(); uint64_t deadline; @@ -8400,16 +8043,14 @@ static void interp_present_source_interval_timed_impl(void) { while (frame_interpolation_schedule_next( &s_interp_schedule, SDL_GetPerformanceCounter(), &deadline, &alpha)) { - if (!asynchronous) interp_wait_until(deadline, frequency); - s_interp_pending_deadline = asynchronous ? deadline : 0; + interp_wait_until(deadline, frequency); latency_ring_mark(LAT_SWAP_BEGIN); if (!pass_gen_present(deadline)) (void)interp_present(alpha); latency_ring_mark(LAT_SWAP_END); - s_interp_pending_deadline = 0; } - if (!asynchronous) - interp_wait_until(frame_interpolation_schedule_end(&s_interp_schedule), frequency); + interp_wait_until(frame_interpolation_schedule_end(&s_interp_schedule), + frequency); now = SDL_GetPerformanceCounter(); if (!diag_start) { @@ -8430,12 +8071,6 @@ static void interp_present_source_interval_timed_impl(void) { } } -static void interp_present_source_interval(void) { - uint64_t start = gl_cpu_begin(4); - interp_present_source_interval_timed_impl(); - gl_cpu_end(4, start); -} - /* ==== Render passes (render_pass.c, docs/RENDER_PASSES.md) ================== * * VRAM transaction. A pass draws into the guest's own display rect, which @@ -8611,9 +8246,6 @@ static void pass_refusal_init(void) { } uint32_t gl_renderer_pass_unavailable(void) { - /* Allocation-mode invariant, set at context initialization. A refusal - * must not drain the render queue on every guest draw hook. */ - if (s_hiw) return PSX_MOD_RENDER_PASS_BACKEND; GL_RT_SYNC("pass_unavailable"); pass_refusal_init(); if (!s_ctx || !s_raster_ok || !s_interp_enabled || s_interp_suspended || @@ -8994,7 +8626,6 @@ static void pass_journal_rollback(void) { pass_blit(j->hr_fbo, s_hr_fbo, 0, 0, x * S, y * S, w * S, h * S, GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); pass_blit(j->raw_fbo, s_raw_fbo, 0, 0, x, y, w, h, GL_COLOR_BUFFER_BIT); - texel_cache_dirty(x,y,x+w-1,y+h-1); } render_pass_journal_rollback(&s_pj_cpu, s_vram, VRAM_W); } @@ -9171,7 +8802,6 @@ static void transaction_restore_ex(int keep_color) { s_pass_w * S, s_pass_h * S, hr_bits); pass_blit(s_pb_raw_fbo, s_raw_fbo, 0, 0, s_pass_x, s_pass_y, s_pass_w, s_pass_h, GL_COLOR_BUFFER_BIT); - texel_cache_dirty(s_pass_x,s_pass_y,s_pass_x+s_pass_w-1,s_pass_y+s_pass_h-1); if (s_pb_wide_src && s_pb_wide_src == pass_wide_fbo_for(s_pass_x)) { pass_blit(s_pb_wide_fbo, s_pb_wide_src, 0, 0, 0, s_pass_y * S, g_wide_w * S, s_pass_h * S, hr_bits); @@ -9668,7 +9298,7 @@ static void openxr_present_native(void) { int ww=0,wh=0,*r=s_native_surface_rect; if(!s_native_surface_enabled || !s_native_surface_pending || s_pass_active)return; s_native_surface_pending=0; - gl_drawable_size(&ww,&wh); + SDL_GL_GetDrawableSize(s_win,&ww,&wh); if(r[0]<0 || r[1]<0 || r[2]<1 || r[3]<1 || r[0]+r[2]>ww || r[1]+r[3]>wh)return; /* No replay or retained pair: this exact native present is the source. * Direct lifecycle permits native 24-bit videos as well as 15-bit UI. */ @@ -9696,7 +9326,7 @@ static int stereo_present(int w, int h) { if (!s_stereo_mode || !s_stereo_valid || s_pass_active || p->w != w || p->h != h || p->tw[0] != w * s_hr_scale || gl_renderer_stereo_unavailable() != PSX_MOD_RENDER_PASS_READY) return 0; - gl_drawable_size(&ww, &wh); + SDL_GL_GetDrawableSize(s_win, &ww, &wh); gl_perf_present_enter(); /* Desktop spectator layout while XR runs: default single left eye filled to * the window, PSX_VR_DESKTOP_MIRROR selects 0 legacy / 1 left / 2 SBS / 3 right. */ @@ -10033,7 +9663,7 @@ static void present_image_ring_capture_gl(void) { if (s_pir_disabled || !p_glMapBuffer || !p_glUnmapBuffer) return; if (!present_image_ring_accepting()) return; int ww = 0, wh = 0; - gl_drawable_size(&ww, &wh); + SDL_GL_GetDrawableSize(s_win, &ww, &wh); const int tw = present_image_ring_thumb_w(ww, wh); const int th = PRESENT_IMAGE_RING_H; if (tw <= 0) return; @@ -10144,7 +9774,6 @@ static int s_pt_want = 0, s_pt_slots = 3; static GLenum s_pt_format = GL_RGBA8; /* the window's: no alpha -> GL_RGB8 */ static SDL_GLContext s_pt_ctx = NULL; static GLuint s_pts_tex[PT_MAX_SLOTS], s_pts_rb[PT_MAX_SLOTS], s_pts_fbo[PT_MAX_SLOTS]; -static uint64_t s_pts_deadline[PT_MAX_SLOTS]; static int s_pts_w[PT_MAX_SLOTS], s_pts_h[PT_MAX_SLOTS]; static unsigned s_pts_gen[PT_MAX_SLOTS]; /* present thread's own objects (FBOs are per-context) */ @@ -10163,7 +9792,7 @@ static GLuint pt_target_fbo(void) { const int i = pt_current(); if (i < 0) return 0; int ww = 0, wh = 0; - gl_drawable_size(&ww, &wh); + SDL_GL_GetDrawableSize(s_win, &ww, &wh); if (ww < 1) ww = 1; if (wh < 1) wh = 1; if (s_pts_fbo[i] && s_pts_w[i] == ww && s_pts_h[i] == wh) return s_pts_fbo[i]; @@ -10213,8 +9842,6 @@ static int pt_cb_ctx(void *user, int current) { /* The one owned swap call: the composing thread's (direct) or the present * thread's. */ -static uint64_t s_swap_times[GL_SWAP_TIMING_CAP], s_swap_times_n; -static _Atomic(GlSwapClockFn) s_swap_clock; static void gl_window_swap(void) { #if defined(PSX_SDL3) psx_video_telemetry_present(SDL_GL_SwapWindow(s_win) ? 1 : 0); @@ -10222,27 +9849,6 @@ static void gl_window_swap(void) { SDL_GL_SwapWindow(s_win); psx_video_telemetry_present(-1); // SDL2 exposes no swap result. #endif - GlSwapClockFn clock = atomic_load_explicit(&s_swap_clock, memory_order_acquire); - if (clock) s_swap_times[s_swap_times_n++ % GL_SWAP_TIMING_CAP] = clock(); -} - -void gl_renderer_swap_timing_start(GlSwapClockFn clock) { - GL_RT_SYNC("swap_timing_start"); - if (s_pt_on) pt_drain(); - s_swap_times_n = 0; - atomic_store_explicit(&s_swap_clock, clock, memory_order_release); -} - -int gl_renderer_swap_timestamps(uint64_t *out, int cap) { - if (!out || cap <= 0) return 0; - GL_RT_SYNC("swap_timestamps"); - if (s_pt_on) pt_drain(); - /* Context handoff plus presenter drain leave the single swap writer idle. */ - uint64_t n = s_swap_times_n < GL_SWAP_TIMING_CAP ? s_swap_times_n : GL_SWAP_TIMING_CAP; - if (n > (uint64_t)cap) n = (uint64_t)cap; - uint64_t first = s_swap_times_n - n; - for (uint64_t i=0; i 0 && wh > 0) { const uint32_t *px = NULL; int ow = 0, oh = 0; @@ -10475,7 +10077,7 @@ static void gl_swap_with_osd(void) { } if (burst && b_i > 0) { int ww = 0, wh = 0; - gl_drawable_size(&ww, &wh); + SDL_GL_GetDrawableSize(s_win, &ww, &wh); if (ww == b_w && wh == b_h && (b_px[b_i] = (uint8_t *)malloc((size_t)ww * wh * 3))) { glPixelStorei(GL_PACK_ALIGNMENT, 1); glReadPixels(0, 0, ww, wh, GL_RGB, GL_UNSIGNED_BYTE, b_px[b_i]); @@ -10506,7 +10108,7 @@ static void gl_swap_with_osd(void) { present_shot_done(ok); } } else if (burst && present_shot_take(shot_path, (int)sizeof(shot_path))) { - gl_drawable_size(&b_w, &b_h); + SDL_GL_GetDrawableSize(s_win, &b_w, &b_h); snprintf(b_path, sizeof b_path, "%s", shot_path); if (b_w > 0 && b_h > 0 && (b_px[0] = (uint8_t *)malloc((size_t)b_w * b_h * 3))) { glPixelStorei(GL_PACK_ALIGNMENT, 1); @@ -10521,7 +10123,7 @@ static void gl_swap_with_osd(void) { present_shot_take(shot_path, (int)sizeof(shot_path))) { int ww = 0, wh = 0; int wrote = 0; - gl_drawable_size(&ww, &wh); + SDL_GL_GetDrawableSize(s_win, &ww, &wh); if (ww > 0 && wh > 0) { uint8_t *rows = (uint8_t *)malloc((size_t)ww * wh * 3); uint8_t *flip = rows ? (uint8_t *)malloc((size_t)ww * wh * 3) : NULL; @@ -10716,7 +10318,7 @@ int gl_renderer_present_hold_last(void) { int lx, ly, lw, lh; if (!s_ctx || !s_win || s_hold_kind == HOLD_NONE || !s_hold_tex) return 0; - gl_drawable_size(&ww, &wh); + SDL_GL_GetDrawableSize(s_win, &ww, &wh); if (ww < 1 || wh < 1) return 0; glDisable(GL_SCISSOR_TEST); @@ -10817,7 +10419,7 @@ static void present_vram_impl(int disp_x, int disp_y, int w, int h, int linear, return; } gl_perf_present_enter(); /* per-frame backdrop-phase reset + dbg snapshot live in here */ - int ww = 0, wh = 0; gl_drawable_size(&ww, &wh); + int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); int lx, ly, lw, lh; if (force_4_3) letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); @@ -10903,7 +10505,7 @@ static void present_vram_impl(int disp_x, int disp_y, int w, int h, int linear, * produced by the mirror; this leaves them untouched. One FBO->FBO blit, * x-translated by the reveal offset. No-op when s_wide_fast is off (then the * mirror drew the full surface, as before). Shared by both present paths. */ -static void wide_blit_center_timed_impl(GLuint wide_fbo, int base_x, int disp_y, int disp_h) { +static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h) { if (!wide_center_is_canonical() || g_wide_w <= 0) return; int native_w = g_wide_w - 2 * g_wide_off; if (native_w <= 0) return; @@ -10968,12 +10570,6 @@ static void wide_blit_center_timed_impl(GLuint wide_fbo, int base_x, int disp_y, p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); } -static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h) { - uint64_t start = gl_cpu_begin(5); - wide_blit_center_timed_impl(wide_fbo,base_x,disp_y,disp_h); - gl_cpu_end(5, start); -} - /* GPU-direct native-wide present: blit the displayed buffer's wide FBO straight * to the window (no glReadPixels / glFinish CPU round-trip). The wide surface is * g_wide_w wide × VRAM_H tall (at scale S); present its [0,g_wide_w] × [disp_y, @@ -11010,6 +10606,7 @@ static int present_wide_fbo_impl(int disp_x, int disp_y, int disp_h, int linear) flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); + hiw_flush_queue(); /* windowed: queued wide mirrors */ if (!s_native_surface_enabled && s_force_present_remaining <= 0 && s_last_present_path == GL_PRES_WIDE && s_last_dx == disp_x && s_last_dy == disp_y && @@ -11018,7 +10615,6 @@ static int present_wide_fbo_impl(int disp_x, int disp_y, int disp_h, int linear) !ov_needs_present() && !psx_present_vsync_owns_cadence() && !gl_renderer_interpolation_owns_cadence()) { - hiw_flush_queue(); s_probe_skip++; gpu_timeline_note(GTL_PRESENT_SKIP, GTL_PATH_WIDE_FBO, (uint32_t)disp_x | ((uint32_t)disp_y << 16)); @@ -11026,9 +10622,8 @@ static int present_wide_fbo_impl(int disp_x, int disp_y, int disp_h, int linear) gl_perf_present_exit(1); return 1; } - hiw_flush_for_center(fbo); gl_perf_present_enter(); - int ww = 0, wh = 0; gl_drawable_size(&ww, &wh); + int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); int lx, ly, lw, lh; letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); wide_blit_center(fbo, disp_x, disp_y, disp_h); /* fast-path: authoritative centre */ @@ -11777,7 +11372,7 @@ static int gl_rth_eligible(void) { extern int psx_netplay_active(void); return s_raster_ok && s_ctx && !s_cpu_auth_dual && !s_depth24_skip_up && !(s_hd_native_authority && s_rth_a0_open) && - !gpu_display_is_depth24() && !s_pass_active && + !gpu_display_is_depth24() && !s_interp_enabled && !s_pass_active && !psx_netplay_active() && !psx_openxr_session_active() && gr_backend() == GR_BACKEND_OPENGL; } @@ -12959,7 +12554,7 @@ static void fg_replay(const FgList *a, const FgList *b, double t, GLuint gen_wid * interpolation capture, no present bookkeeping), then swapped. */ static void fg_compose(const FgList *b, GLuint wide_fbo, GLuint wide_tex, int linear, int force_4_3) { - int ww = 0, wh = 0; gl_drawable_size(&ww, &wh); + int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); int lx, ly, lw, lh; if (b->wide) { letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); @@ -14133,7 +13728,7 @@ static void fg_generate_timed(double t) { /* Keep the composed back buffer (a real frame) for later. 1 when kept. */ static int fg_keep_real(void) { int ww = 0, wh = 0; - gl_drawable_size(&ww, &wh); + SDL_GL_GetDrawableSize(s_win, &ww, &wh); if (ww <= 0 || wh <= 0) return 0; if (!s_fg_real_fbo || s_fg_real_w != ww || s_fg_real_h != wh) { if (s_fg_real_fbo) { p_glDeleteFramebuffers(1, &s_fg_real_fbo); glDeleteTextures(1, &s_fg_real_tex); } @@ -14161,7 +13756,7 @@ static void fg_present_real(void) { if (!s_fg_real_kept) return; /* nothing changed: the present was skipped */ s_fg_real_kept = 0; int ww = 0, wh = 0; - gl_drawable_size(&ww, &wh); + SDL_GL_GetDrawableSize(s_win, &ww, &wh); p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_fg_real_fbo); p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); glDisable(GL_SCISSOR_TEST); @@ -15095,7 +14690,6 @@ int gl_renderer_render_thread_active(void) { return s_rth_on; } * to the render thread when the frame may run asynchronously, take it when it * may not, then close the frame (the in-flight bound applies here). */ void gl_renderer_render_thread_frame_boundary(void) { - if (!drawable_events_enabled()) gl_drawable_refresh(); if (!s_rth_on) return; fg_note_guest_frame(); if (rt_held()) { diff --git a/runtime/src/gpu_hd_textures.cpp b/runtime/src/gpu_hd_textures.cpp index 62811dd9..5430dfb3 100644 --- a/runtime/src/gpu_hd_textures.cpp +++ b/runtime/src/gpu_hd_textures.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,11 @@ constexpr size_t kCompositionBudget = 64u * 1024u * 1024u; constexpr size_t kCompositionCount = 256; constexpr uint32_t kCompositionDimension = 8192; constexpr size_t kMaxParts = 64; +/* Elective full preload of an installed pack: at most this much decoded RGBA + * pinned across both formats, decoded by at most this many threads. Anything + * beyond decodes on demand under the normal budget. */ +constexpr size_t kPreloadMaxBytes = size_t{512} * 1024u * 1024u; +constexpr unsigned kPreloadThreads = 4; struct Composition { std::vector signature; std::vector rgba; @@ -289,6 +295,32 @@ extern "C" int gpu_hd_textures_configure(const char* root, int replacements, std::filesystem::create_directories(directory / "dumps"); } if (!duck_texture_pack_create(root, &next->duck, error, capacity)) return 0; + if (next->replacements) { + /* Replacements decoded and resident before the game draws, within + * kPreloadMaxBytes; the rest stays on the budgeted async path. */ + const auto t0 = std::chrono::steady_clock::now(); + HdTexturePreloadResult dr{}, br{}; + const int dstatus = duck_texture_pack_preload(next->duck, kPreloadMaxBytes, + kPreloadThreads, &dr); + int bstatus = 1; + if (next->beetle) { + const size_t left = dr.bytes < kPreloadMaxBytes ? kPreloadMaxBytes - dr.bytes : 0; + bstatus = hd_texture_pack_preload(next->beetle, left, kPreloadThreads, &br); + } + const auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + const int requested = dr.requested + br.requested; + if (requested > 0 || dstatus < 0 || bstatus < 0) + std::fprintf(stdout, "psxrecomp: texture pack: preloaded %d/%d images (%.1f MiB) in %lld ms" + "%s; %d over the %u MiB preload cap, %d failed (%d worker errors);" + " the rest decode on demand\n", + dr.loaded + br.loaded, requested, (dr.bytes + br.bytes) / 1048576.0, + static_cast(ms), + (dstatus < 0 || bstatus < 0) ? " (preload error)" : + (dstatus == 1 && bstatus == 1) ? " (complete)" : " (partial)", + dr.over_budget + br.over_budget, static_cast(kPreloadMaxBytes / (1024u * 1024u)), + dr.failed + br.failed, dr.worker_errors + br.worker_errors); + } /* Quiesce renderer users (GL render thread: dump_draw appends to the * old session's sources) before flushing its dumps or reading its * residency; the cache is rebuilt from the new session. */ @@ -396,6 +428,10 @@ extern "C" void gpu_hd_textures_get_diag(GpuHdTextureDiag* out) { session->diagnostics.push_back(std::move(diagnostic)); out->diagnostic = session->diagnostics.back().c_str(); } +extern "C" int gpu_hd_textures_debug_uploads_at(int x, int y, char* out, size_t cap) { + if (out && cap) out[0] = '\0'; + return session ? hd_texture_pack_debug_uploads_at(session->beetle, (unsigned)x, (unsigned)y, out, cap) : 0; +} extern "C" void gpu_hd_textures_note_applied(void) { if (session) ++session->diag.applied_draws; } extern "C" void gpu_hd_textures_note_gl_cache(int eviction) { if (session) { @@ -557,7 +593,7 @@ static int acquire_draw(uint16_t tp, uint16_t cx, uint16_t cy, image->origin_u = query.u_first - (match.source_word_x * pixels_per_word + (query.u_first % pixels_per_word)); image->origin_v = query.v_first - match.source_y; image->cache_key = (static_cast(match.entry.texture_hash) << 32) | match.entry.palette_hash; - image->alpha_mode = 1; + image->alpha_mode = match.entry.palette_hash == HD_TEXTURE_PALETTE_INDEXED ? 5 : 1; } else { try { std::array matches{}; size_t count = 0; diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 7c9c36ce..5d60ef36 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -1,5 +1,6 @@ #include #include "gte.h" +#include "gte_projection_publication.hpp" #include "gte_view.h" #include "cpu_state.h" #include "execution_profile.h" @@ -20,6 +21,12 @@ extern "C" int gpu_ws_precise_nclip_enabled(void); * plus native replay of one candidate) is running. */ extern "C" int psx_overlay_shadow_diff_active(void); extern "C" uint64_t s_frame_count; /* debug_server.c */ +/* Latest projection (H, OFX, OFY in 16.16), read by the post-effect executor. */ +static GteProjectionPublication s_postfx_projection; +extern "C" void gte_postfx_projection_enable(int enabled) { s_postfx_projection.enable(enabled!=0); } +extern "C" int gte_postfx_projection_read(GtePostfxProjection* out) { + return out && s_postfx_projection.read(*out); +} extern "C" uint32_t g_debug_last_store_pc; /* debug_server.c */ extern "C" { int (*g_psx_projection_command)(CPUState*, uint32_t) = nullptr; } @@ -1206,6 +1213,9 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) } } dome_probe_note(gte->SZ[3]); /* locate the dome draw fn (far-vertex tally) */ + if (!s_gte_replay_sandbox) { /* projection of the latest 3D draw, for post effects (u_proj) */ + s_postfx_projection.publish(gte->H,gte->OFX,gte->OFY); + } int64_t sx16 = gte->OFX + xterm; int64_t sy16 = gte->OFY + (int64_t)gte->IR2 * h_div_sz; /* Render-view projection override (psx_mod_render_view, projection=1). diff --git a/runtime/src/hd_texture_pack.cpp b/runtime/src/hd_texture_pack.cpp index 720b6794..10e2af2a 100644 --- a/runtime/src/hd_texture_pack.cpp +++ b/runtime/src/hd_texture_pack.cpp @@ -1,10 +1,12 @@ #include "hd_texture_pack.h" +#include "texture_pack_preload.hpp" #ifndef HD_TEXTURE_PACK_DISABLE_PNG_DECODE #include "../third_party/stb_image.h" /* declarations only; implementation is shared */ #endif #include +#include #include #include #include @@ -113,6 +115,10 @@ bool parse_key_text(const std::string& text, const size_t dash = text.find('-'); if (dash == std::string::npos || text.find('-', dash + 1) != std::string::npos) return false; + if (ascii_lower(text.substr(dash + 1)) == "index") { + *palette_hash = HD_TEXTURE_PALETTE_INDEXED; + return parse_hex_component(text.substr(0, dash), texture_hash); + } return parse_hex_component(text.substr(0, dash), texture_hash) && parse_hex_component(text.substr(dash + 1), palette_hash); } @@ -448,6 +454,7 @@ struct DecodeItem { std::shared_ptr image; size_t bytes = 0; uint64_t last_used = 0; + bool pinned = false; /* preloaded: never evicted */ }; struct DecodeCache { @@ -480,7 +487,7 @@ void evict_decode_cache(DecodeCache& cache, size_t incoming = 0) { while (cache.used + incoming > cache.budget) { auto victim = cache.items.end(); for (auto it = cache.items.begin(); it != cache.items.end(); ++it) { - if (it->second.state != DecodeState::Ready) continue; + if (it->second.state != DecodeState::Ready || it->second.pinned) continue; if (victim == cache.items.end() || it->second.last_used < victim->second.last_used) victim = it; @@ -511,38 +518,46 @@ void decode_worker(DecodeCache* cache) { path = it->second.path; } - std::vector encoded; - { - std::ifstream input(path, std::ios::binary | std::ios::ate); - std::streamoff length = -1; - if (input) length = static_cast(input.tellg()); - if (length > 0 && static_cast(length) <= kMaxPngFileBytes && - static_cast(length) <= - static_cast(std::numeric_limits::max())) { - encoded.resize(static_cast(length)); - input.seekg(0); - if (!input.read(reinterpret_cast(encoded.data()), length)) - encoded.clear(); - } - } - int width = 0; - int height = 0; - int components = 0; - stbi_uc* loaded = encoded.empty() ? nullptr : stbi_load_from_memory( - encoded.data(), static_cast(encoded.size()), - &width, &height, &components, 4); std::shared_ptr decoded; size_t bytes = 0; - if (loaded && width > 0 && height > 0 && - static_cast(width) * static_cast(height) <= - std::numeric_limits::max() / 4) { - bytes = static_cast(width) * static_cast(height) * 4; - decoded = std::make_shared(); - decoded->width = static_cast(width); - decoded->height = static_cast(height); - decoded->rgba.assign(loaded, loaded + bytes); + /* Allocation failure (encoded buffer, RGBA copy) must not escape the + * thread entry: the item fails like an undecodable image. */ + try { + std::vector encoded; + { + std::ifstream input(path, std::ios::binary | std::ios::ate); + std::streamoff length = -1; + if (input) length = static_cast(input.tellg()); + if (length > 0 && static_cast(length) <= kMaxPngFileBytes && + static_cast(length) <= + static_cast(std::numeric_limits::max())) { + encoded.resize(static_cast(length)); + input.seekg(0); + if (!input.read(reinterpret_cast(encoded.data()), length)) + encoded.clear(); + } + } + int width = 0; + int height = 0; + int components = 0; + std::unique_ptr loaded( + encoded.empty() ? nullptr : stbi_load_from_memory( + encoded.data(), static_cast(encoded.size()), + &width, &height, &components, 4), + stbi_image_free); + if (loaded && width > 0 && height > 0 && + static_cast(width) * static_cast(height) <= + std::numeric_limits::max() / 4) { + bytes = static_cast(width) * static_cast(height) * 4; + decoded = std::make_shared(); + decoded->width = static_cast(width); + decoded->height = static_cast(height); + decoded->rgba.assign(loaded.get(), loaded.get() + bytes); + } + } catch (...) { + decoded.reset(); + bytes = 0; } - if (loaded) stbi_image_free(loaded); std::lock_guard lock(cache->mutex); auto it = cache->items.find(key); @@ -1070,6 +1085,35 @@ int hd_texture_pack_track_upload(HdTexturePack* pack, return 1; } +/* Debug: uploads whose live fragments cover VRAM (x, y), newest first, as + * JSON objects: serial, texture hash, size, fragment rect and source offset, + * and whether the pack holds any replacement for that hash. */ +int hd_texture_pack_debug_uploads_at(const HdTexturePack* pack, unsigned x, unsigned y, + char* out, size_t cap) { + if (!out || cap == 0) return 0; + size_t pos = 0; + int n = 0; + out[0] = '\0'; + if (!pack) return 0; + for (auto it = pack->uploads.rbegin(); it != pack->uploads.rend(); ++it) { + for (const Fragment& f : it->fragments) { + if (x < f.rect.x || x >= f.rect.x + f.rect.width || + y < f.rect.y || y >= f.rect.y + f.rect.height) continue; + const int w = std::snprintf(out + pos, cap - pos, + "%s{\"serial\":%llu,\"hash\":\"%08x\",\"w\":%u,\"h\":%u," + "\"rect\":[%u,%u,%u,%u],\"src\":[%u,%u],\"in_pack\":%d}", + n ? "," : "", (unsigned long long)it->serial, it->hash, + it->width, it->height, f.rect.x, f.rect.y, f.rect.width, f.rect.height, + f.source_x, f.source_y, pack->texture_hashes.count(it->hash) ? 1 : 0); + if (w < 0 || (size_t)w >= cap - pos) return n; + pos += (size_t)w; + ++n; + break; + } + } + return n; +} + void hd_texture_pack_reset_tracking(HdTexturePack* pack) { if (pack) { pack->uploads.clear(); @@ -1212,7 +1256,9 @@ int hd_texture_pack_match(HdTexturePack* pack, if (indexed == pack->upload_by_serial.end() || !indexed->second) continue; const Upload& upload = *indexed->second; - const auto record = pack->entries.find(make_key(upload.hash, palette_hash)); + auto record = pack->entries.find(make_key(upload.hash, palette_hash)); + if (record == pack->entries.end() && query->depth != HD_TEXTURE_DEPTH_16BPP) + record = pack->entries.find(make_key(upload.hash, HD_TEXTURE_PALETTE_INDEXED)); if (record == pack->entries.end()) continue; if (!covered_by_upload(upload, wanted)) continue; if (record->second.ambiguous) return HD_TEXTURE_LOOKUP_AMBIGUOUS; @@ -1282,6 +1328,120 @@ void hd_texture_pack_set_decode_budget(HdTexturePack* pack, evict_decode_cache(pack->decode); } +int hd_texture_pack_preload(HdTexturePack* pack, size_t max_bytes, unsigned max_threads, + HdTexturePreloadResult* out) { + HdTexturePreloadResult r{}; + if (out) *out = r; +#ifdef HD_TEXTURE_PACK_DISABLE_PNG_DECODE + (void)pack; (void)max_bytes; (void)max_threads; + return -1; +#else + if (!pack) return -1; + try { + namespace tp = psx_texpreload; + std::vector> work_items; + size_t per_image = 0; + { + std::lock_guard lock(pack->decode.mutex); + per_image = pack->decode.budget; + for (const auto& item : pack->decode.items) + if (item.second.pinned) r.bytes += item.second.bytes; + for (const auto& entry : pack->entries) { + if (entry.second.ambiguous) continue; + ++r.requested; + const auto it = pack->decode.items.find(entry.first); + if (it != pack->decode.items.end() && it->second.pinned) ++r.loaded; + } + if (r.bytes > max_bytes) { + r.failed = r.requested - r.loaded; + if (out) *out = r; + return -1; + } + for (const auto& entry : pack->entries) { + if (entry.second.ambiguous) continue; + const auto it = pack->decode.items.find(entry.first); + if (it == pack->decode.items.end() || !it->second.pinned) + work_items.emplace_back(entry.first, entry.second.replacement_path); + } + } + std::vector> images(work_items.size()); + std::vector status(work_items.size(), tp::kPending); + tp::Budget budget(max_bytes - r.bytes); + r.worker_errors = static_cast(tp::run(work_items.size(), max_threads, [&](size_t k) { + status[k] = tp::kFailed; + std::ifstream input(work_items[k].second, std::ios::binary | std::ios::ate); + const std::streamoff length = input ? static_cast(input.tellg()) : -1; + if (length <= 0 || static_cast(length) > kMaxPngFileBytes || + static_cast(length) > static_cast(std::numeric_limits::max())) + return; + std::vector encoded(static_cast(length)); + input.seekg(0); + if (!input.read(reinterpret_cast(encoded.data()), length)) return; + int width = 0, height = 0, components = 0; + if (!stbi_info_from_memory(encoded.data(), static_cast(encoded.size()), + &width, &height, &components) || + width <= 0 || height <= 0 || + static_cast(width) * static_cast(height) > + static_cast(per_image) / 4) + return; + const size_t need = static_cast(width) * static_cast(height) * 4; + if (!budget.reserve(need)) { status[k] = tp::kOverBudget; return; } + struct Reservation { + tp::Budget& b; size_t n; bool keep = false; + ~Reservation() { if (!keep) b.release(n); } + } reservation{budget, need}; + int lw = 0, lh = 0, lc = 0; + std::unique_ptr loaded( + stbi_load_from_memory(encoded.data(), static_cast(encoded.size()), + &lw, &lh, &lc, 4), + stbi_image_free); + if (!loaded || lw != width || lh != height) return; + auto image = std::make_shared(); + image->width = static_cast(width); + image->height = static_cast(height); + image->rgba.assign(loaded.get(), loaded.get() + need); + images[k] = std::move(image); + reservation.keep = true; + status[k] = tp::kLoaded; + })); + std::lock_guard lock(pack->decode.mutex); + for (size_t k = 0; k < work_items.size(); ++k) { + if (status[k] == tp::kOverBudget) { ++r.over_budget; continue; } + if (status[k] != tp::kLoaded) continue; + auto it = pack->decode.items.find(work_items[k].first); + if (it != pack->decode.items.end()) { + /* Already pinned, or an on-demand decode in flight: leave it. */ + if (it->second.pinned || it->second.state == DecodeState::Queued || + it->second.state == DecodeState::Loading) continue; + } + const size_t previous_bytes = it != pack->decode.items.end() && + it->second.state == DecodeState::Ready ? it->second.bytes : 0; + DecodeItem item; + /* Prepare throwing metadata before touching an existing entry. */ + item.path = work_items[k].second; + item.bytes = images[k]->rgba.size(); + item.image = std::move(images[k]); + item.state = DecodeState::Ready; item.pinned = true; + item.last_used = ++pack->decode.tick; + const size_t bytes = item.bytes; + if (it == pack->decode.items.end()) + pack->decode.items.emplace(work_items[k].first, std::move(item)); + else it->second = std::move(item); + pack->decode.used -= previous_bytes; + r.bytes += bytes; ++r.loaded; + tp::after_publish(r.loaded); + } + r.failed = r.requested - r.loaded - r.over_budget; + if (out) *out = r; + return r.loaded == r.requested ? 1 : 0; + } catch (...) { + r.failed = r.requested - r.loaded - r.over_budget; + if (out) *out = r; /* Account the pinned prefix even on publication failure. */ + return -1; + } +#endif +} + int hd_texture_pack_request_decode(HdTexturePack* pack, uint32_t texture_hash, uint32_t palette_hash) { diff --git a/runtime/src/host_span_ring.c b/runtime/src/host_span_ring.c index c56f7b7f..c1c8fd79 100644 --- a/runtime/src/host_span_ring.c +++ b/runtime/src/host_span_ring.c @@ -142,8 +142,6 @@ const char *host_span_kind_name(unsigned kind) { "none", "autocapture_tick", "provider_poll", "publish_commit", "loader_rescan", "dll_register", "lazy_load", "capture_on_dma", "capture_before_dma", "ac_request", "ac_done_join", "ac_prepare_image", "ac_child", "overlay_dispatch", "mod_work", "frame_present", "disc_read", - "host_events", "rewind_capture", - "display_probe", "audio_update", }; return kind < HSPAN_KIND_COUNT ? names[kind] : "?"; } diff --git a/runtime/src/input_replay.c b/runtime/src/input_replay.c deleted file mode 100644 index a1b7caa7..00000000 --- a/runtime/src/input_replay.c +++ /dev/null @@ -1,66 +0,0 @@ -#include "input_replay.h" -#include -#include -#include -#include - -typedef struct ReplayStep { uint64_t end; unsigned buttons; } ReplayStep; -static ReplayStep *s_steps; -static size_t s_count, s_index; -static uint64_t s_base; -static int s_armed; - -int input_replay_load(const char *path) { - FILE *f = fopen(path, "r"); - ReplayStep *steps = NULL; - size_t count = 0; - uint64_t end = 0; - char line[256]; - int ok = f != NULL; - if (!f) return 0; - while (fgets(line, sizeof line, f)) { - char *p = line; - unsigned long long frames, buttons; - char *endp; - while (isspace((unsigned char)*p)) p++; - if (!*p || *p == '#') continue; - if (*p == '-' || *p == '+') { ok = 0; break; } - errno = 0; frames = strtoull(p, &endp, 10); - if (endp == p || errno || !isspace((unsigned char)*endp) || - !frames || frames > UINT32_MAX) { ok = 0; break; } - p = endp; - while (isspace((unsigned char)*p)) p++; - if (*p == '-' || *p == '+') { ok = 0; break; } - errno = 0; buttons = strtoull(p, &endp, 16); - if (endp == p || errno || buttons > 65535) { ok = 0; break; } - while (isspace((unsigned char)*endp)) endp++; - if (*endp || count >= 65536 || end + frames > UINT32_MAX) { ok = 0; break; } - ReplayStep *next = realloc(steps, (count+1)*sizeof *steps); - if (!next) { ok = 0; break; } - steps = next; - end += frames; - steps[count++] = (ReplayStep){end, buttons}; - } - if (ferror(f)) ok = 0; - fclose(f); - if (!ok || !count) { free(steps); return 0; } - free(s_steps); - s_steps = steps; s_count = count; s_index = 0; s_armed = 0; - return 1; -} - -void input_replay_on_load(uint64_t next_frame) { - s_base = next_frame; s_index = 0; s_armed = s_count != 0; -} - -uint64_t input_replay_frame(uint64_t frame) { - return s_armed && frame >= s_base ? frame-s_base : UINT64_MAX; -} - -int input_replay_buttons(uint64_t frame) { - uint64_t at = input_replay_frame(frame); - if (at == UINT64_MAX) return -1; - if (s_index && at < s_steps[s_index-1].end) s_index = 0; - while (s_index < s_count && at >= s_steps[s_index].end) s_index++; - return s_index < s_count ? (int)s_steps[s_index].buttons : 65535; -} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index b95a6d5d..8b00f22d 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -49,7 +49,6 @@ #include "autocompile.h" #include "code_provider.h" #include "host_span_ring.h" -#include "input_replay.h" #ifdef PSX_HAS_OVERLAY_DISPATCH extern "C" void psx_overlay_static_get_stats(uint64_t *, uint64_t *, uint64_t *, uint64_t *); #endif @@ -134,6 +133,7 @@ extern "C" uint32_t g_guest_last_store_addr, g_guest_last_store_val; #include "mod_plugins.h" #include "mod_local_input_policy.h" #include "mod_session_baseline.h" +#include "func_override.h" #include "mod_runtime.h" #include "video_texture_filter.h" #include "video_telemetry.h" @@ -1082,10 +1082,6 @@ static void post_load_probe_on_vblank(int turbo_active, int present_reached) { * restored VRAM — including a blank if display was disabled in the snapshot. */ static void savestate_input_guard_arm(void); extern "C" void psx_frontend_on_savestate_notify(int is_load, int slot, int ok) { - if (is_load && !ok && std::getenv("PSX_INPUT_REPLAY")) { - std::puts("psxrecomp: requested input replay savestate failed to load"); - std::exit(1); - } char buf[64]; const int disp = slot + 1; if (!is_load && ok) @@ -1108,16 +1104,11 @@ extern "C" void psx_frontend_on_savestate_notify(int is_load, int slot, int ok) extern "C" void psx_frontend_on_savestate_refused(int is_load, int slot, const char *reason) { - if (is_load && std::getenv("PSX_INPUT_REPLAY")) { - std::puts(reason); - std::exit(1); - } (void)is_load; (void)slot; host_osd_push(reason, 3000); } extern "C" void psx_frontend_on_savestate_loaded(void) { - input_replay_on_load(s_frame_count + 1); psx_projection_reset_session(); psx_local_mouse_reset(); mod_runtime_on_savestate_loaded(); @@ -3889,10 +3880,6 @@ static void refresh_host_display_cadence(int force_log, int force_probe) { return; } g_host_refresh_last_probe_ms = now_ms ? now_ms : 1ull; - struct ProbeSpan { - uint64_t start = host_span_begin(); - ~ProbeSpan() { host_span_end(HSPAN_DISPLAY_PROBE, start, 0); } - } probe_span; double host_hz = 0.0, max_hz = 0.0; if (disp_idx >= 0) { @@ -4070,22 +4057,9 @@ static int present_effective_swap_interval(void) { } static int present_should_wall_pace(void) { - static const bool queued_pace = [] { - const char *e = std::getenv("PSX_GL_ASYNC_GUEST_PACE"); - return !(e && e[0] == '0'); - }(); - /* The presenter explicitly waits for each guest-rate deadline. Bounded - * render/present queues carry that clock back to the guest, independently - * of driver vsync; a second guest timer only serializes the pipeline. */ - if (queued_pace && g_frame_interpolation && g_gl_active && - gl_renderer_render_thread_active() && !rt_held() && - gl_renderer_interpolation_async_cadence()) - return 0; - /* While this thread owns the GL context, temporal presentation subdivides - * this same interval. When rendering is asynchronous, cap the guest here: - * the renderer's presentation deadline belongs to its own thread. */ + /* The single-context temporal-blend path subdivides this same stock frame + * interval. Do not run the outer pacer as well or guest timing doubles. */ if (g_frame_interpolation && g_gl_active && - (!gl_renderer_render_thread_active() || rt_held()) && gl_renderer_interpolation_owns_cadence()) return 0; /* Run the pacer even when driver vsync is SUPPOSED to own the cadence. @@ -4830,11 +4804,7 @@ static void runtime_perf_frame_end() { class RuntimePerfFrameScope { public: - uint64_t start = host_span_begin(); - ~RuntimePerfFrameScope() { - runtime_perf_frame_end(); - host_span_end(HSPAN_FRAME_PRESENT, start, 0); - } + ~RuntimePerfFrameScope() { runtime_perf_frame_end(); } }; static uint64_t runtime_perf_section_begin() { @@ -5000,142 +4970,6 @@ static void openxr_ready_report_tick(uint64_t frames) { } } - -/* Explicit, file-driven production benchmark. No debug server, sampler or - * per-instruction tracing is needed. Capture outside the measured interval; - * export the existing frame ring before requesting the normal quit path. */ -struct ReplayWorkSample { uint64_t frame, guest, pacer, queue, completed; }; -static std::vector g_replay_work; -static void input_replay_frame_completed(uint64_t frame) { - if (g_replay_work.empty()) return; - auto &sample = g_replay_work[frame % HOST_FRAME_STAMP_CAP]; - if (sample.frame == frame) sample.completed = host_clock_us(); -} -static void input_replay_frontend_tick(uint64_t frame) { - uint64_t relative = input_replay_frame(frame); - if (relative == UINT64_MAX) return; - static uint64_t stop = 0, seconds = 0, deadline_us = 0; - static const char *report = nullptr, *shots = nullptr; - static bool initialized = false, finished = false; - auto &work = g_replay_work; - if (!initialized) { - if (const char *e = std::getenv("PSX_INPUT_REPLAY_STOP")) stop = std::strtoull(e, nullptr, 10); - if (const char *e = std::getenv("PSX_INPUT_REPLAY_SECONDS")) { - char *end = nullptr; - seconds = std::strtoull(e, &end, 10); - if (!seconds || seconds > 60 || !end || *end || stop) - throw std::runtime_error("replay seconds must be 1..60, without replay stop"); - } - report = std::getenv("PSX_INPUT_REPLAY_REPORT"); - shots = std::getenv("PSX_INPUT_REPLAY_SHOTS"); - if (report && *report) { - work.resize(HOST_FRAME_STAMP_CAP); - if (g_gl_active) gl_renderer_swap_timing_start(host_clock_us); - } - initialized = true; - } - if (finished) return; - if (seconds && relative >= 900 && !deadline_us) - deadline_us = host_clock_us() + seconds * 1000000; - if (deadline_us && !stop && host_clock_us() >= deadline_us) stop = relative; - if (report && *report) { - RtStats rs{}; - rt_get_stats(&rs); - const uint64_t hz = SDL_GetPerformanceFrequency(); - work[frame % HOST_FRAME_STAMP_CAP] = {frame, g_runtime_perf.guest_work_ticks * 1000000 / hz, - g_runtime_perf.pacer_ticks * 1000000 / hz, - (rs.backpressure_ns + rs.ring_full_ns + rs.acquire_ns) / 1000, 0}; - } - if (shots && (relative == 800 || (stop > 0 && relative == stop+2))) { - char path[512]; - std::snprintf(path, sizeof path, "%s-%llu.png", shots, (unsigned long long)relative); - present_shot_request(path); - } - if (!stop || relative < stop+10) return; - finished = true; - if (report && *report) { - FILE *f = std::fopen(report, "w"); - if (!f) throw std::runtime_error("cannot write input replay frame report"); - std::fputs("frame,relative,us,interp,checks,faults,io_kb,guest_us,pacer_us,queue_us,completed_us\n", f); - uint64_t total = host_frame_stamp_total(); - uint64_t first = total > HOST_FRAME_STAMP_CAP ? total-HOST_FRAME_STAMP_CAP : 0; - for (uint64_t i=first; i HOST_SPAN_RING_CAP ? total-HOST_SPAN_RING_CAP : 0; - for (uint64_t i=first; i= (int)sizeof thread_json) - throw std::runtime_error("render thread report exceeds its buffer"); - std::string thread_path = std::string(report) + ".rth.json"; - f = std::fopen(thread_path.c_str(), "w"); - if (!f) throw std::runtime_error("cannot write input replay render thread report"); - std::fprintf(f, "{%.*s}\n", length, thread_json); - if (std::fclose(f) != 0) throw std::runtime_error("cannot finish input replay render thread report"); - GlRenderWorkStats stats{}; - gl_renderer_work_stats(&stats); - std::vector swaps(GL_SWAP_TIMING_CAP); - const int swap_count = gl_renderer_swap_timestamps(swaps.data(), (int)swaps.size()); - std::string swap_path = std::string(report) + ".present.csv"; - f = std::fopen(swap_path.c_str(), "w"); - if (!f) throw std::runtime_error("cannot write input replay presentation report"); - std::fputs("us\n", f); - for (int i=0; i 0) { - std::string path = std::string(report) + ".gpu.csv"; - f = std::fopen(path.c_str(), "w"); - if (!f) throw std::runtime_error("cannot write input replay GPU report"); - const char *names[18] = {"samples", "total_ms", "total_max_ms", "cpu_ms", - "present_wall_ms", "scene_gpu_ms", "scene_gpu_max_ms", "present_gpu_ms", - "present_gpu_max_ms", "primitives", "mirror_gpu_ms", "mirror_gpu_max_ms", - "mirror_passes", "cpu_flush_ms", "cpu_wide_ms", "batches", "wide_sets", "fbo_creations"}; - std::fputs("stat,value\n", f); - for (int i=0; i<18; ++i) std::fprintf(f, "%s,%.6f\n", names[i], gpu[i]); - if (std::fclose(f) != 0) throw std::runtime_error("cannot finish input replay GPU report"); - } - } - SDL_Event event{}; event.type = SDL_QUIT; SDL_PushEvent(&event); -} - static void frame_report_tick(uint64_t frames) { static int interval_ms = -1; static uint64_t first_ticks = 0, last_ticks = 0, last_frames = 0; @@ -5350,10 +5184,6 @@ static void sdl_audio_pump_midframe(void) { } static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) { - struct AudioSpan { - uint64_t start = host_span_begin(); - ~AudioSpan() { host_span_end(HSPAN_AUDIO_UPDATE, start, 0); } - } audio_span; { /* Tag audio events with the vblank frame counter. */ extern uint64_t s_frame_count; audio_trace_note_frame((uint32_t)s_frame_count); @@ -9181,10 +9011,6 @@ static int debug_toggles_on(void); static int debug_toggle_key(int key, char *out, int cap); #endif static bool drain_host_events() { - struct EventSpan { - uint64_t start = host_span_begin(); - ~EventSpan() { host_span_end(HSPAN_HOST_EVENTS, start, 0); } - } event_span; SDL_Event ev; while (SDL_PollEvent(&ev)) { if (psx_local_mouse_event(ev)) continue; @@ -9379,8 +9205,6 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { int override = -1; int override_p2 = -1; #endif - int replay_override = input_replay_buttons(s_frame_count); - if (replay_override >= 0) override = replay_override; /* Always-on wall-time stamp for this guest frame (host_span_ring.h). */ extern uint64_t s_frame_count; uint32_t stamp_static_checks = 0; @@ -9402,7 +9226,6 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { } runtime_perf_frame_begin(); RuntimePerfFrameScope runtime_perf_frame_scope; - input_replay_frontend_tick(s_frame_count); runtime_perf_diag_tick(); /* Lightweight frontend telemetry from PR #13. Count simulated vblanks rather @@ -9440,6 +9263,15 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { snprintf(title, sizeof(title), "%s [Game %.0f fps %.2fx]", s_fps_base_title.c_str(), fps, speed); } + { + PsxPresentCadence cadence{}; + psx_video_telemetry_cadence(1000, 0, &cadence); + const double sfps = cadence.fps; + const unsigned hitch = cadence.hitches, worst = cadence.max_us / 1000; + size_t l = strlen(title); + snprintf(title + l, sizeof(title) - l, " [Present %.1f fps, %u hitch, max %u ms]", + sfps, hitch, worst); + } SDL_SetWindowTitle(sdl_window, title); } if (!g_headless) { @@ -9751,11 +9583,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * frame hooks may touch guest state, so neither may depend on whether this * frame is presented — otherwise headless validation runs a different game * than the windowed build. Hooks still run after all normal input sampling. */ - { - const uint64_t start = host_span_begin(); - mod_call_frame_hooks(); - host_span_end(HSPAN_MOD_WORK, start, 0); - } + mod_call_frame_hooks(); /* Resize-driven mode updates the pending aspect during BIOS boot too, but * the actual wide compositor remains disengaged until game entry. No-op @@ -10524,7 +10352,8 @@ static void render_thread_vblank(void) { } } else { std::fprintf(stdout, "psxrecomp: render thread requested but not started " - "(needs the OpenGL backend without netplay or a 24-bit display)\n"); + "(needs the OpenGL backend without netplay, frame interpolation " + "or a 24-bit display)\n"); if (g_frame_generation) std::fprintf(stdout, "psxrecomp: Smooth motion (frame generation) needs the render thread; off\n"); } @@ -11279,13 +11108,6 @@ extern "C" int psx_dynres_status_json(char *out, int cap) { static void sdl_vblank_present(void) { - extern uint64_t s_frame_count; - struct ReplayCompletion { - ~ReplayCompletion() { - extern uint64_t s_frame_count; - input_replay_frame_completed(s_frame_count); - } - } completion; sync_guest_cadence_to_video_standard(); NetplayVblankEpilogue ep = sdl_vblank_present_body(); render_thread_vblank(); @@ -18541,6 +18363,26 @@ int main(int argc, char** argv) { addresses.data(), expected.data(), results.data(), (int)addresses.size()); } + { + // [[widescreen.cull.sxy]]: four slots per site. + std::vector vertices, fold_addresses, fold_expected, + vertex_regs, consumer_addresses, consumer_expected; + for (const auto& site : gc.ws_cull_sxy_sites) { + vertices.push_back(site.vertices); + for (size_t k = 0; k < 4; k++) { + const bool used = k < site.vertices; + fold_addresses.push_back(used ? site.fold_addresses[k] : 0u); + fold_expected.push_back(used ? site.fold_expected[k] : 0u); + vertex_regs.push_back(used ? site.vertex_regs[k] : 0u); + } + consumer_addresses.push_back(site.consumer_address); + consumer_expected.push_back(site.consumer_expected); + } + gpu_ws_set_sxy_cull_sites( + vertices.data(), fold_addresses.data(), fold_expected.data(), + vertex_regs.data(), consumer_addresses.data(), + consumer_expected.data(), (int)vertices.size()); + } { std::vector addresses, expected, masks; for (const auto& site : gc.ws_cull_masked_reject_sites) { @@ -20599,6 +20441,11 @@ int main(int argc, char** argv) { netplay && PSXRecompV4::mod_runtime_netplay_view_active() ? 1 : 0); psx_mod_immediate_begin_session(netplay ? 1 : 0); mod_runtime_activate_plugins(); + if (const char* error = mod_runtime_activation_error()) { + std::fprintf(stderr,"psxrecomp: %s\n",error); + return false; + } + func_override_install(); if (!psx_mod_immediate_session_valid()) { std::fprintf(stderr, "psxrecomp: immediate-selector enhancements are offline-only\n"); return false; @@ -21253,12 +21100,6 @@ session_reboot: memcard_init_slots(memcard_dir_str.c_str(), slots); } if (!rematch_session) { - if (const char *route = std::getenv("PSX_INPUT_REPLAY")) { - if (psx_netplay_active() || !input_replay_load(route)) { - std::puts("psxrecomp: input replay requires a valid route and an offline session"); - return 1; - } - } std::atexit(memcard_flush_all); /* Persist the game's native OPTION settings on any exit path (belt-and- * suspenders; save-on-change already persists them live). No-op if not yet diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 53b3c460..2aa2dfbe 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1687,6 +1687,16 @@ uint32_t psx_read_word(uint32_t addr) { if (!psx_get_in_exception()) ls_read_hook(addr, 4, v); return v; } +/* Untraced word peek for HOST-SIDE inspection: same address decode as a guest + * read, but no lockstep record, no lockstep-replay substitution, and no + * data-shard/DuckStation capture. Use this for anything the guest did not + * actually execute — residency guards, host-side checks, diagnostics — so a + * host inspection can never appear in a guest read stream (which would both + * dirty every divergence comparison and, under replay, return replayed data + * instead of resident bytes). Never use it to service a guest load. */ +uint32_t psx_peek_word_untraced(uint32_t addr) { + return psx_read_word_raw(addr); +} /* Physical address of a CPU/DMA main-RAM access. Fold KUSEG/KSEG0/KSEG1 first * (0x1FFFFFFF), then fold the 2nd-4th main-RAM mirrors: real hardware mirrors the * 2 MB DRAM across the WHOLE 0..0x7FFFFF physical window (PSX-SPX memorymap, diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 3409c467..a189b6eb 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -50,6 +50,10 @@ struct RegisteredPlugin { std::vector function_entries; std::vector guest_functions; std::vector instructions; + /* Set when a function override is queued under this id (the callback + * itself lives in mod_runtime's pending list) — participates in + * availability so a manifest can gate an override-only plugin. */ + bool function_override = false; }; std::map& registered_plugins() { @@ -1535,12 +1539,20 @@ bool mod_register_function_filter_plugin(const std::string& id, uint32_t address return true; } +bool mod_register_function_override_marker(const std::string& id) { + if (!valid_id(id)) return false; + RegisteredPlugin& plugin = registered_plugins()[id]; + if (plugin.function_override) return false; + plugin.function_override = true; + return true; +} + bool mod_plugin_registered(const std::string& id) { const auto found = registered_plugins().find(id); return found != registered_plugins().end() && (found->second.activation || found->second.session_reset || found->second.vblank || found->second.savestate || !found->second.function_entries.empty() || !found->second.guest_functions.empty() || - !found->second.instructions.empty()); + !found->second.instructions.empty() || found->second.function_override); } bool mod_register_guest_function_plugin(const std::string& id, uint32_t address, diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index f44d43ab..e2e09ba2 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -5,6 +5,7 @@ #include "disc_path.h" #include "host_file_identity.h" +#include "func_override.h" #include "iso_reader.h" #include "mod_packages.h" #include "mod_plugins.h" @@ -368,6 +369,27 @@ void build_function_entry_hooks(const RuntimeMods& s) { g_psx_mod_instruction_hooks = (uint32_t)instructions.size(); } +/* Function overrides queue here at constructor time and are ARMED into the + * func_override tier only for plugins the resolved package plan selects — + * the same gating as vblank/activation callbacks. `id` is the full + * registered name (may carry a ":label" suffix, kept for diagnostics); + * `plugin` is the manifest-facing part gating matches on. */ +struct FunctionOverridePlugin { + std::string id; + std::string plugin; + uint32_t address = 0; + PSXModFunctionOverrideFn fn = nullptr; + uint32_t guard[FO_MAX_GUARD_WORDS] = {0, 0, 0, 0}; + int n_guard = 0; + int32_t credit = 0; + bool armed = false; +}; + +std::vector& function_override_plugins() { + static std::vector value; + return value; +} + const ModPackage* selected_package(const std::string& id) { return state().manager.selected_package(id); } @@ -1663,6 +1685,20 @@ bool mod_runtime_clear_for_netplay(std::string* error) { s.disc_guard_failed = false; s.netplay_view_plan = false; s.error.clear(); + /* Clearing s.plan is enough for activation/vblank callbacks — they only + * run while something iterates the plan. Function overrides are armed + * into func_override.c's own table with the dispatcher hook installed, + * so they survive a cleared plan and keep firing unless explicitly + * disarmed. That matters most on the rematch path (main.cpp jumps to + * session_reboot, which is PAST mod_runtime_activate_plugins and + * func_override_install), where a modded session entering netplay would + * otherwise print the vanilla banner and still run its overrides — + * diverging from a peer without the mod. Also re-arm-able: dropping the + * armed flag lets a later plan register the same override again. */ + const int disarmed = func_override_reset_package_armed(); + (void)disarmed; + for (FunctionOverridePlugin& pending : function_override_plugins()) + pending.armed = false; if (error) error->clear(); std::fprintf(stdout, "psxrecomp: mods cleared for netplay (vanilla session)\n"); return true; @@ -2962,6 +2998,17 @@ bool mod_runtime_read_disc_file_sectors(const std::string& path, uint32_t max_by } } // namespace PSXRecompV4 +static std::string s_override_activation_error; +extern "C" const char* mod_runtime_activation_error(void) { + return s_override_activation_error.empty() ? nullptr : s_override_activation_error.c_str(); +} + +static int invoke_package_override(CPUState* cpu, FuncOverrideFn fn, const void* context) { + using namespace PSXRecompV4; + PluginCallbackScope scope(state(),static_cast(context),cpu); + return fn(cpu); +} + extern "C" void mod_runtime_activate_plugins(void) { using namespace PSXRecompV4; HostLaunchTimingScope timing(HOST_LAUNCH_PLUGIN_ACTIVATION); @@ -2969,6 +3016,9 @@ extern "C" void mod_runtime_activate_plugins(void) { psx_host_set_hud_draw_cb(nullptr, nullptr); mod_reset_plugin_sessions(); RuntimeMods& s = state(); + s_override_activation_error.clear(); + func_override_reset_package_armed(); + for (auto& pending : function_override_plugins()) pending.armed = false; s.ticket_ready = false; psx_mod_set_disc_request_provider(nullptr); psx_projection_reset_session(); @@ -2979,6 +3029,28 @@ extern "C" void mod_runtime_activate_plugins(void) { s.disc_extents.clear(); s.audio_tracks.clear(); s.extent_start = 0; + /* Arm the entire tier before activation callbacks change host state. */ + for (auto& pending : function_override_plugins()) { + const auto owner = std::find_if(s.plan.plugins.begin(),s.plan.plugins.end(), + [&](const ModResolution::Plugin& p) { return p.id == pending.plugin; }); + if (owner == s.plan.plugins.end()) continue; + const int rc = s.netplay_view_plan ? FO_ERR_ARGS : func_override_add_package_scoped( + pending.id.c_str(),pending.address,pending.fn, + pending.n_guard ? pending.guard : nullptr,pending.n_guard,pending.credit, + invoke_package_override,&*owner); + if (rc != FO_OK) { + s_override_activation_error = "Cannot arm function override " + pending.id + + " (registration error " + std::to_string(rc) + ")"; + s.error = s_override_activation_error; + s.plan.ok = false; + func_override_reset_package_armed(); + for (auto& item : function_override_plugins()) item.armed = false; + clear_function_entry_hooks(); + return; + } + pending.armed = true; + } + func_override_install(); s.activating = true; psx_mod_immediate_begin_activation(); for (const ModResolution::Plugin& plugin : s.plan.plugins) { @@ -3471,6 +3543,45 @@ static int mod_plan_hooks_parked(void) { return PSXRecompV4::state().netplay_view_plan && !psx_mod_local_view_scope(); } +extern "C" int psx_mod_register_function_override( + const char* id, uint32_t address, PSXModFunctionOverrideFn fn, + const uint32_t* expected_words, int n_words, int32_t credit) { + using namespace PSXRecompV4; + if (!id || !*id || !(address & 0x1fffffffu) || (address & 3u) || !fn) return 0; + if (n_words < 0 || n_words > FO_MAX_GUARD_WORDS) return 0; + if (n_words > 0 && !expected_words) return 0; + if (credit < FO_CREDIT_SELF) return 0; + /* An optional ":label" suffix names this override in diagnostics (the + * `func_override` TCP command) without multiplying manifest plugin ids — + * gating and resolver availability use only the part before the ':'. + * "pkg.feature:aim" and "pkg.feature:fire" are two overrides under the + * one manifest plugin "pkg.feature". */ + const char* colon = strchr(id, ':'); + const std::string plugin_id = colon ? std::string(id, colon - id) + : std::string(id); + if (plugin_id.empty() || (colon && !colon[1])) return 0; + auto& plugins = function_override_plugins(); + const auto duplicate = std::find_if( + plugins.begin(), plugins.end(), + [&](const FunctionOverridePlugin& item) { + return item.id == id && item.address == address; + }); + if (duplicate != plugins.end()) return 0; + FunctionOverridePlugin plugin; + plugin.id = id; + plugin.plugin = plugin_id; + plugin.address = address; + plugin.fn = fn; + for (int i = 0; i < n_words; ++i) plugin.guard[i] = expected_words[i]; + plugin.n_guard = n_words; + plugin.credit = credit; + plugins.push_back(plugin); + /* Mark the plugin id available to the package resolver so a manifest can + * gate an override-only plugin (multiple overrides may share one). */ + mod_register_function_override_marker(plugin_id); + return 1; +} + extern "C" int psx_mod_dispatch_guest_function(CPUState* cpu, uint32_t address) { using namespace PSXRecompV4; if (!g_psx_mod_guest_functions || !cpu || address >= 0xC0000000u) return 0; @@ -3673,7 +3784,7 @@ static int game_netplay_function_entry(CPUState* cpu, uint32_t address) { } extern "C" int psx_mod_function_entry_active(void) { - return PSXRecompV4::function_entry_depth != 0; + return PSXRecompV4::function_entry_depth != 0 || func_override_active(); } extern "C" void mod_runtime_function_entry_context_save(ModFunctionEntryContext *out) { diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp index e0559351..6eb0abeb 100644 --- a/runtime/src/pgxp.cpp +++ b/runtime/src/pgxp.cpp @@ -161,14 +161,9 @@ extern "C" void pgxp_invalidate_all(void) { extern "C" void pgxp_set_enabled(int enabled) { int resized = 0; - /* Writer/triangle/miss receipts are consumed only by debug commands. - * Production still propagates every precision shadow and keeps summary - * counters, but does not copy a diagnostic record for each guest store. */ -#ifndef PSX_NO_DEBUG_TOOLS if (enabled && !s_store_ring) s_store_ring = (PGXPStoreRecord *)std::calloc(PGXP_STORE_RING_CAP, sizeof(PGXPStoreRecord)); -#endif if (enabled && !s_ram) { s_ram = (PGXPValue *)std::calloc(PGXP_SHADOW_WORDS, sizeof(PGXPValue)); if (s_ram) @@ -276,14 +271,10 @@ static uint64_t s_tri_seq = 0; static int ck_in_pass(void); /* render-pass checkpoint open */ extern "C" void pgxp_note_triangle_detail(const PGXPTriRecord *rec) { -#ifndef PSX_NO_DEBUG_TOOLS PGXPTriRecord *r = &s_tri_ring[s_tri_seq % PGXP_TRI_RING_CAP]; *r = *rec; r->seq = s_tri_seq++; r->pass = ck_in_pass() ? 1u : 0u; -#else - (void)rec; -#endif } extern "C" uint64_t pgxp_tri_ring(const PGXPTriRecord **ring, uint32_t *cap) { @@ -307,7 +298,6 @@ extern "C" void pgxp_set_trace_sources(const uint32_t *store_pc, static void note_store(uint32_t op, uint32_t reg, const PGXPValue *src, uint32_t addr, uint32_t value, const PGXPValue *dst) { -#ifndef PSX_NO_DEBUG_TOOLS if (!s_store_ring) return; PGXPStoreRecord *r = &s_store_ring[s_store_seq % PGXP_STORE_RING_CAP]; r->seq = s_store_seq++; @@ -323,9 +313,6 @@ static void note_store(uint32_t op, uint32_t reg, const PGXPValue *src, r->dst_flags = (dst && dst->gen == s_gen) ? dst->flags : 0u; r->dst_x16 = dst ? dst->x16 : 0; r->dst_y16 = dst ? dst->y16 : 0; -#else - (void)op; (void)reg; (void)src; (void)addr; (void)value; (void)dst; -#endif } extern "C" uint64_t pgxp_store_ring(const PGXPStoreRecord **ring, uint32_t *cap) { @@ -1998,7 +1985,6 @@ static uint64_t s_miss_seq = 0; static void note_word_miss(uint32_t addr, uint32_t packed, const PGXPValue *pv, uint8_t reason) { -#ifndef PSX_NO_DEBUG_TOOLS PGXPWordMiss *m = &s_miss_ring[s_miss_seq % PGXP_MISS_RING_CAP]; m->seq = s_miss_seq++; m->addr = addr; @@ -2007,9 +1993,6 @@ static void note_word_miss(uint32_t addr, uint32_t packed, const PGXPValue *pv, m->shadow_flags = pv ? (uint8_t)pv->flags : 0; m->live = (pv && pv->gen == s_gen) ? 1 : 0; m->reason = reason; -#else - (void)addr; (void)packed; (void)pv; (void)reason; -#endif } extern "C" uint64_t pgxp_word_miss_ring(const PGXPWordMiss **ring, diff --git a/runtime/src/postfx_chain.c b/runtime/src/postfx_chain.c index 6c735552..76d06f04 100644 --- a/runtime/src/postfx_chain.c +++ b/runtime/src/postfx_chain.c @@ -238,13 +238,15 @@ int pfx_chain_parse(const char *text, const char *name, PfxShaderLoader load, vo if (!strcmp(f, "source")) from = PFX_IN_SOURCE; else if (!strcmp(f, "source_down")) from = PFX_IN_SOURCE_DOWN; else if (!strcmp(f, "mask")) { from = PFX_IN_MASK; out->uses_mask = 1; } + else if (!strcmp(f, "depth")) { from = PFX_IN_DEPTH; out->uses_depth = 1; } + else if (!strcmp(f, "history")) from = PFX_IN_HISTORY; else if (!strcmp(f, "previous")) { if (self == 0) { pfx_chain_free(out); return pfx_fail(err, errlen, line, "the first pass has no previous"); } from = self - 1; } else { from = -100; for (int i = 0; i < self; i++) if (!strcmp(out->pass[i].name, f)) from = i; - if (from == -100) { pfx_chain_free(out); return pfx_fail(err, errlen, line, "unknown input '%s' (source, mask, previous or an earlier pass)", f); } + if (from == -100) { pfx_chain_free(out); return pfx_fail(err, errlen, line, "unknown input '%s' (source, mask, depth, history, previous or an earlier pass)", f); } } snprintf(cur->in[cur->ninputs].sampler, PFX_NAME, "%s", s); cur->in[cur->ninputs++].from = from; diff --git a/runtime/src/psx_rewind.c b/runtime/src/psx_rewind.c index 4f2c68e5..1b3a1a2c 100644 --- a/runtime/src/psx_rewind.c +++ b/runtime/src/psx_rewind.c @@ -7,7 +7,6 @@ #include "gpu.h" #include "host_keymap.h" #include "host_osd.h" -#include "host_span_ring.h" #include "interrupts.h" #include "mdec.h" #include "psx_cycles.h" @@ -584,11 +583,8 @@ void psx_rewind_poll(CPUState *cpu, uint32_t resume_pc) (void)do_load(cpu, tick); return; } - if (s_capture_due && !s_open && !psx_netplay_active() && !s_title_blocked) { - const uint64_t start = host_span_begin(); - const int ok = do_capture(cpu, resume_pc); - host_span_end(HSPAN_REWIND_CAPTURE, start, (uint32_t)ok); - } + if (s_capture_due && !s_open && !psx_netplay_active() && !s_title_blocked) + (void)do_capture(cpu, resume_pc); } int psx_rewind_toggle(void) diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index e08a42dc..e119c7ca 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -839,7 +839,7 @@ static int transaction_end(int eye, uint32_t alpha, int keep) { static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass, PSXModRenderPassFn fn, void *user, int eye) { - uint64_t t0, t1, tv, tc, tb, tg, te, tr, hash_before = 0, hash_after = 0; + uint64_t t0, t1, tb, tg, te, tr, hash_before = 0, hash_after = 0; uint64_t cycles_before; uint32_t leaks; int ok = 0, open, reuse; @@ -916,7 +916,6 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass } s_open_generation = 0; s_leaks_before = gl_renderer_pass_leaks(); - tv = gl_renderer_perf_ticks(); /* A stereo eye, or the second matte of a capture, whose machine state still * equals the baseline captured for it skips re-capturing that state. */ @@ -939,7 +938,6 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass } if (reuse_baseline) s_stereo.checkpoint_reuses++; else { s_stereo.checkpoint_saves++; s_stereo.checkpoint_ram_bytes += s_ck.ram_bytes; } - tc = gl_renderer_perf_ticks(); if (verify_on()) hash_before = state_hash(cpu); tb = gl_renderer_perf_ticks(); @@ -1081,16 +1079,6 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass double ms = (double)(t1 - t0) * 1000.0 / (double)gl_renderer_perf_frequency(); s_stats.last_pass_ms = ms; - s_stats.timed_passes++; - s_stats.total_gl_begin_ms += (tv - t0) * s_ms_per_tick; - s_stats.total_checkpoint_ms += (tc - tv) * s_ms_per_tick; - s_stats.total_verify_before_ms += (tb - tc) * s_ms_per_tick; - s_stats.total_guest_ms += (tg - tb) * s_ms_per_tick; - s_stats.total_gl_end_ms += (te - tg) * s_ms_per_tick; - s_stats.total_restore_ms += (tr - te) * s_ms_per_tick; - s_stats.total_verify_after_ms += (t1 - tr) * s_ms_per_tick; - s_stats.total_pass_ms += ms; - if (ms > s_stats.max_pass_ms) s_stats.max_pass_ms = ms; s_stats.avg_pass_ms = s_stats.avg_pass_ms > 0.0 ? s_stats.avg_pass_ms * 0.9 + ms * 0.1 : ms; } diff --git a/runtime/src/render_thread.c b/runtime/src/render_thread.c index e3351933..0cd14fde 100644 --- a/runtime/src/render_thread.c +++ b/runtime/src/render_thread.c @@ -102,11 +102,8 @@ static struct { uint64_t tick_at; /* consumer: next tick deadline (0 = none) */ /* shared positions / counters */ - /* Single-writer publications must not invalidate each other's cache line. */ - _Alignas(128) _Atomic uint64_t wpos; - _Atomic uint64_t frames_produced; - _Alignas(128) _Atomic uint64_t rpos; - _Atomic uint64_t frames_consumed; + _Atomic uint64_t wpos, rpos; + _Atomic uint64_t frames_produced, frames_consumed; _Atomic int render_sleeping, emu_sleeping; _Atomic int running; @@ -117,7 +114,7 @@ static struct { _Atomic int released; /* read unlocked by emu_wait's first check */ /* producer-only */ - _Alignas(128) uint64_t pend_start, pend_size, pend_payload_off; + uint64_t pend_start, pend_size, pend_payload_off; uint16_t pend_op, pend_flags; uint32_t pend_payload; int pending; @@ -128,8 +125,8 @@ static struct { /* stats (producer unless noted) */ uint64_t records, bytes, acquires, releases, oversize; uint64_t bp_waits, full_waits, bp_ns, full_ns, acq_ns, high_water; - _Alignas(128) _Atomic uint64_t busy_ns, idle_ns; /* consumer */ - _Alignas(128) _Atomic uint64_t bp_events; /* producer writes, any thread reads */ + _Atomic uint64_t busy_ns, idle_ns; /* consumer */ + _Atomic uint64_t bp_events; /* producer writes, any thread reads */ RtAcquireEvent acq_ring[ACQ_RING]; uint64_t acq_n; @@ -210,8 +207,6 @@ static void *render_main(void *arg) R.ctx(R.user, 1); uint64_t r = atomic_load(&R.rpos); int have_ctx = 1; - const char *batch_env = getenv("PSX_RENDER_QUEUE_BATCH"); - const unsigned batch_limit = batch_env && batch_env[0] == '0' ? 1 : WAKE_RECORDS; for (;;) { run_tick(); uint64_t w = atomic_load_explicit(&R.wpos, memory_order_acquire); @@ -247,23 +242,16 @@ static void *render_main(void *arg) continue; } uint64_t t0 = now_ns(); - unsigned count = 0; - /* Time and publish a bounded ready prefix, not every state command. - * Stop at frame markers so producer backpressure is released at the - * same boundary. The acquire above covers every payload in this prefix. */ - do { - const RtCmd *c = (const RtCmd *)(R.ring + (r & R.mask)); - const int frame = c->op == OP_PAD && (c->flags & F_FRAME); - if (c->op == OP_PAD) { - if (frame) atomic_fetch_add_explicit(&R.frames_consumed, 1, memory_order_relaxed); - } else { - R.exec(R.user, c, (const uint8_t *)c + sizeof(RtCmd)); - } - r += c->size; - if (frame) break; - } while (r != w && ++count < batch_limit); - atomic_store_explicit(&R.rpos, r, memory_order_release); - atomic_fetch_add_explicit(&R.busy_ns, now_ns() - t0, memory_order_relaxed); + const RtCmd *c = (const RtCmd *)(R.ring + (r & R.mask)); + if (c->op == OP_PAD) { + if (c->flags & F_FRAME) + atomic_fetch_add(&R.frames_consumed, 1); + } else { + R.exec(R.user, c, (const uint8_t *)c + sizeof(RtCmd)); + } + r += c->size; + atomic_store(&R.rpos, r); + atomic_fetch_add(&R.busy_ns, now_ns() - t0); wake_emu(); } if (have_ctx) R.ctx(R.user, 0); @@ -345,7 +333,7 @@ void rt_cmd_commit(void) { if (!R.pending) return; R.pending = 0; uint64_t end = R.pend_start + R.pend_size; - atomic_store_explicit(&R.wpos, end, memory_order_release); + atomic_store(&R.wpos, end); R.records++; R.bytes += R.pend_size; uint64_t depth = end - atomic_load(&R.rpos); @@ -362,7 +350,7 @@ static void commit_marker(uint16_t flags) { if (!pred_space(w + size)) emu_wait(pred_space, w + size); RtCmd *c = (RtCmd *)(R.ring + off); /* 16 bytes always fit before the end */ c->size = (uint32_t)size; c->op = OP_PAD; c->flags = flags; c->payload = 0; c->seq = R.seq++; - atomic_store_explicit(&R.wpos, w + size, memory_order_release); + atomic_store(&R.wpos, w + size); } void rt_frame_end(void) { diff --git a/runtime/src/traps.c b/runtime/src/traps.c index 3f9180d9..46074364 100644 --- a/runtime/src/traps.c +++ b/runtime/src/traps.c @@ -572,8 +572,15 @@ static int psx_change_thread_fiber(CPUState* cpu, uint32_t target_tcb) HostThreadFiber* current = psx_bind_current_host_thread(cpu, current_tcb); int saved_current_context = 0; - if (psx_tcb_state(cpu, current_tcb) == 0x4000u) { + /* SYS03 saves the outgoing register context before changing the PCB, + * including CloseTh(current); ChangeTh(other): CloseTh only marks the TCB + * free and does not suppress the exception handler's register stores. + * OpenTh initializes PC/SP/FP/GP but keeps the other saved registers when + * it reuses the TCB. Saving never reopens the closed TCB. */ + const uint32_t outgoing_state = psx_tcb_state(cpu, current_tcb); + if (outgoing_state == 0x4000u || outgoing_state == 0x1000u) psx_save_context_to_tcb(cpu, current_tcb, cpu->gpr[31]); + if (outgoing_state == 0x4000u) { saved_current_context = 1; } else { /* 0x1000 is the BIOS closed/free state and must retire the host fiber. @@ -681,9 +688,13 @@ static int psx_request_thread_switch(CPUState* cpu, uint32_t target_tcb) } /* Commit the yielding thread's context so it can be re-dispatched later. * resume PC = its return address (resume right after the ChangeThread call). - * Only a still-running (0x4000) thread keeps a live context; a closed/free - * (0x1000) thread is being torn down and must not be saved. */ - if (psx_tcb_state(cpu, current_tcb) == 0x4000u) { + * A closed/free (0x1000) thread is still the current thread until SYS03 + * switches the PCB, and the BIOS exception handler stores its registers + * regardless: CloseTh(current); ChangeTh(other) keeps those stores. OpenTh + * initializes PC/SP/FP/GP but keeps the other saved registers when it + * reuses the TCB. Saving never reopens the closed TCB. */ + const uint32_t outgoing_state = psx_tcb_state(cpu, current_tcb); + if (outgoing_state == 0x4000u || outgoing_state == 0x1000u) { psx_save_context_to_tcb(cpu, current_tcb, cpu->gpr[31]); } psx_set_current_tcb(cpu, target_tcb); diff --git a/runtime/src/video_telemetry.cpp b/runtime/src/video_telemetry.cpp index c430194d..4f97b468 100644 --- a/runtime/src/video_telemetry.cpp +++ b/runtime/src/video_telemetry.cpp @@ -1,11 +1,23 @@ #include "video_telemetry.h" #include +#include +#include +#include +#include namespace { std::atomic presents{0}, errors{0}; std::atomic status_available{0}; std::atomic generation{0}; std::atomic fields[8]{}; +constexpr unsigned cadence_capacity = 4096; +std::atomic intervals[cadence_capacity]{}; +std::atomic cadence_version{0}, cadence_count{0}; +std::atomic cadence_reset{false}; +// Only the current present owner touches these. Readers request reset rather +// than changing the producer's cursor or timestamp. +uint64_t last_present_us; +bool have_last_present; } extern "C" void psx_video_telemetry_compose(int renderer, int scale, int sw, int sh, @@ -20,8 +32,75 @@ extern "C" void psx_video_telemetry_compose(int renderer, int scale, int sw, int } extern "C" void psx_video_telemetry_present(int result) { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + psx_video_telemetry_present_at(result, + std::chrono::duration_cast(now).count()); +} + +extern "C" void psx_video_telemetry_present_at(int result, uint64_t now_us) { status_available.store(result >= 0, std::memory_order_relaxed); (result != 0 ? presents : errors).fetch_add(1, std::memory_order_relaxed); + if (result == 0) return; + cadence_version.fetch_add(1); + if (cadence_reset.exchange(false)) { + cadence_count.store(0); + have_last_present = false; + } + if (have_last_present && now_us > last_present_us) { + const uint64_t count = cadence_count.load(); + const auto delta = std::min(now_us - last_present_us, UINT32_MAX); + intervals[count % cadence_capacity].store(static_cast(delta)); + cadence_count.store(count + 1); + } + last_present_us = now_us; + have_last_present = true; + cadence_version.fetch_add(1); +} + +extern "C" void psx_video_telemetry_cadence(int window_ms, int reset, PsxPresentCadence *out) { + if (!out) return; + *out = {}; + uint32_t values[cadence_capacity]; // each reader owns its sorting storage + uint64_t sum = 0; + for (unsigned attempt = 0; attempt < 3; ++attempt) { + out->count = 0; sum = 0; + const uint64_t before = cadence_version.load(); + if (before & 1u) continue; + if (cadence_reset.load()) { out->valid = 1; break; } + const uint64_t count = cadence_count.load(); + const unsigned n = static_cast(std::min(count, cadence_capacity)); + for (unsigned i = 0; i < n; ++i) { + const uint32_t value = intervals[(count - 1 - i) % cadence_capacity].load(); + if (window_ms > 0 && sum + value > static_cast(window_ms) * 1000) break; + sum += value; values[out->count++] = value; + } + if (before == cadence_version.load()) { out->valid = 1; break; } + } + if (reset) cadence_reset.store(true); + if (!out->valid) { *out = {}; return; } + if (!out->count) return; + std::sort(values, values + out->count); + const auto k = out->count; + out->mean_us = static_cast(sum / k); + out->p50_us = values[k / 2]; + out->p95_us = values[(k - 1) * 95 / 100]; + out->p99_us = values[(k - 1) * 99 / 100]; + out->max_us = values[k - 1]; + for (unsigned i = 0; i < k; ++i) + if (static_cast(values[i]) * 2 > static_cast(out->p50_us) * 3) + ++out->hitches; + out->span_ms = sum / 1000.0; + out->fps = sum ? k * 1e6 / static_cast(sum) : 0; +} + +extern "C" int psx_video_telemetry_cadence_json(int window_ms, int reset, char *out, int capacity) { + PsxPresentCadence r{}; + psx_video_telemetry_cadence(window_ms, reset, &r); + return std::snprintf(out, capacity, + "\"valid\":%s,\"measurement\":\"host_api_completion\",\"swaps\":%u,\"span_ms\":%.1f," + "\"fps\":%.2f,\"mean_us\":%u,\"p50_us\":%u,\"p95_us\":%u,\"p99_us\":%u,\"max_us\":%u,\"hitches\":%u", + r.valid ? "true" : "false", r.count, r.span_ms, r.fps, r.mean_us, + r.p50_us, r.p95_us, r.p99_us, r.max_us, r.hitches); } extern "C" void psx_video_telemetry_snapshot(PsxVideoTelemetry *out) { diff --git a/runtime/tests/gl_postfx_projection_fixture.h b/runtime/tests/gl_postfx_projection_fixture.h new file mode 100644 index 00000000..7dfa91f4 --- /dev/null +++ b/runtime/tests/gl_postfx_projection_fixture.h @@ -0,0 +1,6 @@ +#pragma once +/* Renderer fixtures have no guest CPU; exercise the neutral unavailable projection. + * Atomic publication is covered independently by postfx_publication_test. */ +#include "gte_postfx_projection.h" +void gte_postfx_projection_enable(int enabled) {(void)enabled;} +int gte_postfx_projection_read(GtePostfxProjection *out) {(void)out;return 0;} diff --git a/runtime/tests/run_gl_render_thread.py b/runtime/tests/run_gl_render_thread.py index 4a3dd6ff..cf088417 100644 --- a/runtime/tests/run_gl_render_thread.py +++ b/runtime/tests/run_gl_render_thread.py @@ -34,7 +34,7 @@ WIN_LIBS = ["opengl32", "kernel32", "user32", "gdi32", "winmm", "imm32", "ole32" "oleaut32", "version", "uuid", "advapi32", "setupapi", "shell32", "dinput8"] SKIP_EXIT = 77 WINDOWS = os.name == "nt" or platform.system().startswith(("MINGW", "MSYS", "CYGWIN")) -KEYS = ("rb", "digest", "hires", "wide", "stencil") +KEYS = ("rb", "digest", "hires", "wide") def parse(stdout): @@ -56,10 +56,6 @@ def main(): ap.add_argument("--fixture", type=pathlib.Path) ap.add_argument("--scales", default="1,4") ap.add_argument("--frames", default="80") - ap.add_argument("--interpolation", action="store_true", - help="compare temporal hold presentation on the owning context") - ap.add_argument("--wide-present", action="store_true") - ap.add_argument("--quality", action="store_true", help="exercise bloom, PGXP depth and anisotropic LOD") ap.add_argument("--single", action="store_true", help="run the fixture once with no arguments; its exit code is the result") args = ap.parse_args() @@ -73,14 +69,12 @@ def main(): print("Evidence directory:", dest) receipt = [] - def run(command, overrides=None): + def run(command): command = [str(c) for c in command] - env = os.environ.copy() - if overrides: env.update(overrides) r = subprocess.run(command, cwd=dest, capture_output=True, text=True, - encoding="utf-8", errors="replace", env=env) + encoding="utf-8", errors="replace") receipt.append({"cmd": command, "exit": r.returncode, - "env": overrides or {}, "stdout": r.stdout, "stderr": r.stderr[-4000:]}) + "stdout": r.stdout, "stderr": r.stderr[-4000:]}) (dest / "receipt.json").write_text(json.dumps(receipt, indent=2), encoding="utf-8") return r @@ -139,10 +133,7 @@ def main(): for s in [int(v) for v in args.scales.split(",") if v]: runs = {} for threaded in (0, 1): - mode = ["interp-wide"] if args.wide_present else ["interp"] if args.interpolation else [] - if args.quality: mode = [*(mode or ["plain"]), "quality"] - r = run([probe, s, threaded, args.frames, *mode], - {"PSX_GL_WIDE_MARGIN_SCISSOR": "1", "PSX_GL_COVERAGE_FAST": "1"} if args.wide_present else None) + r = run([probe, s, threaded, args.frames]) if first and r.returncode == SKIP_EXIT: # No window/GL context on this host: skip. Only the first # run may skip; after it, 77 is a failure like any other. @@ -156,26 +147,6 @@ def main(): if r.returncode or p["failures"] != 0 or any(p[k] is None for k in KEYS): ok = False runs[threaded] = p - if args.wide_present: - flags = ( - "PSX_GL_WIDE_MARGIN_SCISSOR", "PSX_GL_COVERAGE_FAST", - "PSX_GL_STATE_QUERY_FAST", "PSX_GL_QUEUE_UPLOAD_FAST", - "PSX_GL_BLOOM_FUSE_EXTEND", "PSX_GL_TEXEL_CACHE", - "PSX_GL_HIW_COPY_FAST", "PSX_GL_IDENTITY_COPY_FAST", - "PSX_GL_DRAWABLE_EVENTS", "PSX_RENDER_QUEUE_BATCH", - "PSX_GL_HIW_ASYNC_PACE") - controls = [{flag: "0" for flag in flags}] - controls += [{flag: "0"} for flag in flags] - controls += [{"PSX_GL_TEXEL_CACHE_LAYERS": "4"}] - for control in controls: - overrides = {flag: "1" for flag in flags} - overrides.update(control) - reference = run([probe, s, threaded, args.frames, *mode], overrides) - baseline = parse(reference.stdout) - if reference.returncode or baseline["failures"] != 0 or any(baseline[k] != p[k] for k in KEYS): - print(f"FAIL scale {s}: controls {control} changed rendered output", baseline, p) - ok = False - for k in KEYS: if runs[0][k] != runs[1][k]: print(f"FAIL scale {s}: {k} differs with the render thread on " @@ -183,7 +154,7 @@ def main(): ok = False # Dynamic resolution: the same level steps between frames, recorded with # the thread on (scales above 1 only; 1x has no levels below it). - for s in [int(v) for v in args.scales.split(",") if v and int(v) > 1 and not args.interpolation and not args.wide_present]: + for s in [int(v) for v in args.scales.split(",") if v and int(v) > 1]: runs = {} for threaded in (0, 1): r = run([probe, s, threaded, args.frames, "dynres"]) diff --git a/runtime/tests/test_duckstation_texture_pack.cpp b/runtime/tests/test_duckstation_texture_pack.cpp index f7f57639..52eb9b24 100644 --- a/runtime/tests/test_duckstation_texture_pack.cpp +++ b/runtime/tests/test_duckstation_texture_pack.cpp @@ -1,6 +1,9 @@ #define STB_IMAGE_IMPLEMENTATION #include "../third_party/stb_image.h" #include "duckstation_texture_pack.h" +#include "texture_pack_preload.hpp" +#include +#include #include "../src/png_write.h" #include @@ -109,6 +112,98 @@ bool decode_fails(DuckTexturePack* pack, uint64_t id) { return status == HD_TEXTURE_LOOKUP_ERROR; } +void preload(const fs::path& root) { + namespace tp = psx_texpreload; + const char* name = "texpage-P4-3631D2A04EC85311-9CBCDEA9305D7528-64x256-4-2-8x2-P0-15.png"; + png(root/"replacements"/name,16,4); + std::vector vram(1024*512); + place(vram,65,258,2,words(4)); place(vram,512,400,16,words(16)); + { + auto pack = create(root); auto q = query(vram,64,256,0,5,10,2,3); + HdTexturePreloadResult r{}; + check(duck_texture_pack_preload(pack.get(),size_t(64)<<20,4,&r) == 1 && r.loaded == 1 && + r.requested == 1 && r.bytes == 16*4*4 && r.failed == 0,"preload decodes the pack"); + DuckTextureMatch match{}; + check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"preloaded fixture matches"); + DuckTexturePixels pixels{}; + check(duck_texture_pack_acquire_decoded(pack.get(),match.entry_id,&pixels) == HD_TEXTURE_LOOKUP_FOUND && + pixels.width == 16,"preloaded image is resident without a decode request"); + duck_texture_pixels_release(&pixels); + duck_texture_pack_set_decode_budget(pack.get(),1); + check(duck_texture_pack_preload(pack.get(),256,1,&r) == 1 && r.loaded == 1 && + r.failed == 0 && r.bytes == 256 && r.worker_errors == 0, + "repeat counts existing residency without decoding under the reduced budget"); + check(duck_texture_pack_preload(pack.get(),255,1,&r) == -1 && r.bytes == 256, + "a smaller lifetime cap rejects without hiding pinned residency"); + check(duck_texture_pack_acquire_decoded(pack.get(),match.entry_id,&pixels) == HD_TEXTURE_LOOKUP_FOUND, + "a preloaded image survives budget eviction"); + duck_texture_pixels_release(&pixels); + } + { /* Over the aggregate cap: partial, nothing pinned, async path still decodes. */ + auto pack = create(root); auto q = query(vram,64,256,0,5,10,2,3); + HdTexturePreloadResult r{}; + check(duck_texture_pack_preload(pack.get(),16,4,&r) == 0 && r.loaded == 0 && r.over_budget == 1 && + r.bytes == 0,"oversized image is skipped by the preload cap"); + DuckTextureMatch match{}; + check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"capped fixture matches"); + DuckTexturePixels pixels = decoded(pack.get(),match.entry_id); + check(pixels.width == 16,"an image the preload skipped decodes on demand"); + duck_texture_pixels_release(&pixels); + } + { /* Injected worker failure: caught, reported, image left to the async path. */ + auto pack = create(root); + tp::fault_mode() = 1; + HdTexturePreloadResult r{}; + const int status = duck_texture_pack_preload(pack.get(),size_t(64)<<20,4,&r); + tp::fault_mode() = 0; + check(status == 0 && r.worker_errors == 1 && r.failed == 1 && r.loaded == 0, + "a throwing decode is caught and reported as a partial preload"); + } + { /* Publication can fail after retaining a pinned prefix. */ + auto pack = create(root); + tp::fault_mode() = 3; + HdTexturePreloadResult r{}; + const int status = duck_texture_pack_preload(pack.get(),size_t(64)<<20,4,&r); + tp::fault_mode() = 0; + check(status == -1 && r.loaded == 1 && r.bytes == 16*4*4, + "publication failure still reports every retained byte"); + check(duck_texture_pack_preload(pack.get(),256,1,&r) == 1 && + r.loaded == 1 && r.failed == 0 && r.bytes == 256, + "publication retry accounts the retained prefix without decoding again"); + } + { /* Malformed image: failed, not pinned, async decode reports failure. */ + std::ofstream(root/"replacements"/name,std::ios::binary|std::ios::trunc) << "not a png"; + auto pack = create(root); auto q = query(vram,64,256,0,5,10,2,3); + HdTexturePreloadResult r{}; + check(duck_texture_pack_preload(pack.get(),size_t(64)<<20,4,&r) == 0 && r.failed == 1 && + r.worker_errors == 0,"malformed image fails the preload item"); + DuckTextureMatch match{}; + if (duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND) + check(decode_fails(pack.get(),match.entry_id),"malformed image still fails on demand"); + } +} + +void preload_pool() { + namespace tp = psx_texpreload; + std::atomic ran{0}; + tp::fault_mode() = 2; /* the second thread cannot be created */ + size_t errors = tp::run(32,8,[&](size_t){ ++ran; }); + check(errors == 0 && ran == 32,"thread creation failure lowers concurrency, all items still run"); + tp::fault_mode() = 1; /* the first item throws bad_alloc */ + ran = 0; + errors = tp::run(32,8,[&](size_t){ ++ran; }); + tp::fault_mode() = 0; + check(errors == 1 && ran == 31,"a throwing item is caught and counted"); + ran = 0; + errors = tp::run(32,8,[&](size_t k){ if (k % 4 == 0) throw std::runtime_error("x"); ++ran; }); + check(errors == 8 && ran == 24,"item exceptions never escape a pool thread"); + tp::Budget budget(100); + check(budget.reserve(60) && !budget.reserve(41) && budget.reserve(40) && !budget.reserve(1), + "aggregate budget reservations are bounded"); + budget.release(50); + check(budget.reserve(50),"released bytes are reusable"); +} + void fixed_hash_vectors() { /* Independently produced by Python xxhash 3.6.0's native wheel. These * literal expectations cover XXH3's short/medium/long-input boundaries. */ @@ -538,6 +633,26 @@ void split_coalesce_copy_parts(const fs::path& root) { check(duck_texture_pack_match_draw(pack.get(),&q,0,&match) == HD_TEXTURE_LOOKUP_NONE,"single-cover API preserves native fallback for composed draw"); } +void release_while_another_source_splits(const fs::path& root) { + config(root,"MaxVRAMWriteSplits: 4\nDumpC16Textures: true\nDumpTextureWidthThreshold: 1\nDumpTextureHeightThreshold: 1\n"); + std::vector vram(1024*512); + place(vram,0,0,4,words(16)); place(vram,8,0,8,words(64)); + auto pack=create(root); char error[512]{}; + duck_texture_pack_track_upload(pack.get(),0,0,4,4,vram.data(),vram.size()); + duck_texture_pack_track_upload(pack.get(),8,0,8,8,vram.data(),vram.size()); + auto first=query(vram,0,0,2,0,3,0,3); + auto second=query(vram,0,0,2,8,15,0,7); + duck_texture_pack_dump_draw(pack.get(),&first,0,error,sizeof error); + duck_texture_pack_dump_draw(pack.get(),&second,0,error,sizeof error); + DuckTexturePackInfo before{},after{}; duck_texture_pack_get_info(pack.get(),&before); + check(before.pending_dump_sources==2,"both independent source observations are pending"); + // Retires the first upload entirely, while the second keeps right and lower clips. + duck_texture_pack_invalidate(pack.get(),0,0,12,4); + duck_texture_pack_get_info(pack.get(),&after); + check(after.pending_dump_sources==1 && after.queued_dump_count==1, + "released source finalizes even when surviving upload splits keep the total count steady"); +} + void resident_checkpoint(const fs::path& root) { std::vector vram(1024*512); place(vram,0,0,8,std::vector(8*16,0x3210)); @@ -604,7 +719,8 @@ int main(int argc, char** argv) { palettes(root/"palettes"); st_alias_duplicates(root/"keys"); failed_payloads(root/"invalid"); dumping(root/"dump"); official_binary_oracle(root/"official-oracle"); defaults_and_options(root/"defaults"); immutable_palettes_and_pages(root/"snapshots"); split_coalesce_copy_parts(root/"lifecycle"); copied_pack_ownership(root/"ownership"); - resident_checkpoint(root/"resident"); + resident_checkpoint(root/"resident"); preload(root/"preload"); preload_pool(); + release_while_another_source_splits(root/"release-split"); /* Every artifact is created beneath this fresh, explicitly named test * root. No user-provided paths participate in recursive cleanup. */ if (argc > 1 && !std::strcmp(argv[1],"--keep-artifacts")) std::printf("ARTIFACT_ROOT: %s\n",root.u8string().c_str()); diff --git a/runtime/tests/test_frame_interpolation_context_ownership.py b/runtime/tests/test_frame_interpolation_context_ownership.py index dbdd5fdb..d1257630 100644 --- a/runtime/tests/test_frame_interpolation_context_ownership.py +++ b/runtime/tests/test_frame_interpolation_context_ownership.py @@ -10,9 +10,8 @@ RENDERER = (ROOT / "runtime" / "src" / "gpu_gl_renderer.c").read_text( ) # The only second context is the present thread's ([video] present_thread, -# docs/RENDER_THREAD.md), created in pt_begin under the existing render thread. -# Temporal composition stays on the renderer's original context, which that -# thread may own; interpolation itself creates no thread or context. +# docs/RENDER_THREAD.md), created in pt_begin under the render thread, which +# frame interpolation makes ineligible. assert RENDERER.count("SDL_GL_CreateContext(") == 2, ( "temporal blending must not create a second GL context" ) @@ -25,14 +24,8 @@ assert "SDL_CreateThread(" not in RENDERER, ( assert "SDL_LockMutex(" not in RENDERER, ( "the GL renderer must not hold an application mutex across driver work" ) -_swap = RENDERER[RENDERER.index("static void gl_window_swap(void) {"):] -_swap = _swap[:_swap.index("\n}\n")] -# SDL3 returns status and SDL2 returns void; both compile-time branches belong -# to the same owner. Counting source occurrences alone mistakes these for two -# independently owned swaps. -assert 1 <= _swap.count("SDL_GL_SwapWindow(") <= 2 -assert RENDERER.count("SDL_GL_SwapWindow(") == _swap.count("SDL_GL_SwapWindow("), ( - "all OpenGL presentation must converge on gl_window_swap" +assert RENDERER.count("SDL_GL_SwapWindow(") == 1, ( + "all OpenGL presentation must converge on one owned swap call" ) assert "gl_renderer_interpolation_owns_cadence" in RENDERER assert "no motion vectors" in RENDERER diff --git a/runtime/tests/test_frame_interpolation_flip_source.py b/runtime/tests/test_frame_interpolation_flip_source.py index cab4d86c..1f8e8c9e 100644 --- a/runtime/tests/test_frame_interpolation_flip_source.py +++ b/runtime/tests/test_frame_interpolation_flip_source.py @@ -54,9 +54,7 @@ assert "if (source != s_interp_source) interp_reset_history_unlocked();" in sett assert "s_interp_source = source;" in setter # ---- interp_capture's FLIP branch (gpu_gl_renderer.c) ----------------------- -wrapper = function_body(GL, "static int interp_capture(GLuint fbo,") -assert "interp_capture_timed_impl(" in wrapper, "timed wrapper must invoke the capture implementation" -capture = function_body(GL, "static int interp_capture_timed_impl(GLuint fbo,") +capture = function_body(GL, "static int interp_capture(GLuint fbo,") flip_at = capture.index("if (s_interp_source == 1) {") flip = capture[flip_at:capture.index("} else {", flip_at)] flat = squash(flip) diff --git a/runtime/tests/test_frame_interpolation_schedule.c b/runtime/tests/test_frame_interpolation_schedule.c index afb80d79..8b508347 100644 --- a/runtime/tests/test_frame_interpolation_schedule.c +++ b/runtime/tests/test_frame_interpolation_schedule.c @@ -284,25 +284,6 @@ int main(void) { &schedule, 0u, 1000000u, 60.0, 59.0), "output below source cadence should be rejected"); - frame_interpolation_schedule_reset(&schedule); - schedule.queued = 1; - for (int i = 1; i <= 6; ++i) { - uint64_t deadline; - float alpha; - CHECK(frame_interpolation_schedule_begin(&schedule, 1000000u, - 1000000u, 60.0, 60.0), "queued interval accepted"); - CHECK(frame_interpolation_schedule_next(&schedule, 1000000u, - &deadline, &alpha), "queued interval has one output"); - CHECK(fabs((double)deadline - (1000000.0 + i * 1000000.0 / 60.0)) < 1.0, - "early producer retains every presentation deadline"); - CHECK(!frame_interpolation_schedule_next(&schedule, 1000000u, - &deadline, &alpha), "queued interval has no duplicate output"); - } - CHECK(frame_interpolation_schedule_begin(&schedule, 2000000u, - 1000000u, 60.0, 60.0), "queued clock recovers after sustained stall"); - CHECK(frame_interpolation_schedule_end(&schedule) == 2016667u, - "late queued producer reanchors after sustained stall"); - printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures); return failures ? 1 : 0; } diff --git a/runtime/tests/test_func_override.c b/runtime/tests/test_func_override.c new file mode 100644 index 00000000..3ad9a6a3 --- /dev/null +++ b/runtime/tests/test_func_override.c @@ -0,0 +1,519 @@ +/* test_func_override.c — pins the func_override tier's contract. + * + * The tier's failure modes are all SILENT: a registration that never gets + * consulted, a package override that survives clear-mods, a guard that + * corrupts instead of declining. None of those announce themselves at + * runtime, so they need pinning here rather than field observation. + * + * The tests drive the real dispatcher entry point — g_psx_func_override_hook, + * the same pointer the generated dispatch and the interpreter call through — + * so nothing here exercises a test-only path. + */ + +#include +#include + +#include "cpu_state.h" +#include "func_override.h" + +extern int (*g_psx_func_override_hook)(CPUState *cpu, uint32_t phys); + +static int g_fail = 0; + +#define CHECK(cond, ...) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + g_fail = 1; \ + } \ + } while (0) + +/* ---- test doubles ------------------------------------------------------- */ + +/* Guest RAM the residency guard peeks at. func_override.c reads through + * psx_peek_word_untraced specifically so this stays a plain host read with no + * lockstep/oracle side effects — that is the property under test in + * guard_declines_on_mismatch, and the reason this double is trivial. */ +#define FAKE_RAM_WORDS 64 +static uint32_t s_ram[FAKE_RAM_WORDS]; + +uint32_t psx_peek_word_untraced(uint32_t addr) +{ + const uint32_t idx = (addr & 0x1FFFFFFFu) >> 2; + return (idx < FAKE_RAM_WORDS) ? s_ram[idx] : 0xDEADBEEFu; +} + +/* Cycle-core doubles: func_override.c charges fixed credits through the + * psx_advance_cycles inline (psx_cycles.h), which reads these globals. The + * doubles keep it a plain counter bump so the credit tests can assert + * against psx_cycle_count deltas directly. */ +uint64_t psx_cycle_count = 0; +uint64_t psx_next_service_cycle = 0; +int psx_in_device_service = 0; +int g_event_step_conservative = 0; +int g_ls_replay_active = 0; +uint32_t g_psx_cyc_batch = 0; +uint32_t g_psx_cyc_batch_limit = 0; +void psx_devices_service_to_now(void) {} +void psx_advance_cycles_slow(uint32_t cycles) { psx_cycle_count += cycles; } + +/* Stands in for the recompiled dispatcher. Re-consults the hook exactly as + * psx_dispatch_impl does, so func_override_call_original's one-shot bypass is + * exercised through its real mechanism rather than assumed. */ +static int s_original_runs = 0; +static int s_dispatch_depth = 0; + +/* When set, the "original" body at this address calls ITSELF once — modelling + * a self-recursive guest function, the case where the one-shot bypass must + * already have been consumed so the recursive call re-consults the override + * (what a guest-level wrap would observe). */ +static uint32_t s_recursive_original_at = 0; +static int s_recursion_budget = 0; + +void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t return_addr) +{ + (void)return_addr; + if (s_dispatch_depth > 8) return; /* runaway guard, not part of the contract */ + s_dispatch_depth++; + if (!g_psx_func_override_hook || + !g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu)) { + /* Nothing handled it: this is the original body running. */ + s_original_runs++; + cpu->gpr[2] = 0xAAAAu; + if (s_recursive_original_at && + (addr & 0x1FFFFFFFu) == (s_recursive_original_at & 0x1FFFFFFFu) && + s_recursion_budget > 0) { + s_recursion_budget--; + psx_dispatch_call(cpu, addr, return_addr); + } + } + s_dispatch_depth--; +} + +/* ---- override implementations ------------------------------------------- */ + +static int s_impl_calls, s_decline_calls, s_wrap_calls; + +static int impl_handles(CPUState *cpu) +{ + CHECK(func_override_active(),"handled native override stack is active"); + s_impl_calls++; + cpu->gpr[2] = 0x1234u; + return 1; +} + +static int impl_declines(CPUState *cpu) +{ + (void)cpu; + CHECK(func_override_active(),"declining native override stack is active"); + s_decline_calls++; + return 0; /* the decline-only probe idiom the TCP inventory relies on */ +} + +static int impl_wraps(CPUState *cpu) +{ + s_wrap_calls++; + func_override_call_original(cpu); + cpu->gpr[2] += 1u; /* prove we ran after the original */ + return 1; +} + +/* ---- helpers ------------------------------------------------------------ */ + +static void reset_all(void) +{ + /* No teardown for direct registrations exists by design (they are + * always-on), so each test group runs in a fresh process section by + * using distinct addresses instead of clearing. Counters do reset. */ + s_impl_calls = s_decline_calls = s_wrap_calls = 0; + s_original_runs = 0; + s_recursive_original_at = 0; + s_recursion_budget = 0; + memset(s_ram, 0, sizeof(s_ram)); +} + +static int consult(CPUState *cpu, uint32_t addr) +{ + if (!g_psx_func_override_hook) return 0; + const int handled = g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu); + CHECK(!func_override_active(),"top-level native override stack has unwound"); + return handled; +} + +/* ---- tests -------------------------------------------------------------- */ + +static void test_install_is_null_until_registered(void) +{ + /* The whole "costs nothing when unused" claim rests on this: with nothing + * registered the hook pointer must stay NULL so dispatch matches a build + * without the tier. */ + func_override_install(); + CHECK(g_psx_func_override_hook == NULL, + "hook must stay NULL with nothing registered"); +} + +static void test_add_rejects_bad_input(void) +{ + CHECK(func_override_add("t.null_fn", 0x80001000u, NULL, 0) == FO_ERR_ARGS, + "NULL fn must be refused"); + /* phys 0 collides with the "not inside an override" sentinel. */ + CHECK(func_override_add("t.zero", 0x80000000u, impl_handles, 0) == FO_ERR_ARGS, + "address normalising to phys 0 must be refused"); + CHECK(func_override_add("t.zero2", 0x00000000u, impl_handles, 0) == FO_ERR_ARGS, + "raw address 0 must be refused"); + static const uint32_t g[1] = {0}; + CHECK(func_override_add_guarded("t.g0", 0x80001000u, impl_handles, g, 0, 0) + == FO_ERR_ARGS, + "guarded with n_words 0 must be refused"); + CHECK(func_override_add_guarded("t.gmax", 0x80001000u, impl_handles, g, + FO_MAX_GUARD_WORDS + 1, 0) == FO_ERR_ARGS, + "guard word count over the cap must be refused"); + CHECK(func_override_add_guarded("t.gnull", 0x80001000u, impl_handles, NULL, + 2, 0) == FO_ERR_ARGS, + "guarded with NULL words must be refused"); +} + +static void test_duplicate_address_refused(void) +{ + const uint32_t addr = 0x80002000u; + CHECK(func_override_add("t.first", addr, impl_handles, 0) == FO_OK, + "first registration must succeed"); + /* A silent last-wins here would be untraceable, which is why this is an + * error and not an overwrite. KSEG vs physical must not defeat it. */ + CHECK(func_override_add("t.second", addr, impl_declines, 0) == FO_ERR_DUPLICATE, + "same address twice must be refused"); + CHECK(func_override_add("t.second_phys", addr & 0x1FFFFFFFu, impl_declines, 0) + == FO_ERR_DUPLICATE, + "same address in physical form must also be refused"); +} + +static void test_handled_and_declined_both_count_as_consults(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t handled_at = 0x80003000u; + const uint32_t declined_at = 0x80003100u; + CHECK(func_override_add("t.handled", handled_at, impl_handles, 0) == FO_OK, "add handled"); + CHECK(func_override_add("t.declined", declined_at, impl_declines, 0) == FO_OK, "add declined"); + func_override_install(); + CHECK(g_psx_func_override_hook != NULL, "hook must install once populated"); + + CHECK(consult(&cpu, handled_at) == 1, "handled override must report handled"); + CHECK(cpu.gpr[2] == 0x1234u, "handled override must write $v0"); + CHECK(s_impl_calls == 1, "handled impl must run once"); + + CHECK(consult(&cpu, declined_at) == 0, "declining override must report not-handled"); + CHECK(s_decline_calls == 1, "declining impl must still run"); + + /* An unregistered address must not be claimed. */ + CHECK(consult(&cpu, 0x8000FF00u) == 0, "unregistered address must not be handled"); + + /* calls counts CONSULTS, so a decline-only probe proves reachability. */ + int found_handled = 0, found_declined = 0; + for (int i = 0; i < func_override_count(); i++) { + char id[FO_MAX_ID]; + uint32_t addr = 0; + uint64_t calls = 0, misses = 0; + int guarded = -1; + if (!func_override_get_ex(i, id, sizeof(id), &addr, &calls, &misses, + &guarded, NULL)) + continue; + if (addr == (handled_at & 0x1FFFFFFFu)) { + found_handled = 1; + CHECK(calls == 1, "handled entry calls==1, got %llu", + (unsigned long long)calls); + CHECK(misses == 0, "handled entry must have no guard misses"); + CHECK(guarded == 0, "handled entry is unguarded"); + CHECK(strcmp(id, "t.handled") == 0, "id round-trips, got '%s'", id); + } + if (addr == (declined_at & 0x1FFFFFFFu)) { + found_declined = 1; + CHECK(calls == 1, "DECLINED entry must still count a consult, got %llu", + (unsigned long long)calls); + } + } + CHECK(found_handled && found_declined, "both entries must be enumerable"); +} + +static void test_guard_declines_on_mismatch(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + /* Address inside the fake RAM window so the guard can be satisfied. */ + const uint32_t addr = 0x80000040u; /* phys 0x40 -> word index 16 */ + static const uint32_t prologue[2] = {0x27BDFFE0u, 0xAFB20018u}; + CHECK(func_override_add_guarded("t.guarded", addr, impl_handles, prologue, 2, 0) + == FO_OK, "guarded add must succeed"); + func_override_install(); + + /* Wrong code resident: must DECLINE and never run the impl. Corrupting + * here instead of declining is the failure the guard exists to prevent. */ + s_ram[16] = 0xFFFFFFFFu; + s_ram[17] = 0xFFFFFFFFu; + CHECK(consult(&cpu, addr) == 0, "guard mismatch must decline"); + CHECK(s_impl_calls == 0, "guard mismatch must NOT run the override body"); + + /* Right code resident: must fire. */ + s_ram[16] = prologue[0]; + s_ram[17] = prologue[1]; + CHECK(consult(&cpu, addr) == 1, "guard match must handle"); + CHECK(s_impl_calls == 1, "guard match must run the override body once"); + + /* Partial match is still a miss. */ + s_ram[17] = 0u; + CHECK(consult(&cpu, addr) == 0, "partial guard match must decline"); + CHECK(s_impl_calls == 1, "partial match must not run the body"); + + for (int i = 0; i < func_override_count(); i++) { + char id[FO_MAX_ID]; + uint32_t a = 0; + uint64_t calls = 0, misses = 0; + int guarded = 0; + if (!func_override_get_ex(i, id, sizeof(id), &a, &calls, &misses, + &guarded, NULL)) + continue; + if (a != (addr & 0x1FFFFFFFu)) continue; + CHECK(guarded == 2, "guard word count must be reported, got %d", guarded); + CHECK(misses == 2, "two guard misses expected, got %llu", + (unsigned long long)misses); + } +} + +static void test_call_original_is_one_shot(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t addr = 0x80004000u; + CHECK(func_override_add("t.wrap", addr, impl_wraps, FO_CREDIT_SELF) == FO_OK, "add wrap"); + func_override_install(); + + CHECK(consult(&cpu, addr) == 1, "wrap must handle"); + CHECK(s_wrap_calls == 1, "wrap body runs once"); + /* The bypass must let the ORIGINAL through exactly once — not zero times + * (wrap becomes replace) and not unboundedly (infinite re-entry). */ + CHECK(s_original_runs == 1, "original must run exactly once, ran %d", + s_original_runs); + CHECK(cpu.gpr[2] == 0xAAAAu + 1u, + "wrap must observe then adjust the original's $v0, got 0x%X", + cpu.gpr[2]); + + /* Bypass must not persist: a second call re-consults the override. */ + CHECK(consult(&cpu, addr) == 1, "second call must still be handled"); + CHECK(s_wrap_calls == 2, "wrap body runs again on the second call"); + CHECK(s_original_runs == 2, "original runs once more, total %d", + s_original_runs); +} + +static void test_bypass_is_consumed_so_recursion_reconsults(void) +{ + /* The header promises: "if the original recursively calls itself, the + * recursive calls consult the override again." That requires the one-shot + * bypass to be CONSUMED (cleared) at the moment it is honoured, not merely + * restored when call_original returns — restoring alone looks correct for + * a non-recursive wrap and hides the bug. */ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t addr = 0x80004100u; + CHECK(func_override_add("t.wrap_rec", addr, impl_wraps, FO_CREDIT_SELF) == FO_OK, + "add recursive wrap"); + func_override_install(); + + s_recursive_original_at = addr; + s_recursion_budget = 1; /* the original self-calls exactly once */ + + CHECK(consult(&cpu, addr) == 1, "recursive wrap must handle"); + + /* Outer override runs, calls the original; the original self-calls, and + * that inner call must land on the OVERRIDE again (wrap body twice), not + * on the original a second time. */ + CHECK(s_wrap_calls == 2, + "override must be re-consulted on the original's self-call, wrap ran %d", + s_wrap_calls); + CHECK(s_original_runs == 2, + "each override invocation runs the original once, original ran %d", + s_original_runs); +} + +static void test_call_original_outside_override_is_a_noop(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + func_override_call_original(&cpu); /* must not dispatch anything */ + CHECK(s_original_runs == 0, + "call_original outside an override must do nothing"); +} + +static void test_package_reset_drops_only_package_entries(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t direct_at = 0x80005000u; + const uint32_t pkg_at = 0x80005100u; + CHECK(func_override_add("t.direct", direct_at, impl_handles, 0) == FO_OK, + "direct add"); + CHECK(func_override_add_package("pkg.feature:a", pkg_at, impl_handles, NULL, 0, 0) + == FO_OK, "package add"); + func_override_install(); + + CHECK(consult(&cpu, direct_at) == 1, "direct override armed"); + CHECK(consult(&cpu, pkg_at) == 1, "package override armed"); + + const int before = func_override_count(); + const int dropped = func_override_reset_package_armed(); + CHECK(dropped >= 1, "reset must report dropping the package entry"); + CHECK(func_override_count() == before - dropped, + "count must shrink by exactly the dropped entries"); + + /* This is the netplay-divergence regression: after clear-mods the package + * override must be gone, while the game's own always-on reimplementation + * survives. */ + CHECK(consult(&cpu, pkg_at) == 0, + "package override must NOT fire after reset (netplay divergence)"); + CHECK(consult(&cpu, direct_at) == 1, + "direct override must survive reset"); + + /* Compaction must not corrupt surviving entries. */ + int seen_direct = 0; + for (int i = 0; i < func_override_count(); i++) { + char id[FO_MAX_ID]; + uint32_t a = 0; + uint64_t calls = 0; + if (!func_override_get_ex(i, id, sizeof(id), &a, &calls, NULL, NULL, + NULL)) + continue; + if (a == (direct_at & 0x1FFFFFFFu)) { + seen_direct = 1; + CHECK(strcmp(id, "t.direct") == 0, + "surviving id intact after compaction, got '%s'", id); + } + CHECK(a != (pkg_at & 0x1FFFFFFFu), + "dropped package entry must not remain enumerable"); + } + CHECK(seen_direct, "direct entry must remain enumerable"); + + /* The address is free again, so a later plan can re-arm it. */ + CHECK(func_override_add_package("pkg.feature:a", pkg_at, impl_handles, NULL, 0, 0) + == FO_OK, "re-arming after reset must succeed"); + CHECK(func_override_reset_package_armed() >= 1, "and drop again"); +} + +static void test_get_ex_bounded_id_copy(void) +{ + /* The id buffer size is the caller's to state; a short buffer must + * truncate with termination, never overrun. */ + int checked = 0; + for (int i = 0; i < func_override_count(); i++) { + char small[4]; + uint32_t a = 0; + memset(small, 0x7F, sizeof(small)); + if (!func_override_get_ex(i, small, sizeof(small), &a, NULL, NULL, NULL, + NULL)) + continue; + CHECK(small[sizeof(small) - 1] == '\0', + "short id buffer must be NUL-terminated"); + CHECK(strlen(small) <= sizeof(small) - 1, "short id must be truncated"); + checked = 1; + } + CHECK(checked, "expected at least one entry to enumerate"); + + CHECK(func_override_get_ex(-1, NULL, 0, NULL, NULL, NULL, NULL, NULL) == 0, + "negative index must fail"); + CHECK(func_override_get_ex(func_override_count(), NULL, 0, NULL, NULL, NULL, + NULL, NULL) == 0, + "out-of-range index must fail"); +} + +static void test_credit_policy(void) +{ + reset_all(); + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + + /* The credit is a required statement: FO_CREDIT_SELF or >= 0. Any other + * negative value is a typo, not a policy. */ + CHECK(func_override_add("t.badcredit", 0x80006000u, impl_handles, -2) + == FO_ERR_ARGS, + "credit below FO_CREDIT_SELF must be refused"); + + CHECK(func_override_add("t.credit40", 0x80006100u, impl_handles, 40) + == FO_OK, "fixed-credit add"); + CHECK(func_override_add("t.credit40d", 0x80006200u, impl_declines, 40) + == FO_OK, "fixed-credit decline probe add"); + CHECK(func_override_add("t.creditself", 0x80006300u, impl_handles, + FO_CREDIT_SELF) + == FO_OK, "self-credit add"); + CHECK(func_override_add("t.credit0", 0x80006400u, impl_handles, 0) + == FO_OK, "zero-credit add"); + func_override_install(); + + /* A handled call charges exactly the declared credit... */ + uint64_t before = psx_cycle_count; + CHECK(consult(&cpu, 0x80006100u) == 1, "fixed-credit override handles"); + CHECK(psx_cycle_count - before == 40u, + "handled call must charge the declared credit"); + + /* ...a DECLINE charges nothing (the original runs and self-charges + through the normal backends, so a tier-side charge would double). */ + before = psx_cycle_count; + CHECK(consult(&cpu, 0x80006200u) == 0, "decline probe declines"); + CHECK(psx_cycle_count == before, "a decline must charge nothing"); + + /* FO_CREDIT_SELF and 0 charge nothing from the tier. */ + before = psx_cycle_count; + CHECK(consult(&cpu, 0x80006300u) == 1, "self-credit override handles"); + CHECK(psx_cycle_count == before, "SELF must not be charged by the tier"); + before = psx_cycle_count; + CHECK(consult(&cpu, 0x80006400u) == 1, "zero-credit override handles"); + CHECK(psx_cycle_count == before, "credit 0 must charge nothing"); + + /* The declared policy is inspectable, not just documented. */ + int seen40 = 0, seenself = 0; + for (int i = 0; i < func_override_count(); i++) { + char id[FO_MAX_ID]; + uint32_t a = 0; + int32_t credit = -99; + if (!func_override_get_ex(i, id, sizeof(id), &a, NULL, NULL, NULL, + &credit)) + continue; + if (a == 0x00006100u) { seen40 = (credit == 40); } + if (a == 0x00006300u) { seenself = (credit == FO_CREDIT_SELF); } + } + CHECK(seen40, "get_ex must report the fixed credit"); + CHECK(seenself, "get_ex must report FO_CREDIT_SELF"); +} + +int main(void) +{ + test_install_is_null_until_registered(); + test_add_rejects_bad_input(); + test_duplicate_address_refused(); + test_handled_and_declined_both_count_as_consults(); + test_guard_declines_on_mismatch(); + test_call_original_is_one_shot(); + test_bypass_is_consumed_so_recursion_reconsults(); + test_call_original_outside_override_is_a_noop(); + test_package_reset_drops_only_package_entries(); + test_get_ex_bounded_id_copy(); + test_credit_policy(); + + if (g_fail) { + printf("func_override: FAILURES\n"); + return 1; + } + printf("func_override: all checks passed (%d entries registered)\n", + func_override_count()); + return 0; +} diff --git a/runtime/tests/test_gl_bloom.c b/runtime/tests/test_gl_bloom.c index 1ca26cc4..9ae618fb 100644 --- a/runtime/tests/test_gl_bloom.c +++ b/runtime/tests/test_gl_bloom.c @@ -11,10 +11,17 @@ * bottom-up) so a run against another tree can be compared byte for byte * (BLOOM_FIXTURE_NO_BLOOM builds it without the bloom API). */ #define GL_PRESENT_TEST_HOOK(gen) bloom_capture_hook(gen) +#include +static uint64_t pfx_fixture_clock(void); +#define PFX_NOW_TICKS() pfx_fixture_clock() static void bloom_capture_hook(int gen); #define main scale_fixture_main #include "test_gl_scale_invariance.c" #undef main +static uint64_t s_pfx_test_clock; +static uint64_t pfx_fixture_clock(void) { + return s_pfx_test_clock ? s_pfx_test_clock : SDL_GetPerformanceCounter(); +} void gpu_hd_textures_begin_upload(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} void gpu_hd_textures_track_upload(int x,int y,int w,int h,const uint16_t* p){(void)x;(void)y;(void)w;(void)h;(void)p;} void gpu_hd_textures_invalidate(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} @@ -54,6 +61,7 @@ static void bloom_capture_hook(int gen) { } enum { SC_2D, SC_ADD, SC_3D, SC_HUD_OVER_3D, SC_PARTIAL, SC_N }; +enum { SC_DEPTH_ONLY = 99 }; static const char *k_scene[SC_N] = { "2d", "additive", "3d", "hud-over-3d", "partial" }; /* SC_PARTIAL's grey HUD panels (native x, y, w, h): one beside the lamp, one * covering part of it. */ @@ -77,8 +85,10 @@ static uint8_t *frame(int scene, float bloom, uint64_t *vram_hash) { glb_set_mask_bits(0, 0); glb_set_semi_transparency(0, 0); glb_fill_rect(0, 0, FRAME_W, FRAME_H, 0x0000); /* A dark 3D ground everywhere: lit (may take a halo), below the threshold. */ - tri3d(0, 0, FRAME_W, 0, 0, FRAME_H, 0x0842); - tri3d(FRAME_W, 0, FRAME_W, FRAME_H, 0, FRAME_H, 0x0842); + if (scene != SC_DEPTH_ONLY) { + tri3d(0, 0, FRAME_W, 0, 0, FRAME_H, 0x0842); + tri3d(FRAME_W, 0, FRAME_W, FRAME_H, 0, FRAME_H, 0x0842); + } switch (scene) { case SC_2D: /* a white HUD panel */ glb_draw_flat_rect(120, 90, 80, 60, 0x7fff); @@ -88,6 +98,7 @@ static uint8_t *frame(int scene, float bloom, uint64_t *vram_hash) { glb_draw_flat_triangle(120, 150, 200, 150, 160, 80, 0x7fff); glb_set_semi_transparency(0, 0); break; + case SC_DEPTH_ONLY: case SC_3D: /* a lit 3D polygon */ tri3d(120, 150, 200, 150, 160, 80, 0x7fff); break; @@ -234,6 +245,7 @@ int main(int argc, char **argv) { fputs("void main(){ o = this is not glsl; }\n", f); fclose(f); } + gl_renderer_render_thread_stop(); for (int pass = 0; pass < 2; pass++) { /* 0: this thread, 1: render thread */ uint64_t h = 0; gl_renderer_set_post_shader("pfx_invert.pfx"); @@ -261,6 +273,69 @@ int main(int argc, char **argv) { check(lit > s_cap_w * s_cap_h / 2, "mask input: the 3D ground and lamp are lit"); free(m); + /* A history input also works on the final window pass. The blue + * channel observes the shader clock, so reload must reset its origin. */ + FILE *history_file=fopen("pfx_history.glsl","w"); + check(history_file!=NULL,"open history shader"); + if(history_file) { + fputs("void main(){ float r=texelFetch(u_prev,ivec2(gl_FragCoord.xy)-u_org,0).r;" + "o=vec4(r*0.5+u_add,0.0,clamp(u_time/4.0,0.0,1.0),1.0); }\n",history_file); + fclose(history_file); + } + history_file=fopen("pfx_history.pfx","w"); + if(history_file) { + fputs("pfx 1\npass history\n shader pfx_history.glsl\n input u_prev history\n uniform u_add 0.25\n",history_file); + fclose(history_file); + } + psx_host_set_post_uniform("u_add",0.25f,0,0,0); + s_pfx_test_clock=100*SDL_GetPerformanceFrequency(); + gl_renderer_set_post_shader("pfx_history.pfx"); + uint8_t *hist=frame(SC_PARTIAL,0,&h); + size_t center=((size_t)(s_cap_h/2)*s_cap_w+s_cap_w/2)*3; + check(abs(hist[center]-64)<=1 && hist[center+2]<=1,"first history frame is cleared and starts at time zero"); + free(hist); + s_pfx_test_clock=102*SDL_GetPerformanceFrequency(); + hist=frame(SC_PARTIAL,0,&h); + check(abs(hist[center]-96)<=1 && abs(hist[center+2]-128)<=1,"final pass samples the previous real image and its own elapsed time"); + free(hist); + if(!s_rth_on) { + for(int repeat=0;repeat<2;++repeat) { + postfx_present(0,0,0,0,s_cap_w,s_cap_h); + bloom_capture_hook(1); + check(abs(s_cap[center]-112)<=1,"generated presents do not advance history"); + } + } + psx_host_set_post_uniform("u_add",0.125f,0,0,0); + hist=frame(SC_PARTIAL,0,&h); + check(abs(hist[center]-80)<=1,"live uniform update reaches the actual shader"); + free(hist); + s_pfx_test_clock=104*SDL_GetPerformanceFrequency(); + gl_renderer_set_post_shader("pfx_history.pfx"); + hist=frame(SC_PARTIAL,0,&h); + check(abs(hist[center]-32)<=1 && hist[center+2]<=1,"reload clears history and resets shader time while retaining live values"); + free(hist); + s_pfx_test_clock=0; + + FILE *depth_file=fopen("pfx_depth.glsl","w"); + if(depth_file) { + fputs("void main(){ float d=texelFetch(u_depth,ivec2(gl_FragCoord.xy)-u_org,0).r;" + "o=vec4(clamp(d/4000.0,0.0,1.0),0.0,0.0,1.0); }\n",depth_file); + fclose(depth_file); + } + depth_file=fopen("pfx_depth.pfx","w"); + if(depth_file) { + fputs("pfx 1\npass depth\n shader pfx_depth.glsl\n input u_depth depth\n",depth_file); + fclose(depth_file); + } + gl_renderer_set_pgxp_depth(1); + gl_renderer_set_post_shader("pfx_depth.pfx"); + uint8_t *depth=frame(SC_DEPTH_ONLY,0,&h); + size_t outside=((size_t)(s_cap_h-1-20)*s_cap_w+20)*3; + check(abs(depth[center]-128)<=3,"depth input reconstructs the triangle's GTE screen depth"); + check(depth[outside]<=1,"depth input is zero where no 3D primitive drew"); + free(depth); + gl_renderer_set_pgxp_depth(0); + gl_renderer_set_post_shader("pfx_screen.pfx"); check(gl_renderer_post_shader_loaded(), "source_down + blend screen chain loads"); uint8_t *sc = frame(SC_PARTIAL, 0.0f, &h); diff --git a/runtime/tests/test_gl_frame_gen.c b/runtime/tests/test_gl_frame_gen.c index f3f65c3e..03821090 100644 --- a/runtime/tests/test_gl_frame_gen.c +++ b/runtime/tests/test_gl_frame_gen.c @@ -34,6 +34,7 @@ static void fixture_present(int generated); static void fixture_window(int slot); #define GL_PRESENT_THREAD_TEST_HOOK(slot) fixture_window(slot) #include "gpu_gl_renderer.c" +#include "gl_postfx_projection_fixture.h" #include "gpu_hd_texture_stubs.inc" #include "mod_texture_banks.c" #include "gl_batch_policy_lle.c" diff --git a/runtime/tests/test_gl_hd_texture_pack.c b/runtime/tests/test_gl_hd_texture_pack.c index bfaa004e..3f7a7a2f 100644 --- a/runtime/tests/test_gl_hd_texture_pack.c +++ b/runtime/tests/test_gl_hd_texture_pack.c @@ -11,8 +11,6 @@ static void fg_fixture_present(int generated); #include "test_gl_scale_invariance.c" #undef main #include "gpu_render.c" -/* This renderer fixture has no emulation-side render-pass transaction. */ -int g_psx_render_pass_active = 0; #include "duckstation_texture_pack.h" #include "hd_texture_pack.h" #include "png_write.h" @@ -361,8 +359,9 @@ static void composition_scene(const char* root) { check(sample(20,8)[0]>200 && sample(20,8)[2]<20,"composed all-zero ST cutout retains destination"); check(sample(24,8)[0]>200 && sample(24,8)[1]<20,"native transparent hole remains cutout"); check(sample(32,8)[0]<20 && sample(32,8)[2]>200,"uncovered native opaque texel retains native color"); + /* Textured draws preserve the source STP bit in canonical guest VRAM. */ uint16_t output[8];gr_vram_transfer_out(2,2,8,1,output); - check(output[0]==0x03e0 && output[2]==0x03ff && output[6]==0x7c00, + check(output[0]==0x03e0 && output[2]==0x83ff && output[6]==0x7c00, "composition does not change guest native draw or STP behavior"); GpuHdTextureImage first={0},again={0}; check(gpu_hd_textures_acquire_draw(tp,0,0,bounds,0,1,&first),"composed cache acquire"); @@ -426,7 +425,7 @@ static void composition_scene(const char* root) { } } -static void cache_eviction_scene(const char* root) { +static void cache_pressure_scene(const char* root) { uint16_t pressure_words[16]; for(int i=0;i<16;++i) pressure_words[i]=0x1234; DuckTextureKey key={0}; key.kind=DUCK_TEXTURE_UPLOAD;key.depth=HD_TEXTURE_DEPTH_16BPP; key.source_hash=duck_texture_hash_words_le(pressure_words,16); @@ -447,7 +446,7 @@ static void cache_eviction_scene(const char* root) { composed_key=image.cache_key; gpu_hd_textures_release_image(&image);if(!composed_key) SDL_Delay(1); } - check(composed_key!=0,"complete composition ready before decoder eviction"); + check(composed_key!=0,"complete composition ready before cache pressure"); gr_fill_rect(0,0,16,16,0x03e0);gr_draw_textured_rect(2,2,4,4,0,0,0,0,texture_page);capture(); GpuHdTextureDiag before,after;gpu_hd_textures_get_diag(&before); gr_vram_transfer_in(640,64,16,1,pressure_words);int ready=0; @@ -456,20 +455,61 @@ static void cache_eviction_scene(const char* root) { gpu_hd_textures_release_image(&image);if(!ready) SDL_Delay(1); } check(ready,"full CPU budget pressure image decoded");gpu_hd_textures_get_diag(&after); - check(after.decoded_bytes==1024*1024 && after.decode_evictions>before.decode_evictions, - "pressure image evicts original decoded replacements"); + /* All fixture images fit in the bounded preload and remain pinned. + * The configured 1 MiB on-demand budget must not evict that preload. + * decoded_bytes accounts only the on-demand cache; pinned bytes are + * separately bounded and are not charged against this budget. */ + check(before.decoded_images>=7 && before.decoded_bytes==0 && + after.decoded_bytes==before.decoded_bytes && + after.decoded_images==before.decoded_images && + after.decode_evictions==before.decode_evictions, + "cache pressure retains pinned preloaded replacements"); check(gpu_hd_textures_acquire_gl_draw(texture_page,0,0,bounds,0,0,&image) && !image.rgba, - "GPU-resident replacement remains ready after CPU eviction");gpu_hd_textures_release_image(&image); + "GPU-resident replacement remains ready under cache pressure");gpu_hd_textures_release_image(&image); check(gpu_hd_textures_acquire_draw(tp,0,0,parts,0,1,&image) && image.cache_key==composed_key, - "complete composition survives eviction of all source PNGs");gpu_hd_textures_release_image(&image); + "complete composition remains warm under cache pressure");gpu_hd_textures_release_image(&image); gr_draw_textured_rect(2,2,4,4,0,0,0,0,texture_page);capture(); check(sample(8,8)[0]>200,"GPU-resident replacement still draws original HD detail"); SDL_Delay(20);gpu_hd_textures_get_diag(&before); check(before.decoded_images==after.decoded_images && before.gl_uploads==after.gl_uploads, - "warm draws do not decode or upload again after CPU eviction"); + "warm draws do not decode or upload again under cache pressure"); pack_config(root,0);check(gpu_hd_textures_reload(error,sizeof(error)),"eviction fixture cleanup reload"); state();gr_vram_transfer_in(512,0,4,4,source_words);wait_ready(0);wait_ready(1); } + +/* Indexed replacements choose a live CLUT entry rather than a baked RGB + * palette. Exercise the real upload/match/decode/shader path. */ +static void indexed_palette_scene(const char* root) { + char dir[2048],path[2304],error[256]={0}; + snprintf(dir,sizeof dir,"%s/beetle",root); + uint16_t words[16],palette[16]={0}; + for(int n=0;n<16;++n) words[n]=0x1111; + palette[1]=0x03e0; palette[2]=0x7c00; + snprintf(path,sizeof path,"%s/demo-texture-replacements/%x-index.png",dir, + hd_texture_crc32_words_le(words,16)); + uint8_t rgba[64*16*4]; + for(int n=0;n<64*16;++n) { + rgba[n*4]=2; rgba[n*4+1]=rgba[n*4+2]=0; rgba[n*4+3]=255; + } + FILE* f=fopen(path,"wb");check(f!=NULL,"indexed GPU fixture opens"); + if(f){check(png_write_rgba(f,rgba,64,16),"indexed GPU fixture writes");fclose(f);} + check(gpu_hd_textures_configure(dir,1,0,error,sizeof error),"indexed GPU session opens"); + state();gr_vram_transfer_in(0,300,16,1,palette);gr_vram_transfer_in(512,0,4,4,words); + const int bounds[4]={0,0,7,3};GpuHdTextureImage image={0};int ready=0; + for(int n=0;n<2000&&!ready;++n) { + ready=gpu_hd_textures_acquire_draw(8u,0,300,bounds,0,0,&image) && image.alpha_mode==5; + gpu_hd_textures_release_image(&image);if(!ready)SDL_Delay(1); + } + check(ready,"indexed GPU path selects decoded palette-independent replacement"); + gr_fill_rect(0,0,16,16,0x001f);gr_draw_textured_rect(2,2,8,4,0,0,0,300,8u);capture(); + check(sample(8,8)[2]>200&&sample(8,8)[1]<20,"indexed shader uses replacement index and live blue CLUT entry"); + gr_vram_write(2,300,0x001f); + gr_draw_textured_rect(2,2,8,4,0,0,0,300,8u);capture(); + check(sample(8,8)[0]>200&&sample(8,8)[2]<20,"indexed shader follows live CLUT change without changing replacement"); + uint16_t native[8];gr_vram_transfer_out(2,2,8,1,native); + check(native[0]==0x03e0,"indexed presentation preserves native source-index RGB"); +} + int main(int argc,char** argv) { if(argc==2 && !strcmp(argv[1],"--native-baseline")) { char* native_args[]={"hd-baseline","4","twin","1"}; @@ -538,7 +578,7 @@ int main(int argc,char** argv) { "ST black alpha127 remains occupied semitransparent black"); gr_set_semi_transparency(0,0); composition_scene(argv[1]);state();wait_ready(0);wait_ready(1); - cache_eviction_scene(argv[1]); + cache_pressure_scene(argv[1]); /* A queued draw must consume its source before a later source overwrite. * The next draw falls back because upload identity has been invalidated. */ @@ -604,7 +644,7 @@ int main(int argc,char** argv) { gr_fill_rect(0,0,16,16,0x03e0); gr_draw_textured_rect(4,4,2,4,5,0,0,0,texture_page); capture(); check(sample(16,16)[2]>200 && sample(16,16)[0]<20,"Beetle partial draw keeps upload-relative UV origin"); - check(vram[4*1024+4]==0x03e0 && vram[517]==0x83e0, + check(vram[4*1024+4]==0x83e0 && vram[517]==0x83e0, "Beetle image preserves native draw RGB and source STP"); gr_draw_textured_rect(8,4,4,4,4,0,0,0,texture_page); capture(); check(sample(44,16)[1]>200,"Beetle native transparent zero still cuts out replacement"); @@ -864,6 +904,7 @@ int main(int argc,char** argv) { check(fg_gen_hud_hd>0 && fg_gen_hud_bad==0,"Smooth motion + HD: generated HUD is the HD replacement"); } } + indexed_palette_scene(argv[1]); memcpy(reference,vram,sizeof(vram)); gl_renderer_set_cpu_auth_dual(1); gpu_hd_textures_shutdown(); diff --git a/runtime/tests/test_gl_readback_region.c b/runtime/tests/test_gl_readback_region.c index 9a5e8350..9b245733 100644 --- a/runtime/tests/test_gl_readback_region.c +++ b/runtime/tests/test_gl_readback_region.c @@ -1,5 +1,6 @@ /* Original source-owned GL readback-coherence regression. No retail payload. */ #include "gpu_gl_renderer.c" +#include "gl_postfx_projection_fixture.h" #include "gpu_hd_texture_stubs.inc" #include "mod_texture_banks.c" #include "gl_batch_policy_lle.c" diff --git a/runtime/tests/test_gl_render_thread.c b/runtime/tests/test_gl_render_thread.c index 7eff984c..d15bbe73 100644 --- a/runtime/tests/test_gl_render_thread.c +++ b/runtime/tests/test_gl_render_thread.c @@ -30,6 +30,7 @@ * surface), and with the thread on its record/acquire counters. * Original source-owned scene; no retail payload. */ #include "gpu_gl_renderer.c" +#include "gl_postfx_projection_fixture.h" #include "gpu_hd_texture_stubs.inc" #include "mod_texture_banks.c" #include "gl_batch_policy_lle.c" @@ -132,23 +133,12 @@ static void test_live_resolution(int original_scale) { check(gl_renderer_set_internal_scale_live(original_scale) == original_scale, "live resolution recovers after refusal"); } -static uint64_t fixture_clock_us(void) { - const uint64_t now = SDL_GetPerformanceCounter(), hz = SDL_GetPerformanceFrequency(); - return now / hz * 1000000u + now % hz * 1000000u / hz; -} -static int drawable_worker(void *data) { - int *size = data; - gl_drawable_size(&size[0], &size[1]); - return 0; -} static uint64_t fnv(const void *pp, size_t n, uint64_t h) { const uint8_t *p = (const uint8_t *)pp; for (size_t i = 0; i < n; i++) { h ^= p[i]; h *= 0x100000001b3ull; } return h; } static uint64_t rb_digest = 0xcbf29ce484222325ull; -static int wide_present; -static int quality_scene; static uint32_t rng = 0x2468ace1u; static uint32_t rnd(void) { rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; return rng; } @@ -251,16 +241,6 @@ static void frame(int f, int buf_x) { gr_draw_flat_rect(576, 64, 32, 32, (uint16_t)(0x03e0 + f)); gr_draw_textured_rect(buf_x + 100, 200, 32, 32, 0, 64, 512, 256, 0x0009); gr_copy_rect(buf_x + 10, 10, buf_x + 14, 13, 80, 50); - /* Identity copies preserve colour/mask unless set-mask adds bit15. */ - mask_bits(f % 4 == 2, 0); - gr_copy_rect(buf_x + 14, 13, buf_x + 14, 13, 80, 50); - mask_bits(0, 0); - if (quality_scene && f % 3 == 1) { - /* Consume the copied bit15 mask before any later framebuffer clear. */ - mask_bits(0, 1); - gr_draw_flat_rect(buf_x + 14, 13, 80, 50, (uint16_t)(0x7c00 + f)); - mask_bits(0, 0); - } /* A half-VRAM upload every frame pushes the ring past its end. */ { static uint16_t big[1024 * 256]; @@ -277,36 +257,7 @@ static void frame(int f, int buf_x) { gr_vram_transfer_out(buf_x + 50, 50, 64, 32, out); rb_digest = fnv(out, sizeof out, rb_digest); } - if (wide_present) { - /* Reach both reveal margins after the last upload/readback, leaving - * real queued work for the final present flush. Alternate the full - * background gate so the pruning fallback is exercised too. */ - g_bg_full = f % 3 == 0; - gr_wide_set_view(0, 0, 0, 0); - g_prim_id = 1; - gr_set_semi_transparency(f % 2, f % 4); - if (quality_scene) { - gr_set_precise_triangle(1, (buf_x-40)*65536, 20*65536, - (buf_x+360)*65536, 20*65536, (buf_x+160)*65536, 220*65536); - gr_set_depth_triangle(1, 2000.0f, 2200.0f, 2400.0f); - } - gr_draw_textured_triangle(buf_x-40, 20, 0, 0, buf_x+360, 20, 63, 0, - buf_x+160, 220, 32, 63, 512, 256, 0x0008); - if (quality_scene) { - /* Minification, transparent/STP texels, and a later occluded draw - * make depth coverage observable in the final colour image. */ - gr_set_semi_transparency(0, 0); - gr_set_precise_triangle(1, (buf_x+80)*65536, 60*65536, - (buf_x+100)*65536, 60*65536, (buf_x+90)*65536, 80*65536); - gr_set_depth_triangle(1, 3000.0f, 3000.0f, 3000.0f); - gr_draw_textured_triangle(buf_x+80, 60, 0, 0, buf_x+100, 60, 255, 0, - buf_x+90, 80, 127, 255, 512, 256, 0x0008); - gr_set_precise_triangle(0, 0, 0, 0, 0, 0, 0); - gr_set_depth_triangle(0, 0, 0, 0); - } - check(gl_renderer_present_wide_fbo(buf_x, 0, FRAME_H, 0), "wide present"); - } else - gl_renderer_present_vram(buf_x, 0, FRAME_W, FRAME_H, 0, 0); + gl_renderer_present_vram(buf_x, 0, FRAME_W, FRAME_H, 0, 0); gl_renderer_render_thread_frame_boundary(); } @@ -315,9 +266,6 @@ int main(int argc, char **argv) { int threaded = argc > 2 && argv[2][0] == '1'; int frames = argc > 3 ? atoi(argv[3]) : 80; int dynres = argc > 4 && !strcmp(argv[4], "dynres"); - wide_present = argc > 4 && !strcmp(argv[4], "interp-wide"); - int interp = wide_present || (argc > 4 && !strcmp(argv[4], "interp")); - quality_scene = argc > 5 && !strcmp(argv[5], "quality"); /* No video device, window or GL context (headless, Windows over SSH): * exit 77, which the harness reports as a CTest skip. */ if (SDL_Init(SDL_INIT_VIDEO) != 0) { @@ -350,25 +298,10 @@ int main(int argc, char **argv) { gr_set_scale(scale); if (dynres) gl_renderer_set_dynamic_resolution(1); gl_renderer_set_swap_interval(0); - if (getenv("PSX_GL_TEST_PRESENT_THREAD")) gl_renderer_set_present_thread(1, 3); if (!gl_renderer_init_context(win)) { fprintf(stderr, "FAIL context\n"); return 2; } check(gr_backend() == GR_BACKEND_OPENGL, "OpenGL backend"); textures(); - if (wide_present && s_hiw) { - check(hiw_ensure(0, FRAME_W) != NULL, "first hires buffer"); - check(hiw_ensure(512, 512+FRAME_W) != NULL, "second hires buffer"); - } - gl_renderer_swap_timing_start(fixture_clock_us); gr_wide_configure(426, 53); - if (quality_scene) { - gl_renderer_set_bloom(1.0f); - gl_renderer_set_pgxp_depth(1); - gl_renderer_set_texture_lod(1, 16); - } - if (interp) { - gl_renderer_set_interpolation_source(1); - gl_renderer_set_interpolation(1, 60.0, 60.0, 60.0, 2); - } if (threaded) { check(gl_renderer_render_thread_start(2) == 1, "render thread started"); check(gl_renderer_render_thread_active(), "render thread active"); @@ -382,7 +315,6 @@ int main(int argc, char **argv) { } static const int levels[] = { 2, 1, 3, 1, 4, 2 }; int nsteps = 0, level = scale; - const uint64_t drawable_queries_before = s_drawable_queries; for (int f = 0; f < frames; f++) { frame(f, (f & 1) ? 512 : 0); if (dynres && f % 7 == 6) { /* includes frames held at a sync point */ @@ -437,70 +369,6 @@ int main(int argc, char **argv) { if (got) wide = fnv(wb, (size_t)ww * wh * 4, 0xcbf29ce484222325ull); free(wb); } - if (interp) { - check(s_interp_captures > 0 && s_interp_swaps > 0, "temporal frames presented"); - } - /* Colour hashes alone do not verify the bloom-source stencil. Read its - * bit on every canonical/window/wide target while the context is held. */ - uint64_t stencil = 0xcbf29ce484222325ull; - if (quality_scene) { - gl_renderer_render_thread_sync("test_stencil"); - GLuint fbos[1 + HIW_MAX_TILES + 8]; - int widths[1 + HIW_MAX_TILES + 8], heights[1 + HIW_MAX_TILES + 8], nf = 0; - fbos[nf] = s_hr_fbo; widths[nf] = VRAM_W * s_hr_scale; - heights[nf++] = VRAM_H * s_hr_scale; - for (int t = 0; t < s_hiw_n; ++t) { - fbos[nf] = s_hiw_t[t].fbo; - widths[nf] = (s_hiw_t[t].x1 - s_hiw_t[t].x0) * scale; - heights[nf++] = VRAM_H * scale; - } - /* The displayed wide surface is sufficient for the centre splice - * and clipped mirror; canonical targets cover both buffer origins. */ - if (g_wide_cur) { - fbos[nf] = g_wide_cur; widths[nf] = g_wide_w * scale; - heights[nf++] = VRAM_H * scale; - } - for (int t = 0; t < nf; ++t) { - size_t bytes = (size_t)widths[t] * heights[t]; - unsigned char *bits = malloc(bytes); - check(bits != NULL, "stencil readback allocation"); - if (!bits) continue; - /* Bit0 can remain deferred under disabled mask checking. Compare - * alpha-derived semantic masks, preserving bloom bit1, rather - * than comparing the unused deferred storage representation. */ - rebuild_target_stencil(fbos[t], widths[t], heights[t]); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbos[t]); - glPixelStorei(GL_PACK_ALIGNMENT, 1); - glReadPixels(0, 0, widths[t], heights[t], GL_STENCIL_INDEX, GL_UNSIGNED_BYTE, bits); - for (size_t p = 0; p < bytes; ++p) bits[p] &= 3; - stencil = fnv(bits, bytes, stencil); - free(bits); - } - glPixelStorei(GL_PACK_ALIGNMENT, 4); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - } - if (wide_present && wide_margin_scissor()) { - check(s_hq_center_pruned > 0, "queued centre shading was elided"); - printf("pruned_cmds=%llu\n", (unsigned long long)s_hq_center_pruned); - } - if (wide_present && hiw_upload_fast()) { - check(s_hq_saved_uploads > s_hq_uploads, "queue uploaded once for multiple replay draws"); - printf("queue_uploads=%llu replay_draws=%llu\n", (unsigned long long)s_hq_uploads, - (unsigned long long)s_hq_saved_uploads); - } - if (quality_scene && coverage_fast_enabled()) { - - check(s_bcls_fused_draws > 0, "bloom stencil fused with colour draws"); - printf("fused_draws=%llu\n", (unsigned long long)s_bcls_fused_draws); - check(s_coverage_depth_draws > 0, "coverage-only depth passes ran"); - printf("coverage_draws=%llu\n", (unsigned long long)s_coverage_depth_draws); - } - if (!getenv("PSX_GL_TEXEL_CACHE") || getenv("PSX_GL_TEXEL_CACHE")[0] != '0') { - check(s_tc_ready == 1 && s_tc_hits > 0 && s_tc_misses > 0, "decoded indexed texels reused"); - printf("texel_cache_hits=%llu misses=%llu evictions=%llu\n", - (unsigned long long)s_tc_hits, (unsigned long long)s_tc_misses, - (unsigned long long)s_tc_evictions); - } if (threaded) { RtStats st; rt_get_stats(&st); @@ -514,38 +382,11 @@ int main(int argc, char **argv) { check(st.acquires >= 2 && st.acquires <= (uint64_t)frames + 4, "acquires bounded"); check(st.bytes > ((uint64_t)32 << 20), "ring wrapped (more than its size recorded)"); } - { - uint64_t times[256], tail[2]; - int count = gl_renderer_swap_timestamps(times, 256); - check(count > 2, "swap completion timestamps captured"); - for (int i=1; i= times[i-1], "swap clock is monotonic"); - check(gl_renderer_swap_timestamps(tail, 2) == 2 && - tail[0] == times[count-2] && tail[1] == times[count-1], - "bounded swap query returns newest entries"); - } - if (drawable_events_enabled()) - check(s_drawable_queries == drawable_queries_before, "no per-frame drawable queries"); - /* Pixel-size events publish resizes without querying the window on a - * worker or taking its property lock on every frame boundary. */ - for (int k = 0; k < 2; ++k) { - SDL_SetWindowSize(win, 400 + k * 80, 300 + k * 60); - SDL_PumpEvents(); - gl_renderer_render_thread_frame_boundary(); - int expected[2] = {0}, seen[2] = {0}; - SDL_GL_GetDrawableSize(win, &expected[0], &expected[1]); - SDL_Thread *worker = SDL_CreateThread(drawable_worker, "drawable-test", seen); - check(worker != NULL, "drawable reader thread created"); - if (worker) SDL_WaitThread(worker, NULL); - check(seen[0] == expected[0] && seen[1] == expected[1], "worker sees resized drawable"); - } - gl_renderer_render_thread_sync("test_done"); check(glGetError() == GL_NO_ERROR, "GL error"); printf("rb=%016llx\n", (unsigned long long)rb_digest); printf("digest=%016llx\n", (unsigned long long)digest); printf("hires=%016llx\n", (unsigned long long)hires); printf("wide=%016llx\n", (unsigned long long)wide); - printf("stencil=%016llx\n", (unsigned long long)stencil); printf("checks=%d failures=%d\n", checks, failures); gl_renderer_shutdown(); check(!gl_renderer_render_thread_active(), "stopped by shutdown"); diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index de2d711f..937664bf 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -81,6 +81,7 @@ * 3x as the nearest rescale of all their pixels, before any redraw. * Original source-owned scene; no retail payload. */ #include "gpu_gl_renderer.c" +#include "gl_postfx_projection_fixture.h" #include "gpu_hd_texture_stubs.inc" #include "mod_texture_banks.c" #include "gl_batch_policy_lle.c" @@ -102,6 +103,15 @@ int psx_netplay_active(void){return 0;} GrBackend gr_backend(void){return GR_BACKEND_OPENGL;} void gr_refresh_backend(void){} #endif +/* These renderer-only fixtures have no frontend telemetry/menu consumer. */ +void psx_video_telemetry_compose(int r,int s,int sw,int sh,int dw,int dh,int vw,int vh) { + (void)r;(void)s;(void)sw;(void)sh;(void)dw;(void)dh;(void)vw;(void)vh; +} +void psx_video_telemetry_present(int result) {(void)result;} +int psx_host_menu_overlay_image(const uint32_t **p,int *w,int *h) { + *p=NULL;*w=*h=0;return 0; +} + int gpu_display_is_depth24(void){return 0;} void gpu_get_display_info(GpuDisplayInfo *out){memset(out,0,sizeof(*out));out->width=320;out->height=240;} int psx_ws_prim_in_backdrop(void){return 0;} diff --git a/runtime/tests/test_hd_texture_pack.cpp b/runtime/tests/test_hd_texture_pack.cpp index 6d952041..79c867eb 100644 --- a/runtime/tests/test_hd_texture_pack.cpp +++ b/runtime/tests/test_hd_texture_pack.cpp @@ -1,6 +1,7 @@ #define STB_IMAGE_IMPLEMENTATION #include "../third_party/stb_image.h" #include "hd_texture_pack.h" +#include "texture_pack_preload.hpp" #include #include @@ -211,6 +212,24 @@ HdTextureDrawQuery query4(const std::vector& vram, return query; } +void test_indexed_palette_fallback() { + Fixture f; + std::ostringstream stem; stem<= 0 && r.loaded == 1 && r.bytes == 64u * 8u * 4u, + "preload pins the valid replacement"); + check(r.requested == r.loaded + r.failed + r.over_budget && + (r.requested == 1 ? status == 1 : (status == 0 && r.failed >= 1)), + "malformed replacements are reported, not counted as preloaded"); + hd_texture_pack_set_decode_budget(pack, 1); + HdTexturePreloadResult repeat{}; + check(hd_texture_pack_preload(pack,64u*8u*4u,1,&repeat) >= 0 && + repeat.loaded == 1 && repeat.bytes == 64u*8u*4u && + repeat.failed == repeat.requested - 1, + "repeat counts pinned images without decoding them under a reduced budget"); + check(hd_texture_pack_preload(pack,64u*8u*4u-1,1,&repeat) == -1 && + repeat.bytes == 64u*8u*4u, + "a smaller lifetime cap rejects and reports retained residency"); + check(hd_texture_pack_track_upload(pack, 10, 20, 8, 4, + fixture.upload_words.data(), + fixture.upload_words.size(), nullptr) == 1, + "preload fixture upload tracks"); + HdTexturePixels pixels{}; + check(hd_texture_pack_acquire_decoded(pack, fixture.upload_hash, fixture.palette4_hash, + &pixels) == HD_TEXTURE_LOOKUP_FOUND && + pixels.width == 64, + "a preloaded image is resident and survives budget eviction"); + hd_texture_pixels_release(&pixels); + hd_texture_pack_destroy(pack); + } + { + HdTexturePack* pack = open_pack(fixture.root); + if (!pack) return; + HdTexturePreloadResult r{}; + check(hd_texture_pack_preload(pack, 64, 4, &r) == 0 && r.loaded == 0 && + r.over_budget == 1 && r.bytes == 0, + "the aggregate cap skips an oversized image"); + hd_texture_pack_destroy(pack); + } + { + HdTexturePack* pack = open_pack(fixture.root); + if (!pack) return; + tp::fault_mode() = 1; + HdTexturePreloadResult r{}; + const int status = hd_texture_pack_preload(pack, size_t{64} << 20, 4, &r); + tp::fault_mode() = 0; + check(status == 0 && r.worker_errors == 1 && r.loaded + 1 + r.over_budget <= r.requested, + "an injected worker failure is caught and reported as partial"); + hd_texture_pack_destroy(pack); + } + { + HdTexturePack* pack = open_pack(fixture.root); + if (!pack) return; + tp::fault_mode() = 3; + HdTexturePreloadResult r{}; + const int status = hd_texture_pack_preload(pack, size_t{64} << 20, 4, &r); + tp::fault_mode() = 0; + check(status == -1 && r.loaded == 1 && r.bytes == 64u * 8u * 4u, + "publication failure still accounts the retained pinned image"); + check(hd_texture_pack_preload(pack,64u*8u*4u,1,&r) >= 0 && + r.loaded == 1 && r.bytes == 64u*8u*4u && r.failed == r.requested-1, + "publication retry accounts existing pinned residency"); + hd_texture_pack_destroy(pack); + } +} + } // namespace int main() { test_crc_exact_little_endian(); + test_indexed_palette_fallback(); Fixture fixture; test_scan_mapping(fixture); test_palette_match_and_invalidation(fixture); @@ -523,6 +613,7 @@ int main() { test_wrapping(fixture); test_ambiguity_fallback(); test_bounded_async_decode(fixture); + test_bounded_preload(fixture); if (failures) { std::fprintf(stderr, "test_hd_texture_pack: %d failure(s)\n", failures); diff --git a/runtime/tests/test_input_replay.c b/runtime/tests/test_input_replay.c deleted file mode 100644 index e55a1e74..00000000 --- a/runtime/tests/test_input_replay.c +++ /dev/null @@ -1,35 +0,0 @@ -#include "input_replay.h" -#include -#include -#define CHECK(x) do { if (!(x)) { puts(#x); return 1; } } while (0) -int main(int argc, char **argv) { - CHECK(argc == 2); - FILE *f = fopen(argv[1], "w"); CHECK(f); - fputs("# neutral then steer\n2 ffff\n3 bfdf\n1 bfff\n", f); fclose(f); - CHECK(input_replay_load(argv[1])); - CHECK(input_replay_buttons(0) == -1); - input_replay_on_load(100); - CHECK(input_replay_buttons(99) == -1); - CHECK(input_replay_buttons(100) == 65535); - CHECK(input_replay_buttons(101) == 65535); - CHECK(input_replay_buttons(102) == 0xbfdf); - CHECK(input_replay_buttons(102) == 0xbfdf); - CHECK(input_replay_buttons(104) == 0xbfdf); - CHECK(input_replay_buttons(105) == 0xbfff); - CHECK(input_replay_buttons(106) == 65535); - CHECK(input_replay_buttons(102) == 0xbfdf); - input_replay_on_load(200); - CHECK(input_replay_frame(200) == 0); - CHECK(input_replay_buttons(202) == 0xbfdf); - f = fopen(argv[1], "w"); CHECK(f); - fputs("0 bfff\n", f); fclose(f); - CHECK(!input_replay_load(argv[1])); - const char *bad[] = {"-1 bfff\n", "4294967296 bfff\n", "1 10000\n", "1 bfff junk\n", "1 -1\n"}; - for (unsigned i=0; i ram; +extern "C" int (*g_psx_func_override_hook)(CPUState*, uint32_t); static int failures; static int activation_calls; +static int override_activation_calls; +static void override_activate() { ++override_activation_calls; } +static int override_scope_checks; +static int override_body(CPUState*) { + char option[16]{}; + if (psx_mod_current_option_value("scope",option,sizeof option) && + !std::strcmp(option,"owned") && psx_mod_function_entry_active()) ++override_scope_checks; + mod_runtime_on_vblank(); + if (psx_mod_current_option_value("scope",option,sizeof option) && + !std::strcmp(option,"owned") && psx_mod_function_entry_active()) ++override_scope_checks; + return 1; +} static int plugin_calls; static int restore_calls; static int option_renderer_activations; @@ -153,6 +167,23 @@ extern "C" void gpu_get_display_info(GpuDisplayInfo* out) { *out = g_test_display; } +/* func_override.c doubles: linked in so mod_runtime's package arming/reset + * paths run for real. The tier's own behavior is pinned by + * test_func_override.c; here the doubles only need to satisfy the link. */ +extern "C" uint32_t psx_peek_word_untraced(uint32_t) { return 0; } +extern "C" void psx_dispatch_call(struct CPUState*, uint32_t, uint32_t) {} +extern "C" { +uint64_t psx_cycle_count = 0; +uint64_t psx_next_service_cycle = 0; +int psx_in_device_service = 0; +int g_event_step_conservative = 0; +int g_ls_replay_active = 0; +uint32_t g_psx_cyc_batch = 0; +uint32_t g_psx_cyc_batch_limit = 0; +void psx_devices_service_to_now(void) {} +void psx_advance_cycles_slow(uint32_t cycles) { psx_cycle_count += cycles; } +} + extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {} static int test_game_started = 1; extern "C" int fntrace_is_game_started(void) { return test_game_started; } @@ -523,6 +554,7 @@ int main(int argc, char** argv) { const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-test"; #endif std::error_code ec; + fs::remove_all(root, ec); const std::vector stock(8 * 2352, 0); std::vector overlay(3000); @@ -2422,6 +2454,68 @@ int main(int argc, char** argv) { "resolver preserves missing selection for existing downstream validation"); } + { + const auto base = root/"function-overrides"; + write_text(base/"bundled/override.test/1.0.0/manifest.toml", R"toml( +format_version = 5 +id = "override.test" +version = "1.0.0" +name = "Overrides" +resolver = "declarative" +save_compatibility = "shared" +[[target]] +game_id = "SLUS-OVERRIDE" +[[feature]] +id = "replace" +name = "Replace" +default_enabled = true +[[option]] +feature = "replace" +id = "scope" +label = "Scope" +type = "choice" +default = "owned" +[[option.choice]] +value = "owned" +label = "Owned" +[[option.choice]] +value = "other" +label = "Other" +[[plugin]] +feature = "replace" +id = "override.test" +)toml"); + check(PSXRecompV4::mod_register_activation_plugin("override.test",override_activate),"register override activation"); + check(psx_mod_register_function_override("override.test:first",0x80006004,override_body,nullptr,0,0),"queue first override"); + check(psx_mod_register_function_override("override.test:second",0x80006000,override_body,nullptr,0,0),"queue second override"); + check(!psx_mod_register_function_override("override.test:bad",0x80006001,override_body,nullptr,0,0),"reject misaligned override"); + check(PSXRecompV4::mod_runtime_initialize(base,"SLUS-OVERRIDE",0x80002000,{},&error),error.c_str()); + check(PSXRecompV4::mod_runtime_commit({},&error),error.c_str()); + mod_runtime_activate_plugins(); + check(!mod_runtime_activation_error() && func_override_count()==2 && override_activation_calls==1,"selected overrides arm together"); + CPUState override_cpu{}; + check(g_psx_func_override_hook && g_psx_func_override_hook(&override_cpu,0x6004),"invoke package override"); + check(override_scope_checks==2 && !psx_mod_function_entry_active(), + "override owns its resource context across VBlank and blocks mid-stack snapshots"); + char no_scope[16]{}; + check(!psx_mod_current_option_value("scope",no_scope,sizeof no_scope),"override restores its caller's context"); + mod_runtime_activate_plugins(); + check(!mod_runtime_activation_error() && func_override_count()==2 && override_activation_calls==2,"session reactivation replaces entries without duplicates"); + check(PSXRecompV4::mod_runtime_clear_for_netplay(&error),error.c_str()); + check(func_override_count()==0,"cleared plan disarms all package entries"); + override_activation_calls=0; + check(func_override_add("direct",0x80006000,override_body,0)==FO_OK,"register colliding direct override"); + check(PSXRecompV4::mod_runtime_initialize(base,"SLUS-OVERRIDE",0x80002000,{},&error),error.c_str()); + check(PSXRecompV4::mod_runtime_commit({},&error),error.c_str()); + mod_runtime_activate_plugins(); + check(mod_runtime_activation_error()!=nullptr && override_activation_calls==0, + "late collision rejects launch before callbacks"); + check(func_override_count()==1,"collision rolls back all package entries and preserves direct entry"); + check(PSXRecompV4::mod_runtime_clear_for_netplay(&error),error.c_str()); + mod_runtime_activate_plugins(); + check(!mod_runtime_activation_error() && func_override_count()==1,"vanilla session clears failure and retains direct tier"); + } + fs::remove_all(root, ec); if (failures) return 1; std::cout << "mod runtime tests passed\n"; diff --git a/runtime/tests/test_pgxp.cpp b/runtime/tests/test_pgxp.cpp index c22d82e7..b2e40931 100644 --- a/runtime/tests/test_pgxp.cpp +++ b/runtime/tests/test_pgxp.cpp @@ -1066,19 +1066,6 @@ int main(void) { CHECK(st.value_mismatch > 0); } -#ifdef PSX_NO_DEBUG_TOOLS - /* Diagnostic-only receipts must stay empty while the numeric tests above - * exercise real stores, projections and word-shadow misses. */ - { - const PGXPStoreRecord *stores; - const PGXPTriRecord *triangles; - const PGXPWordMiss *misses; - uint32_t capacity; - CHECK(pgxp_store_ring(&stores, &capacity) == 0); - CHECK(pgxp_tri_ring(&triangles, &capacity) == 0); - CHECK(pgxp_word_miss_ring(&misses, &capacity) == 0); - } -#endif if (g_failures) { std::fprintf(stderr, "test_pgxp: %d FAILURES\n", g_failures); return 1; diff --git a/runtime/tests/test_postfx_publication.cpp b/runtime/tests/test_postfx_publication.cpp new file mode 100644 index 00000000..5c55b20c --- /dev/null +++ b/runtime/tests/test_postfx_publication.cpp @@ -0,0 +1,40 @@ +#include "gte_projection_publication.hpp" +#include "postfx_debug_json.h" +#include +#include +#include +#include +#include +static int failures; +static void check(bool ok,const char* why) { + if(!ok){std::fprintf(stderr,"%s\n",why);++failures;} +} +int main() { + GteProjectionPublication state; GtePostfxProjection out{}; + state.publish(1,2,3); check(!state.read(out),"disabled projection is unavailable"); + state.enable(true); state.publish(1,2,3); + check(state.read(out)&&out.h==1&&out.ofx==2&&out.ofy==3,"first enabled tuple"); + state.enable(false);state.enable(true); + check(!state.read(out),"re-enabled session cannot reuse a stale tuple"); + state.publish(1,2,3);check(state.read(out),"unchanged values publish in the new epoch"); + std::atomic done{false},bad{false}; + std::thread writer([&]{for(int i=1;i<200000;++i)state.publish(i,i*2,i*3);done=true;}); + auto read=[&]{while(!done.load()){GtePostfxProjection p{}; + if(state.read(p)&&(p.ofx!=p.h*2||p.ofy!=p.h*3))bad=true;}}; + std::thread a(read),b(read);writer.join();a.join();b.join(); + check(!bad,"concurrent readers never see mixed projection fields"); + PfxDebugLive live{};std::strcpy(live.name,"u_\"x\n");live.v[0]=1.25f; + char json[512];check(pfx_debug_json(json,sizeof json,"C:\\a\"b\n","bad\tpreset",&live,1)>0,"serialize escaped diagnostics"); + check(std::strstr(json,R"("path":"C:\\a\"b\u000a")")!=nullptr,"path round trips without mutation"); + check(std::strstr(json,R"("u_\"x\u000a":[1.25,0,0,0])")!=nullptr,"uniform names are escaped"); + std::printf("{%s}\n",json); + for(int cap=0;cap<160;++cap) { + char guarded[192];std::memset(guarded,'X',sizeof guarded); + const int n=pfx_debug_json(guarded+1,cap,"C:\\a\"b\n","bad\tpreset",&live,1); + check(guarded[0]=='X'&&guarded[cap+1]=='X',"small output buffer stays within bounds"); + if(!n&&cap)check(guarded[1]==0,"failed JSON output is empty"); + } + live.v[0]=std::numeric_limits::infinity(); + check(!pfx_debug_json(json,sizeof json,"","",&live,1)&&!json[0],"nonfinite values fail without invalid JSON"); + return failures?1:0; +} diff --git a/runtime/tests/test_psx_cycle_event_boundaries.c b/runtime/tests/test_psx_cycle_event_boundaries.c index 324d08a0..83b6e885 100644 --- a/runtime/tests/test_psx_cycle_event_boundaries.c +++ b/runtime/tests/test_psx_cycle_event_boundaries.c @@ -13,9 +13,6 @@ int g_ls_replay_active = 0; int g_ls_mode = 0; int g_precise_mode = 0; int g_psx_call_bail = 0; -/* These standalone fixtures have no host-output sandbox or restored CPU. */ -int g_boot_state_cpu_timing_restored = 0; -int psx_mod_host_output_scope(void) { return 0; } uint32_t i_mask = 0; uint64_t g_guest_store_count = 0; uint64_t g_mmio_access_count = 0; diff --git a/runtime/tests/test_psx_guest_cycle_scale_gate.c b/runtime/tests/test_psx_guest_cycle_scale_gate.c index 3d56c9c9..44a57526 100644 --- a/runtime/tests/test_psx_guest_cycle_scale_gate.c +++ b/runtime/tests/test_psx_guest_cycle_scale_gate.c @@ -34,9 +34,6 @@ int g_ls_replay_active = 0; int g_ls_mode = 0; int g_precise_mode = 0; int g_psx_call_bail = 0; -/* These standalone fixtures have no host-output sandbox or restored CPU. */ -int g_boot_state_cpu_timing_restored = 0; -int psx_mod_host_output_scope(void) { return 0; } uint32_t i_mask = 0; uint64_t g_guest_store_count = 0; uint64_t g_mmio_access_count = 0; diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index 4a86f504..58eb58aa 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -620,16 +620,6 @@ int main(void) { render_pass_get_stats(&st); CHECK(st.device_reads==i+1 && st.aborted==i+1 && st.watchdog==0, "device read refusal is distinct from a watchdog"); - CHECK(st.timed_passes==i+1 && st.max_pass_ms>0, - "timing resets per session and includes aborted passes"); - { - double parts = st.total_gl_begin_ms + st.total_checkpoint_ms + - st.total_verify_before_ms + st.total_guest_ms + st.total_gl_end_ms + - st.total_restore_ms + st.total_verify_after_ms; - CHECK(parts >= st.total_pass_ms - 0.000001 && - parts <= st.total_pass_ms + 0.000001, - "phase wall times account for the entire transaction"); - } CHECK(st.last_device_read==reads[i][0] && st.last_device_read_width==reads[i][1], "device read diagnostic identifies the access"); CHECK(st.last_failure.reason && strcmp(st.last_failure.reason,"device_read")==0, diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c index ca927443..f6d6ed44 100644 --- a/runtime/tests/test_render_pass_sandbox.c +++ b/runtime/tests/test_render_pass_sandbox.c @@ -999,6 +999,34 @@ static int local_temporal_draw(CPUState *cpu, void *user, uint32_t alpha) { return local_draw(cpu, user, 0); } +/* Shared-state digest deliberately excludes the local presentation image. */ +static uint64_t shared_digest(const CPUState *cpu) { + uint64_t h = 0xcbf29ce484222325ull; + const uint8_t *parts[] = { (const uint8_t *)cpu->gpr, s_ram, s_spad }; + const size_t sizes[] = { sizeof cpu->gpr, s_ram_live, sizeof s_spad }; + for (size_t p = 0; p < sizeof parts / sizeof parts[0]; ++p) + for (size_t i = 0; i < sizes[p]; ++i) { + h ^= parts[p][i]; + h *= 0x100000001b3ull; + } + h ^= psx_cycle_count; + h *= 0x100000001b3ull; + h ^= ((uint64_t)i_mask << 32) | i_stat; + return h * 0x100000001b3ull; +} + +static int local_temporal_discard(CPUState *cpu, void *user, uint32_t alpha) { + (void)local_temporal_draw(cpu, user, alpha); + return 0; +} + +static int local_temporal_abort(CPUState *cpu, void *user, uint32_t alpha) { + (void)local_temporal_draw(cpu, user, alpha); + (void)render_pass_mmio_read_allowed(0x1F801800u, 1); + CHECK(0, "device-read abort must not return to guest code"); + return 0; +} + /* Netplay local view: forward netplay frames only; guest state restored * exactly; a committed image cancels the crop request. */ static void test_local_view(void) { @@ -1006,6 +1034,7 @@ static void test_local_view(void) { PSXModRenderPass r; RenderPassStats st; uint64_t cycle; + uint64_t digest; render_pass_reset_session(); s_gl_status = 0; s_begin_ok = 1; memset(&r, 0, sizeof r); r.struct_size = sizeof r; @@ -1049,8 +1078,17 @@ static void test_local_view(void) { "guest state restored after the local view"); psx_mod_set_render_pass_netplay(1); r.alpha_q16 = 32768u; + digest = shared_digest(&cpu); CHECK(psx_mod_render_pass(&cpu, &r, local_temporal_draw, NULL) == 1, "opted-in temporal pass uses own-view scope"); + CHECK(shared_digest(&cpu) == digest, + "actual opted-in temporal pass preserves shared digest"); + CHECK(!psx_mod_render_pass(&cpu, &r, local_temporal_discard, NULL) && + shared_digest(&cpu) == digest, + "discarded temporal pass preserves shared digest"); + CHECK(!psx_mod_render_pass(&cpu, &r, local_temporal_abort, NULL) && + shared_digest(&cpu) == digest && s_open_passes == 0, + "aborted temporal pass restores shared state and closes transaction"); CHECK(s_ws_scope == 0 && psx_mod_local_view_scope() == 0 && cpu.gpr[8] == 7 && s_ram[0x456] == 0x77 && psx_cycle_count == cycle, "temporal local pass restores scope, RAM, registers and clock"); @@ -1058,6 +1096,10 @@ static void test_local_view(void) { CHECK(!psx_mod_render_pass(&cpu, &r, never_fn, NULL), "temporal local rendering never runs during rollback replay"); s_resim = 0; + psx_mod_set_render_pass_netplay(0); + CHECK(!psx_mod_render_pass(&cpu, &r, never_fn, NULL) && + shared_digest(&cpu) == digest, + "nonparticipating peer refuses temporal pass without shared-state change"); r.x = 900; CHECK(!psx_mod_render_local_view(&cpu, &r, never_fn, NULL), "rect outside VRAM refused"); diff --git a/runtime/tests/test_syscall_closed_thread_context.c b/runtime/tests/test_syscall_closed_thread_context.c new file mode 100644 index 00000000..c6caa2ec --- /dev/null +++ b/runtime/tests/test_syscall_closed_thread_context.c @@ -0,0 +1,52 @@ +/* SYS03 must preserve register RAM even after CloseTh marks the outgoing TCB + * free. Generic register patterns only: no game addresses. */ +#include +#include +#include +#include +#include "cpu_state.h" +#include "psx_scheduler.h" + +static uint32_t ram[0x10000 / 4]; +static CPUState cpu; +uint64_t s_frame_count = 0; + +static uint32_t rd(uint32_t a) { assert(a < sizeof ram && !(a & 3)); return ram[a / 4]; } +static void wr(uint32_t a, uint32_t v) { assert(a < sizeof ram && !(a & 3)); ram[a / 4] = v; } +int psx_get_in_exception(void) { return 0; } +int overlay_loader_shadow_native_thread_switch_bail(void) { return 0; } +int parity_trace_is_armed(void) { return 0; } +void debug_server_log_thread_event(uint32_t k, CPUState* c, uint32_t a, uint32_t b, uint32_t p) +{ (void)k; (void)c; (void)a; (void)b; (void)p; } +int psx_syscall(CPUState*, uint32_t); + +int main(void) +{ + const uint32_t pcb = 0xe000, old = 0xe1f4, target = old + 0xc0; + ram[0x108 / 4] = pcb; + ram[0x110 / 4] = old; + ram[0x114 / 4] = 4 * 0xc0; + ram[pcb / 4] = old; + ram[old / 4] = 0x1000; /* Closed, but still the current thread until SYS03. */ + ram[target / 4] = 0x4000; + cpu.read_word = rd; + cpu.write_word = wr; + for (unsigned i = 1; i < 32; i++) cpu.gpr[i] = 0x16160000u + i; + cpu.gpr[4] = 3; + cpu.gpr[5] = target; + cpu.gpr[31] = 0x2104; + cpu.cop0[12] = 0x401; + cpu.hi = 0xaaaa; + cpu.lo = 0xbbbb; + if (setjmp(g_scheduler_jmpbuf) == 0) { + psx_syscall(&cpu, 0); + assert(!"must yield to scheduler"); + } + assert(ram[old / 4] == 0x1000); /* Saving never reopens the retired thread. */ + assert(ram[pcb / 4] == target); + assert(ram[(old + 8 + 22 * 4) / 4] == 0x16160016u); + assert(ram[(old + 8 + 128) / 4] == 0x2104); + assert(ram[(old + 8 + 132) / 4] == 0xaaaa && ram[(old + 8 + 136) / 4] == 0xbbbb); + assert(g_sched_escape.target_tcb == target); + return 0; +} diff --git a/runtime/tests/test_syscall_closed_thread_context.py b/runtime/tests/test_syscall_closed_thread_context.py new file mode 100644 index 00000000..dcc57a06 --- /dev/null +++ b/runtime/tests/test_syscall_closed_thread_context.py @@ -0,0 +1,21 @@ +"""CloseTh(current); ChangeTh(other) must still save the outgoing registers. + +Links the production traps.c into test_syscall_closed_thread_context.c at -O0 +and -O2; every other runtime seam is an abort() stub (fixture_link.py). +""" +import argparse +import tempfile +from pathlib import Path + +from fixture_link import build_and_run + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--cc', default='gcc') + args = parser.parse_args() + here = Path(__file__).resolve().parent + with tempfile.TemporaryDirectory() as root: + for opt in ('-O0', '-O2'): + build_and_run(args.cc, here, here.parent, opt, Path(root), + ['traps.c'], 'test_syscall_closed_thread_context.c') + print('PASS: closed outgoing thread keeps its context without reopening (O0/O2)') diff --git a/runtime/tests/test_video_telemetry.cpp b/runtime/tests/test_video_telemetry.cpp index f89c5013..0abfb90a 100644 --- a/runtime/tests/test_video_telemetry.cpp +++ b/runtime/tests/test_video_telemetry.cpp @@ -52,6 +52,42 @@ int main() { CHECK(sample.valid && sample.internal_scale == 10000 && sample.presents == 10002); CHECK(sample.present_status_available); CHECK(sample.present_errors == 1); + PsxPresentCadence cadence{}; + psx_video_telemetry_cadence(0,1,&cadence); + psx_video_telemetry_present_at(1,1000); + psx_video_telemetry_present_at(1,11000); + psx_video_telemetry_present_at(-1,21000); + psx_video_telemetry_present_at(1,51000); + psx_video_telemetry_cadence(0,0,&cadence); + CHECK(cadence.valid && cadence.count==3 && cadence.mean_us==16666); + CHECK(cadence.p50_us==10000 && cadence.max_us==30000 && cadence.hitches==1); + psx_video_telemetry_cadence(35,0,&cadence); + CHECK(cadence.valid && cadence.count==1 && cadence.max_us==30000); + psx_video_telemetry_cadence(0,1,&cadence); + psx_video_telemetry_cadence(0,0,&cadence); + CHECK(cadence.valid && !cadence.count); + psx_video_telemetry_present_at(1,100000); + psx_video_telemetry_cadence(0,0,&cadence); + CHECK(cadence.valid && !cadence.count); // producer reset re-anchors timing + std::atomic cadence_done{false}; + std::atomic cadence_consistent{true}; + std::thread presenter([&] { + for(uint64_t i=1;i<=20000;++i) psx_video_telemetry_present_at(1,100000+i*10000); + cadence_done=true; + }); + auto reader=[&](bool resets) { + unsigned reads=0; + do { + PsxPresentCadence c{}; + psx_video_telemetry_cadence(0,resets && (++reads%17==0),&c); + if(c.valid && c.count && (c.mean_us!=10000 || c.max_us!=10000 || c.hitches!=0)) + cadence_consistent=false; + }while(!cadence_done.load()); + }; + std::thread other_reader([&]{reader(true);}); + reader(false); + presenter.join();other_reader.join(); + CHECK(cadence_consistent.load()); psx_video_telemetry_snapshot(nullptr); return 0; } diff --git a/runtime/tests/test_ws_cull_edge_interp.c b/runtime/tests/test_ws_cull_edge_interp.c index 3b946d3b..9222fc63 100644 --- a/runtime/tests/test_ws_cull_edge_interp.c +++ b/runtime/tests/test_ws_cull_edge_interp.c @@ -41,6 +41,9 @@ uint32_t g_debug_last_store_pc; uint32_t g_dirty_ram_code_gen; uint64_t g_dispatch_static_hits; uint32_t g_psx_mod_instruction_hooks; +/* The interpreter consults the optional override tier even in this isolated + * culling fixture. No overrides are registered here. */ +int (*g_psx_func_override_hook)(CPUState *, uint32_t); uint32_t psx_mod_memory_address(const CPUState *cpu, uint32_t pc, uint32_t word, uint32_t address) { (void)cpu; (void)pc; (void)word; return address; } @@ -531,6 +534,96 @@ static void test_screen_x_precedence(void) { gpu_ws_set_signed_x_bound_sites(NULL, NULL, 0); } +/* [[widescreen.cull.sxy]] and the screen_x `slti rt, x, 0` form through + * real interpreted instructions. The fold is Wild 9's triangle reject (with + * its interleaved mfc2/sw/sub left in RAM); the interpreter must produce the + * same word as the helper chain the recompiler emits, and treat a variant + * with any other fold or consumer word as vanilla code. */ +static uint32_t sxy(int x, int y) { + return ((uint32_t)(uint16_t)(int16_t)y << 16) | (uint16_t)(int16_t)x; +} +static void test_sxy_fold(void) { + CPUState cpu; + memset(&cpu, 0, sizeof cpu); + memset(test_ram, 0, sizeof test_ram); + enum { AT = 1, T0 = 8, T3 = 11, S1 = 17, S4 = 20, S6 = 22 }; + const uint32_t base = CODE + 0x300; + const uint32_t words[] = { 0x01110824u, 0x480B7000u, 0x02810824u, 0xAC880008u, + 0x01610824u, 0x02084022u, 0x14200001u }; + for (uint32_t i = 0; i < 7; i++) put_word(base + 4u * i, words[i]); + const uint32_t vertices[] = { 3 }; + const uint32_t fold_a[] = { base, base + 8u, base + 0x10u, 0 }; + const uint32_t fold_e[] = { words[0], words[2], words[4], 0 }; + const uint32_t regs[] = { T0, S4, T3, 0 }; + const uint32_t cons_a[] = { base + 0x18u }, cons_e[] = { words[6] }; + gpu_ws_set_sxy_cull_sites(vertices, fold_a, fold_e, regs, cons_a, cons_e, 1); + const uint32_t screen_x[] = { base + 0x40u }; + gpu_ws_set_cull_screen_x_sites(screen_x, 1); + const uint32_t slti_at_s6 = i_type(0x0A, S6, AT, 0); + + for (int pass = 0; pass < 2; pass++) { + const int m = pass ? 53 : 0; + gpu_ws_set_margin_override(m); + const uint32_t cases[][3] = { + { sxy(-60, 10), sxy(-53, 20), sxy(-100, 30) }, /* one in the band */ + { sxy(-60, 10), sxy(-54, 20), sxy(-100, 30) }, /* all left of -m */ + { sxy(-10, -1), sxy(100, -5), sxy(5, -9) }, /* all above */ + { sxy(10, 10), sxy(400, 20), sxy(-5, 30) }, /* on screen */ + }; + for (int c = 0; c < 4; c++) { + cpu.gpr[S1] = 0x80008000u; + cpu.gpr[T0] = cases[c][0]; + cpu.gpr[S4] = cases[c][1]; + cpu.gpr[T3] = cases[c][2]; + (void)step(&cpu, base, words[0]); + (void)step(&cpu, base + 8u, words[2]); + (void)step(&cpu, base + 0x10u, words[4]); + const uint32_t native = psx_ws_cull_sxy_and(psx_ws_cull_sxy_and( + psx_ws_cull_sxy_and(0x80008000u, cases[c][0]), cases[c][1]), + cases[c][2]); + const uint32_t vanilla = 0x80008000u & cases[c][0] & cases[c][1] & cases[c][2]; + CHECK(cpu.gpr[AT] == native, "interp sxy fold == emitted helper chain, case %d m=%d", c, m); + if (m == 0) + CHECK(cpu.gpr[AT] == vanilla, "sxy fold vanilla at 4:3, case %d", c); + } + CHECK(m == 0 || psx_ws_cull_sxy_and(psx_ws_cull_sxy_and(psx_ws_cull_sxy_and( + 0x80008000u, sxy(-60, 10)), sxy(-53, 20)), sxy(-100, 30)) == 0u, + "the band vertex keeps the triangle while widened"); + + /* A variant whose consumer differs is vanilla code. */ + put_word(base + 0x18u, 0x10200001u); + cpu.gpr[S1] = 0x80008000u; + cpu.gpr[T0] = sxy(-60, 10); + cpu.gpr[S4] = sxy(-53, 20); + cpu.gpr[T3] = sxy(-100, 30); + (void)step(&cpu, base, words[0]); + (void)step(&cpu, base + 8u, words[2]); + (void)step(&cpu, base + 0x10u, words[4]); + CHECK(cpu.gpr[AT] == 0x8000u, "sxy variant with another consumer stays vanilla, m=%d", m); + put_word(base + 0x18u, words[6]); + + /* An unlisted AND is vanilla. */ + cpu.gpr[S1] = 0x80008000u; + (void)step(&cpu, base + 0x100u, words[0]); + CHECK(cpu.gpr[AT] == (0x80008000u & cpu.gpr[T0]), "unlisted AND vanilla, m=%d", m); + + /* screen_x slti rt,x,0 with x = SX << 16. */ + cpu.gpr[S6] = (uint32_t)-5 << 16; + (void)step(&cpu, base + 0x40u, slti_at_s6); + CHECK(cpu.gpr[AT] == (m ? 0u : 1u), "screen_x slti keeps the band, m=%d", m); + cpu.gpr[S6] = (uint32_t)-54 << 16; + (void)step(&cpu, base + 0x40u, slti_at_s6); + CHECK(cpu.gpr[AT] == 1u, "screen_x slti rejects x < -m, m=%d", m); + cpu.gpr[S6] = (uint32_t)-5 << 16; + (void)step(&cpu, base + 0x140u, slti_at_s6); + CHECK(cpu.gpr[AT] == 1u, "unlisted slti vanilla, m=%d", m); + } + gpu_ws_set_margin_override(-1); + gpu_ws_set_sxy_cull_sites(NULL, NULL, NULL, NULL, NULL, NULL, 0); + gpu_ws_set_cull_screen_x_sites(NULL, 0); + CHECK(g_psx_ws_sxy_cull_n == 0, "sxy sites cleared"); +} + static void test_masked_reject(void) { CPUState cpu = {0}; memset(test_ram, 0, sizeof test_ram); @@ -601,6 +694,7 @@ int main(int argc, char **argv) { test_interpreter(); test_screen_x_precedence(); test_masked_reject(); + test_sxy_fold(); test_wide_nclip_branches(); if (failures) { fprintf(stderr, "%d check(s) failed\n", failures);