diff --git a/TCP_COMMANDS.md b/TCP_COMMANDS.md index 73d1ae32a..081ff36ef 100644 --- a/TCP_COMMANDS.md +++ b/TCP_COMMANDS.md @@ -49,10 +49,11 @@ Columns: **N** = native, **D** = DuckStation oracle. | `write_ram` | ✓ | ✓ | `addr`, `val` | Write **one byte** to PS1 address space. Note the parameter is `val` (not `hex`), and the write is a single byte per call — this row previously documented both incorrectly | | `read_scratch` | | ✓ | `addr`, `len` | Read PS1 scratchpad (0x1F800000 region) | | `read_vram` / `vram_peek` | ✓¹ | ✓ | `x`, `y`, `w`, `h` | Read 16-bit VRAM pixels (max 128×128) | +| `func_override` | ✓ | | — | Inventory of armed function overrides (`func_override.h`): per entry `id`, guest `addr`, `calls`, `guard_misses`, `guarded`, `credit` (the declared cycle policy — a per-handled-call charge, or `"self"` when the body charges its own). `calls` counts **consults**, declines included — so a decline-only probe proves an address crosses a hooked path, and `calls: 0` means the override was never reached (wrong address, or that path never ran). Package-gated overrides appear only after the mod plan arms them; an id may read `plugin:label` when one plugin registers several overrides | | `gpu_state` | ✓ | ✓ | — | Display area, display depth, draw offset, GPUSTAT, clip rect, xfer state | -| `screenshot_hires` | ✓ | ✓ | `path` | PNG of the **supersampled** surface (the present path the window uses), at `display × gr_scale()`. ⚠ `screenshot`/`screenshot_file` capture native 15-bit VRAM and are **blind to anything that only exists in the hi-res mirror** — geometry correction, SSAA edges, perspective UVs — so they show a clean frame while the player sees a broken one. Use this one to verify those. Falls back to the native resolve (and reports `scale: 1`) when no hi-res surface exists | -| `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) | -| `present_shot_seq` | ✓ | | — | Completion counter for `present_shot`, plus `wrote` (1 = that completion produced a PNG). Sample before staging, poll until `seq` moves. Advances on success *and* failure, so the poll always terminates | +| `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) | +| `present_shot_seq` | ✓ | | — | Completion counter for `present_shot`, plus `wrote` (1 = that completion produced a PNG). Sample before staging, poll until `seq` moves. Advances on success *and* failure, so the poll always terminates | | `geom_correction` | | ✓ | — | `[video] geometry_correction` / `perspective_texturing` engagement: enable flag plus free-running `geometry_vertex_hits` and `perspective_triangles` totals. Both enhancements silently fall back to the faithful path on anything they cannot prove is projected geometry, so a zero counter with the flag on means the title never qualifies — sample twice and diff for a rate | | `sio_state` | ✓ | ✓ | — | SIO registers + (native only) pad/memcard protocol + TX/RX history | | `irq_state` | ✓ | ✓ | — | `I_STAT`, `I_MASK` (both), plus chain state on native | @@ -94,30 +95,30 @@ Columns: **N** = native, **D** = DuckStation oracle. | `pc_hit_clear` | | ✓² | — | Clear the last-hit record | | `quit` | ✓ | | — | Shutdown native runtime | -¹ Native `vram_peek` is the legacy name; DS calls it `read_vram`. Same semantics. -² The `pc_*` family is specific to the DS oracle: DuckStation's CPU core honours `CPU::AddBreakpointWithCallback`, while our native runtime dispatches whole recompiled functions (no mid-function PC breaks). - -### Boot-time write ranges - -Set `PSX_WTRACE_BOOT=lo,hi[;lo,hi...]` before launching a debug-tools build to -retain the first writes to one or more half-open RAM ranges from guest -instruction zero. Addresses may be hexadecimal or decimal; KSEG addresses are -normalized to physical addresses. For example, the Crash Bash investigation -that motivated this option can be reproduced without title-specific code: - -```powershell -$env:PSX_WTRACE_BOOT='0x000B3A80,0x000B3B00' -.\CrashBashRecomp.exe -``` - -Connect at any later point and query `wtrace_boot_stats`, -`wtrace_boot_summary`, or `wtrace_boot_dump`. Each retained entry includes the -write address/value/width, guest PC and return address, register context, frame, -and DMA channel. The option is ignored in builds made with debug tools disabled. - ---- - -## Divergence-hunt workflow +¹ Native `vram_peek` is the legacy name; DS calls it `read_vram`. Same semantics. +² The `pc_*` family is specific to the DS oracle: DuckStation's CPU core honours `CPU::AddBreakpointWithCallback`, while our native runtime dispatches whole recompiled functions (no mid-function PC breaks). + +### Boot-time write ranges + +Set `PSX_WTRACE_BOOT=lo,hi[;lo,hi...]` before launching a debug-tools build to +retain the first writes to one or more half-open RAM ranges from guest +instruction zero. Addresses may be hexadecimal or decimal; KSEG addresses are +normalized to physical addresses. For example, the Crash Bash investigation +that motivated this option can be reproduced without title-specific code: + +```powershell +$env:PSX_WTRACE_BOOT='0x000B3A80,0x000B3B00' +.\CrashBashRecomp.exe +``` + +Connect at any later point and query `wtrace_boot_stats`, +`wtrace_boot_summary`, or `wtrace_boot_dump`. Each retained entry includes the +write address/value/width, guest PC and return address, register context, frame, +and DMA channel. The option is ignored in builds made with debug tools disabled. + +--- + +## Divergence-hunt workflow When a recompiled-BIOS bug is suspected, the two servers let you find the **first** divergence instead of chasing symptoms. Standard procedure (inherited from v3's `DEBUG.md`): @@ -216,7 +217,7 @@ ring names the return path that let it come back. `sp_b`/`ra_b`/`s0_b`/`s3_b`, post-call `pc_a`/`ra_a`/`sp_a`/`s0_a`/ `s3_a`/`v0_a`, `bail`/`rfe`/`esc`/`in_exc` flags, `dstatic`/`dblocks`/ `dexc` engine-attribution deltas across the call, `last_func`. -- `{"cmd":"callret_watch","lo":"0"}` — disarm. +- `{"cmd":"callret_watch","lo":"0"}` — disarm. ## `hle_dump` — BIOS-HLE tier call ring (native only) @@ -265,9 +266,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i ## Complete command index (generated) -**306 commands registered** — 293 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). - -51 of 306 have prose above; **255 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +**311 commands registered** — 298 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). + +52 of 311 have prose above; **259 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -312,6 +313,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `cdrom_sector_history_clear` | ✓ | | ✓ | | `cdrom_state` | ✓ | | | | `cdrom_timing` | ✓ | | | +| `cdrom_timing_dump` | ✓ | | | | `cdrom_trace_clear` | ✓ | | | | `cdrom_trace_dump` | ✓ | | | | `ce_profile` | ✓ | | | @@ -381,6 +383,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `frame_range` | ✓ | ✓ | ✓ | | `frame_timeseries` | ✓ | ✓ | ✓ | | `freeze_check` | ✓ | | | +| `func_override` | ✓ | | ✓ | | `game_options` | ✓ | | | | `geom_correction` | ✓ | | ✓ | | `get_frame` | ✓ | ✓ | ✓ | @@ -464,8 +467,8 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `phase_profile` | ✓ | | | | `ping` | ✓ | ✓ | ✓ | | `present_ring` | ✓ | | | -| `present_shot` | ✓ | | ✓ | -| `present_shot_seq` | ✓ | | ✓ | +| `present_shot` | ✓ | | ✓ | +| `present_shot_seq` | ✓ | | ✓ | | `press` | ✓ | ✓ | | | `probe_clear` | ✓ | | | | `probe_trace` | ✓ | | | @@ -490,6 +493,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `run_to_frame` | ✓ | | ✓ | | `s3_smear_watch` | ✓ | | ✓ | | `savestate` | ✓ | | | +| `savestate_status` | ✓ | | | | `screenshot` | ✓ | ✓ | ✓ | | `screenshot_file` | ✓ | ✓ | ✓ | | `screenshot_hires` | ✓ | | ✓ | @@ -555,6 +559,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `ws_hud_mode` | ✓ | | | | `ws_margin` | ✓ | | | | `ws_nw` | ✓ | | | +| `ws_ui_groups` | ✓ | | | | `wtrace_add` | ✓ | | | | `wtrace_all_dump` | ✓ | ✓ | | | `wtrace_all_reset` | ✓ | ✓ | | @@ -579,5 +584,6 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `xlate` | ✓ | | | | `xprobe` | ✓ | | | | `xprobe_arm` | ✓ | | | +| `xprobe_watch` | ✓ | | | diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index cec1a1b64..110e86e44 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -320,6 +320,23 @@ frames. Trusted callbacks receive only the narrow C services exposed by `runtime/include/mod_plugins.h`. Games should continue to use declarative patches and overlays when those operations are sufficient. +An implementation may also register **function overrides** under a plugin id +(`psx_mod_register_function_override`): hand-written C that replaces or wraps +a guest function at a given address, with an optional prologue-word residency +guard. Registration only queues the override; it is ARMED into the dispatcher +tier when the resolved plan selects that plugin — the same gating as the other +callback kinds, so an override-only plugin id counts as available to the +resolver. Every registration states a required guest-cycle `credit` (a fixed +per-handled-call charge, `0` for a mod with no hardware analog, or +`FO_CREDIT_SELF` when the body — or a wrapped original — accounts for its own +time). The full execution contract (guest ABI, decline semantics, +`func_override_call_original` wrap primitive, `func_override_guest_call`, +determinism and cycle-accounting policy) is documented in +`runtime/include/func_override.h`. Overrides registered directly through +`func_override_add` (game `EXTRAS_SOURCES` constructors, the progressive- +decompilation idiom) bypass package gating and are always active; packages +are the right home for anything a player should be able to toggle. + `psx_mod_set_load_acceleration(multiplier, release_frames)` is the narrow pre-boot service for a game-owned fast-loading feature. It changes host wall-clock pacing only: guest VBlanks, CD deadlines, interrupts, callbacks, and diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index c15f32d45..601984910 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -1713,6 +1713,12 @@ void FullFunctionEmitter::emit_dispatch( out += " * NULL (the default) = pure LLE, dispatch identical to a build without\n"; out += " * the tier. */\n"; out += "extern int (*g_psx_bios_hle_hook)(CPUState* cpu, uint32_t phys);\n\n"; + out += "/* Game function-override tier (runtime/include/func_override.h):\n"; + out += " * hand-written C registered against a guest address. NULL (the\n"; + out += " * default) = no overrides, dispatch identical to a build without\n"; + out += " * the tier. Returns 1 when the override handled the call; the\n"; + out += " * guest resumes at $ra exactly as if the original ran jr $ra. */\n"; + out += "extern int (*g_psx_func_override_hook)(CPUState* cpu, uint32_t phys);\n\n"; out += "#ifdef PSX_HAS_GAME_DISPATCH\n"; out += "extern int psx_game_address_in_text(uint32_t addr);\n"; out += "#endif\n\n"; @@ -2046,6 +2052,18 @@ void FullFunctionEmitter::emit_dispatch( out += " /* Byte-guarded A0/B0/C0 call-vector tail stubs. */\n"; out += " if (!found && psx_bios_try_native_call_stub(cpu, addr))\n"; out += " found = 1;\n"; + out += " /* Function-override tier: AFTER the BIOS tiers (an override\n"; + out += " * can never shadow a kernel service vector), BEFORE every\n"; + out += " * game code backend, so one address-keyed hook covers the\n"; + out += " * static EXE, runtime-loaded overlays and dirty RAM alike.\n"; + out += " * Handled (rc 1) => the override completed against guest\n"; + out += " * state; resume at $ra via the trampoline's normal\n"; + out += " * return/tail contract. rc 0 => fall through untouched. */\n"; + out += " if (!found && g_psx_func_override_hook &&\n"; + out += " g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu)) {\n"; + out += " cpu->pc = cpu->gpr[31];\n"; + out += " found = 1;\n"; + out += " }\n"; out += "#ifdef PSX_HAS_GAME_DISPATCH\n"; out += " /* Game EXEs can overlap the BIOS shell copy window at\n"; out += " * physical 0x30000-0x5AFFF. If the target belongs to the\n"; diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 7e7acf880..e12d475f3 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -220,6 +220,16 @@ if(BUILD_TESTING) target_include_directories(bios_hle_plan_test PRIVATE include) add_test(NAME bios_hle_plan_test COMMAND bios_hle_plan_test) + # Every func_override failure mode is silent at runtime: a registration + # that is never consulted, a package override that survives clear-mods + # (a netplay divergence), a residency guard that corrupts instead of + # declining. Drives the real g_psx_func_override_hook pointer. + add_executable(func_override_test + tests/test_func_override.c + src/func_override.c) + target_include_directories(func_override_test PRIVATE include) + add_test(NAME func_override_test COMMAND func_override_test) + # A cue, conventional raw image, or Steam .car image is a valid pick; each # must mount the same disc and identify off the same data track. add_executable(disc_path_resolve_test @@ -276,6 +286,7 @@ if(BUILD_TESTING) add_executable(mod_runtime_test tests/test_mod_runtime.cpp src/mod_runtime.cpp + src/func_override.c src/mod_packages.cpp src/disc_path.cpp src/cue_sheet.cpp diff --git a/runtime/include/func_override.h b/runtime/include/func_override.h new file mode 100644 index 000000000..3e7362597 --- /dev/null +++ b/runtime/include/func_override.h @@ -0,0 +1,230 @@ +/* func_override.h — replace a guest function with hand-written C. + * + * WHY THIS EXISTS + * + * A recomp turns the game into C, but that C is a build artifact: regenerated + * from the player's own disc, never hand-edited (CLAUDE.md rule 4). So there + * is no supported way to say "this function should do something else" — which + * is the single most important thing a port needs to grow beyond a faithful + * replay of the original. + * + * This is that mechanism. Register hand-written C against a guest address and + * the dispatcher calls yours instead of the recompiled original. + * + * NO SIZE LIMIT. This is not a ROM patch; there is no slot to fit inside, no + * code cave, nothing to shift. Your replacement is native code in a native + * binary — five instructions or five thousand lines. That freedom is the + * entire point of recompiling rather than hacking bytes. + * + * WHERE IT HOOKS, AND WHY IT MATTERS + * + * The hook is consulted in psx_dispatch_impl after the BIOS tiers (so an + * override can never shadow a kernel service vector) and BEFORE every game + * code backend. That placement is deliberate and load-bearing: one + * address-keyed hook covers the STATIC EXE, runtime-loaded OVERLAYS and + * DIRTY RAM alike. The interpreter's JAL/JALR call-resolution tiers consult + * it on the SAME terms — before enter-compiled, before overlay-native and + * before the local pc-chain (the reserved CRES_OVERRIDE slot) — so no call + * the interpreter resolves itself can bypass an override. Ordering there is + * not cosmetic: interp_enter_compiled calls psx_dispatch_game_compiled -> + * entry->fn(cpu) directly and never re-enters psx_dispatch_impl, so a hook + * placed after it is unreachable for any override on a statically-compiled + * function reached from interpreted code, while registration and the + * func_override inventory both still look healthy. + * + * SCOPE: overrides are CALL-site keyed. A guest tail transfer (j / jr) into + * an overridden address from the dirty-RAM block loop is a TRANSFER, not a + * call, and does not consult the hook — see dirty_ram_interp.c's compiled + * handoff sites. Registering on a function only ever entered by tail + * transfer will show calls == 0. + * + * THE CONTRACT + * + * Your function receives the CPUState and must obey the guest ABI: + * + * int my_impl(CPUState *cpu) { + * uint32_t a0 = cpu->gpr[4], a1 = cpu->gpr[5]; // arguments + * ... + * cpu->gpr[2] = result; // $v0 = return value + * return 1; + * } + * + * Return 1 and the guest resumes at $ra exactly as if the original had run + * `jr $ra`. Return 0 and the original recompiled or interpreted code runs + * untouched, so an override can decline case by case (a conditional + * pre-hook: mutate state, return 0, original runs). + * + * POINTERS MUST BE GUEST ADDRESSES. The caller is recompiled too: when it + * dereferences your return value it performs a guest load. A host pointer + * will read garbage. If you need to hand back a buffer, write it into guest + * RAM (psx_write_byte and friends, or psx_mod_alloc_guest_memory) and return + * that address. + * + * DETERMINISM. Keep ALL mutable state in guest RAM. Rollback/rewind and + * netplay snapshot guest state only; host-side statics in an override + * desync replays. Reading host config that never changes mid-session is + * fine. + * + * CYCLE ACCOUNTING — REQUIRED AT REGISTRATION. + * On hardware the replaced function took guest time, and every device in + * the machine schedules against that clock; a replacement that takes zero + * cycles shifts IRQ phase for the rest of the session. So every + * registration MUST state its timing intent via the `credit` argument — + * there is no default to inherit. Two forms: + * + * credit >= 0 The tier charges exactly `credit` guest cycles + * (psx_advance_cycles) on every HANDLED call, after + * the body runs. Declines charge nothing — the + * original runs and accrues its own cycles. 0 is + * legal and means "deliberately free": right for a + * player-toggled mod whose behavior has no hardware + * analog, indefensible for an always-on + * reimplementation claiming to be indistinguishable. + * + * FO_CREDIT_SELF The tier charges nothing; the BODY owns its timing + * and calls psx_advance_cycles() itself. Use this + * when the original's cost is data-dependent + * (charge base + per_iteration * n, the bios_hle.c + * TIMING NOTE pattern), and ALWAYS for wraps: the + * original re-dispatched by func_override_call_original + * accrues its exact cycles by executing, so a fixed + * nonzero credit on a wrap double-counts. Guest code + * invoked via func_override_guest_call likewise + * self-charges — credit only the code you REPLACED. + * + * Credits are approximations of the original's dynamic instruction count + * unless measured; LLE remains the timing oracle (run with the override + * declined and sample the cycle delta across the call boundary — the + * callret ring — to measure a real distribution). See FAITHFUL_TIMING_PLAN. + * + * WHAT IT DOES NOT DO + * + * It does not lift the game's own assumptions. If the caller copies your + * result into a fixed-size field, that field is still fixed. The override + * removes the code limit, not every limit. + */ + +#ifndef PSXRECOMP_FUNC_OVERRIDE_H +#define PSXRECOMP_FUNC_OVERRIDE_H + +#include +#include + +struct CPUState; + +#ifdef __cplusplus +extern "C" { +#endif + +#define FO_MAX_OVERRIDES 128 +#define FO_MAX_ID 64 +#define FO_MAX_GUARD_WORDS 4 + +/* `credit` value: the override body states its own timing by calling + * psx_advance_cycles() itself (mandatory for wraps — see CYCLE ACCOUNTING + * above). Any other negative value is FO_ERR_ARGS. */ +#define FO_CREDIT_SELF (-1) + +/* func_override_add return codes. */ +#define FO_OK 0 +#define FO_ERR_FULL 1 +#define FO_ERR_ARGS 2 +#define FO_ERR_DUPLICATE 3 + +/* An override implementation. Reads arguments from cpu->gpr[4..7], writes + * the return value to cpu->gpr[2]. Returning nonzero means "handled"; + * returning 0 declines this call and the original code runs instead. */ +typedef int (*FuncOverrideFn)(struct CPUState *cpu); + +/* Register `fn` for guest address `addr` (KSEG or physical — normalised). + * `id` is copied, used for diagnostics, and may be NULL. Registering the + * same address twice is an error rather than a silent last-wins, because a + * silent shadow is impossible to debug. `credit` is the guest-cycle policy + * (CYCLE ACCOUNTING above): >= 0 charged per handled call, or + * FO_CREDIT_SELF when the body charges its own. */ +int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn, + int32_t credit); + +/* Like func_override_add, plus a residency guard: before each consult the + * first `n_words` guest words at `addr` are compared against + * `expected_words`; on mismatch the override silently declines (the guard + * miss is counted, see func_override_get_ex). Use this when the address + * belongs to overlay/dirty RAM that other code may occupy at other times — + * the guard makes "wrong code resident" a decline instead of a corruption. + * n_words must be 1..FO_MAX_GUARD_WORDS. */ +int func_override_add_guarded(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, + int32_t credit); + +/* Call a guest function from inside an override (or any native code on the + * dispatch thread): args already placed in cpu->gpr[4..7], returns after the + * callee completes; result in cpu->gpr[2]. `site_ra` is the return address + * the callee should observe in $ra — pass the original call site's return + * address when re-creating a call the original code would have made, so + * callees, fntrace and crash forensics see authentic values. $ra is + * restored around the call. */ +void func_override_guest_call(struct CPUState *cpu, uint32_t target, + uint32_t site_ra); + +/* Run the ORIGINAL code of the function currently being overridden — the + * wrap primitive. Callable only from inside an executing override; arms a + * one-shot bypass for this address, re-dispatches it, and returns after the + * original completes (result in cpu->gpr[2]). Typical post-hook: + * + * int wrap(CPUState *cpu) { + * func_override_call_original(cpu); // original runs fully + * ...inspect / adjust results... + * return 1; // we handled the call + * } + * + * The bypass is one-shot per invocation: if the original recursively calls + * itself, the recursive calls consult the override again (matching what a + * guest-level wrap would observe). */ +void func_override_call_original(struct CPUState *cpu); + +/* Register on behalf of a mod package plan. Identical to the two calls above + * (pass n_words 0 for unguarded) except the entry is tagged package-armed, so + * func_override_reset_package_armed can drop it when the plan is cleared. + * The mod runtime uses this; game code registering its own always-on + * reimplementations should use func_override_add/_add_guarded. */ +int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, + int32_t credit); + +/* Install the dispatcher hook. Call once at startup AFTER registering (the + * mod runtime calls it again after arming package-gated overrides — safe). + * Cheap with nothing registered — it leaves the hook NULL, so dispatch is + * byte-identical to a build without the tier. */ +void func_override_install(void); + +/* Drop every package-armed override, keep direct registrations, re-install + * (so the hook goes back to NULL if the table empties). Returns how many were + * dropped. THIS IS REQUIRED FOR CORRECTNESS, not a convenience: a cleared mod + * plan stops activation/vblank callbacks simply by no longer being iterated, + * but an armed override lives in this module's own table, so without an + * explicit reset it keeps firing after the plan is gone — including after + * netplay's clear-mods announces a vanilla session, which is a peer + * divergence. Direct registrations are deliberately kept: they are the game's + * own always-on faithful reimplementations, present identically in both + * peers' builds and not player-selectable. */ +int func_override_reset_package_armed(void); + +/* Introspection, surfaced by the `func_override` TCP command. An override + * whose `calls` stays 0 was never reached: wrong address, or that code path + * never ran. `guard_misses` counts consults declined by the residency + * guard. `credit_out` reports the declared cycle policy (FO_CREDIT_SELF or + * the fixed per-call charge), so every armed override's timing intent is + * inspectable, not just documented. */ +int func_override_count(void); +int func_override_get(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out); +int func_override_get_ex(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out, + uint64_t *guard_misses_out, int *guarded_out, + int32_t *credit_out); + +#ifdef __cplusplus +} +#endif + +#endif /* PSXRECOMP_FUNC_OVERRIDE_H */ diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 65f4510c5..19352f6b9 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -378,6 +378,7 @@ void mod_clear_builtin_resolvers_for_tests(); bool mod_register_activation_plugin(const std::string& id, void (*callback)(void)); bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)); bool mod_plugin_registered(const std::string& id); +bool mod_register_function_override_marker(const std::string& id); void mod_invoke_activation_plugin(const std::string& id); void mod_invoke_vblank_plugin(const std::string& id); void mod_clear_plugins_for_tests(); diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index a1e4645e1..d38dd136c 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -2,6 +2,8 @@ #include +#include "func_override.h" /* FO_CREDIT_SELF for the credit argument below */ + #ifdef __cplusplus extern "C" { #endif @@ -26,6 +28,34 @@ int psx_mod_register_function_entry_plugin( /* Called only from generated functions explicitly listed by the game config. */ void psx_mod_function_entry(struct CPUState* cpu, uint32_t address); +/* + * Package-gated function override (see runtime/include/func_override.h for + * the full contract). Registration queues the override under the plugin id; + * it is ARMED into the dispatcher tier only when a resolved package plan + * selects that plugin — the same gating as vblank/activation callbacks. The + * callback obeys the guest ABI (args cpu->gpr[4..7], result cpu->gpr[2]); + * return 1 = handled (guest resumes at $ra), 0 = decline (original runs). + * Wrap semantics via func_override_call_original(); optional residency + * guard via expected_words (NULL/0 = unguarded). + * + * `id` may carry an optional ":label" suffix ("pkg.feature:aim") that names + * the override in the `func_override` TCP inventory; gating and manifest + * matching use only the part before the ':', so several overrides can sit + * under one [[plugin]] entry and still read apart in diagnostics. + * + * `credit` is the REQUIRED guest-cycle policy (func_override.h, CYCLE + * ACCOUNTING): >= 0 cycles charged per handled call, or FO_CREDIT_SELF when + * the body charges its own. For a mod whose behavior has no hardware analog + * an explicit 0 is the honest value; a wrap that runs the original via + * func_override_call_original must pass FO_CREDIT_SELF (the original + * self-charges by executing — a fixed credit would double-count). + */ +typedef int (*PSXModFunctionOverrideFn)(struct CPUState* cpu); +int psx_mod_register_function_override(const char* id, uint32_t address, + PSXModFunctionOverrideFn fn, + const uint32_t* expected_words, + int n_words, int32_t credit); + /* Narrow guest services available to trusted plugin callbacks. */ int psx_mod_game_started(void); uint8_t psx_mod_read_byte(uint32_t address); diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index e9b9aa7ce..e9e5f2a3a 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -389,6 +389,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/autocompile.c ${PSXRECOMP_ROOT}/runtime/src/code_provider.c ${PSXRECOMP_ROOT}/runtime/src/event_ring.c + ${PSXRECOMP_ROOT}/runtime/src/func_override.c ${PSXRECOMP_ROOT}/runtime/src/game_options.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_speed.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_pgxp.c diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 27c17f425..0b171b99c 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9003,6 +9003,61 @@ static void handle_wide_full(int id, const char *json) id, path, W, H); } +/* Function-override tier introspection (func_override.h): count + per-entry + * {id, addr, calls, guard_misses}. calls == 0 = never reached (wrong address + * or path never ran); guard_misses = consults declined by the residency + * guard. */ +static void handle_func_override(int id, const char *json) +{ + extern int func_override_count(void); + extern int func_override_get_ex(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out, + uint64_t *guard_misses_out, + int *guarded_out, int32_t *credit_out); + (void)json; + char buf[16 * 1024]; + int n = snprintf(buf, sizeof(buf), + "{\"id\":%d,\"ok\":true,\"count\":%d,\"overrides\":[", + id, func_override_count()); + for (int i = 0; i < func_override_count(); i++) { + char oid[64]; + uint32_t addr = 0; + uint64_t calls = 0, misses = 0; + int guarded = 0; + int32_t credit = 0; + char creditstr[16]; + if (!func_override_get_ex(i, oid, sizeof(oid), &addr, &calls, + &misses, &guarded, &credit)) + break; + /* credit: the declared cycle policy — a number (fixed per-call + * charge) or the string "self" (the body charges its own). */ + if (credit < 0) + snprintf(creditstr, sizeof(creditstr), "\"self\""); + else + snprintf(creditstr, sizeof(creditstr), "%d", credit); + /* Clamp before advancing: snprintf returns the length it WOULD have + * written, so on truncation an unclamped n exceeds sizeof(buf) and + * the next sizeof(buf) - n underflows to a huge size_t. The 256-byte + * headroom check below keeps that unreachable at today's caps, but + * raising FO_MAX_ID or FO_MAX_OVERRIDES must not silently turn this + * into an overflow. */ + const int w = snprintf(buf + n, sizeof(buf) - (size_t)n, + "%s{\"id\":\"%s\",\"addr\":\"0x%08X\",\"calls\":%llu," + "\"guard_misses\":%llu,\"guarded\":%d,\"credit\":%s}", + i ? "," : "", oid, addr, + (unsigned long long)calls, (unsigned long long)misses, + guarded, creditstr); + if (w < 0) break; + n += w; + if ((size_t)n >= sizeof(buf) - 256) { + n = (int)(sizeof(buf) - 256); + break; + } + } + snprintf(buf + n, sizeof(buf) - (size_t)n, "]}"); + send_fmt("%s", buf); +} + static void handle_vram_peek(int id, const char *json) { int x = json_get_int(json, "x", 0); @@ -13541,6 +13596,7 @@ static const CmdEntry s_commands[] = { { "mmx6_freshfix", handle_mmx6_freshfix }, { "mem_words", handle_mem_words }, { "vram_peek", handle_vram_peek }, + { "func_override", handle_func_override }, { "gl_coh_ring", handle_gl_coh_ring }, { "gl_present_ring", handle_gl_present_ring }, { "present_ring", handle_present_ring }, diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 3da7fd6fa..84013a016 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -1062,7 +1062,13 @@ enum { XSITE_INTERP = 0, XSITE_DD = 1 }; /* XOP_RES path codes (in the `site` field). */ enum { XRES_EC_BAIL = 2, XRES_EC_PC = 3, XRES_EC_CONTRACT = 4, XRES_EC_RET = 5, XRES_OV_BAIL = 6, XRES_OV_PC = 7, XRES_OV_CONTRACT = 8, XRES_OV_RET = 9, - XRES_NONLOCAL = 10, XRES_PCCHAIN = 11, XRES_UNDECODABLE = 12 }; + XRES_NONLOCAL = 10, XRES_PCCHAIN = 11, XRES_UNDECODABLE = 12, + /* Appended, NOT slotted into consult order: unlike CRES (which had a + * reserved gap at 6) this enum is dense, so inserting would renumber + * the codes above and invalidate every previously captured xprobe + * trace. The override tier is consulted FIRST at both call sites; the + * wire value carries no ordering meaning. */ + XRES_OVERRIDE = 13 }; int g_mixed_depth = 0; /* best-effort interp→compiled nesting depth; reset per frame */ @@ -1660,6 +1666,37 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, uint32_t _cr = callret_begin(cpu, pc, target); /* call-resolution ring */ #define CRET(code, rv) do { callret_end(_cr, cpu, (code)); return (rv); } while (0) if (g_precise_mode || g_ls_replay_active) { cpu->pc = target; CRET(CRES_PLAIN, 1); } /* slice / lockstep-replay: plain transfer, never execute the callee */ + /* Function-override tier (func_override.h): consulted BEFORE + * every game code backend, matching psx_dispatch_impl. It must + * precede interp_enter_compiled, not follow it: that tier calls + * psx_dispatch_game_compiled -> entry->fn(cpu) directly and + * never re-enters psx_dispatch_impl, so a hook consulted after + * it is unreachable for any override on a statically-compiled + * function called from interpreted code. Registration would + * still succeed and the func_override inventory would still + * list the entry, so the miss is invisible — the same + * registration-is-not-coverage failure the overlay-native and + * pc-chain tiers below already had. Handled => the override + * completed against guest state; same call contract as a + * compiled callee. */ + { + extern int (*g_psx_func_override_hook)(CPUState *cpu, + uint32_t phys); + if (g_psx_func_override_hook) { + cpu->pc = 0; + if (g_psx_func_override_hook(cpu, target & 0x1FFFFFFFu)) { + if (g_psx_call_bail) CRET(CRES_OVERRIDE, 1); + if (cpu->pc != 0) CRET(CRES_OVERRIDE, 1); + if (rd == 0 || rd == 31) { + if (psx_call_contract(cpu, return_pc, site_sp)) + CRET(CRES_OVERRIDE, 1); + } + { int _r = dirty_ram_finish_call_return(cpu, return_pc, + next_pc_out); + CRET(CRES_OVERRIDE | (_r ? 0x100u : 0u), _r); } + } + } + } #ifdef PSX_HAS_GAME_DISPATCH cpu->pc = 0; if (interp_enter_compiled(cpu, target)) { @@ -1871,6 +1908,27 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, #define XRES(code) do { (void)(code); } while (0) #endif if (g_precise_mode || g_ls_replay_active) { cpu->pc = target; return 1; } /* slice / lockstep-replay: plain transfer, never execute the callee */ + /* Function-override tier: same placement and contract as the JALR + * site above — BEFORE interp_enter_compiled, so an override on a + * statically-compiled callee is reachable from interpreted code + * (reserved CRES_OVERRIDE slot). */ + { + extern int (*g_psx_func_override_hook)(CPUState *cpu, + uint32_t phys); + if (g_psx_func_override_hook) { + cpu->pc = 0; + if (g_psx_func_override_hook(cpu, target & 0x1FFFFFFFu)) { + if (g_psx_call_bail) { XRES(XRES_OVERRIDE); return 1; } + if (cpu->pc != 0) { XRES(XRES_OVERRIDE); return 1; } + if (psx_call_contract(cpu, return_pc, site_sp)) { + XRES(XRES_OVERRIDE); return 1; + } + XRES(XRES_OVERRIDE); + return dirty_ram_finish_call_return(cpu, return_pc, + next_pc_out); + } + } + } #ifdef PSX_HAS_GAME_DISPATCH cpu->pc = 0; if (interp_enter_compiled(cpu, target)) { diff --git a/runtime/src/func_override.c b/runtime/src/func_override.c new file mode 100644 index 000000000..9575d04d2 --- /dev/null +++ b/runtime/src/func_override.c @@ -0,0 +1,278 @@ +/* func_override.c — replace a guest function with hand-written C. + * See runtime/include/func_override.h. + * + * CLAUDE.md rule 3 (no printf, no logs): this module prints nothing. State + * is exposed through the accessors and surfaced by the `func_override` TCP + * command. Registration errors are returned as codes for the caller to + * report. + */ + +#include "func_override.h" + +#include + +#include "cpu_state.h" +#include "psx_cycles.h" + +/* Residency-guard reads go through the UNTRACED peek, never psx_read_word. + * psx_read_word is a traced accessor (memory.c): under lockstep it feeds + * ls_read_hook, under lockstep replay it RETURNS the replayed value instead + * of RAM, and under DuckStation recording it calls ds_note_read. The guard + * is a host-side residency check, not a guest memory access — routing it + * through the traced path injects phantom reads the oracle side never + * performs (dirtying every divergence comparison) and makes the guard + * compare replayed data rather than resident bytes. The peek keeps the full + * address decode, so a guard on a non-RAM address stays correct. */ +extern uint32_t psx_peek_word_untraced(uint32_t addr); + +/* Set by us, read at the top of every psx_dispatch_impl (emitted by + * recompiler/src/full_function_emitter.cpp) and at the interpreter's + * JAL/JALR call-resolution tiers (dirty_ram_interp.c). NULL until install, + * so a build with no overrides dispatches exactly as before. */ +int (*g_psx_func_override_hook)(CPUState *cpu, uint32_t phys) = NULL; + +typedef struct { + char id[FO_MAX_ID]; + uint32_t phys; /* normalised: KSEG bits masked off */ + FuncOverrideFn fn; + uint64_t calls; + uint64_t guard_misses; + uint32_t guard[FO_MAX_GUARD_WORDS]; + int n_guard; /* 0 = unguarded */ + int package; /* 1 = armed by a mod package plan */ + int32_t credit; /* >= 0 charged per handled call; + FO_CREDIT_SELF = body self-charges */ +} Entry; + +static Entry s_entries[FO_MAX_OVERRIDES]; +static int s_count = 0; + +/* call_original support: the address whose hook is currently executing + * (for redial) and a one-shot bypass armed by func_override_call_original. + * Dispatch is single-threaded; a nested override (an override whose + * guest_call reaches another overridden function) saves/restores around + * the inner consult, so plain statics are correct here. */ +static uint32_t s_active_phys = 0; +static uint32_t s_bypass_phys = 0; + +static uint32_t normalise(uint32_t addr) { return addr & 0x1FFFFFFFu; } + +static Entry *find(uint32_t phys) +{ + for (int i = 0; i < s_count; i++) + if (s_entries[i].phys == phys) + return &s_entries[i]; + return NULL; +} + +static int add_common(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *guard, int n_guard, int package, + int32_t credit) +{ + if (!fn) return FO_ERR_ARGS; + if (n_guard < 0 || n_guard > FO_MAX_GUARD_WORDS) return FO_ERR_ARGS; + if (n_guard > 0 && !guard) return FO_ERR_ARGS; + /* The credit is a required statement of timing intent (header, CYCLE + * ACCOUNTING): a fixed per-call charge, or FO_CREDIT_SELF. Any other + * negative value is a typo, not a policy. */ + if (credit < FO_CREDIT_SELF) return FO_ERR_ARGS; + if (s_count >= FO_MAX_OVERRIDES) return FO_ERR_FULL; + + const uint32_t phys = normalise(addr); + /* phys 0 is the "not inside an override" sentinel for s_active_phys and + * the "no bypass armed" sentinel for s_bypass_phys, so an override there + * would silently break func_override_call_original. No real function + * lives at guest 0 anyway. */ + if (phys == 0) return FO_ERR_ARGS; + /* Two overrides on one address is always a mistake, and a silent + * last-wins would be untraceable. Refuse it. */ + if (find(phys)) return FO_ERR_DUPLICATE; + + Entry *e = &s_entries[s_count]; + memset(e, 0, sizeof(*e)); + if (id) { + strncpy(e->id, id, FO_MAX_ID - 1); + e->id[FO_MAX_ID - 1] = '\0'; + } + e->phys = phys; + e->fn = fn; + for (int i = 0; i < n_guard; i++) e->guard[i] = guard[i]; + e->n_guard = n_guard; + e->package = package ? 1 : 0; + e->credit = credit; + s_count++; + return FO_OK; +} + +int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn, + int32_t credit) +{ + return add_common(id, addr, fn, NULL, 0, 0, credit); +} + +int func_override_add_guarded(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, + int32_t credit) +{ + if (n_words < 1) return FO_ERR_ARGS; + return add_common(id, addr, fn, expected_words, n_words, 0, credit); +} + +int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, + int32_t credit) +{ + if (n_words < 0 || n_words > FO_MAX_GUARD_WORDS) return FO_ERR_ARGS; + return add_common(id, addr, fn, n_words ? expected_words : NULL, n_words, + 1, credit); +} + +/* The hook. Runs on EVERY dispatch, so the common path — nothing registered + * for this address — must stay cheap. A linear scan over a handful of + * entries beats a hash for realistic counts and keeps the code honest; + * revisit if a game ever registers hundreds. */ +static int hook(CPUState *cpu, uint32_t phys) +{ + for (int i = 0; i < s_count; i++) { + Entry *e = &s_entries[i]; + if (e->phys != phys) continue; + /* One-shot bypass: func_override_call_original re-dispatched this + * address; let the original backend take it exactly once. */ + if (s_bypass_phys == phys) { + s_bypass_phys = 0; + return 0; + } + /* Residency guard: wrong bytes at the address (overlay swapped, + * page reused) means the function this override targets is not + * resident — decline, never corrupt. */ + if (e->n_guard) { + const uint32_t base = 0x80000000u | phys; + int miss = 0; + for (int w = 0; w < e->n_guard; w++) + if (psx_peek_word_untraced(base + (uint32_t)(w * 4)) != + e->guard[w]) { + miss = 1; + break; + } + if (miss) { + e->guard_misses++; + return 0; + } + } + /* Count the CONSULT, not just the handled case: an override that + * declines still proves the address was reached through a hooked + * path, which is exactly what a diagnostic probe needs (calls == 0 + * means "this call never crosses a hook", the bypass class of bug + * that hid the interp local-chain gap). */ + e->calls++; + { + const uint32_t saved_active = s_active_phys; + s_active_phys = phys; + const int handled = e->fn(cpu) ? 1 : 0; + s_active_phys = saved_active; + /* Charge the declared credit only when HANDLED: a decline runs + * the original, which accrues its exact cycles by executing. + * FO_CREDIT_SELF (< 0) and 0 charge nothing here — SELF bodies + * called psx_advance_cycles themselves, and guest work the body + * ran via func_override_guest_call / call_original has already + * self-charged through the normal backends. */ + if (handled && e->credit > 0) + psx_advance_cycles((uint32_t)e->credit); + return handled; + } + } + return 0; +} + +void func_override_guest_call(CPUState *cpu, uint32_t target, uint32_t site_ra) +{ + /* Spoof $ra to the original call site so callees, fntrace and crash + * forensics see authentic values, then restore. psx_dispatch_call runs + * the callee to completion (pc == site_ra with the caller's $sp). */ + extern void psx_dispatch_call(CPUState *cpu, uint32_t addr, + uint32_t return_addr); + const uint32_t saved_ra = cpu->gpr[31]; + cpu->gpr[31] = site_ra; + psx_dispatch_call(cpu, target, site_ra); + cpu->gpr[31] = saved_ra; +} + +void func_override_call_original(CPUState *cpu) +{ + if (!s_active_phys) return; /* not inside an override — nothing to do */ + const uint32_t phys = s_active_phys; + const uint32_t saved = s_bypass_phys; + s_bypass_phys = phys; + /* KSEG0 form: game text dispatches with the 0x8000_0000 view; the hook + * itself keys on the normalised address either way. Return to the + * override's caller frame: the original must observe the same $ra the + * override was entered with. */ + func_override_guest_call(cpu, 0x80000000u | phys, cpu->gpr[31]); + s_bypass_phys = saved; +} + +void func_override_install(void) +{ + /* Leave the hook NULL when empty: dispatch then matches a build without + * the tier exactly, which keeps "overrides cost nothing" literally + * true. */ + g_psx_func_override_hook = (s_count > 0) ? hook : NULL; +} + +int func_override_reset_package_armed(void) +{ + /* Drop every package-armed entry and compact, keeping direct + * registrations. Direct ones are the game's own always-on faithful + * reimplementations: not player-toggleable, identical in both peers' + * builds, so netplay has no reason to disarm them. Package-armed ones + * ARE player-selected, so a cleared plan must leave no trace of them — + * without this the entry survives in the table with the hook still + * installed and keeps firing after the vanilla-session banner prints. + * + * Any in-flight bypass/active marker refers to an entry that may be + * disappearing, so clear both rather than leave a dangling address. */ + int removed = 0; + int w = 0; + for (int i = 0; i < s_count; i++) { + if (s_entries[i].package) { removed++; continue; } + if (w != i) s_entries[w] = s_entries[i]; + w++; + } + s_count = w; + if (removed) { + s_active_phys = 0; + s_bypass_phys = 0; + func_override_install(); /* follows s_count back to NULL if empty */ + } + return removed; +} + +int func_override_count(void) { return s_count; } + +int func_override_get(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out) +{ + return func_override_get_ex(index, id_out, id_cap, addr_out, calls_out, + NULL, NULL, NULL); +} + +int func_override_get_ex(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out, + uint64_t *guard_misses_out, int *guarded_out, + int32_t *credit_out) +{ + if (index < 0 || index >= s_count) return 0; + const Entry *e = &s_entries[index]; + /* Bounded copy: the caller states its buffer size rather than being + * silently required to supply FO_MAX_ID bytes. Always NUL-terminated. */ + if (id_out && id_cap) { + strncpy(id_out, e->id, id_cap - 1); + id_out[id_cap - 1] = '\0'; + } + if (addr_out) *addr_out = e->phys; + if (calls_out) *calls_out = e->calls; + if (guard_misses_out) *guard_misses_out = e->guard_misses; + if (guarded_out) *guarded_out = e->n_guard; + if (credit_out) *credit_out = e->credit; + return 1; +} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 5d62ea394..4d6c00816 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -75,6 +75,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "launcher_device.h" #include "game_options.h" #include "mod_plugins.h" +#include "func_override.h" #include "mod_runtime.h" #include "crc32.h" #include "disc_identity.h" @@ -12932,6 +12933,11 @@ int main(int argc, char** argv) { g_turbo_loads_enabled = 0; g_frame_interpolation_blend = g_frame_interpolation_blend_default; mod_runtime_activate_plugins(); + /* Arm directly-registered function overrides (EXTRAS_SOURCES game code + * calling func_override_add from constructors, the adrecomp idiom) even + * when no package plan exists; package-gated overrides were armed by + * mod_runtime_activate_plugins above. Idempotent. */ + func_override_install(); apply_netplay_local_viewport_aspect(net_cfg.enabled); for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { if (g_mod_controller_mode_override[i] >= 0) diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 6a4383118..76e5a4c98 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1472,6 +1472,16 @@ uint32_t psx_read_word(uint32_t addr) { if (!psx_get_in_exception()) ls_read_hook(addr, 4, v); return v; } +/* Untraced word peek for HOST-SIDE inspection: same address decode as a guest + * read, but no lockstep record, no lockstep-replay substitution, and no + * data-shard/DuckStation capture. Use this for anything the guest did not + * actually execute — residency guards, host-side checks, diagnostics — so a + * host inspection can never appear in a guest read stream (which would both + * dirty every divergence comparison and, under replay, return replayed data + * instead of resident bytes). Never use it to service a guest load. */ +uint32_t psx_peek_word_untraced(uint32_t addr) { + return psx_read_word_raw(addr); +} /* Physical address of a CPU/DMA main-RAM access. Fold KUSEG/KSEG0/KSEG1 first * (0x1FFFFFFF), then fold the 2nd-4th main-RAM mirrors: real hardware mirrors the * 2 MB DRAM across the WHOLE 0..0x7FFFFF physical window (Beetle libretro.cpp:874 diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index e9292e2ea..9796244fd 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -34,6 +34,10 @@ std::map& builtin_resolvers() { struct RegisteredPlugin { PSXModActivationCallback activation = nullptr; PSXModVBlankCallback vblank = nullptr; + /* Set when a function override is queued under this id (the callback + * itself lives in mod_runtime's pending list) — participates in + * availability so a manifest can gate an override-only plugin. */ + bool function_override = false; }; std::map& registered_plugins() { @@ -1449,10 +1453,19 @@ bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)) { return true; } +bool mod_register_function_override_marker(const std::string& id) { + if (!valid_id(id)) return false; + RegisteredPlugin& plugin = registered_plugins()[id]; + if (plugin.function_override) return false; + plugin.function_override = true; + return true; +} + bool mod_plugin_registered(const std::string& id) { const auto found = registered_plugins().find(id); return found != registered_plugins().end() && - (found->second.activation || found->second.vblank); + (found->second.activation || found->second.vblank || + found->second.function_override); } void mod_invoke_activation_plugin(const std::string& id) { diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index e481779f4..052033726 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1,6 +1,7 @@ #include "mod_runtime.h" #include "disc_path.h" +#include "func_override.h" #include "iso_reader.h" #include "mod_packages.h" #include "mod_plugins.h" @@ -88,6 +89,27 @@ std::vector& function_entry_plugins() { return value; } +/* Function overrides queue here at constructor time and are ARMED into the + * func_override tier only for plugins the resolved package plan selects — + * the same gating as vblank/activation callbacks. `id` is the full + * registered name (may carry a ":label" suffix, kept for diagnostics); + * `plugin` is the manifest-facing part gating matches on. */ +struct FunctionOverridePlugin { + std::string id; + std::string plugin; + uint32_t address = 0; + PSXModFunctionOverrideFn fn = nullptr; + uint32_t guard[FO_MAX_GUARD_WORDS] = {0, 0, 0, 0}; + int n_guard = 0; + int32_t credit = 0; + bool armed = false; +}; + +std::vector& function_override_plugins() { + static std::vector value; + return value; +} + const ModPackage* selected_package(const std::string& id) { return state().manager.selected_package(id); } @@ -1134,6 +1156,20 @@ bool mod_runtime_clear_for_netplay(std::string* error) { s.disc_enabled = false; s.disc_guard_failed = false; s.error.clear(); + /* Clearing s.plan is enough for activation/vblank callbacks — they only + * run while something iterates the plan. Function overrides are armed + * into func_override.c's own table with the dispatcher hook installed, + * so they survive a cleared plan and keep firing unless explicitly + * disarmed. That matters most on the rematch path (main.cpp jumps to + * session_reboot, which is PAST mod_runtime_activate_plugins and + * func_override_install), where a modded session entering netplay would + * otherwise print the vanilla banner and still run its overrides — + * diverging from a peer without the mod. Also re-arm-able: dropping the + * armed flag lets a later plan register the same override again. */ + const int disarmed = func_override_reset_package_armed(); + if (disarmed > 0) + for (FunctionOverridePlugin& pending : function_override_plugins()) + pending.armed = false; if (error) error->clear(); std::fprintf(stdout, "psxrecomp: mods cleared for netplay (vanilla session)\n"); return true; @@ -1281,6 +1317,26 @@ extern "C" void mod_runtime_activate_plugins(void) { 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) { @@ -1419,6 +1475,45 @@ extern "C" int psx_mod_register_function_entry_plugin( return 1; } +extern "C" int psx_mod_register_function_override( + const char* id, uint32_t address, PSXModFunctionOverrideFn fn, + const uint32_t* expected_words, int n_words, int32_t credit) { + using namespace PSXRecompV4; + if (!id || !*id || !address || !fn) return 0; + if (n_words < 0 || n_words > FO_MAX_GUARD_WORDS) return 0; + if (n_words > 0 && !expected_words) return 0; + if (credit < FO_CREDIT_SELF) return 0; + /* An optional ":label" suffix names this override in diagnostics (the + * `func_override` TCP command) without multiplying manifest plugin ids — + * gating and resolver availability use only the part before the ':'. + * "pkg.feature:aim" and "pkg.feature:fire" are two overrides under the + * one manifest plugin "pkg.feature". */ + const char* colon = strchr(id, ':'); + const std::string plugin_id = colon ? std::string(id, colon - id) + : std::string(id); + if (plugin_id.empty() || (colon && !colon[1])) return 0; + auto& plugins = function_override_plugins(); + const auto duplicate = std::find_if( + plugins.begin(), plugins.end(), + [&](const FunctionOverridePlugin& item) { + return item.id == id && item.address == address; + }); + if (duplicate != plugins.end()) return 0; + FunctionOverridePlugin plugin; + plugin.id = id; + plugin.plugin = plugin_id; + plugin.address = address; + plugin.fn = fn; + for (int i = 0; i < n_words; ++i) plugin.guard[i] = expected_words[i]; + plugin.n_guard = n_words; + plugin.credit = credit; + plugins.push_back(plugin); + /* Mark the plugin id available to the package resolver so a manifest can + * gate an override-only plugin (multiple overrides may share one). */ + mod_register_function_override_marker(plugin_id); + return 1; +} + extern "C" void psx_mod_function_entry(CPUState* cpu, uint32_t address) { using namespace PSXRecompV4; if (!cpu) return; diff --git a/runtime/tests/test_func_override.c b/runtime/tests/test_func_override.c new file mode 100644 index 000000000..c48fbc388 --- /dev/null +++ b/runtime/tests/test_func_override.c @@ -0,0 +1,515 @@ +/* test_func_override.c — pins the func_override tier's contract. + * + * The tier's failure modes are all SILENT: a registration that never gets + * consulted, a package override that survives clear-mods, a guard that + * corrupts instead of declining. None of those announce themselves at + * runtime, so they need pinning here rather than field observation. + * + * The tests drive the real dispatcher entry point — g_psx_func_override_hook, + * the same pointer the generated dispatch and the interpreter call through — + * so nothing here exercises a test-only path. + */ + +#include +#include + +#include "cpu_state.h" +#include "func_override.h" + +extern int (*g_psx_func_override_hook)(CPUState *cpu, uint32_t phys); + +static int g_fail = 0; + +#define CHECK(cond, ...) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + g_fail = 1; \ + } \ + } while (0) + +/* ---- test doubles ------------------------------------------------------- */ + +/* Guest RAM the residency guard peeks at. func_override.c reads through + * psx_peek_word_untraced specifically so this stays a plain host read with no + * lockstep/oracle side effects — that is the property under test in + * guard_declines_on_mismatch, and the reason this double is trivial. */ +#define FAKE_RAM_WORDS 64 +static uint32_t s_ram[FAKE_RAM_WORDS]; + +uint32_t psx_peek_word_untraced(uint32_t addr) +{ + const uint32_t idx = (addr & 0x1FFFFFFFu) >> 2; + return (idx < FAKE_RAM_WORDS) ? s_ram[idx] : 0xDEADBEEFu; +} + +/* Cycle-core doubles: func_override.c charges fixed credits through the + * psx_advance_cycles inline (psx_cycles.h), which reads these globals. The + * doubles keep it a plain counter bump so the credit tests can assert + * against psx_cycle_count deltas directly. */ +uint64_t psx_cycle_count = 0; +uint64_t psx_next_service_cycle = 0; +int psx_in_device_service = 0; +int g_event_step_conservative = 0; +int g_ls_replay_active = 0; +uint32_t g_psx_cyc_batch = 0; +uint32_t g_psx_cyc_batch_limit = 0; +void psx_devices_service_to_now(void) {} +void psx_advance_cycles_slow(uint32_t cycles) { psx_cycle_count += cycles; } + +/* Stands in for the recompiled dispatcher. Re-consults the hook exactly as + * psx_dispatch_impl does, so func_override_call_original's one-shot bypass is + * exercised through its real mechanism rather than assumed. */ +static int s_original_runs = 0; +static int s_dispatch_depth = 0; + +/* When set, the "original" body at this address calls ITSELF once — modelling + * a self-recursive guest function, the case where the one-shot bypass must + * already have been consumed so the recursive call re-consults the override + * (what a guest-level wrap would observe). */ +static uint32_t s_recursive_original_at = 0; +static int s_recursion_budget = 0; + +void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t return_addr) +{ + (void)return_addr; + if (s_dispatch_depth > 8) return; /* runaway guard, not part of the contract */ + s_dispatch_depth++; + if (!g_psx_func_override_hook || + !g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu)) { + /* Nothing handled it: this is the original body running. */ + s_original_runs++; + cpu->gpr[2] = 0xAAAAu; + if (s_recursive_original_at && + (addr & 0x1FFFFFFFu) == (s_recursive_original_at & 0x1FFFFFFFu) && + s_recursion_budget > 0) { + s_recursion_budget--; + psx_dispatch_call(cpu, addr, return_addr); + } + } + s_dispatch_depth--; +} + +/* ---- override implementations ------------------------------------------- */ + +static int s_impl_calls, s_decline_calls, s_wrap_calls; + +static int impl_handles(CPUState *cpu) +{ + s_impl_calls++; + cpu->gpr[2] = 0x1234u; + return 1; +} + +static int impl_declines(CPUState *cpu) +{ + (void)cpu; + s_decline_calls++; + return 0; /* the decline-only probe idiom the TCP inventory relies on */ +} + +static int impl_wraps(CPUState *cpu) +{ + s_wrap_calls++; + func_override_call_original(cpu); + cpu->gpr[2] += 1u; /* prove we ran after the original */ + return 1; +} + +/* ---- helpers ------------------------------------------------------------ */ + +static void reset_all(void) +{ + /* No teardown for direct registrations exists by design (they are + * always-on), so each test group runs in a fresh process section by + * using distinct addresses instead of clearing. Counters do reset. */ + s_impl_calls = s_decline_calls = s_wrap_calls = 0; + s_original_runs = 0; + s_recursive_original_at = 0; + s_recursion_budget = 0; + memset(s_ram, 0, sizeof(s_ram)); +} + +static int consult(CPUState *cpu, uint32_t addr) +{ + if (!g_psx_func_override_hook) return 0; + return g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu); +} + +/* ---- tests -------------------------------------------------------------- */ + +static void test_install_is_null_until_registered(void) +{ + /* The whole "costs nothing when unused" claim rests on this: with nothing + * registered the hook pointer must stay NULL so dispatch matches a build + * without the tier. */ + func_override_install(); + CHECK(g_psx_func_override_hook == NULL, + "hook must stay NULL with nothing registered"); +} + +static void test_add_rejects_bad_input(void) +{ + CHECK(func_override_add("t.null_fn", 0x80001000u, NULL, 0) == FO_ERR_ARGS, + "NULL fn must be refused"); + /* phys 0 collides with the "not inside an override" sentinel. */ + CHECK(func_override_add("t.zero", 0x80000000u, impl_handles, 0) == FO_ERR_ARGS, + "address normalising to phys 0 must be refused"); + CHECK(func_override_add("t.zero2", 0x00000000u, impl_handles, 0) == FO_ERR_ARGS, + "raw address 0 must be refused"); + static const uint32_t g[1] = {0}; + CHECK(func_override_add_guarded("t.g0", 0x80001000u, impl_handles, g, 0, 0) + == FO_ERR_ARGS, + "guarded with n_words 0 must be refused"); + CHECK(func_override_add_guarded("t.gmax", 0x80001000u, impl_handles, g, + FO_MAX_GUARD_WORDS + 1, 0) == FO_ERR_ARGS, + "guard word count over the cap must be refused"); + CHECK(func_override_add_guarded("t.gnull", 0x80001000u, impl_handles, NULL, + 2, 0) == FO_ERR_ARGS, + "guarded with NULL words must be refused"); +} + +static void test_duplicate_address_refused(void) +{ + const uint32_t addr = 0x80002000u; + CHECK(func_override_add("t.first", addr, impl_handles, 0) == FO_OK, + "first registration must succeed"); + /* A silent last-wins here would be untraceable, which is why this is an + * error and not an overwrite. KSEG vs physical must not defeat it. */ + CHECK(func_override_add("t.second", addr, impl_declines, 0) == FO_ERR_DUPLICATE, + "same address twice must be refused"); + CHECK(func_override_add("t.second_phys", addr & 0x1FFFFFFFu, impl_declines, 0) + == FO_ERR_DUPLICATE, + "same address in physical form must also be refused"); +} + +static void test_handled_and_declined_both_count_as_consults(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t handled_at = 0x80003000u; + const uint32_t declined_at = 0x80003100u; + CHECK(func_override_add("t.handled", handled_at, impl_handles, 0) == FO_OK, "add handled"); + CHECK(func_override_add("t.declined", declined_at, impl_declines, 0) == FO_OK, "add declined"); + func_override_install(); + CHECK(g_psx_func_override_hook != NULL, "hook must install once populated"); + + CHECK(consult(&cpu, handled_at) == 1, "handled override must report handled"); + CHECK(cpu.gpr[2] == 0x1234u, "handled override must write $v0"); + CHECK(s_impl_calls == 1, "handled impl must run once"); + + CHECK(consult(&cpu, declined_at) == 0, "declining override must report not-handled"); + CHECK(s_decline_calls == 1, "declining impl must still run"); + + /* An unregistered address must not be claimed. */ + CHECK(consult(&cpu, 0x8000FF00u) == 0, "unregistered address must not be handled"); + + /* calls counts CONSULTS, so a decline-only probe proves reachability. */ + int found_handled = 0, found_declined = 0; + for (int i = 0; i < func_override_count(); i++) { + char id[FO_MAX_ID]; + uint32_t addr = 0; + uint64_t calls = 0, misses = 0; + int guarded = -1; + if (!func_override_get_ex(i, id, sizeof(id), &addr, &calls, &misses, + &guarded, NULL)) + continue; + if (addr == (handled_at & 0x1FFFFFFFu)) { + found_handled = 1; + CHECK(calls == 1, "handled entry calls==1, got %llu", + (unsigned long long)calls); + CHECK(misses == 0, "handled entry must have no guard misses"); + CHECK(guarded == 0, "handled entry is unguarded"); + CHECK(strcmp(id, "t.handled") == 0, "id round-trips, got '%s'", id); + } + if (addr == (declined_at & 0x1FFFFFFFu)) { + found_declined = 1; + CHECK(calls == 1, "DECLINED entry must still count a consult, got %llu", + (unsigned long long)calls); + } + } + CHECK(found_handled && found_declined, "both entries must be enumerable"); +} + +static void test_guard_declines_on_mismatch(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + /* Address inside the fake RAM window so the guard can be satisfied. */ + const uint32_t addr = 0x80000040u; /* phys 0x40 -> word index 16 */ + static const uint32_t prologue[2] = {0x27BDFFE0u, 0xAFB20018u}; + CHECK(func_override_add_guarded("t.guarded", addr, impl_handles, prologue, 2, 0) + == FO_OK, "guarded add must succeed"); + func_override_install(); + + /* Wrong code resident: must DECLINE and never run the impl. Corrupting + * here instead of declining is the failure the guard exists to prevent. */ + s_ram[16] = 0xFFFFFFFFu; + s_ram[17] = 0xFFFFFFFFu; + CHECK(consult(&cpu, addr) == 0, "guard mismatch must decline"); + CHECK(s_impl_calls == 0, "guard mismatch must NOT run the override body"); + + /* Right code resident: must fire. */ + s_ram[16] = prologue[0]; + s_ram[17] = prologue[1]; + CHECK(consult(&cpu, addr) == 1, "guard match must handle"); + CHECK(s_impl_calls == 1, "guard match must run the override body once"); + + /* Partial match is still a miss. */ + s_ram[17] = 0u; + CHECK(consult(&cpu, addr) == 0, "partial guard match must decline"); + CHECK(s_impl_calls == 1, "partial match must not run the body"); + + for (int i = 0; i < func_override_count(); i++) { + char id[FO_MAX_ID]; + uint32_t a = 0; + uint64_t calls = 0, misses = 0; + int guarded = 0; + if (!func_override_get_ex(i, id, sizeof(id), &a, &calls, &misses, + &guarded, NULL)) + continue; + if (a != (addr & 0x1FFFFFFFu)) continue; + CHECK(guarded == 2, "guard word count must be reported, got %d", guarded); + CHECK(misses == 2, "two guard misses expected, got %llu", + (unsigned long long)misses); + } +} + +static void test_call_original_is_one_shot(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t addr = 0x80004000u; + CHECK(func_override_add("t.wrap", addr, impl_wraps, FO_CREDIT_SELF) == FO_OK, "add wrap"); + func_override_install(); + + CHECK(consult(&cpu, addr) == 1, "wrap must handle"); + CHECK(s_wrap_calls == 1, "wrap body runs once"); + /* The bypass must let the ORIGINAL through exactly once — not zero times + * (wrap becomes replace) and not unboundedly (infinite re-entry). */ + CHECK(s_original_runs == 1, "original must run exactly once, ran %d", + s_original_runs); + CHECK(cpu.gpr[2] == 0xAAAAu + 1u, + "wrap must observe then adjust the original's $v0, got 0x%X", + cpu.gpr[2]); + + /* Bypass must not persist: a second call re-consults the override. */ + CHECK(consult(&cpu, addr) == 1, "second call must still be handled"); + CHECK(s_wrap_calls == 2, "wrap body runs again on the second call"); + CHECK(s_original_runs == 2, "original runs once more, total %d", + s_original_runs); +} + +static void test_bypass_is_consumed_so_recursion_reconsults(void) +{ + /* The header promises: "if the original recursively calls itself, the + * recursive calls consult the override again." That requires the one-shot + * bypass to be CONSUMED (cleared) at the moment it is honoured, not merely + * restored when call_original returns — restoring alone looks correct for + * a non-recursive wrap and hides the bug. */ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t addr = 0x80004100u; + CHECK(func_override_add("t.wrap_rec", addr, impl_wraps, FO_CREDIT_SELF) == FO_OK, + "add recursive wrap"); + func_override_install(); + + s_recursive_original_at = addr; + s_recursion_budget = 1; /* the original self-calls exactly once */ + + CHECK(consult(&cpu, addr) == 1, "recursive wrap must handle"); + + /* Outer override runs, calls the original; the original self-calls, and + * that inner call must land on the OVERRIDE again (wrap body twice), not + * on the original a second time. */ + CHECK(s_wrap_calls == 2, + "override must be re-consulted on the original's self-call, wrap ran %d", + s_wrap_calls); + CHECK(s_original_runs == 2, + "each override invocation runs the original once, original ran %d", + s_original_runs); +} + +static void test_call_original_outside_override_is_a_noop(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + func_override_call_original(&cpu); /* must not dispatch anything */ + CHECK(s_original_runs == 0, + "call_original outside an override must do nothing"); +} + +static void test_package_reset_drops_only_package_entries(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t direct_at = 0x80005000u; + const uint32_t pkg_at = 0x80005100u; + CHECK(func_override_add("t.direct", direct_at, impl_handles, 0) == FO_OK, + "direct add"); + CHECK(func_override_add_package("pkg.feature:a", pkg_at, impl_handles, NULL, 0, 0) + == FO_OK, "package add"); + func_override_install(); + + CHECK(consult(&cpu, direct_at) == 1, "direct override armed"); + CHECK(consult(&cpu, pkg_at) == 1, "package override armed"); + + const int before = func_override_count(); + const int dropped = func_override_reset_package_armed(); + CHECK(dropped >= 1, "reset must report dropping the package entry"); + CHECK(func_override_count() == before - dropped, + "count must shrink by exactly the dropped entries"); + + /* This is the netplay-divergence regression: after clear-mods the package + * override must be gone, while the game's own always-on reimplementation + * survives. */ + CHECK(consult(&cpu, pkg_at) == 0, + "package override must NOT fire after reset (netplay divergence)"); + CHECK(consult(&cpu, direct_at) == 1, + "direct override must survive reset"); + + /* Compaction must not corrupt surviving entries. */ + int seen_direct = 0; + for (int i = 0; i < func_override_count(); i++) { + char id[FO_MAX_ID]; + uint32_t a = 0; + uint64_t calls = 0; + if (!func_override_get_ex(i, id, sizeof(id), &a, &calls, NULL, NULL, + NULL)) + continue; + if (a == (direct_at & 0x1FFFFFFFu)) { + seen_direct = 1; + CHECK(strcmp(id, "t.direct") == 0, + "surviving id intact after compaction, got '%s'", id); + } + CHECK(a != (pkg_at & 0x1FFFFFFFu), + "dropped package entry must not remain enumerable"); + } + CHECK(seen_direct, "direct entry must remain enumerable"); + + /* The address is free again, so a later plan can re-arm it. */ + CHECK(func_override_add_package("pkg.feature:a", pkg_at, impl_handles, NULL, 0, 0) + == FO_OK, "re-arming after reset must succeed"); + CHECK(func_override_reset_package_armed() >= 1, "and drop again"); +} + +static void test_get_ex_bounded_id_copy(void) +{ + /* The id buffer size is the caller's to state; a short buffer must + * truncate with termination, never overrun. */ + int checked = 0; + for (int i = 0; i < func_override_count(); i++) { + char small[4]; + uint32_t a = 0; + memset(small, 0x7F, sizeof(small)); + if (!func_override_get_ex(i, small, sizeof(small), &a, NULL, NULL, NULL, + NULL)) + continue; + CHECK(small[sizeof(small) - 1] == '\0', + "short id buffer must be NUL-terminated"); + CHECK(strlen(small) <= sizeof(small) - 1, "short id must be truncated"); + checked = 1; + } + CHECK(checked, "expected at least one entry to enumerate"); + + CHECK(func_override_get_ex(-1, NULL, 0, NULL, NULL, NULL, NULL, NULL) == 0, + "negative index must fail"); + CHECK(func_override_get_ex(func_override_count(), NULL, 0, NULL, NULL, NULL, + NULL, NULL) == 0, + "out-of-range index must fail"); +} + +static void test_credit_policy(void) +{ + reset_all(); + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + + /* The credit is a required statement: FO_CREDIT_SELF or >= 0. Any other + * negative value is a typo, not a policy. */ + CHECK(func_override_add("t.badcredit", 0x80006000u, impl_handles, -2) + == FO_ERR_ARGS, + "credit below FO_CREDIT_SELF must be refused"); + + CHECK(func_override_add("t.credit40", 0x80006100u, impl_handles, 40) + == FO_OK, "fixed-credit add"); + CHECK(func_override_add("t.credit40d", 0x80006200u, impl_declines, 40) + == FO_OK, "fixed-credit decline probe add"); + CHECK(func_override_add("t.creditself", 0x80006300u, impl_handles, + FO_CREDIT_SELF) + == FO_OK, "self-credit add"); + CHECK(func_override_add("t.credit0", 0x80006400u, impl_handles, 0) + == FO_OK, "zero-credit add"); + func_override_install(); + + /* A handled call charges exactly the declared credit... */ + uint64_t before = psx_cycle_count; + CHECK(consult(&cpu, 0x80006100u) == 1, "fixed-credit override handles"); + CHECK(psx_cycle_count - before == 40u, + "handled call must charge the declared credit"); + + /* ...a DECLINE charges nothing (the original runs and self-charges + through the normal backends, so a tier-side charge would double). */ + before = psx_cycle_count; + CHECK(consult(&cpu, 0x80006200u) == 0, "decline probe declines"); + CHECK(psx_cycle_count == before, "a decline must charge nothing"); + + /* FO_CREDIT_SELF and 0 charge nothing from the tier. */ + before = psx_cycle_count; + CHECK(consult(&cpu, 0x80006300u) == 1, "self-credit override handles"); + CHECK(psx_cycle_count == before, "SELF must not be charged by the tier"); + before = psx_cycle_count; + CHECK(consult(&cpu, 0x80006400u) == 1, "zero-credit override handles"); + CHECK(psx_cycle_count == before, "credit 0 must charge nothing"); + + /* The declared policy is inspectable, not just documented. */ + int seen40 = 0, seenself = 0; + for (int i = 0; i < func_override_count(); i++) { + char id[FO_MAX_ID]; + uint32_t a = 0; + int32_t credit = -99; + if (!func_override_get_ex(i, id, sizeof(id), &a, NULL, NULL, NULL, + &credit)) + continue; + if (a == 0x00006100u) { seen40 = (credit == 40); } + if (a == 0x00006300u) { seenself = (credit == FO_CREDIT_SELF); } + } + CHECK(seen40, "get_ex must report the fixed credit"); + CHECK(seenself, "get_ex must report FO_CREDIT_SELF"); +} + +int main(void) +{ + test_install_is_null_until_registered(); + test_add_rejects_bad_input(); + test_duplicate_address_refused(); + test_handled_and_declined_both_count_as_consults(); + test_guard_declines_on_mismatch(); + test_call_original_is_one_shot(); + test_bypass_is_consumed_so_recursion_reconsults(); + test_call_original_outside_override_is_a_noop(); + test_package_reset_drops_only_package_entries(); + test_get_ex_bounded_id_copy(); + test_credit_policy(); + + if (g_fail) { + printf("func_override: FAILURES\n"); + return 1; + } + printf("func_override: all checks passed (%d entries registered)\n", + func_override_count()); + return 0; +} diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 107782018..00aa2a248 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -73,6 +73,23 @@ extern "C" void gpu_get_display_info(GpuDisplayInfo* out) { extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {} extern "C" int fntrace_is_game_started(void) { return 1; } +/* func_override.c doubles: linked in so mod_runtime's package arming/reset + * paths run for real. The tier's own behavior is pinned by + * test_func_override.c; here the doubles only need to satisfy the link. */ +extern "C" uint32_t psx_peek_word_untraced(uint32_t) { return 0; } +extern "C" void psx_dispatch_call(struct CPUState*, uint32_t, uint32_t) {} +extern "C" { +uint64_t psx_cycle_count = 0; +uint64_t psx_next_service_cycle = 0; +int psx_in_device_service = 0; +int g_event_step_conservative = 0; +int g_ls_replay_active = 0; +uint32_t g_psx_cyc_batch = 0; +uint32_t g_psx_cyc_batch_limit = 0; +void psx_devices_service_to_now(void) {} +void psx_advance_cycles_slow(uint32_t cycles) { psx_cycle_count += cycles; } +} + static void test_vblank_plugin(void) { plugin_calls++; }