mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
Guard patched BIOS ranges before control flow and cycle charging
This commit is contained in:
@@ -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 = ⦥ 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user