From 4cbedf5579b1e5f180cdb2b5464ffc72c27bdf1c Mon Sep 17 00:00:00 2001 From: Henrique Guedes Date: Sat, 24 Oct 2026 09:18:57 -0700 Subject: [PATCH 1/4] Add function overrides with guarded registration and cycle credit on current runtime --- docs/MOD_PACKAGES.md | 16 + recompiler/src/full_function_emitter.cpp | 18 + runtime/CMakeLists.txt | 11 + runtime/include/func_override.h | 230 ++++++++++ runtime/include/mod_packages.h | 1 + runtime/include/mod_plugins.h | 31 ++ runtime/runtime.cmake | 1 + runtime/src/debug_server.c | 56 +++ runtime/src/dirty_ram_interp.c | 60 ++- runtime/src/func_override.c | 278 ++++++++++++ runtime/src/main.cpp | 2 + runtime/src/memory.c | 10 + runtime/src/mod_packages.cpp | 14 +- runtime/src/mod_runtime.cpp | 95 +++++ runtime/tests/test_func_override.c | 515 +++++++++++++++++++++++ runtime/tests/test_mod_runtime.cpp | 17 + 16 files changed, 1353 insertions(+), 2 deletions(-) create mode 100644 runtime/include/func_override.h create mode 100644 runtime/src/func_override.c create mode 100644 runtime/tests/test_func_override.c diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 8a3de9b8..6a75927f 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -538,6 +538,22 @@ frames. Trusted callbacks receive only the narrow C services exposed by `runtime/include/mod_plugins.h`. Games should continue to use declarative patches and overlays when those operations are sufficient. +An implementation may also register **function overrides** under a plugin id +(`psx_mod_register_function_override`): hand-written C that replaces or wraps +a guest function at a given address, with an optional prologue-word residency +guard. Registration only queues the override; it is ARMED into the dispatcher +tier when the resolved plan selects that plugin — the same gating as the other +callback kinds, so an override-only plugin id counts as available to the +resolver. Every registration states a required guest-cycle `credit` (a fixed +per-handled-call charge, `0` for a mod with no hardware analog, or +`FO_CREDIT_SELF` when the body — or a wrapped original — accounts for its own +time). The full execution contract (guest ABI, decline semantics, +`func_override_call_original` wrap primitive, `func_override_guest_call`, +determinism and cycle-accounting policy) is documented in +`runtime/include/func_override.h`. Overrides registered directly through +`func_override_add` (game `EXTRAS_SOURCES` constructors, the progressive- +decompilation idiom) bypass package gating and are always active; packages +are the right home for anything a player should be able to toggle. ### Session starts and mod-owned state What a plugin sets up should last only for a session whose resolved plan 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/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 94028664..72edd072 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1282,6 +1282,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) @@ -1411,6 +1421,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/func_override.h b/runtime/include/func_override.h new file mode 100644 index 00000000..3e736259 --- /dev/null +++ b/runtime/include/func_override.h @@ -0,0 +1,230 @@ +/* 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); + +/* 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/mod_packages.h b/runtime/include/mod_packages.h index 9f489169..2501ffac 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -627,6 +627,7 @@ bool mod_register_function_filter_plugin(const std::string& id, uint32_t address bool mod_register_guest_function_plugin(const std::string& id, uint32_t address, PSXModFunctionEntryCallback callback); bool mod_plugin_registered(const std::string& id); +bool mod_register_function_override_marker(const std::string& id); void mod_invoke_activation_plugin(const std::string& id); void mod_invoke_vblank_plugin(const std::string& id); struct ModFunctionEntryHook { diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index d77acc18..bd2cb9e9 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,35 @@ 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). + * 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/runtime.cmake b/runtime/runtime.cmake index bfaa49cb..29a49786 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -502,6 +502,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/autocompile.c ${PSXRECOMP_ROOT}/runtime/src/code_provider.c ${PSXRECOMP_ROOT}/runtime/src/event_ring.c + ${PSXRECOMP_ROOT}/runtime/src/func_override.c ${PSXRECOMP_ROOT}/runtime/src/game_options.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_speed.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_pgxp.c diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 553bcc1b..3dfc9c89 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -11216,6 +11216,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); @@ -16339,6 +16394,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 }, diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 0d8856fa..8ee6ec0d 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -1137,7 +1137,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 +1792,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)) { @@ -2017,6 +2054,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)) { diff --git a/runtime/src/func_override.c b/runtime/src/func_override.c new file mode 100644 index 00000000..9575d04d --- /dev/null +++ b/runtime/src/func_override.c @@ -0,0 +1,278 @@ +/* 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; + 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); +} + +/* 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->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/main.cpp b/runtime/src/main.cpp index fc425137..bf442910 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -133,6 +133,7 @@ extern "C" uint32_t g_guest_last_store_addr, g_guest_last_store_val; #include "mod_plugins.h" #include "mod_local_input_policy.h" #include "mod_session_baseline.h" +#include "func_override.h" #include "mod_runtime.h" #include "video_texture_filter.h" #include "video_telemetry.h" @@ -20411,6 +20412,7 @@ 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(); + 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 3409c467..a189b6eb 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -50,6 +50,10 @@ struct RegisteredPlugin { std::vector function_entries; std::vector guest_functions; std::vector instructions; + /* Set when a function override is queued under this id (the callback + * itself lives in mod_runtime's pending list) — participates in + * availability so a manifest can gate an override-only plugin. */ + bool function_override = false; }; std::map& registered_plugins() { @@ -1535,12 +1539,20 @@ bool mod_register_function_filter_plugin(const std::string& id, uint32_t address return true; } +bool mod_register_function_override_marker(const std::string& id) { + if (!valid_id(id)) return false; + RegisteredPlugin& plugin = registered_plugins()[id]; + if (plugin.function_override) return false; + plugin.function_override = true; + return true; +} + bool mod_plugin_registered(const std::string& id) { const auto found = registered_plugins().find(id); return found != registered_plugins().end() && (found->second.activation || found->second.session_reset || found->second.vblank || found->second.savestate || !found->second.function_entries.empty() || !found->second.guest_functions.empty() || - !found->second.instructions.empty()); + !found->second.instructions.empty() || found->second.function_override); } bool mod_register_guest_function_plugin(const std::string& id, uint32_t address, diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index f44d43ab..baac2634 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -5,6 +5,7 @@ #include "disc_path.h" #include "host_file_identity.h" +#include "func_override.h" #include "iso_reader.h" #include "mod_packages.h" #include "mod_plugins.h" @@ -368,6 +369,27 @@ void build_function_entry_hooks(const RuntimeMods& s) { g_psx_mod_instruction_hooks = (uint32_t)instructions.size(); } +/* Function overrides queue here at constructor time and are ARMED into the + * func_override tier only for plugins the resolved package plan selects — + * the same gating as vblank/activation callbacks. `id` is the full + * registered name (may carry a ":label" suffix, kept for diagnostics); + * `plugin` is the manifest-facing part gating matches on. */ +struct FunctionOverridePlugin { + std::string id; + std::string plugin; + uint32_t address = 0; + PSXModFunctionOverrideFn fn = nullptr; + uint32_t guard[FO_MAX_GUARD_WORDS] = {0, 0, 0, 0}; + int n_guard = 0; + int32_t credit = 0; + bool armed = false; +}; + +std::vector& function_override_plugins() { + static std::vector value; + return value; +} + const ModPackage* selected_package(const std::string& id) { return state().manager.selected_package(id); } @@ -1663,6 +1685,20 @@ bool mod_runtime_clear_for_netplay(std::string* error) { s.disc_guard_failed = false; s.netplay_view_plan = false; s.error.clear(); + /* Clearing s.plan is enough for activation/vblank callbacks — they only + * run while something iterates the plan. Function overrides are armed + * into func_override.c's own table with the dispatcher hook installed, + * so they survive a cleared plan and keep firing unless explicitly + * disarmed. That matters most on the rematch path (main.cpp jumps to + * session_reboot, which is PAST mod_runtime_activate_plugins and + * func_override_install), where a modded session entering netplay would + * otherwise print the vanilla banner and still run its overrides — + * diverging from a peer without the mod. Also re-arm-able: dropping the + * armed flag lets a later plan register the same override again. */ + const int disarmed = func_override_reset_package_armed(); + if (disarmed > 0) + 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; @@ -2988,6 +3024,26 @@ extern "C" void mod_runtime_activate_plugins(void) { mod_invoke_activation_plugin(plugin.id); plugin_timing.success(); } + /* Arm the package-gated function overrides for plan-selected plugin + * ids, then (re)install the dispatcher hook. func_override_add refuses + * duplicate addresses; a refusal here means two active plugins claim + * one function, which the resolver should have prevented — the armed + * flag stays false and the `func_override` TCP command shows the gap. */ + for (FunctionOverridePlugin& pending : function_override_plugins()) { + if (pending.armed) continue; + const bool selected = std::any_of( + s.plan.plugins.begin(), s.plan.plugins.end(), + [&](const ModResolution::Plugin& plugin) { + return plugin.id == pending.plugin; + }); + if (!selected) continue; + const int rc = func_override_add_package( + pending.id.c_str(), pending.address, pending.fn, + pending.n_guard ? pending.guard : nullptr, pending.n_guard, + pending.credit); + pending.armed = (rc == FO_OK); + } + func_override_install(); psx_mod_immediate_end_activation(); s.activating = false; build_function_entry_hooks(s); @@ -3471,6 +3527,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 || !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; diff --git a/runtime/tests/test_func_override.c b/runtime/tests/test_func_override.c new file mode 100644 index 00000000..c48fbc38 --- /dev/null +++ b/runtime/tests/test_func_override.c @@ -0,0 +1,515 @@ +/* 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) +{ + s_impl_calls++; + cpu->gpr[2] = 0x1234u; + return 1; +} + +static int impl_declines(CPUState *cpu) +{ + (void)cpu; + 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; + return g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu); +} + +/* ---- 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_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index bd095287..bdf564a9 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -153,6 +153,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; } From 56b6839602873d8cccd93248540ee8a7050195fe Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 09:31:11 -0700 Subject: [PATCH 2/4] Reject partial function override activation before session callbacks --- docs/MOD_PACKAGES.md | 7 ++++ docs/TCP_COMMANDS.md | 5 +-- runtime/include/mod_runtime.h | 2 ++ runtime/src/main.cpp | 4 +++ runtime/src/mod_runtime.cpp | 55 +++++++++++++++++------------- runtime/tests/test_mod_runtime.cpp | 49 ++++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 25 deletions(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 6a75927f..e8ca6019 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -1182,3 +1182,10 @@ existing post-commit path, not the preparation worker. The `prepare_resources` method is separate from read-only `resolve`, so editing launcher settings does not trigger conversions. Runtime activation still sees only fully verified immutable resources. + +Function-override package arming is transactional and runs before activation +callbacks. Address collisions or capacity failures reject the launch through +`mod_runtime_activation_error()` and remove all package entries, retaining +always-on direct implementations. Every session rearms its selected plan. +Replacement overrides cannot be selected as netplay local-view plugins: they +change shared execution rather than only sandboxed presentation. diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index c81a2eeb..9a686e77 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` | ✓ | | ✓ | 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/src/main.cpp b/runtime/src/main.cpp index bf442910..9ecd7977 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -20412,6 +20412,10 @@ 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"); diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index baac2634..b6b7bf8d 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1696,8 +1696,8 @@ bool mod_runtime_clear_for_netplay(std::string* error) { * 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(); - if (disarmed > 0) - for (FunctionOverridePlugin& pending : function_override_plugins()) + (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"); @@ -2998,6 +2998,11 @@ 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(); +} + extern "C" void mod_runtime_activate_plugins(void) { using namespace PSXRecompV4; HostLaunchTimingScope timing(HOST_LAUNCH_PLUGIN_ACTIVATION); @@ -3005,6 +3010,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(); @@ -3015,6 +3023,27 @@ 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 bool selected = std::any_of(s.plan.plugins.begin(),s.plan.plugins.end(), + [&](const ModResolution::Plugin& p) { return p.id == pending.plugin; }); + if (!selected) continue; + const int rc = s.netplay_view_plan ? FO_ERR_ARGS : func_override_add_package( + pending.id.c_str(),pending.address,pending.fn, + pending.n_guard ? pending.guard : nullptr,pending.n_guard,pending.credit); + 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) { @@ -3024,26 +3053,6 @@ extern "C" void mod_runtime_activate_plugins(void) { mod_invoke_activation_plugin(plugin.id); plugin_timing.success(); } - /* Arm the package-gated function overrides for plan-selected plugin - * ids, then (re)install the dispatcher hook. func_override_add refuses - * duplicate addresses; a refusal here means two active plugins claim - * one function, which the resolver should have prevented — the armed - * flag stays false and the `func_override` TCP command shows the gap. */ - for (FunctionOverridePlugin& pending : function_override_plugins()) { - if (pending.armed) continue; - const bool selected = std::any_of( - s.plan.plugins.begin(), s.plan.plugins.end(), - [&](const ModResolution::Plugin& plugin) { - return plugin.id == pending.plugin; - }); - if (!selected) continue; - const int rc = func_override_add_package( - pending.id.c_str(), pending.address, pending.fn, - pending.n_guard ? pending.guard : nullptr, pending.n_guard, - pending.credit); - pending.armed = (rc == FO_OK); - } - func_override_install(); psx_mod_immediate_end_activation(); s.activating = false; build_function_entry_hooks(s); @@ -3531,7 +3540,7 @@ 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 || !fn) return 0; + 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; diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index bdf564a9..33a55313 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" @@ -32,6 +33,9 @@ namespace fs = std::filesystem; static std::array ram; static int failures; static int activation_calls; +static int override_activation_calls; +static void override_activate() { ++override_activation_calls; } +static int override_body(CPUState*) { return 1; } static int plugin_calls; static int restore_calls; static int option_renderer_activations; @@ -540,6 +544,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); @@ -2439,6 +2444,50 @@ 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 +[[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"); + 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"; From ab400a3953c5ac4190e30f5a1c941db0e84e4d2d Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 10:08:15 -0700 Subject: [PATCH 3/4] Regenerate OpenBIOS dispatch with the function override tier --- generated/OpenBIOS.emitter.sha | 2 +- generated/OpenBIOS_dispatch.c | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 8979439c..ddefaefb 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -86f7329c2692e76133c40fa11cc8cfac214302d8238e05369dc8b263f4dc76fd +6b3ba0804aeca3d52f6e44dd26ec7a8e82f40edce68ecf01ff14705373aef847 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 From d486a78acf0fcccbedbae9479551abd28a9802cf Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 11:16:39 -0700 Subject: [PATCH 4/4] Scope package overrides and defer snapshots across their native stacks --- runtime/include/func_override.h | 11 +++++++++++ runtime/include/mod_plugins.h | 4 +++- runtime/src/func_override.c | 20 +++++++++++++++++++- runtime/src/mod_runtime.cpp | 17 ++++++++++++----- runtime/tests/test_func_override.c | 6 +++++- runtime/tests/test_mod_runtime.cpp | 30 +++++++++++++++++++++++++++++- 6 files changed, 79 insertions(+), 9 deletions(-) diff --git a/runtime/include/func_override.h b/runtime/include/func_override.h index 3e736259..2e114219 100644 --- a/runtime/include/func_override.h +++ b/runtime/include/func_override.h @@ -190,6 +190,17 @@ void func_override_call_original(struct CPUState *cpu); 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). diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index bd2cb9e9..671b41fe 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -145,7 +145,9 @@ uint32_t psx_mod_guest_cycle_scale(void); * 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). - * Wrap semantics via func_override_call_original(); optional residency + * 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 diff --git a/runtime/src/func_override.c b/runtime/src/func_override.c index 9575d04d..97985f6b 100644 --- a/runtime/src/func_override.c +++ b/runtime/src/func_override.c @@ -35,6 +35,8 @@ 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]; @@ -127,6 +129,22 @@ int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn, 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; @@ -168,7 +186,7 @@ static int hook(CPUState *cpu, uint32_t phys) { const uint32_t saved_active = s_active_phys; s_active_phys = phys; - const int handled = e->fn(cpu) ? 1 : 0; + 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. diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index b6b7bf8d..e2e09ba2 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -3003,6 +3003,12 @@ 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); @@ -3025,12 +3031,13 @@ extern "C" void mod_runtime_activate_plugins(void) { s.extent_start = 0; /* Arm the entire tier before activation callbacks change host state. */ for (auto& pending : function_override_plugins()) { - const bool selected = std::any_of(s.plan.plugins.begin(),s.plan.plugins.end(), + const auto owner = std::find_if(s.plan.plugins.begin(),s.plan.plugins.end(), [&](const ModResolution::Plugin& p) { return p.id == pending.plugin; }); - if (!selected) continue; - const int rc = s.netplay_view_plan ? FO_ERR_ARGS : func_override_add_package( + 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); + 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) + ")"; @@ -3777,7 +3784,7 @@ static int game_netplay_function_entry(CPUState* cpu, uint32_t address) { } extern "C" int psx_mod_function_entry_active(void) { - return PSXRecompV4::function_entry_depth != 0; + return PSXRecompV4::function_entry_depth != 0 || func_override_active(); } extern "C" void mod_runtime_function_entry_context_save(ModFunctionEntryContext *out) { diff --git a/runtime/tests/test_func_override.c b/runtime/tests/test_func_override.c index c48fbc38..3ad9a6a3 100644 --- a/runtime/tests/test_func_override.c +++ b/runtime/tests/test_func_override.c @@ -98,6 +98,7 @@ 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; @@ -106,6 +107,7 @@ static int impl_handles(CPUState *cpu) 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 */ } @@ -135,7 +137,9 @@ static void reset_all(void) static int consult(CPUState *cpu, uint32_t addr) { if (!g_psx_func_override_hook) return 0; - return g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu); + 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 -------------------------------------------------------------- */ diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 33a55313..5e5e7b08 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -31,11 +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_body(CPUState*) { return 1; } +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; @@ -2459,6 +2469,18 @@ game_id = "SLUS-OVERRIDE" 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" @@ -2471,6 +2493,12 @@ id = "override.test" 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());