diff --git a/TCP_COMMANDS.md b/TCP_COMMANDS.md index 081ff36ef..da149da8f 100644 --- a/TCP_COMMANDS.md +++ b/TCP_COMMANDS.md @@ -49,7 +49,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `write_ram` | ✓ | ✓ | `addr`, `val` | Write **one byte** to PS1 address space. Note the parameter is `val` (not `hex`), and the write is a single byte per call — this row previously documented both incorrectly | | `read_scratch` | | ✓ | `addr`, `len` | Read PS1 scratchpad (0x1F800000 region) | | `read_vram` / `vram_peek` | ✓¹ | ✓ | `x`, `y`, `w`, `h` | Read 16-bit VRAM pixels (max 128×128) | -| `func_override` | ✓ | | — | Inventory of armed function overrides (`func_override.h`): per entry `id`, guest `addr`, `calls`, `guard_misses`, `guarded`, `credit` (the declared cycle policy — a per-handled-call charge, or `"self"` when the body charges its own). `calls` counts **consults**, declines included — so a decline-only probe proves an address crosses a hooked path, and `calls: 0` means the override was never reached (wrong address, or that path never ran). Package-gated overrides appear only after the mod plan arms them; an id may read `plugin:label` when one plugin registers several overrides | +| `func_override` | ✓ | | — | Inventory of armed function overrides (`func_override.h`): per entry `id`, guest `addr`, `calls`, `guard_misses`, `guarded`, `guard_kind`, `guard_count`, `guard_crc`, and `credit`. `guard_kind` is `none`, `words`, or `code_crc32`. `guard_crc` is meaningful for `code_crc32`. `credit` is a fixed per-handled-call charge or `"self"` when the body charges its own. `calls` counts every matched-address consult, including guard misses and declines. A decline-only probe therefore proves that an address crosses a hooked path. `calls: 0` means the override was never reached. Package-gated overrides appear only after the mod plan arms them. An id can use `plugin:label` when one plugin registers several overrides. | | `gpu_state` | ✓ | ✓ | — | Display area, display depth, draw offset, GPUSTAT, clip rect, xfer state | | `screenshot_hires` | ✓ | ✓ | `path` | PNG of the **supersampled** surface (the present path the window uses), at `display × gr_scale()`. ⚠ `screenshot`/`screenshot_file` capture native 15-bit VRAM and are **blind to anything that only exists in the hi-res mirror** — geometry correction, SSAA edges, perspective UVs — so they show a clean frame while the player sees a broken one. Use this one to verify those. Falls back to the native resolve (and reports `scale: 1`) when no hi-res surface exists | | `present_shot` | ✓ | | `path` | PNG of the **composed present surface** — the frame after the backend fits the display buffer to the window, so it carries the presented aspect. ⚠ every other capture resolves the display buffer *before* that fit: on a 508×256 display in a 4:3 window they answer 508×256 while the player sees 640×480. Use this one for anything aspect-shaped (widescreen, letterbox), where a pre-fit buffer would hide the very stage the change touches. Staged and fulfilled on the next present, so the ack means *queued* — poll `present_shot_seq`. Unavailable headless and on the Vulkan backend (its swapchain has no readback hook) | diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 110e86e44..329e71b91 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -322,20 +322,33 @@ 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. +a guest function at a given address. A short prologue-word guard is available +for fixed code. It is not an overlay identity. Use +`psx_mod_register_function_override_exact` for an overlay or a reused dirty-RAM +address. That API checks the CRC32 of all declared code ranges. It uses the +same page-generation cache as native overlay dispatch. + +Registration only queues the override. The commit checks the complete selected +set for address collisions and capacity. A collision rejects the plan before +activation. Activation arms the complete set before it runs any activation +callback. An unexpected arming error removes the complete package set. +It also disables all main-memory writes, disc patches, and callbacks from the +selected plan. A later successful commit clears this failure state. Invalid +plugin ids fail during registration. + +Calls and unlinked function tail entries use the same override. These entries +are `j`, or `jr` through a register other than `$ra`. A `jr $ra` is a return. +A handled override normally continues at `$ra`. Dispatch preserves a nonzero +`cpu->pc` that the override, a guest call, or a wrapped original selects. Every +matched-address consult increments `calls`. An identity mismatch also +increments `guard_misses`. + +Every registration states a required guest-cycle `credit`. Use a fixed charge +for a measured constant cost. Use `0` for a mod with no hardware analog. Use +`FO_CREDIT_SELF` when the body or a wrapped original accounts for its own time. +The full execution contract is in `runtime/include/func_override.h`. Direct +registrations through `func_override_add` bypass package gating and are always +active. Use packages for player-selectable behavior. `psx_mod_set_load_acceleration(multiplier, release_frames)` is the narrow pre-boot service for a game-owned fast-loading feature. It changes host diff --git a/docs/TESTING.md b/docs/TESTING.md index 2fe84b139..e4e6bead5 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -58,19 +58,21 @@ and catch whole regression classes without running a game. Registered from runtime tree cannot configure until a BIOS has been generated, and these need neither. -## Known-failing tests (not registered) +The `overlay_pair_dedup_runtime` test is an executable loader test, not a +source-invariant guard. A plain recompiler build keeps it registered but +disables it when CMake cannot find `gcc`, `cc`, or `clang`. The test builds +redistributable fixture libraries and links a harness against the real loader. -Three tests exist and are **deliberately left out of `ctest`** because they fail -today. They are not registered because a suite with a known-red test is a suite -people stop believing — the exact failure mode that took CI off pull requests in -the first place (see `.github/workflows/cli-release.yml`). Fix or retire them, -then wire them in. +## Known-failing tests (registered as disabled) + +Two tests are **deliberately disabled in `ctest`** because they fail today. They +stay registered so `ctest -N` counts them without making the suite red. Fix or +retire each test, then remove its `DISABLED` property. | Test | Status | |---|---| | `runtime/tests/test_interpreter_perf_guards.py` | Asserts `psx_devices_mmio_sync` invalidates the inline cycle limit. It does not: the function delegates to `psx_devices_service_to_now()` (which clears `g_psx_cycle_fast_limit`, `psx_cycles.c:161`) **or** to `psx_devices_recompute_deadline()` (`:153-157`), and that second branch never clears it. Needs a timing owner to decide whether the guard found a real hole or the invariant moved. The guard is also partly stale — it still names `s_next_service_cycle`, since renamed to `psx_next_service_cycle`. | | `runtime/tests/test_runtime_perf_diag_guards.py` | Asserts a substring that is no longer present in the runtime source. Either the diagnostic was removed or it was renamed; the guard has not been updated either way. | -| `runtime/tests/test_overlay_pair_dedup_runtime.py` | Needs its companion harness (`overlay_pair_dedup_harness.c`) built. Unlike the other Python tests it is not source-only, so it needs a build target before it can be registered. | ## Tests that are not in `ctest` and should not be diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 4e74eddcd..1f0c62f4d 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -628,8 +628,25 @@ if(BUILD_TESTING) WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) endforeach() + # This executable loader test is not a source-invariant guard. It + # builds redistributable fixture DLLs and a real-loader harness with a + # GCC-compatible command line. Keep plain MSVC-only recompiler builds + # green when that separate compiler is unavailable, but keep the test + # registered and visible to the registration audit. + find_program(PSX_OVERLAY_FIXTURE_CC NAMES gcc cc clang) + add_test( + NAME overlay_pair_dedup_runtime + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_overlay_pair_dedup_runtime.py + --gcc ${PSX_OVERLAY_FIXTURE_CC} + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + if(NOT PSX_OVERLAY_FIXTURE_CC) + set_tests_properties(overlay_pair_dedup_runtime + PROPERTIES DISABLED TRUE) + endif() + # ---- known-failing guards: DISABLED, not de-registered --------------- - # These three were previously left out of the list above with a comment + # These two were previously left out of the list above with a comment # explaining that they FAIL and that wiring a red check would recreate # the problem that took CI off PRs. That reasoning is right; the remedy # was not. De-registering is precisely how a test stops being counted, @@ -646,12 +663,9 @@ if(BUILD_TESTING) # s_next_service_cycle # runtime_perf_diag_guards asserts a substring no longer present in # the runtime source - # overlay_pair_dedup_runtime needs its companion harness built; not a - # source-only guard foreach(_t IN ITEMS interpreter_perf_guards - runtime_perf_diag_guards - overlay_pair_dedup_runtime) + runtime_perf_diag_guards) add_test( NAME ${_t} COMMAND ${Python3_EXECUTABLE} diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index 601984910..ae7caf1c6 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -1717,8 +1717,10 @@ void FullFunctionEmitter::emit_dispatch( 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 += " * guest resumes at $ra unless the body selected a non-local pc. */\n"; out += "extern int (*g_psx_func_override_hook)(CPUState* cpu, uint32_t phys);\n\n"; + out += "extern int func_override_try_dispatch(CPUState* cpu, uint32_t target,\n"; + out += " uint32_t default_pc);\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"; @@ -2056,12 +2058,11 @@ void FullFunctionEmitter::emit_dispatch( 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 += " * Handled (rc 1) => use the body's non-local cpu->pc when\n"; + out += " * set, otherwise resume at $ra. rc 0 => fall through\n"; + out += " * without changing the incoming cpu->pc. */\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 += " func_override_try_dispatch(cpu, addr, cpu->gpr[31])) {\n"; out += " found = 1;\n"; out += " }\n"; out += "#ifdef PSX_HAS_GAME_DISPATCH\n"; diff --git a/recompiler/tests/full_function_emitter_test.cpp b/recompiler/tests/full_function_emitter_test.cpp index 747e977d4..f9245c937 100644 --- a/recompiler/tests/full_function_emitter_test.cpp +++ b/recompiler/tests/full_function_emitter_test.cpp @@ -197,6 +197,24 @@ void complementary_lwl_lwr_stays_native() { "complementary LWL/LWR remains native"); } +void function_override_preserves_nonlocal_pc() { + const auto result = run_case( + "override-continuation", + { + 0x03E00008u, // jr ra + 0x00000000u, // nop + }, + {function_at(kBase, kBase + 4u, {kBase})}); + expect(result.dispatch.find( + "func_override_try_dispatch(cpu, addr, cpu->gpr[31])") != + std::string::npos, + "generated dispatch uses the continuation-preserving override helper"); + expect(result.dispatch.find( + "cpu->pc = cpu->gpr[31];\n found = 1;\n }\n#ifdef PSX_HAS_GAME_DISPATCH") == + std::string::npos, + "generated dispatch must not overwrite a non-local override continuation"); +} + } // namespace int main() { @@ -221,6 +239,7 @@ int main() { fragment_split_load_falls_back(); noncomplementary_lwl_falls_back(); complementary_lwl_lwr_stays_native(); + function_override_preserves_nonlocal_pc(); if (failures != 0) { std::fprintf(stderr, "%d full-function emitter test(s) failed\n", failures); diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index e12d475f3..dd08959fc 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -226,7 +226,8 @@ if(BUILD_TESTING) # declining. Drives the real g_psx_func_override_hook pointer. add_executable(func_override_test tests/test_func_override.c - src/func_override.c) + src/func_override.c + src/crc32.c) target_include_directories(func_override_test PRIVATE include) add_test(NAME func_override_test COMMAND func_override_test) @@ -296,7 +297,17 @@ if(BUILD_TESTING) target_include_directories(mod_runtime_test PRIVATE include ../recompiler/lib/toml11) + target_compile_definitions(mod_runtime_test PRIVATE + PSX_MOD_RUNTIME_TEST=1) target_link_libraries(mod_runtime_test PRIVATE chdr-static) + # sha256_file carries ISOReader's large CHD decode state on this focused + # test's stack. Match the product runtime's Windows stack contract so the + # test reaches its assertions under both MinGW/Clang and MSVC. + if(MINGW) + target_link_options(mod_runtime_test PRIVATE -Wl,--stack,67108864) + elseif(MSVC) + target_link_options(mod_runtime_test PRIVATE /STACK:67108864,67108864) + endif() add_test(NAME mod_runtime_test COMMAND mod_runtime_test) if(WIN32) @@ -548,6 +559,9 @@ if(BUILD_TESTING) add_test(NAME cdrom_lid_integration_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_cdrom_lid_integration.py) + add_test(NAME func_override_route_test + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_func_override_routes.py) endif() # Full card protocol regression: read, write, address echo, per-slot state, diff --git a/runtime/include/func_override.h b/runtime/include/func_override.h index 3e7362597..b6cea3025 100644 --- a/runtime/include/func_override.h +++ b/runtime/include/func_override.h @@ -32,11 +32,12 @@ * 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. + * Guest calls and unlinked function tail transfers (`j`, or `jr` through a + * register other than $ra) consult the same override. A handled override + * normally resumes at $ra. If the body or guest + * code that it invokes leaves a nonzero cpu->pc, dispatch preserves that + * continuation instead. This is required for longjmp-style guest control + * flow and for wrappers whose original function does not return locally. * * THE CONTRACT * @@ -119,6 +120,7 @@ extern "C" { #define FO_MAX_OVERRIDES 128 #define FO_MAX_ID 64 #define FO_MAX_GUARD_WORDS 4 +#define FO_MAX_CODE_RANGES 16 /* `credit` value: the override body states its own timing by calling * psx_advance_cycles() itself (mandatory for wraps — see CYCLE ACCOUNTING @@ -131,6 +133,10 @@ extern "C" { #define FO_ERR_ARGS 2 #define FO_ERR_DUPLICATE 3 +#define FO_GUARD_NONE 0 +#define FO_GUARD_WORDS 1 +#define FO_GUARD_CODE_CRC32 2 + /* 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. */ @@ -156,6 +162,26 @@ int func_override_add_guarded(const char *id, uint32_t addr, FuncOverrideFn fn, const uint32_t *expected_words, int n_words, int32_t credit); +/* Exact code-identity guard for overlays and reused dirty-RAM addresses. + * `lo_len_pairs` contains n_ranges immutable {guest_start, byte_length} + * pairs. The concatenated bytes must match expected_crc (IEEE CRC32) before + * the override can run. The ranges are copied at registration. They must be + * word-aligned, non-overlapping RAM ranges and must cover addr. This uses the + * overlay loader's page-generation cache, so unchanged code does not re-hash + * on every call. A short prefix guard is only a quick sanity check; use this + * API when the resident overlay generation must be identified exactly. + * n_ranges must be 1..FO_MAX_CODE_RANGES. */ +int func_override_add_exact(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *lo_len_pairs, int n_ranges, + uint32_t expected_crc, int32_t credit); + +/* Validate the range shape used by func_override_add_exact. n_ranges must be + * 1..FO_MAX_CODE_RANGES. Exposed so the package registry can reject bad + * constructor input before plan selection. */ +int func_override_code_ranges_valid(uint32_t addr, + const uint32_t *lo_len_pairs, + int n_ranges); + /* 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 @@ -182,6 +208,13 @@ void func_override_guest_call(struct CPUState *cpu, uint32_t target, * guest-level wrap would observe). */ void func_override_call_original(struct CPUState *cpu); +/* Shared dispatcher boundary. It clears stale cpu->pc before the consult, + * restores it when the override declines, and on a handled call uses + * default_pc only when the override did not choose a non-local continuation. + * Generated dispatch and dirty-RAM j/jr tail entry both use this helper. */ +int func_override_try_dispatch(struct CPUState *cpu, uint32_t target, + uint32_t default_pc); + /* 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. @@ -190,6 +223,12 @@ void func_override_call_original(struct CPUState *cpu); int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn, const uint32_t *expected_words, int n_words, int32_t credit); +/* Exact package form. n_ranges must be 1..FO_MAX_CODE_RANGES. */ +int func_override_add_package_exact(const char *id, uint32_t addr, + FuncOverrideFn fn, + const uint32_t *lo_len_pairs, + int n_ranges, uint32_t expected_crc, + int32_t credit); /* Install the dispatcher hook. Call once at startup AFTER registering (the * mod runtime calls it again after arming package-gated overrides — safe). @@ -209,6 +248,10 @@ void func_override_install(void); * peers' builds and not player-selectable. */ int func_override_reset_package_armed(void); +/* Package-plan preflight uses this to ignore entries from an older selected + * plan while still detecting conflicts with direct registrations. */ +int func_override_is_package(int index); + /* 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 @@ -222,6 +265,8 @@ 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); +int func_override_get_guard_info(int index, int *kind_out, int *count_out, + uint32_t *expected_crc_out); #ifdef __cplusplus } diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 19352f6b9..9a92980e2 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -379,6 +379,7 @@ bool mod_register_activation_plugin(const std::string& id, void (*callback)(void bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)); bool mod_plugin_registered(const std::string& id); bool mod_register_function_override_marker(const std::string& id); +bool mod_plugin_id_valid(const std::string& id); void mod_invoke_activation_plugin(const std::string& id); void mod_invoke_vblank_plugin(const std::string& id); void mod_clear_plugins_for_tests(); diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index d38dd136c..72477dbf5 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -56,6 +56,15 @@ int psx_mod_register_function_override(const char* id, uint32_t address, const uint32_t* expected_words, int n_words, int32_t credit); +/* Exact overlay identity form. `lo_len_pairs` contains n_ranges guest code + * {start, byte_length} pairs. The override runs only while their concatenated + * CRC32 equals expected_crc. Use this for overlay addresses; the short word + * prefix above does not identify a complete overlay generation. */ +int psx_mod_register_function_override_exact( + const char* id, uint32_t address, PSXModFunctionOverrideFn fn, + const uint32_t* lo_len_pairs, int n_ranges, uint32_t expected_crc, + int32_t credit); + /* Narrow guest services available to trusted plugin callbacks. */ int psx_mod_game_started(void); uint8_t psx_mod_read_byte(uint32_t address); diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index 20a3b8479..43a7a34dc 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -22,6 +22,13 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path = {}, * user's persisted offline selection on disk. Netplay is always vanilla for * now (no synced mod plans). */ bool mod_runtime_clear_for_netplay(std::string* error = nullptr); +/* Arm the committed plan's package function overrides without running + * activation callbacks. mod_runtime_commit disarms the previous set, so a + * path that commits without activating (the offline lobby rematch, which + * jumps past mod_runtime_activate_plugins) must re-arm here or the selected + * overrides stay silently off. Fails closed like activation: on a late + * table collision the whole plan is dropped and *error names the entry. */ +bool mod_runtime_arm_function_overrides(std::string* error = nullptr); const std::string& mod_runtime_fingerprint(); const std::filesystem::path& mod_runtime_effective_disc_path(); diff --git a/runtime/include/overlay_loader.h b/runtime/include/overlay_loader.h index 122f3dc7a..402252822 100644 --- a/runtime/include/overlay_loader.h +++ b/runtime/include/overlay_loader.h @@ -59,6 +59,19 @@ void overlay_loader_clear_lazy_miss(void); * cache so native vs interp is re-decided against restored bytes. */ void overlay_loader_resync_validation_after_restore(void); +/* Exact code-range identity shared by generated static overlays and guarded + * function overrides. The pair array must remain at a stable address so the + * page-generation cache can key it without copying. */ +int psx_overlay_static_code_matches(const uint32_t *lo_len_pairs, + uint32_t count, + uint32_t expected_crc); +void overlay_loader_static_match_stats(uint64_t *rehashes, + uint64_t *crc_misses, + uint64_t *gen_fastpath); +/* Range-array addresses are cache identities. Call this before mutable owners + * reuse an address for different ranges. Generated arrays are immutable. */ +void overlay_loader_static_match_cache_clear(void); + /* Step 2.8: re-scan the cache dir for DLLs compiled after init and clear the * checked-regions memo so the next dispatch reconsiders the cache. Idempotent * (loaded DLLs stay loaded); emu thread only. */ diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 0b171b99c..e5a8d9251 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9004,9 +9004,9 @@ static void handle_wide_full(int id, const char *json) } /* 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. */ + * {id, addr, calls, guard identity, 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); @@ -9014,8 +9014,11 @@ static void handle_func_override(int id, const char *json) uint32_t *addr_out, uint64_t *calls_out, uint64_t *guard_misses_out, int *guarded_out, int32_t *credit_out); + extern int func_override_get_guard_info(int index, int *kind_out, + int *count_out, + uint32_t *expected_crc_out); (void)json; - char buf[16 * 1024]; + char buf[32 * 1024]; int n = snprintf(buf, sizeof(buf), "{\"id\":%d,\"ok\":true,\"count\":%d,\"overrides\":[", id, func_override_count()); @@ -9024,11 +9027,17 @@ static void handle_func_override(int id, const char *json) uint32_t addr = 0; uint64_t calls = 0, misses = 0; int guarded = 0; + int guard_kind = 0, guard_count = 0; + uint32_t guard_crc = 0; int32_t credit = 0; char creditstr[16]; if (!func_override_get_ex(i, oid, sizeof(oid), &addr, &calls, &misses, &guarded, &credit)) break; + (void)func_override_get_guard_info(i, &guard_kind, &guard_count, + &guard_crc); + const char *guard_name = guard_kind == 2 ? "code_crc32" + : guard_kind == 1 ? "words" : "none"; /* credit: the declared cycle policy — a number (fixed per-call * charge) or the string "self" (the body charges its own). */ if (credit < 0) @@ -9043,10 +9052,12 @@ static void handle_func_override(int id, const char *json) * into an overflow. */ const int w = snprintf(buf + n, sizeof(buf) - (size_t)n, "%s{\"id\":\"%s\",\"addr\":\"0x%08X\",\"calls\":%llu," - "\"guard_misses\":%llu,\"guarded\":%d,\"credit\":%s}", + "\"guard_misses\":%llu,\"guarded\":%d," + "\"guard_kind\":\"%s\",\"guard_count\":%d," + "\"guard_crc\":\"0x%08X\",\"credit\":%s}", i ? "," : "", oid, addr, (unsigned long long)calls, (unsigned long long)misses, - guarded, creditstr); + guarded, guard_name, guard_count, guard_crc, creditstr); if (w < 0) break; n += w; if ((size_t)n >= sizeof(buf) - 256) { diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 84013a016..35306fed3 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -35,6 +35,7 @@ #include "lockstep.h" #include "starvation_ring.h" #include "fntrace.h" /* fntrace_is_game_started / fntrace_mark_game_started */ +#include "func_override.h" #include #include @@ -483,6 +484,9 @@ uint32_t g_overlay_region_floor = OVERLAY_REGION_FLOOR_DEFAULT; * high-loading game pins it; main.cpp sets it at game load. See the header. */ uint32_t g_text_image_lo = DIRTY_RAM_KERNEL_WINDOW_END; +/* func_override.c: NULL unless an override is armed. */ +extern int (*g_psx_func_override_hook)(CPUState *cpu, uint32_t phys); + #ifdef PSX_HAS_GAME_DISPATCH extern int psx_dispatch_game_compiled(CPUState* cpu, uint32_t addr); extern int psx_game_address_in_text(uint32_t addr); @@ -2869,6 +2873,19 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ int _ovfp = overlay_fp_enabled() && overlay_cache_window_contains(phys) && overlay_loader_is_candidate(phys); + /* Function-entry provenance only feeds the J/JR override route below. + * With no override armed, skip both entry lookups so a build without + * overrides pays nothing here. Zero is the conservative value: it can + * only make the route decline, never consult on an unproven entry. */ + uint32_t current_function_entry_phys = 0u; + if (g_psx_func_override_hook) { + if (overlay_loader_is_candidate(phys)) + current_function_entry_phys = phys; +#ifdef PSX_HAS_GAME_DISPATCH + else if (psx_game_is_function_entry(addr)) + current_function_entry_phys = phys; +#endif + } uint32_t _in_regs[34]; if (_ovfp) { overlay_regs_snap(_in_regs, cpu); @@ -3210,6 +3227,46 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ OV_FPLOG_RET1(); } target = cpu->pc; + uint32_t target_phys = target & 0x1FFFFFFFu; + /* A J/JR can be an unlinked function tail entry or an ordinary + * intra-function jump. Only a known static or overlay function + * entry proves the former. Check the override after the interrupt + * safe point and before local dirty flow, then surface the + * continuation so dispatch stays flat. Precise/replay mode keeps + * the same plain-transfer policy as JAL/JALR above. */ + const uint32_t opc = insn >> 26; + const int unlinked_tail = + opc == 0x02u || + (opc == 0x00u && (insn & 0x3Fu) == 0x08u && + ((insn >> 21) & 0x1Fu) != 31u); + /* Test the hook before the entry lookups: every interpreted + * J/JR reaches this point, and with no override armed the + * lookups can only feed a route that declines anyway. */ + int target_is_function_entry = 0; + if (unlinked_tail && g_psx_func_override_hook) { + target_is_function_entry = + overlay_loader_is_candidate(target_phys); +#ifdef PSX_HAS_GAME_DISPATCH + if (!target_is_function_entry) + target_is_function_entry = + psx_game_is_function_entry(target); +#endif + } + const uint32_t source_phys = pc & 0x1FFFFFFFu; + const int proven_tail_entry = + target != 0u && target_phys != source_phys && + current_function_entry_phys != 0u && + target_phys != current_function_entry_phys && + target_is_function_entry; + if (unlinked_tail && proven_tail_entry && + !g_precise_mode && !g_ls_replay_active && + func_override_try_dispatch(cpu, target, cpu->gpr[31])) { + 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(); + } + target = cpu->pc; #ifdef PSX_HAS_GAME_DISPATCH if (target != 0) { /* §20 FIX — the long-run idle freeze. A guest TAIL-transfer (j/jr/ @@ -3238,11 +3295,12 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ cpu->pc = target; /* surfaced; trampoline re-dispatches flat */ } #endif - uint32_t target_phys = target & 0x1FFFFFFFu; if (allow_local_dirty_flow && target != 0 && target != stop_addr && phys_is_overlay_flow_region(target_phys) && dirty_ram_is_dirty(target_phys)) { + if (unlinked_tail && target_is_function_entry) + current_function_entry_phys = target_phys; /* A runtime overlay may start executing while its final code * bytes are still being installed. Entry-time native validation * must reject that partial image, but local dirty flow used to diff --git a/runtime/src/func_override.c b/runtime/src/func_override.c index 9575d04d2..0cb471976 100644 --- a/runtime/src/func_override.c +++ b/runtime/src/func_override.c @@ -12,6 +12,7 @@ #include #include "cpu_state.h" +#include "overlay_loader.h" #include "psx_cycles.h" /* Residency-guard reads go through the UNTRACED peek, never psx_read_word. @@ -39,6 +40,9 @@ typedef struct { uint64_t guard_misses; uint32_t guard[FO_MAX_GUARD_WORDS]; int n_guard; /* 0 = unguarded */ + uint32_t ranges[FO_MAX_CODE_RANGES * 2]; + int n_ranges; /* 0 = no exact CRC identity guard */ + uint32_t expected_crc; int package; /* 1 = armed by a mod package plan */ int32_t credit; /* >= 0 charged per handled call; FO_CREDIT_SELF = body self-charges */ @@ -65,13 +69,44 @@ static Entry *find(uint32_t phys) return NULL; } +int func_override_code_ranges_valid(uint32_t addr, + const uint32_t *lo_len_pairs, + int n_ranges) +{ + if (!lo_len_pairs || n_ranges < 1 || n_ranges > FO_MAX_CODE_RANGES) + return 0; + const uint32_t entry = normalise(addr); + int entry_covered = 0; + for (int i = 0; i < n_ranges; i++) { + const uint32_t lo = normalise(lo_len_pairs[i * 2]); + const uint32_t len = lo_len_pairs[i * 2 + 1]; + if ((lo & 3u) || (len & 3u) || len < 4u || + lo >= 2u * 1024u * 1024u || + len > 2u * 1024u * 1024u - lo) + return 0; + if (entry >= lo && entry - lo <= len - 4u) entry_covered = 1; + for (int p = 0; p < i; p++) { + const uint32_t prev_lo = normalise(lo_len_pairs[p * 2]); + const uint32_t prev_hi = prev_lo + lo_len_pairs[p * 2 + 1]; + const uint32_t hi = lo + len; + if (lo < prev_hi && prev_lo < hi) return 0; + } + } + return entry_covered; +} + static int add_common(const char *id, uint32_t addr, FuncOverrideFn fn, - const uint32_t *guard, int n_guard, int package, - int32_t credit) + const uint32_t *guard, int n_guard, + const uint32_t *ranges, int n_ranges, + uint32_t expected_crc, 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; + if (n_guard > 0 && n_ranges > 0) return FO_ERR_ARGS; + if (n_ranges < 0 || n_ranges > FO_MAX_CODE_RANGES) return FO_ERR_ARGS; + if (n_ranges > 0 && !func_override_code_ranges_valid( + addr, ranges, n_ranges)) 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. */ @@ -98,6 +133,9 @@ static int add_common(const char *id, uint32_t addr, FuncOverrideFn fn, e->fn = fn; for (int i = 0; i < n_guard; i++) e->guard[i] = guard[i]; e->n_guard = n_guard; + for (int i = 0; i < n_ranges * 2; i++) e->ranges[i] = ranges[i]; + e->n_ranges = n_ranges; + e->expected_crc = expected_crc; e->package = package ? 1 : 0; e->credit = credit; s_count++; @@ -107,7 +145,7 @@ static int add_common(const char *id, uint32_t addr, FuncOverrideFn fn, int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn, int32_t credit) { - return add_common(id, addr, fn, NULL, 0, 0, credit); + return add_common(id, addr, fn, NULL, 0, NULL, 0, 0, 0, credit); } int func_override_add_guarded(const char *id, uint32_t addr, FuncOverrideFn fn, @@ -115,7 +153,17 @@ int func_override_add_guarded(const char *id, uint32_t addr, FuncOverrideFn fn, int32_t credit) { if (n_words < 1) return FO_ERR_ARGS; - return add_common(id, addr, fn, expected_words, n_words, 0, credit); + return add_common(id, addr, fn, expected_words, n_words, NULL, 0, 0, + 0, credit); +} + +int func_override_add_exact(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *lo_len_pairs, int n_ranges, + uint32_t expected_crc, int32_t credit) +{ + if (n_ranges < 1) return FO_ERR_ARGS; + return add_common(id, addr, fn, NULL, 0, lo_len_pairs, n_ranges, + expected_crc, 0, credit); } int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn, @@ -124,7 +172,18 @@ int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn, { 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); + NULL, 0, 0, 1, credit); +} + +int func_override_add_package_exact(const char *id, uint32_t addr, + FuncOverrideFn fn, + const uint32_t *lo_len_pairs, + int n_ranges, uint32_t expected_crc, + int32_t credit) +{ + if (n_ranges < 1) return FO_ERR_ARGS; + return add_common(id, addr, fn, NULL, 0, lo_len_pairs, n_ranges, + expected_crc, 1, credit); } /* The hook. Runs on EVERY dispatch, so the common path — nothing registered @@ -136,6 +195,10 @@ 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; + /* Every matched-address consult counts, including an exact/prefix + * identity miss and the one-shot original bypass. This makes calls + * a reachability counter rather than an execution counter. */ + e->calls++; /* One-shot bypass: func_override_call_original re-dispatched this * address; let the original backend take it exactly once. */ if (s_bypass_phys == phys) { @@ -159,12 +222,11 @@ static int hook(CPUState *cpu, uint32_t phys) 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++; + if (e->n_ranges && !psx_overlay_static_code_matches( + e->ranges, (uint32_t)e->n_ranges, e->expected_crc)) { + e->guard_misses++; + return 0; + } { const uint32_t saved_active = s_active_phys; s_active_phys = phys; @@ -184,6 +246,20 @@ static int hook(CPUState *cpu, uint32_t phys) return 0; } +int func_override_try_dispatch(CPUState *cpu, uint32_t target, + uint32_t default_pc) +{ + if (!cpu || !g_psx_func_override_hook) return 0; + const uint32_t saved_pc = cpu->pc; + cpu->pc = 0; + if (!g_psx_func_override_hook(cpu, normalise(target))) { + cpu->pc = saved_pc; + return 0; + } + if (cpu->pc == 0) cpu->pc = default_pc; + return 1; +} + 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 @@ -240,6 +316,10 @@ int func_override_reset_package_armed(void) } s_count = w; if (removed) { + /* Entry compaction and later package re-arming can reuse the address + * of an exact range array. The overlay matcher keys immutable arrays + * by address, so discard those cache identities before reuse. */ + overlay_loader_static_match_cache_clear(); s_active_phys = 0; s_bypass_phys = 0; func_override_install(); /* follows s_count back to NULL if empty */ @@ -249,6 +329,11 @@ int func_override_reset_package_armed(void) int func_override_count(void) { return s_count; } +int func_override_is_package(int index) +{ + return (index >= 0 && index < s_count) ? s_entries[index].package : 0; +} + int func_override_get(int index, char *id_out, size_t id_cap, uint32_t *addr_out, uint64_t *calls_out) { @@ -272,7 +357,23 @@ int func_override_get_ex(int index, char *id_out, size_t id_cap, 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 (guarded_out) *guarded_out = e->n_guard ? e->n_guard + : e->n_ranges; if (credit_out) *credit_out = e->credit; return 1; } + +int func_override_get_guard_info(int index, int *kind_out, int *count_out, + uint32_t *expected_crc_out) +{ + if (index < 0 || index >= s_count) return 0; + const Entry *e = &s_entries[index]; + const int kind = e->n_ranges ? FO_GUARD_CODE_CRC32 + : (e->n_guard ? FO_GUARD_WORDS + : FO_GUARD_NONE); + if (kind_out) *kind_out = kind; + if (count_out) *count_out = e->n_ranges ? e->n_ranges : e->n_guard; + if (expected_crc_out) + *expected_crc_out = e->n_ranges ? e->expected_crc : 0; + return 1; +} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 4d6c00816..681d15fcc 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -14742,6 +14742,16 @@ int main(int argc, char** argv) { mod_error.c_str()); SDL_Quit(); return 1; + } else if (!PSXRecompV4::mod_runtime_arm_function_overrides( + &mod_error)) { + /* Commit disarmed the previous override set and + * session_reboot is past mod_runtime_activate_plugins, + * so re-arm here. On failure the plan is dropped and the + * rematch runs vanilla; say so rather than stay silent. */ + std::fprintf(stderr, + "psxrecomp: selected mods disabled for " + "rematch: %s\n", + mod_error.c_str()); } } apply_netplay_local_viewport_aspect(net_cfg.enabled); diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 9796244fd..a960ce861 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -1456,11 +1456,15 @@ bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)) { 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; + if (plugin.function_override) return true; plugin.function_override = true; return true; } +bool mod_plugin_id_valid(const std::string& id) { + return valid_id(id); +} + bool mod_plugin_registered(const std::string& id) { const auto found = registered_plugins().find(id); return found != registered_plugins().end() && diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 052033726..f54cd713a 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -101,6 +101,9 @@ struct FunctionOverridePlugin { PSXModFunctionOverrideFn fn = nullptr; uint32_t guard[FO_MAX_GUARD_WORDS] = {0, 0, 0, 0}; int n_guard = 0; + uint32_t ranges[FO_MAX_CODE_RANGES * 2] = {}; + int n_ranges = 0; + uint32_t expected_crc = 0; int32_t credit = 0; bool armed = false; }; @@ -110,6 +113,48 @@ std::vector& function_override_plugins() { return value; } +bool plan_selects_plugin(const ModResolution& plan, const std::string& id) { + return std::any_of( + plan.plugins.begin(), plan.plugins.end(), + [&](const ModResolution::Plugin& plugin) { return plugin.id == id; }); +} + +bool validate_function_override_plan(const ModResolution& plan, + std::string& error) { + std::map claims; + int total = 0; + for (int i = 0; i < func_override_count(); ++i) { + if (func_override_is_package(i)) continue; + char id[FO_MAX_ID]; + uint32_t address = 0; + if (!func_override_get_ex(i, id, sizeof(id), &address, nullptr, + nullptr, nullptr, nullptr)) + continue; + claims[address] = id[0] ? id : "direct registration"; + ++total; + } + for (const FunctionOverridePlugin& pending : function_override_plugins()) { + if (!plan_selects_plugin(plan, pending.plugin)) continue; + const uint32_t address = pending.address & 0x1FFFFFFFu; + const auto inserted = claims.emplace(address, pending.id); + if (!inserted.second) { + std::ostringstream out; + out << "function override collision at 0x" << std::hex + << std::uppercase << std::setw(8) << std::setfill('0') + << address << ": " << inserted.first->second << " and " + << pending.id; + error = out.str(); + return false; + } + ++total; + } + if (total > FO_MAX_OVERRIDES) { + error = "function override plan exceeds FO_MAX_OVERRIDES"; + return false; + } + return true; +} + const ModPackage* selected_package(const std::string& id) { return state().manager.selected_package(id); } @@ -1104,6 +1149,9 @@ bool mod_runtime_initialize(const std::filesystem::path& root, const std::filesystem::path& exe_path, std::string* error) { RuntimeMods& s = state(); + func_override_reset_package_armed(); + for (FunctionOverridePlugin& pending : function_override_plugins()) + pending.armed = false; s.manager.set_root({}); s.plan = {}; s.validation = {}; @@ -1197,6 +1245,10 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err if (error) *error = s.error; return false; } + if (!validate_function_override_plan(plan, s.error)) { + if (error) *error = s.error; + return false; + } for (const ModResolution::Overlay& overlay : plan.overlays) { if (overlay.expected_sha256.empty()) continue; std::string actual; @@ -1220,10 +1272,18 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err if (error) *error = s.error; return false; } + /* A successful commit replaces the old package plan. Disarm its override + * table now, before publishing the new plan, so no dispatch window can + * run callbacks from one plan and overrides from another. Activation + * below will arm the complete new set atomically. */ + func_override_reset_package_armed(); + for (FunctionOverridePlugin& pending : function_override_plugins()) + pending.armed = false; s.plan = std::move(plan); build_disc_index(s); s.effective_disc_path = std::move(effective_disc); s.main_applied = false; + s.disc_guard_failed = false; s.error.clear(); return true; } @@ -1308,35 +1368,80 @@ extern "C" void mod_runtime_enable_disc_patches(void) { PSXRecompV4::state().disc_enabled = true; } +#ifdef PSX_MOD_RUNTIME_TEST +extern "C" void mod_runtime_clear_function_override_plugins_for_tests(void) { + PSXRecompV4::function_override_plugins().clear(); +} +#endif + +namespace PSXRecompV4 { + +bool mod_runtime_arm_function_overrides(std::string* error) { + RuntimeMods& s = state(); + if (!s.initialized || !s.plan.ok) return true; + /* Commit already preflighted collisions and capacity. If the table + * changed after commit, roll back the complete package set and fail + * closed instead of silently arming a subset. */ + func_override_reset_package_armed(); + for (FunctionOverridePlugin& pending : function_override_plugins()) + pending.armed = false; + for (FunctionOverridePlugin& pending : function_override_plugins()) { + if (!plan_selects_plugin(s.plan, pending.plugin)) continue; + const int rc = pending.n_ranges + ? func_override_add_package_exact( + pending.id.c_str(), pending.address, pending.fn, + pending.ranges, pending.n_ranges, pending.expected_crc, + pending.credit) + : func_override_add_package( + pending.id.c_str(), pending.address, pending.fn, + pending.n_guard ? pending.guard : nullptr, pending.n_guard, + pending.credit); + pending.armed = (rc == FO_OK); + if (rc != FO_OK) { + func_override_reset_package_armed(); + for (FunctionOverridePlugin& item : function_override_plugins()) + item.armed = false; + std::ostringstream out; + out << "function override activation failed for " << pending.id + << " (error " << rc << ")"; + s.error = out.str(); + /* A late table change invalidated the committed selection. Drop + * every part of that plan. Main-RAM writes, disc patches, and + * callbacks must not continue after its override set failed. */ + s.plan = {}; + s.validation = {}; + s.raw_disc_index.clear(); + s.user_disc_index.clear(); + s.raw_overlay_index.clear(); + s.user_overlay_index.clear(); + s.effective_disc_path.clear(); + s.main_applied = true; + s.disc_guard_failed = true; + func_override_install(); + /* The launcher reads s.error through provider_error. Keep this + * failure on that existing diagnostics boundary. */ + if (error) *error = s.error; + return false; + } + } + func_override_install(); + return true; +} + +} // namespace PSXRecompV4 + extern "C" void mod_runtime_activate_plugins(void) { using namespace PSXRecompV4; RuntimeMods& s = state(); if (!s.initialized || !s.plan.ok) return; + /* Arm the complete selected override set before any activation callback + * can change host or guest state. */ + if (!mod_runtime_arm_function_overrides()) return; for (const ModResolution::Plugin& plugin : s.plan.plugins) { s.current_plugin = &plugin; mod_invoke_activation_plugin(plugin.id); s.current_plugin = nullptr; } - /* Arm the package-gated function overrides for plan-selected plugin - * ids, then (re)install the dispatcher hook. func_override_add refuses - * duplicate addresses; a refusal here means two active plugins claim - * one function, which the resolver should have prevented — the armed - * flag stays false and the `func_override` TCP command shows the gap. */ - for (FunctionOverridePlugin& pending : function_override_plugins()) { - if (pending.armed) continue; - const bool selected = std::any_of( - s.plan.plugins.begin(), s.plan.plugins.end(), - [&](const ModResolution::Plugin& plugin) { - return plugin.id == pending.plugin; - }); - if (!selected) continue; - const int rc = func_override_add_package( - pending.id.c_str(), pending.address, pending.fn, - pending.n_guard ? pending.guard : nullptr, pending.n_guard, - pending.credit); - pending.armed = (rc == FO_OK); - } - func_override_install(); } extern "C" void mod_runtime_on_vblank(void) { @@ -1475,13 +1580,19 @@ extern "C" int psx_mod_register_function_entry_plugin( return 1; } -extern "C" int psx_mod_register_function_override( +static int register_function_override( const char* id, uint32_t address, PSXModFunctionOverrideFn fn, - const uint32_t* expected_words, int n_words, int32_t credit) { + const uint32_t* expected_words, int n_words, + const uint32_t* ranges, int n_ranges, uint32_t expected_crc, + int32_t credit) { using namespace PSXRecompV4; if (!id || !*id || !address || !fn) return 0; if (n_words < 0 || n_words > FO_MAX_GUARD_WORDS) return 0; if (n_words > 0 && !expected_words) return 0; + if (n_words > 0 && n_ranges > 0) return 0; + if (n_ranges < 0 || n_ranges > FO_MAX_CODE_RANGES) return 0; + if (n_ranges > 0 && + !func_override_code_ranges_valid(address, ranges, n_ranges)) 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 — @@ -1492,6 +1603,7 @@ extern "C" int psx_mod_register_function_override( const std::string plugin_id = colon ? std::string(id, colon - id) : std::string(id); if (plugin_id.empty() || (colon && !colon[1])) return 0; + if (!mod_plugin_id_valid(plugin_id)) return 0; auto& plugins = function_override_plugins(); const auto duplicate = std::find_if( plugins.begin(), plugins.end(), @@ -1506,12 +1618,31 @@ extern "C" int psx_mod_register_function_override( plugin.fn = fn; for (int i = 0; i < n_words; ++i) plugin.guard[i] = expected_words[i]; plugin.n_guard = n_words; + for (int i = 0; i < n_ranges * 2; ++i) plugin.ranges[i] = ranges[i]; + plugin.n_ranges = n_ranges; + plugin.expected_crc = expected_crc; 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; + return mod_register_function_override_marker(plugin_id) ? 1 : 0; +} + +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) { + return register_function_override(id, address, fn, expected_words, n_words, + nullptr, 0, 0, credit); +} + +extern "C" int psx_mod_register_function_override_exact( + const char* id, uint32_t address, PSXModFunctionOverrideFn fn, + const uint32_t* lo_len_pairs, int n_ranges, uint32_t expected_crc, + int32_t credit) { + if (n_ranges < 1) return 0; + return register_function_override(id, address, fn, nullptr, 0, + lo_len_pairs, n_ranges, expected_crc, + credit); } extern "C" void psx_mod_function_entry(CPUState* cpu, uint32_t address) { diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index 6a0ca6864..9ab9a869c 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -553,7 +553,6 @@ static uint32_t cand_gensum(const Candidate *c) { return s; } -#ifdef PSX_HAS_OVERLAY_DISPATCH /* Static-overlay validation uses the same exact-code-range contract as the * dynamic DLL loader. Generated code passes immutable {phys_lo, len} pairs and * the CRC of the bytes it was compiled from. A page-generation cache keeps the @@ -643,7 +642,10 @@ void overlay_loader_static_match_stats(uint64_t *rehashes, if (crc_misses) *crc_misses = s_static_match_crc_misses; if (gen_fastpath) *gen_fastpath = s_static_match_gen_fastpath; } -#endif + +void overlay_loader_static_match_cache_clear(void) { + memset(s_static_match_cache, 0, sizeof(s_static_match_cache)); +} /* ---- Per-DLL code-range manifest --------------------------------------- */ /* Strict v2 line format emitted by tools/compile_overlays.py beside each DLL: @@ -2855,9 +2857,7 @@ void overlay_loader_resync_validation_after_restore(void) int i; for (i = 0; i < s_cand_n; i++) s_cand[i].val_gen ^= 0x80000000u; -#ifdef PSX_HAS_OVERLAY_DISPATCH - memset(s_static_match_cache, 0, sizeof(s_static_match_cache)); -#endif + overlay_loader_static_match_cache_clear(); } /* Re-scan the cache dir for DLLs compiled after init (step 2.8 autocompile) diff --git a/runtime/tests/overlay_pair_dedup_harness.c b/runtime/tests/overlay_pair_dedup_harness.c index 617aba1cd..19ebfe08d 100644 --- a/runtime/tests/overlay_pair_dedup_harness.c +++ b/runtime/tests/overlay_pair_dedup_harness.c @@ -1,6 +1,7 @@ #define PSX_OVERLAY_DLL_BUILD 1 #include "overlay_loader.h" #undef PSX_OVERLAY_DLL_BUILD +#include "crc32.h" #include #include @@ -22,6 +23,8 @@ _Static_assert(PSX_OVERLAY_TEST_CANDIDATE_CAP == 4, static uint8_t s_ram[RAM_SIZE]; static uint8_t s_scratch[1024]; +static uint32_t s_page_generation; +static int s_ram_available = 1; uint32_t g_debug_current_func_addr; uint32_t g_debug_last_store_pc; @@ -36,10 +39,10 @@ uint64_t s_frame_count; int g_shadow_mmio_watch; uint64_t g_shadow_mmio_hits; -uint8_t *memory_get_ram_ptr(void) { return s_ram; } +uint8_t *memory_get_ram_ptr(void) { return s_ram_available ? s_ram : NULL; } uint8_t *memory_get_scratchpad_ptr(void) { return s_scratch; } uint32_t overlay_watch_pagegen_sum(uint32_t phys, uint32_t len) { - (void)phys; (void)len; return 0; + (void)phys; (void)len; return s_page_generation; } void overlay_watch_set_range(uint32_t phys, uint32_t len) { (void)phys; (void)len; @@ -57,6 +60,36 @@ void dirty_ram_xprobe_call_note(CPUState *cpu, uint32_t target, (void)cpu; (void)target; (void)ra; (void)phase; } +int psx_netplay_is_resimulating(void) { return 0; } +void psx_pgxp_load(CPUState *cpu, uint32_t instr, uint32_t addr, + uint32_t value) { + (void)cpu; (void)instr; (void)addr; (void)value; +} +void psx_pgxp_store(CPUState *cpu, uint32_t instr, uint32_t addr, + uint32_t value) { + (void)cpu; (void)instr; (void)addr; (void)value; +} +void psx_pgxp_alu(CPUState *cpu, uint32_t instr, uint32_t result, + uint32_t s1, uint32_t s2) { + (void)cpu; (void)instr; (void)result; (void)s1; (void)s2; +} +void psx_pgxp_muldiv(CPUState *cpu, uint32_t instr, uint32_t hi, + uint32_t lo, uint32_t s1, uint32_t s2) { + (void)cpu; (void)instr; (void)hi; (void)lo; (void)s1; (void)s2; +} +void psx_pgxp_cop2(CPUState *cpu, uint32_t instr, uint32_t value, + uint32_t addr) { + (void)cpu; (void)instr; (void)value; (void)addr; +} +uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced) { + (void)forced; return vanilla; +} +uint32_t psx_ws_aspect_cone_result(uint32_t site, uint32_t vanilla, + uint32_t forced) { + (void)site; (void)forced; return vanilla; +} +uint32_t psx_ws_angle_widen(uint32_t vanilla) { return vanilla; } + void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t ra) { (void)cpu; (void)addr; (void)ra; } @@ -223,6 +256,89 @@ static int expect_int(const char *what, long long actual, long long expected) { return 0; } +static int expect_static_match_stats(uint64_t want_rehashes, + uint64_t want_misses, + uint64_t want_fastpath) { + uint64_t rehashes = 0, misses = 0, fastpath = 0; + overlay_loader_static_match_stats(&rehashes, &misses, &fastpath); + return expect_int("static rehashes", (long long)rehashes, + (long long)want_rehashes) & + expect_int("static CRC misses", (long long)misses, + (long long)want_misses) & + expect_int("static generation fast-path", (long long)fastpath, + (long long)want_fastpath); +} + +static int static_match_lifecycle(void) { + static const uint32_t ranges[] = { + 0x80001000u, 7u, + 0xA0003000u, 9u, + }; + static const uint32_t zero_length[] = {0x80001000u, 0u}; + static const uint32_t out_of_bounds[] = {0x801FFFFCu, 8u}; + uint32_t crc = 0xFFFFFFFFu; + int ok = 1; + + s_ram_available = 1; + s_page_generation = 0; + for (uint32_t i = 0; i < RAM_SIZE; i++) + s_ram[i] = (uint8_t)((i * 37u + 11u) & 0xFFu); + crc = crc32_update(crc, s_ram + 0x1000u, 7u); + crc = crc32_update(crc, s_ram + 0x3000u, 9u) ^ 0xFFFFFFFFu; + overlay_loader_static_match_cache_clear(); + + ok &= expect_int("cold exact match", + psx_overlay_static_code_matches(ranges, 2u, crc), 1); + ok &= expect_static_match_stats(1u, 0u, 0u); + ok &= expect_int("warm exact match", + psx_overlay_static_code_matches(ranges, 2u, crc), 1); + ok &= expect_static_match_stats(1u, 0u, 1u); + + const uint8_t original = s_ram[0x3004u]; + s_ram[0x3004u] ^= 0x5Au; + s_page_generation++; + ok &= expect_int("replacement rejected", + psx_overlay_static_code_matches(ranges, 2u, crc), 0); + ok &= expect_static_match_stats(2u, 1u, 1u); + ok &= expect_int("warm replacement rejected", + psx_overlay_static_code_matches(ranges, 2u, crc), 0); + ok &= expect_static_match_stats(2u, 1u, 2u); + + s_ram[0x3004u] = original; + s_page_generation++; + ok &= expect_int("restored bytes accepted", + psx_overlay_static_code_matches(ranges, 2u, crc), 1); + ok &= expect_static_match_stats(3u, 1u, 2u); + ok &= expect_int("warm restored bytes accepted", + psx_overlay_static_code_matches(ranges, 2u, crc), 1); + ok &= expect_static_match_stats(3u, 1u, 3u); + + overlay_loader_static_match_cache_clear(); + ok &= expect_int("explicit reset forces rehash", + psx_overlay_static_code_matches(ranges, 2u, crc), 1); + ok &= expect_static_match_stats(4u, 1u, 3u); + + ok &= expect_int("null ranges rejected", + psx_overlay_static_code_matches(NULL, 1u, crc), 0); + ok &= expect_int("zero range count rejected", + psx_overlay_static_code_matches(ranges, 0u, crc), 0); + ok &= expect_int("range-count cap enforced", + psx_overlay_static_code_matches(ranges, 4097u, crc), 0); + ok &= expect_int("zero length rejected", + psx_overlay_static_code_matches(zero_length, 1u, crc), 0); + ok &= expect_int("out-of-bounds range rejected", + psx_overlay_static_code_matches(out_of_bounds, 1u, crc), 0); + s_ram_available = 0; + ok &= expect_int("missing RAM rejected", + psx_overlay_static_code_matches(ranges, 2u, crc), 0); + s_ram_available = 1; + ok &= expect_static_match_stats(4u, 7u, 3u); + + if (ok) + puts("PASS static-match replacement/restore/reset lifecycle"); + return ok ? 0 : 1; +} + static uint32_t loader_owner_count(void) { uint32_t loads = 0; overlay_loader_get_counters(&loads, NULL, NULL, NULL, NULL, NULL, @@ -273,6 +389,8 @@ static int reveal_second_pair(const char *second) { } int main(int argc, char **argv) { + if (argc == 2 && strcmp(argv[1], "--static-match-lifecycle") == 0) + return static_match_lifecycle(); if (argc != 5) { fprintf(stderr, "usage: %s \n", argv[0]); diff --git a/runtime/tests/test_func_override.c b/runtime/tests/test_func_override.c index c48fbc388..820b38c93 100644 --- a/runtime/tests/test_func_override.c +++ b/runtime/tests/test_func_override.c @@ -14,6 +14,7 @@ #include #include "cpu_state.h" +#include "crc32.h" #include "func_override.h" extern int (*g_psx_func_override_hook)(CPUState *cpu, uint32_t phys); @@ -38,6 +39,26 @@ static int g_fail = 0; * guard_declines_on_mismatch, and the reason this double is trivial. */ #define FAKE_RAM_WORDS 64 static uint32_t s_ram[FAKE_RAM_WORDS]; +static int s_static_match_cache_clears; + +int psx_overlay_static_code_matches(const uint32_t *lo_len_pairs, + uint32_t count, + uint32_t expected_crc) +{ + uint32_t crc = 0xFFFFFFFFu; + for (uint32_t i = 0; i < count; i++) { + const uint32_t lo = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; + const uint32_t len = lo_len_pairs[i * 2u + 1u]; + if (lo >= sizeof(s_ram) || len > sizeof(s_ram) - lo) return 0; + crc = crc32_update(crc, (const uint8_t *)s_ram + lo, len); + } + return (crc ^ 0xFFFFFFFFu) == expected_crc; +} + +void overlay_loader_static_match_cache_clear(void) +{ + s_static_match_cache_clears++; +} uint32_t psx_peek_word_untraced(uint32_t addr) { @@ -71,6 +92,8 @@ static int s_dispatch_depth = 0; * (what a guest-level wrap would observe). */ static uint32_t s_recursive_original_at = 0; static int s_recursion_budget = 0; +static uint32_t s_original_redirect_at = 0; +static uint32_t s_original_redirect_to = 0; void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t return_addr) { @@ -82,6 +105,9 @@ void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t return_addr) /* Nothing handled it: this is the original body running. */ s_original_runs++; cpu->gpr[2] = 0xAAAAu; + if ((addr & 0x1FFFFFFFu) == + (s_original_redirect_at & 0x1FFFFFFFu)) + cpu->pc = s_original_redirect_to; if (s_recursive_original_at && (addr & 0x1FFFFFFFu) == (s_recursive_original_at & 0x1FFFFFFFu) && s_recursion_budget > 0) { @@ -118,6 +144,13 @@ static int impl_wraps(CPUState *cpu) return 1; } +static int impl_redirects(CPUState *cpu) +{ + s_impl_calls++; + cpu->pc = 0x8000D00Du; + return 1; +} + /* ---- helpers ------------------------------------------------------------ */ static void reset_all(void) @@ -129,6 +162,8 @@ static void reset_all(void) s_original_runs = 0; s_recursive_original_at = 0; s_recursion_budget = 0; + s_original_redirect_at = 0; + s_original_redirect_to = 0; memset(s_ram, 0, sizeof(s_ram)); } @@ -169,6 +204,61 @@ static void test_add_rejects_bad_input(void) CHECK(func_override_add_guarded("t.gnull", 0x80001000u, impl_handles, NULL, 2, 0) == FO_ERR_ARGS, "guarded with NULL words must be refused"); + static const uint32_t range[2] = {0x80001000u, 4u}; + CHECK(func_override_add_exact("t.x0", 0x80001000u, impl_handles, + range, 0, 0, 0) == FO_ERR_ARGS, + "exact override with zero ranges must be refused"); + CHECK(func_override_add_package_exact("t.px0", 0x80001000u, impl_handles, + range, 0, 0, 0) == FO_ERR_ARGS, + "package exact override with zero ranges must be refused"); +} + +static void test_code_range_validator_edges(void) +{ + static const uint32_t valid_aliases[] = { + 0xA0000100u, 4u, + 0x80000200u, 8u, + }; + static const uint32_t adjacent[] = { + 0x80000100u, 4u, + 0x80000104u, 4u, + }; + static const uint32_t unaligned_start[] = {0x80000102u, 4u}; + static const uint32_t unaligned_length[] = {0x80000100u, 6u}; + static const uint32_t short_length[] = {0x80000100u, 0u}; + static const uint32_t start_outside_ram[] = {0x80200000u, 4u}; + static const uint32_t crosses_ram_end[] = {0x801FFFFCu, 8u}; + static const uint32_t misses_entry[] = {0x80000100u, 4u}; + static const uint32_t alias_overlap[] = { + 0x80000100u, 8u, + 0xA0000104u, 4u, + }; + + CHECK(!func_override_code_ranges_valid(0x80000100u, NULL, 1), + "NULL exact range array must be refused"); + CHECK(!func_override_code_ranges_valid(0x80000100u, valid_aliases, 0), + "zero exact ranges must be refused"); + CHECK(!func_override_code_ranges_valid(0x80000100u, valid_aliases, + FO_MAX_CODE_RANGES + 1), + "exact range count above the cap must be refused"); + CHECK(func_override_code_ranges_valid(0xA0000204u, valid_aliases, 2), + "KSEG aliases and discontiguous ranges must validate"); + CHECK(func_override_code_ranges_valid(0x80000104u, adjacent, 2), + "adjacent ranges must not count as overlap"); + CHECK(!func_override_code_ranges_valid(0x80000100u, unaligned_start, 1), + "unaligned exact range start must be refused"); + CHECK(!func_override_code_ranges_valid(0x80000100u, unaligned_length, 1), + "unaligned exact range length must be refused"); + CHECK(!func_override_code_ranges_valid(0x80000100u, short_length, 1), + "exact range shorter than one instruction must be refused"); + CHECK(!func_override_code_ranges_valid(0x80200000u, start_outside_ram, 1), + "exact range starting outside RAM must be refused"); + CHECK(!func_override_code_ranges_valid(0x801FFFFCu, crosses_ram_end, 1), + "exact range crossing the RAM end must be refused"); + CHECK(!func_override_code_ranges_valid(0x80000104u, misses_entry, 1), + "exact ranges must cover the override entry"); + CHECK(!func_override_code_ranges_valid(0x80000100u, alias_overlap, 2), + "overlap through KSEG aliases must be refused"); } static void test_duplicate_address_refused(void) @@ -278,9 +368,106 @@ static void test_guard_declines_on_mismatch(void) 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); + CHECK(calls == 3, "all three matched-address consults must count, got %llu", + (unsigned long long)calls); } } +static void test_exact_code_identity_covers_the_complete_range(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t addr = 0x80000080u; + static const uint32_t ranges[2] = {0x00000080u, 20u}; + for (int i = 0; i < 5; i++) s_ram[32 + i] = 0x10000000u + (uint32_t)i; + const uint32_t crc = crc32_compute((const uint8_t *)&s_ram[32], 20u); + CHECK(func_override_add_exact("t.exact", addr, impl_handles, + ranges, 1, crc, 0) == FO_OK, + "exact-range add must succeed"); + func_override_install(); + + CHECK(consult(&cpu, addr) == 1, "complete matching code identity must handle"); + for (int i = 0; i < func_override_count(); i++) { + uint32_t a = 0, reported_crc = 0; + int kind = -1, count = 0; + if (!func_override_get_ex(i, NULL, 0, &a, NULL, NULL, NULL, NULL) || + a != (addr & 0x1FFFFFFFu)) continue; + CHECK(func_override_get_guard_info(i, &kind, &count, &reported_crc), + "exact guard information must be inspectable"); + CHECK(kind == FO_GUARD_CODE_CRC32 && count == 1 && reported_crc == crc, + "inventory must identify the exact CRC guard"); + } + s_ram[36] ^= 1u; /* word five: outside the legacy four-word prefix */ + CHECK(consult(&cpu, addr) == 0, + "a changed fifth word must reject the exact code identity"); + CHECK(s_impl_calls == 1, "identity mismatch must not run the override"); +} + +static void test_dispatch_preserves_nonlocal_continuations(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t redirect_at = 0x80007000u; + CHECK(func_override_add("t.redirect", redirect_at, impl_redirects, 0) == FO_OK, + "redirect add"); + func_override_install(); + cpu.gpr[31] = 0x80001234u; + CHECK(func_override_try_dispatch(&cpu, redirect_at, cpu.gpr[31]) == 1, + "redirect override must handle"); + CHECK(cpu.pc == 0x8000D00Du, + "handled non-local continuation must not be overwritten with $ra"); + + const uint32_t plain_at = 0x80007100u; + CHECK(func_override_add("t.plain_return", plain_at, impl_handles, 0) == FO_OK, + "plain return add"); + cpu.pc = 0xFFFFFFFFu; + CHECK(func_override_try_dispatch(&cpu, plain_at, cpu.gpr[31]) == 1, + "plain override must handle"); + CHECK(cpu.pc == cpu.gpr[31], "zero continuation must default to $ra"); +} + +static void test_wrap_preserves_original_nonlocal_continuation(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t addr = 0x80007200u; + CHECK(func_override_add("t.wrap_redirect", addr, impl_wraps, + FO_CREDIT_SELF) == FO_OK, + "redirecting wrap add"); + func_override_install(); + cpu.gpr[31] = 0x80002222u; + s_original_redirect_at = addr; + s_original_redirect_to = 0x8000BEEFu; + CHECK(func_override_try_dispatch(&cpu, addr, cpu.gpr[31]) == 1, + "redirecting wrap must handle"); + CHECK(cpu.pc == s_original_redirect_to, + "wrap must preserve the original's non-local continuation"); + CHECK(cpu.gpr[31] == 0x80002222u, "wrap must restore the caller's $ra"); +} + +static void test_guest_call_preserves_nonlocal_continuation(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t target = 0x80007300u; + cpu.gpr[31] = 0x80003333u; + s_original_redirect_at = target; + s_original_redirect_to = 0x8000CAFEu; + func_override_guest_call(&cpu, target, 0x80004444u); + CHECK(cpu.pc == s_original_redirect_to, + "guest_call must preserve the callee's non-local continuation"); + CHECK(cpu.gpr[31] == 0x80003333u, + "guest_call must restore the native caller's $ra"); +} + static void test_call_original_is_one_shot(void) { CPUState cpu; @@ -368,10 +555,13 @@ static void test_package_reset_drops_only_package_entries(void) CHECK(consult(&cpu, pkg_at) == 1, "package override armed"); const int before = func_override_count(); + const int clears_before = s_static_match_cache_clears; 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"); + CHECK(s_static_match_cache_clears == clears_before + 1, + "package slot reuse must invalidate exact-identity cache keys"); /* This is the netplay-divergence regression: after clear-mods the package * override must be gone, while the game's own always-on reimplementation @@ -495,9 +685,14 @@ int main(void) { test_install_is_null_until_registered(); test_add_rejects_bad_input(); + test_code_range_validator_edges(); test_duplicate_address_refused(); test_handled_and_declined_both_count_as_consults(); test_guard_declines_on_mismatch(); + test_exact_code_identity_covers_the_complete_range(); + test_dispatch_preserves_nonlocal_continuations(); + test_wrap_preserves_original_nonlocal_continuation(); + test_guest_call_preserves_nonlocal_continuation(); test_call_original_is_one_shot(); test_bypass_is_consumed_so_recursion_reconsults(); test_call_original_outside_override_is_a_noop(); diff --git a/runtime/tests/test_func_override_routes.py b/runtime/tests/test_func_override_routes.py new file mode 100644 index 000000000..4e9adfe6b --- /dev/null +++ b/runtime/tests/test_func_override_routes.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Pin the dirty-RAM function-override entry route.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +DIRTY = (ROOT / "runtime/src/dirty_ram_interp.c").read_text(encoding="utf-8") + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def main() -> None: + helper = "func_override_try_dispatch(cpu, target, cpu->gpr[31])" + pump = DIRTY.index("dirty_ram_pump_boundary(cpu, target, 1)") + tail = DIRTY.index(helper, pump) + local = DIRTY.index("if (allow_local_dirty_flow", tail) + update = DIRTY.index("current_function_entry_phys = target_phys", local) + require(pump < tail < local < update, + "J/JR override entry must follow the IRQ safe point and precede local flow") + require("!g_precise_mode && !g_ls_replay_active" in DIRTY[pump:local], + "tail override entry must keep precise/replay plain-transfer policy") + require("((insn >> 21) & 0x1Fu) != 31u" in DIRTY[pump:local], + "JR $ra returns must not be treated as function tail entries") + require("current_function_entry_phys" in DIRTY[pump:tail], + "J/JR override entry must track current function provenance") + require("target_is_function_entry" in DIRTY[pump:tail], + "J/JR override entry must prove the target function entry") + require("source_phys = pc & 0x1FFFFFFFu" in DIRTY[pump:tail], + "J/JR override entry must use the current transfer source") + require("target_phys != current_function_entry_phys" in DIRTY[pump:tail], + "a back-edge to the current function entry must not re-consult") + require("overlay_loader_is_candidate(target_phys)" in DIRTY[pump:tail], + "dynamic tail entry must require an exact overlay entry") + require("psx_game_is_function_entry(target)" in DIRTY[pump:tail], + "static tail entry must require a generated function entry") + gate = DIRTY.find("if (unlinked_tail && g_psx_func_override_hook)", pump, tail) + lookup = DIRTY.find("overlay_loader_is_candidate(target_phys)", pump, tail) + require(0 <= gate < lookup, + "J/JR entry lookups must wait for an armed override hook") + entry = DIRTY.index("uint32_t current_function_entry_phys = 0u;") + require(DIRTY.find("if (g_psx_func_override_hook)", entry, entry + 400) + > entry, + "dispatch entry provenance lookups must wait for an armed hook") + print("function override entry routes passed") + + +if __name__ == "__main__": + main() diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 00aa2a248..24846115b 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -77,6 +77,9 @@ extern "C" int fntrace_is_game_started(void) { return 1; } * 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" int psx_overlay_static_code_matches(const uint32_t*, uint32_t, + uint32_t) { return 1; } +extern "C" void overlay_loader_static_match_cache_clear(void) {} extern "C" void psx_dispatch_call(struct CPUState*, uint32_t, uint32_t) {} extern "C" { uint64_t psx_cycle_count = 0; @@ -98,6 +101,9 @@ static void test_activation_plugin(void) { activation_calls++; } +static int test_function_override(CPUState*) { return 1; } +extern "C" void mod_runtime_clear_function_override_plugins_for_tests(void); + static void check(bool value, const char* message) { if (!value) { std::cerr << "FAIL: " << message << "\n"; @@ -508,6 +514,110 @@ int main() { "unestablished display geometry must report zero so callers skip " "drawing instead of guessing"); + check(psx_mod_register_function_override( + "Invalid ID:bad", 0x80018000u, test_function_override, + nullptr, 0, 0) == 0, + "invalid package plugin ids must be rejected at registration"); + const uint32_t exact_ranges[2] = {0x00018000u, 4u}; + check(psx_mod_register_function_override_exact( + "runtime.test-vblank:exact", 0x80018000u, + test_function_override, exact_ranges, 1, 0x12345678u, 0) == 1, + "exact package override must queue"); + check(PSXRecompV4::mod_runtime_commit(stock_path, &error), error.c_str()); + mod_runtime_activate_plugins(); + check(func_override_count() == 1, + "selected exact package override must arm"); + /* Offline lobby rematch: commit again, then jump past activation. The + * commit disarms the previous set; the rematch path must re-arm it + * without re-running activation callbacks. */ + const int activations_before_rematch = activation_calls; + check(PSXRecompV4::mod_runtime_commit(stock_path, &error), error.c_str()); + check(func_override_count() == 0, + "commit must disarm the previous package override set"); + check(PSXRecompV4::mod_runtime_arm_function_overrides(&error), + error.c_str()); + check(func_override_count() == 1, + "rematch re-arm must restore the committed package override"); + check(activation_calls == activations_before_rematch, + "rematch re-arm must not run activation callbacks"); + int guard_kind = -1, guard_count = 0; + uint32_t guard_crc = 0; + check(func_override_get_guard_info(0, &guard_kind, &guard_count, + &guard_crc) == 1 && + guard_kind == FO_GUARD_CODE_CRC32 && guard_count == 1 && + guard_crc == 0x12345678u, + "armed package inventory must retain exact code identity"); + check(PSXRecompV4::mod_runtime_clear_for_netplay(&error), error.c_str()); + check(func_override_count() == 0, + "clearing the package plan must disarm the exact override"); + check(psx_mod_register_function_override( + "runtime.test-vblank:first", 0x80018100u, + test_function_override, nullptr, 0, 0) == 1, + "first package override must queue"); + check(psx_mod_register_function_override( + "runtime.test-vblank:second", 0x80018100u, + test_function_override, nullptr, 0, 0) == 1, + "a second package claim may queue until selection is known"); + check(!PSXRecompV4::mod_runtime_commit(stock_path, &error), + "commit must reject two selected package overrides at one address"); + check(error.find("function override collision") != std::string::npos, + "collision rejection must name the cause"); + check(func_override_count() == 0, + "a rejected package override set must arm no partial entries"); + + /* A direct registration can race with the interval between plan commit + * and package activation. The late duplicate must disable the complete + * committed plan, not apply its main or disc patches without its + * function override. */ + PSXRecompV4::mod_clear_plugins_for_tests(); + mod_runtime_clear_function_override_plugins_for_tests(); + check(psx_mod_register_function_override_exact( + "runtime.test-vblank:race", 0x80018000u, + test_function_override, exact_ranges, 1, 0x12345678u, 0) == 1, + "race package override must queue"); + check(PSXRecompV4::mod_runtime_initialize( + root, "SLUS-RUNTIME", 0x80002000, {}, &error), + error.c_str()); + check(PSXRecompV4::mod_runtime_commit(stock_path, &error), error.c_str()); + check(func_override_add("runtime.direct-race", 0x80018000u, + test_function_override, 0) == FO_OK, + "direct override must occupy the committed package address"); + mod_runtime_activate_plugins(); + ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; + mod_runtime_on_dispatch(0x80002000); + check(ram[0x1000] == 1 && ram[0x1003] == 4, + "late override failure must disable main-memory plan writes"); + mod_runtime_enable_disc_patches(); + std::array disabled_disc{}; + disabled_disc[15] = 2; + disabled_disc[18] = 0; + disabled_disc[24 + 10] = 0xcc; + mod_runtime_patch_disc_sector( + 3, 1, disabled_disc.data(), (uint32_t)disabled_disc.size()); + check(disabled_disc[24 + 10] == 0xcc, + "late override failure must disable disc plan writes"); + + /* Remove the package registration that conflicted with the direct entry. + * A new successful commit must clear the failure latch and make its disc + * plan usable again. */ + mod_runtime_clear_function_override_plugins_for_tests(); + check(PSXRecompV4::mod_runtime_commit(stock_path, &error), error.c_str()); + mod_runtime_activate_plugins(); + check(func_override_count() == 1, + "a corrected plan must preserve the direct override"); + std::array recovered_disc{}; + recovered_disc[15] = 2; + recovered_disc[18] = 0; + recovered_disc[24 + 10] = 0xcc; + recovered_disc[24 + 20] = 0x11; + recovered_disc[24 + 21] = 0x22; + recovered_disc[24 + 22] = 0x33; + recovered_disc[24 + 23] = 0x44; + mod_runtime_patch_disc_sector( + 3, 1, recovered_disc.data(), (uint32_t)recovered_disc.size()); + check(recovered_disc[24 + 10] == 0xdd, + "a corrected plan must recover disc patching after late failure"); + fs::remove_all(root, ec); if (failures) return 1; std::cout << "mod runtime tests passed\n"; diff --git a/runtime/tests/test_overlay_pair_dedup_runtime.py b/runtime/tests/test_overlay_pair_dedup_runtime.py index 4f3cc1dac..389dc57cc 100644 --- a/runtime/tests/test_overlay_pair_dedup_runtime.py +++ b/runtime/tests/test_overlay_pair_dedup_runtime.py @@ -27,17 +27,25 @@ def codegen_leaf() -> str: hash_header = (RUNTIME / "include" / "overlay_codegen_hash.h") version = "0" code_hash = "00000000" + flavor = "0" if api.is_file(): match = re.search(r"PSX_OVERLAY_CODEGEN_VER\s+(\d+)", api.read_text(encoding="utf-8")) if match: version = match.group(1) + match = re.search(r"PSX_OVERLAY_FLAVOR\s+(\d+)", + api.read_text(encoding="utf-8")) + if match: + flavor = match.group(1) if hash_header.is_file(): match = re.search(r"PSX_OVERLAY_CODEGEN_HASH\s+0x([0-9A-Fa-f]{8})", hash_header.read_text(encoding="utf-8")) if match: code_hash = match.group(1).lower() - return f"cg{version}_{code_hash}" + # overlay_loader_init below uses config hash zero. Keep the fixture on the + # runtime's complete cache identity so it reaches the loader behavior under + # test instead of stopping at the stale-cache guard (FAIL-061). + return f"cg{version}_{code_hash}_gc00000000_f{flavor}" def arch_abi() -> str: @@ -181,10 +189,11 @@ def main() -> int: compile_fixture(args.gcc, full_second, instance=2) compile_fixture(args.gcc, partial, instance=1, partial=True) compile_harness(args.gcc, harness) + run([str(harness), "--static-match-lifecycle"]) for name in ("alias-at-cap", "manifest-mismatch", "provenance-mismatch", "cross-tier", "partial-first"): scenario(tmp, harness, full_first, full_second, partial, name) - print("PASS: executable overlay whole-pair dedup behavior") + print("PASS: executable overlay lifecycle and whole-pair dedup behavior") return 0 diff --git a/tools/aot_overlay_spike/README.md b/tools/aot_overlay_spike/README.md index d524b27ca..2a60fedcd 100644 --- a/tools/aot_overlay_spike/README.md +++ b/tools/aot_overlay_spike/README.md @@ -144,7 +144,9 @@ optional coverage to the next command. `runtime/tests/test_overlay_pair_dedup_runtime.py` compiles the real loader at a four-slot test cap plus real shared-library fixtures, and behaviorally covers an exact alias at capacity, handle/init/flush ownership, staged rescan idempotence, -manifest/provenance/tier negatives, and partial-export non-authority. +manifest/provenance/tier negatives, and partial-export non-authority. The same +test also covers static code-identity cache replacement, restoration, reset, +and invalid-range rejection against the real loader. Dynamic compilation serializes canonically sorted recipes under a whole-command namespace lock so a full cache has the same accepted subset on every clean run. GCC-first basename shadowing matches the loader, and non-growing replacements