From ae98d82f22eaeec19e8579d0c8b3ea5bdecbc342 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 14 Sep 2026 15:58:24 -0700 Subject: [PATCH] Add opt-in native guest function replacement service --- docs/MOD_FUNCTION_REPLACEMENTS.md | 44 ++++++++++++++ recompiler/CMakeLists.txt | 22 +++++++ recompiler/src/code_generator.cpp | 6 +- .../mod_function_replacement_codegen_test.cpp | 58 +++++++++++++++++++ runtime/CMakeLists.txt | 3 + runtime/include/mod_plugins.h | 9 +++ runtime/runtime.cmake | 1 + runtime/src/dirty_ram_interp.c | 10 ++++ runtime/src/mod_function_replacements.c | 49 ++++++++++++++++ runtime/tests/test_interp_mod_entry_guards.py | 2 + .../tests/test_mod_function_replacements.c | 58 +++++++++++++++++++ 11 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 docs/MOD_FUNCTION_REPLACEMENTS.md create mode 100644 recompiler/tests/mod_function_replacement_codegen_test.cpp create mode 100644 runtime/src/mod_function_replacements.c create mode 100644 runtime/tests/test_mod_function_replacements.c diff --git a/docs/MOD_FUNCTION_REPLACEMENTS.md b/docs/MOD_FUNCTION_REPLACEMENTS.md new file mode 100644 index 00000000..d9f7802b --- /dev/null +++ b/docs/MOD_FUNCTION_REPLACEMENTS.md @@ -0,0 +1,44 @@ +# Trusted opt-in native function replacements + +Enhanced ports can replace a narrowly selected guest function with statically +linked native code, without assigning its additional PC work a simulated PS1 +instruction budget. Nothing is registered by default. This is an enhancement +service, not a change to faithful CPU/GTE/DMA timing. + +1. List the guest entry in `[recompiler] mod_function_entry_funcs` and regenerate. +2. During activation of an **opt-in** game plugin, call + `psx_mod_set_function_replacement(address, callback)`. +3. Validate the guest version, inputs, resources and output capacity before + changing anything. Return zero to execute the original implementation, or + nonzero only after completing its caller-visible effects. + +Existing function-entry preparation callbacks run first. The replacement +dispatcher captures the incoming `$ra` and publishes that address in `cpu->pc` +when the callback succeeds, using the normal CPS return convention. Interior +compiled continuations bypass the fresh-entry hook. Interpreted entry/local +transfer paths also consult the registry, including when a patched prologue +prevents static dispatch. + +The callback owns ABI preservation, memory/precision bookkeeping and any +intentional timing effects. The service does not add cycles, accelerate clocks, +or undo a declined callback's side effects: a callback returning zero **must +leave guest/runtime state untouched**. Never use this to skip unrelated game +logic. Calls and registration are emulation-thread-only. + +The registry is bounded at 64 exact, nonzero, word-aligned addresses. Replacing +an existing registration does not consume another slot. A null callback removes +it. The default-empty path returns without scanning the table. Registrations +are process configuration, not savestate payload; do not store pointers or +function registrations in guest RAM. + +Regeneration changes the generated-code signature. Existing savestates may be +rejected by the normal compatibility guard; retain the older build for them and +use fresh private checkpoints for same-build comparisons. Do not bypass the +guard to make a performance test load. + +Tests cover default-off/declined calls, exact addresses, capacity/reuse/removal, +the return ABI (including a callback that clobbers `$ra`), generated opt-in scope +and continuation ordering, and both interpreted callback sites. + +Tracking: beads-eio.3.161. The first consumer is Crash's default-off N9 native +normal-terrain transform experiment, tracked in beads-t8j.8. diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 4dd33e72..87f4d6bd 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -449,6 +449,28 @@ if(BUILD_TESTING) add_test(NAME signed_x_bound_codegen_test COMMAND signed_x_bound_codegen_test) + add_executable(mod_function_replacement_codegen_test + tests/mod_function_replacement_codegen_test.cpp + src/ps1_exe_parser.cpp + src/mips_decoder.cpp + src/control_flow.cpp + src/function_analysis.cpp + src/code_generator.cpp + src/pgxp_hook_emitter.cpp + src/annotations.cpp + src/bios_address_model.cpp + src/config_loader.cpp + src/recompiler_patch.cpp + ) + target_include_directories(mod_function_replacement_codegen_test PRIVATE + include src lib/rabbitizer/include lib/rabbitizer/cplusplus/include + lib/fmt/include lib/toml11 lib/ELFIO) + target_link_libraries(mod_function_replacement_codegen_test PRIVATE fmt rabbitizer) + add_test(NAME mod_function_replacement_codegen_test + COMMAND mod_function_replacement_codegen_test) + set_tests_properties(mod_function_replacement_codegen_test PROPERTIES + ENVIRONMENT "PSX_CPS=1") + add_executable(recompiler_patch_cxx17_header_test tests/recompiler_patch_cxx17_header_test.cpp ) diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index ef96f8d8..525bc25f 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -2784,8 +2784,9 @@ GeneratedFunction CodeGenerator::generate_function( body_ss << config_.indent << fmt::format( "psx_mod_function_entry(cpu, 0x{:08X}u);" - " /* trusted opt-in game-mod hook */\n", - func.start_addr); + " /* trusted opt-in game-mod hook */\n" + " if (psx_mod_try_function_replacement(cpu, 0x{:08X}u)) return;\n", + func.start_addr, func.start_addr); } if (config_.ws_sprite_tag_funcs.count(func.start_addr)) { body_ss << config_.indent @@ -3286,6 +3287,7 @@ void CodeGenerator::emit_runtime_externs(std::ostream& ss) const { ss << "extern int psx_game_text_native_ok(uint32_t addr); /* stale-static guard (dispatch shard) */\n"; ss << "extern int psx_datashard_enter(CPUState* cpu, uint32_t key); /* data-shard replay/capture (data_shards.c) */\n"; ss << "extern void psx_mod_function_entry(CPUState* cpu, uint32_t address); /* trusted opt-in game-mod hook */\n"; + ss << "extern int psx_mod_try_function_replacement(CPUState* cpu, uint32_t address);\n"; ss << "extern void psx_datashard_ret(CPUState* cpu); /* data-shard capture finalize */\n"; ss << "extern int psx_vsync_query_hle_enter(CPUState* cpu, uint32_t func, uint32_t counter_addr, uint32_t gpustat_ptr_addr, uint32_t timer1_ptr_addr, uint32_t timer1_cache_addr); /* load_accel.c */\n"; ss << "extern void psx_ws_sprite_tag(CPUState* cpu); /* widescreen prim tag (gpu.c) */\n"; diff --git a/recompiler/tests/mod_function_replacement_codegen_test.cpp b/recompiler/tests/mod_function_replacement_codegen_test.cpp new file mode 100644 index 00000000..dc1ad33e --- /dev/null +++ b/recompiler/tests/mod_function_replacement_codegen_test.cpp @@ -0,0 +1,58 @@ +#include "code_generator.h" +#include "control_flow.h" +#include +#include + +static void check(bool condition, const char* message) { + if (!condition) { + std::fprintf(stderr, "FAIL: %s\n", message); + std::exit(1); + } +} + +int main() { + constexpr uint32_t base = 0x80010000u; + PSXRecomp::PS1Executable exe{}; + exe.header.load_address = exe.header.initial_pc = base; + const uint32_t words[] = {0x24020001u, 0x10400002u, 0, 0x24030002u, 0x03E00008u, 0}; + for (uint32_t word : words) + for (unsigned byte = 0; byte < 4; ++byte) + exe.code_data.push_back(static_cast(word >> (byte * 8))); + exe.header.file_size = static_cast(exe.code_data.size()); + PSXRecomp::Function function{}; + function.start_addr = base; + function.size = exe.header.file_size; + function.end_addr = base + function.size; + function.name = "replacement_test"; + PSXRecomp::ControlFlowAnalyzer analyzer(exe); + const auto flow = analyzer.analyze_function(function); + PSXRecomp::CodeGenConfig config{}; + config.indent = " "; + const auto generate = [&]() { + PSXRecomp::CodeGenerator generator(exe, config); + return generator.generate_function(function, flow).full_code; + }; + const std::string hook = "if (psx_mod_try_function_replacement(cpu, 0x80010000u)) return;"; + check(generate().find("psx_mod_try_function_replacement") == std::string::npos, + "default configuration emits no replacement call"); + config.mod_function_entry_funcs.insert(base + 4); + check(generate().find("psx_mod_try_function_replacement") == std::string::npos, + "listing a different address does not hook this function"); + config.mod_function_entry_funcs.insert(base); + const auto code = generate(); + const auto at = code.find(hook); + check(at != std::string::npos && code.find(hook, at + 1) == std::string::npos, + "explicit function entry emits exactly one replacement call"); + check(code.find("psx_mod_function_entry(cpu, 0x80010000u);") < at, + "existing preparation callback runs before replacement"); + check(at < code.find("block_80010000:"), "fallback body remains after replacement"); + const auto continuation = code.find("switch (_cont)"); + check(continuation != std::string::npos && continuation < at, + "interior CPS resumes bypass fresh-entry replacement"); + PSXRecomp::CodeGenerator generator(exe, config); + check(generator.build_shared_decls_header({}).find( + "extern int psx_mod_try_function_replacement(CPUState* cpu, uint32_t address);") != + std::string::npos, + "generated shards declare runtime replacement ABI"); + std::puts("PASS default-off, exact-entry, fallback and continuation-safe replacement codegen"); +} diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 76184bea..e3d77056 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -23,6 +23,9 @@ psxrecomp_add_runtime_target(psx-runtime # (docs/ENHANCEMENTS.md G1.10). Titles get their _pgxp the same way. if(BUILD_TESTING) + add_executable(mod_function_replacements_test tests/test_mod_function_replacements.c src/mod_function_replacements.c) + target_include_directories(mod_function_replacements_test PRIVATE include) + add_test(NAME mod_function_replacements_test COMMAND mod_function_replacements_test) add_executable(ws_scene_hold_test tests/test_ws_scene_hold.c) target_include_directories(ws_scene_hold_test PRIVATE include) add_test(NAME ws_scene_hold_test COMMAND ws_scene_hold_test) diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 5320fb2b..c24f3868 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -25,6 +25,15 @@ int psx_mod_register_function_entry_plugin( const char* id, uint32_t address, PSXModFunctionEntryCallback callback); /* Called only from generated functions explicitly listed by the game config. */ void psx_mod_function_entry(struct CPUState* cpu, uint32_t address); +/* Opt-in native implementation of an explicitly hooked function. Register + * during activation. A callback returns nonzero only after completing the + * function's caller-visible effects; the dispatcher publishes pc=$ra. A zero + * result must leave state untouched and falls through to the original body. + * No emulated cycles are credited automatically. Unregister with NULL. + * Registration is process configuration, not savestate payload. */ +typedef int (*PSXModFunctionReplacement)(struct CPUState* cpu, uint32_t address); +int psx_mod_set_function_replacement(uint32_t address, PSXModFunctionReplacement callback); +int psx_mod_try_function_replacement(struct CPUState* cpu, uint32_t address); /* Narrow guest services available to trusted plugin callbacks. */ int psx_mod_game_started(void); diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 5b92e4e4..af85f7f2 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -417,6 +417,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/mod_packages.cpp ${PSXRECOMP_ROOT}/runtime/src/mod_runtime.cpp ${PSXRECOMP_ROOT}/runtime/src/mod_texture_banks.c + ${PSXRECOMP_ROOT}/runtime/src/mod_function_replacements.c ${PSXRECOMP_ROOT}/runtime/src/psx_keybinds.c ${PSXRECOMP_ROOT}/runtime/src/psx_bios_backend.c ${PSXRECOMP_ROOT}/runtime/src/psx_netplay.c diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 922c9fe8..04f415db 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -2883,7 +2883,9 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ * executes the page. */ { extern void psx_mod_function_entry(CPUState *cpu, uint32_t address); + extern int psx_mod_try_function_replacement(CPUState *cpu, uint32_t address); psx_mod_function_entry(cpu, addr); + if (psx_mod_try_function_replacement(cpu, addr)) OV_FPLOG_RET1(); } /* Per-PC entry counter (visible via dirty_ram_stats). */ @@ -3273,7 +3275,15 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ * function-entry hooks as a surfaced interpreter entry. */ { extern void psx_mod_function_entry(CPUState *, uint32_t); + extern int psx_mod_try_function_replacement(CPUState *, uint32_t); psx_mod_function_entry(cpu, target); + if (psx_mod_try_function_replacement(cpu, target)) { + g_dirty_ram_native_handoffs++; + g_dirty_ram_blocks_run++; + if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; + g_dirty_interp_chain_target = cpu->pc; + OV_FPLOG_RET1(); + } } pc = target; current_page = target_phys >> 12; diff --git a/runtime/src/mod_function_replacements.c b/runtime/src/mod_function_replacements.c new file mode 100644 index 00000000..27be7bd7 --- /dev/null +++ b/runtime/src/mod_function_replacements.c @@ -0,0 +1,49 @@ +#include "cpu_state.h" +#include "mod_plugins.h" + +/* Bounded, allocation-free and emulation-thread-only. Nothing is registered + * by default; only trusted linked game code may opt in. */ +static struct { + uint32_t address; + PSXModFunctionReplacement callback; +} replacements[64]; +static unsigned replacement_count; +int psx_mod_set_function_replacement(uint32_t address, PSXModFunctionReplacement callback) { + unsigned i; + if (!address || (address & 3)) + return 0; + for (i = 0; i < replacement_count; ++i) { + if (replacements[i].address == address) { + if (callback) + replacements[i].callback = callback; + else { + replacements[i] = replacements[--replacement_count]; + replacements[replacement_count].address = 0; + replacements[replacement_count].callback = 0; + } + return 1; + } + } + if (!callback) + return 1; + if (replacement_count == 64) + return 0; + replacements[replacement_count].address = address; + replacements[replacement_count++].callback = callback; + return 1; +} +int psx_mod_try_function_replacement(CPUState *cpu, uint32_t address) { + unsigned i; + /* No table scan in faithful/default-off games or builds. */ + if (!replacement_count || !cpu) + return 0; + for (i = 0; i < replacement_count; ++i) + if (replacements[i].address == address) { + const uint32_t return_pc = cpu->gpr[31]; + if (!replacements[i].callback(cpu, address)) + return 0; + cpu->pc = return_pc; + return 1; + } + return 0; +} diff --git a/runtime/tests/test_interp_mod_entry_guards.py b/runtime/tests/test_interp_mod_entry_guards.py index 7562d5da..78e46c4a 100644 --- a/runtime/tests/test_interp_mod_entry_guards.py +++ b/runtime/tests/test_interp_mod_entry_guards.py @@ -14,6 +14,7 @@ def main(): 'psx_overlay_dispatch(cpu, addr);') < static_dispatch.index( 'g_exec_phase = previous_phase;') < static_dispatch.index('if (handled) return 1;') assert source.count('psx_mod_function_entry(cpu, addr);') == 1 + assert source.count('psx_mod_try_function_replacement(cpu, addr)') == 1 local_start = source.index('if (allow_local_dirty_flow && target != 0 &&') local_end = source.index('current_page = target_phys >> 12;', local_start) local = source[local_start:local_end] @@ -24,6 +25,7 @@ def main(): assert 'OV_FPLOG_RET1();' in probe assert 'psx_overlay_dispatch(cpu' not in probe assert local.count('psx_mod_function_entry(cpu, target);') == 1 + assert local.count('psx_mod_try_function_replacement(cpu, target)') == 1 assert local.index('overlay_loader_dispatch(cpu, target)') < local.index( 'psx_mod_function_entry(cpu, target);') < local.index('pc = target;') print('interpreter mod entry guards: PASS') diff --git a/runtime/tests/test_mod_function_replacements.c b/runtime/tests/test_mod_function_replacements.c new file mode 100644 index 00000000..10334d33 --- /dev/null +++ b/runtime/tests/test_mod_function_replacements.c @@ -0,0 +1,58 @@ +#include "cpu_state.h" +#include "mod_plugins.h" +#undef NDEBUG +#include +#include +#include +static int enabled, calls; +static int replace(CPUState *cpu, uint32_t address) { + ++calls; + if (!enabled) + return 0; + cpu->gpr[2] = address; + return 1; +} +static int replace_with_ra_clobber(CPUState *cpu, uint32_t address) { + cpu->gpr[31] = address; + return 1; +} +int main(void) { + CPUState cpu = {0}, before; + cpu.gpr[31] = 0x80001234; + cpu.pc = 0; + before = cpu; + assert(!psx_mod_try_function_replacement(&cpu, 0x80001000)); + assert(!memcmp(&before, &cpu, sizeof cpu)); + assert(!psx_mod_set_function_replacement(0, replace)); + assert(!psx_mod_set_function_replacement(0x80001001, replace)); + assert(psx_mod_set_function_replacement(0x80001000, replace)); + assert(!psx_mod_try_function_replacement(&cpu, 0x80001000)); + assert(calls == 1 && !memcmp(&before, &cpu, sizeof cpu)); + enabled = 1; + assert(!psx_mod_try_function_replacement(&cpu, 0x80001004)); + assert(psx_mod_try_function_replacement(&cpu, 0x80001000)); + assert(cpu.pc == 0x80001234 && cpu.gpr[2] == 0x80001000 && cpu.gpr[31] == before.gpr[31]); + assert(psx_mod_set_function_replacement(0x80001000, 0)); + assert(!psx_mod_try_function_replacement(&cpu, 0x80001000)); + assert(psx_mod_set_function_replacement(0x80001000, 0)); + assert(psx_mod_set_function_replacement(0x80001000, replace_with_ra_clobber)); + assert(!psx_mod_try_function_replacement(0, 0x80001000)); + cpu.gpr[31] = 0x80001234; + assert(psx_mod_try_function_replacement(&cpu, 0x80001000)); + assert(cpu.pc == 0x80001234 && cpu.gpr[31] == 0x80001000); + assert(psx_mod_set_function_replacement(0x80001000, 0)); + for (unsigned i = 0; i < 64; ++i) + assert(psx_mod_set_function_replacement(0x80002000 + 4 * i, replace)); + assert(!psx_mod_set_function_replacement(0x80004000, replace)); + assert(psx_mod_set_function_replacement(0x80002000, 0)); + assert(psx_mod_set_function_replacement(0x80004000, replace)); + assert(psx_mod_try_function_replacement(&cpu, 0x800020FC)); + assert(psx_mod_try_function_replacement(&cpu, 0x80004000)); + for (unsigned i = 1; i < 64; ++i) + assert(psx_mod_set_function_replacement(0x80002000 + 4 * i, 0)); + assert(psx_mod_set_function_replacement(0x80004000, 0)); + before = cpu; + assert(!psx_mod_try_function_replacement(&cpu, 0x80004000)); + assert(!memcmp(&before, &cpu, sizeof cpu)); + puts("PASS native replacement opt-in, fallback, return ABI, unregister and capacity"); +}