diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index dfd97de4..4203f747 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -545,6 +545,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 @@ -1173,3 +1189,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/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/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 f7d5bd9e..cebddf2a 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 69fd7ec3..91bfc519 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -167,6 +167,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; }); @@ -314,6 +335,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 @@ -2374,6 +2499,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; @@ -2474,6 +2600,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")) { @@ -2916,6 +3053,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 2d1afd68..04fada2a 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 @@ -1282,6 +1297,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 71f0482b..18c18113 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -23,6 +23,9 @@ 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) @@ -1289,6 +1292,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) @@ -1425,6 +1438,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 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/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 38c054fd..41dabd52 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -348,6 +348,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 @@ -401,6 +412,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 826f07a8..4d66891a 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -200,6 +200,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; 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/mod_packages.h b/runtime/include/mod_packages.h index e7812b5d..f310d3bd 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -637,6 +637,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 cf916c1c..d99c26b3 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 892b5ad6..0f914a13 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -503,6 +503,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 363d1a10..790d7f65 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8780,6 +8780,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); @@ -9449,6 +9462,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); @@ -11227,6 +11259,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); @@ -16326,6 +16413,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 }, @@ -16350,6 +16439,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 }, @@ -16358,6 +16448,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/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/gpu.c b/runtime/src/gpu.c index ede613de..ddcaf275 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -1108,6 +1108,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 1fbe2d08..0021ebc0 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -198,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 *); @@ -271,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; @@ -352,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"); @@ -2011,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 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; @@ -3509,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 @@ -3520,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) @@ -3567,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); 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/main.cpp b/runtime/src/main.cpp index d9a8c6ad..f5ab5a95 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -136,6 +136,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" @@ -9306,6 +9307,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) { @@ -18442,6 +18452,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) { @@ -20536,6 +20566,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; 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 438bd307..3fe9dd52 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 ebe0ef2b..d7029ae2 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 "host_sha256.h" #include "iso_reader.h" #include "mod_packages.h" @@ -369,6 +370,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); } @@ -1734,6 +1756,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; @@ -3043,6 +3079,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); @@ -3050,6 +3097,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(); @@ -3060,6 +3110,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) { @@ -3555,6 +3627,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; @@ -3757,7 +3868,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/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/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/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_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 2f6a9e3c..3f7a7a2f 100644 --- a/runtime/tests/test_gl_hd_texture_pack.c +++ b/runtime/tests/test_gl_hd_texture_pack.c @@ -359,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"); @@ -424,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); @@ -445,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; @@ -454,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"}; @@ -536,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. */ @@ -602,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"); @@ -862,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 0d095002..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" diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index e89d304c..4c53b8e9 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_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 8e6be8ec..7f954eb3 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1,4 +1,5 @@ #include "mod_runtime.h" +#include "func_override.h" #include "mod_netplay.h" #include "host_launch_timing.h" #include "host_overlay.h" @@ -30,8 +31,21 @@ namespace fs = std::filesystem; static std::array 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; } @@ -524,6 +555,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); @@ -2423,6 +2455,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_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_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);