mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
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:
@@ -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.
|
||||
|
||||
@@ -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 @@
|
||||
86f7329c2692e76133c40fa11cc8cfac214302d8238e05369dc8b263f4dc76fd
|
||||
6b3ba0804aeca3d52f6e44dd26ec7a8e82f40edce68ecf01ff14705373aef847
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user