Guard patched BIOS ranges before control flow and cycle charging

This commit is contained in:
Matthew Stanley
2026-09-11 23:39:33 -07:00
parent ac0e40628a
commit fd84134c19
4 changed files with 197 additions and 87 deletions
+130 -85
View File
@@ -251,6 +251,17 @@ bool FullFunctionEmitter::emit_function(
// dominated by kernel poll-loop leaders 0x45FC/0x4614 inside func_00004498 —
// both had labels/gotos but no dispatch entries (only jal+8 conts were registered).
// Entry block is already a function dispatch key; skip it. Dedup later.
// Split before computing cycle totals and load-delay boundaries. Guards
// precede all replaced instructions, including interior branch targets.
for (const auto& [pc, word] : addr_to_raw) {
(void)word;
const uint32_t ram_pc = addr_model().rom_to_ram_phys(pc);
for (const auto& slot : addr_model().install_slots()) {
if ((ram_pc >= slot.ram_addr && ram_pc < slot.hi()) ||
ram_pc == slot.resume_addr())
block_leaders.insert(pc);
}
}
for (uint32_t leader : block_leaders) {
if (!addr_to_raw.count(leader)) continue;
uint32_t leader_norm = normalize_address(leader);
@@ -355,6 +366,24 @@ bool FullFunctionEmitter::emit_function(
}
}
// A guard cannot unwind a live branch/load delay using only a PC. Leave
// such a RAM-backed body on the existing fail-closed interpreter path;
// never widen the declared bytes to conceal an unsafe boundary.
for (const auto& [pc, word] : addr_to_raw) {
(void)word;
const uint32_t ram_pc = addr_model().rom_to_ram_phys(pc);
if (!addr_model().is_install_slot(ram_pc)) continue;
const auto previous = addr_to_raw.find(pc - 4u);
const bool prior_load = previous != addr_to_raw.end() &&
(previous->second >> 26) >= 0x20u && (previous->second >> 26) <= 0x26u;
if (pending_at.count(pc) || prior_load) {
if (out_interpreter_reason)
*out_interpreter_reason = fmt::format(
"patch range at RAM 0x{:08X} crosses a branch/load delay boundary", ram_pc);
return false;
}
}
// Detect delay slots that fall outside this function's address range.
// These will never be visited by the emit loop, so their PendingBranch
// would silently vanish. Collect them for immediate resolution.
@@ -754,6 +783,106 @@ bool FullFunctionEmitter::emit_function(
relocate_ra(rom_addr));
};
auto emit_patch_range_guard = [&](uint32_t addr) {
// 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.
//
// At every possible entry into 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);
const BiosInstallSlot* slot = nullptr;
for (const auto& range : addr_model().install_slots()) {
if (ram_pc >= range.ram_addr && ram_pc < range.hi()) { slot = &range; break; }
}
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});
}
}
}
/* 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 - (ram_pc - slot->ram_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",
slot->ram_addr + off, rom_word);
}
// A normal delay-slot execution belongs to the guarded branch.
// A direct branch TO this label has no pending branch flag.
auto pending = pending_at.find(addr);
if (pending != pending_at.end())
cond = fmt::format("!psx_delay_{:08X} && ({})",
pending->second.terminator_addr, cond);
out += fmt::format(
" /* 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"
"#ifdef PSX_ENABLE_BLOCK_CYCLES\n"
" psx_cyc_bb_defer_flush();\n"
"#endif\n"
" psx_check_interrupts_at(cpu, 0x{:08X}u);\n"
" cpu->pc = 0x{:08X}u; return;\n"
" }}\n",
addr, slot->ram_addr, slot->hi(), resume_ram,
cond, ram_pc, ram_pc);
}
};
for (auto it = addr_to_raw.begin(); it != addr_to_raw.end(); ++it) {
uint32_t addr = it->first;
uint32_t raw = it->second;
@@ -763,6 +892,7 @@ bool FullFunctionEmitter::emit_function(
// can service vblank and other hardware interrupts.
if (block_leaders.count(addr)) {
out += fmt::format("label_{:08X}:\n", addr);
emit_patch_range_guard(addr);
// Per-block-leader cycle observe (cyc_watch ruler). Sampled BEFORE
// this block's cycle advance, so it reports cumulative cycles for
// all PRIOR blocks — matching Beetle's before-instruction sample
@@ -1192,91 +1322,6 @@ bool FullFunctionEmitter::emit_function(
continue;
}
// 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.
//
// 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);
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});
}
}
}
/* 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}: 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 + slot->len, resume_ram,
cond, ram_pc, ram_pc);
}
// Non-terminator: emit normally — unless this load's successor reads
// its destination (MIPS-I load-delay pair): then defer the register
@@ -62,6 +62,7 @@ std::string read_file(const std::filesystem::path& path) {
struct RunResult {
EmitStats stats;
std::string dispatch;
std::string full;
};
RunResult run_case(const char* name, const std::vector<uint32_t>& words,
@@ -86,6 +87,7 @@ RunResult run_case(const char* name, const std::vector<uint32_t>& words,
rom, kBase, kBase + static_cast<uint32_t>(rom.size()) - 1u,
discovery, "synthetic", out_dir.string(), stem);
result.dispatch = read_file(out_dir / (stem + "_dispatch.c"));
result.full = read_file(out_dir / (stem + "_full.c"));
std::filesystem::remove_all(out_dir);
return result;
}
@@ -102,7 +104,10 @@ void expect_dispatch_key_absent(const RunResult& result, uint32_t normalized,
const char* message) {
char needle[32];
std::snprintf(needle, sizeof(needle), "{ 0x%08Xu,", normalized);
expect(result.dispatch.find(needle) == std::string::npos, message);
const auto begin = result.dispatch.find("static const DispatchEntry dispatch_table[");
const auto end = result.dispatch.find("};", begin);
expect(begin != std::string::npos &&
result.dispatch.substr(begin, end - begin).find(needle) == std::string::npos, message);
}
void delay_slot_load_falls_back() {
@@ -199,6 +204,62 @@ void complementary_lwl_lwr_stays_native() {
} // namespace
void patch_range_guards(BiosConfig config) {
PSXRecompV4::BiosInstallSlot slot;
slot.ram_addr = 0x508u;
slot.len = 8u;
slot.resume = PSXRecompV4::BiosInstallSlot::Resume::Fallthrough;
config.install_slots.push_back(slot);
config.address_copies[0].kernel_bless = true;
const auto model = BiosAddressModel::from_config(config);
FullFunctionEmitter::set_address_model(&model);
const auto result = run_case("patch-terminator", {
0x10000002u, // branch directly into the range interior at +12
0x00000000u,
0x03E00008u, // jr ra at the range START must also be guarded
0x00000000u,
0x03E00008u,
0x00000000u,
}, {function_at(kBase, kBase + 20u, {kBase, kBase + 8u, kBase + 12u})});
const auto start = result.full.find("label_BFC00008:");
const auto interior = result.full.find("label_BFC0000C:");
expect(start != std::string::npos &&
result.full.find("kernel patch-range hook", start) < interior,
"a terminator at range start is guarded");
expect(interior != std::string::npos &&
result.full.find("kernel patch-range hook", interior) != std::string::npos,
"a branch into a range interior cannot bypass the guard");
const auto guard = result.full.find("kernel patch-range hook", start);
expect(guard < result.full.find("psx_cyc_step", start),
"patched instructions are not charged before interpreter handoff");
expect_dispatch_key_absent(result, 0x508u, "range start not externally dispatched");
expect_dispatch_key_absent(result, 0x50Cu, "range interior not externally dispatched");
}
void patch_range_delay_boundaries(BiosConfig config) {
PSXRecompV4::BiosInstallSlot slot;
slot.ram_addr = 0x504u;
slot.len = 4u;
slot.resume = PSXRecompV4::BiosInstallSlot::Resume::Fallthrough;
config.install_slots.push_back(slot);
config.address_copies[0].kernel_bless = true;
const auto model = BiosAddressModel::from_config(config);
FullFunctionEmitter::set_address_model(&model);
for (uint32_t predecessor : {0x10000001u, 0x8D280000u}) {
const auto result = run_case("patch-delay-boundary", {
predecessor, // branch or CPU load immediately before the range
0x00000000u,
0x03E00008u,
0x00000000u,
}, {function_at(kBase, kBase + 12u, {kBase})});
expect(result.stats.functions_interpreted == 1,
"a patch guard cannot discard a pending branch/load delay");
expect(result.stats.functions_emitted == 0,
"an unsafe patch boundary is fail-closed");
expect_entry_absent(result, 0x500u, "unsafe patch owner not dispatched");
}
}
int main() {
BiosConfig config{};
config.config_path = "test://full-function-emitter";
@@ -221,6 +282,8 @@ int main() {
fragment_split_load_falls_back();
noncomplementary_lwl_falls_back();
complementary_lwl_lwr_stays_native();
patch_range_guards(config);
patch_range_delay_boundaries(config);
if (failures != 0) {
std::fprintf(stderr, "%d full-function emitter test(s) failed\n", failures);
+1 -1
View File
@@ -3331,7 +3331,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_
* 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 &&
if (!s_ld_pend_armed && next_phys < DIRTY_RAM_KERNEL_WINDOW_END &&
psx_kernel_patch_range_ends_at(next_phys) &&
psx_kernel_bless_dispatchable(next_phys)) {
cpu->pc = pc;
+2
View File
@@ -389,6 +389,8 @@ void psx_kernel_bless_reset_for_boot(void) {
s_kb_lo = 0;
s_kb_span = 0;
s_kb_rom_off = 0;
s_kb_pr = NULL;
s_kb_pr_n = 0;
memset(kbless_state, KBLESS_UNKNOWN, sizeof(kbless_state));
}