diff --git a/bios/OpenBIOS.toml b/bios/OpenBIOS.toml index de6a781b..141da1a4 100644 --- a/bios/OpenBIOS.toml +++ b/bios/OpenBIOS.toml @@ -81,9 +81,35 @@ runtime_base = "0x80030000" dispatch_key = "rom" kernel_bless = false -# No [[recompiler.install_slots]]: none identified yet. Runtime-installed -# stubs fall to the dirty-RAM interpreter, which is correct (just slower) -# until a slot is identified and added. +# Kernel-RAM RANGES the guest's Psy-Q libapi patchers overwrite at runtime +# (CLAUDE.md rule 18; docs/dynamic_handler_install.md). See SCPH1001.toml for +# what `len` and `resume` mean. OpenBIOS declared none until 2026-09-11; the +# consequence was that its exception handler failed the kernel-bless memcmp +# from the first card access onward and interpreted for the life of the +# process — 1.22 billion instructions in one measured title. +# +# Measured on a live boot of Breath of Fire III (SLPS-00990, Psy-Q SDK 3.70) +# against the ROM source, 30 s in. The shapes are the same three the retail +# image shows, because the patchers are the game's, not the BIOS's. + +[[recompiler.install_slots]] +ram_addr = "0x000027B4" # exceptionHandler: register-save prologue +len = "0x30" # shifted two words + an inserted mfc0 v0,Cause +resume = "fallthrough" + +[[recompiler.install_slots]] +ram_addr = "0x0000281C" # stub into exceptionHandlerCardFastTrack +len = "0x10" # lui/addiu/jalr/nop over ROM zeros; installed +resume = "jalr" # on the first card access, not at boot + +[[recompiler.install_slots]] +ram_addr = "0x0000357C" # exceptionHandlerCardFastTrack: the kernel's +len = "0x10" # card handler replaced by jr into boot EXE +resume = "none" # 0x8017EAA0 (_patch_card payload) + +# NOT declared: the three pad-table words after readPadHighLevel (0x45FC, +# 0x460C, 0x461C). They are data, not instructions, so they unbless nothing +# and a range there would only widen what the verifier stops checking. # HLE anchors: only the boot-skip anchor is exported. deliver_event_ret is # deliberately ABSENT (0) until the B0 event services are validated against diff --git a/bios/SCPH1001.toml b/bios/SCPH1001.toml index 1f95ade7..5f5b39b8 100644 --- a/bios/SCPH1001.toml +++ b/bios/SCPH1001.toml @@ -16,6 +16,20 @@ load_address = "0xBFC00000" entry_pc = "0xBFC00000" text_size = "0x80000" +# Declared identity. The emitter refuses to generate from any other image +# (main_bios.cpp's declared-identity gate), which is the point: the seeds in +# this profile name v2.2 function starts, and generating from a SCPH-1001 +# **v2.0** dump (CRC32 55847D8C, 1994-09-22) produced three boot halts and a +# live A0 table with 90 of 161 ROM targets uncompiled. That looked like +# missing upstream seeds and was actually a revision mismatch. An empty pin +# let it through silently. +# +# This is v2.2, 1995-09-18: CRC32 37157331, 512 KiB. +[program.image] +sha256 = "71af94d1e47a68c11e8fdb9f8368040601514a42a5a399cda48c7d3bff1e99d3" +license = "proprietary" +redistributable = false + [recompiler] seeds = "recompiler/seeds/phase2_ghidra_seeds.json" out_dir = "generated" @@ -60,11 +74,56 @@ kernel_bless = false # class-B bug. Widening this window is a REAL BEHAVIOR CHANGE requiring its # own evidence-backed commit — it is not a parameterization cleanup. -# Kernel-RAM PCs the BIOS overwrites with 4-instruction dispatch stubs at +# Kernel-RAM RANGES the BIOS and the game's Psy-Q libapi patchers overwrite at # runtime (CLAUDE.md rule 18; docs/dynamic_handler_install.md). The emitter -# plants a dirty-check hook at these PCs so the installed stub executes. +# plants a compare-against-ROM hook at each range start so the guest's patched +# instructions execute; the runtime excludes the range from the kernel-bless +# memcmp and resumes native at the range end, so only the patched words +# interpret. +# +# `resume` says how the compiled body picks up again: "jalr" for the classic +# 4-word lui/addiu/jalr/nop stub (the call returns to ram_addr+0x10), +# "fallthrough" when the patched words ARE the function, "none" when the patch +# jumps out and never comes back. +# +# Measured on a live boot of Breath of Fire III (SLPS-00990, Psy-Q SDK 3.70) +# against the ROM source, 30 s in, identical at t+8 s — the patchers are +# _patch_gte / _patch_card / _patch_card2 / _patch_pad. Every SDK title runs +# these, so the ranges are a property of the BIOS revision and libapi release, +# not of one game. Re-measure per SDK version before trusting the numbers: +# the schema tolerates declaring the union. + +[[recompiler.install_slots]] +ram_addr = "0x00000C88" # exception handler: register-save prologue +len = "0x30" # shifted two words + an inserted mfc0 v0,Cause +resume = "fallthrough" # the patched words are the function itself + [[recompiler.install_slots]] ram_addr = "0x00000CF0" # SIO data-byte handler dispatch slot +len = "0x10" # lui/addiu/jalr/nop over ROM zeros +resume = "jalr" # the stub's call returns to 0x0D00 + +[[recompiler.install_slots]] +ram_addr = "0x00004964" # pad driver clear routine, 11 words NOP'd +len = "0x2C" # (RemovePatchPad / ChgclrPAD family) +resume = "fallthrough" + +[[recompiler.install_slots]] +ram_addr = "0x00004D98" # _patch_card2 payload: jalr into boot EXE +len = "0x14" # 0x8017EB6C; the ra landing word is NOP'd too, +resume = "fallthrough" # so the body resumes past it, not at +0x10 + +[[recompiler.install_slots]] +ram_addr = "0x00006444" # _patch_card payload: the kernel's card +len = "0x10" # handler replaced by jr into boot EXE +resume = "none" # 0x8017EAA0 — a jr never returns here + +# NOT declared: RAM 0x0500, one word of kernel_trampoline_0 cleared to zero +# (ROM `lui t2, 0`). It differs on every boot, but what writes it is not yet +# identified, and a range declared on a guess would tell the bless verifier to +# stop checking a word it should be checking. Rule 14: unknown is acceptable, +# guessing is not. Undeclared means that body keeps interpreting, exactly as +# it does today. # Per-image anchors couriered into the generated C (psx_bios_image) for the # runtime HLE tier. Values are FACTS about this image, not tunables: the diff --git a/docs/config_schema.md b/docs/config_schema.md index 5a268708..5c102fae 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -554,16 +554,45 @@ dispatch_key = "ram" # "ram": functions keyed by RAM address; # "rom": RAM alias folds back to ROM kernel_bless = true # runtime may byte-verify + run native -[[recompiler.install_slots]] # kernel-RAM PCs the BIOS patches at runtime -ram_addr = "0x00000CF0" +[[recompiler.install_slots]] # kernel-RAM RANGES patched at runtime +ram_addr = "0x00000CF0" # legacy form: len 0x10, resume "jalr" +[[recompiler.install_slots]] +ram_addr = "0x00000C88" +len = "0x30" # bytes; the patched range is [addr, addr+len) +resume = "fallthrough" # "jalr" (default) | "fallthrough" | "none" [recompiler.runtime_exports] # per-image HLE anchors (omit = unavailable) shell_entry_phys = "0x00030000" deliver_event_ret = "0x80001720" ``` +An `install_slots` entry declares kernel-RAM words the guest is EXPECTED to +overwrite at runtime — the BIOS's own install stubs and, far more often, the +Psy-Q libapi patchers every SDK title runs (`_patch_gte`, `_patch_card`, +`_patch_card2`, `_patch_pad`). The emitter plants a compare-against-ROM hook +at the range start, so a live patch dispatches into the interpreter and the +guest's own instructions execute; the runtime excludes the range from the +kernel-bless memcmp and resumes native at the range end. Without the +declaration the patched body fails verification forever and interprets for +the life of the process. + +`resume` says how the compiled body picks up again: + +| `resume` | Continuation PC | Use for | +|---|---|---| +| `"jalr"` (default) | `ram_addr + 0x10` | the classic 4-word `lui/addiu/jalr/nop` stub, whose call returns there | +| `"fallthrough"` | `ram_addr + len` | the patched words ARE the function (a prologue rewrite, a NOP'd routine) | +| `"none"` | — | the patch jumps out and never returns to this body (a `jr` into game text) | + +`ram_addr` and `len` must be 4-aligned, `len` non-zero, ranges must not +overlap, and every range must lie inside the `kernel_bless` window; the +loader refuses the profile otherwise and sorts the list for the runtime. +Finding the ranges for a new image is described in +[`dynamic_handler_install.md`](dynamic_handler_install.md). + Every `copy` entry is a claim that the boot copy is byte-verbatim; the -runtime kernel-bless memcmp enforces it. A BIOS with no copies (runs +runtime kernel-bless memcmp enforces it, minus the declared install-slot +ranges. A BIOS with no copies (runs entirely from ROM) is valid: normalization degenerates to the KSEG mask. Semantic invariants (disjoint windows, no fold-output/input intersection, single bless window) are enforced at load; violations refuse to build. diff --git a/docs/dynamic_handler_install.md b/docs/dynamic_handler_install.md index 17a5c279..5a3e371b 100644 --- a/docs/dynamic_handler_install.md +++ b/docs/dynamic_handler_install.md @@ -115,20 +115,85 @@ control returns to `psx_dispatch` for the next basic block. The combined effect is that BIOS-installed code at RAM 0xCF0 *runs*, correctly, on the same CPU register state as static-recompiled C. +## The patcher is usually the GAME, not the BIOS (measured 2026-09-11) + +The section above describes the BIOS patching itself. In practice the larger +source of patched kernel words is the **game**: every Psy-Q SDK title links +libapi, whose `_patch_gte` / `_patch_card` / `_patch_card2` / `_patch_pad` +routines rewrite kernel RAM during `ResetCallback`. Measured on Breath of Fire +III (SLPS-00990, SDK 3.70) against both BIOS images, 30 s into a boot, five +distinct shapes appear — and only the first fits the original one-PC, +`word != 0` model: + +| Shape | Example | Resume | +|---|---|---| +| 4-word `lui/addiu/jalr/nop` stub over ROM **zeros** | retail `0x0CF0`, OpenBIOS `0x281C` | `"jalr"` — the call returns to `+0x10` | +| 11-word prologue rewrite with an inserted `mfc0 v0, Cause` | retail `0x0C88`, OpenBIOS `0x27B4` | `"fallthrough"` — the patched words ARE the function | +| 11 words NOP'd (a driver routine removed) | retail `0x4964` | `"fallthrough"` | +| 5-word `jalr` into game text, `ra` landing word also NOP'd | retail `0x4D98` | `"fallthrough"`, not `+0x10` | +| 4-word `jr` into game text over **real ROM instructions** | retail `0x6444`, OpenBIOS `0x357C` | `"none"` — a `jr` never comes back | + +The three patched shapes that overwrite real ROM instructions are invisible to +a zero test, and two of them never return to `ram_addr + 0x10`. That is why a +slot is a **range** with an explicit `resume` (see +[`config_schema.md`](config_schema.md) → install slots), detected by comparing +every word against its ROM-baked value. + +## The slot and the bless verifier must compose + +Declaring a slot is necessary but not sufficient, and getting this wrong cost +one title its whole kernel budget. The mechanism has three parts that only +work together: + +1. **The emitted hook** (`full_function_emitter.cpp`) fires when any word in + the declared range differs from ROM, and dispatches to the range start so + the interpreter runs the guest's patched instructions. +2. **The bless verifier** (`memory.c`, via `kernel_patch_ranges.c`) compares a + kernel body in segments that SKIP the declared ranges. Without this the + body's whole-extent memcmp fails on the first patch and the body is + MISMATCH for the life of the process — so native code never reaches the + hook at all. The two halves were written separately and did not compose: + Breath of Fire III's card stub landed at exactly the address + `bios/SCPH1001.toml` already declared, and the exception handler still + interpreted **1.22 billion instructions**, 44.6 % of all interpreted work. +3. **The interpreter hand-back** (`dirty_ram_interp.c`) surfaces to static + dispatch when straight-line flow reaches a range's `hi`, which the emitter + registered as a continuation key. Kernel page 0 is permanently dirty (the + handler saves registers there on every exception), so without this the + interpreter would run the entire function after the patch rather than just + the patched words. + +`PSX_KERNEL_PATCH_RANGES=0` drops the declared ranges at runtime, restoring +the whole-body memcmp. One binary then measures both sides of the change +(companion to `PSX_KERNEL_BLESS=0`, which disables the mechanism outright). + ## Discoverability -Other install slots may exist. The tracker should detect them -mechanically by: +Finding the ranges for a new image is a diff, not a hunt: snapshot the live +kernel RAM copy, compare it word-for-word against the ROM source it was +copied from, and keep the differences that land inside a compiled body. -- Logging every write into `RAM 0x000..0xFFFF` (kernel code area). -- Reporting any PC dispatch that hits a written page. +- Read the kernel-bless window and ROM offset out of the linked image + (`psx_bios_image`), read live RAM over the debug server, and diff. +- Attribute each differing word to a body using the emitted body table; words + outside every body are data (event tables, TCB saves, pad tables) and + unbless nothing — do not declare them. +- Group contiguous differing words into one range, then read the LIVE words to + pick `resume`: a `jalr` returns to `+0x10`, a `jr` never returns, and + anything else falls through to the range end. +- Snapshot twice (early and late). Some patches land at boot and some on the + first card access, and a range that appears only late is still a range. +- Cross-check `kernel_bless` over the debug server: `mismatch` should fall to + the bodies you have NOT declared, and `patch_skips` should be non-zero. -Known/suspected slots to verify after the SIO data handler works: +A word whose writer you cannot identify is a word you should not declare: the +declaration tells the verifier to stop checking it. Leaving it undeclared +costs performance in one body; declaring it wrongly costs correctness +everywhere (CLAUDE.md Rule 14). -- VBlank pad poll fast-path (?) -- Memcard slot-1 detection -- CD-ROM ready handler -- Anything in `RAM 0xC80..0xFFF` (kernel hook region) +Slot addresses are per BIOS revision AND per libapi release. Re-measure on a +second title before assuming a profile's list is complete; the schema +tolerates declaring the union. ## NOT HLE diff --git a/recompiler/src/bios_address_model.cpp b/recompiler/src/bios_address_model.cpp index 9c3d9259..495f224f 100644 --- a/recompiler/src/bios_address_model.cpp +++ b/recompiler/src/bios_address_model.cpp @@ -2,6 +2,7 @@ #include "bios_address_model.h" +#include #include #include "fmt/format.h" @@ -91,10 +92,43 @@ BiosAddressModel BiosAddressModel::from_config(const BiosConfig& cfg) { m.rom_keyed_idx_ = static_cast(i); } - for (uint32_t slot : m.install_slots_) { - if (slot & 3u) - fail(fmt::format("install slot 0x{:08X} must be 4-aligned", slot)); + // Install slots are RANGES now: validate both ends, reject empty and + // overlapping declarations (the runtime's verifier walks them in order + // and the emitter plants one hook per start). + for (size_t i = 0; i < m.install_slots_.size(); ++i) { + const BiosInstallSlot& s = m.install_slots_[i]; + if (s.ram_addr & 3u) + fail(fmt::format("install slot 0x{:08X}: ram_addr must be 4-aligned", + s.ram_addr)); + if (s.len == 0u || (s.len & 3u)) + fail(fmt::format("install slot 0x{:08X}: len must be a non-zero " + "multiple of 4 (got 0x{:X})", s.ram_addr, s.len)); + if (s.hi() < s.ram_addr) + fail(fmt::format("install slot 0x{:08X}: len 0x{:X} wraps", + s.ram_addr, s.len)); + for (size_t j = 0; j < i; ++j) { + const BiosInstallSlot& p = m.install_slots_[j]; + if (s.ram_addr < p.hi() && p.ram_addr < s.hi()) + fail(fmt::format("install slots 0x{:08X} and 0x{:08X} overlap", + p.ram_addr, s.ram_addr)); + } + // A slot outside the kernel-bless window would be published to the + // runtime's verifier as a range it can never reach: a profile defect. + if (m.kbless_idx_ >= 0) { + uint32_t lo = m.copies_[m.kbless_idx_].ram_lo; + uint32_t hi = m.copies_[m.kbless_idx_].ram_hi(); + if (s.ram_addr < lo || s.hi() > hi) + fail(fmt::format("install slot [0x{:08X},0x{:08X}) lies outside " + "the kernel_bless window [0x{:08X},0x{:08X})", + s.ram_addr, s.hi(), lo, hi)); + } } + // Sorted by start: memory.c's segmented verifier relies on the emitted + // table being ordered, and the emitter couriers this vector verbatim. + std::sort(m.install_slots_.begin(), m.install_slots_.end(), + [](const BiosInstallSlot& a, const BiosInstallSlot& b) { + return a.ram_addr < b.ram_addr; + }); return m; } @@ -202,12 +236,23 @@ bool BiosAddressModel::in_kbless(uint32_t norm) const { } bool BiosAddressModel::is_install_slot(uint32_t ram_pc) const { - for (uint32_t slot : install_slots_) { - if (ram_pc == slot) return true; + return install_slot_at(ram_pc) != nullptr; +} + +bool BiosAddressModel::in_install_slot_range(uint32_t ram_pc) const { + for (const BiosInstallSlot& s : install_slots_) { + if (ram_pc >= s.ram_addr && ram_pc < s.hi()) return true; } return false; } +const BiosInstallSlot* BiosAddressModel::install_slot_at(uint32_t ram_pc) const { + for (const BiosInstallSlot& s : install_slots_) { + if (ram_pc == s.ram_addr) return &s; + } + return nullptr; +} + uint32_t BiosAddressModel::rom_keyed_ram_lo() const { return copies_[rom_keyed_idx_].ram_lo; } diff --git a/recompiler/src/bios_address_model.h b/recompiler/src/bios_address_model.h index b21bcb65..29d3dbc0 100644 --- a/recompiler/src/bios_address_model.h +++ b/recompiler/src/bios_address_model.h @@ -48,6 +48,41 @@ struct BiosAddrCopy { uint32_t ram_hi() const { return ram_lo + len(); } // exclusive }; +// One [[recompiler.install_slots]]: a RANGE of kernel-RAM words the guest is +// expected to overwrite at runtime (Psy-Q's _patch_gte / _patch_card / +// _patch_card2 / _patch_pad, and the BIOS's own install stubs). +// +// The original model was a single 4-word `lui/addiu/jalr/nop` stub written +// over ROM zeros, detected by testing the first word against 0. Only one of +// the five shapes observed in the wild fits that (docs/dynamic_handler_install.md): +// patchers also overwrite REAL ROM instructions with a `jr`, rewrite an +// 11-word prologue and insert an `mfc0 Cause`, and NOP out a driver routine. +// So a slot carries a length and is detected against the ROM-BAKED words, +// not against zero. +struct BiosInstallSlot { + // How the compiled body resumes native execution after the interpreter + // has run the patched words. + enum class Resume { + Jalr, // legacy 4-word stub: the jalr's ra = ram_addr + 0x10 + Fallthrough, // the patched words ARE the function: resume at ram_addr + len + None, // the patch never returns here (a `jr` out of the kernel) + }; + + uint32_t ram_addr = 0; // start of the patched range + uint32_t len = 0x10; // bytes; legacy default = 4 words + Resume resume = Resume::Jalr; // legacy default + + uint32_t hi() const { return ram_addr + len; } // exclusive + // RAM PC the compiled body resumes at, or 0 for Resume::None. + uint32_t resume_addr() const { + switch (resume) { + case Resume::Jalr: return ram_addr + 0x10u; + case Resume::Fallthrough: return hi(); + default: return 0u; + } + } +}; + class BiosAddressModel { public: // Empty model: pure KSEG-mask normalization, no copies, no install slots. @@ -99,7 +134,20 @@ public: // --- install slots ---------------------------------------------------- + // True when ram_pc is the START of a declared slot (where the emitter + // plants the hook). Interior words of a range are NOT slot starts. bool is_install_slot(uint32_t ram_pc) const; + // The slot starting at ram_pc, or null. + const BiosInstallSlot* install_slot_at(uint32_t ram_pc) const; + const std::vector& install_slots() const { return install_slots_; } + // Is this RAM PC inside ANY declared range? Such a PC must never become a + // native dispatch key: the compiled bytes there are not what executes — + // the guest's patch is. A key inside a range re-enters the compiled body, + // whose patch-range hook immediately sets pc back to the range start and + // returns, so the dispatch loop spins forever (observed 2026-09-11: a + // pre-existing jal-return continuation at OpenBIOS RAM 0x357C wedged the + // boot at frame 0). + bool in_install_slot_range(uint32_t ram_pc) const; // --- ROM-keyed RAM window (shell-style), for the game-overlap text ---- @@ -116,7 +164,7 @@ public: private: std::vector copies_; - std::vector install_slots_; + std::vector install_slots_; uint32_t rom_base_phys_ = 0x1FC00000u; uint32_t rom_size_ = 0x80000u; int kbless_idx_ = -1; // index into copies_ diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index db8975fe..c82ec734 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -1060,14 +1060,31 @@ BiosConfig load_bios_config(const fs::path& config_path_in) { } } - // [[recompiler.install_slots]] — kernel-RAM PCs the BIOS overwrites with - // dispatch stubs at runtime. - std::vector install_slots; + // [[recompiler.install_slots]] — kernel-RAM ranges the BIOS or the game's + // Psy-Q libapi patchers overwrite at runtime. `ram_addr` alone keeps the + // original meaning (a 4-word jalr stub); `len` and `resume` describe the + // other patch shapes (BiosInstallSlot, bios_address_model.h). + std::vector install_slots; if (recomp.contains("install_slots")) { for (const auto& v : recomp.at("install_slots").as_array()) { - install_slots.push_back(parse_hex( + BiosInstallSlot slot; + slot.ram_addr = parse_hex( toml::find(v, "ram_addr"), - "install_slots.ram_addr")); + "install_slots.ram_addr"); + if (v.contains("len")) + slot.len = parse_hex(toml::find(v, "len"), + "install_slots.len"); + if (v.contains("resume")) { + const std::string r = toml::find(v, "resume"); + if (r == "jalr") slot.resume = BiosInstallSlot::Resume::Jalr; + else if (r == "fallthrough") slot.resume = BiosInstallSlot::Resume::Fallthrough; + else if (r == "none") slot.resume = BiosInstallSlot::Resume::None; + else throw std::runtime_error(fmt::format( + "{}: install_slots 0x{:08X}: resume must be \"jalr\", " + "\"fallthrough\" or \"none\", got '{}'", + config_path.string(), slot.ram_addr, r)); + } + install_slots.push_back(slot); } } diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 9457279f..4dbde78b 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -695,9 +695,11 @@ struct BiosConfig { // copies, semantic validation and consumption in BiosAddressModel // (bios_address_model.h). Empty = the BIOS runs entirely from ROM. std::vector address_copies; - // [[recompiler.install_slots]]: kernel-RAM PCs the BIOS overwrites with - // dispatch stubs at runtime (see docs/dynamic_handler_install.md). - std::vector install_slots; + // [[recompiler.install_slots]]: kernel-RAM RANGES the BIOS (or the game's + // Psy-Q libapi patchers) overwrite at runtime. Layout, defaults and the + // resume shapes are in BiosInstallSlot (bios_address_model.h); see + // docs/dynamic_handler_install.md for how to find new ones. + std::vector install_slots; // [recompiler.runtime_exports]: per-image anchors the emitter couriers // into the generated C (psx_bios_image, runtime/include/psx_bios_image.h) diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index c15f32d4..d201ada2 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -1192,56 +1192,90 @@ bool FullFunctionEmitter::emit_function( continue; } - // Install-slot hook (CLAUDE.md Rule 18 / docs/dynamic_handler_install.md). - // The PS1 BIOS overwrites specific kernel-RAM addresses at runtime - // with dispatch stubs (e.g. RAM 0xCF0 for the SIO data-byte handler). - // The recompiler emitted NOPs from the ROM image; if we just run those - // statically, the installed stub never executes. At known install-slot - // PCs, emit a runtime check: if the page is dirty (RAM was written-to), - // dispatch into RAM to run the installed code. Otherwise fall through - // to the static NOP. + // Kernel patch-range hook (CLAUDE.md Rule 18 / docs/dynamic_handler_install.md). + // The BIOS and the game's Psy-Q libapi patchers overwrite specific + // kernel-RAM words at runtime: install stubs written over ROM zeros + // (RAM 0xCF0, the SIO data-byte handler), but also a `jr` planted over + // real ROM instructions, an 11-word prologue rewrite that inserts an + // `mfc0 Cause`, and a driver routine NOP'd out. The recompiler emitted + // the ROM bytes; if we run those statically the guest's patch never + // executes. // - // Install slots come from the profile's [[recompiler.install_slots]] - // (e.g. SCPH1001's SIO data-byte handler slot at RAM 0xCF0). See + // At the START of a declared range, emit a runtime check: if ANY word + // in the range differs from its ROM-baked value, the patch is live — + // dispatch into RAM so the interpreter runs the guest's own + // instructions (Rule 18: we execute the patch as written, we do not + // synthesise its effect). The rest of the body still runs native, + // because the range is also published to the runtime, which excludes + // it from the kernel-bless memcmp (memory.c) and hands straight-line + // flow back at the range end (dirty_ram_interp.c). + // + // Ranges come from the profile's [[recompiler.install_slots]]; see // docs/dynamic_handler_install.md for how to find new ones. uint32_t ram_pc = addr_model().rom_to_ram_phys(addr); - bool is_install_slot = addr_model().is_install_slot(ram_pc); - if (is_install_slot) { - /* The installed stub is 4 instructions: lui, addiu, jalr, nop. - * The jalr captures ra = stub_PC + 8 = install_slot + 0x10. When - * the stub's called function returns via jr ra, control flows back - * to install_slot + 0x10. Register that ROM address as both a - * block leader (so label_: is emitted) and a local - * continuation (so the entry-switch routes to it). Without this, - * external dispatch to the post-stub PC misses and falls into - * interpretation, which doesn't have COP0 and crashes the - * exception handler. */ - uint32_t post_stub_rom = addr + 0x10u; - if (addr_to_raw.count(post_stub_rom)) { - block_leaders.insert(post_stub_rom); - uint32_t post_stub_norm = normalize_address(post_stub_rom); - if (!all_function_entries_norm.count(post_stub_norm)) { - local_continuations.push_back({post_stub_rom, post_stub_norm, norm}); + const BiosInstallSlot* slot = addr_model().install_slot_at(ram_pc); + if (slot) { + /* Where the compiled body picks up again: + * - Jalr (legacy 4-word stub): the stub's jalr captures + * ra = stub_PC + 8 = ram_addr + 0x10, so the called function + * returns there. + * - Fallthrough: the patched words ARE the function; execution + * continues at the end of the range. + * - None: the patch is a `jr` out of the kernel and never comes + * back to this body. + * For the first two, register the resume PC as both a block leader + * (so label_: exists) and a local continuation (so the + * entry-switch and the emitted continuation wrapper route to it). + * Without the continuation key, external dispatch to the resume PC + * misses and falls into interpretation, which has no COP0 and + * crashes the exception handler. */ + uint32_t resume_ram = slot->resume_addr(); + if (resume_ram != 0u) { + uint32_t resume_rom = addr + (resume_ram - ram_pc); + if (addr_to_raw.count(resume_rom)) { + block_leaders.insert(resume_rom); + uint32_t resume_norm = normalize_address(resume_rom); + if (!all_function_entries_norm.count(resume_norm)) { + local_continuations.push_back({resume_rom, resume_norm, norm}); + } } } - /* Hook fires only when the FIRST instruction word at this PC - * differs from the ROM-baked value (= 0x00000000 NOP for these - * slots). A page-level dirty bit is too coarse: the kernel - * handler dirties page 0 on every exception by saving registers, - * which would unconditionally redirect into the interpreter. - * Word-level check is exact: the slot is "live" iff the BIOS - * install function has actually overwritten it. */ + /* Compare every word in the range against the ROM-baked value. + * The old test was `first word != 0`, which only fires for a stub + * written over ROM zeros — a `jr` planted over a real instruction + * and a prologue rewrite both leave non-zero ROM words and were + * never detected. A page-level dirty bit is far too coarse: the + * kernel handler dirties page 0 on every exception by saving + * registers, which would redirect unconditionally. Word-level + * compare is exact: the range is "live" iff the guest has actually + * changed it. */ + std::string cond; + uint32_t base_phys_local = base_addr & 0x1FFFFFFFu; + for (uint32_t off = 0; off < slot->len; off += 4u) { + uint32_t w_rom_addr = addr + off; + uint32_t w_phys = w_rom_addr & 0x1FFFFFFFu; + if (w_phys < base_phys_local || + w_phys + 4u > base_phys_local + rom.size()) { + throw std::runtime_error(fmt::format( + "install slot RAM 0x{:08X} len 0x{:X}: word +0x{:X} " + "lies outside the ROM image", ram_pc, slot->len, off)); + } + uint32_t rom_word = read_u32_le(rom, w_phys - base_phys_local); + if (!cond.empty()) cond += " ||\n "; + cond += fmt::format("cpu->read_word(0x{:08X}u) != 0x{:08X}u", + ram_pc + off, rom_word); + } out += fmt::format( - " /* 0x{:08X}: install-slot hook (RAM 0x{:08X}) — if the BIOS\n" - " * has overwritten this slot with an install stub, dispatch\n" - " * into the stub. Otherwise fall through to static NOP.\n" - " * After the stub's jalr returns, ra=RAM 0x{:08X} routes\n" - " * back here as a registered continuation target. */\n" - " if (cpu->read_word(0x{:08X}u) != 0u) {{\n" + " /* 0x{:08X}: kernel patch-range hook (RAM [0x{:08X},0x{:08X}),\n" + " * resume RAM 0x{:08X}). If the guest has overwritten any word\n" + " * of this range, dispatch into RAM so its own instructions run;\n" + " * otherwise fall through to the statically compiled ROM code. */\n" + " if ({}) {{\n" " psx_check_interrupts_at(cpu, 0x{:08X}u);\n" " cpu->pc = 0x{:08X}u; return;\n" " }}\n", - addr, ram_pc, ram_pc + 0x10u, ram_pc, ram_pc, ram_pc); + addr, ram_pc, ram_pc + slot->len, resume_ram, + cond, ram_pc, ram_pc); } // Non-terminator: emit normally — unless this load's successor reads @@ -1912,6 +1946,40 @@ void FullFunctionEmitter::emit_dispatch( g_sym_prefix, kb_count); } + // --- Kernel patch-range table (runtime bless + interp hand-back) --- + // The profile's [[recompiler.install_slots]], couriered verbatim to the + // runtime. Two consumers read it: memory.c verifies a kernel body in + // segments that SKIP these ranges (so a body with a live install stub is + // still blessed for native execution — before this, the whole-body memcmp + // failed forever on the first patch and the BIOS exception handler + // interpreted for the life of the process), and dirty_ram_interp.c hands + // straight-line flow back to static dispatch at a range's hi, which the + // per-function emitter registered as a continuation key. The guest's + // patched words themselves still execute on the interpreter, as written. + // + // Emitted sorted and non-overlapping (BiosAddressModel::from_config + // validates and sorts), because the runtime's verifier walks them in + // order. + { + std::string pr; + size_t pr_count = 0; + for (const BiosInstallSlot& sl : addr_model().install_slots()) { + pr += fmt::format(" {{ 0x{:08X}u, 0x{:08X}u }},\n", + sl.ram_addr, sl.hi()); + pr_count++; + } + out += fmt::format( + "static const PsxKernelPatchRange {}psx_bios_kernel_patch_ranges[{}] = {{\n", + g_sym_prefix, pr_count ? pr_count : 1); + // A zero-length array is not C; an image with no declared slots emits + // one inert entry and a count of 0, which every consumer range-tests + // against the count before indexing. + out += pr_count ? pr : " { 0u, 0u },\n"; + out += "};\n"; + out += fmt::format("enum {{ {}psx_bios_kernel_patch_range_count = {}u }};\n\n", + g_sym_prefix, pr_count); + } + // --- Image self-description (runtime/include/psx_bios_image.h) --- // The runtime reads these instead of hardcoding per-image constants; // emitted next to the code they describe, so they cannot disagree with @@ -2288,6 +2356,8 @@ void FullFunctionEmitter::emit_dispatch( " {0}psx_dispatch_call," "\n" " {0}psx_bios_kernel_bodies," "\n" " {0}psx_bios_kernel_body_count," "\n" + " {0}psx_bios_kernel_patch_ranges," "\n" + " {0}psx_bios_kernel_patch_range_count," "\n" "}};" "\n", g_sym_prefix); } @@ -2517,12 +2587,47 @@ EmitStats FullFunctionEmitter::emit( // --- Emit continuation wrappers --- // Deduplicate continuations by norm_addr (same label from multiple callers). std::map unique_continuations; + size_t slot_range_keys_dropped = 0; for (const auto& cl : all_continuations) { + // A PC inside a declared install-slot range must NOT become a native + // dispatch key. The compiled bytes there are not what executes — the + // guest's patch is — and the patch-range hook at the range start sets + // cpu->pc back to that same PC and returns, so dispatching into the + // body spins the dispatch loop forever. Dropping the key lets dispatch + // miss through to the dirty-RAM interpreter, which runs the guest's + // patched words: the intended path. + // + // Cost paid 2026-09-11: OpenBIOS RAM 0x357C already carried a + // jal-return continuation from before install slots existed, and + // declaring the card-handler range there wedged the boot at frame 0. + // Four more such keys sit inside retail's NOP'd pad-clear range. + if (addr_model().in_install_slot_range(cl.norm_addr)) { + slot_range_keys_dropped++; + continue; + } // Only add if not already a function entry (shouldn't be, but guard). if (!emitted_normalized.count(cl.norm_addr)) { unique_continuations[cl.norm_addr] = cl; } } + if (slot_range_keys_dropped) { + std::fprintf(stdout, + "psxrecomp-bios: dropped %zu continuation key(s) inside declared install-slot ranges\n", + slot_range_keys_dropped); + } + // A FUNCTION ENTRY inside a declared range is a different matter: dropping + // it would silently remove a callable function from dispatch. That is a + // profile error (the range is too wide, or it covers real code the guest + // does not patch), so refuse to emit rather than produce a build whose + // dispatch quietly misses. + for (uint32_t norm : emitted_normalized) { + if (addr_model().in_install_slot_range(norm)) { + throw std::runtime_error(fmt::format( + "install-slot range covers the function entry at RAM 0x{:08X}: " + "a declared range may only cover words the guest patches, never " + "a dispatch entry. Narrow the range in the BIOS profile.", norm)); + } + } if (!unique_continuations.empty()) { full_c += fmt::format("\n/* --- {} continuation wrappers for jal/jalr return routing --- */\n\n", diff --git a/recompiler/tests/bios_address_model_test.cpp b/recompiler/tests/bios_address_model_test.cpp index 3f8a3cc2..282be397 100644 --- a/recompiler/tests/bios_address_model_test.cpp +++ b/recompiler/tests/bios_address_model_test.cpp @@ -15,6 +15,8 @@ #include #include #include +#include +#include #include #include "../src/bios_address_model.h" @@ -23,6 +25,7 @@ using PSXRecompV4::BiosAddressModel; using PSXRecompV4::BiosAddrCopy; using PSXRecompV4::BiosConfig; +using PSXRecompV4::BiosInstallSlot; // --------------------------------------------------------------------------- // Reference implementations: verbatim pre-refactor SCPH1001 hardcodes. @@ -175,8 +178,66 @@ int main(int argc, char** argv) { expect_eq(model.kbless_rom_off(), 0x10000u, "kbless_rom_off", 0); expect_eq(model.rom_keyed_ram_lo(), 0x30000u, "rom_keyed_ram_lo", 0); expect_eq(model.rom_keyed_ram_hi_incl(), 0x5AFFFu, "rom_keyed_ram_hi_incl", 0); + + // --- install slots are RANGES ---------------------------------------- + // Only a range START is a slot: that is where the emitter plants the + // compare-against-ROM hook. Interior words are covered by the range, not + // by a second hook. if (!model.is_install_slot(0xCF0u)) { std::fprintf(stderr, "FAIL: 0xCF0 not an install slot\n"); g_failures++; } if (model.is_install_slot(0xCF4u)) { std::fprintf(stderr, "FAIL: 0xCF4 claims install slot\n"); g_failures++; } + // bios/SCPH1001.toml declares the measured Psy-Q patch sites. The legacy + // 0xCF0 form (ram_addr alone) must still mean a 4-word jalr stub. + { + const BiosInstallSlot* sio = model.install_slot_at(0xCF0u); + if (!sio) { std::fprintf(stderr, "FAIL: no slot at 0xCF0\n"); g_failures++; } + else { + expect_eq(sio->len, 0x10u, "slot.0xCF0.len", 0xCF0u); + expect_eq(sio->hi(), 0xD00u, "slot.0xCF0.hi", 0xCF0u); + expect_eq(sio->resume_addr(), 0xD00u, "slot.0xCF0.resume", 0xCF0u); + if (sio->resume != BiosInstallSlot::Resume::Jalr) { + std::fprintf(stderr, "FAIL: 0xCF0 is not a jalr slot\n"); g_failures++; + } + } + // The exception-handler prologue rewrite: a range, resumed by + // fallthrough at its end (the patched words ARE the function). + const BiosInstallSlot* exc = model.install_slot_at(0xC88u); + if (!exc) { std::fprintf(stderr, "FAIL: no slot at 0xC88\n"); g_failures++; } + else { + expect_eq(exc->len, 0x30u, "slot.0xC88.len", 0xC88u); + expect_eq(exc->resume_addr(), 0xCB8u, "slot.0xC88.resume", 0xC88u); + } + // The card-handler replacement is a `jr` out of the kernel: no resume. + const BiosInstallSlot* card = model.install_slot_at(0x6444u); + if (!card) { std::fprintf(stderr, "FAIL: no slot at 0x6444\n"); g_failures++; } + else expect_eq(card->resume_addr(), 0u, "slot.0x6444.resume", 0x6444u); + // Range CONTAINMENT, not just the start. The emitter drops any + // dispatch key inside a range, so this predicate decides which keys + // survive; getting it wrong wedges the boot (the 0x357C loop). + if (!model.in_install_slot_range(0xCF0u) || + !model.in_install_slot_range(0xCF4u) || + !model.in_install_slot_range(0xCFCu)) { + std::fprintf(stderr, "FAIL: 0xCF0 range does not contain its own words\n"); + g_failures++; + } + if (model.in_install_slot_range(0xD00u)) { + std::fprintf(stderr, "FAIL: range hi 0xD00 claimed as inside (must be exclusive)\n"); + g_failures++; + } + if (model.in_install_slot_range(0xCECu)) { + std::fprintf(stderr, "FAIL: 0xCEC below the range claimed as inside\n"); + g_failures++; + } + // Emitted order is the runtime verifier's contract. + uint32_t prev = 0; + for (const BiosInstallSlot& sl : model.install_slots()) { + if (sl.ram_addr < prev) { + std::fprintf(stderr, "FAIL: install slots not sorted at 0x%08X\n", + sl.ram_addr); + g_failures++; + } + prev = sl.ram_addr; + } + } // --- from_config invariants refuse bad profiles ----------------------- expect_throw("overlapping ROM windows", [](BiosConfig& c) { @@ -221,6 +282,42 @@ int main(int argc, char** argv) { }; }, minimal_cfg()); + // --- install-slot invariants refuse bad declarations ------------------ + { + auto with_slots = [](std::vector slots) { + BiosConfig c = minimal_cfg(); + c.address_copies = { + mk("k", 0x1FC10000u, 0x1FC18000u, 0x500u, 0x500u, true, true), + }; + c.install_slots = std::move(slots); + return c; + }; + auto slot = [](uint32_t at, uint32_t len) { + BiosInstallSlot s; s.ram_addr = at; s.len = len; + s.resume = BiosInstallSlot::Resume::Fallthrough; + return s; + }; + expect_throw("misaligned slot start", + [](BiosConfig&) {}, with_slots({slot(0xC8Au, 0x10u)})); + expect_throw("zero-length slot", + [](BiosConfig&) {}, with_slots({slot(0xC88u, 0u)})); + expect_throw("misaligned slot length", + [](BiosConfig&) {}, with_slots({slot(0xC88u, 0x12u)})); + expect_throw("overlapping slots", + [](BiosConfig&) {}, with_slots({slot(0xC88u, 0x30u), slot(0xCB4u, 0x10u)})); + expect_throw("slot outside the bless window", + [](BiosConfig&) {}, with_slots({slot(0x9000u, 0x10u)})); + expect_throw("slot range crossing the bless window end", + [](BiosConfig&) {}, with_slots({slot(0x84F0u, 0x30u)})); + // Declared out of order: accepted, and sorted for the runtime. + { + BiosConfig c = with_slots({slot(0x4964u, 0x2Cu), slot(0xC88u, 0x30u)}); + const auto m = BiosAddressModel::from_config(c); + expect_eq(m.install_slots()[0].ram_addr, 0xC88u, "slots.sorted[0]", 0); + expect_eq(m.install_slots()[1].ram_addr, 0x4964u, "slots.sorted[1]", 0); + } + } + // --- empty model degenerates to the KSEG mask ------------------------- { BiosAddressModel empty; diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 37464aea..46c476a3 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -303,6 +303,17 @@ if(BUILD_TESTING) target_include_directories(overlay_path_canon_test PRIVATE include) add_test(NAME overlay_path_canon_test COMMAND overlay_path_canon_test) + # The kernel-bless verifier must accept a body whose DECLARED patch range + # the guest has overwritten (Psy-Q's install stubs land inside compiled + # kernel bodies) and still reject one changed anywhere else. Before this, + # the whole-body memcmp failed forever on the first install and the BIOS + # exception handler interpreted for the life of the process. + add_executable(kernel_patch_ranges_test + tests/test_kernel_patch_ranges.c + src/kernel_patch_ranges.c) + target_include_directories(kernel_patch_ranges_test PRIVATE include) + add_test(NAME kernel_patch_ranges_test COMMAND kernel_patch_ranges_test) + # The player-facing "skip the BIOS" flag must resolve to the same boot # behaviour on retail SCPH-1001 and on the bundled OpenBIOS, whose # kernel-call HLE is (correctly) refused for want of a DeliverEvent anchor. diff --git a/runtime/include/dirty_ram_interp.h b/runtime/include/dirty_ram_interp.h index 1fb60442..24cfe449 100644 --- a/runtime/include/dirty_ram_interp.h +++ b/runtime/include/dirty_ram_interp.h @@ -139,8 +139,13 @@ int dirty_ram_is_dirty(uint32_t phys); * on writes into the body. Runtime-patched bodies (pad/SIO install stubs) * never verify and keep interpreting — faithful either way. */ int psx_kernel_bless_dispatchable(uint32_t phys); +/* True when a declared kernel patch range ENDS at this RAM address. The + * emitter registered that PC as a continuation key, so the interpreter hands + * straight-line flow back to static dispatch there and only the guest's + * patched words interpret (memory.c psx_bios_kernel_patch_ranges). */ +int psx_kernel_patch_range_ends_at(uint32_t phys); void psx_kernel_bless_note_range(uint32_t phys, uint32_t len); -void psx_kernel_bless_stats(uint64_t out[6]); +void psx_kernel_bless_stats(uint64_t out[8]); void psx_kernel_bless_resync_after_restore(void); /* Soft-return rematch / BIOS switch: drop latched SCPH↔OpenBIOS window + * CLEAN/MISMATCH so the next kbless_on() re-reads psx_bios_image. */ diff --git a/runtime/include/kernel_patch_ranges.h b/runtime/include/kernel_patch_ranges.h new file mode 100644 index 00000000..5d1a3b39 --- /dev/null +++ b/runtime/include/kernel_patch_ranges.h @@ -0,0 +1,58 @@ +/* kernel_patch_ranges.h — the pure half of the kernel patch-range mechanism. + * + * The guest legitimately overwrites words inside compiled BIOS kernel bodies + * at boot: the Psy-Q libapi patchers (_patch_gte / _patch_card / _patch_card2 + * / _patch_pad) and the BIOS's own install stubs. Those ranges are declared + * per image by [[recompiler.install_slots]] and emitted next to the kernel + * body table (psx_bios_image.h, PsxKernelPatchRange). + * + * These two functions are the whole decision, kept free of runtime globals so + * they can be tested directly (runtime/tests/test_kernel_patch_ranges.c). + * memory.c wraps them with its snapshotted window constants; dirty_ram_interp.c + * reads the second one through that wrapper. + * + * Rule 18: the patched words still execute, on the dirty-RAM interpreter, + * exactly as the guest wrote them. Nothing here synthesises behaviour — it + * only decides which backend runs which words. + */ +#ifndef PSX_KERNEL_PATCH_RANGES_H +#define PSX_KERNEL_PATCH_RANGES_H + +#include + +#include "psx_bios_image.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Compare live kernel RAM [lo, hi) against its ROM source, SKIPPING every + * declared patch range that intersects it. Returns 0 when every compared + * segment matches (memcmp's "equal" sense), non-zero otherwise. + * + * ram/rom point at the two images; `ram_lo` is the RAM address that + * rom[rom_off] corresponds to, so a byte at RAM address a is ram[a] and + * rom[rom_off + (a - ram_lo)]. `ranges` must be sorted by lo and + * non-overlapping (BiosAddressModel::from_config validates and sorts, so a + * malformed profile cannot reach here). `skips_out`, when non-null, is + * incremented once per skipped segment. + * + * A body with NO intersecting range degenerates to a single memcmp of the + * whole extent — the original behaviour, unchanged. */ +int psx_kernel_patch_cmp(const PsxKernelPatchRange *ranges, uint32_t n, + const uint8_t *ram, const uint8_t *rom, + uint32_t ram_lo, uint32_t rom_off, + uint32_t lo, uint32_t hi, + uint64_t *skips_out); + +/* Does a declared range END at this RAM address? The emitter registered each + * range's hi as a continuation key, so the interpreter hands straight-line + * flow back to static dispatch there and only the patched words interpret. */ +int psx_kernel_patch_ends_at(const PsxKernelPatchRange *ranges, uint32_t n, + uint32_t phys); + +#ifdef __cplusplus +} +#endif + +#endif /* PSX_KERNEL_PATCH_RANGES_H */ diff --git a/runtime/include/psx_bios_backend.h b/runtime/include/psx_bios_backend.h index c5e1842a..62398197 100644 --- a/runtime/include/psx_bios_backend.h +++ b/runtime/include/psx_bios_backend.h @@ -49,6 +49,13 @@ typedef struct PsxBiosBackend { * the generated dispatch itself, so it stays static there. */ const PsxKernelBody *kernel_bodies; uint32_t kernel_body_count; + + /* Kernel-RAM ranges the guest legitimately patches at runtime, from this + * image's [[recompiler.install_slots]] (psx_bios_image.h). The bless + * verifier skips them and the dirty-RAM interpreter resumes native at + * each range's hi. Null/0 for an image that declares no slots. */ + const PsxKernelPatchRange *kernel_patch_ranges; + uint32_t kernel_patch_range_count; } PsxBiosBackend; /* The backend in use. Null before psx_bios_select() runs; every forwarder and diff --git a/runtime/include/psx_bios_image.h b/runtime/include/psx_bios_image.h index e5301ef6..b2ae401d 100644 --- a/runtime/include/psx_bios_image.h +++ b/runtime/include/psx_bios_image.h @@ -28,12 +28,34 @@ typedef struct { uint32_t body_hi; /* exclusive */ } PsxKernelBody; +/* Kernel-RAM ranges the guest is EXPECTED to overwrite at runtime: the Psy-Q + * libapi patchers (_patch_gte / _patch_card / _patch_card2 / _patch_pad) and + * the BIOS's own install stubs rewrite words inside compiled kernel bodies at + * boot. Declared per image by [[recompiler.install_slots]] and emitted next to + * the body table. + * + * Two consumers, one table: + * - memory.c's bless verifier compares a body in segments that SKIP these + * ranges, so a body with a live patch inside it can still run native. + * - dirty_ram_interp.c surfaces back to static dispatch when straight-line + * interpretation reaches a range's `hi`, which the emitter registered as a + * continuation key. Only the patched words themselves interpret. + * + * This is not HLE: the guest's patched instructions still execute as written, + * on the dirty-RAM interpreter. Only the code AROUND them changes backend. */ +typedef struct { + uint32_t lo; /* RAM extent of the patched words [lo, hi) */ + uint32_t hi; /* exclusive; also the native resume key */ +} PsxKernelPatchRange; + /* Published from the ACTIVE backend at selection (psx_bios_backend.c), * hence a pointer rather than an array: a build links more than one * recompiled BIOS and each carries its own table. Indexing is unchanged * for consumers. */ extern const PsxKernelBody *psx_bios_kernel_bodies; extern uint32_t psx_bios_kernel_body_count; +extern const PsxKernelPatchRange *psx_bios_kernel_patch_ranges; +extern uint32_t psx_bios_kernel_patch_range_count; /* Native call-stub extents (A0/B0/C0). Layout shared with the generated * dispatch, which keeps its own static table. */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index dc2fc5d2..77b89e97 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -322,6 +322,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/psx_sdl_audio.cpp ${PSXRECOMP_ROOT}/runtime/src/psx_stick.c ${PSXRECOMP_ROOT}/runtime/src/memory.c + ${PSXRECOMP_ROOT}/runtime/src/kernel_patch_ranges.c ${PSXRECOMP_ROOT}/runtime/src/gpu.c ${PSXRECOMP_ROOT}/runtime/src/ws_ui_group.c ${PSXRECOMP_ROOT}/runtime/src/ws_aspect_cone_math.c diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 48ab208f..cb9dda37 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7735,20 +7735,24 @@ static void handle_ws_hud_mode(int id, const char *json) } /* Kernel-image bless state: {"cmd":"kernel_bless"} -> - * entries/clean/mismatch/native_hits/verifies/invalidations. + * entries/clean/mismatch/native_hits/verifies/invalidations, plus the + * declared kernel patch ranges and the segments they made the verifier + * skip (psx_bios_kernel_patch_ranges). * (memory.c psx_kernel_bless_*; PSX_KERNEL_BLESS=0 disables the mechanism.) */ static void handle_kernel_bless(int id, const char *json) { - extern void psx_kernel_bless_stats(uint64_t out[6]); + extern void psx_kernel_bless_stats(uint64_t out[8]); (void)json; - uint64_t s[6]; + uint64_t s[8]; psx_kernel_bless_stats(s); send_fmt("{\"id\":%d,\"ok\":true,\"entries\":%llu,\"clean\":%llu," "\"mismatch\":%llu,\"native_hits\":%llu,\"verifies\":%llu," - "\"invalidations\":%llu}", + "\"invalidations\":%llu,\"patch_ranges\":%llu," + "\"patch_skips\":%llu}", id, (unsigned long long)s[0], (unsigned long long)s[1], (unsigned long long)s[2], (unsigned long long)s[3], - (unsigned long long)s[4], (unsigned long long)s[5]); + (unsigned long long)s[4], (unsigned long long)s[5], + (unsigned long long)s[6], (unsigned long long)s[7]); } static void handle_ws_margin(int id, const char *json) diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index dbdfdb72..7ba7498f 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -3322,9 +3322,27 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ g_dirty_interp_chain_target = pc; OV_FPLOG_RET1(); } + uint32_t next_phys = pc & 0x1FFFFFFFu; + /* A declared kernel patch range ends here (Rule 18 + the profile's + * [[recompiler.install_slots]]). The emitted body dispatched us in at + * the range's lo to run the guest's patched words; the emitter + * registered this PC as a continuation key and the bless verifier + * skips the patched words, so the rest of the body runs native. + * Without this hand-back the kernel page stays dirty and straight- + * line flow would interpret the whole function — which is why + * declaring the slot alone never paid off. */ + if (next_phys < DIRTY_RAM_KERNEL_WINDOW_END && + psx_kernel_patch_range_ends_at(next_phys) && + psx_kernel_bless_dispatchable(next_phys)) { + cpu->pc = pc; + g_dirty_ram_native_handoffs++; + g_dirty_ram_blocks_run++; + if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; + g_dirty_interp_chain_target = pc; + OV_FPLOG_RET1(); + } /* Straight-line code that left the dirty page — hand back to * static dispatch by setting cpu->pc and returning. */ - uint32_t next_phys = pc & 0x1FFFFFFFu; uint32_t next_page = next_phys >> 12; if ((!current_page_dirty || next_page != current_page) && !dirty_ram_is_dirty(next_phys)) { diff --git a/runtime/src/kernel_patch_ranges.c b/runtime/src/kernel_patch_ranges.c new file mode 100644 index 00000000..4937f5d5 --- /dev/null +++ b/runtime/src/kernel_patch_ranges.c @@ -0,0 +1,40 @@ +/* kernel_patch_ranges.c — see kernel_patch_ranges.h for the contract. */ + +#include "kernel_patch_ranges.h" + +#include + +int psx_kernel_patch_cmp(const PsxKernelPatchRange *ranges, uint32_t n, + const uint8_t *ram, const uint8_t *rom, + uint32_t ram_lo, uint32_t rom_off, + uint32_t lo, uint32_t hi, + uint64_t *skips_out) +{ + uint32_t at = lo; + if (hi <= lo) return 0; + for (uint32_t i = 0; i < n && at < hi; i++) { + uint32_t plo = ranges[i].lo, phi = ranges[i].hi; + if (phi <= at) continue; /* range lies behind the cursor */ + if (plo >= hi) break; /* sorted: no later range intersects */ + if (plo > at && + memcmp(ram + at, rom + rom_off + (at - ram_lo), plo - at) != 0) + return 1; + /* Skip the patched words. A range may start below `lo` or end above + * `hi`; clamp so the cursor only ever advances inside the body. */ + at = phi > hi ? hi : phi; + if (skips_out) (*skips_out)++; + } + if (at < hi && + memcmp(ram + at, rom + rom_off + (at - ram_lo), hi - at) != 0) + return 1; + return 0; +} + +int psx_kernel_patch_ends_at(const PsxKernelPatchRange *ranges, uint32_t n, + uint32_t phys) +{ + for (uint32_t i = 0; i < n; i++) { + if (ranges[i].hi == phys) return 1; + } + return 0; +} diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 75f08675..f731d25b 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -193,9 +193,15 @@ static inline void dirty_ram_mark_page(uint32_t phys) { * through the extern each time. A BIOS with no bless window exports all * zeros: the span-0 range test then rejects every address. */ #include "psx_bios_image.h" +#include "kernel_patch_ranges.h" static uint32_t s_kb_lo = 0, s_kb_span = 0, s_kb_rom_off = 0; +static const PsxKernelPatchRange* s_kb_pr = 0; +static uint32_t s_kb_pr_n = 0; +static uint64_t kbless_patch_skips = 0; /* segments skipped, TCP counter */ + + #define KBLESS_UNKNOWN 0u #define KBLESS_CLEAN 1u #define KBLESS_MISMATCH 2u @@ -216,6 +222,19 @@ static int kbless_on(void) { s_kb_lo = psx_bios_image.kbless_ram_lo; s_kb_span = psx_bios_image.kbless_ram_hi - psx_bios_image.kbless_ram_lo; s_kb_rom_off = psx_bios_image.kbless_rom_off; + s_kb_pr = psx_bios_kernel_patch_ranges; + s_kb_pr_n = psx_bios_kernel_patch_ranges ? + psx_bios_kernel_patch_range_count : 0u; + /* PSX_KERNEL_PATCH_RANGES=0 drops the declared ranges, restoring the + * whole-body memcmp. Same purpose as PSX_KERNEL_BLESS=0 one level up: + * an A/B instrument. With the ranges dropped a patched body mismatches + * forever and its emitted hook is unreachable, which is exactly the + * behaviour before the ranges existed — so one binary measures both + * sides. */ + { + const char* pe = getenv("PSX_KERNEL_PATCH_RANGES"); + if (pe && pe[0] == '0') s_kb_pr_n = 0; + } if (s_kb_span == 0) kbless_enabled = 0; /* BIOS with no bless window */ /* The emitted constants must agree with each other and the ROM * array: a window whose ROM source exceeds the image is a build @@ -231,6 +250,29 @@ static int kbless_on(void) { return kbless_enabled; } +/* Declared patch ranges (psx_bios_kernel_patch_ranges, emitted from the + * profile's [[recompiler.install_slots]]): kernel-RAM words the guest is + * EXPECTED to overwrite at boot. They sit inside compiled bodies, so the + * whole-body memcmp below used to fail forever on the first install — the + * reason Breath of Fire III's BIOS exception handler interpreted 1.22 billion + * instructions with its card stub sitting in the profile's declared slot. + * The body is verified in segments that skip them instead: CLEAN now means + * every byte OUTSIDE every declared range still matches the ROM source, which + * is exactly the claim the native code depends on. The patched words + * themselves still execute on the dirty-RAM interpreter (Rule 18), entered + * through the emitted patch-range hook. + * + * Snapshotted on the same latch as the window constants. The decision itself + * lives in kernel_patch_ranges.c so it can be unit-tested without the + * runtime's globals. */ +/* Does a declared patch range END at this RAM address? The emitter registered + * that PC as a continuation key, so the dirty-RAM interpreter hands straight- + * line flow back to static dispatch there and only the patched words + * interpret (dirty_ram_interp.c). */ +int psx_kernel_patch_range_ends_at(uint32_t phys) { + return psx_kernel_patch_ends_at(s_kb_pr, s_kb_pr_n, phys); +} + /* Binary search the (key-sorted) body table. -1 if absent. */ static int kbless_find(uint32_t phys) { uint32_t lo = 0, hi = psx_bios_kernel_body_count; @@ -255,9 +297,10 @@ int psx_kernel_bless_dispatchable(uint32_t phys) { if (st == KBLESS_MISMATCH) return 0; const PsxKernelBody* b = &psx_bios_kernel_bodies[i]; kbless_verifies++; - if (memcmp(ram + b->body_lo, - bios_rom + s_kb_rom_off + (b->body_lo - s_kb_lo), - b->body_hi - b->body_lo) == 0) { + if (psx_kernel_patch_cmp(s_kb_pr, s_kb_pr_n, ram, bios_rom, + s_kb_lo, s_kb_rom_off, + b->body_lo, b->body_hi, + &kbless_patch_skips) == 0) { kbless_state[i] = KBLESS_CLEAN; kbless_native_hits++; return 1; @@ -308,7 +351,7 @@ void psx_kernel_bless_note_range(uint32_t phys, uint32_t len) { } } -void psx_kernel_bless_stats(uint64_t out[6]) { +void psx_kernel_bless_stats(uint64_t out[8]) { uint32_t n = psx_bios_kernel_body_count; uint32_t clean = 0, mism = 0; if (n > KBLESS_MAX_ENTRIES) n = KBLESS_MAX_ENTRIES; @@ -322,6 +365,11 @@ void psx_kernel_bless_stats(uint64_t out[6]) { out[3] = kbless_native_hits; out[4] = kbless_verifies; out[5] = kbless_invalidations; + /* Declared patch ranges, and how many segments the verifier skipped + * because of them: the proof that a body with a live install stub is + * being blessed rather than failing forever. */ + out[6] = s_kb_pr_n; + out[7] = kbless_patch_skips; } void psx_kernel_bless_resync_after_restore(void) { diff --git a/runtime/src/psx_bios_backend.c b/runtime/src/psx_bios_backend.c index c6eb0ba5..d00340be 100644 --- a/runtime/src/psx_bios_backend.c +++ b/runtime/src/psx_bios_backend.c @@ -31,6 +31,8 @@ PsxBiosImageInfo psx_bios_image; const PsxKernelBody *psx_bios_kernel_bodies = 0; uint32_t psx_bios_kernel_body_count = 0; +const PsxKernelPatchRange *psx_bios_kernel_patch_ranges = 0; +uint32_t psx_bios_kernel_patch_range_count = 0; /* Dispatch nesting depth. Shared dispatch state, not per-image: each generated * dispatch used to define its own copy, which is precisely why two of them @@ -84,6 +86,8 @@ int psx_bios_activate(const PsxBiosBackend *backend) psx_bios_image = *backend->image; psx_bios_kernel_bodies = backend->kernel_bodies; psx_bios_kernel_body_count = backend->kernel_body_count; + psx_bios_kernel_patch_ranges = backend->kernel_patch_ranges; + psx_bios_kernel_patch_range_count = backend->kernel_patch_range_count; /* Soft-return rematch can switch OPENBIOS ↔ SCPH without process exit. * Drop the prior image's call-HLE / boot-skip hook immediately so a * sticky SCPH DeliverEvent path cannot run against OpenBIOS ROM bytes diff --git a/runtime/src/psx_selfcheck.c b/runtime/src/psx_selfcheck.c index c6203333..a35f840c 100644 --- a/runtime/src/psx_selfcheck.c +++ b/runtime/src/psx_selfcheck.c @@ -753,7 +753,7 @@ static void sc_do_load(struct CPUState *cpu, const char *via) extern uint64_t g_guest_store_count; extern uint64_t g_irqctx_seq; extern uint32_t dirty_ram_text_diverged_pages(void); - uint64_t kb[6]; + uint64_t kb[8]; const int next_pass = s_warming ? 0 : (s_pass == 0 ? 1 : s_pass + 1); psx_kernel_bless_stats(kb); fprintf(stderr, diff --git a/runtime/tests/test_kernel_patch_ranges.c b/runtime/tests/test_kernel_patch_ranges.c new file mode 100644 index 00000000..e315e770 --- /dev/null +++ b/runtime/tests/test_kernel_patch_ranges.c @@ -0,0 +1,136 @@ +/* test_kernel_patch_ranges.c — the kernel-bless verifier must accept a body + * whose DECLARED patch range has been overwritten, and still reject one + * changed anywhere else. + * + * The regression this pins is the whole point of the mechanism. The verifier + * used to memcmp a body's entire extent against the ROM source, so the + * instant the guest's Psy-Q patcher wrote its install stub, the body + * mismatched and every dispatch to it ran on the interpreter for the life of + * the process. On Breath of Fire III that was the BIOS exception handler: + * 1.22 billion interpreted instructions, 44.6 % of all interpreted work, with + * the stub sitting at exactly the address the BIOS profile already declared. + * Declaring a slot changed nothing because the verifier had never heard of + * slots (`grep install_slot runtime/` returned nothing). + * + * Rule 18 is not relaxed here: a patched word is skipped by the VERIFIER, not + * by execution. The emitted hook still dispatches those words to the + * dirty-RAM interpreter, which runs the guest's own instructions. + */ +#include "kernel_patch_ranges.h" + +#include +#include + +static int s_fails = 0; + +static void expect(int cond, const char *what) { + if (!cond) { + fprintf(stderr, "FAIL: %s\n", what); + s_fails++; + } +} + +/* A synthetic 256-byte kernel body at RAM 0x500, sourced from ROM offset + * 0x10000 — the same shape as SCPH-1001's "Kernel Part 2" copy. */ +#define RAM_LO 0x500u +#define ROM_OFF 0x10000u +#define BODY_LO 0x500u +#define BODY_HI 0x600u + +static uint8_t ram[0x800]; +static uint8_t rom[0x11000]; + +static void reset(void) { + /* Byte-verbatim copy: the claim every kernel body depends on. */ + for (unsigned i = 0; i < sizeof ram; i++) ram[i] = (uint8_t)(i * 7u + 3u); + memset(rom, 0xAA, sizeof rom); + memcpy(rom + ROM_OFF, ram + RAM_LO, sizeof ram - RAM_LO); +} + +static int cmp(const PsxKernelPatchRange *r, uint32_t n, uint64_t *skips) { + return psx_kernel_patch_cmp(r, n, ram, rom, RAM_LO, ROM_OFF, + BODY_LO, BODY_HI, skips); +} + +int main(void) { + /* ---- 1. An untouched body verifies, with and without ranges -------- */ + const PsxKernelPatchRange one[] = { { 0x540u, 0x550u } }; + reset(); + expect(cmp(0, 0, 0) == 0, "clean body, no ranges declared"); + expect(cmp(one, 1, 0) == 0, "clean body, one range declared"); + + /* ---- 2. A word inside a DECLARED range does not unbless the body --- */ + reset(); + ram[0x544u] ^= 0xFFu; /* inside [0x540, 0x550) */ + expect(cmp(one, 1, 0) == 0, "patched word inside a declared range: CLEAN"); + expect(cmp(0, 0, 0) != 0, + "the same word with NO range declared: MISMATCH (the old behaviour)"); + + /* Every word of the range, and both its edges. */ + reset(); + for (uint32_t a = 0x540u; a < 0x550u; a++) ram[a] ^= 0xFFu; + expect(cmp(one, 1, 0) == 0, "whole declared range overwritten: CLEAN"); + + /* ---- 3. A word OUTSIDE the range still unblesses ------------------- */ + reset(); + ram[0x53Cu] ^= 0xFFu; /* one byte below the range */ + expect(cmp(one, 1, 0) != 0, "word just below the range: MISMATCH"); + reset(); + ram[0x550u] ^= 0xFFu; /* first byte above the range */ + expect(cmp(one, 1, 0) != 0, "word just above the range: MISMATCH"); + reset(); + ram[BODY_LO] ^= 0xFFu; + expect(cmp(one, 1, 0) != 0, "first word of the body: MISMATCH"); + reset(); + ram[BODY_HI - 1u] ^= 0xFFu; + expect(cmp(one, 1, 0) != 0, "last word of the body: MISMATCH"); + + /* ---- 4. Several ranges, and ranges outside this body --------------- */ + /* Sorted and non-overlapping, as the emitter guarantees. 0x700 lies past + * the body: it must neither be skipped into nor stop the walk early. */ + const PsxKernelPatchRange many[] = { + { 0x510u, 0x520u }, { 0x540u, 0x550u }, { 0x5F0u, 0x600u }, + { 0x700u, 0x710u }, + }; + reset(); + ram[0x514u] ^= 0xFFu; + ram[0x548u] ^= 0xFFu; + ram[0x5F8u] ^= 0xFFu; /* a range flush against body_hi */ + expect(cmp(many, 4, 0) == 0, "three declared ranges patched: CLEAN"); + ram[0x530u] ^= 0xFFu; /* in the gap between ranges */ + expect(cmp(many, 4, 0) != 0, "a gap between declared ranges still checked"); + + /* A range that starts before the body still clamps correctly. */ + const PsxKernelPatchRange spanning[] = { { 0x4F0u, 0x510u } }; + reset(); + ram[0x504u] ^= 0xFFu; + expect(cmp(spanning, 1, 0) == 0, "range starting below body_lo: CLEAN"); + + /* ---- 5. The skip counter reports real work ------------------------- */ + { + uint64_t skips = 0; + reset(); + (void)cmp(many, 4, &skips); + expect(skips == 3u, "skipped exactly the three intersecting ranges"); + skips = 0; + (void)cmp(0, 0, &skips); + expect(skips == 0u, "no ranges declared: nothing skipped"); + } + + /* ---- 6. The interpreter hand-back predicate ------------------------ */ + expect(psx_kernel_patch_ends_at(many, 4, 0x550u) == 1, + "a range end is a resume PC"); + expect(psx_kernel_patch_ends_at(many, 4, 0x540u) == 0, + "a range START is not a resume PC"); + expect(psx_kernel_patch_ends_at(many, 4, 0x548u) == 0, + "an interior word is not a resume PC"); + expect(psx_kernel_patch_ends_at(0, 0, 0x550u) == 0, + "no ranges: no resume PCs"); + + if (s_fails == 0) { + printf("test_kernel_patch_ranges: all checks passed\n"); + return 0; + } + fprintf(stderr, "test_kernel_patch_ranges: %d failure(s)\n", s_fails); + return 1; +}