func_override tier: replace or wrap any guest function with hand-written C - #174
func_override tier: replace or wrap any guest function with hand-written C#174rickguedes wants to merge 4 commits into
Conversation
An address-keyed replace-or-decline tier for hand-written native C:
- Core registry (func_override.{h,c}): register an implementation
against a guest address; return 1 = handled (guest resumes at $ra),
0 = decline (the original recompiled/interpreted code runs). The
dispatch hook stays NULL with nothing registered, so a build without
overrides dispatches byte-identically.
- Consulted in psx_dispatch_impl AFTER the BIOS tiers (an override can
never shadow a kernel service vector) and BEFORE every game backend
(emitted by full_function_emitter.cpp), and at the interpreter
JAL/JALR call-resolution tiers (the reserved CRES_OVERRIDE slot) so
locally-resolved calls cannot bypass a hook. One hook covers the
static EXE, runtime-loaded overlays, and dirty RAM alike.
- Ergonomics as API instead of copy-paste idioms:
func_override_guest_call (call guest code from native, authentic $ra),
func_override_call_original (one-shot bypass = real wrap semantics),
func_override_add_guarded (prologue-word residency guard for overlay
addresses other code may occupy).
- Mod-system integration: psx_mod_register_function_override queues
under a plugin id; overrides ARM only when the resolved package plan
selects that plugin -- same gating as vblank/activation callbacks.
- func_override TCP command: per-override id/addr/calls/guard_misses;
calls counts consults (declines included) so a decline-only probe
proves an address crosses a hooked path.
Cycle crediting is documented as deferred until the shared cycle core
exposes a public credit API; overrides should wrap (call_original) on
timing-sensitive paths. Rollback constraint documented: all mutable
override state belongs in guest RAM.
An id whose only registration is a queued function override was invisible to mod_plugin_registered, so a manifest gating an override-only plugin failed resolution with 'trusted plugin is unavailable'. Registration now marks the id in the plugin registry (same map as activation/vblank). MOD_PACKAGES.md documents the function-override plugin kind and the direct-registration (progressive-decomp) idiom.
…TCP command
The func_override TCP inventory doubles as the live list of what native
mods hooked, but a plugin that registers several overrides showed one
indistinguishable id per row (only the address told them apart). Adopt
the registry convention from the tier's original game-side deployment:
an optional ":label" suffix on the registered id ("pkg.feature:aim")
names the override in diagnostics, while gating and resolver
availability match only the part before the ':' — several overrides sit
under one manifest [[plugin]] entry and still read apart.
Also add the missing TCP_COMMANDS.md entry for `func_override` (the
command existed, the doc row didn't), spelling out the decline-probe
workflow: `calls` counts consults, declines included, so a decline-only
probe proves an address crosses a hooked path. Index regenerated.
…he guard Review fixes for the func_override tier (PR mstan#174), tracked as beads-eio.3.59. All three were silent failures: nothing at runtime reports them. 1. Coverage. Both interpreter call sites consulted the hook AFTER interp_enter_compiled, which reaches psx_dispatch_game_compiled -> entry->fn(cpu) directly and never re-enters psx_dispatch_impl. Any override on a statically-compiled function called from interpreted (dirty-RAM / overlay) code therefore ran the ORIGINAL, while registration, the armed count and the func_override inventory all looked healthy. The consult now precedes enter-compiled at both sites, matching the placement psx_dispatch_impl already uses and the invariant the header documents. Tail-transfer sites are deliberately NOT hooked; the header now states that overrides are call-site keyed so a calls==0 on a tail-called address is explained rather than mysterious. 2. Teardown. func_override.c had no removal path, so armed overrides outlived a cleared mod plan. Clearing s.plan is enough for activation/vblank callbacks because those only run while something iterates the plan, but an armed override lives in this module's own table with the hook installed. On the rematch path (main.cpp jumps to session_reboot, past mod_runtime_activate_plugins and func_override_install) a modded session entering netplay printed the vanilla-session banner and kept running its overrides, diverging from a peer without the mod. Adds func_override_add_package / func_override_reset_package_armed, wired into mod_runtime_clear_for_netplay, dropping package-armed entries while keeping direct always-on registrations. 3. Observability. The residency guard read through psx_read_word, which is traced: it feeds ls_read_hook under lockstep, RETURNS the replayed value under lockstep replay, and calls ds_note_read under DuckStation recording. Guard words were therefore injected into the divergence streams as phantom guest reads, and compared replayed data instead of resident bytes during replay. Adds psx_peek_word_untraced (same address decode, no tracing) and uses it for the guard. Also: reject an address normalising to phys 0 (it collides with the not-inside-an-override sentinel and would silently break call_original); give func_override_get/_get_ex an id buffer size instead of an implicit FO_MAX_ID requirement; clamp the accumulated length in handle_func_override so raising FO_MAX_ID or FO_MAX_OVERRIDES cannot underflow the remaining-size argument; move XRES_OVERRIDE to the end of its enum with a note that it is appended, NOT slotted into consult order (that enum is dense, unlike CRES, so inserting would renumber the codes above it and invalidate captured xprobe traces). Corrects two claims in the header: the interpreter placement, and the assertion that a cycle-credit parameter is blocked on a missing API. psx_advance_cycles is public in psx_cycles.h and bios_hle.c already charges per service with it. The zero-credit behaviour is unchanged here and now documented as an unresolved POLICY question, not a technical one. Adds runtime/tests/test_func_override.c (registered in runtime/CMakeLists.txt): install-is-NULL-when-empty, argument and duplicate refusal, consult counting for declines, guard decline without running the body, call_original one-shot semantics, bypass consumption proven via a self-recursive original, package reset keeping direct entries, and bounded id copy. Verified by mutation: no-op'ing the package reset, removing the phys 0 check, and leaving the bypass armed each fail the suite. Not verified here: no full runtime link and no game run, so fix 1 is confirmed by compile and reasoning only, not by observing an override fire from interpreted code. That is acceptance criterion 1 on beads-eio.3.59 and remains open.
|
Thanks for opening this as a draft — the design reads well, and the hook-placement notes made it much easier to review. I read the change against master, and I have pushed one commit onto this branch ( There is also one thing I would like to decide together, and I have deliberately not touched it. The thing I want to discuss: cycle accountingThe header says a cycle-credit parameter is deferred "until the shared cycle core exposes a public credit API." I do not think that blocker exists. So the mechanism is there. What is left is a policy choice, and that is what I would like your view on. My concern: with zero credit, a handled override collapses the replaced function to 0 guest cycles and shifts IRQ phase. For a player-toggled mod that is fine. For the always-on faithful-decomp use case — the one the header describes as "indistinguishable from the code they replace" — a rewrite that takes zero time is not indistinguishable. Interrupt-phase drift is the class of bug this project treats as foundation work, so I am wary of a tier that defaults the whole thing to free. My suggestion is that the credit becomes a required argument at registration, even when the honest answer is 0, so every call site has to state its timing intent instead of inheriting a silent default. Two questions for you, since you have actually shipped this and I have only read it:
In the commit I only corrected the header's factual claim about the missing API and relabelled this as an open policy question. The zero-credit behaviour itself is unchanged. What the commit fixes1. The override is skipped for interp → statically-compiled callees. At both interpreter call sites ( It is the same "registration is not coverage" shape you fixed for the overlay-native and pc-chain paths. The commit moves the consult above that block at both sites, which also restores the invariant the header states ("BEFORE every game code backend"). I also documented the tail-transfer sites ( 2. There is no teardown, so clear-mods cannot disarm an override.
On the rematch path this is reachable: The commit adds 3. The residency guard read through a traced accessor.
The commit adds Smaller items in the same commit: reject an address normalising to phys 0 (it collides with the "not inside an override" sentinel and would quietly break I moved TestsThe tier had no tests, and every failure mode here is silent, so I would rather pin the behaviour than rely on field reports. The commit adds I checked the test is not vacuous by breaking the code on purpose three times. Two failures were caught immediately. The third — leaving the one-shot bypass armed — passed, which was a hole in my test, not in your code: Two things I checked that are fineSo you do not have to worry about them:
I also want to withdraw something before you read it anywhere else: I initially thought the What is NOT verifiedI want to be straight about the limits of this review. Everything above came from reading the code, plus compiling the new test. Specifically:
If any of the three fixes conflicts with something you learned running this in Bushido Blade 2 or Azure Dreams, your field experience beats my reading — say so and we will change it. |
|
Follow-up: I said the coverage fix (defect 1) was reasoned about but not measured. It is measured now, in a real game. Reproduced on the PR head, confirmed fixed by the commit. SetupSingle-variable A/B, headless, Ape Escape (SCUS-94423):
The instrument was a temporary decline-only probe mod driven by an env var ( ResultProbes on the two statically-compiled targets, after ~290M / 231M interpreted instructions:
41,894 overridden calls that silently ran the original on the PR head. Controls, same run — six probes on interp→overlay targets, which reach the consult in both arms because
6/6 fire in both arms, one of them bit-identical at 6,992,463. So the base arm's probe machinery is fully functional and a zero on the compiled targets is a genuine miss, not a dead instrument. The base arm also executed more interpreted work than the fix arm (288M vs 231M instructions) and still recorded zero, so it is not a workload artefact. No regression. Both arms render the title screen correctly, zero abort/fatal/wedge markers in either log. Worth calling out specifically: the fix arm processed 41,894 declining consults on compiled functions and every one fell through to the compiled original correctly — so moving the consult above How to find these targets again
A negative result worth recordingTomba 2 cannot demonstrate this bug, and I want to save you the run. 2290 sampled JALR resolutions there produced zero EC paths — only Practical consequence for the tier: whether this gap bites a given title depends on whether its static text stays clean. A game that self-modifies its text is accidentally immune; one that does not is exposed. That is not a property anyone would guess from the registration API, which is part of why I wanted it measured rather than argued. Still not measuredBeing straight about what this run does and does not cover:
|
What this is
A recomp turns the game into C, but that C is a build artifact — regenerated from the player's own disc, never hand-edited. So there has been 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: progressive decompilation, native mods, QoL features.
This PR adds that mechanism: register hand-written C against a guest address and the dispatcher calls yours instead of the recompiled original.
Return
1and the guest resumes at$raexactly as if the original ranjr $ra. Return0and the original recompiled/interpreted code runs untouched — an override can decline case by case (conditional pre-hook: mutate state, return 0, original runs).No size limit. This is not a ROM patch; there is no slot to fit inside, no code cave. The replacement is native code in a native binary — five instructions or five thousand lines. That freedom is the point of recompiling rather than hacking bytes.
Where it hooks — and why that placement is load-bearing
psx_dispatch_implafter the BIOS tiers (an override can never shadow a kernel service vector) and before every game backend — one address-keyed hook covers the static EXE, runtime-loaded overlays, and dirty RAM alike. Overlay code that the recompiler classified observed-PC-only (never became a standalone C function) is overridable; content-hash shard revocation/rebuild cannot orphan an override because the hook sits upstream of the shard cache.CRES_OVERRIDEslot). This one is scar tissue: in the tier's first game-side deployment, the dirty-RAM interpreter resolved same-region calls itself (native shard or local pc-chain) and silently bypassed every registered hook — registration is not coverage. Both interpreter call sites consult the hook ahead of both resolution paths; handled calls resume via the same call contract as a native callee.API surface (
runtime/include/func_override.hhas the full contract)func_override_add(id, addr, fn)func_override_add_guarded(..., words, n)addrdo not match, the override declines instead of corrupting — for overlay/dirty-RAM addresses other code may occupyfunc_override_call_original(cpu)func_override_guest_call(cpu, target, site_ra)$raso callees, fntrace, and crash forensics see real valuespsx_mod_register_function_override(id, addr, fn, guard, n)func_overrideTCP commandid,addr,calls,guard_misses.callscounts consults (declines included) — a decline-only probe proves an address crosses a hooked path;calls: 0means never reachedSecurity model: overrides compile with the recomp —
.psxmodfiles carry no codeThis deliberately follows the existing format-5 trusted-plugin rule (MOD_PACKAGES.md, "Trusted adapters and archive safety"): a package archive supplies no native code. Overrides are C source compiled into the runtime binary by the game repo's own build (
EXTRAS_SOURCES/ the game's globbed mod sources). A.psxmodcan only select statically-registered plugin ids:psx_mod_register_function_override("pkg.feature", addr, fn, ...)queues the override at constructor time; it is armed only when the resolved package plan selects that plugin id — the same gating as activation/VBlank callbacks. Not selected = the address is never hooked, dispatch identical to a build without the mod..psxmodcannot inject code, hook new addresses, or reach beyond the implementations the game shipped — resolution fails before launch if an enabled plugin has no registered implementation.func_override_add(no package) is for the game repo's own faithful decomp reimplementations — always on, meant to be indistinguishable from the code they replace, not player-toggleable.How a game uses it
1. Progressive decompilation (direct, always-on — the faithful-reimplementation idiom):
2. A player-toggleable mod, gated by a
.psxmod— the C side registers under the plugin id; several overrides can share one manifest plugin using the optional:labelsuffix (gating matches the part before the:, the full id names each row in the TCP inventory):3. Wrapping instead of replacing (post-hook; also the right shape on timing-sensitive paths):
Rules an override must obey (documented in the header): pointers handed back to the guest must be guest addresses (the caller performs guest loads — a host pointer reads garbage; write into guest RAM and return that address). Keep all mutable state in guest RAM — rollback/rewind/netplay snapshot guest state only, host statics desync replays.
Verification workflow (from live deployments)
func_overrideover TCP is the ground truth: an override whosecallsstays 0 was never reached (wrong address, or that path never ran);guard_missescounts guard declines. Becausecallscounts consults, registering a decline-only probe (always return 0) is a safe way to prove an address crosses a hooked path before writing the real implementation.What was tested
cteston this branch's own fresh tree: 45/50 pass. The 5 failures are pre-existing on master and unrelated (mod_load_accelerationasserts agi.has_turbo_loadsline removed by the turbo_loads retirement;launcher_vulkan_optionis a cp1252 decode error in the test harness on Windows;aot_overlay_discovery/release_zip/vk_present_wait_stagefail identically without this change).tools/gen_tcp_commands.py --checkpasses; note the regenerated index also picks up rows for commands that had drifted on master since its last regen (the checked-in index says 292, the servers register 304 before this PR).Scope / downstream
full_function_emitter.cppemits the dispatch consult, so consuming games pick the tier up on their next BIOS + game regen. Until then their builds are unchanged. The runtime side is inert without registrations either way.call_original) instead of replace, until the shared cycle core exposes a public credit API. Happy to discuss whether acyclesparameter should land now instead.FO_MAX_OVERRIDES128,FO_MAX_GUARD_WORDS4) — sized for hand-written registries, trivially raisable if a game outgrows them.