From 6877bc9bef0817a2efcc06081d1610e5d0b8adca Mon Sep 17 00:00:00 2001 From: Henrique Guedes Date: Sun, 16 Aug 2026 15:07:51 -0300 Subject: [PATCH 1/5] func_override tier: replace guest functions with hand-written C An address-keyed replace-or-decline tier for hand-written native C: - Core registry (func_override.{h,c}): register an implementation against a guest address; return 1 = handled (guest resumes at $ra), 0 = decline (the original recompiled/interpreted code runs). The dispatch hook stays NULL with nothing registered, so a build without overrides dispatches byte-identically. - Consulted in psx_dispatch_impl AFTER the BIOS tiers (an override can never shadow a kernel service vector) and BEFORE every game backend (emitted by full_function_emitter.cpp), and at the interpreter JAL/JALR call-resolution tiers (the reserved CRES_OVERRIDE slot) so locally-resolved calls cannot bypass a hook. One hook covers the static EXE, runtime-loaded overlays, and dirty RAM alike. - Ergonomics as API instead of copy-paste idioms: func_override_guest_call (call guest code from native, authentic $ra), func_override_call_original (one-shot bypass = real wrap semantics), func_override_add_guarded (prologue-word residency guard for overlay addresses other code may occupy). - Mod-system integration: psx_mod_register_function_override queues under a plugin id; overrides ARM only when the resolved package plan selects that plugin -- same gating as vblank/activation callbacks. - func_override TCP command: per-override id/addr/calls/guard_misses; calls counts consults (declines included) so a decline-only probe proves an address crosses a hooked path. Cycle crediting is documented as deferred until the shared cycle core exposes a public credit API; overrides should wrap (call_original) on timing-sensitive paths. Rollback constraint documented: all mutable override state belongs in guest RAM. --- recompiler/src/full_function_emitter.cpp | 18 +++ runtime/include/func_override.h | 159 ++++++++++++++++++ runtime/include/mod_plugins.h | 16 ++ runtime/runtime.cmake | 1 + runtime/src/debug_server.c | 36 +++++ runtime/src/dirty_ram_interp.c | 47 +++++- runtime/src/func_override.c | 198 +++++++++++++++++++++++ runtime/src/main.cpp | 6 + runtime/src/mod_runtime.cpp | 65 ++++++++ 9 files changed, 545 insertions(+), 1 deletion(-) create mode 100644 runtime/include/func_override.h create mode 100644 runtime/src/func_override.c diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index c15f32d45..601984910 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -1713,6 +1713,12 @@ void FullFunctionEmitter::emit_dispatch( out += " * NULL (the default) = pure LLE, dispatch identical to a build without\n"; out += " * the tier. */\n"; out += "extern int (*g_psx_bios_hle_hook)(CPUState* cpu, uint32_t phys);\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"; @@ -2046,6 +2052,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/include/func_override.h b/runtime/include/func_override.h new file mode 100644 index 000000000..e81aa6477 --- /dev/null +++ b/runtime/include/func_override.h @@ -0,0 +1,159 @@ +/* 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 too (between enter-compiled and overlay-native — the reserved + * CRES_OVERRIDE slot), so calls the interpreter resolves locally cannot + * bypass an override. + * + * 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. A handled override credits no guest cycles for the code + * it skipped; on timing-sensitive paths prefer wrapping (call the original + * via func_override_call_original, then adjust) over wholesale replacement. + * A cycle-credit parameter is deliberately deferred until the shared cycle + * core exposes a public credit API — 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 + +struct CPUState; + +#ifdef __cplusplus +extern "C" { +#endif + +#define FO_MAX_OVERRIDES 128 +#define FO_MAX_ID 64 +#define FO_MAX_GUARD_WORDS 4 + +/* 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. */ +int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn); + +/* 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); + +/* 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); + +/* 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); + +/* 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. */ +int func_override_count(void); +int func_override_get(int index, char *id_out, uint32_t *addr_out, + uint64_t *calls_out); +int func_override_get_ex(int index, char *id_out, uint32_t *addr_out, + uint64_t *calls_out, uint64_t *guard_misses_out, + int *guarded_out); + +#ifdef __cplusplus +} +#endif + +#endif /* PSXRECOMP_FUNC_OVERRIDE_H */ diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 610f245d2..f8acab4d7 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -26,6 +26,22 @@ int psx_mod_register_function_entry_plugin( /* Called only from generated functions explicitly listed by the game config. */ void psx_mod_function_entry(struct CPUState* cpu, uint32_t address); +/* + * 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). + */ +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); + /* Narrow guest services available to trusted plugin callbacks. */ int psx_mod_game_started(void); uint8_t psx_mod_read_byte(uint32_t address); diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index a8520439c..b111501c0 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -330,6 +330,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 4c8677f76..4c4289da7 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8877,6 +8877,41 @@ 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, + uint32_t *addr_out, uint64_t *calls_out, + uint64_t *guard_misses_out, + int *guarded_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; + if (!func_override_get_ex(i, oid, &addr, &calls, &misses, &guarded)) + break; + n += snprintf(buf + n, sizeof(buf) - (size_t)n, + "%s{\"id\":\"%s\",\"addr\":\"0x%08X\",\"calls\":%llu," + "\"guard_misses\":%llu,\"guarded\":%d}", + i ? "," : "", oid, addr, + (unsigned long long)calls, (unsigned long long)misses, + guarded); + if ((size_t)n >= 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); @@ -13271,6 +13306,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 5ec7fd014..0f2a97c04 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -1046,7 +1046,8 @@ enum { XOP_JAL = 0, XOP_JALR = 1, XOP_JR = 2, XOP_J = 3, XOP_DD = 4, XOP_BR = 5, ds_insn = v0 after the path ran */ }; 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, +enum { XRES_OVERRIDE = 13, + 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 }; @@ -1594,6 +1595,31 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, CRET(CRES_EC_RET | (_r ? 0x100u : 0u), _r); } } #endif + /* Function-override tier (func_override.h): consulted between + * the compiled and overlay-native backends — the reserved + * CRES_OVERRIDE slot. Without this, a call the interpreter + * resolves below (overlay-native or local pc-chain) would + * bypass an armed override. 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); } + } + } + } /* Native overlay candidates get the SAME call contract as * statically-compiled callees: run as a unit, resume at * return_pc. A bare pc-chain here loses the return obligation @@ -1803,6 +1829,25 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, return dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); } #endif + /* Function-override tier: same placement and contract as the JALR + * site above (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); + } + } + } /* Native overlay candidates get the SAME call contract as statically- * compiled callees: run as a unit, resume at return_pc. A bare * pc-chain here loses the return obligation when the callee runs diff --git a/runtime/src/func_override.c b/runtime/src/func_override.c new file mode 100644 index 000000000..82d267652 --- /dev/null +++ b/runtime/src/func_override.c @@ -0,0 +1,198 @@ +/* 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" + +extern uint32_t psx_read_word(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 */ +} 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) +{ + 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; + if (s_count >= FO_MAX_OVERRIDES) return FO_ERR_FULL; + + const uint32_t phys = normalise(addr); + /* 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; + s_count++; + return FO_OK; +} + +int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn) +{ + return add_common(id, addr, fn, NULL, 0); +} + +int func_override_add_guarded(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words) +{ + if (n_words < 1) return FO_ERR_ARGS; + return add_common(id, addr, fn, expected_words, n_words); +} + +/* 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_read_word(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; + 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_count(void) { return s_count; } + +int func_override_get(int index, char *id_out, uint32_t *addr_out, + uint64_t *calls_out) +{ + return func_override_get_ex(index, id_out, addr_out, calls_out, NULL, + NULL); +} + +int func_override_get_ex(int index, char *id_out, uint32_t *addr_out, + uint64_t *calls_out, uint64_t *guard_misses_out, + int *guarded_out) +{ + if (index < 0 || index >= s_count) return 0; + const Entry *e = &s_entries[index]; + if (id_out) { memcpy(id_out, e->id, FO_MAX_ID); } + 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; + return 1; +} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 9a8215cff..e5ed6c03b 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -71,6 +71,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "launcher_device.h" #include "game_options.h" #include "mod_plugins.h" +#include "func_override.h" #include "mod_runtime.h" #include "crc32.h" #include "disc_identity.h" @@ -12194,6 +12195,11 @@ int main(int argc, char** argv) { g_turbo_loads_enabled = 0; g_frame_interpolation_blend = g_frame_interpolation_blend_default; mod_runtime_activate_plugins(); + /* Arm directly-registered function overrides (EXTRAS_SOURCES game code + * calling func_override_add from constructors, the adrecomp idiom) even + * when no package plan exists; package-gated overrides were armed by + * mod_runtime_activate_plugins above. Idempotent. */ + func_override_install(); apply_netplay_local_viewport_aspect(net_cfg.enabled); if (g_mod_controller_mode_override[0] >= 0) player_mode[0] = g_mod_controller_mode_override[0]; diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 8fc1a6a11..716cc8801 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1,6 +1,7 @@ #include "mod_runtime.h" #include "disc_path.h" +#include "func_override.h" #include "iso_reader.h" #include "mod_packages.h" #include "mod_plugins.h" @@ -86,6 +87,23 @@ std::vector& function_entry_plugins() { return value; } +/* 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. */ +struct FunctionOverridePlugin { + std::string id; + uint32_t address = 0; + PSXModFunctionOverrideFn fn = nullptr; + uint32_t guard[FO_MAX_GUARD_WORDS] = {0, 0, 0, 0}; + int n_guard = 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); } @@ -1209,6 +1227,29 @@ extern "C" void mod_runtime_activate_plugins(void) { if (!s.initialized || !s.plan.ok) return; for (const ModResolution::Plugin& plugin : s.plan.plugins) mod_invoke_activation_plugin(plugin.id); + /* 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.id; + }); + if (!selected) continue; + const int rc = + pending.n_guard + ? func_override_add_guarded(pending.id.c_str(), + pending.address, pending.fn, + pending.guard, pending.n_guard) + : func_override_add(pending.id.c_str(), pending.address, + pending.fn); + pending.armed = (rc == FO_OK); + } + func_override_install(); } extern "C" void mod_runtime_on_vblank(void) { @@ -1302,6 +1343,30 @@ extern "C" int psx_mod_register_function_entry_plugin( return 1; } +extern "C" int psx_mod_register_function_override( + const char* id, uint32_t address, PSXModFunctionOverrideFn fn, + const uint32_t* expected_words, int n_words) { + 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; + 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.address = address; + plugin.fn = fn; + for (int i = 0; i < n_words; ++i) plugin.guard[i] = expected_words[i]; + plugin.n_guard = n_words; + plugins.push_back(plugin); + return 1; +} + extern "C" void psx_mod_function_entry(CPUState* cpu, uint32_t address) { using namespace PSXRecompV4; if (!cpu) return; From 1dcc5d08ee8c97aaa0dab4c6722215336852ab84 Mon Sep 17 00:00:00 2001 From: Henrique Guedes Date: Sun, 16 Aug 2026 16:47:02 -0300 Subject: [PATCH 2/5] func_override: count override-only plugin ids as available; docs An id whose only registration is a queued function override was invisible to mod_plugin_registered, so a manifest gating an override-only plugin failed resolution with 'trusted plugin is unavailable'. Registration now marks the id in the plugin registry (same map as activation/vblank). MOD_PACKAGES.md documents the function-override plugin kind and the direct-registration (progressive-decomp) idiom. --- docs/MOD_PACKAGES.md | 14 ++++++++++++++ runtime/include/mod_packages.h | 1 + runtime/src/mod_packages.cpp | 15 ++++++++++++++- runtime/src/mod_runtime.cpp | 3 +++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index b040f978a..38dc7337b 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -294,6 +294,20 @@ 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. The full execution contract (guest ABI, decline semantics, +`func_override_call_original` wrap primitive, `func_override_guest_call`, +determinism and cycle-accounting constraints) 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. + `psx_mod_set_load_acceleration(multiplier, release_frames)` is the narrow pre-boot service for a game-owned fast-loading feature. It changes host wall-clock pacing only: guest VBlanks, CD deadlines, interrupts, callbacks, and diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 950d18d26..a2b2fd58a 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -348,6 +348,7 @@ void mod_clear_builtin_resolvers_for_tests(); bool mod_register_activation_plugin(const std::string& id, void (*callback)(void)); bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)); 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); void mod_clear_plugins_for_tests(); diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 7c5f10682..6297548ea 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -34,6 +34,10 @@ std::map& builtin_resolvers() { struct RegisteredPlugin { PSXModActivationCallback activation = nullptr; PSXModVBlankCallback vblank = nullptr; + /* 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() { @@ -1416,10 +1420,19 @@ bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)) { 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.vblank); + (found->second.activation || found->second.vblank || + found->second.function_override); } void mod_invoke_activation_plugin(const std::string& id) { diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 716cc8801..1ec1428b5 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1364,6 +1364,9 @@ extern "C" int psx_mod_register_function_override( for (int i = 0; i < n_words; ++i) plugin.guard[i] = expected_words[i]; plugin.n_guard = n_words; plugins.push_back(plugin); + /* Mark the id available to the package resolver so a manifest can gate + * an override-only plugin (multiple overrides may share one id). */ + mod_register_function_override_marker(id); return 1; } From 6ae886e99d10217a4dde1c7be75dc3cd3a0c5d0b Mon Sep 17 00:00:00 2001 From: Henrique Guedes Date: Sat, 22 Aug 2026 19:34:46 -0300 Subject: [PATCH 3/5] func_override: per-override labels under one plugin id; document the TCP command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The func_override TCP inventory doubles as the live list of what native mods hooked, but a plugin that registers several overrides showed one indistinguishable id per row (only the address told them apart). Adopt the registry convention from the tier's original game-side deployment: an optional ":label" suffix on the registered id ("pkg.feature:aim") names the override in diagnostics, while gating and resolver availability match only the part before the ':' — several overrides sit under one manifest [[plugin]] entry and still read apart. Also add the missing TCP_COMMANDS.md entry for `func_override` (the command existed, the doc row didn't), spelling out the decline-probe workflow: `calls` counts consults, declines included, so a decline-only probe proves an address crosses a hooked path. Index regenerated. --- TCP_COMMANDS.md | 68 +++++++++++++++++++++-------------- runtime/include/mod_plugins.h | 5 +++ runtime/src/mod_runtime.cpp | 23 +++++++++--- 3 files changed, 64 insertions(+), 32 deletions(-) diff --git a/TCP_COMMANDS.md b/TCP_COMMANDS.md index ddb53ac38..ba2fd02f2 100644 --- a/TCP_COMMANDS.md +++ b/TCP_COMMANDS.md @@ -49,6 +49,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `write_ram` | ✓ | ✓ | `addr`, `val` | Write **one byte** to PS1 address space. Note the parameter is `val` (not `hex`), and the write is a single byte per call — this row previously documented both incorrectly | | `read_scratch` | | ✓ | `addr`, `len` | Read PS1 scratchpad (0x1F800000 region) | | `read_vram` / `vram_peek` | ✓¹ | ✓ | `x`, `y`, `w`, `h` | Read 16-bit VRAM pixels (max 128×128) | +| `func_override` | ✓ | | — | Inventory of armed function overrides (`func_override.h`): per entry `id`, guest `addr`, `calls`, `guard_misses`, `guarded`. `calls` counts **consults**, declines included — so a decline-only probe proves an address crosses a hooked path, and `calls: 0` means the override was never reached (wrong address, or that path never ran). Package-gated overrides appear only after the mod plan arms them; an id may read `plugin:label` when one plugin registers several overrides | | `gpu_state` | ✓ | ✓ | — | Display area, display depth, draw offset, GPUSTAT, clip rect, xfer state | | `screenshot_hires` | | ✓ | `path` | PNG of the **supersampled** surface (the present path the window uses), at `display × gr_scale()`. ⚠ `screenshot`/`screenshot_file` capture native 15-bit VRAM and are **blind to anything that only exists in the hi-res mirror** — geometry correction, SSAA edges, perspective UVs — so they show a clean frame while the player sees a broken one. Use this one to verify those. Falls back to the native resolve (and reports `scale: 1`) when no hi-res surface exists | | `geom_correction` | | ✓ | — | `[video] geometry_correction` / `perspective_texturing` engagement: enable flag plus free-running `geometry_vertex_hits` and `perspective_triangles` totals. Both enhancements silently fall back to the faithful path on anything they cannot prove is projected geometry, so a zero counter with the flag on means the title never qualifies — sample twice and diff for a rate | @@ -92,30 +93,30 @@ Columns: **N** = native, **D** = DuckStation oracle. | `pc_hit_clear` | | ✓² | — | Clear the last-hit record | | `quit` | ✓ | | — | Shutdown native runtime | -¹ Native `vram_peek` is the legacy name; DS calls it `read_vram`. Same semantics. -² The `pc_*` family is specific to the DS oracle: DuckStation's CPU core honours `CPU::AddBreakpointWithCallback`, while our native runtime dispatches whole recompiled functions (no mid-function PC breaks). - -### Boot-time write ranges - -Set `PSX_WTRACE_BOOT=lo,hi[;lo,hi...]` before launching a debug-tools build to -retain the first writes to one or more half-open RAM ranges from guest -instruction zero. Addresses may be hexadecimal or decimal; KSEG addresses are -normalized to physical addresses. For example, the Crash Bash investigation -that motivated this option can be reproduced without title-specific code: - -```powershell -$env:PSX_WTRACE_BOOT='0x000B3A80,0x000B3B00' -.\CrashBashRecomp.exe -``` - -Connect at any later point and query `wtrace_boot_stats`, -`wtrace_boot_summary`, or `wtrace_boot_dump`. Each retained entry includes the -write address/value/width, guest PC and return address, register context, frame, -and DMA channel. The option is ignored in builds made with debug tools disabled. - ---- - -## Divergence-hunt workflow +¹ Native `vram_peek` is the legacy name; DS calls it `read_vram`. Same semantics. +² The `pc_*` family is specific to the DS oracle: DuckStation's CPU core honours `CPU::AddBreakpointWithCallback`, while our native runtime dispatches whole recompiled functions (no mid-function PC breaks). + +### Boot-time write ranges + +Set `PSX_WTRACE_BOOT=lo,hi[;lo,hi...]` before launching a debug-tools build to +retain the first writes to one or more half-open RAM ranges from guest +instruction zero. Addresses may be hexadecimal or decimal; KSEG addresses are +normalized to physical addresses. For example, the Crash Bash investigation +that motivated this option can be reproduced without title-specific code: + +```powershell +$env:PSX_WTRACE_BOOT='0x000B3A80,0x000B3B00' +.\CrashBashRecomp.exe +``` + +Connect at any later point and query `wtrace_boot_stats`, +`wtrace_boot_summary`, or `wtrace_boot_dump`. Each retained entry includes the +write address/value/width, guest PC and return address, register context, frame, +and DMA channel. The option is ignored in builds made with debug tools disabled. + +--- + +## Divergence-hunt workflow When a recompiled-BIOS bug is suspected, the two servers let you find the **first** divergence instead of chasing symptoms. Standard procedure (inherited from v3's `DEBUG.md`): @@ -263,9 +264,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i ## Complete command index (generated) -**292 commands registered** — 279 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). - -47 of 292 have prose above; **245 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. +**305 commands registered** — 292 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). + +50 of 305 have prose above; **255 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. @@ -289,6 +290,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `card_buffer_dump` | ✓ | | | | `card_data_writes` | ✓ | | | | `card_data_writes_reset` | ✓ | | | +| `card_handoff` | ✓ | | | | `card_mgr_clear` | ✓ | | | | `card_mgr_trace` | ✓ | | | | `card_read_summary` | ✓ | | | @@ -378,7 +380,9 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `frame_range` | ✓ | ✓ | ✓ | | `frame_timeseries` | ✓ | ✓ | ✓ | | `freeze_check` | ✓ | | | +| `func_override` | ✓ | | ✓ | | `game_options` | ✓ | | | +| `geom_correction` | ✓ | | ✓ | | `get_frame` | ✓ | ✓ | ✓ | | `get_quads` | ✓ | | | | `get_registers` | ✓ | ✓ | ✓ | @@ -404,6 +408,11 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `hle_dump` | ✓ | | ✓ | | `idle_skip` | ✓ | | | | `imask_trace` | ✓ | | | +| `input_route_append` | ✓ | | | +| `input_route_clear` | ✓ | | | +| `input_route_start` | ✓ | | | +| `input_route_status` | ✓ | | | +| `input_route_stop` | ✓ | | | | `insn_freeze` | ✓ | | | | `insn_freeze_snapshot` | ✓ | | | | `insn_freeze_status` | ✓ | | | @@ -447,6 +456,10 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `parity_ctl` | ✓ | ✓ | | | `parity_dump` | ✓ | ✓ | | | `pause` | ✓ | | ✓ | +| `pc_probe_arm` | ✓ | | | +| `pc_probe_clear` | ✓ | | | +| `pc_probe_dump` | ✓ | | | +| `pgxp` | ✓ | | | | `phase_hot` | ✓ | | | | `phase_profile` | ✓ | | | | `ping` | ✓ | ✓ | ✓ | @@ -477,6 +490,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `savestate` | ✓ | | | | `screenshot` | ✓ | ✓ | ✓ | | `screenshot_file` | ✓ | ✓ | ✓ | +| `screenshot_hires` | ✓ | | ✓ | | `set_input` | ✓ | ✓ | ✓ | | `set_snapshot` | ✓ | ✓ | ✓ | | `sio_arm_audit` | ✓ | | | diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index f8acab4d7..6a5d78afc 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -35,6 +35,11 @@ void psx_mod_function_entry(struct CPUState* cpu, uint32_t address); * 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. */ typedef int (*PSXModFunctionOverrideFn)(struct CPUState* cpu); int psx_mod_register_function_override(const char* id, uint32_t address, diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 1ec1428b5..4378a1797 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -89,9 +89,12 @@ std::vector& function_entry_plugins() { /* 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. */ + * 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}; @@ -1237,7 +1240,7 @@ extern "C" void mod_runtime_activate_plugins(void) { const bool selected = std::any_of( s.plan.plugins.begin(), s.plan.plugins.end(), [&](const ModResolution::Plugin& plugin) { - return plugin.id == pending.id; + return plugin.id == pending.plugin; }); if (!selected) continue; const int rc = @@ -1350,6 +1353,15 @@ extern "C" int psx_mod_register_function_override( 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; + /* 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(), @@ -1359,14 +1371,15 @@ extern "C" int psx_mod_register_function_override( 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; plugins.push_back(plugin); - /* Mark the id available to the package resolver so a manifest can gate - * an override-only plugin (multiple overrides may share one id). */ - mod_register_function_override_marker(id); + /* 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; } From 8105d937970604d1c332ede515abd0776657eefe Mon Sep 17 00:00:00 2001 From: Matthew Stanley Date: Sat, 22 Aug 2026 22:35:58 -0700 Subject: [PATCH 4/5] func_override: close the interp coverage gap, add teardown, untrace the guard Review fixes for the func_override tier (PR #174), tracked as beads-eio.3.59. All three were silent failures: nothing at runtime reports them. 1. Coverage. Both interpreter call sites consulted the hook AFTER interp_enter_compiled, which reaches psx_dispatch_game_compiled -> entry->fn(cpu) directly and never re-enters psx_dispatch_impl. Any override on a statically-compiled function called from interpreted (dirty-RAM / overlay) code therefore ran the ORIGINAL, while registration, the armed count and the func_override inventory all looked healthy. The consult now precedes enter-compiled at both sites, matching the placement psx_dispatch_impl already uses and the invariant the header documents. Tail-transfer sites are deliberately NOT hooked; the header now states that overrides are call-site keyed so a calls==0 on a tail-called address is explained rather than mysterious. 2. Teardown. func_override.c had no removal path, so armed overrides outlived a cleared mod plan. Clearing s.plan is enough for activation/vblank callbacks because those only run while something iterates the plan, but an armed override lives in this module's own table with the hook installed. On the rematch path (main.cpp jumps to session_reboot, past mod_runtime_activate_plugins and func_override_install) a modded session entering netplay printed the vanilla-session banner and kept running its overrides, diverging from a peer without the mod. Adds func_override_add_package / func_override_reset_package_armed, wired into mod_runtime_clear_for_netplay, dropping package-armed entries while keeping direct always-on registrations. 3. Observability. The residency guard read through psx_read_word, which is traced: it feeds ls_read_hook under lockstep, RETURNS the replayed value under lockstep replay, and calls ds_note_read under DuckStation recording. Guard words were therefore injected into the divergence streams as phantom guest reads, and compared replayed data instead of resident bytes during replay. Adds psx_peek_word_untraced (same address decode, no tracing) and uses it for the guard. Also: reject an address normalising to phys 0 (it collides with the not-inside-an-override sentinel and would silently break call_original); give func_override_get/_get_ex an id buffer size instead of an implicit FO_MAX_ID requirement; clamp the accumulated length in handle_func_override so raising FO_MAX_ID or FO_MAX_OVERRIDES cannot underflow the remaining-size argument; move XRES_OVERRIDE to the end of its enum with a note that it is appended, NOT slotted into consult order (that enum is dense, unlike CRES, so inserting would renumber the codes above it and invalidate captured xprobe traces). Corrects two claims in the header: the interpreter placement, and the assertion that a cycle-credit parameter is blocked on a missing API. psx_advance_cycles is public in psx_cycles.h and bios_hle.c already charges per service with it. The zero-credit behaviour is unchanged here and now documented as an unresolved POLICY question, not a technical one. Adds runtime/tests/test_func_override.c (registered in runtime/CMakeLists.txt): install-is-NULL-when-empty, argument and duplicate refusal, consult counting for declines, guard decline without running the body, call_original one-shot semantics, bypass consumption proven via a self-recursive original, package reset keeping direct entries, and bounded id copy. Verified by mutation: no-op'ing the package reset, removing the phys 0 check, and leaving the bypass armed each fail the suite. Not verified here: no full runtime link and no game run, so fix 1 is confirmed by compile and reasoning only, not by observing an override fire from interpreted code. That is acceptance criterion 1 on beads-eio.3.59 and remains open. --- runtime/CMakeLists.txt | 10 + runtime/include/func_override.h | 68 ++++- runtime/src/debug_server.c | 20 +- runtime/src/dirty_ram_interp.c | 79 +++--- runtime/src/func_override.c | 84 +++++- runtime/src/memory.c | 10 + runtime/src/mod_runtime.cpp | 24 +- runtime/tests/test_func_override.c | 438 +++++++++++++++++++++++++++++ 8 files changed, 663 insertions(+), 70 deletions(-) create mode 100644 runtime/tests/test_func_override.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index afa711891..f0a1c6585 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -178,6 +178,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(disc_path_resolve_test diff --git a/runtime/include/func_override.h b/runtime/include/func_override.h index e81aa6477..2dc650252 100644 --- a/runtime/include/func_override.h +++ b/runtime/include/func_override.h @@ -23,9 +23,20 @@ * 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 too (between enter-compiled and overlay-native — the reserved - * CRES_OVERRIDE slot), so calls the interpreter resolves locally cannot - * bypass an override. + * 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 * @@ -54,11 +65,21 @@ * desync replays. Reading host config that never changes mid-session is * fine. * - * CYCLE ACCOUNTING. A handled override credits no guest cycles for the code - * it skipped; on timing-sensitive paths prefer wrapping (call the original - * via func_override_call_original, then adjust) over wholesale replacement. - * A cycle-credit parameter is deliberately deferred until the shared cycle - * core exposes a public credit API — see FAITHFUL_TIMING_PLAN. + * CYCLE ACCOUNTING — UNRESOLVED, READ THIS BEFORE USING THE TIER. + * A handled override credits NO guest cycles for the code it skipped, so + * replacing a function collapses its guest-time cost to zero and shifts IRQ + * phase. On timing-sensitive paths prefer wrapping (call the original via + * func_override_call_original, then adjust) over wholesale replacement. + * + * There is NO technical blocker to charging cycles: psx_advance_cycles() is + * public in runtime/include/psx_cycles.h and the adjacent BIOS HLE tier + * already charges per service with it (bios_hle.c, under its own TIMING + * NOTE). What is unresolved is POLICY — whether a credit argument should be + * required at registration, and what a wrap should charge given the original + * accrues its own cycles. Until that is settled, treat zero-credit as a + * known faithfulness gap: acceptable for a player-toggled mod, NOT yet + * justified for an always-on reimplementation claiming to be + * indistinguishable from the code it replaces. See FAITHFUL_TIMING_PLAN. * * WHAT IT DOES NOT DO * @@ -70,6 +91,7 @@ #ifndef PSXRECOMP_FUNC_OVERRIDE_H #define PSXRECOMP_FUNC_OVERRIDE_H +#include #include struct CPUState; @@ -135,22 +157,42 @@ void func_override_guest_call(struct CPUState *cpu, uint32_t target, * 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); + /* 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. */ int func_override_count(void); -int func_override_get(int index, char *id_out, uint32_t *addr_out, - uint64_t *calls_out); -int func_override_get_ex(int index, char *id_out, uint32_t *addr_out, - uint64_t *calls_out, uint64_t *guard_misses_out, - int *guarded_out); +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); #ifdef __cplusplus } diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 4c4289da7..f445858b3 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8884,7 +8884,7 @@ static void handle_wide_full(int id, const char *json) 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, + 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); @@ -8898,15 +8898,27 @@ static void handle_func_override(int id, const char *json) uint32_t addr = 0; uint64_t calls = 0, misses = 0; int guarded = 0; - if (!func_override_get_ex(i, oid, &addr, &calls, &misses, &guarded)) + if (!func_override_get_ex(i, oid, sizeof(oid), &addr, &calls, + &misses, &guarded)) break; - n += snprintf(buf + n, sizeof(buf) - (size_t)n, + /* 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}", i ? "," : "", oid, addr, (unsigned long long)calls, (unsigned long long)misses, guarded); - if ((size_t)n >= sizeof(buf) - 256) break; + 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); diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 0f2a97c04..27a71e147 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -1046,10 +1046,15 @@ enum { XOP_JAL = 0, XOP_JALR = 1, XOP_JR = 2, XOP_J = 3, XOP_DD = 4, XOP_BR = 5, ds_insn = v0 after the path ran */ }; enum { XSITE_INTERP = 0, XSITE_DD = 1 }; /* XOP_RES path codes (in the `site` field). */ -enum { XRES_OVERRIDE = 13, - XRES_EC_BAIL = 2, XRES_EC_PC = 3, XRES_EC_CONTRACT = 4, XRES_EC_RET = 5, +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 */ @@ -1583,25 +1588,19 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, uint32_t _cr = callret_begin(cpu, pc, target); /* call-resolution ring */ #define CRET(code, rv) do { callret_end(_cr, cpu, (code)); return (rv); } while (0) if (g_precise_mode || g_ls_replay_active) { cpu->pc = target; CRET(CRES_PLAIN, 1); } /* slice / lockstep-replay: plain transfer, never execute the callee */ -#ifdef PSX_HAS_GAME_DISPATCH - cpu->pc = 0; - if (interp_enter_compiled(cpu, target)) { - if (g_psx_call_bail) CRET(CRES_EC_BAIL, 1); /* wild unwind: cpu->pc = true target */ - if (cpu->pc != 0) CRET(CRES_EC_PC, 1); - if (rd == 0 || rd == 31) { - if (psx_call_contract(cpu, return_pc, site_sp)) CRET(CRES_EC_CONTRACT, 1); - } - { int _r = dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); - CRET(CRES_EC_RET | (_r ? 0x100u : 0u), _r); } - } -#endif - /* Function-override tier (func_override.h): consulted between - * the compiled and overlay-native backends — the reserved - * CRES_OVERRIDE slot. Without this, a call the interpreter - * resolves below (overlay-native or local pc-chain) would - * bypass an armed override. Handled => the override completed - * against guest state; same call contract as a compiled - * callee. */ + /* 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); @@ -1620,6 +1619,18 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, } } } +#ifdef PSX_HAS_GAME_DISPATCH + cpu->pc = 0; + if (interp_enter_compiled(cpu, target)) { + if (g_psx_call_bail) CRET(CRES_EC_BAIL, 1); /* wild unwind: cpu->pc = true target */ + if (cpu->pc != 0) CRET(CRES_EC_PC, 1); + if (rd == 0 || rd == 31) { + if (psx_call_contract(cpu, return_pc, site_sp)) CRET(CRES_EC_CONTRACT, 1); + } + { int _r = dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); + CRET(CRES_EC_RET | (_r ? 0x100u : 0u), _r); } + } +#endif /* Native overlay candidates get the SAME call contract as * statically-compiled callees: run as a unit, resume at * return_pc. A bare pc-chain here loses the return obligation @@ -1819,18 +1830,10 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, #define XRES(code) do { (void)(code); } while (0) #endif if (g_precise_mode || g_ls_replay_active) { cpu->pc = target; return 1; } /* slice / lockstep-replay: plain transfer, never execute the callee */ -#ifdef PSX_HAS_GAME_DISPATCH - cpu->pc = 0; - if (interp_enter_compiled(cpu, target)) { - if (g_psx_call_bail) { XRES(XRES_EC_BAIL); return 1; } /* wild unwind: cpu->pc = true target */ - if (cpu->pc != 0) { XRES(XRES_EC_PC); return 1; } - if (psx_call_contract(cpu, return_pc, site_sp)) { XRES(XRES_EC_CONTRACT); return 1; } - XRES(XRES_EC_RET); - return dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); - } -#endif /* Function-override tier: same placement and contract as the JALR - * site above (reserved CRES_OVERRIDE slot). */ + * 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); @@ -1848,6 +1851,16 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, } } } +#ifdef PSX_HAS_GAME_DISPATCH + cpu->pc = 0; + if (interp_enter_compiled(cpu, target)) { + if (g_psx_call_bail) { XRES(XRES_EC_BAIL); return 1; } /* wild unwind: cpu->pc = true target */ + if (cpu->pc != 0) { XRES(XRES_EC_PC); return 1; } + if (psx_call_contract(cpu, return_pc, site_sp)) { XRES(XRES_EC_CONTRACT); return 1; } + XRES(XRES_EC_RET); + return dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); + } +#endif /* Native overlay candidates get the SAME call contract as statically- * compiled callees: run as a unit, resume at return_pc. A bare * pc-chain here loses the return obligation when the callee runs diff --git a/runtime/src/func_override.c b/runtime/src/func_override.c index 82d267652..edfcf63f4 100644 --- a/runtime/src/func_override.c +++ b/runtime/src/func_override.c @@ -13,7 +13,16 @@ #include "cpu_state.h" -extern uint32_t psx_read_word(uint32_t addr); +/* 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 @@ -29,6 +38,7 @@ typedef struct { 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 */ } Entry; static Entry s_entries[FO_MAX_OVERRIDES]; @@ -53,7 +63,7 @@ static Entry *find(uint32_t phys) } static int add_common(const char *id, uint32_t addr, FuncOverrideFn fn, - const uint32_t *guard, int n_guard) + const uint32_t *guard, int n_guard, int package) { if (!fn) return FO_ERR_ARGS; if (n_guard < 0 || n_guard > FO_MAX_GUARD_WORDS) return FO_ERR_ARGS; @@ -61,6 +71,11 @@ static int add_common(const char *id, uint32_t addr, FuncOverrideFn fn, 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; @@ -75,20 +90,29 @@ static int add_common(const char *id, uint32_t addr, FuncOverrideFn fn, 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; s_count++; return FO_OK; } int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn) { - return add_common(id, addr, fn, NULL, 0); + return add_common(id, addr, fn, NULL, 0, 0); } int func_override_add_guarded(const char *id, uint32_t addr, FuncOverrideFn fn, const uint32_t *expected_words, int n_words) { if (n_words < 1) return FO_ERR_ARGS; - return add_common(id, addr, fn, expected_words, n_words); + return add_common(id, addr, fn, expected_words, n_words, 0); +} + +int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words) +{ + 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); } /* The hook. Runs on EVERY dispatch, so the common path — nothing registered @@ -113,7 +137,8 @@ static int hook(CPUState *cpu, uint32_t phys) const uint32_t base = 0x80000000u | phys; int miss = 0; for (int w = 0; w < e->n_guard; w++) - if (psx_read_word(base + (uint32_t)(w * 4)) != e->guard[w]) { + if (psx_peek_word_untraced(base + (uint32_t)(w * 4)) != + e->guard[w]) { miss = 1; break; } @@ -174,22 +199,55 @@ void func_override_install(void) 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, uint32_t *addr_out, - uint64_t *calls_out) +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, addr_out, calls_out, NULL, - NULL); + return func_override_get_ex(index, id_out, id_cap, addr_out, calls_out, + NULL, NULL); } -int func_override_get_ex(int index, char *id_out, uint32_t *addr_out, - uint64_t *calls_out, uint64_t *guard_misses_out, - int *guarded_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) { if (index < 0 || index >= s_count) return 0; const Entry *e = &s_entries[index]; - if (id_out) { memcpy(id_out, e->id, FO_MAX_ID); } + /* 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; diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 9c309083b..55401fd47 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1444,6 +1444,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 (Beetle libretro.cpp:874 diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 4378a1797..1ccc3c9c2 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1086,6 +1086,20 @@ bool mod_runtime_clear_for_netplay(std::string* error) { s.disc_enabled = false; s.disc_guard_failed = 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; @@ -1243,13 +1257,9 @@ extern "C" void mod_runtime_activate_plugins(void) { return plugin.id == pending.plugin; }); if (!selected) continue; - const int rc = - pending.n_guard - ? func_override_add_guarded(pending.id.c_str(), - pending.address, pending.fn, - pending.guard, pending.n_guard) - : func_override_add(pending.id.c_str(), pending.address, - pending.fn); + 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.armed = (rc == FO_OK); } func_override_install(); diff --git a/runtime/tests/test_func_override.c b/runtime/tests/test_func_override.c new file mode 100644 index 000000000..70affbde1 --- /dev/null +++ b/runtime/tests/test_func_override.c @@ -0,0 +1,438 @@ +/* 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; +} + +/* 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) == 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) == FO_ERR_ARGS, + "address normalising to phys 0 must be refused"); + CHECK(func_override_add("t.zero2", 0x00000000u, impl_handles) == 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) + == 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) == FO_ERR_ARGS, + "guard word count over the cap must be refused"); + CHECK(func_override_add_guarded("t.gnull", 0x80001000u, impl_handles, NULL, + 2) == 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) == 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) == FO_ERR_DUPLICATE, + "same address twice must be refused"); + CHECK(func_override_add("t.second_phys", addr & 0x1FFFFFFFu, impl_declines) + == 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) == FO_OK, "add handled"); + CHECK(func_override_add("t.declined", declined_at, impl_declines) == 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)) + 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) + == 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)) + 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_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_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) == FO_OK, + "direct add"); + CHECK(func_override_add_package("pkg.feature:a", pkg_at, impl_handles, NULL, 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)) + 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) + == 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)) + 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) == 0, + "negative index must fail"); + CHECK(func_override_get_ex(func_override_count(), NULL, 0, NULL, NULL, NULL, + NULL) == 0, + "out-of-range index must fail"); +} + +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(); + + 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; +} From b35d946414dae1a2b0e51b56996b060ba75d3c93 Mon Sep 17 00:00:00 2001 From: Henrique Guedes Date: Mon, 31 Aug 2026 00:34:32 -0300 Subject: [PATCH 5/5] func_override: cycle credit is a required registration argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the open policy question: a handled override replaced code that took guest time on hardware, and every device schedules against that clock, so zero-credit-by-default silently shifts IRQ phase for the rest of the session. Every registration now states its timing intent — there is no default to inherit. Two forms: credit >= 0 charged via 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 visible — right for a player-toggled mod whose behavior has no hardware analog. FO_CREDIT_SELF the tier charges nothing; the body owns its timing and calls psx_advance_cycles itself. Required for data-dependent costs (base + per_iteration * n, the bios_hle.c TIMING NOTE pattern) and for wraps: the original re-dispatched by call_original accrues its exact cycles by executing, so a fixed nonzero credit on a wrap would double-count. Any other negative credit is FO_ERR_ARGS (a typo, not a policy). The declared credit is surfaced per entry by func_override_get_ex and the func_override TCP command ("self" or the number), so timing intent is inspectable, not just documented. Credits are approximations of the original's dynamic instruction count unless measured; LLE remains the timing oracle (decline the override and sample cycle deltas across the call boundary via the callret ring to measure a real distribution). Tests: credit below FO_CREDIT_SELF refused; a handled call charges exactly the declared credit; a decline charges nothing; SELF and 0 charge nothing from the tier; get_ex reports both forms. Verified by mutation (suppressing the charge fails the suite). The suite gains cycle-core doubles so the charge is observable through psx_cycle_count. Also repairs mod_runtime_test, which has been unbuildable since the tier landed: it links mod_runtime.cpp, which references func_override_add_package/_install, but never linked func_override.c. The target now links the tier with inert doubles, so the package arming/reset paths in mod_runtime.cpp are exercised for real. Restores the func_override row in the curated TCP_COMMANDS.md inventory (lost in the aa924200 merge resolution, which regenerated only the autogenerated index) and documents the credit field; index regenerated, gen_tcp_commands.py --check passes. --- TCP_COMMANDS.md | 5 +- docs/MOD_PACKAGES.md | 7 +- runtime/CMakeLists.txt | 1 + runtime/include/func_override.h | 71 ++++++++++++----- runtime/include/mod_plugins.h | 11 ++- runtime/src/debug_server.c | 16 +++- runtime/src/func_override.c | 40 +++++++--- runtime/src/mod_runtime.cpp | 8 +- runtime/tests/test_func_override.c | 123 +++++++++++++++++++++++------ runtime/tests/test_mod_runtime.cpp | 17 ++++ 10 files changed, 235 insertions(+), 64 deletions(-) diff --git a/TCP_COMMANDS.md b/TCP_COMMANDS.md index 18c23e819..f8bdf6561 100644 --- a/TCP_COMMANDS.md +++ b/TCP_COMMANDS.md @@ -49,6 +49,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `write_ram` | ✓ | ✓ | `addr`, `val` | Write **one byte** to PS1 address space. Note the parameter is `val` (not `hex`), and the write is a single byte per call — this row previously documented both incorrectly | | `read_scratch` | | ✓ | `addr`, `len` | Read PS1 scratchpad (0x1F800000 region) | | `read_vram` / `vram_peek` | ✓¹ | ✓ | `x`, `y`, `w`, `h` | Read 16-bit VRAM pixels (max 128×128) | +| `func_override` | ✓ | | — | Inventory of armed function overrides (`func_override.h`): per entry `id`, guest `addr`, `calls`, `guard_misses`, `guarded`, `credit` (the declared cycle policy — a per-handled-call charge, or `"self"` when the body charges its own). `calls` counts **consults**, declines included — so a decline-only probe proves an address crosses a hooked path, and `calls: 0` means the override was never reached (wrong address, or that path never ran). Package-gated overrides appear only after the mod plan arms them; an id may read `plugin:label` when one plugin registers several overrides | | `gpu_state` | ✓ | ✓ | — | Display area, display depth, draw offset, GPUSTAT, clip rect, xfer state | | `screenshot_hires` | | ✓ | `path` | PNG of the **supersampled** surface (the present path the window uses), at `display × gr_scale()`. ⚠ `screenshot`/`screenshot_file` capture native 15-bit VRAM and are **blind to anything that only exists in the hi-res mirror** — geometry correction, SSAA edges, perspective UVs — so they show a clean frame while the player sees a broken one. Use this one to verify those. Falls back to the native resolve (and reports `scale: 1`) when no hi-res surface exists | | `geom_correction` | | ✓ | — | `[video] geometry_correction` / `perspective_texturing` engagement: enable flag plus free-running `geometry_vertex_hits` and `perspective_triangles` totals. Both enhancements silently fall back to the faithful path on anything they cannot prove is projected geometry, so a zero counter with the flag on means the title never qualifies — sample twice and diff for a rate | @@ -274,7 +275,7 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i **306 commands registered** — 293 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -49 of 306 have prose above; **257 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. +50 of 306 have prose above; **256 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. @@ -388,7 +389,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `frame_range` | ✓ | ✓ | ✓ | | `frame_timeseries` | ✓ | ✓ | ✓ | | `freeze_check` | ✓ | | | -| `func_override` | ✓ | | | +| `func_override` | ✓ | | ✓ | | `game_options` | ✓ | | | | `geom_correction` | ✓ | | ✓ | | `get_frame` | ✓ | ✓ | ✓ | diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 38dc7337b..8c8c7aa9e 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -300,9 +300,12 @@ 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. The full execution contract (guest ABI, decline semantics, +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 constraints) is documented in +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 diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 45fc25b4d..cd0d7b0cf 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -236,6 +236,7 @@ if(BUILD_TESTING) add_executable(mod_runtime_test tests/test_mod_runtime.cpp src/mod_runtime.cpp + src/func_override.c src/mod_packages.cpp src/disc_path.cpp src/cue_sheet.cpp diff --git a/runtime/include/func_override.h b/runtime/include/func_override.h index 2dc650252..3e7362597 100644 --- a/runtime/include/func_override.h +++ b/runtime/include/func_override.h @@ -65,21 +65,37 @@ * desync replays. Reading host config that never changes mid-session is * fine. * - * CYCLE ACCOUNTING — UNRESOLVED, READ THIS BEFORE USING THE TIER. - * A handled override credits NO guest cycles for the code it skipped, so - * replacing a function collapses its guest-time cost to zero and shifts IRQ - * phase. On timing-sensitive paths prefer wrapping (call the original via - * func_override_call_original, then adjust) over wholesale replacement. - * - * There is NO technical blocker to charging cycles: psx_advance_cycles() is - * public in runtime/include/psx_cycles.h and the adjacent BIOS HLE tier - * already charges per service with it (bios_hle.c, under its own TIMING - * NOTE). What is unresolved is POLICY — whether a credit argument should be - * required at registration, and what a wrap should charge given the original - * accrues its own cycles. Until that is settled, treat zero-credit as a - * known faithfulness gap: acceptable for a player-toggled mod, NOT yet - * justified for an always-on reimplementation claiming to be - * indistinguishable from the code it replaces. See FAITHFUL_TIMING_PLAN. + * 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 * @@ -104,6 +120,11 @@ extern "C" { #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 @@ -118,8 +139,11 @@ 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. */ -int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn); + * 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 @@ -129,7 +153,8 @@ int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn); * 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); + 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 @@ -163,7 +188,8 @@ void func_override_call_original(struct CPUState *cpu); * 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); + 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). @@ -186,13 +212,16 @@ 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. */ + * 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); + uint64_t *guard_misses_out, int *guarded_out, + int32_t *credit_out); #ifdef __cplusplus } diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 6a5d78afc..a45570091 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 @@ -40,12 +42,19 @@ void psx_mod_function_entry(struct CPUState* cpu, uint32_t address); * 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); + int n_words, int32_t credit); /* Narrow guest services available to trusted plugin callbacks. */ int psx_mod_game_started(void); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index f7a25ed42..0bf91eb80 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8887,7 +8887,7 @@ static void handle_func_override(int id, const char *json) 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); + int *guarded_out, int32_t *credit_out); (void)json; char buf[16 * 1024]; int n = snprintf(buf, sizeof(buf), @@ -8898,9 +8898,17 @@ static void handle_func_override(int id, const char *json) 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)) + &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 @@ -8909,10 +8917,10 @@ static void handle_func_override(int id, const char *json) * 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}", + "\"guard_misses\":%llu,\"guarded\":%d,\"credit\":%s}", i ? "," : "", oid, addr, (unsigned long long)calls, (unsigned long long)misses, - guarded); + guarded, creditstr); if (w < 0) break; n += w; if ((size_t)n >= sizeof(buf) - 256) { diff --git a/runtime/src/func_override.c b/runtime/src/func_override.c index edfcf63f4..9575d04d2 100644 --- a/runtime/src/func_override.c +++ b/runtime/src/func_override.c @@ -12,6 +12,7 @@ #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 @@ -39,6 +40,8 @@ typedef struct { 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]; @@ -63,11 +66,16 @@ static Entry *find(uint32_t phys) } static int add_common(const char *id, uint32_t addr, FuncOverrideFn fn, - const uint32_t *guard, int n_guard, int package) + 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); @@ -91,28 +99,32 @@ static int add_common(const char *id, uint32_t addr, FuncOverrideFn 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) +int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn, + int32_t credit) { - return add_common(id, addr, fn, NULL, 0, 0); + 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) + 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); + 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) + 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); + 1, credit); } /* The hook. Runs on EVERY dispatch, so the common path — nothing registered @@ -158,6 +170,14 @@ static int hook(CPUState *cpu, uint32_t 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; } } @@ -233,12 +253,13 @@ 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, 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) + 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]; @@ -252,5 +273,6 @@ int func_override_get_ex(int index, char *id_out, size_t id_cap, 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/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 1ccc3c9c2..97e1aa158 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -99,6 +99,7 @@ struct FunctionOverridePlugin { 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; }; @@ -1259,7 +1260,8 @@ extern "C" void mod_runtime_activate_plugins(void) { 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.n_guard ? pending.guard : nullptr, pending.n_guard, + pending.credit); pending.armed = (rc == FO_OK); } func_override_install(); @@ -1358,11 +1360,12 @@ extern "C" int psx_mod_register_function_entry_plugin( extern "C" int psx_mod_register_function_override( const char* id, uint32_t address, PSXModFunctionOverrideFn fn, - const uint32_t* expected_words, int n_words) { + 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 ':'. @@ -1386,6 +1389,7 @@ extern "C" int psx_mod_register_function_override( 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). */ diff --git a/runtime/tests/test_func_override.c b/runtime/tests/test_func_override.c index 70affbde1..c48fbc388 100644 --- a/runtime/tests/test_func_override.c +++ b/runtime/tests/test_func_override.c @@ -45,6 +45,20 @@ uint32_t psx_peek_word_untraced(uint32_t addr) 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. */ @@ -138,35 +152,35 @@ static void test_install_is_null_until_registered(void) static void test_add_rejects_bad_input(void) { - CHECK(func_override_add("t.null_fn", 0x80001000u, NULL) == FO_ERR_ARGS, + 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) == FO_ERR_ARGS, + 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) == FO_ERR_ARGS, + 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) + 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) == FO_ERR_ARGS, + 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) == FO_ERR_ARGS, + 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) == FO_OK, + 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) == FO_ERR_DUPLICATE, + 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) + CHECK(func_override_add("t.second_phys", addr & 0x1FFFFFFFu, impl_declines, 0) == FO_ERR_DUPLICATE, "same address in physical form must also be refused"); } @@ -179,8 +193,8 @@ static void test_handled_and_declined_both_count_as_consults(void) const uint32_t handled_at = 0x80003000u; const uint32_t declined_at = 0x80003100u; - CHECK(func_override_add("t.handled", handled_at, impl_handles) == FO_OK, "add handled"); - CHECK(func_override_add("t.declined", declined_at, impl_declines) == FO_OK, "add declined"); + 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"); @@ -202,7 +216,7 @@ static void test_handled_and_declined_both_count_as_consults(void) uint64_t calls = 0, misses = 0; int guarded = -1; if (!func_override_get_ex(i, id, sizeof(id), &addr, &calls, &misses, - &guarded)) + &guarded, NULL)) continue; if (addr == (handled_at & 0x1FFFFFFFu)) { found_handled = 1; @@ -230,7 +244,7 @@ static void test_guard_declines_on_mismatch(void) /* 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) + CHECK(func_override_add_guarded("t.guarded", addr, impl_handles, prologue, 2, 0) == FO_OK, "guarded add must succeed"); func_override_install(); @@ -257,7 +271,8 @@ static void test_guard_declines_on_mismatch(void) 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)) + 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); @@ -273,7 +288,7 @@ static void test_call_original_is_one_shot(void) reset_all(); const uint32_t addr = 0x80004000u; - CHECK(func_override_add("t.wrap", addr, impl_wraps) == FO_OK, "add wrap"); + 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"); @@ -305,7 +320,7 @@ static void test_bypass_is_consumed_so_recursion_reconsults(void) reset_all(); const uint32_t addr = 0x80004100u; - CHECK(func_override_add("t.wrap_rec", addr, impl_wraps) == FO_OK, + CHECK(func_override_add("t.wrap_rec", addr, impl_wraps, FO_CREDIT_SELF) == FO_OK, "add recursive wrap"); func_override_install(); @@ -343,9 +358,9 @@ static void test_package_reset_drops_only_package_entries(void) const uint32_t direct_at = 0x80005000u; const uint32_t pkg_at = 0x80005100u; - CHECK(func_override_add("t.direct", direct_at, impl_handles) == FO_OK, + 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) + CHECK(func_override_add_package("pkg.feature:a", pkg_at, impl_handles, NULL, 0, 0) == FO_OK, "package add"); func_override_install(); @@ -372,7 +387,8 @@ static void test_package_reset_drops_only_package_entries(void) 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)) + if (!func_override_get_ex(i, id, sizeof(id), &a, &calls, NULL, NULL, + NULL)) continue; if (a == (direct_at & 0x1FFFFFFFu)) { seen_direct = 1; @@ -385,7 +401,7 @@ static void test_package_reset_drops_only_package_entries(void) 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) + 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"); } @@ -399,7 +415,8 @@ static void test_get_ex_bounded_id_copy(void) 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)) + 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"); @@ -408,13 +425,72 @@ static void test_get_ex_bounded_id_copy(void) } CHECK(checked, "expected at least one entry to enumerate"); - CHECK(func_override_get_ex(-1, NULL, 0, NULL, NULL, NULL, NULL) == 0, + 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) == 0, + 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(); @@ -427,6 +503,7 @@ int main(void) 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"); diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index b9e608d3b..f0e3092a8 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -61,6 +61,23 @@ extern "C" int psx_ws_x_margin(void) { return 0; } extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {} extern "C" int fntrace_is_game_started(void) { return 1; } +/* 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; } +} + static void test_vblank_plugin(void) { plugin_calls++; }