kernel-bless: publish install-slot ranges so patched BIOS bodies run native

The BIOS exception handler ran interpreted for the life of every process,
1.22 billion instructions and 44.6% of all interpreted work in Breath of
Fire III, even though it is compiled and even though the stub that
unblessed it sat at exactly the address bios/SCPH1001.toml already
declared as an install slot. Two mechanisms existed and did not compose:
the emitter hooked the declared slot PC, while the runtime's bless
verifier memcmp'd the whole body including that slot, so the body was
MISMATCH forever and native code never reached the hook.

Three parts, none of which pays off alone:

1. Publish the declared ranges to the runtime. The emitter emits a
   PsxKernelPatchRange table next to the kernel body table and routes it
   through the backend descriptor; the bless verifier compares a body in
   segments that skip them. CLEAN now means "every byte outside every
   declared range still matches ROM", which is the claim the native code
   actually depends on.

2. Generalise the slot model to what the patches are. The old model
   assumed a 4-word lui/addiu/jalr/nop stub written over ROM zeros and
   fired on `word != 0`. Measured against both images, only one of five
   observed shapes fits: patchers also plant a `jr` over real ROM
   instructions, rewrite an 11-word prologue and insert an `mfc0 Cause`,
   and NOP out a driver routine. A slot is now a range with a length and
   an explicit `resume` (jalr / fallthrough / none), detected by
   comparing every word against its ROM-baked value.

3. Hand back from the interpreter at the range end. Kernel page 0 is
   permanently dirty, because the handler saves registers there on every
   exception, so straight-line interpretation would run the whole
   function after the patch. The emitter registers the resume PC as a
   continuation key and dirty_ram_interp surfaces there.

A PC inside a declared range must never be a native dispatch key: the
compiled bytes there are not what executes. Seven pre-existing
jal-return continuations violated that and wedged the boot at frame 0 in
an infinite dispatch loop (OpenBIOS RAM 0x357C). The emitter now drops
continuation keys inside a range so dispatch misses through to the
interpreter, and refuses to emit if a range covers a function entry,
which would silently remove a callable function instead.

Both profiles declare their measured ranges, and SCPH1001.toml finally
pins [program.image] sha256 to v2.2 (CRC32 37157331) so a v2.0 dump
refuses to generate rather than producing phantom discovery gaps.

Measured on Breath of Fire III (SLPS-00990, Psy-Q SDK 3.70), headless,
matched frames, PSX_KERNEL_PATCH_RANGES=0 for the OFF side:

                      OpenBIOS            SCPH-1001 v2.2
  bless mismatch      21 -> 0             74 -> 0
  kernel bodies       -87.7%/frame        -97.8%/frame
  all interp insns    -42.6%/frame        -94.2%/frame

The residual kernel-body work is exactly declared-words x entries on
every range, so nothing leaks past the hook. The A0/B0/C0 vector
trampolines are unchanged (+0.2%, noise): the profile excludes them by
design and they are a separate job. Card read traces are byte-identical
across the A/B on both images, all 32 entries, which exercises the two
ranges that replace the kernel's card handler.

Rule 18 holds throughout: the guest's patched instructions still execute
as written on the dirty-RAM interpreter. Only the code around them
changes backend. Nothing is synthesised.

Tests: runtime/tests/test_kernel_patch_ranges.c (new, the verifier's
accept/reject behaviour and the hand-back predicate) and the install-slot
range invariants in recompiler/tests/bios_address_model_test.cpp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kevin
2026-09-11 16:30:32 -04:00
parent ed55299be3
commit 3cbe6c1cde
23 changed files with 931 additions and 84 deletions
+29 -3
View File
@@ -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
+61 -2
View File
@@ -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
+32 -3
View File
@@ -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.
+74 -9
View File
@@ -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
+50 -5
View File
@@ -2,6 +2,7 @@
#include "bios_address_model.h"
#include <algorithm>
#include <stdexcept>
#include "fmt/format.h"
@@ -91,10 +92,43 @@ BiosAddressModel BiosAddressModel::from_config(const BiosConfig& cfg) {
m.rom_keyed_idx_ = static_cast<int>(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;
}
+49 -1
View File
@@ -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<BiosInstallSlot>& 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<BiosAddrCopy> copies_;
std::vector<uint32_t> install_slots_;
std::vector<BiosInstallSlot> install_slots_;
uint32_t rom_base_phys_ = 0x1FC00000u;
uint32_t rom_size_ = 0x80000u;
int kbless_idx_ = -1; // index into copies_
+22 -5
View File
@@ -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<uint32_t> 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<BiosInstallSlot> 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<std::string>(v, "ram_addr"),
"install_slots.ram_addr"));
"install_slots.ram_addr");
if (v.contains("len"))
slot.len = parse_hex(toml::find<std::string>(v, "len"),
"install_slots.len");
if (v.contains("resume")) {
const std::string r = toml::find<std::string>(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);
}
}
+5 -3
View File
@@ -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<BiosAddrCopy> address_copies;
// [[recompiler.install_slots]]: kernel-RAM PCs the BIOS overwrites with
// dispatch stubs at runtime (see docs/dynamic_handler_install.md).
std::vector<uint32_t> 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<BiosInstallSlot> install_slots;
// [recompiler.runtime_exports]: per-image anchors the emitter couriers
// into the generated C (psx_bios_image, runtime/include/psx_bios_image.h)
+146 -41
View File
@@ -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_<post_stub>: 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_<resume>: 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<uint32_t, ContinuationLabel> 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",
@@ -15,6 +15,8 @@
#include <cstdint>
#include <cstdio>
#include <stdexcept>
#include <utility>
#include <vector>
#include <string>
#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<BiosInstallSlot> 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;
+11
View File
@@ -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.
+6 -1
View File
@@ -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. */
+58
View File
@@ -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 <stdint.h>
#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 */
+7
View File
@@ -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
+22
View File
@@ -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. */
+1
View File
@@ -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
+9 -5
View File
@@ -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)
+19 -1
View File
@@ -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)) {
+40
View File
@@ -0,0 +1,40 @@
/* kernel_patch_ranges.c — see kernel_patch_ranges.h for the contract. */
#include "kernel_patch_ranges.h"
#include <string.h>
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;
}
+52 -4
View File
@@ -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) {
+4
View File
@@ -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
+1 -1
View File
@@ -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,
+136
View File
@@ -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 <stdio.h>
#include <string.h>
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;
}