Merge pull request #174: function override tier

Reviewed repaired head d486a78acf0fcccbedbae9479551abd28a9802cf; combined regression evidence and owner visual acceptance recorded on review/psx-prs-20261024. Owner explicitly authorized merging the six reviewed ready PRs.
This commit is contained in:
Matt Stanley
2026-10-24 12:23:27 -07:00
committed by GitHub
20 changed files with 1518 additions and 6 deletions
+23
View File
@@ -538,6 +538,22 @@ frames. Trusted callbacks receive only the narrow C services exposed by
`runtime/include/mod_plugins.h`. Games should continue to use declarative
patches and overlays when those operations are sufficient.
An implementation may also register **function overrides** under a plugin id
(`psx_mod_register_function_override`): hand-written C that replaces or wraps
a guest function at a given address, with an optional prologue-word residency
guard. Registration only queues the override; it is ARMED into the dispatcher
tier when the resolved plan selects that plugin — the same gating as the other
callback kinds, so an override-only plugin id counts as available to the
resolver. Every registration states a required guest-cycle `credit` (a fixed
per-handled-call charge, `0` for a mod with no hardware analog, or
`FO_CREDIT_SELF` when the body — or a wrapped original — accounts for its own
time). The full execution contract (guest ABI, decline semantics,
`func_override_call_original` wrap primitive, `func_override_guest_call`,
determinism and cycle-accounting policy) is documented in
`runtime/include/func_override.h`. Overrides registered directly through
`func_override_add` (game `EXTRAS_SOURCES` constructors, the progressive-
decompilation idiom) bypass package gating and are always active; packages
are the right home for anything a player should be able to toggle.
### Session starts and mod-owned state
What a plugin sets up should last only for a session whose resolved plan
@@ -1166,3 +1182,10 @@ existing post-commit path, not the preparation worker.
The `prepare_resources` method is separate from read-only `resolve`, so editing
launcher settings does not trigger conversions. Runtime activation still sees
only fully verified immutable resources.
Function-override package arming is transactional and runs before activation
callbacks. Address collisions or capacity failures reject the launch through
`mod_runtime_activation_error()` and remove all package entries, retaining
always-on direct implementations. Every session rearms its selected plan.
Replacement overrides cannot be selected as netplay local-view plugins: they
change shared execution rather than only sandboxed presentation.
+3 -2
View File
@@ -493,9 +493,9 @@ between Play and TCP availability.
## Complete command index (generated)
**386 commands registered** — 373 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
**387 commands registered** — 374 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
85 of 386 have prose above; **301 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":"<name>"}` and read the reply, or find its `handle_*` function in the server source.
85 of 387 have prose above; **302 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":"<name>"}` and read the reply, or find its `handle_*` function in the server source.
Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code.
@@ -630,6 +630,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `frame_trace` | ✓ | | |
| `frame_wall_ring` | ✓ | | |
| `freeze_check` | ✓ | | |
| `func_override` | ✓ | | |
| `game_options` | ✓ | | |
| `gcp_ring` | ✓ | | ✓ |
| `geom_correction` | ✓ | | ✓ |
+1 -1
View File
@@ -1 +1 @@
86f7329c2692e76133c40fa11cc8cfac214302d8238e05369dc8b263f4dc76fd
6b3ba0804aeca3d52f6e44dd26ec7a8e82f40edce68ecf01ff14705373aef847
+19
View File
@@ -36,6 +36,13 @@ extern int (*g_psx_bios_hle_hook)(CPUState* cpu, uint32_t phys);
extern uint32_t g_psx_mod_guest_functions;
extern int psx_mod_dispatch_guest_function(CPUState*, uint32_t);
/* Game function-override tier (runtime/include/func_override.h):
* hand-written C registered against a guest address. NULL (the
* default) = no overrides, dispatch identical to a build without
* the tier. Returns 1 when the override handled the call; the
* guest resumes at $ra exactly as if the original ran jr $ra. */
extern int (*g_psx_func_override_hook)(CPUState* cpu, uint32_t phys);
#ifdef PSX_HAS_GAME_DISPATCH
extern int psx_game_address_in_text(uint32_t addr);
#endif
@@ -9111,6 +9118,18 @@ static void psx_dispatch_impl(CPUState* cpu, uint32_t addr, uint32_t stop_addr)
/* Byte-guarded A0/B0/C0 call-vector tail stubs. */
if (!found && psx_bios_try_native_call_stub(cpu, addr))
found = 1;
/* Function-override tier: AFTER the BIOS tiers (an override
* can never shadow a kernel service vector), BEFORE every
* game code backend, so one address-keyed hook covers the
* static EXE, runtime-loaded overlays and dirty RAM alike.
* Handled (rc 1) => the override completed against guest
* state; resume at $ra via the trampoline's normal
* return/tail contract. rc 0 => fall through untouched. */
if (!found && g_psx_func_override_hook &&
g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu)) {
cpu->pc = cpu->gpr[31];
found = 1;
}
#ifdef PSX_HAS_GAME_DISPATCH
/* Game EXEs can overlap the BIOS shell copy window at
* physical 0x30000-0x5AFFF. If the target belongs to the
+18
View File
@@ -1839,6 +1839,12 @@ void FullFunctionEmitter::emit_dispatch(
out += "extern int (*g_psx_bios_hle_hook)(CPUState* cpu, uint32_t phys);\n\n";
out += "extern uint32_t g_psx_mod_guest_functions;\n";
out += "extern int psx_mod_dispatch_guest_function(CPUState*, uint32_t);\n\n";
out += "/* Game function-override tier (runtime/include/func_override.h):\n";
out += " * hand-written C registered against a guest address. NULL (the\n";
out += " * default) = no overrides, dispatch identical to a build without\n";
out += " * the tier. Returns 1 when the override handled the call; the\n";
out += " * guest resumes at $ra exactly as if the original ran jr $ra. */\n";
out += "extern int (*g_psx_func_override_hook)(CPUState* cpu, uint32_t phys);\n\n";
out += "#ifdef PSX_HAS_GAME_DISPATCH\n";
out += "extern int psx_game_address_in_text(uint32_t addr);\n";
out += "#endif\n\n";
@@ -2285,6 +2291,18 @@ void FullFunctionEmitter::emit_dispatch(
out += " /* Byte-guarded A0/B0/C0 call-vector tail stubs. */\n";
out += " if (!found && psx_bios_try_native_call_stub(cpu, addr))\n";
out += " found = 1;\n";
out += " /* Function-override tier: AFTER the BIOS tiers (an override\n";
out += " * can never shadow a kernel service vector), BEFORE every\n";
out += " * game code backend, so one address-keyed hook covers the\n";
out += " * static EXE, runtime-loaded overlays and dirty RAM alike.\n";
out += " * Handled (rc 1) => the override completed against guest\n";
out += " * state; resume at $ra via the trampoline's normal\n";
out += " * return/tail contract. rc 0 => fall through untouched. */\n";
out += " if (!found && g_psx_func_override_hook &&\n";
out += " g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu)) {\n";
out += " cpu->pc = cpu->gpr[31];\n";
out += " found = 1;\n";
out += " }\n";
out += "#ifdef PSX_HAS_GAME_DISPATCH\n";
out += " /* Game EXEs can overlap the BIOS shell copy window at\n";
out += " * physical 0x30000-0x5AFFF. If the target belongs to the\n";
+11
View File
@@ -1282,6 +1282,16 @@ if(BUILD_TESTING)
target_include_directories(bios_hle_plan_test PRIVATE include)
add_test(NAME bios_hle_plan_test COMMAND bios_hle_plan_test)
# Every func_override failure mode is silent at runtime: a registration
# that is never consulted, a package override that survives clear-mods
# (a netplay divergence), a residency guard that corrupts instead of
# declining. Drives the real g_psx_func_override_hook pointer.
add_executable(func_override_test
tests/test_func_override.c
src/func_override.c)
target_include_directories(func_override_test PRIVATE include)
add_test(NAME func_override_test COMMAND func_override_test)
# A cue, conventional raw image, or Steam .car image is a valid pick; each
# must mount the same disc and identify off the same data track.
add_executable(sbi_setup_test tests/test_sbi_setup.cpp src/psx_sha256.c)
@@ -1411,6 +1421,7 @@ if(BUILD_TESTING)
src/mod_immediate.c
src/host_overlay.c
tests/test_mod_runtime.cpp
src/func_override.c
src/iso_reader_c.cpp
src/host_span_ring.c
src/mod_runtime.cpp
+241
View File
@@ -0,0 +1,241 @@
/* func_override.h — replace a guest function with hand-written C.
*
* WHY THIS EXISTS
*
* A recomp turns the game into C, but that C is a build artifact: regenerated
* from the player's own disc, never hand-edited (CLAUDE.md rule 4). So there
* is no supported way to say "this function should do something else" — which
* is the single most important thing a port needs to grow beyond a faithful
* replay of the original.
*
* This is that mechanism. Register hand-written C against a guest address and
* the dispatcher calls yours instead of the recompiled original.
*
* NO SIZE LIMIT. This is not a ROM patch; there is no slot to fit inside, no
* code cave, nothing to shift. Your replacement is native code in a native
* binary — five instructions or five thousand lines. That freedom is the
* entire point of recompiling rather than hacking bytes.
*
* WHERE IT HOOKS, AND WHY IT MATTERS
*
* The hook is consulted in psx_dispatch_impl after the BIOS tiers (so an
* override can never shadow a kernel service vector) and BEFORE every game
* code backend. That placement is deliberate and load-bearing: one
* address-keyed hook covers the STATIC EXE, runtime-loaded OVERLAYS and
* DIRTY RAM alike. The interpreter's JAL/JALR call-resolution tiers consult
* it on the SAME terms — before enter-compiled, before overlay-native and
* before the local pc-chain (the reserved CRES_OVERRIDE slot) — so no call
* the interpreter resolves itself can bypass an override. Ordering there is
* not cosmetic: interp_enter_compiled calls psx_dispatch_game_compiled ->
* entry->fn(cpu) directly and never re-enters psx_dispatch_impl, so a hook
* placed after it is unreachable for any override on a statically-compiled
* function reached from interpreted code, while registration and the
* func_override inventory both still look healthy.
*
* SCOPE: overrides are CALL-site keyed. A guest tail transfer (j / jr) into
* an overridden address from the dirty-RAM block loop is a TRANSFER, not a
* call, and does not consult the hook — see dirty_ram_interp.c's compiled
* handoff sites. Registering on a function only ever entered by tail
* transfer will show calls == 0.
*
* THE CONTRACT
*
* Your function receives the CPUState and must obey the guest ABI:
*
* int my_impl(CPUState *cpu) {
* uint32_t a0 = cpu->gpr[4], a1 = cpu->gpr[5]; // arguments
* ...
* cpu->gpr[2] = result; // $v0 = return value
* return 1;
* }
*
* Return 1 and the guest resumes at $ra exactly as if the original had run
* `jr $ra`. Return 0 and the original recompiled or interpreted code runs
* untouched, so an override can decline case by case (a conditional
* pre-hook: mutate state, return 0, original runs).
*
* POINTERS MUST BE GUEST ADDRESSES. The caller is recompiled too: when it
* dereferences your return value it performs a guest load. A host pointer
* will read garbage. If you need to hand back a buffer, write it into guest
* RAM (psx_write_byte and friends, or psx_mod_alloc_guest_memory) and return
* that address.
*
* DETERMINISM. Keep ALL mutable state in guest RAM. Rollback/rewind and
* netplay snapshot guest state only; host-side statics in an override
* desync replays. Reading host config that never changes mid-session is
* fine.
*
* CYCLE ACCOUNTING — REQUIRED AT REGISTRATION.
* On hardware the replaced function took guest time, and every device in
* the machine schedules against that clock; a replacement that takes zero
* cycles shifts IRQ phase for the rest of the session. So every
* registration MUST state its timing intent via the `credit` argument —
* there is no default to inherit. Two forms:
*
* credit >= 0 The tier charges exactly `credit` guest cycles
* (psx_advance_cycles) on every HANDLED call, after
* the body runs. Declines charge nothing — the
* original runs and accrues its own cycles. 0 is
* legal and means "deliberately free": right for a
* player-toggled mod whose behavior has no hardware
* analog, indefensible for an always-on
* reimplementation claiming to be indistinguishable.
*
* FO_CREDIT_SELF The tier charges nothing; the BODY owns its timing
* and calls psx_advance_cycles() itself. Use this
* when the original's cost is data-dependent
* (charge base + per_iteration * n, the bios_hle.c
* TIMING NOTE pattern), and ALWAYS for wraps: the
* original re-dispatched by func_override_call_original
* accrues its exact cycles by executing, so a fixed
* nonzero credit on a wrap double-counts. Guest code
* invoked via func_override_guest_call likewise
* self-charges — credit only the code you REPLACED.
*
* Credits are approximations of the original's dynamic instruction count
* unless measured; LLE remains the timing oracle (run with the override
* declined and sample the cycle delta across the call boundary — the
* callret ring — to measure a real distribution). See FAITHFUL_TIMING_PLAN.
*
* WHAT IT DOES NOT DO
*
* It does not lift the game's own assumptions. If the caller copies your
* result into a fixed-size field, that field is still fixed. The override
* removes the code limit, not every limit.
*/
#ifndef PSXRECOMP_FUNC_OVERRIDE_H
#define PSXRECOMP_FUNC_OVERRIDE_H
#include <stddef.h>
#include <stdint.h>
struct CPUState;
#ifdef __cplusplus
extern "C" {
#endif
#define FO_MAX_OVERRIDES 128
#define FO_MAX_ID 64
#define FO_MAX_GUARD_WORDS 4
/* `credit` value: the override body states its own timing by calling
* psx_advance_cycles() itself (mandatory for wraps — see CYCLE ACCOUNTING
* above). Any other negative value is FO_ERR_ARGS. */
#define FO_CREDIT_SELF (-1)
/* func_override_add return codes. */
#define FO_OK 0
#define FO_ERR_FULL 1
#define FO_ERR_ARGS 2
#define FO_ERR_DUPLICATE 3
/* An override implementation. Reads arguments from cpu->gpr[4..7], writes
* the return value to cpu->gpr[2]. Returning nonzero means "handled";
* returning 0 declines this call and the original code runs instead. */
typedef int (*FuncOverrideFn)(struct CPUState *cpu);
/* Register `fn` for guest address `addr` (KSEG or physical — normalised).
* `id` is copied, used for diagnostics, and may be NULL. Registering the
* same address twice is an error rather than a silent last-wins, because a
* silent shadow is impossible to debug. `credit` is the guest-cycle policy
* (CYCLE ACCOUNTING above): >= 0 charged per handled call, or
* FO_CREDIT_SELF when the body charges its own. */
int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn,
int32_t credit);
/* Like func_override_add, plus a residency guard: before each consult the
* first `n_words` guest words at `addr` are compared against
* `expected_words`; on mismatch the override silently declines (the guard
* miss is counted, see func_override_get_ex). Use this when the address
* belongs to overlay/dirty RAM that other code may occupy at other times —
* the guard makes "wrong code resident" a decline instead of a corruption.
* n_words must be 1..FO_MAX_GUARD_WORDS. */
int func_override_add_guarded(const char *id, uint32_t addr, FuncOverrideFn fn,
const uint32_t *expected_words, int n_words,
int32_t credit);
/* Call a guest function from inside an override (or any native code on the
* dispatch thread): args already placed in cpu->gpr[4..7], returns after the
* callee completes; result in cpu->gpr[2]. `site_ra` is the return address
* the callee should observe in $ra — pass the original call site's return
* address when re-creating a call the original code would have made, so
* callees, fntrace and crash forensics see authentic values. $ra is
* restored around the call. */
void func_override_guest_call(struct CPUState *cpu, uint32_t target,
uint32_t site_ra);
/* Run the ORIGINAL code of the function currently being overridden — the
* wrap primitive. Callable only from inside an executing override; arms a
* one-shot bypass for this address, re-dispatches it, and returns after the
* original completes (result in cpu->gpr[2]). Typical post-hook:
*
* int wrap(CPUState *cpu) {
* func_override_call_original(cpu); // original runs fully
* ...inspect / adjust results...
* return 1; // we handled the call
* }
*
* The bypass is one-shot per invocation: if the original recursively calls
* itself, the recursive calls consult the override again (matching what a
* guest-level wrap would observe). */
void func_override_call_original(struct CPUState *cpu);
/* Register on behalf of a mod package plan. Identical to the two calls above
* (pass n_words 0 for unguarded) except the entry is tagged package-armed, so
* func_override_reset_package_armed can drop it when the plan is cleared.
* The mod runtime uses this; game code registering its own always-on
* reimplementations should use func_override_add/_add_guarded. */
int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn,
const uint32_t *expected_words, int n_words,
int32_t credit);
/* Scoped package variant. The owner supplies a stable plan context and
* invokes fn inside that plugin's resource/completion context. The plan must
* disarm package entries before replacing that context. */
typedef int (*FuncOverrideInvoker)(struct CPUState *cpu, FuncOverrideFn fn,
const void *context);
int func_override_add_package_scoped(const char *id, uint32_t addr, FuncOverrideFn fn,
const uint32_t *expected_words, int n_words, int32_t credit,
FuncOverrideInvoker invoke, const void *context);
/* Nonzero while a native override stack is live, including nested guest
* calls. Save/load and rewind must defer until this returns to zero. */
int func_override_active(void);
/* Install the dispatcher hook. Call once at startup AFTER registering (the
* mod runtime calls it again after arming package-gated overrides — safe).
* Cheap with nothing registered — it leaves the hook NULL, so dispatch is
* byte-identical to a build without the tier. */
void func_override_install(void);
/* Drop every package-armed override, keep direct registrations, re-install
* (so the hook goes back to NULL if the table empties). Returns how many were
* dropped. THIS IS REQUIRED FOR CORRECTNESS, not a convenience: a cleared mod
* plan stops activation/vblank callbacks simply by no longer being iterated,
* but an armed override lives in this module's own table, so without an
* explicit reset it keeps firing after the plan is gone — including after
* netplay's clear-mods announces a vanilla session, which is a peer
* divergence. Direct registrations are deliberately kept: they are the game's
* own always-on faithful reimplementations, present identically in both
* peers' builds and not player-selectable. */
int func_override_reset_package_armed(void);
/* Introspection, surfaced by the `func_override` TCP command. An override
* whose `calls` stays 0 was never reached: wrong address, or that code path
* never ran. `guard_misses` counts consults declined by the residency
* guard. `credit_out` reports the declared cycle policy (FO_CREDIT_SELF or
* the fixed per-call charge), so every armed override's timing intent is
* inspectable, not just documented. */
int func_override_count(void);
int func_override_get(int index, char *id_out, size_t id_cap,
uint32_t *addr_out, uint64_t *calls_out);
int func_override_get_ex(int index, char *id_out, size_t id_cap,
uint32_t *addr_out, uint64_t *calls_out,
uint64_t *guard_misses_out, int *guarded_out,
int32_t *credit_out);
#ifdef __cplusplus
}
#endif
#endif /* PSXRECOMP_FUNC_OVERRIDE_H */
+1
View File
@@ -627,6 +627,7 @@ bool mod_register_function_filter_plugin(const std::string& id, uint32_t address
bool mod_register_guest_function_plugin(const std::string& id, uint32_t address,
PSXModFunctionEntryCallback callback);
bool mod_plugin_registered(const std::string& id);
bool mod_register_function_override_marker(const std::string& id);
void mod_invoke_activation_plugin(const std::string& id);
void mod_invoke_vblank_plugin(const std::string& id);
struct ModFunctionEntryHook {
+33
View File
@@ -2,6 +2,8 @@
#include <stdint.h>
#include "func_override.h" /* FO_CREDIT_SELF for the credit argument below */
#ifdef __cplusplus
extern "C" {
#endif
@@ -135,6 +137,37 @@ int psx_mod_function_entry_active(void);
void psx_mod_set_guest_cycle_scale_gate(int open);
/* Live scale: the configured one while every gate is open, else 1. */
uint32_t psx_mod_guest_cycle_scale(void);
/*
* Package-gated function override (see runtime/include/func_override.h for
* the full contract). Registration queues the override under the plugin id;
* it is ARMED into the dispatcher tier only when a resolved package plan
* selects that plugin — the same gating as vblank/activation callbacks. The
* callback obeys the guest ABI (args cpu->gpr[4..7], result cpu->gpr[2]);
* return 1 = handled (guest resumes at $ra), 0 = decline (original runs).
* The owning plugin resource context is active throughout the callback and
* restored across nested callbacks. Snapshots wait until its native stack
* unwinds. Wrap semantics via func_override_call_original(); optional residency
* guard via expected_words (NULL/0 = unguarded).
*
* `id` may carry an optional ":label" suffix ("pkg.feature:aim") that names
* the override in the `func_override` TCP inventory; gating and manifest
* matching use only the part before the ':', so several overrides can sit
* under one [[plugin]] entry and still read apart in diagnostics.
*
* `credit` is the REQUIRED guest-cycle policy (func_override.h, CYCLE
* ACCOUNTING): >= 0 cycles charged per handled call, or FO_CREDIT_SELF when
* the body charges its own. For a mod whose behavior has no hardware analog
* an explicit 0 is the honest value; a wrap that runs the original via
* func_override_call_original must pass FO_CREDIT_SELF (the original
* self-charges by executing — a fixed credit would double-count).
*/
typedef int (*PSXModFunctionOverrideFn)(struct CPUState* cpu);
int psx_mod_register_function_override(const char* id, uint32_t address,
PSXModFunctionOverrideFn fn,
const uint32_t* expected_words,
int n_words, int32_t credit);
/* Narrow guest services available to trusted plugin callbacks. */
int psx_mod_game_started(void);
/* Read an original mounted-disc file without changing guest CD state/timing.
+2
View File
@@ -113,6 +113,8 @@ void mod_runtime_set_progress_hook(void (*hook)(void));
/* Invokes activation callbacks for the committed plan. Call after the final
* launcher commit and before renderer/window initialization. */
void mod_runtime_activate_plugins(void);
/* NULL on success; a non-NULL arming failure requires refusing this launch. */
const char* mod_runtime_activation_error(void);
void mod_runtime_on_vblank(void);
/* Host VBlank query for the active title's optional disc-request provider. */
int mod_runtime_disc_request(int *ready);
+1
View File
@@ -502,6 +502,7 @@ set(PSXRECOMP_RUNTIME_SOURCES
${PSXRECOMP_ROOT}/runtime/src/autocompile.c
${PSXRECOMP_ROOT}/runtime/src/code_provider.c
${PSXRECOMP_ROOT}/runtime/src/event_ring.c
${PSXRECOMP_ROOT}/runtime/src/func_override.c
${PSXRECOMP_ROOT}/runtime/src/game_options.c
${PSXRECOMP_ROOT}/runtime/src/mod_builtin_speed.c
${PSXRECOMP_ROOT}/runtime/src/mod_builtin_pgxp.c
+56
View File
@@ -11216,6 +11216,61 @@ static void handle_wide_full(int id, const char *json)
id, path, W, H);
}
/* Function-override tier introspection (func_override.h): count + per-entry
* {id, addr, calls, guard_misses}. calls == 0 = never reached (wrong address
* or path never ran); guard_misses = consults declined by the residency
* guard. */
static void handle_func_override(int id, const char *json)
{
extern int func_override_count(void);
extern int func_override_get_ex(int index, char *id_out, size_t id_cap,
uint32_t *addr_out, uint64_t *calls_out,
uint64_t *guard_misses_out,
int *guarded_out, int32_t *credit_out);
(void)json;
char buf[16 * 1024];
int n = snprintf(buf, sizeof(buf),
"{\"id\":%d,\"ok\":true,\"count\":%d,\"overrides\":[",
id, func_override_count());
for (int i = 0; i < func_override_count(); i++) {
char oid[64];
uint32_t addr = 0;
uint64_t calls = 0, misses = 0;
int guarded = 0;
int32_t credit = 0;
char creditstr[16];
if (!func_override_get_ex(i, oid, sizeof(oid), &addr, &calls,
&misses, &guarded, &credit))
break;
/* credit: the declared cycle policy — a number (fixed per-call
* charge) or the string "self" (the body charges its own). */
if (credit < 0)
snprintf(creditstr, sizeof(creditstr), "\"self\"");
else
snprintf(creditstr, sizeof(creditstr), "%d", credit);
/* Clamp before advancing: snprintf returns the length it WOULD have
* written, so on truncation an unclamped n exceeds sizeof(buf) and
* the next sizeof(buf) - n underflows to a huge size_t. The 256-byte
* headroom check below keeps that unreachable at today's caps, but
* raising FO_MAX_ID or FO_MAX_OVERRIDES must not silently turn this
* into an overflow. */
const int w = snprintf(buf + n, sizeof(buf) - (size_t)n,
"%s{\"id\":\"%s\",\"addr\":\"0x%08X\",\"calls\":%llu,"
"\"guard_misses\":%llu,\"guarded\":%d,\"credit\":%s}",
i ? "," : "", oid, addr,
(unsigned long long)calls, (unsigned long long)misses,
guarded, creditstr);
if (w < 0) break;
n += w;
if ((size_t)n >= sizeof(buf) - 256) {
n = (int)(sizeof(buf) - 256);
break;
}
}
snprintf(buf + n, sizeof(buf) - (size_t)n, "]}");
send_fmt("%s", buf);
}
static void handle_vram_peek(int id, const char *json)
{
int x = json_get_int(json, "x", 0);
@@ -16339,6 +16394,7 @@ static const CmdEntry s_commands[] = {
{ "mmx6_freshfix", handle_mmx6_freshfix },
{ "mem_words", handle_mem_words },
{ "vram_peek", handle_vram_peek },
{ "func_override", handle_func_override },
{ "gl_coh_ring", handle_gl_coh_ring },
{ "gl_present_ring", handle_gl_present_ring },
{ "present_ring", handle_present_ring },
+59 -1
View File
@@ -1137,7 +1137,13 @@ enum { XSITE_INTERP = 0, XSITE_DD = 1 };
/* XOP_RES path codes (in the `site` field). */
enum { XRES_EC_BAIL = 2, XRES_EC_PC = 3, XRES_EC_CONTRACT = 4, XRES_EC_RET = 5,
XRES_OV_BAIL = 6, XRES_OV_PC = 7, XRES_OV_CONTRACT = 8, XRES_OV_RET = 9,
XRES_NONLOCAL = 10, XRES_PCCHAIN = 11, XRES_UNDECODABLE = 12 };
XRES_NONLOCAL = 10, XRES_PCCHAIN = 11, XRES_UNDECODABLE = 12,
/* Appended, NOT slotted into consult order: unlike CRES (which had a
* reserved gap at 6) this enum is dense, so inserting would renumber
* the codes above and invalidate every previously captured xprobe
* trace. The override tier is consulted FIRST at both call sites; the
* wire value carries no ordering meaning. */
XRES_OVERRIDE = 13 };
int g_mixed_depth = 0; /* best-effort interp→compiled nesting depth; reset per frame */
@@ -1786,6 +1792,37 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn,
cpu->pc = target;
CRET(CRES_PCCHAIN, 1);
}
/* Function-override tier (func_override.h): consulted BEFORE
* every game code backend, matching psx_dispatch_impl. It must
* precede interp_enter_compiled, not follow it: that tier calls
* psx_dispatch_game_compiled -> entry->fn(cpu) directly and
* never re-enters psx_dispatch_impl, so a hook consulted after
* it is unreachable for any override on a statically-compiled
* function called from interpreted code. Registration would
* still succeed and the func_override inventory would still
* list the entry, so the miss is invisible — the same
* registration-is-not-coverage failure the overlay-native and
* pc-chain tiers below already had. Handled => the override
* completed against guest state; same call contract as a
* compiled callee. */
{
extern int (*g_psx_func_override_hook)(CPUState *cpu,
uint32_t phys);
if (g_psx_func_override_hook) {
cpu->pc = 0;
if (g_psx_func_override_hook(cpu, target & 0x1FFFFFFFu)) {
if (g_psx_call_bail) CRET(CRES_OVERRIDE, 1);
if (cpu->pc != 0) CRET(CRES_OVERRIDE, 1);
if (rd == 0 || rd == 31) {
if (psx_call_contract(cpu, return_pc, site_sp))
CRET(CRES_OVERRIDE, 1);
}
{ int _r = dirty_ram_finish_call_return(cpu, return_pc,
next_pc_out);
CRET(CRES_OVERRIDE | (_r ? 0x100u : 0u), _r); }
}
}
}
#ifdef PSX_HAS_GAME_DISPATCH
cpu->pc = 0;
if (interp_enter_compiled(cpu, target)) {
@@ -2017,6 +2054,27 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn,
/* slice / lockstep-replay / guest span: plain transfer, never execute the
* callee (a span's runner calls it with the full trampoline). */
if (g_precise_mode || g_ls_replay_active || span_forced(pc & 0x1FFFFFFFu)) { cpu->pc = target; return 1; }
/* Function-override tier: same placement and contract as the JALR
* site above — BEFORE interp_enter_compiled, so an override on a
* statically-compiled callee is reachable from interpreted code
* (reserved CRES_OVERRIDE slot). */
{
extern int (*g_psx_func_override_hook)(CPUState *cpu,
uint32_t phys);
if (g_psx_func_override_hook) {
cpu->pc = 0;
if (g_psx_func_override_hook(cpu, target & 0x1FFFFFFFu)) {
if (g_psx_call_bail) { XRES(XRES_OVERRIDE); return 1; }
if (cpu->pc != 0) { XRES(XRES_OVERRIDE); return 1; }
if (psx_call_contract(cpu, return_pc, site_sp)) {
XRES(XRES_OVERRIDE); return 1;
}
XRES(XRES_OVERRIDE);
return dirty_ram_finish_call_return(cpu, return_pc,
next_pc_out);
}
}
}
#ifdef PSX_HAS_GAME_DISPATCH
cpu->pc = 0;
if (interp_enter_compiled(cpu, target)) {
+296
View File
@@ -0,0 +1,296 @@
/* func_override.c — replace a guest function with hand-written C.
* See runtime/include/func_override.h.
*
* CLAUDE.md rule 3 (no printf, no logs): this module prints nothing. State
* is exposed through the accessors and surfaced by the `func_override` TCP
* command. Registration errors are returned as codes for the caller to
* report.
*/
#include "func_override.h"
#include <string.h>
#include "cpu_state.h"
#include "psx_cycles.h"
/* Residency-guard reads go through the UNTRACED peek, never psx_read_word.
* psx_read_word is a traced accessor (memory.c): under lockstep it feeds
* ls_read_hook, under lockstep replay it RETURNS the replayed value instead
* of RAM, and under DuckStation recording it calls ds_note_read. The guard
* is a host-side residency check, not a guest memory access — routing it
* through the traced path injects phantom reads the oracle side never
* performs (dirtying every divergence comparison) and makes the guard
* compare replayed data rather than resident bytes. The peek keeps the full
* address decode, so a guard on a non-RAM address stays correct. */
extern uint32_t psx_peek_word_untraced(uint32_t addr);
/* Set by us, read at the top of every psx_dispatch_impl (emitted by
* recompiler/src/full_function_emitter.cpp) and at the interpreter's
* JAL/JALR call-resolution tiers (dirty_ram_interp.c). NULL until install,
* so a build with no overrides dispatches exactly as before. */
int (*g_psx_func_override_hook)(CPUState *cpu, uint32_t phys) = NULL;
typedef struct {
char id[FO_MAX_ID];
uint32_t phys; /* normalised: KSEG bits masked off */
FuncOverrideFn fn;
FuncOverrideInvoker invoke;
const void *context;
uint64_t calls;
uint64_t guard_misses;
uint32_t guard[FO_MAX_GUARD_WORDS];
int n_guard; /* 0 = unguarded */
int package; /* 1 = armed by a mod package plan */
int32_t credit; /* >= 0 charged per handled call;
FO_CREDIT_SELF = body self-charges */
} Entry;
static Entry s_entries[FO_MAX_OVERRIDES];
static int s_count = 0;
/* call_original support: the address whose hook is currently executing
* (for redial) and a one-shot bypass armed by func_override_call_original.
* Dispatch is single-threaded; a nested override (an override whose
* guest_call reaches another overridden function) saves/restores around
* the inner consult, so plain statics are correct here. */
static uint32_t s_active_phys = 0;
static uint32_t s_bypass_phys = 0;
static uint32_t normalise(uint32_t addr) { return addr & 0x1FFFFFFFu; }
static Entry *find(uint32_t phys)
{
for (int i = 0; i < s_count; i++)
if (s_entries[i].phys == phys)
return &s_entries[i];
return NULL;
}
static int add_common(const char *id, uint32_t addr, FuncOverrideFn fn,
const uint32_t *guard, int n_guard, int package,
int32_t credit)
{
if (!fn) return FO_ERR_ARGS;
if (n_guard < 0 || n_guard > FO_MAX_GUARD_WORDS) return FO_ERR_ARGS;
if (n_guard > 0 && !guard) return FO_ERR_ARGS;
/* The credit is a required statement of timing intent (header, CYCLE
* ACCOUNTING): a fixed per-call charge, or FO_CREDIT_SELF. Any other
* negative value is a typo, not a policy. */
if (credit < FO_CREDIT_SELF) return FO_ERR_ARGS;
if (s_count >= FO_MAX_OVERRIDES) return FO_ERR_FULL;
const uint32_t phys = normalise(addr);
/* phys 0 is the "not inside an override" sentinel for s_active_phys and
* the "no bypass armed" sentinel for s_bypass_phys, so an override there
* would silently break func_override_call_original. No real function
* lives at guest 0 anyway. */
if (phys == 0) return FO_ERR_ARGS;
/* Two overrides on one address is always a mistake, and a silent
* last-wins would be untraceable. Refuse it. */
if (find(phys)) return FO_ERR_DUPLICATE;
Entry *e = &s_entries[s_count];
memset(e, 0, sizeof(*e));
if (id) {
strncpy(e->id, id, FO_MAX_ID - 1);
e->id[FO_MAX_ID - 1] = '\0';
}
e->phys = phys;
e->fn = fn;
for (int i = 0; i < n_guard; i++) e->guard[i] = guard[i];
e->n_guard = n_guard;
e->package = package ? 1 : 0;
e->credit = credit;
s_count++;
return FO_OK;
}
int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn,
int32_t credit)
{
return add_common(id, addr, fn, NULL, 0, 0, credit);
}
int func_override_add_guarded(const char *id, uint32_t addr, FuncOverrideFn fn,
const uint32_t *expected_words, int n_words,
int32_t credit)
{
if (n_words < 1) return FO_ERR_ARGS;
return add_common(id, addr, fn, expected_words, n_words, 0, credit);
}
int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn,
const uint32_t *expected_words, int n_words,
int32_t credit)
{
if (n_words < 0 || n_words > FO_MAX_GUARD_WORDS) return FO_ERR_ARGS;
return add_common(id, addr, fn, n_words ? expected_words : NULL, n_words,
1, credit);
}
int func_override_add_package_scoped(const char *id, uint32_t addr, FuncOverrideFn fn,
const uint32_t *expected_words, int n_words, int32_t credit,
FuncOverrideInvoker invoke, const void *context)
{
if (!invoke || !context) return FO_ERR_ARGS;
const int rc = func_override_add_package(id,addr,fn,expected_words,n_words,credit);
if (rc == FO_OK) {
Entry *entry = &s_entries[s_count-1];
entry->invoke = invoke;
entry->context = context;
}
return rc;
}
int func_override_active(void) { return s_active_phys != 0; }
/* The hook. Runs on EVERY dispatch, so the common path — nothing registered
* for this address — must stay cheap. A linear scan over a handful of
* entries beats a hash for realistic counts and keeps the code honest;
* revisit if a game ever registers hundreds. */
static int hook(CPUState *cpu, uint32_t phys)
{
for (int i = 0; i < s_count; i++) {
Entry *e = &s_entries[i];
if (e->phys != phys) continue;
/* One-shot bypass: func_override_call_original re-dispatched this
* address; let the original backend take it exactly once. */
if (s_bypass_phys == phys) {
s_bypass_phys = 0;
return 0;
}
/* Residency guard: wrong bytes at the address (overlay swapped,
* page reused) means the function this override targets is not
* resident — decline, never corrupt. */
if (e->n_guard) {
const uint32_t base = 0x80000000u | phys;
int miss = 0;
for (int w = 0; w < e->n_guard; w++)
if (psx_peek_word_untraced(base + (uint32_t)(w * 4)) !=
e->guard[w]) {
miss = 1;
break;
}
if (miss) {
e->guard_misses++;
return 0;
}
}
/* Count the CONSULT, not just the handled case: an override that
* declines still proves the address was reached through a hooked
* path, which is exactly what a diagnostic probe needs (calls == 0
* means "this call never crosses a hook", the bypass class of bug
* that hid the interp local-chain gap). */
e->calls++;
{
const uint32_t saved_active = s_active_phys;
s_active_phys = phys;
const int handled = (e->invoke ? e->invoke(cpu,e->fn,e->context) : e->fn(cpu)) ? 1 : 0;
s_active_phys = saved_active;
/* Charge the declared credit only when HANDLED: a decline runs
* the original, which accrues its exact cycles by executing.
* FO_CREDIT_SELF (< 0) and 0 charge nothing here — SELF bodies
* called psx_advance_cycles themselves, and guest work the body
* ran via func_override_guest_call / call_original has already
* self-charged through the normal backends. */
if (handled && e->credit > 0)
psx_advance_cycles((uint32_t)e->credit);
return handled;
}
}
return 0;
}
void func_override_guest_call(CPUState *cpu, uint32_t target, uint32_t site_ra)
{
/* Spoof $ra to the original call site so callees, fntrace and crash
* forensics see authentic values, then restore. psx_dispatch_call runs
* the callee to completion (pc == site_ra with the caller's $sp). */
extern void psx_dispatch_call(CPUState *cpu, uint32_t addr,
uint32_t return_addr);
const uint32_t saved_ra = cpu->gpr[31];
cpu->gpr[31] = site_ra;
psx_dispatch_call(cpu, target, site_ra);
cpu->gpr[31] = saved_ra;
}
void func_override_call_original(CPUState *cpu)
{
if (!s_active_phys) return; /* not inside an override — nothing to do */
const uint32_t phys = s_active_phys;
const uint32_t saved = s_bypass_phys;
s_bypass_phys = phys;
/* KSEG0 form: game text dispatches with the 0x8000_0000 view; the hook
* itself keys on the normalised address either way. Return to the
* override's caller frame: the original must observe the same $ra the
* override was entered with. */
func_override_guest_call(cpu, 0x80000000u | phys, cpu->gpr[31]);
s_bypass_phys = saved;
}
void func_override_install(void)
{
/* Leave the hook NULL when empty: dispatch then matches a build without
* the tier exactly, which keeps "overrides cost nothing" literally
* true. */
g_psx_func_override_hook = (s_count > 0) ? hook : NULL;
}
int func_override_reset_package_armed(void)
{
/* Drop every package-armed entry and compact, keeping direct
* registrations. Direct ones are the game's own always-on faithful
* reimplementations: not player-toggleable, identical in both peers'
* builds, so netplay has no reason to disarm them. Package-armed ones
* ARE player-selected, so a cleared plan must leave no trace of them —
* without this the entry survives in the table with the hook still
* installed and keeps firing after the vanilla-session banner prints.
*
* Any in-flight bypass/active marker refers to an entry that may be
* disappearing, so clear both rather than leave a dangling address. */
int removed = 0;
int w = 0;
for (int i = 0; i < s_count; i++) {
if (s_entries[i].package) { removed++; continue; }
if (w != i) s_entries[w] = s_entries[i];
w++;
}
s_count = w;
if (removed) {
s_active_phys = 0;
s_bypass_phys = 0;
func_override_install(); /* follows s_count back to NULL if empty */
}
return removed;
}
int func_override_count(void) { return s_count; }
int func_override_get(int index, char *id_out, size_t id_cap,
uint32_t *addr_out, uint64_t *calls_out)
{
return func_override_get_ex(index, id_out, id_cap, addr_out, calls_out,
NULL, NULL, NULL);
}
int func_override_get_ex(int index, char *id_out, size_t id_cap,
uint32_t *addr_out, uint64_t *calls_out,
uint64_t *guard_misses_out, int *guarded_out,
int32_t *credit_out)
{
if (index < 0 || index >= s_count) return 0;
const Entry *e = &s_entries[index];
/* Bounded copy: the caller states its buffer size rather than being
* silently required to supply FO_MAX_ID bytes. Always NUL-terminated. */
if (id_out && id_cap) {
strncpy(id_out, e->id, id_cap - 1);
id_out[id_cap - 1] = '\0';
}
if (addr_out) *addr_out = e->phys;
if (calls_out) *calls_out = e->calls;
if (guard_misses_out) *guard_misses_out = e->guard_misses;
if (guarded_out) *guarded_out = e->n_guard;
if (credit_out) *credit_out = e->credit;
return 1;
}
+6
View File
@@ -133,6 +133,7 @@ extern "C" uint32_t g_guest_last_store_addr, g_guest_last_store_val;
#include "mod_plugins.h"
#include "mod_local_input_policy.h"
#include "mod_session_baseline.h"
#include "func_override.h"
#include "mod_runtime.h"
#include "video_texture_filter.h"
#include "video_telemetry.h"
@@ -20411,6 +20412,11 @@ int main(int argc, char** argv) {
netplay && PSXRecompV4::mod_runtime_netplay_view_active() ? 1 : 0);
psx_mod_immediate_begin_session(netplay ? 1 : 0);
mod_runtime_activate_plugins();
if (const char* error = mod_runtime_activation_error()) {
std::fprintf(stderr,"psxrecomp: %s\n",error);
return false;
}
func_override_install();
if (!psx_mod_immediate_session_valid()) {
std::fprintf(stderr, "psxrecomp: immediate-selector enhancements are offline-only\n");
return false;
+10
View File
@@ -1687,6 +1687,16 @@ uint32_t psx_read_word(uint32_t addr) {
if (!psx_get_in_exception()) ls_read_hook(addr, 4, v);
return v;
}
/* Untraced word peek for HOST-SIDE inspection: same address decode as a guest
* read, but no lockstep record, no lockstep-replay substitution, and no
* data-shard/DuckStation capture. Use this for anything the guest did not
* actually execute — residency guards, host-side checks, diagnostics — so a
* host inspection can never appear in a guest read stream (which would both
* dirty every divergence comparison and, under replay, return replayed data
* instead of resident bytes). Never use it to service a guest load. */
uint32_t psx_peek_word_untraced(uint32_t addr) {
return psx_read_word_raw(addr);
}
/* Physical address of a CPU/DMA main-RAM access. Fold KUSEG/KSEG0/KSEG1 first
* (0x1FFFFFFF), then fold the 2nd-4th main-RAM mirrors: real hardware mirrors the
* 2 MB DRAM across the WHOLE 0..0x7FFFFF physical window (PSX-SPX memorymap,
+13 -1
View File
@@ -50,6 +50,10 @@ struct RegisteredPlugin {
std::vector<ModFunctionEntryHook> function_entries;
std::vector<ModFunctionEntryHook> guest_functions;
std::vector<ModInstructionHook> instructions;
/* Set when a function override is queued under this id (the callback
* itself lives in mod_runtime's pending list) — participates in
* availability so a manifest can gate an override-only plugin. */
bool function_override = false;
};
std::map<std::string, RegisteredPlugin>& registered_plugins() {
@@ -1535,12 +1539,20 @@ bool mod_register_function_filter_plugin(const std::string& id, uint32_t address
return true;
}
bool mod_register_function_override_marker(const std::string& id) {
if (!valid_id(id)) return false;
RegisteredPlugin& plugin = registered_plugins()[id];
if (plugin.function_override) return false;
plugin.function_override = true;
return true;
}
bool mod_plugin_registered(const std::string& id) {
const auto found = registered_plugins().find(id);
return found != registered_plugins().end() &&
(found->second.activation || found->second.session_reset || found->second.vblank || found->second.savestate ||
!found->second.function_entries.empty() || !found->second.guest_functions.empty() ||
!found->second.instructions.empty());
!found->second.instructions.empty() || found->second.function_override);
}
bool mod_register_guest_function_plugin(const std::string& id, uint32_t address,
+112 -1
View File
@@ -5,6 +5,7 @@
#include "disc_path.h"
#include "host_file_identity.h"
#include "func_override.h"
#include "iso_reader.h"
#include "mod_packages.h"
#include "mod_plugins.h"
@@ -368,6 +369,27 @@ void build_function_entry_hooks(const RuntimeMods& s) {
g_psx_mod_instruction_hooks = (uint32_t)instructions.size();
}
/* Function overrides queue here at constructor time and are ARMED into the
* func_override tier only for plugins the resolved package plan selects —
* the same gating as vblank/activation callbacks. `id` is the full
* registered name (may carry a ":label" suffix, kept for diagnostics);
* `plugin` is the manifest-facing part gating matches on. */
struct FunctionOverridePlugin {
std::string id;
std::string plugin;
uint32_t address = 0;
PSXModFunctionOverrideFn fn = nullptr;
uint32_t guard[FO_MAX_GUARD_WORDS] = {0, 0, 0, 0};
int n_guard = 0;
int32_t credit = 0;
bool armed = false;
};
std::vector<FunctionOverridePlugin>& function_override_plugins() {
static std::vector<FunctionOverridePlugin> value;
return value;
}
const ModPackage* selected_package(const std::string& id) {
return state().manager.selected_package(id);
}
@@ -1663,6 +1685,20 @@ bool mod_runtime_clear_for_netplay(std::string* error) {
s.disc_guard_failed = false;
s.netplay_view_plan = false;
s.error.clear();
/* Clearing s.plan is enough for activation/vblank callbacks — they only
* run while something iterates the plan. Function overrides are armed
* into func_override.c's own table with the dispatcher hook installed,
* so they survive a cleared plan and keep firing unless explicitly
* disarmed. That matters most on the rematch path (main.cpp jumps to
* session_reboot, which is PAST mod_runtime_activate_plugins and
* func_override_install), where a modded session entering netplay would
* otherwise print the vanilla banner and still run its overrides —
* diverging from a peer without the mod. Also re-arm-able: dropping the
* armed flag lets a later plan register the same override again. */
const int disarmed = func_override_reset_package_armed();
(void)disarmed;
for (FunctionOverridePlugin& pending : function_override_plugins())
pending.armed = false;
if (error) error->clear();
std::fprintf(stdout, "psxrecomp: mods cleared for netplay (vanilla session)\n");
return true;
@@ -2962,6 +2998,17 @@ bool mod_runtime_read_disc_file_sectors(const std::string& path, uint32_t max_by
}
} // namespace PSXRecompV4
static std::string s_override_activation_error;
extern "C" const char* mod_runtime_activation_error(void) {
return s_override_activation_error.empty() ? nullptr : s_override_activation_error.c_str();
}
static int invoke_package_override(CPUState* cpu, FuncOverrideFn fn, const void* context) {
using namespace PSXRecompV4;
PluginCallbackScope scope(state(),static_cast<const ModResolution::Plugin*>(context),cpu);
return fn(cpu);
}
extern "C" void mod_runtime_activate_plugins(void) {
using namespace PSXRecompV4;
HostLaunchTimingScope timing(HOST_LAUNCH_PLUGIN_ACTIVATION);
@@ -2969,6 +3016,9 @@ extern "C" void mod_runtime_activate_plugins(void) {
psx_host_set_hud_draw_cb(nullptr, nullptr);
mod_reset_plugin_sessions();
RuntimeMods& s = state();
s_override_activation_error.clear();
func_override_reset_package_armed();
for (auto& pending : function_override_plugins()) pending.armed = false;
s.ticket_ready = false;
psx_mod_set_disc_request_provider(nullptr);
psx_projection_reset_session();
@@ -2979,6 +3029,28 @@ extern "C" void mod_runtime_activate_plugins(void) {
s.disc_extents.clear();
s.audio_tracks.clear();
s.extent_start = 0;
/* Arm the entire tier before activation callbacks change host state. */
for (auto& pending : function_override_plugins()) {
const auto owner = std::find_if(s.plan.plugins.begin(),s.plan.plugins.end(),
[&](const ModResolution::Plugin& p) { return p.id == pending.plugin; });
if (owner == s.plan.plugins.end()) continue;
const int rc = s.netplay_view_plan ? FO_ERR_ARGS : func_override_add_package_scoped(
pending.id.c_str(),pending.address,pending.fn,
pending.n_guard ? pending.guard : nullptr,pending.n_guard,pending.credit,
invoke_package_override,&*owner);
if (rc != FO_OK) {
s_override_activation_error = "Cannot arm function override " + pending.id +
" (registration error " + std::to_string(rc) + ")";
s.error = s_override_activation_error;
s.plan.ok = false;
func_override_reset_package_armed();
for (auto& item : function_override_plugins()) item.armed = false;
clear_function_entry_hooks();
return;
}
pending.armed = true;
}
func_override_install();
s.activating = true;
psx_mod_immediate_begin_activation();
for (const ModResolution::Plugin& plugin : s.plan.plugins) {
@@ -3471,6 +3543,45 @@ static int mod_plan_hooks_parked(void) {
return PSXRecompV4::state().netplay_view_plan && !psx_mod_local_view_scope();
}
extern "C" int psx_mod_register_function_override(
const char* id, uint32_t address, PSXModFunctionOverrideFn fn,
const uint32_t* expected_words, int n_words, int32_t credit) {
using namespace PSXRecompV4;
if (!id || !*id || !(address & 0x1fffffffu) || (address & 3u) || !fn) return 0;
if (n_words < 0 || n_words > FO_MAX_GUARD_WORDS) return 0;
if (n_words > 0 && !expected_words) return 0;
if (credit < FO_CREDIT_SELF) return 0;
/* An optional ":label" suffix names this override in diagnostics (the
* `func_override` TCP command) without multiplying manifest plugin ids —
* gating and resolver availability use only the part before the ':'.
* "pkg.feature:aim" and "pkg.feature:fire" are two overrides under the
* one manifest plugin "pkg.feature". */
const char* colon = strchr(id, ':');
const std::string plugin_id = colon ? std::string(id, colon - id)
: std::string(id);
if (plugin_id.empty() || (colon && !colon[1])) return 0;
auto& plugins = function_override_plugins();
const auto duplicate = std::find_if(
plugins.begin(), plugins.end(),
[&](const FunctionOverridePlugin& item) {
return item.id == id && item.address == address;
});
if (duplicate != plugins.end()) return 0;
FunctionOverridePlugin plugin;
plugin.id = id;
plugin.plugin = plugin_id;
plugin.address = address;
plugin.fn = fn;
for (int i = 0; i < n_words; ++i) plugin.guard[i] = expected_words[i];
plugin.n_guard = n_words;
plugin.credit = credit;
plugins.push_back(plugin);
/* Mark the plugin id available to the package resolver so a manifest can
* gate an override-only plugin (multiple overrides may share one). */
mod_register_function_override_marker(plugin_id);
return 1;
}
extern "C" int psx_mod_dispatch_guest_function(CPUState* cpu, uint32_t address) {
using namespace PSXRecompV4;
if (!g_psx_mod_guest_functions || !cpu || address >= 0xC0000000u) return 0;
@@ -3673,7 +3784,7 @@ static int game_netplay_function_entry(CPUState* cpu, uint32_t address) {
}
extern "C" int psx_mod_function_entry_active(void) {
return PSXRecompV4::function_entry_depth != 0;
return PSXRecompV4::function_entry_depth != 0 || func_override_active();
}
extern "C" void mod_runtime_function_entry_context_save(ModFunctionEntryContext *out) {
+519
View File
@@ -0,0 +1,519 @@
/* test_func_override.c — pins the func_override tier's contract.
*
* The tier's failure modes are all SILENT: a registration that never gets
* consulted, a package override that survives clear-mods, a guard that
* corrupts instead of declining. None of those announce themselves at
* runtime, so they need pinning here rather than field observation.
*
* The tests drive the real dispatcher entry point — g_psx_func_override_hook,
* the same pointer the generated dispatch and the interpreter call through —
* so nothing here exercises a test-only path.
*/
#include <stdio.h>
#include <string.h>
#include "cpu_state.h"
#include "func_override.h"
extern int (*g_psx_func_override_hook)(CPUState *cpu, uint32_t phys);
static int g_fail = 0;
#define CHECK(cond, ...) \
do { \
if (!(cond)) { \
printf("FAIL %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
g_fail = 1; \
} \
} while (0)
/* ---- test doubles ------------------------------------------------------- */
/* Guest RAM the residency guard peeks at. func_override.c reads through
* psx_peek_word_untraced specifically so this stays a plain host read with no
* lockstep/oracle side effects — that is the property under test in
* guard_declines_on_mismatch, and the reason this double is trivial. */
#define FAKE_RAM_WORDS 64
static uint32_t s_ram[FAKE_RAM_WORDS];
uint32_t psx_peek_word_untraced(uint32_t addr)
{
const uint32_t idx = (addr & 0x1FFFFFFFu) >> 2;
return (idx < FAKE_RAM_WORDS) ? s_ram[idx] : 0xDEADBEEFu;
}
/* Cycle-core doubles: func_override.c charges fixed credits through the
* psx_advance_cycles inline (psx_cycles.h), which reads these globals. The
* doubles keep it a plain counter bump so the credit tests can assert
* against psx_cycle_count deltas directly. */
uint64_t psx_cycle_count = 0;
uint64_t psx_next_service_cycle = 0;
int psx_in_device_service = 0;
int g_event_step_conservative = 0;
int g_ls_replay_active = 0;
uint32_t g_psx_cyc_batch = 0;
uint32_t g_psx_cyc_batch_limit = 0;
void psx_devices_service_to_now(void) {}
void psx_advance_cycles_slow(uint32_t cycles) { psx_cycle_count += cycles; }
/* Stands in for the recompiled dispatcher. Re-consults the hook exactly as
* psx_dispatch_impl does, so func_override_call_original's one-shot bypass is
* exercised through its real mechanism rather than assumed. */
static int s_original_runs = 0;
static int s_dispatch_depth = 0;
/* When set, the "original" body at this address calls ITSELF once — modelling
* a self-recursive guest function, the case where the one-shot bypass must
* already have been consumed so the recursive call re-consults the override
* (what a guest-level wrap would observe). */
static uint32_t s_recursive_original_at = 0;
static int s_recursion_budget = 0;
void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t return_addr)
{
(void)return_addr;
if (s_dispatch_depth > 8) return; /* runaway guard, not part of the contract */
s_dispatch_depth++;
if (!g_psx_func_override_hook ||
!g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu)) {
/* Nothing handled it: this is the original body running. */
s_original_runs++;
cpu->gpr[2] = 0xAAAAu;
if (s_recursive_original_at &&
(addr & 0x1FFFFFFFu) == (s_recursive_original_at & 0x1FFFFFFFu) &&
s_recursion_budget > 0) {
s_recursion_budget--;
psx_dispatch_call(cpu, addr, return_addr);
}
}
s_dispatch_depth--;
}
/* ---- override implementations ------------------------------------------- */
static int s_impl_calls, s_decline_calls, s_wrap_calls;
static int impl_handles(CPUState *cpu)
{
CHECK(func_override_active(),"handled native override stack is active");
s_impl_calls++;
cpu->gpr[2] = 0x1234u;
return 1;
}
static int impl_declines(CPUState *cpu)
{
(void)cpu;
CHECK(func_override_active(),"declining native override stack is active");
s_decline_calls++;
return 0; /* the decline-only probe idiom the TCP inventory relies on */
}
static int impl_wraps(CPUState *cpu)
{
s_wrap_calls++;
func_override_call_original(cpu);
cpu->gpr[2] += 1u; /* prove we ran after the original */
return 1;
}
/* ---- helpers ------------------------------------------------------------ */
static void reset_all(void)
{
/* No teardown for direct registrations exists by design (they are
* always-on), so each test group runs in a fresh process section by
* using distinct addresses instead of clearing. Counters do reset. */
s_impl_calls = s_decline_calls = s_wrap_calls = 0;
s_original_runs = 0;
s_recursive_original_at = 0;
s_recursion_budget = 0;
memset(s_ram, 0, sizeof(s_ram));
}
static int consult(CPUState *cpu, uint32_t addr)
{
if (!g_psx_func_override_hook) return 0;
const int handled = g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu);
CHECK(!func_override_active(),"top-level native override stack has unwound");
return handled;
}
/* ---- tests -------------------------------------------------------------- */
static void test_install_is_null_until_registered(void)
{
/* The whole "costs nothing when unused" claim rests on this: with nothing
* registered the hook pointer must stay NULL so dispatch matches a build
* without the tier. */
func_override_install();
CHECK(g_psx_func_override_hook == NULL,
"hook must stay NULL with nothing registered");
}
static void test_add_rejects_bad_input(void)
{
CHECK(func_override_add("t.null_fn", 0x80001000u, NULL, 0) == FO_ERR_ARGS,
"NULL fn must be refused");
/* phys 0 collides with the "not inside an override" sentinel. */
CHECK(func_override_add("t.zero", 0x80000000u, impl_handles, 0) == FO_ERR_ARGS,
"address normalising to phys 0 must be refused");
CHECK(func_override_add("t.zero2", 0x00000000u, impl_handles, 0) == FO_ERR_ARGS,
"raw address 0 must be refused");
static const uint32_t g[1] = {0};
CHECK(func_override_add_guarded("t.g0", 0x80001000u, impl_handles, g, 0, 0)
== FO_ERR_ARGS,
"guarded with n_words 0 must be refused");
CHECK(func_override_add_guarded("t.gmax", 0x80001000u, impl_handles, g,
FO_MAX_GUARD_WORDS + 1, 0) == FO_ERR_ARGS,
"guard word count over the cap must be refused");
CHECK(func_override_add_guarded("t.gnull", 0x80001000u, impl_handles, NULL,
2, 0) == FO_ERR_ARGS,
"guarded with NULL words must be refused");
}
static void test_duplicate_address_refused(void)
{
const uint32_t addr = 0x80002000u;
CHECK(func_override_add("t.first", addr, impl_handles, 0) == FO_OK,
"first registration must succeed");
/* A silent last-wins here would be untraceable, which is why this is an
* error and not an overwrite. KSEG vs physical must not defeat it. */
CHECK(func_override_add("t.second", addr, impl_declines, 0) == FO_ERR_DUPLICATE,
"same address twice must be refused");
CHECK(func_override_add("t.second_phys", addr & 0x1FFFFFFFu, impl_declines, 0)
== FO_ERR_DUPLICATE,
"same address in physical form must also be refused");
}
static void test_handled_and_declined_both_count_as_consults(void)
{
CPUState cpu;
memset(&cpu, 0, sizeof(cpu));
reset_all();
const uint32_t handled_at = 0x80003000u;
const uint32_t declined_at = 0x80003100u;
CHECK(func_override_add("t.handled", handled_at, impl_handles, 0) == FO_OK, "add handled");
CHECK(func_override_add("t.declined", declined_at, impl_declines, 0) == FO_OK, "add declined");
func_override_install();
CHECK(g_psx_func_override_hook != NULL, "hook must install once populated");
CHECK(consult(&cpu, handled_at) == 1, "handled override must report handled");
CHECK(cpu.gpr[2] == 0x1234u, "handled override must write $v0");
CHECK(s_impl_calls == 1, "handled impl must run once");
CHECK(consult(&cpu, declined_at) == 0, "declining override must report not-handled");
CHECK(s_decline_calls == 1, "declining impl must still run");
/* An unregistered address must not be claimed. */
CHECK(consult(&cpu, 0x8000FF00u) == 0, "unregistered address must not be handled");
/* calls counts CONSULTS, so a decline-only probe proves reachability. */
int found_handled = 0, found_declined = 0;
for (int i = 0; i < func_override_count(); i++) {
char id[FO_MAX_ID];
uint32_t addr = 0;
uint64_t calls = 0, misses = 0;
int guarded = -1;
if (!func_override_get_ex(i, id, sizeof(id), &addr, &calls, &misses,
&guarded, NULL))
continue;
if (addr == (handled_at & 0x1FFFFFFFu)) {
found_handled = 1;
CHECK(calls == 1, "handled entry calls==1, got %llu",
(unsigned long long)calls);
CHECK(misses == 0, "handled entry must have no guard misses");
CHECK(guarded == 0, "handled entry is unguarded");
CHECK(strcmp(id, "t.handled") == 0, "id round-trips, got '%s'", id);
}
if (addr == (declined_at & 0x1FFFFFFFu)) {
found_declined = 1;
CHECK(calls == 1, "DECLINED entry must still count a consult, got %llu",
(unsigned long long)calls);
}
}
CHECK(found_handled && found_declined, "both entries must be enumerable");
}
static void test_guard_declines_on_mismatch(void)
{
CPUState cpu;
memset(&cpu, 0, sizeof(cpu));
reset_all();
/* Address inside the fake RAM window so the guard can be satisfied. */
const uint32_t addr = 0x80000040u; /* phys 0x40 -> word index 16 */
static const uint32_t prologue[2] = {0x27BDFFE0u, 0xAFB20018u};
CHECK(func_override_add_guarded("t.guarded", addr, impl_handles, prologue, 2, 0)
== FO_OK, "guarded add must succeed");
func_override_install();
/* Wrong code resident: must DECLINE and never run the impl. Corrupting
* here instead of declining is the failure the guard exists to prevent. */
s_ram[16] = 0xFFFFFFFFu;
s_ram[17] = 0xFFFFFFFFu;
CHECK(consult(&cpu, addr) == 0, "guard mismatch must decline");
CHECK(s_impl_calls == 0, "guard mismatch must NOT run the override body");
/* Right code resident: must fire. */
s_ram[16] = prologue[0];
s_ram[17] = prologue[1];
CHECK(consult(&cpu, addr) == 1, "guard match must handle");
CHECK(s_impl_calls == 1, "guard match must run the override body once");
/* Partial match is still a miss. */
s_ram[17] = 0u;
CHECK(consult(&cpu, addr) == 0, "partial guard match must decline");
CHECK(s_impl_calls == 1, "partial match must not run the body");
for (int i = 0; i < func_override_count(); i++) {
char id[FO_MAX_ID];
uint32_t a = 0;
uint64_t calls = 0, misses = 0;
int guarded = 0;
if (!func_override_get_ex(i, id, sizeof(id), &a, &calls, &misses,
&guarded, NULL))
continue;
if (a != (addr & 0x1FFFFFFFu)) continue;
CHECK(guarded == 2, "guard word count must be reported, got %d", guarded);
CHECK(misses == 2, "two guard misses expected, got %llu",
(unsigned long long)misses);
}
}
static void test_call_original_is_one_shot(void)
{
CPUState cpu;
memset(&cpu, 0, sizeof(cpu));
reset_all();
const uint32_t addr = 0x80004000u;
CHECK(func_override_add("t.wrap", addr, impl_wraps, FO_CREDIT_SELF) == FO_OK, "add wrap");
func_override_install();
CHECK(consult(&cpu, addr) == 1, "wrap must handle");
CHECK(s_wrap_calls == 1, "wrap body runs once");
/* The bypass must let the ORIGINAL through exactly once — not zero times
* (wrap becomes replace) and not unboundedly (infinite re-entry). */
CHECK(s_original_runs == 1, "original must run exactly once, ran %d",
s_original_runs);
CHECK(cpu.gpr[2] == 0xAAAAu + 1u,
"wrap must observe then adjust the original's $v0, got 0x%X",
cpu.gpr[2]);
/* Bypass must not persist: a second call re-consults the override. */
CHECK(consult(&cpu, addr) == 1, "second call must still be handled");
CHECK(s_wrap_calls == 2, "wrap body runs again on the second call");
CHECK(s_original_runs == 2, "original runs once more, total %d",
s_original_runs);
}
static void test_bypass_is_consumed_so_recursion_reconsults(void)
{
/* The header promises: "if the original recursively calls itself, the
* recursive calls consult the override again." That requires the one-shot
* bypass to be CONSUMED (cleared) at the moment it is honoured, not merely
* restored when call_original returns — restoring alone looks correct for
* a non-recursive wrap and hides the bug. */
CPUState cpu;
memset(&cpu, 0, sizeof(cpu));
reset_all();
const uint32_t addr = 0x80004100u;
CHECK(func_override_add("t.wrap_rec", addr, impl_wraps, FO_CREDIT_SELF) == FO_OK,
"add recursive wrap");
func_override_install();
s_recursive_original_at = addr;
s_recursion_budget = 1; /* the original self-calls exactly once */
CHECK(consult(&cpu, addr) == 1, "recursive wrap must handle");
/* Outer override runs, calls the original; the original self-calls, and
* that inner call must land on the OVERRIDE again (wrap body twice), not
* on the original a second time. */
CHECK(s_wrap_calls == 2,
"override must be re-consulted on the original's self-call, wrap ran %d",
s_wrap_calls);
CHECK(s_original_runs == 2,
"each override invocation runs the original once, original ran %d",
s_original_runs);
}
static void test_call_original_outside_override_is_a_noop(void)
{
CPUState cpu;
memset(&cpu, 0, sizeof(cpu));
reset_all();
func_override_call_original(&cpu); /* must not dispatch anything */
CHECK(s_original_runs == 0,
"call_original outside an override must do nothing");
}
static void test_package_reset_drops_only_package_entries(void)
{
CPUState cpu;
memset(&cpu, 0, sizeof(cpu));
reset_all();
const uint32_t direct_at = 0x80005000u;
const uint32_t pkg_at = 0x80005100u;
CHECK(func_override_add("t.direct", direct_at, impl_handles, 0) == FO_OK,
"direct add");
CHECK(func_override_add_package("pkg.feature:a", pkg_at, impl_handles, NULL, 0, 0)
== FO_OK, "package add");
func_override_install();
CHECK(consult(&cpu, direct_at) == 1, "direct override armed");
CHECK(consult(&cpu, pkg_at) == 1, "package override armed");
const int before = func_override_count();
const int dropped = func_override_reset_package_armed();
CHECK(dropped >= 1, "reset must report dropping the package entry");
CHECK(func_override_count() == before - dropped,
"count must shrink by exactly the dropped entries");
/* This is the netplay-divergence regression: after clear-mods the package
* override must be gone, while the game's own always-on reimplementation
* survives. */
CHECK(consult(&cpu, pkg_at) == 0,
"package override must NOT fire after reset (netplay divergence)");
CHECK(consult(&cpu, direct_at) == 1,
"direct override must survive reset");
/* Compaction must not corrupt surviving entries. */
int seen_direct = 0;
for (int i = 0; i < func_override_count(); i++) {
char id[FO_MAX_ID];
uint32_t a = 0;
uint64_t calls = 0;
if (!func_override_get_ex(i, id, sizeof(id), &a, &calls, NULL, NULL,
NULL))
continue;
if (a == (direct_at & 0x1FFFFFFFu)) {
seen_direct = 1;
CHECK(strcmp(id, "t.direct") == 0,
"surviving id intact after compaction, got '%s'", id);
}
CHECK(a != (pkg_at & 0x1FFFFFFFu),
"dropped package entry must not remain enumerable");
}
CHECK(seen_direct, "direct entry must remain enumerable");
/* The address is free again, so a later plan can re-arm it. */
CHECK(func_override_add_package("pkg.feature:a", pkg_at, impl_handles, NULL, 0, 0)
== FO_OK, "re-arming after reset must succeed");
CHECK(func_override_reset_package_armed() >= 1, "and drop again");
}
static void test_get_ex_bounded_id_copy(void)
{
/* The id buffer size is the caller's to state; a short buffer must
* truncate with termination, never overrun. */
int checked = 0;
for (int i = 0; i < func_override_count(); i++) {
char small[4];
uint32_t a = 0;
memset(small, 0x7F, sizeof(small));
if (!func_override_get_ex(i, small, sizeof(small), &a, NULL, NULL, NULL,
NULL))
continue;
CHECK(small[sizeof(small) - 1] == '\0',
"short id buffer must be NUL-terminated");
CHECK(strlen(small) <= sizeof(small) - 1, "short id must be truncated");
checked = 1;
}
CHECK(checked, "expected at least one entry to enumerate");
CHECK(func_override_get_ex(-1, NULL, 0, NULL, NULL, NULL, NULL, NULL) == 0,
"negative index must fail");
CHECK(func_override_get_ex(func_override_count(), NULL, 0, NULL, NULL, NULL,
NULL, NULL) == 0,
"out-of-range index must fail");
}
static void test_credit_policy(void)
{
reset_all();
CPUState cpu;
memset(&cpu, 0, sizeof(cpu));
/* The credit is a required statement: FO_CREDIT_SELF or >= 0. Any other
* negative value is a typo, not a policy. */
CHECK(func_override_add("t.badcredit", 0x80006000u, impl_handles, -2)
== FO_ERR_ARGS,
"credit below FO_CREDIT_SELF must be refused");
CHECK(func_override_add("t.credit40", 0x80006100u, impl_handles, 40)
== FO_OK, "fixed-credit add");
CHECK(func_override_add("t.credit40d", 0x80006200u, impl_declines, 40)
== FO_OK, "fixed-credit decline probe add");
CHECK(func_override_add("t.creditself", 0x80006300u, impl_handles,
FO_CREDIT_SELF)
== FO_OK, "self-credit add");
CHECK(func_override_add("t.credit0", 0x80006400u, impl_handles, 0)
== FO_OK, "zero-credit add");
func_override_install();
/* A handled call charges exactly the declared credit... */
uint64_t before = psx_cycle_count;
CHECK(consult(&cpu, 0x80006100u) == 1, "fixed-credit override handles");
CHECK(psx_cycle_count - before == 40u,
"handled call must charge the declared credit");
/* ...a DECLINE charges nothing (the original runs and self-charges
through the normal backends, so a tier-side charge would double). */
before = psx_cycle_count;
CHECK(consult(&cpu, 0x80006200u) == 0, "decline probe declines");
CHECK(psx_cycle_count == before, "a decline must charge nothing");
/* FO_CREDIT_SELF and 0 charge nothing from the tier. */
before = psx_cycle_count;
CHECK(consult(&cpu, 0x80006300u) == 1, "self-credit override handles");
CHECK(psx_cycle_count == before, "SELF must not be charged by the tier");
before = psx_cycle_count;
CHECK(consult(&cpu, 0x80006400u) == 1, "zero-credit override handles");
CHECK(psx_cycle_count == before, "credit 0 must charge nothing");
/* The declared policy is inspectable, not just documented. */
int seen40 = 0, seenself = 0;
for (int i = 0; i < func_override_count(); i++) {
char id[FO_MAX_ID];
uint32_t a = 0;
int32_t credit = -99;
if (!func_override_get_ex(i, id, sizeof(id), &a, NULL, NULL, NULL,
&credit))
continue;
if (a == 0x00006100u) { seen40 = (credit == 40); }
if (a == 0x00006300u) { seenself = (credit == FO_CREDIT_SELF); }
}
CHECK(seen40, "get_ex must report the fixed credit");
CHECK(seenself, "get_ex must report FO_CREDIT_SELF");
}
int main(void)
{
test_install_is_null_until_registered();
test_add_rejects_bad_input();
test_duplicate_address_refused();
test_handled_and_declined_both_count_as_consults();
test_guard_declines_on_mismatch();
test_call_original_is_one_shot();
test_bypass_is_consumed_so_recursion_reconsults();
test_call_original_outside_override_is_a_noop();
test_package_reset_drops_only_package_entries();
test_get_ex_bounded_id_copy();
test_credit_policy();
if (g_fail) {
printf("func_override: FAILURES\n");
return 1;
}
printf("func_override: all checks passed (%d entries registered)\n",
func_override_count());
return 0;
}
+94
View File
@@ -1,4 +1,5 @@
#include "mod_runtime.h"
#include "func_override.h"
#include "mod_netplay.h"
#include "host_launch_timing.h"
#include "host_overlay.h"
@@ -30,8 +31,21 @@
namespace fs = std::filesystem;
static std::array<uint8_t, 2 * 1024 * 1024> ram;
extern "C" int (*g_psx_func_override_hook)(CPUState*, uint32_t);
static int failures;
static int activation_calls;
static int override_activation_calls;
static void override_activate() { ++override_activation_calls; }
static int override_scope_checks;
static int override_body(CPUState*) {
char option[16]{};
if (psx_mod_current_option_value("scope",option,sizeof option) &&
!std::strcmp(option,"owned") && psx_mod_function_entry_active()) ++override_scope_checks;
mod_runtime_on_vblank();
if (psx_mod_current_option_value("scope",option,sizeof option) &&
!std::strcmp(option,"owned") && psx_mod_function_entry_active()) ++override_scope_checks;
return 1;
}
static int plugin_calls;
static int restore_calls;
static int option_renderer_activations;
@@ -153,6 +167,23 @@ extern "C" void gpu_get_display_info(GpuDisplayInfo* out) {
*out = g_test_display;
}
/* func_override.c doubles: linked in so mod_runtime's package arming/reset
* paths run for real. The tier's own behavior is pinned by
* test_func_override.c; here the doubles only need to satisfy the link. */
extern "C" uint32_t psx_peek_word_untraced(uint32_t) { return 0; }
extern "C" void psx_dispatch_call(struct CPUState*, uint32_t, uint32_t) {}
extern "C" {
uint64_t psx_cycle_count = 0;
uint64_t psx_next_service_cycle = 0;
int psx_in_device_service = 0;
int g_event_step_conservative = 0;
int g_ls_replay_active = 0;
uint32_t g_psx_cyc_batch = 0;
uint32_t g_psx_cyc_batch_limit = 0;
void psx_devices_service_to_now(void) {}
void psx_advance_cycles_slow(uint32_t cycles) { psx_cycle_count += cycles; }
}
extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {}
static int test_game_started = 1;
extern "C" int fntrace_is_game_started(void) { return test_game_started; }
@@ -523,6 +554,7 @@ int main(int argc, char** argv) {
const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-test";
#endif
std::error_code ec;
fs::remove_all(root, ec);
const std::vector<uint8_t> stock(8 * 2352, 0);
std::vector<uint8_t> overlay(3000);
@@ -2422,6 +2454,68 @@ int main(int argc, char** argv) {
"resolver preserves missing selection for existing downstream validation");
}
{
const auto base = root/"function-overrides";
write_text(base/"bundled/override.test/1.0.0/manifest.toml", R"toml(
format_version = 5
id = "override.test"
version = "1.0.0"
name = "Overrides"
resolver = "declarative"
save_compatibility = "shared"
[[target]]
game_id = "SLUS-OVERRIDE"
[[feature]]
id = "replace"
name = "Replace"
default_enabled = true
[[option]]
feature = "replace"
id = "scope"
label = "Scope"
type = "choice"
default = "owned"
[[option.choice]]
value = "owned"
label = "Owned"
[[option.choice]]
value = "other"
label = "Other"
[[plugin]]
feature = "replace"
id = "override.test"
)toml");
check(PSXRecompV4::mod_register_activation_plugin("override.test",override_activate),"register override activation");
check(psx_mod_register_function_override("override.test:first",0x80006004,override_body,nullptr,0,0),"queue first override");
check(psx_mod_register_function_override("override.test:second",0x80006000,override_body,nullptr,0,0),"queue second override");
check(!psx_mod_register_function_override("override.test:bad",0x80006001,override_body,nullptr,0,0),"reject misaligned override");
check(PSXRecompV4::mod_runtime_initialize(base,"SLUS-OVERRIDE",0x80002000,{},&error),error.c_str());
check(PSXRecompV4::mod_runtime_commit({},&error),error.c_str());
mod_runtime_activate_plugins();
check(!mod_runtime_activation_error() && func_override_count()==2 && override_activation_calls==1,"selected overrides arm together");
CPUState override_cpu{};
check(g_psx_func_override_hook && g_psx_func_override_hook(&override_cpu,0x6004),"invoke package override");
check(override_scope_checks==2 && !psx_mod_function_entry_active(),
"override owns its resource context across VBlank and blocks mid-stack snapshots");
char no_scope[16]{};
check(!psx_mod_current_option_value("scope",no_scope,sizeof no_scope),"override restores its caller's context");
mod_runtime_activate_plugins();
check(!mod_runtime_activation_error() && func_override_count()==2 && override_activation_calls==2,"session reactivation replaces entries without duplicates");
check(PSXRecompV4::mod_runtime_clear_for_netplay(&error),error.c_str());
check(func_override_count()==0,"cleared plan disarms all package entries");
override_activation_calls=0;
check(func_override_add("direct",0x80006000,override_body,0)==FO_OK,"register colliding direct override");
check(PSXRecompV4::mod_runtime_initialize(base,"SLUS-OVERRIDE",0x80002000,{},&error),error.c_str());
check(PSXRecompV4::mod_runtime_commit({},&error),error.c_str());
mod_runtime_activate_plugins();
check(mod_runtime_activation_error()!=nullptr && override_activation_calls==0,
"late collision rejects launch before callbacks");
check(func_override_count()==1,"collision rolls back all package entries and preserves direct entry");
check(PSXRecompV4::mod_runtime_clear_for_netplay(&error),error.c_str());
mod_runtime_activate_plugins();
check(!mod_runtime_activation_error() && func_override_count()==1,"vanilla session clears failure and retains direct tier");
}
fs::remove_all(root, ec);
if (failures) return 1;
std::cout << "mod runtime tests passed\n";