mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
3cbe6c1cde
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>
344 lines
15 KiB
C++
344 lines
15 KiB
C++
// bios_address_model_test.cpp — proves the profile-driven BiosAddressModel is
|
|
// function-level equivalent to the hardcoded SCPH1001 helpers it replaced.
|
|
//
|
|
// The byte-identity check on the regenerated SCPH1001_*.c is artifact-level
|
|
// evidence; this test is the function-level version: every 4-byte address in
|
|
// the RAM and ROM ranges is swept through the model built from
|
|
// bios/SCPH1001.toml and compared against verbatim copies of the pre-refactor
|
|
// implementations (full_function_emitter.cpp / function_discovery.cpp as of
|
|
// the commit this refactor replaced). It also exercises the from_config
|
|
// invariants.
|
|
//
|
|
// Usage: bios_address_model_test [path/to/SCPH1001.toml]
|
|
// default: bios/SCPH1001.toml relative to the CWD (run from framework root)
|
|
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <stdexcept>
|
|
#include <utility>
|
|
#include <vector>
|
|
#include <string>
|
|
|
|
#include "../src/bios_address_model.h"
|
|
#include "../src/config_loader.h"
|
|
|
|
using PSXRecompV4::BiosAddressModel;
|
|
using PSXRecompV4::BiosAddrCopy;
|
|
using PSXRecompV4::BiosConfig;
|
|
using PSXRecompV4::BiosInstallSlot;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reference implementations: verbatim pre-refactor SCPH1001 hardcodes.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static uint32_t ref_normalize(uint32_t addr) {
|
|
uint32_t phys = addr & 0x1FFFFFFFu;
|
|
if (phys >= 0x1FC10000u && phys <= 0x1FC17FFFu) {
|
|
phys = phys - 0x1FC10000u + 0x00000500u;
|
|
}
|
|
if (phys >= 0x00030000u && phys <= 0x0005AFFFu) {
|
|
phys = phys - 0x00030000u + 0x1FC18000u;
|
|
}
|
|
return phys;
|
|
}
|
|
|
|
static uint32_t ref_ram_alias_to_rom(uint32_t addr) {
|
|
uint32_t phys = addr & 0x1FFFFFFFu;
|
|
if (phys >= 0x00000500u && phys < 0x00008500u) {
|
|
return 0xBFC10000u + (phys - 0x00000500u);
|
|
}
|
|
if (phys >= 0x00030000u && phys <= 0x0005AFFFu) {
|
|
return 0xBFC18000u + (phys - 0x00030000u);
|
|
}
|
|
return addr;
|
|
}
|
|
|
|
static uint32_t ref_runtime_pc(uint32_t rom_pc) {
|
|
uint32_t phys = rom_pc & 0x1FFFFFFFu;
|
|
if (phys >= 0x1FC18000u && phys <= 0x1FC42FFFu) {
|
|
return 0x80030000u + (phys - 0x1FC18000u);
|
|
}
|
|
if (phys >= 0x1FC10000u && phys <= 0x1FC17FFFu) {
|
|
return phys - 0x1FC10000u + 0x00000500u;
|
|
}
|
|
return rom_pc;
|
|
}
|
|
|
|
static uint32_t ref_relocate_j_target(uint32_t rom_addr, uint32_t target) {
|
|
uint32_t phys = rom_addr & 0x1FFFFFFFu;
|
|
if (phys >= 0x1FC10000u && phys <= 0x1FC17FFFu) {
|
|
uint32_t runtime_addr = phys - 0x1FC10000u + 0x00000500u;
|
|
return (runtime_addr & 0xF0000000u) | (target & 0x0FFFFFFFu);
|
|
}
|
|
if (phys >= 0x1FC18000u && phys <= 0x1FC42FFFu) {
|
|
uint32_t runtime_addr = 0x80030000u + (phys - 0x1FC18000u);
|
|
return (runtime_addr & 0xF0000000u) | (target & 0x0FFFFFFFu);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
static uint32_t ref_rom_to_ram_phys(uint32_t rom_addr) {
|
|
uint32_t phys = rom_addr & 0x1FFFFFFFu;
|
|
if (phys >= 0x1FC10000u && phys <= 0x1FC17FFFu) {
|
|
return phys - 0x1FC10000u + 0x00000500u;
|
|
}
|
|
return phys;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static int g_failures = 0;
|
|
|
|
static void expect_eq(uint32_t got, uint32_t want, const char* what, uint32_t at) {
|
|
if (got != want) {
|
|
std::fprintf(stderr, "FAIL %s(0x%08X): got 0x%08X want 0x%08X\n",
|
|
what, at, got, want);
|
|
if (++g_failures > 20) {
|
|
std::fprintf(stderr, "too many failures, aborting\n");
|
|
std::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
static void expect_throw(const char* what, void (*fn)(BiosConfig&), BiosConfig base) {
|
|
try {
|
|
fn(base);
|
|
BiosAddressModel::from_config(base);
|
|
std::fprintf(stderr, "FAIL invariant '%s': expected from_config to throw\n", what);
|
|
g_failures++;
|
|
} catch (const std::runtime_error&) {
|
|
/* expected */
|
|
}
|
|
}
|
|
|
|
static BiosConfig minimal_cfg() {
|
|
BiosConfig cfg{};
|
|
cfg.config_path = "test://synthetic";
|
|
cfg.load_address = 0xBFC00000u;
|
|
cfg.text_size = 0x80000u;
|
|
return cfg;
|
|
}
|
|
|
|
static BiosAddrCopy mk(const char* name, uint32_t rom_lo, uint32_t rom_hi,
|
|
uint32_t ram_lo, uint32_t runtime_base,
|
|
bool key_is_ram, bool bless) {
|
|
BiosAddrCopy c;
|
|
c.name = name; c.rom_lo = rom_lo; c.rom_hi = rom_hi; c.ram_lo = ram_lo;
|
|
c.runtime_base = runtime_base; c.key_is_ram = key_is_ram;
|
|
c.kernel_bless = bless;
|
|
return c;
|
|
}
|
|
|
|
int main(int argc, char** argv) {
|
|
const std::string profile = argc > 1 ? argv[1] : "bios/SCPH1001.toml";
|
|
|
|
BiosAddressModel model;
|
|
try {
|
|
const auto cfg = PSXRecompV4::load_bios_config(profile);
|
|
model = BiosAddressModel::from_config(cfg);
|
|
} catch (const std::exception& ex) {
|
|
std::fprintf(stderr, "cannot load %s: %s\n(run from the framework root)\n",
|
|
profile.c_str(), ex.what());
|
|
return 2;
|
|
}
|
|
|
|
// --- Sweep: RAM range + ROM range, every 4 bytes, all helpers ---------
|
|
auto sweep = [&](uint32_t lo, uint32_t hi) {
|
|
for (uint64_t a = lo; a < hi; a += 4) {
|
|
const uint32_t addr = static_cast<uint32_t>(a);
|
|
expect_eq(model.normalize(addr), ref_normalize(addr), "normalize", addr);
|
|
expect_eq(model.ram_alias_to_rom(addr), ref_ram_alias_to_rom(addr), "ram_alias_to_rom", addr);
|
|
expect_eq(model.runtime_pc(addr), ref_runtime_pc(addr), "runtime_pc", addr);
|
|
expect_eq(model.rom_to_ram_phys(addr), ref_rom_to_ram_phys(addr), "rom_to_ram_phys", addr);
|
|
// J-target relocation: source sweep with two representative
|
|
// 26-bit targets (one per runtime region shape).
|
|
expect_eq(model.relocate_j_target(addr, 0x0FC12344u),
|
|
ref_relocate_j_target(addr, 0x0FC12344u), "relocate_j(a)", addr);
|
|
expect_eq(model.relocate_j_target(addr, 0x00030010u),
|
|
ref_relocate_j_target(addr, 0x00030010u), "relocate_j(b)", addr);
|
|
}
|
|
};
|
|
sweep(0x00000000u, 0x00200000u); // RAM (2 MiB)
|
|
sweep(0x1FC00000u, 0x1FC80000u); // BIOS ROM phys
|
|
sweep(0xBFC00000u, 0xBFC80000u); // BIOS ROM KSEG1 (mask path)
|
|
sweep(0x80000000u, 0x80200000u); // RAM KSEG0 (mask path)
|
|
|
|
// Main RAM aliases cover the first 8 MiB of physical space. Dispatch
|
|
// normalization must agree with memory access normalization at every
|
|
// mirror, including the exact 0x80200000 boundary seen in indirect calls.
|
|
expect_eq(model.normalize(0x00200000u), 0x00000000u, "normalize.ram_mirror", 0x00200000u);
|
|
expect_eq(model.normalize(0x007FFFFCu), 0x001FFFFCu, "normalize.ram_mirror", 0x007FFFFCu);
|
|
expect_eq(model.normalize(0x80200000u), 0x00000000u, "normalize.ram_mirror", 0x80200000u);
|
|
expect_eq(model.normalize(0xA07FFFFCu), 0x001FFFFCu, "normalize.ram_mirror", 0xA07FFFFCu);
|
|
|
|
// --- kbless / rom-keyed accessors match the SCPH1001 constants --------
|
|
if (!model.has_kbless()) { std::fprintf(stderr, "FAIL: no kbless window\n"); g_failures++; }
|
|
expect_eq(model.kbless_ram_lo(), 0x500u, "kbless_ram_lo", 0);
|
|
expect_eq(model.kbless_ram_hi(), 0x8500u, "kbless_ram_hi", 0);
|
|
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) {
|
|
c.address_copies = {
|
|
mk("a", 0x1FC10000u, 0x1FC18000u, 0x500u, 0x500u, true, false),
|
|
mk("b", 0x1FC14000u, 0x1FC43000u, 0x30000u, 0x80030000u, false, false),
|
|
};
|
|
}, minimal_cfg());
|
|
expect_throw("two bless windows", [](BiosConfig& c) {
|
|
c.address_copies = {
|
|
mk("a", 0x1FC10000u, 0x1FC14000u, 0x500u, 0x500u, true, true),
|
|
mk("b", 0x1FC14000u, 0x1FC18000u, 0x9000u, 0x9000u, true, true),
|
|
};
|
|
}, minimal_cfg());
|
|
expect_throw("misaligned bounds", [](BiosConfig& c) {
|
|
c.address_copies = {
|
|
mk("a", 0x1FC10002u, 0x1FC18000u, 0x500u, 0x500u, true, false),
|
|
};
|
|
}, minimal_cfg());
|
|
expect_throw("ROM window outside image", [](BiosConfig& c) {
|
|
c.address_copies = {
|
|
mk("a", 0x1FB00000u, 0x1FB10000u, 0x500u, 0x500u, true, false),
|
|
};
|
|
}, minimal_cfg());
|
|
expect_throw("runtime_base phys mismatch", [](BiosConfig& c) {
|
|
c.address_copies = {
|
|
mk("a", 0x1FC10000u, 0x1FC18000u, 0x500u, 0x80000600u, true, false),
|
|
};
|
|
}, minimal_cfg());
|
|
expect_throw("bless on rom-keyed copy", [](BiosConfig& c) {
|
|
c.address_copies = {
|
|
mk("a", 0x1FC18000u, 0x1FC43000u, 0x30000u, 0x80030000u, false, true),
|
|
};
|
|
}, minimal_cfg());
|
|
expect_throw("overlapping RAM windows / double-fold", [](BiosConfig& c) {
|
|
// a folds ROM->RAM into [0x500,0x8500); b (rom-keyed) folds RAM
|
|
// [0x500,...) back out — sequential normalize() would double-fold
|
|
// a's output. Refused (as overlapping RAM destinations).
|
|
c.address_copies = {
|
|
mk("a", 0x1FC10000u, 0x1FC18000u, 0x500u, 0x500u, true, false),
|
|
mk("b", 0x1FC20000u, 0x1FC28000u, 0x500u, 0x500u, false, false),
|
|
};
|
|
}, 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;
|
|
expect_eq(empty.normalize(0xBFC12345u), 0x1FC12345u, "empty.normalize", 0xBFC12345u);
|
|
expect_eq(empty.runtime_pc(0xBFC12344u), 0xBFC12344u, "empty.runtime_pc", 0xBFC12344u);
|
|
if (empty.has_kbless() || empty.has_rom_keyed_ram_window()) {
|
|
std::fprintf(stderr, "FAIL: empty model claims windows\n");
|
|
g_failures++;
|
|
}
|
|
const std::string n = empty.emit_normalize_c();
|
|
if (n.find("0x1FC1") != std::string::npos) {
|
|
std::fprintf(stderr, "FAIL: empty model emits copy folds\n");
|
|
g_failures++;
|
|
}
|
|
}
|
|
|
|
if (g_failures == 0) {
|
|
std::printf("bios_address_model_test: all checks passed\n");
|
|
return 0;
|
|
}
|
|
std::fprintf(stderr, "bios_address_model_test: %d failure(s)\n", g_failures);
|
|
return 1;
|
|
}
|