Merge pull request #348 from RetroPortingToolKit/integrate/wo6-bios343-346

Integrate WO-6 dispatch lookup and BIOS #343/#346 with game regressions
This commit is contained in:
Matt Stanley
2026-09-11 23:52:03 -07:00
committed by GitHub
44 changed files with 2535 additions and 216 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
+20
View File
@@ -213,6 +213,26 @@ on a fixed region -> next.
## 5. Status / Log (update every session)
- **2026-09-11 (WO-6/WO-7 and BIOS #343/#346 combined validation):**
Branch `integrate/wo6-bios343-346` preserves both contributor PR histories.
Resident game dispatch now uses an immutable physical-word index, retaining
live-byte validation, flat CPS returns and interrupt service. WO-7's sampled
owner activations include continuations, unlike function-entry counts; the
telemetry label now states that distinction and its collision lower bound.
BIOS setup checks configured, linkable backend pairs in the configured
framework location. Patch-range guards run before cycles and terminators,
cover interior entry labels, and fail closed at unsafe delay boundaries.
Fresh Tomba/MMX6 baseline and integration builds each passed 11,000-frame
OpenBIOS and SCPH-1001 LLE runs with screenshot and native-coverage checks.
The first baseline Tomba screenshot request and first integration MMX6 cold
startup exceeded harness timeouts; retries completed. Recompiler tests pass
(65 enabled); runtime tests pass (78 executed, one explicit skip, two disabled).
The disabled overlay-pair executable regression also passes when run directly.
Refresh against master `b4ea4c37` and all eight final live reruns pass, with
clean exits and completed card reads. See
[the integration review](WO6_BIOS_INTEGRATION_REVIEW.md) for evidence and
coverage limits. Tracked by `beads-eio.3.140`.
- **2026-08-31 (GPU DMA2 review correction — source gate passed):**
The first fork review found two valid timing defects in the DMA2 candidate. The
linked-list engine now reads and emits one live payload word at each
@@ -0,0 +1,113 @@
# WO-6 / WO-7 and BIOS #343 / #346 integration
Tracked by `beads-eio.3.140`. Integration branch:
`integrate/wo6-bios343-346`. Contributor heads `c2f9c3d5` (#343) and
`baca0a8a` (#346) are retained as ancestors, not copied or squashed.
Initial baseline: `736c002a`; refreshed baseline: `b4ea4c37`.
## Changes and conclusions
- WO-6: generate an unconditional immutable physical-word index for resident
dispatch tables spanning less than 2 MiB. Wide/ambiguous tables retain the
original binary search. Keep CPS returns, live-code validation, interrupt
checks and mod hooks. This accelerates address resolution; it does not turn
overlay calls into nested C calls or promise fewer dispatcher activations.
Tomba's 18,130-entry index is 265,706 bytes; MMX6's 18,858-entry index is
255,918 bytes. An isolated benchmark of the actual emitted lookup with
simplified table records and 4,096 repeated mixed-hit queries measured
roughly 54-61 ns/query for binary search and 1.2-1.6 ns/query for the index.
This is **not** a measurement of total dispatch overhead or game speedup.
- WO-7: the native hot-owner sampler counts owner activations, including CPS
continuation returns. Function-entry tracing counts invocations. Its
direct-mapped bucket resets on a collision, so it can undercount but does not
accumulate another owner's calls. Clarify the telemetry label and API docs;
no counter algorithm change or confirmed counter corruption. The supplied
brief's 3.2 microseconds divides total guest work by activations and therefore
does not isolate dispatch cost. Its branch at `0x80191BC0` with immediate
`FFFB` targets `0x80191BB0`, not `0x80191BB4`.
- #343: setup detection now requires a configured dispatch/full pair and its
matching backend descriptor, and uses the configured framework location.
Preserve expected-retail discovery for `OpenBIOS;SCPH1001` and alternate
retail stems. Add the CLI test file registered but missing from the PR.
- #346: guard patched BIOS ranges before instruction charges and terminators,
including entry through interior labels. Compare the whole declared range
and hand off at the requested RAM PC. Reject native emission when the range
begins across a live branch/load-delay boundary. Do not hand back from the
interpreter with a pending load. Clear range metadata on memory reset.
No patch ranges were widened and no guest patch effects were synthesized.
## Validation
Windows native Clang 22, Release recompiler / RelWithDebInfo runtimes; fresh
game and BIOS generation, isolated builds, overlay caches and memory cards.
Real title-owned mods were built/staged; no original game output or save was
modified. Live runs use LLE BIOS boot/calls, headless framebuffer readback,
debug diagnostics and native GCC/Clang overlay compilation. UI, Vulkan,
netplay, rewind and PGXP are disabled in this regression configuration.
Recompiler CTest: 65 enabled tests pass, including the executable generated
lookup check (aliases, holes, alignment, bounds, stale-code rejection and
interrupt/call behavior). Runtime CTest: 78 executed tests pass; one explicit
skip and two disabled. Recompiler has three disabled tests; its disabled
overlay-pair test was run directly and passed all five executable scenarios.
The #343 host probe was also run with real recomp-ui headers and native CC:
seven positive/negative cases pass. Upstream relocation/AOT tests: 4 + 24 pass.
The test-harness repairs include two Windows text/byte fixtures, a stale
assertion that demanded unsafe continuation-range clipping, a cache-tag
fixture now using the canonical serializer, and a Windows stack allowance for
the mod-runtime test's real 1 MiB disc-hashing scratch buffer. None changes
production guest behavior.
Both linked BIOS images were regenerated. OpenBIOS emits 651 functions with
zero tagged interpreter fallbacks and four existing unsupported-instruction
skips; retail emits 1,309 with its one existing load-delay fallback and one
existing unsupported-instruction skip.
Retail SHA-256:
`71af94d1e47a68c11e8fdb9f8368040601514a42a5a399cda48c7d3bff1e99d3`.
Initial matrix: all eight combinations of Tomba/MMX6, OpenBIOS/SCPH-1001 and
baseline/integration completed at least 11,000 frames. Screenshots show real
FMV and MMX6's gameplay demo; integration has native overlay/kernel execution,
zero remaining kernel-body mismatches, and completed 128-byte card reads.
The first baseline Tomba screenshot exceeded a five-second TCP deadline;
its rerun passed. The first integration MMX6 run spent most of its budget in
host startup and reached only 1,414 frames; a longer-budget retry passed.
The baseline also exhibited slow host startup. Do not present either failed
attempt as a successful run or interpret cold-start times as dispatch cost.
The rebuilt eight-way matrix against `b4ea4c37` passed, with clean process
exits and 32 completed 128-byte card transactions per run:
| Game | BIOS | Baseline frames | Integration frames | Kernel mismatches, baseline -> integration |
| --- | --- | ---: | ---: | ---: |
| Tomba | OpenBIOS | 11,005 | 11,015 | 22 -> 0 |
| Tomba | SCPH-1001 | 11,009 | 11,008 | 11 -> 0 |
| MMX6 | OpenBIOS | 11,002 | 11,013 | 7 -> 0 |
| MMX6 | SCPH-1001 | 11,014 | 11,015 | 11 -> 0 |
Each integration run has native overlays plus native kernel execution and
11-33 patch-skip events. Observed warm wall times were 82.5/86.2 seconds for
Tomba/OpenBIOS, 71.6/74.2 for Tomba/retail, 70.2/69.3 for MMX6/OpenBIOS and
70.6/70.8 for MMX6/retail (integration/baseline). These runs share a busy host
and are not controlled FPS benchmarks. Retail Tomba's frame-1,500 checkpoint
falls in a display-disabled boot interval on both builds; subsequent
checkpoints show the same FMV. Local raw reports/screenshots are under
`F:/Projects/psxrecomp/_validation-wo6-bios/{baseline,integration}/{tomba,mmx6}/results/`;
`final-openbios` and `final-SCPH1001` identify the refreshed runs.
An additional OpenBIOS/MMX6 run on each side captured the demo fade-out at
roughly 20-frame intervals through frame 11,150. Both show the same fade,
brief display-disable interval, and return to the 512-wide Capcom screen.
This explains the darker initial integration screenshot sampled 11 frames
later than baseline; it is not missing background rendering.
Integration PR: [#348](https://github.com/RetroPortingToolKit/psxrecomp/pull/348).
## Scope of confidence
This is regression evidence for the tested boot/FMVs/attract paths, generated
dispatch contracts, BIOS patch boundaries and card protocol, not a full
playthrough, hardware differential proof, audible-audio assessment, UI test,
save/load round trip, cross-platform certification or Parasite Eve FPS result.
Tomba 2 and alternate retail BIOS images were not part of this matrix.
+179 -20
View File
@@ -2,6 +2,8 @@
#include "psxrecomp_codegen_host.h"
#include "psx_bios_known_images.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
@@ -1135,15 +1137,68 @@ static int resolve_build_paths(void) {
return join_path(g_exe_path, sizeof(g_exe_path), g_build_dir, exe_name);
}
#ifndef PSX_SETUP_BIOS_STEMS
#define PSX_SETUP_BIOS_STEMS "OpenBIOS|SCPH1001"
#endif
#ifndef PSX_SETUP_FRAMEWORK_REL
#define PSX_SETUP_FRAMEWORK_REL "psxrecomp"
#endif
/* Match runtime.cmake: a requested pair with its backend descriptor. A stale
* pre-descriptor pair or unrelated game dispatch cannot complete BIOS setup. */
static int generated_bios_backend_linkable(const char* dir, const char* stem) {
char full[600], path[1200], descriptor[600], line[4096];
FILE* f;
int found = 0;
if ((size_t)snprintf(full, sizeof(full), "%s_full.c", stem) >= sizeof(full))
return 0;
if (!join_path(path, sizeof(path), dir, full) || !path_is_file(path))
return 0;
snprintf(full, sizeof(full), "%s_dispatch.c", stem);
if (!join_path(path, sizeof(path), dir, full))
return 0;
f = fopen(path, "r");
if (!f) return 0;
snprintf(descriptor, sizeof(descriptor), "%s_psx_bios_backend", stem);
while (fgets(line, sizeof(line), f)) {
if (strstr(line, descriptor)) { found = 1; break; }
}
fclose(f);
return found;
}
/* Does the configured framework hold a linkable requested BIOS backend?
*
* This used to probe two hardcoded names, OpenBIOS_dispatch.c and
* SCPH1001_dispatch.c. A port that pins a different image — via
* PSXRECOMP_BIOS_STEMS / game.toml recompiler.bios_config, as every wave-3
* kit does with SCPH5552 — emits its backend under that other stem, so the
* probe was permanently unsatisfied: Generate kept succeeding, the wizard
* kept reopening, and first-run setup could never complete. CMake supplies
* the requested stems; accept any linkable member, not an unrelated pair. */
static int generated_has_bios_backend(const char* dir) {
const char* next = PSX_SETUP_BIOS_STEMS;
while (*next) {
char stem[512];
const char* end = strchr(next, '|');
size_t len = end ? (size_t)(end - next) : strlen(next);
if (len && len < sizeof(stem)) {
memcpy(stem, next, len);
stem[len] = 0;
if (generated_bios_backend_linkable(dir, stem)) return 1;
}
if (!end) break;
next = end + 1;
}
return 0;
}
static int bios_backends_missing(void) {
char openbios[1100], scph[1100];
if (!join_path(openbios, sizeof(openbios), g_project_root,
"psxrecomp/generated/OpenBIOS_dispatch.c"))
char framework[1100], gen[1200];
if (!join_path(framework, sizeof(framework), g_project_root, PSX_SETUP_FRAMEWORK_REL) ||
!join_path(gen, sizeof(gen), framework, "generated"))
return 1;
if (!join_path(scph, sizeof(scph), g_project_root,
"psxrecomp/generated/SCPH1001_dispatch.c"))
return 1;
return !(path_is_file(openbios) || path_is_file(scph));
return !generated_has_bios_backend(gen);
}
int psxrecomp_codegen_host_sources_missing(
@@ -1209,7 +1264,7 @@ static void write_sidecar_near_exe(const char* near_exe, const char* name,
write_line_file(path, value ? value : "");
}
/* IEEE CRC-32 (zlib / Ethernet) — SCPH-1001 identity for setup discovery. */
/* IEEE CRC-32 (zlib / Ethernet) — retail BIOS identity for setup discovery. */
static uint32_t host_crc32(const unsigned char* data, size_t len) {
uint32_t crc = 0xFFFFFFFFu;
size_t i, j;
@@ -1221,17 +1276,23 @@ static uint32_t host_crc32(const unsigned char* data, size_t len) {
return ~crc;
}
/* Does this file match the retail image THIS build pins? A setup host has no
* linked backend to ask, so it consults psx_bios_known_images.h rather than
* assuming SCPH-1001 — which made every non-SCPH1001 kit reject a perfectly
* good dump. An unknown pinned stem adopts nothing and the player is asked. */
static int retail_bios_file_ok_c(const char* path) {
const PsxKnownBiosImage* want = psx_expected_bios();
FILE* f;
long size;
unsigned char* buf;
uint32_t crc;
if (!want) return 0;
if (!path || !path[0] || !path_is_file(path)) return 0;
f = fopen(path, "rb");
if (!f) return 0;
if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return 0; }
size = ftell(f);
if (size != 512 * 1024) { fclose(f); return 0; }
if (size != (long)want->size) { fclose(f); return 0; }
if (fseek(f, 0, SEEK_SET) != 0) { fclose(f); return 0; }
buf = (unsigned char*)malloc((size_t)size);
if (!buf) { fclose(f); return 0; }
@@ -1243,22 +1304,22 @@ static int retail_bios_file_ok_c(const char* path) {
fclose(f);
crc = host_crc32(buf, (size_t)size);
free(buf);
return crc == 0x37157331u; /* SCPH-1001 */
return crc == want->crc32;
}
/* Prefer a player-supplied SCPH1001 next to the project/exe for Generate.
* Missing → leave empty (OpenBIOS). Does not override an explicit OpenBIOS. */
/* Prefer a player-supplied dump of the pinned retail image next to the
* project/exe for Generate. Missing → leave empty (OpenBIOS). Does not
* override an explicit OpenBIOS. */
static int discover_retail_bios_c(char* out, size_t cap) {
static const char* names[] = {
"SCPH1001.BIN", "scph1001.bin", "SCPH-1001.BIN", "scph-1001.bin",
"SCPH1001.bin", "scph1001.BIN",
};
char names[8][32];
int nnames = psx_known_bios_filenames(psx_expected_bios(), names, 8);
static const char* subs[] = {
"bios", "", "system", "firmware", "psxrecomp/bios", "psxrecomp-v4/bios",
};
char roots[3][1100];
int nroots = 0;
int r, s, n;
if (nnames <= 0) { out[0] = 0; return 0; }
if (g_project_root[0]) {
snprintf(roots[nroots], sizeof(roots[0]), "%s", g_project_root);
++nroots;
@@ -1282,7 +1343,7 @@ static int discover_retail_bios_c(char* out, size_t cap) {
} else {
snprintf(dir, sizeof(dir), "%s", walk);
}
for (n = 0; n < (int)(sizeof(names) / sizeof(names[0])); ++n) {
for (n = 0; n < nnames; ++n) {
char cand[1300];
if (!join_path(cand, sizeof(cand), dir, names[n])) continue;
if (!retail_bios_file_ok_c(cand)) continue;
@@ -3667,6 +3728,7 @@ static const char* host_loop_breaker_note(void) {
char sidecar[1200], line[64], found[512], marker_abs[1200];
long long then, now;
const char* marker_rel;
const char* cause;
int game_missing, bios_missing;
if (!join_path(sidecar, sizeof(sidecar), g_project_root,
HOST_LAST_GENERATE_SIDECAR))
@@ -3687,16 +3749,30 @@ static const char* host_loop_breaker_note(void) {
if (!game_missing && !bios_missing)
return NULL;
list_generated_dispatch(found, sizeof(found));
/* Each branch has its own cause, so do not assert a single one. A missing
* game dispatch really does point at disagreeing boot-EXE names. Missing
* BIOS backends do not: they mean Generate never emitted them, normally
* because no retail BIOS was available to emit them from. Blaming
* boot-EXE names for that sent players after the wrong thing. */
if (game_missing)
cause = "please report this to the port maintainer: the project's "
"boot-EXE names disagree";
else
cause = "Generate produced the game code but no BIOS backend, which "
"normally means it had no retail BIOS to work from. Select "
"the PlayStation BIOS dump this port requires (named in the "
"README; it must be exactly 512 KB) in the launcher, then "
"run Generate again";
snprintf(g_loop_breaker_note, sizeof(g_loop_breaker_note),
"A Generate completed here recently, yet the launcher still "
"cannot find %s%s%s. generated/ contains: %s. Running Generate "
"again will very likely loop — please report this to the port "
"maintainer: the project's boot-EXE names disagree.",
"again will very likely loop — %s.",
game_missing ? marker_rel : "",
(game_missing && bios_missing) ? " and " : "",
bios_missing ? "the BIOS backends under psxrecomp/generated/"
: "",
found[0] ? found : "no *_dispatch.c at all");
found[0] ? found : "no *_dispatch.c at all",
cause);
fprintf(stderr, "psxrecomp-codegen: %s\n", g_loop_breaker_note);
return g_loop_breaker_note;
}
@@ -4200,9 +4276,92 @@ static int host_paths_same_file(const char* a, const char* b) {
#endif
}
/* --setup-selfcheck: report what the setup host believes about this tree, as
* JSON on stdout, then exit. 0 = generated sources complete, 2 = the wizard
* would reopen, 1 = could not tell (no project root).
*
* This exists because the decision layer -- "are the generated sources
* present?" -- had no headless entry point. Disc selection, BIOS selection and
* generation were already scriptable via psxrecomp_cli.py; the verdict on
* whether setup is DONE was reachable only by clicking through the wizard, so
* nothing in CI could assert it. A stem mismatch there shipped a first-run
* loop on 26 titles before anyone noticed.
*
* Deliberately ahead of the PSX_HAS_GAME_DISPATCH early return below, so a
* product build answers too. Every title already calls this function with
* argc/argv, so no per-title change is needed to gain the flag. */
static void host_json_str(const char* s) {
putchar('"');
for (; s && *s; ++s) {
if (*s == '\\' || *s == '"')
putchar('\\');
putchar(*s);
}
putchar('"');
}
static void host_selfcheck_or_return(const PsxrecompCodegenHostConfig* cfg,
int argc, char** argv) {
const PsxKnownBiosImage* want;
const char* marker_rel;
char marker_abs[1200];
int i, missing, game_ok, bios_ok;
for (i = 1; i < argc; ++i)
if (argv[i] && strcmp(argv[i], "--setup-selfcheck") == 0)
break;
if (i >= argc)
return;
if (!cfg || !cfg->cmake_target || !cfg->exe_basename) {
printf("{\"error\": \"no codegen host config linked\"}\n");
exit(1);
}
/* Sets g_cfg and g_project_root as a side effect. */
missing = psxrecomp_codegen_host_sources_missing(cfg);
if (!g_project_root[0]) {
printf("{\"error\": \"project root not found\"}\n");
exit(1);
}
marker_rel = cfg_or(cfg->gen_marker_relpath,
"generated/SLUS_011.89_dispatch.c");
game_ok = join_path(marker_abs, sizeof(marker_abs), g_project_root,
marker_rel) && path_is_file(marker_abs);
bios_ok = !bios_backends_missing();
want = psx_expected_bios();
printf("{\n");
printf(" \"display_name\": ");
host_json_str(cfg_or(cfg->display_name, "Game"));
printf(",\n \"project_root\": ");
host_json_str(g_project_root);
printf(",\n \"expected_bios_stem\": ");
host_json_str(PSX_EXPECTED_BIOS_STEM);
printf(",\n \"expected_bios_id\": ");
host_json_str(want ? want->id : "");
printf(",\n \"expected_bios_crc32\": ");
if (want) {
char crcbuf[16];
snprintf(crcbuf, sizeof(crcbuf), "0x%08X", want->crc32);
host_json_str(crcbuf);
} else {
printf("null");
}
printf(",\n \"game_dispatch\": ");
host_json_str(marker_rel);
printf(",\n \"game_dispatch_present\": %s", game_ok ? "true" : "false");
printf(",\n \"bios_backends_present\": %s", bios_ok ? "true" : "false");
printf(",\n \"sources_missing\": %s", missing ? "true" : "false");
printf("\n}\n");
fflush(stdout);
exit(missing ? 2 : 0);
}
/* Setup-host zip-root exe → build-release product (bios/mods/assets/settings). */
void psxrecomp_codegen_host_forward_if_built(
const PsxrecompCodegenHostConfig* cfg, int argc, char** argv) {
host_selfcheck_or_return(cfg, argc, argv); /* exits when requested */
#if defined(PSX_HAS_GAME_DISPATCH)
/* Full game binary — already the product tree. */
(void)cfg;
+9 -9
View File
@@ -688,7 +688,7 @@ def _build_recompiler_targets(
progress.log(" ".join(cmake_args))
proc = subprocess.run(
cmake_args, cwd=str(project_root), capture_output=True, text=True
cmake_args, cwd=str(project_root), capture_output=True, text=True, encoding="utf-8", errors="replace"
)
for stream in (proc.stdout, proc.stderr):
if stream:
@@ -705,7 +705,7 @@ def _build_recompiler_targets(
for target in targets:
build_cmd += ["--target", target]
progress.log(" ".join(build_cmd))
proc = subprocess.run(build_cmd, capture_output=True, text=True)
proc = subprocess.run(build_cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
for stream in (proc.stdout, proc.stderr):
if stream:
for line in stream.splitlines():
@@ -818,7 +818,7 @@ def regen_bios_profile(
[str(bios_tool), "--config", profile_rel],
cwd=str(fw),
capture_output=True,
text=True,
text=True, encoding="utf-8", errors="replace",
)
for stream in (proc.stdout, proc.stderr):
if not stream:
@@ -938,7 +938,7 @@ def run_prepare_disc(
str(project_root),
str(source),
]
proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True)
proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True, encoding="utf-8", errors="replace")
out = (proc.stdout or "") + (proc.stderr or "")
for line in out.splitlines():
if line.strip():
@@ -1141,7 +1141,7 @@ def cmd_generate(args: argparse.Namespace, progress: ProgressReporter) -> int:
cmd,
cwd=str(project_root),
capture_output=True,
text=True,
text=True, encoding="utf-8", errors="replace",
)
ri_warn = 0
for stream in (proc.stdout, proc.stderr):
@@ -1314,7 +1314,7 @@ def _cmake_configure(
*extra,
]
progress.log(" ".join(cmd))
proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True)
proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True, encoding="utf-8", errors="replace")
for stream in (proc.stdout, proc.stderr):
if stream:
for line in stream.splitlines():
@@ -1397,7 +1397,7 @@ def _cmake_build(
target,
]
progress.log(" ".join(cmd))
proc = subprocess.run(cmd, capture_output=True, text=True)
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
for stream in (proc.stdout, proc.stderr):
if stream:
for line in stream.splitlines():
@@ -1583,7 +1583,7 @@ def run_pgo_train(
r = subprocess.run(
["xcrun", "--find", "llvm-profdata"],
capture_output=True,
text=True,
text=True, encoding="utf-8", errors="replace",
check=False,
)
if r.returncode == 0 and r.stdout.strip():
@@ -1964,7 +1964,7 @@ def cmd_analyze(args: argparse.Namespace, progress: ProgressReporter) -> int:
progress.phase("analyze", pct=0.3, message=f"Analyzing {exe_path.name}…")
progress.log(" ".join(cmd))
proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True)
proc = subprocess.run(cmd, cwd=str(project_root), capture_output=True, text=True, encoding="utf-8", errors="replace")
for stream in (proc.stdout, proc.stderr):
if stream:
for line in stream.splitlines():
+3
View File
@@ -569,6 +569,7 @@ if(BUILD_TESTING)
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_kuseg_dispatch_lookup.py
--recompiler $<TARGET_FILE:psxrecomp-game>
--compiler ${CMAKE_C_COMPILER}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..)
add_test(
NAME overlay_shim_compile_contract
@@ -649,6 +650,8 @@ if(BUILD_TESTING)
foreach(_t IN ITEMS
bios_selection_guards
capture_history
cli_retail_bios_profile
codegen_host_bios_stems
default_renderer_guards
mod_owned_display_controls
fmv_quiet_guards
+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)
+201 -51
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,57 +1322,6 @@ 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.
//
// Install slots come from the profile's [[recompiler.install_slots]]
// (e.g. SCPH1001's SIO data-byte handler slot at RAM 0xCF0). 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});
}
}
/* 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. */
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"
" 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);
}
// Non-terminator: emit normally — unless this load's successor reads
// its destination (MIPS-I load-delay pair): then defer the register
@@ -1912,6 +1991,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 +2401,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 +2632,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",
+44 -9
View File
@@ -1602,6 +1602,34 @@ int main(int argc, char** argv) {
}
ds << "};\n";
ds << fmt::format("#define PSX_GAME_DISPATCH_COUNT {}u\n\n", records.size());
// WO-6: resident dispatch keys are immutable. Index them once at build
// time instead of binary-searching on every overlay-to-EXE call and
// every text-validity query. This caches resolution, NEVER validity:
// callers below still check live instruction ranges on every dispatch.
// Bound the indexed span to one PS1 RAM image; wider or ambiguous
// tables retain the existing binary search. No mutable cache or flag.
const uint32_t lookup_lo = records.empty() ? 0u : (records.front().addr & 0x1FFFFFFFu);
const uint32_t lookup_hi = records.empty() ? 0u : (records.back().addr & 0x1FFFFFFFu);
bool indexed_lookup = !records.empty() && lookup_hi - lookup_lo < 0x200000u;
for (size_t i = 0; i < records.size(); ++i) {
const uint32_t key = records[i].addr & 0x1FFFFFFFu;
if ((key & 3u) || (i && key == (records[i - 1].addr & 0x1FFFFFFFu)))
indexed_lookup = false;
}
if (indexed_lookup) {
std::vector<uint32_t> index((lookup_hi - lookup_lo) / 4u + 1u, 0u);
for (size_t i = 0; i < records.size(); ++i)
index[((records[i].addr & 0x1FFFFFFFu) - lookup_lo) / 4u] = (uint32_t)i + 1u;
ds << "/* Immutable physical-word index; zero denotes a dispatch miss. */\n";
ds << "static const " << (records.size() <= 65535u ? "uint16_t" : "uint32_t")
<< " k_psx_game_dispatch_index[] = {\n";
for (size_t i = 0; i < index.size(); ++i) {
if ((i & 15u) == 0) ds << " ";
ds << index[i] << "u,";
ds << (((i & 15u) == 15u || i + 1 == index.size()) ? "\n" : " ");
}
ds << "};\n\n";
}
ds << "/* PS1 segments alias the same physical RAM. A game whose PS-X EXE\n";
ds << " * header carries KUSEG addresses (load address and entry PC without the\n";
ds << " * KSEG bit) executes with a KUSEG PC, while this table is keyed by the\n";
@@ -1612,15 +1640,22 @@ int main(int argc, char** argv) {
ds << " * the table is sorted by the same masked key. */\n";
ds << "static const PsxGameDispatchEntry* psx_game_find_entry(uint32_t addr) {\n";
ds << " const uint32_t want = addr & 0x1FFFFFFFu;\n";
ds << " uint32_t lo = 0, hi = PSX_GAME_DISPATCH_COUNT;\n";
ds << " while (lo < hi) {\n";
ds << " uint32_t mid = lo + (hi - lo) / 2;\n";
ds << " uint32_t key = k_psx_game_dispatch[mid].addr & 0x1FFFFFFFu;\n";
ds << " if (want < key) hi = mid;\n";
ds << " else if (want > key) lo = mid + 1;\n";
ds << " else return &k_psx_game_dispatch[mid];\n";
ds << " }\n";
ds << " return 0;\n";
if (indexed_lookup) {
ds << fmt::format(" const uint32_t offset = want - 0x{:08X}u;\n", lookup_lo);
ds << fmt::format(" if ((want & 3u) || offset > 0x{:X}u) return 0;\n", lookup_hi - lookup_lo);
ds << " const uint32_t index = k_psx_game_dispatch_index[offset >> 2];\n";
ds << " return index ? &k_psx_game_dispatch[index - 1u] : 0;\n";
} else {
ds << " uint32_t lo = 0, hi = PSX_GAME_DISPATCH_COUNT;\n";
ds << " while (lo < hi) {\n";
ds << " uint32_t mid = lo + (hi - lo) / 2;\n";
ds << " uint32_t key = k_psx_game_dispatch[mid].addr & 0x1FFFFFFFu;\n";
ds << " if (want < key) hi = mid;\n";
ds << " else if (want > key) lo = mid + 1;\n";
ds << " else return &k_psx_game_dispatch[mid];\n";
ds << " }\n";
ds << " return 0;\n";
}
ds << "}\n\n";
ds << "/* Exact static-code validity for this entry's emitted CFG ranges. */\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;
@@ -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);
+75 -5
View File
@@ -20,7 +20,7 @@ key so the binary-search invariant holds.
Usage: python test_kuseg_dispatch_lookup.py [--recompiler <psxrecomp-game>]
Exit 0 = PASS.
"""
import argparse, os, re, struct, subprocess, sys, tempfile
import argparse, os, re, shutil, struct, subprocess, sys, tempfile
LOAD = 0x00010000 # KUSEG: no KSEG bit, the case under test
PHYS_MASK = 0x1FFFFFFF
@@ -80,6 +80,7 @@ def main():
ap.add_argument("--recompiler",
default=os.path.normpath(os.path.join(here, "..", "build",
"psxrecomp-game")))
ap.add_argument("--compiler", default=os.environ.get("CC") or shutil.which("cc") or shutil.which("gcc"))
args = ap.parse_args()
if not os.path.isfile(args.recompiler):
raise SystemExit("recompiler not found: %s (build it first)" % args.recompiler)
@@ -98,10 +99,8 @@ def main():
raise SystemExit(
"psx_game_find_entry does not mask to a physical address; a KUSEG "
"guest PC can never match KSEG-normalized table keys")
# ... and so must the stored key, otherwise the comparison is still mixed.
if not re.search(r"k_psx_game_dispatch\[mid\]\.addr\s*&\s*0x1FFFFFFFu", body):
raise SystemExit(
"psx_game_find_entry compares against an unmasked table key")
if "k_psx_game_dispatch_index" not in body:
raise SystemExit("small resident tables must use the immutable word index")
# The binary search requires the table to be ordered by the same key it
# compares. Parse the emitted entries and assert monotonic physical order.
@@ -116,6 +115,77 @@ def main():
raise SystemExit("dispatch table is not sorted by physical address; "
"the masked binary search would miss entries")
# Compile the REAL emitted lookup, validity gates and dispatch function.
# Only peripheral callbacks and generated MIPS bodies are fixture doubles.
if not args.compiler:
raise SystemExit("a C compiler is required for the dispatch regression")
begin = src.index("typedef struct { uint32_t lo; uint32_t len; } PsxGameCodeRange;")
end = src.index("/* 1 iff addr is a re-enterable", begin)
fragment = src[begin:end]
funcs = sorted(set(re.findall(r"func_[0-9A-Fa-f]{8}", fragment)))
harness = r'''
#include <stdint.h>
#include <stddef.h>
#include <assert.h>
typedef struct { uint32_t pc; } CPUState;
static int allowed = 1, calls = 0, irqs = 0;
static uint32_t resumed;
static void dummy(CPUState* cpu) { calls++; resumed = cpu->pc; }
static int dirty_ram_text_native_ok_ranges_from(const uint32_t* r, uint32_t n, uint32_t a) {
(void)r; (void)n; (void)a; return allowed;
}
static int dirty_ram_text_native_ok_ranges(const uint32_t* r, uint32_t n) {
return dirty_ram_text_native_ok_ranges_from(r, n, 0);
}
static void psx_check_interrupts_dispatch_entry(CPUState* cpu, uint32_t a) {
(void)cpu; (void)a; irqs++;
}
int psx_vsync_query_hle_try(CPUState* cpu, uint32_t a) { (void)cpu; (void)a; return 0; }
'''
harness += "\n".join(f"#define {fn} dummy" for fn in funcs) + "\n" + fragment
harness += r'''
int main(void) {
const uint32_t aliases[] = {0, 0x80000000u, 0xA0000000u};
CPUState cpu = {0};
for (unsigned a = 0; a < 3; ++a) {
for (uint32_t phys = 0xFFF0u; phys < 0x10080u; ++phys) {
const PsxGameDispatchEntry* expected = 0;
for (unsigned i = 0; i < PSX_GAME_DISPATCH_COUNT; ++i)
if ((k_psx_game_dispatch[i].addr & 0x1FFFFFFFu) == phys)
expected = &k_psx_game_dispatch[i];
uint32_t addr = aliases[a] | phys;
assert(psx_game_find_entry(addr) == expected);
if (!expected) continue;
// A previously resolved PC must NOT cache its live-byte verdict.
allowed = 0; cpu.pc = 0xDEADBEEFu;
int old_calls = calls, old_irqs = irqs;
assert(!psx_game_text_native_ok(addr));
assert(!psx_dispatch_game_compiled(&cpu, addr));
assert(calls == old_calls && irqs == old_irqs && cpu.pc == 0xDEADBEEFu);
allowed = 1;
assert(psx_dispatch_game_compiled(&cpu, addr));
assert(calls == old_calls + 1 && irqs == old_irqs + 1);
assert(resumed == expected->resume_pc);
}
}
assert(!psx_game_find_entry(0xFFFFFFFFu));
assert(!psx_game_find_entry(0));
return 0;
}
'''
with tempfile.TemporaryDirectory() as tmp:
source = os.path.join(tmp, "lookup.c")
binary = os.path.join(tmp, "lookup.exe" if os.name == "nt" else "lookup")
with open(source, "w", encoding="utf-8") as f:
f.write(harness)
compiler_name = os.path.basename(args.compiler).lower()
if compiler_name in ("cl", "cl.exe", "clang-cl", "clang-cl.exe"):
command = [args.compiler, "/nologo", "/Od", source, "/Fe:" + binary]
else:
command = [args.compiler, "-std=c11", "-O2", source, "-o", binary]
subprocess.run(command, check=True, cwd=tmp)
subprocess.run([binary], check=True, cwd=tmp)
print("KUSEG dispatch lookup test passed (%d entries, physical-keyed)" % len(keys))
+20
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.
@@ -382,6 +393,15 @@ if(BUILD_TESTING)
include
../recompiler/lib/toml11)
target_link_libraries(mod_runtime_test PRIVATE chdr-static)
# The real disc hasher uses a 1 MiB scratch buffer. Match the runtime's
# stack allowance instead of overflowing MinGW's 1 MiB executable default.
if(WIN32)
if(MSVC)
target_link_options(mod_runtime_test PRIVATE /STACK:16777216)
else()
target_link_options(mod_runtime_test PRIVATE -Wl,--stack,16777216)
endif()
endif()
add_test(NAME mod_runtime_test COMMAND mod_runtime_test)
if(WIN32)
+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 */
+4 -1
View File
@@ -113,7 +113,10 @@ void overlay_loader_get_counters(uint32_t *loads, uint32_t *invalidations,
void overlay_loader_get_load_timing(uint64_t *total_us, uint64_t *max_us,
uint64_t *last_us);
/* Opt-in PSX_RUNTIME_PERF_DIAG sampler: returns and clears the hottest native
* owner since the preceding call. Disabled runs pay no table update cost. */
* owner since the preceding call. Counts owner activations, INCLUDING CPS
* continuations, not guest function invocations (use fntrace for entries).
* The bounded direct-mapped sampler can undercount on collision; it cannot
* inflate a count. Disabled runs pay no table update cost. */
void overlay_loader_take_hot_native(uint32_t *pc, uint64_t *calls);
/* Exact shadow-differential summary for opt-in perf diagnostics. */
void overlay_loader_get_shadow_summary(uint64_t *calls, uint64_t *divergences,
+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. */
+106
View File
@@ -0,0 +1,106 @@
/* psx_bios_known_images.h — identities of the retail BIOS images this
* framework ships a build profile for.
*
* A *built* host asks its linked backends (psx_bios_registry) which image it
* accepts. A *setup* host has no linked backend — that is what makes it a
* setup host — so first-run discovery, bios.cfg seeding and picker copy had
* nowhere to look and hardcoded SCPH-1001. Any kit pinning another image via
* PSXRECOMP_BIOS_STEM / recompiler.bios_config (every wave-3 kit pins
* SCPH5552) then refused to auto-discover a perfectly good dump and told the
* player their BIOS was the wrong one.
*
* Keep in sync with bios/<stem>.toml. A stem absent from this table still
* builds and runs; it only loses first-run auto-discovery and falls back to
* asking the player, which is safe.
*/
#ifndef PSX_BIOS_KNOWN_IMAGES_H
#define PSX_BIOS_KNOWN_IMAGES_H
#include <stdint.h>
#include <string.h>
/* Set by runtime.cmake from PSXRECOMP_BIOS_STEM. */
#ifndef PSX_EXPECTED_BIOS_STEM
#define PSX_EXPECTED_BIOS_STEM "SCPH1001"
#endif
typedef struct PsxKnownBiosImage {
const char* stem; /* generated/<stem>_dispatch.c, bios/<stem>.toml */
const char* id; /* profile id, e.g. "SCPH-5552" */
uint32_t crc32; /* IEEE CRC-32 (zlib/Ethernet) over the whole image */
uint32_t size; /* bytes */
} PsxKnownBiosImage;
/* Each entry verified two ways: against the dump itself, and against the
* identity the recompiler emits into generated/<stem>_dispatch.c. */
static const PsxKnownBiosImage psx_known_bios_images[] = {
{ "SCPH1001", "SCPH-1001", 0x37157331u, 524288u },
{ "SCPH5552", "SCPH-5552", 0xD786F0B9u, 524288u },
};
static inline const PsxKnownBiosImage* psx_known_bios_by_stem(const char* stem) {
size_t i;
if (!stem || !stem[0])
return 0;
for (i = 0; i < sizeof(psx_known_bios_images) / sizeof(psx_known_bios_images[0]); ++i) {
if (strcmp(psx_known_bios_images[i].stem, stem) == 0)
return &psx_known_bios_images[i];
}
return 0;
}
/* The retail image THIS build was configured for, or NULL when the pinned
* stem is not in the table. Callers must handle NULL by asking the player
* rather than assuming SCPH-1001. */
static inline const PsxKnownBiosImage* psx_expected_bios(void) {
return psx_known_bios_by_stem(PSX_EXPECTED_BIOS_STEM);
}
/* A short label for the pinned image, for player-facing copy: "SCPH5552.BIN".
* Falls back to the raw stem when the identity is not in the table. */
static inline const char* psx_expected_bios_label(void) {
static char label[40];
if (!label[0]) {
const PsxKnownBiosImage* e = psx_expected_bios();
const char* stem = e ? e->stem : PSX_EXPECTED_BIOS_STEM;
size_t n = strlen(stem);
if (n > sizeof(label) - 5)
n = sizeof(label) - 5;
memcpy(label, stem, n);
memcpy(label + n, ".BIN", 5);
}
return label;
}
/* Filename spellings worth probing for <img> during first-run discovery,
* written into out[0..n). Covers the stem and the dashed profile id, each in
* upper and lower case: SCPH5552.BIN, scph5552.bin, SCPH-5552.BIN, ... */
static inline int psx_known_bios_filenames(const PsxKnownBiosImage* img,
char out[][32], int cap) {
const char* bases[2];
int n = 0, b, lower;
if (!img)
return 0;
bases[0] = img->stem;
bases[1] = img->id;
for (b = 0; b < 2; ++b) {
for (lower = 0; lower < 2; ++lower) {
const char* ext = lower ? "bin" : "BIN";
size_t i, len = strlen(bases[b]);
char* dst;
if (n >= cap || len + 5 > 32)
continue;
dst = out[n++];
for (i = 0; i < len; ++i) {
char c = bases[b][i];
dst[i] = (char)(lower && c >= 'A' && c <= 'Z' ? c - 'A' + 'a' : c);
}
dst[len] = '.';
memcpy(dst + len + 1, ext, 3);
dst[len + 4] = 0;
}
}
return n;
}
#endif /* PSX_BIOS_KNOWN_IMAGES_H */
+49 -10
View File
@@ -329,6 +329,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
@@ -515,7 +516,7 @@ else()
if(PSXRECOMP_HAS_RECOMP_NET)
message(FATAL_ERROR
"psxrecomp: PSX_NETPLAY needs retcomm-rbengine.\n"
" git submodule update --init lib/retcomm-rbengine\n"
" git submodule update --init --recursive lib/retcomm-rbengine\n"
" or -DRECOMP_RBENGINE_ROOT=/path/to/retcomm-rbengine")
endif()
endif()
@@ -544,7 +545,13 @@ if(PSX_REWIND)
message(FATAL_ERROR
"psxrecomp: PSX_REWIND=ON exposes the Rewind launcher controls "
"but no retcomm-rbengine snap-ring backend was found.\n"
" git submodule update --init lib/retcomm-rbengine\n"
"A source ZIP downloaded from GitHub never contains submodule "
"contents and cannot build. Clone instead:\n"
" git clone --recurse-submodules <repo-url>\n"
"In an existing clone, run this from the GAME repo root. Note "
"--recursive: rbengine is a submodule of psxrecomp, not of the "
"game, so a non-recursive init leaves it empty.\n"
" git submodule update --init --recursive\n"
" or -DRECOMP_RBENGINE_ROOT=/path/to/retcomm-rbengine\n"
" or configure with -DPSX_REWIND=OFF to hide Rewind.")
endif()
@@ -611,6 +618,20 @@ set(PSXRECOMP_BIOS_STEM "${PSXRECOMP_BIOS_STEM_PRIMARY}" CACHE STRING
set(PSXRECOMP_BIOS_PROFILE "${PSXRECOMP_ROOT}/bios/${PSXRECOMP_BIOS_STEM}.toml" CACHE FILEPATH
"BIOS profile TOML this build regenerates from (staleness stamp input)")
# The setup host must test the same requested backends as the linker, including
# a relocated framework checkout. Keep the path relative for portable kits.
file(RELATIVE_PATH PSXRECOMP_SETUP_FRAMEWORK_REL "${CMAKE_SOURCE_DIR}" "${PSXRECOMP_ROOT}")
string(REPLACE ";" "|" PSXRECOMP_SETUP_BIOS_STEMS "${PSXRECOMP_BIOS_STEMS}")
set(PSXRECOMP_EXPECTED_RETAIL_STEM "")
foreach(_stem IN LISTS PSXRECOMP_BIOS_STEMS)
if(NOT _stem MATCHES "^[A-Za-z_][A-Za-z0-9_]*$")
message(FATAL_ERROR "Invalid BIOS backend stem: ${_stem}")
endif()
if(NOT _stem STREQUAL "OpenBIOS" AND NOT PSXRECOMP_EXPECTED_RETAIL_STEM)
set(PSXRECOMP_EXPECTED_RETAIL_STEM "${_stem}")
endif()
endforeach()
# Link a stem only if its generated sources are actually present.
#
# PSXRECOMP_BIOS_STEMS lists every stem this build WOULD like. SCPH1001 is in
@@ -1248,15 +1269,18 @@ function(psxrecomp_add_runtime_target target)
# where releases are validated. Dev checkouts still resolve the relative
# default without prompting via the exe-dir upward search, which also tries
# <ancestor>/psxrecomp-v4/<relative> for game-project layouts.
# Follow the stem this build actually pins; assuming SCPH1001 here handed
# every non-SCPH1001 kit a default path that could never resolve.
set(_psxrt_stem_bios "bios/${PSXRECOMP_BIOS_STEM}.BIN")
if(NOT PSXRT_DEFAULT_BIOS_PATH)
set(PSXRT_DEFAULT_BIOS_PATH "bios/SCPH1001.BIN")
set(PSXRT_DEFAULT_BIOS_PATH "${_psxrt_stem_bios}")
elseif(IS_ABSOLUTE "${PSXRT_DEFAULT_BIOS_PATH}")
message(WARNING
"DEFAULT_BIOS_PATH '${PSXRT_DEFAULT_BIOS_PATH}' is absolute; refusing to "
"bake a build-machine path into the binary (release exes must prompt on "
"user machines). Using relative 'bios/SCPH1001.BIN' instead — drop the "
"user machines). Using relative '${_psxrt_stem_bios}' instead — drop the "
"DEFAULT_BIOS_PATH argument from this game's CMakeLists.")
set(PSXRT_DEFAULT_BIOS_PATH "bios/SCPH1001.BIN")
set(PSXRT_DEFAULT_BIOS_PATH "${_psxrt_stem_bios}")
endif()
if(NOT DEFINED PSXRT_DEFAULT_GAME_CONFIG_PATH)
set(PSXRT_DEFAULT_GAME_CONFIG_PATH "")
@@ -1715,6 +1739,12 @@ function(psxrecomp_add_runtime_target target)
target_compile_definitions(${target} PRIVATE
DEFAULT_DEBUG_PORT=${PSXRT_DEBUG_PORT}
PSX_DEFAULT_BIOS_PATH="${PSXRT_DEFAULT_BIOS_PATH}"
# The retail stem this build pins. A setup host has no linked
# backend to ask, so this is how it knows which image to look for
# and name (psx_bios_known_images.h) instead of assuming SCPH-1001.
PSX_EXPECTED_BIOS_STEM="${PSXRECOMP_EXPECTED_RETAIL_STEM}"
PSX_SETUP_BIOS_STEMS="${PSXRECOMP_SETUP_BIOS_STEMS}"
PSX_SETUP_FRAMEWORK_REL="${PSXRECOMP_SETUP_FRAMEWORK_REL}"
# Where the shipped redistributable image lives, relative to the exe.
# This is what a player gets when they choose no BIOS.
PSX_BUNDLED_BIOS_PATH="${PSXRECOMP_BUNDLED_BIOS_PATH}"
@@ -1909,11 +1939,20 @@ function(psxrecomp_add_runtime_target target)
if(PSX_RECOMP_UI AND NOT PSXRT_ORACLE)
if(NOT RECOMP_UI_ROOT OR NOT EXISTS "${RECOMP_UI_ROOT}/recomp_ui.cmake")
message(FATAL_ERROR
"PSX_RECOMP_UI=ON but recomp-ui is missing.\n"
"Add at the game repo root:\n"
" git submodule add -b master "
"https://github.com/RetroPortingToolKit/recomp-ui.git recomp-ui\n"
"Or set -DRECOMP_UI_ROOT=/path/to/recomp-ui")
"PSX_RECOMP_UI=ON but recomp-ui is missing from the game "
"repo root.\n"
"A source ZIP downloaded from GitHub never contains "
"submodule contents and cannot build. Clone instead:\n"
" git clone --recurse-submodules <repo-url>\n"
"In a clone that already declares recomp-ui, fetch the "
"pinned commit:\n"
" git submodule update --init --recursive\n"
"Only if this repo does not declare recomp-ui yet, add it "
"(use the fork this project pins, not necessarily "
"upstream):\n"
" git submodule add <recomp-ui-url> recomp-ui\n"
"Or point at an existing checkout: "
"-DRECOMP_UI_ROOT=/path/to/recomp-ui")
endif()
# recomp-ui gates its Mods view behind RECOMP_UI_ENABLE_MODS, which
# defaults OFF there -- correct for a cross-console launcher, since a
+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 (!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;
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;
}
+64 -40
View File
@@ -16,6 +16,7 @@
#include "boot_state.h"
#include "bios_hle.h"
#include "bios_hle_plan.h"
#include "psx_bios_known_images.h"
#include "psx_bios_backend.h"
#include "psx_cycles.h"
#include "starvation_ring.h"
@@ -2514,11 +2515,32 @@ static std::filesystem::path resolve_bios_for_runtime(const char* requested,
const bool bundled_only =
openbios_allowed && bundled && !player_bios_selectable;
/* 0. Setup host (CI zip root): no BIOS backends are linked yet, so there
* is nothing an image could be validated against — bios_backend_for_file()
* rejects every file, including the correct one, and a title with
* openbios = false has no fallback to land on. This MUST precede the
* explicit-choice branch below: a remembered bios.cfg pick reached
* validate_bios_for_launch() first and deadlocked first-run setup — the
* player was told their good SCPH-1001 was "not an image this build was
* compiled from", and could never supply the BIOS that Generate needs in
* order to emit the backend. Play belongs to the product binary under
* build-release/ after Generate & rebuild. */
if (psx_bios_registry_count == 0) {
launcher_warning("Setup host — finish Generate & rebuild",
"This executable is the first-run setup host (no game/BIOS code "
"linked).\n\n"
"Use Generate & rebuild in the launcher. After that succeeds, open "
"this same shortcut again — it starts the game from build-release/ "
"(where bios/, mods/, and settings live).\n\n"
"Or run build-release/<game>.exe directly.");
return {};
}
/* 1. An explicit choice: --bios, else a remembered pick. A product build
* with only its bundled backend has no meaningful player choice: ignore
* stale settings/bios.cfg paths instead of validating an image the hidden
* launcher row cannot clear. Setup hosts (registry_count == 0) retain their
* picker/generation flow. */
* launcher row cannot clear. Setup hosts (registry_count == 0) already
* returned above. */
std::filesystem::path chosen;
if (!bundled_only) {
if (requested_is_explicit && requested && requested[0]) {
@@ -2551,19 +2573,6 @@ static std::filesystem::path resolve_bios_for_runtime(const char* requested,
return {};
}
/* Setup host (CI zip root): no BIOS backends linked yet. Play belongs to
* the product binary under build-release/ after Generate & rebuild. */
if (psx_bios_registry_count == 0) {
launcher_warning("Setup host — finish Generate & rebuild",
"This executable is the first-run setup host (no game/BIOS code "
"linked).\n\n"
"Use Generate & rebuild in the launcher. After that succeeds, open "
"this same shortcut again — it starts the game from build-release/ "
"(where bios/, mods/, and settings live).\n\n"
"Or run build-release/<game>.exe directly.");
return {};
}
/* 3. This title requires a retail BIOS: ask for one. */
const std::string accepted = bios_accepted_images();
launcher_info((s_picker_game_name + " — PlayStation BIOS needed").c_str(),
@@ -2571,7 +2580,8 @@ static std::filesystem::path resolve_bios_for_runtime(const char* requested,
"Step 1 of 2 — PlayStation BIOS\n\n"
"In the next window, select your PlayStation BIOS dump. This build "
"requires the exact image it was compiled from: " + accepted + ". "
"Usually named SCPH1001.BIN and exactly 512 KB. Dump from your own "
"Usually named " + std::string(psx_expected_bios_label()) +
" and exactly 512 KB. Dump from your own "
"console or otherwise legally obtain it.\n\n"
"(This is NOT the game disc — that is asked for next.)");
std::string bios_title =
@@ -2704,24 +2714,27 @@ static bool retail_bios_file_ok(const std::filesystem::path& path) {
const PsxBiosBackend* b = bios_backend_for_file(path, nullptr, nullptr);
return b && b->image && !b->image->image_bundled;
}
/* Setup host (no backends linked yet): accept validated SCPH-1001 only. */
constexpr uint64_t kSize = 512u * 1024u;
constexpr uint32_t kScph1001Crc = 0x37157331u;
/* Setup host (no backends linked yet): accept the retail image THIS build
* pins, from psx_bios_known_images.h. This used to hardcode SCPH-1001, so
* a kit pinning anything else refused to seed from a correct dump. An
* unknown pinned stem seeds nothing and the player is asked instead. */
const PsxKnownBiosImage* want = psx_expected_bios();
if (!want) return false;
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f.is_open()) return false;
const auto size = static_cast<uint64_t>(f.tellg());
if (size != kSize) return false;
if (size != static_cast<uint64_t>(want->size)) return false;
std::vector<uint8_t> data(static_cast<size_t>(size));
if (!read_at(f, 0, data.data(), data.size())) return false;
return crc32_compute(data.data(), data.size()) == kScph1001Crc;
return crc32_compute(data.data(), data.size()) == want->crc32;
}
static std::filesystem::path discover_retail_bios_near(const char* argv0) {
namespace fs = std::filesystem;
static const char* kNames[] = {
"SCPH1001.BIN", "scph1001.bin", "SCPH-1001.BIN", "scph-1001.bin",
"SCPH1001.bin", "scph1001.BIN",
};
char name_buf[8][32];
const int name_count =
psx_known_bios_filenames(psx_expected_bios(), name_buf, 8);
if (name_count <= 0) return {};
static const char* kSubdirs[] = {
"bios", "", "system", "firmware", "psxrecomp/bios", "psxrecomp-v4/bios",
};
@@ -2730,8 +2743,8 @@ static std::filesystem::path discover_retail_bios_near(const char* argv0) {
for (fs::path root = exe_dir; !root.empty(); root = root.parent_path()) {
for (const char* sub : kSubdirs) {
const fs::path dir = (sub && sub[0]) ? (root / sub) : root;
for (const char* name : kNames) {
const fs::path cand = dir / name;
for (int ni = 0; ni < name_count; ++ni) {
const fs::path cand = dir / name_buf[ni];
if (retail_bios_file_ok(cand)) {
auto abs = fs::weakly_canonical(cand, ec);
if (ec) abs = fs::absolute(cand, ec);
@@ -3587,7 +3600,7 @@ static void runtime_perf_diag_tick() {
"cpu=%.1f tex=%.1f draw=%.1f ms/s; "
"work guest=%.1f pacer=%.1f autocapture=%.1f provider_poll=%.1f ms/s, "
"dirty=%.0f insn/s %.0f dispatch/s; "
"overlay native=+%llu interp=+%llu hot_native=0x%08X/+%llu "
"overlay native=+%llu interp=+%llu hot_native_owner=0x%08X/activations>=+%llu "
"shadow=+%llu div=+%llu first_div=0x%08X "
"loads=+%u revalidations=+%u "
"load_wall=%.1f ms max=%.1f last=%.1f ms; "
@@ -7666,12 +7679,14 @@ namespace {
out->ok = 1;
std::snprintf(out->detail, sizeof(out->detail),
"OpenBIOS will be emitted on Generate & rebuild "
"(optional: pick SCPH1001). Play uses "
"build-release/ after rebuild.");
"(optional: pick %s). Play uses "
"build-release/ after rebuild.",
psx_expected_bios_label());
return 1;
}
std::snprintf(out->detail, sizeof(out->detail),
"PlayStation BIOS required (SCPH1001.BIN).");
"PlayStation BIOS required (%s).",
psx_expected_bios_label());
return 1;
}
/* Match runtime resolve: relative picks like bios/SCPH1001.BIN must not
@@ -7689,12 +7704,16 @@ namespace {
"BIOS file not found.");
return 1;
}
const PsxKnownBiosImage* want = psx_expected_bios();
const std::streamoff want_size =
want ? (std::streamoff)want->size : (std::streamoff)(512 * 1024);
const std::streamoff size = f.tellg();
if (size != 512 * 1024) {
if (size != want_size) {
std::snprintf(out->detail, sizeof(out->detail),
"BIOS must be exactly 512 KiB (got %lld). Use "
"SCPH1001.BIN.",
(long long)size);
"BIOS must be exactly %lld bytes (got %lld). Use "
"%s.",
(long long)want_size, (long long)size,
psx_expected_bios_label());
return 1;
}
std::vector<uint8_t> data((size_t)size);
@@ -7704,15 +7723,20 @@ namespace {
return 1;
}
const uint32_t crc = crc32_compute(data.data(), data.size());
if (crc != 0x37157331u) {
if (want && crc != want->crc32) {
out->warn = 1;
std::snprintf(out->detail, sizeof(out->detail),
"CRC32 %08X (validated dump is SCPH1001 CRC32 "
"37157331).",
crc);
"CRC32 %08X (this build expects %s, CRC32 %08X).",
crc, want->id, want->crc32);
} else if (!want) {
out->warn = 1;
std::snprintf(out->detail, sizeof(out->detail),
"CRC32 %08X (this build pins %s, whose identity "
"is not recorded here).",
crc, PSX_EXPECTED_BIOS_STEM);
} else {
std::snprintf(out->detail, sizeof(out->detail),
"SCPH1001.BIN (CRC OK).");
"%s (CRC OK).", psx_expected_bios_label());
}
/* Setup host (no backends yet): file is fine for first Generate. */
if (psx_bios_registry_count == 0) {
+54 -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) {
@@ -341,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));
}
+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,
@@ -0,0 +1,58 @@
"""CLI half of the BIOS setup contract (registered by PR #343)."""
import importlib.util
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
spec = importlib.util.spec_from_file_location("psxrecomp_cli_under_test", ROOT / "psxrecomp_cli.py")
cli = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = cli
spec.loader.exec_module(cli)
class RetailBiosProfileTests(unittest.TestCase):
def test_pairs_require_matching_descriptor(self):
with tempfile.TemporaryDirectory() as temporary:
fw = Path(temporary)
generated = fw / "generated"
generated.mkdir()
for stem in ("OpenBIOS", "SCPH1001", "SCPH5552"):
with self.subTest(stem=stem):
dispatch = generated / f"{stem}_dispatch.c"
full = generated / f"{stem}_full.c"
self.assertFalse(cli.bios_backend_present(fw, stem))
dispatch.write_text(f"const PsxBiosBackend {stem}_psx_bios_backend;", encoding="utf-8")
self.assertFalse(cli.bios_backend_present(fw, stem))
full.write_text("", encoding="utf-8")
self.assertTrue(cli.bios_backend_present(fw, stem))
dispatch.write_text("const PsxBiosBackend unrelated_psx_bios_backend;", encoding="utf-8")
self.assertFalse(cli.bios_backend_present(fw, stem))
def test_profile_and_utf8_are_forwarded_and_failures_propagate(self):
with tempfile.TemporaryDirectory() as temporary:
fw = Path(temporary)
(fw / "bios").mkdir()
(fw / "bios/SCPH5552.toml").write_text('[program]\nid="SCPH-5552"\n', encoding="utf-8")
progress = Mock()
with patch.object(cli, "framework_root", return_value=fw), \
patch.object(cli, "find_psxrecomp_bios", return_value=fw / "bios-tool"), \
patch.object(cli.subprocess, "run") as run:
run.return_value = subprocess.CompletedProcess([], 0, "BIOS → generated", "")
cli.regen_bios_profile(fw, "bios/SCPH5552.toml", progress=progress)
args, kwargs = run.call_args
self.assertEqual(args[0][1:], ["--config", "bios/SCPH5552.toml"])
self.assertEqual(kwargs["cwd"], str(fw))
self.assertEqual(kwargs["encoding"], "utf-8")
self.assertEqual(kwargs["errors"], "replace")
run.return_value = subprocess.CompletedProcess([], 2, "", "wrong image")
with self.assertRaisesRegex(RuntimeError, "SCPH5552.*exit 2"):
cli.regen_bios_profile(fw, "bios/SCPH5552.toml", progress=progress)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Regression: the codegen host must accept any configured, linkable BIOS stem.
bios_backends_missing() in host/psxrecomp_codegen_host.c used to probe two
hardcoded filenames, psxrecomp/generated/OpenBIOS_dispatch.c and
SCPH1001_dispatch.c. Ports that pin another image via recompiler.bios_config /
PSXRECOMP_BIOS_STEMS emit <stem>_dispatch.c + <stem>_full.c instead, so the
probe could never be satisfied: Generate succeeded, the setup wizard reopened,
and first-run setup looped forever. Every wave-3 kit pins SCPH5552 and every
one of them shipped with that loop.
test_cli_retail_bios_profile.py already covers the psxrecomp_cli.py half of
this feature. This is the C host half, which had no coverage.
"""
from pathlib import Path
import os
import shutil
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[2]
HOST_C = ROOT / "host" / "psxrecomp_codegen_host.c"
PROBE = """
#include <string.h>
#include <stdio.h>
#include "psxrecomp_codegen_host.h"
/* recomp-ui symbol the host references but sources_missing() never reaches. */
int recomp_launcher_relaunch_exe(char* o, size_t c) { (void)o; (void)c; return 0; }
int main(void) {
PsxrecompCodegenHostConfig cfg;
memset(&cfg, 0, sizeof(cfg));
cfg.cmake_target = "psx-runtime";
cfg.exe_basename = "Probe";
cfg.gen_marker_relpath = "generated/SCUS_943.51_dispatch.c";
printf("%d", psxrecomp_codegen_host_sources_missing(&cfg));
return 0;
}
"""
def find_recomp_ui() -> Path | None:
env = os.environ.get("RECOMP_UI_ROOT")
candidates = ([Path(env)] if env else []) + [
ROOT.parent / "recomp-ui",
ROOT / "recomp-ui",
]
for c in candidates:
if (c / "src" / "recomp_launcher.h").is_file():
return c
return None
def make_project(root: Path, bios_files: list[str], framework: str, descriptor: bool) -> None:
"""A project tree the host recognises, with the given BIOS artefacts."""
(root / "generated").mkdir(parents=True, exist_ok=True)
(root / "generated" / "SCUS_943.51_dispatch.c").write_text("", encoding="utf-8")
fw_gen = root / framework / "generated"
fw_gen.mkdir(parents=True, exist_ok=True)
(root / "game.toml").write_text("[game]\n", encoding="utf-8")
(root / "psxrecomp").mkdir(exist_ok=True)
(root / "psxrecomp" / "psxrecomp_cli.py").write_text("", encoding="utf-8")
for name in bios_files:
text = ""
if descriptor and name.endswith("_dispatch.c"):
text = "const PsxBiosBackend " + name.removesuffix("_dispatch.c") + "_psx_bios_backend = {};\n"
(fw_gen / name).write_text(text, encoding="utf-8")
def main() -> int:
ui = find_recomp_ui()
if ui is None:
print("SKIP: recomp-ui not found (set RECOMP_UI_ROOT)")
return 0
cc = os.environ.get("CC") or shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
if cc is None:
print("SKIP: no C compiler on PATH")
return 0
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
probe_c = tmp / "probe.c"
probe_c.write_text(PROBE, encoding="utf-8")
exe = tmp / ("probe.exe" if os.name == "nt" else "probe")
framework = "framework checkout"
build = subprocess.run(
[cc, "-std=c11", "-o", str(exe), str(probe_c), str(HOST_C),
'-DPSX_SETUP_BIOS_STEMS="OpenBIOS|SCPH5552"',
f'-DPSX_SETUP_FRAMEWORK_REL="{framework}"',
"-I", str(ROOT / "host"),
"-I", str(ROOT / "runtime" / "include"),
"-I", str(ui / "src"), "-I", str(ui / "src" / "common")],
capture_output=True, text=True)
if build.returncode != 0:
print("FAIL: could not build probe\n" + build.stderr[-2000:])
return 1
cases = [
# (BIOS artefacts present, expected sources_missing, why)
(["SCPH5552_dispatch.c", "SCPH5552_full.c"], True, 0,
"a pinned non-SCPH1001 stem must satisfy the host"),
(["OpenBIOS_dispatch.c", "OpenBIOS_full.c"], True, 0,
"the bundled stem must still satisfy the host"),
(["SCPH5552_dispatch.c"], True, 1,
"a dispatch with no _full.c is not a linkable backend"),
(["SCPH5552_dispatch.c", "SCPH5552_full.c"], False, 1,
"a pre-descriptor pair must still require generation"),
(["SCPH1001_dispatch.c", "SCPH1001_full.c"], True, 1,
"an unrequested backend must not bypass setup"),
(["SCUS_943.51_dispatch.c", "SCUS_943.51_full.c"], True, 1,
"game sources cannot masquerade as a configured BIOS"),
([], False, 1,
"no BIOS backend at all means setup is genuinely incomplete"),
]
failures = 0
for index, (artefacts, descriptor, expected, why) in enumerate(cases):
project = tmp / f"case{index}"
make_project(project, artefacts, framework, descriptor)
env = dict(os.environ, PSXRECOMP_PROJECT_ROOT=str(project))
run = subprocess.run([str(exe)], capture_output=True, text=True, env=env)
got = run.stdout.strip()
if got != str(expected):
print("FAIL: %s\n artefacts=%s expected=%s got=%r"
% (why, artefacts or "(none)", expected, got))
failures += 1
else:
print("ok: %s" % why)
if failures:
return 1
print("PASS: codegen host accepts configured, linkable BIOS stems in relocated frameworks")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -18,13 +18,15 @@ def main() -> int:
required_range_fragments = (
"uint32_t exec_pc",
"if (phys + len <= at) continue;",
"len -= at - phys;",
"(void)exec_pc;",
"memcmp(ram + phys, text_ref_image + (phys - text_ref_lo), len)",
"if (!any)",
)
for fragment in required_range_fragments:
if fragment not in range_guard:
raise AssertionError(f"missing continuation range guard: {fragment}")
if "len -= at - phys;" in range_guard or "if (phys + len <= at) continue;" in range_guard:
raise AssertionError("continuation guard clips away code reachable by a backward edge")
if "text_diverged_bitmap" in range_guard:
raise AssertionError("exact-range mismatch still sticky-poisons a whole page")
if "dirty_ram_text_native_ok_ranges_from(lo_len_pairs, count, 0u)" not in memory:
+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;
}
+4 -4
View File
@@ -2,10 +2,10 @@
from pathlib import Path
root = Path(__file__).resolve().parents[2]
main = (root / "runtime" / "src" / "main.cpp").read_text()
config_h = (root / "recompiler" / "src" / "config_loader.h").read_text()
config_cpp = (root / "recompiler" / "src" / "config_loader.cpp").read_text()
runtime_cmake = (root / "runtime" / "runtime.cmake").read_text()
main = (root / "runtime" / "src" / "main.cpp").read_text(encoding="utf-8")
config_h = (root / "recompiler" / "src" / "config_loader.h").read_text(encoding="utf-8")
config_cpp = (root / "recompiler" / "src" / "config_loader.cpp").read_text(encoding="utf-8")
runtime_cmake = (root / "runtime" / "runtime.cmake").read_text(encoding="utf-8")
assert "runtime/" + "launcher" not in runtime_cmake
assert "Rml" + "Ui" not in runtime_cmake
@@ -7,10 +7,11 @@ import argparse
import os
import pathlib
import platform
import re
import shutil
import subprocess
import sys
import tempfile
from unittest.mock import patch
import zlib
@@ -23,21 +24,12 @@ RESIDENT = "psxrecomp bios resident shard v1\n"
def codegen_leaf() -> str:
api = (RUNTIME / "include" / "overlay_api.h")
hash_header = (RUNTIME / "include" / "overlay_codegen_hash.h")
version = "0"
code_hash = "00000000"
if api.is_file():
match = re.search(r"PSX_OVERLAY_CODEGEN_VER\s+(\d+)",
api.read_text(encoding="utf-8"))
if match:
version = match.group(1)
if hash_header.is_file():
match = re.search(r"PSX_OVERLAY_CODEGEN_HASH\s+0x([0-9A-Fa-f]{8})",
hash_header.read_text(encoding="utf-8"))
if match:
code_hash = match.group(1).lower()
return f"cg{version}_{code_hash}_gc00000000_f0"
sys.path.insert(0, str(ROOT / "tools"))
import compile_overlays
# The synthetic loader uses a zero config hash; serialize its namespace
# with the production owner so new namespace fields cannot go stale here.
with patch.object(compile_overlays, "overlay_config_hash", return_value=0):
return compile_overlays.cache_tag(str(RUNTIME / "include"), "", "")
def arch_abi() -> str:
+2 -2
View File
@@ -16,8 +16,8 @@ with tempfile.TemporaryDirectory() as temporary:
stage = base / "stage"
(stage / "mods" / "package").mkdir(parents=True)
(stage / "Tomba Recompiled.exe").write_bytes(b"exe")
(stage / "mods" / "package" / "manifest.toml").write_text(
'id = "test"\n', encoding="utf-8"
(stage / "mods" / "package" / "manifest.toml").write_bytes(
b'id = "test"\n'
)
output = base / "release.zip"
subprocess.run(
+8 -3
View File
@@ -143,9 +143,14 @@ def send_cmd(sock, cmd_dict):
return json.loads(buf.decode().strip())
def query(host, port, cmd_dict):
"""One-shot: connect, send, receive, close."""
s = connect(host, port)
def query(host, port, cmd_dict, timeout=10.0):
"""One-shot: connect, send, receive, close.
`timeout` is per-socket-operation. The 10 s default suits the small
commands; bulk ones (dirty_ram_stats walks a large PC table, read_ram of a
whole window) need more, so callers that ask for those must raise it.
"""
s = connect(host, port, timeout)
try:
return send_cmd(s, cmd_dict)
finally:
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""A/B the declared kernel install-slot ranges: what do they buy this game?
Boots the game twice against the SAME binary, to the same frame, once with the
profile's [[recompiler.install_slots]] ranges live and once with
PSX_KERNEL_PATCH_RANGES=0, which drops them and restores the whole-body
kernel-bless memcmp - the behaviour before the ranges existed. One binary
measures both sides, so the overlays, the codegen and the disc are held
constant and only the bless verdict moves.
Run from a game project root:
python psxrecomp/tools/kernel_patch_ab.py
python psxrecomp/tools/kernel_patch_ab.py --bios psxrecomp/bios/SCPH1001.BIN
python psxrecomp/tools/kernel_patch_ab.py --frames 20000
Set PSX_BIOS_HLE=0 to isolate this change from the kernel-call HLE tier. That
matters on an image exporting a DeliverEvent anchor (retail SCPH-1001);
OpenBIOS has none, so its kernel calls are LLE either way.
Headless runs uncapped, so a frame is a unit of guest work rather than wall
time, and the two sides reach slightly different frames in the same wall
budget - the report normalises per frame.
Read "kernel" and "all" as the result. The bodies/vectors split underneath is
diagnosis: whether the A0/B0/C0 vectors interpret on both sides is
title-dependent (see kernel_patch_common.VECTOR_STUBS), so they are shown but
never discounted.
"""
import argparse
import json
import os
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import kernel_patch_common as kp
def wait_port(host, port, secs=180):
t0 = time.time()
while time.time() - t0 < secs:
try:
r = kp.q("frame", host, port, timeout=30.0)
if r.get("ok") or "frame" in r:
return
except Exception:
pass
time.sleep(1)
raise SystemExit("debug port never came up")
def run_side(a, root, ranges_on):
env = dict(os.environ, PSX_STARVATION_TIMEOUT_US="0")
env.pop("PSX_LOAD_SLOT", None)
if ranges_on:
env.pop("PSX_KERNEL_PATCH_RANGES", None)
else:
env["PSX_KERNEL_PATCH_RANGES"] = "0"
argv = [os.path.join(root, a.exe), "--game", a.game, "--no-launcher",
"--headless", "--debug-port", str(a.port)]
if a.bios:
argv += ["--bios", os.path.join(root, a.bios)]
proc = subprocess.Popen(argv, cwd=root, env=env,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
try:
wait_port(a.host, a.port)
t0 = time.time()
# A wedge shows up as a frame counter that stops advancing while the
# debug server still answers, so watch for STALL as well as the overall
# timeout - a boot that hangs at frame 0 should not burn the whole
# budget before saying so. It did once: a dispatch key inside a
# declared range spun the dispatch loop forever.
last_fr, last_move = -1, time.time()
while True:
try:
fr = kp.q("frame", a.host, a.port, timeout=30.0).get("frame") or 0
except Exception:
fr = last_fr # transient; the stall check still applies
if fr >= a.frames:
break
if fr != last_fr:
last_fr, last_move = fr, time.time()
elif time.time() - last_move > a.stall:
raise SystemExit("WEDGED: frame stuck at %s for %ds (target %d)"
% (fr, a.stall, a.frames))
if time.time() - t0 > a.timeout:
raise SystemExit("only reached frame %s of %d in %ds"
% (fr, a.frames, a.timeout))
time.sleep(1.0)
kb = kp.q("kernel_bless", a.host, a.port)
st = kp.q("dirty_ram_stats", a.host, a.port)
rows = [r for r in st.get("per_pc", [])
if int(r["pc"], 16) < kp.KERNEL_WINDOW_END]
kernel = sum(r["insns"] for r in rows)
tramp = sum(r["insns"] for r in rows
if int(r["pc"], 16) in kp.VECTOR_STUBS)
top = sorted(rows, key=lambda r: -r["insns"])[:12]
return {
"ranges_on": ranges_on,
"frame": fr,
"wall_s": round(time.time() - t0, 1),
"bless_entries": kb.get("entries"),
"bless_clean": kb.get("clean"),
"bless_mismatch": kb.get("mismatch"),
"bless_native_hits": kb.get("native_hits"),
"patch_ranges": kb.get("patch_ranges"),
"patch_skips": kb.get("patch_skips"),
"interp_insns_total": st.get("insns_run"),
"interp_insns_kernel": kernel,
"interp_insns_vectors": tramp,
"interp_insns_kernel_bodies": kernel - tramp,
"native_handoffs": st.get("native_handoffs"),
"top_kernel_pcs": [{"pc": r["pc"], "insns": r["insns"],
"entries": r["entries"]} for r in top],
}
finally:
proc.kill()
time.sleep(2)
def main():
ap = argparse.ArgumentParser(description=__doc__.split(chr(10) + chr(10), 1)[0])
kp.add_project_args(ap, default_port=4372)
ap.add_argument("--frames", type=int, default=11000,
help="frame target for both sides (default 11000)")
ap.add_argument("--timeout", type=int, default=900)
ap.add_argument("--stall", type=int, default=90,
help="fail if the frame counter does not advance for this long")
ap.add_argument("--out", default="analysis/kernel_patch_ab.json")
a = ap.parse_args()
root = kp.resolve(a)
pranges = kp.parse_patch_ranges(os.path.join(root, a.dispatch))
print("%s declares %d install-slot range(s): %s"
% (os.path.basename(a.dispatch), len(pranges),
", ".join("0x%04X..0x%04X" % r for r in pranges) or "none"), flush=True)
if not pranges:
raise SystemExit("nothing to A/B: this backend declares no ranges. Add "
"[[recompiler.install_slots]] to the BIOS profile and "
"regenerate (see docs/dynamic_handler_install.md).")
rows = []
for ranges_on in (False, True):
print("=== %s" % ("ranges ON (declared install slots)" if ranges_on else
"ranges OFF (PSX_KERNEL_PATCH_RANGES=0, pre-change "
"behaviour)"), flush=True)
r = run_side(a, root, ranges_on)
rows.append(r)
print(" frame %(frame)s in %(wall_s)ss bless: %(bless_clean)s clean / "
"%(bless_mismatch)s mismatch of %(bless_entries)s "
"ranges=%(patch_ranges)s skips=%(patch_skips)s" % r, flush=True)
print(" interpreted: %(interp_insns_total)s total, "
"%(interp_insns_kernel)s kernel RAM" % r, flush=True)
for t in r["top_kernel_pcs"][:6]:
print(" %s insns=%-10s entries=%s"
% (t["pc"], t["insns"], t["entries"]), flush=True)
off, on = rows[0], rows[1]
print()
print("frames: OFF %s, ON %s; counts below are PER FRAME, since headless "
"runs uncapped" % (off["frame"], on["frame"]))
print("%-30s %14s %14s %9s" % ("", "ranges OFF", "ranges ON", "change"))
def line(label, key, per_frame=True):
o, n = off.get(key) or 0, on.get(key) or 0
if per_frame:
o = o / float(off["frame"] or 1)
n = n / float(on["frame"] or 1)
os_, ns_ = "%.1f" % o, "%.1f" % n
else:
os_, ns_ = str(o), str(n)
ch = ("%+.1f%%" % (100.0 * (n - o) / o)) if o else "n/a"
print("%-30s %14s %14s %9s" % (label, os_, ns_, ch))
line("kernel-bless mismatch", "bless_mismatch", False)
line("kernel-bless clean", "bless_clean", False)
line("interp insns / frame, kernel", "interp_insns_kernel")
line("interp insns / frame, all", "interp_insns_total")
print(" of which (diagnostic, not discounted):")
line(" kernel bodies", "interp_insns_kernel_bodies")
line(" A0/B0/C0 vectors", "interp_insns_vectors")
out = os.path.join(root, a.out)
os.makedirs(os.path.dirname(out), exist_ok=True)
json.dump({"frames": a.frames, "bios": a.bios, "dispatch": a.dispatch,
"declared_ranges": ["0x%04X..0x%04X" % r for r in pranges],
"sides": rows},
open(out, "w", encoding="utf-8"), indent=1)
print(chr(10) + "wrote " + a.out)
if __name__ == "__main__":
main()
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""kernel_patch_common.py - shared plumbing for the kernel install-slot tools.
Both kernel_patch_diff.py (which kernel-RAM words does this game patch?) and
kernel_patch_ab.py (what do the declared ranges buy?) run a GAME project
headless and talk to its debug server. This module holds the three things they
would otherwise each get subtly wrong: locating the project's exe, reading the
BIOS profile, and reading the emitted tables back out of the generated C.
Run both tools from a game project root (the directory holding game.toml and
the framework checkout), not from the framework:
python psxrecomp/tools/kernel_patch_diff.py
python psxrecomp/tools/kernel_patch_ab.py --bios psxrecomp/bios/SCPH1001.BIN
See docs/dynamic_handler_install.md for what the ranges are and how to find
them for a new image.
"""
import glob
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import debug_client
# Kernel RAM below this is the relocated BIOS copy (dirty_ram_interp.h's
# DIRTY_RAM_KERNEL_WINDOW_END).
KERNEL_WINDOW_END = 0x10000
# The four 16-byte kernel call vectors, reported on their own line by the A/B.
#
# They are NOT discounted from its headline. An earlier version subtracted them
# on the grounds that they interpret on both sides and would mask the result.
# That is true of some titles and false in general: on Mega Man X6 with
# OpenBIOS the B0 vector burns 2,361,470 instructions over 1,180,735 entries
# with the ranges off and vanishes entirely with them on, because once the
# bless verdict is clean the vector body dispatches native. Subtracting it hid
# a 130 insn/frame win.
VECTOR_STUBS = (0x80, 0xA0, 0xB0, 0xC0)
def add_project_args(ap, default_port):
"""The arguments every kernel-patch tool takes."""
ap.add_argument("--project-root", default=".",
help="game project root (holds game.toml); default cwd")
ap.add_argument("--framework", default="psxrecomp",
help="framework checkout, relative to --project-root")
ap.add_argument("--build-dir", default="build-relprof",
help="build tree to launch from, relative to --project-root")
ap.add_argument("--exe", default=None,
help="runtime exe; default: the single .exe in --build-dir")
ap.add_argument("--game", default="game.toml",
help="game config passed to --game")
ap.add_argument("--profile", default=None,
help="BIOS profile toml; default <framework>/bios/OpenBIOS.toml")
ap.add_argument("--dispatch", default=None,
help="generated <stem>_dispatch.c; default derived from --profile")
ap.add_argument("--bios", default=None,
help="BIOS image to launch with; default = the runtime's own pick")
ap.add_argument("--port", type=int, default=default_port)
ap.add_argument("--host", default="127.0.0.1")
def resolve(a):
"""Fill in the derived paths on a parsed args namespace. Returns the root."""
root = os.path.abspath(a.project_root)
fw = os.path.join(root, a.framework)
if not os.path.isdir(fw):
raise SystemExit("no framework checkout at %s (pass --framework)" % fw)
if a.profile is None:
a.profile = os.path.join(a.framework, "bios", "OpenBIOS.toml")
if a.dispatch is None:
stem = read_out_stem(os.path.join(root, a.profile))
a.dispatch = os.path.join(a.framework, "generated", stem + "_dispatch.c")
if a.exe is None:
a.exe = find_exe(os.path.join(root, a.build_dir))
for key in ("profile", "dispatch", "exe"):
p = os.path.join(root, getattr(a, key))
if not os.path.exists(p):
raise SystemExit("--%s not found: %s" % (key, p))
return root
def find_exe(build_dir):
"""The game's runtime exe. Its name is derived from the window title, so it
differs per title; glob rather than guess, and refuse to choose."""
if not os.path.isdir(build_dir):
raise SystemExit("no build dir at %s (pass --build-dir or --exe)" % build_dir)
found = [p for p in glob.glob(os.path.join(build_dir, "*.exe"))
if "_oracle" not in os.path.basename(p)]
if not found:
raise SystemExit("no .exe in %s - build psx-runtime first" % build_dir)
if len(found) > 1:
names = "\n ".join(os.path.basename(p) for p in found)
raise SystemExit("several exes in %s; pass --exe:\n %s" % (build_dir, names))
return found[0]
def read_out_stem(profile_path):
"""[recompiler] out_stem from a BIOS profile ("OpenBIOS", "SCPH1001")."""
txt = open(profile_path, encoding="utf-8").read()
m = re.search(r'^\s*out_stem\s*=\s*"([^"]+)"', txt, re.M)
if not m:
raise SystemExit("%s has no [recompiler] out_stem" % profile_path)
return m.group(1)
def parse_profile(path):
"""(rom_path, [(name, rom_lo, ram_lo, length, kernel_bless)]) from a profile."""
txt = open(path, encoding="utf-8").read()
rom = re.search(r'^rom\s*=\s*"([^"]+)"', txt, re.M).group(1)
copies = []
for m in re.finditer(r'\[\[recompiler\.address_model\.copy\]\](.*?)(?=\n\[|\Z)',
txt, re.S):
blk = m.group(1)
g = lambda k: re.search(r'^\s*%s\s*=\s*"?([^"\n]+)"?' % k,
blk, re.M).group(1).strip()
rom_lo = int(g("rom_lo"), 16)
rom_hi = int(g("rom_hi"), 16)
copies.append((g("name"), rom_lo, int(g("ram_lo"), 16),
rom_hi - rom_lo, "true" in g("kernel_bless")))
return rom, copies
def parse_bodies(dispatch_c):
"""[(key, body_lo, body_hi)] from the emitted PsxKernelBody table."""
txt = open(dispatch_c, encoding="utf-8", errors="replace").read()
m = re.search(r'psx_bios_kernel_bodies\[\d+\]\s*=\s*\{(.*?)\};', txt, re.S)
if not m:
return []
return [(int(a, 16), int(b, 16), int(c, 16)) for a, b, c in
re.findall(r'\{\s*0x([0-9A-Fa-f]+)u,\s*0x([0-9A-Fa-f]+)u,'
r'\s*0x([0-9A-Fa-f]+)u\s*\}', m.group(1))]
def parse_patch_ranges(dispatch_c):
"""[(lo, hi)] from the emitted PsxKernelPatchRange table.
The profile's [[recompiler.install_slots]], couriered into the generated C.
A differing word INSIDE one of these is a declared patch: the bless
verifier skips it and the body still runs native. A differing word outside
every range is what actually unblesses a body. Empty for a backend
generated before install-slot ranges existed.
"""
txt = open(dispatch_c, encoding="utf-8", errors="replace").read()
m = re.search(r'psx_bios_kernel_patch_ranges\[\d+\]\s*=\s*\{(.*?)\};', txt, re.S)
if not m:
return []
pairs = [(int(a, 16), int(b, 16)) for a, b in
re.findall(r'\{\s*0x([0-9A-Fa-f]+)u,\s*0x([0-9A-Fa-f]+)u\s*\}',
m.group(1))]
# An image with no slots emits one inert { 0, 0 } entry and a count of 0.
cnt = re.search(r'psx_bios_kernel_patch_range_count\s*=\s*(\d+)u', txt)
return pairs[:int(cnt.group(1))] if cnt else pairs
def q(cmd, host, port, timeout=120.0, **kw):
"""One debug-server command. Bulk commands need the long default."""
return debug_client.query(host, port, dict(cmd=cmd, **kw), timeout=timeout)
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""Which kernel-RAM words does this game patch at runtime?
Boots the game headless, then diffs live kernel RAM against the BIOS image's
boot-time copy (the [[recompiler.address_model.copy]] window with
kernel_bless = true) and reports every differing word, joined to the compiled
kernel bodies from the generated <stem>_dispatch.c. The output says which
FUNCTIONS carry a patch, and which of those still fail the bless check and so
run interpreted.
This is how you find [[recompiler.install_slots]] ranges for a new image or a
new SDK version. Run from a game project root:
python psxrecomp/tools/kernel_patch_diff.py # OpenBIOS
python psxrecomp/tools/kernel_patch_diff.py --at 5 --at 30 # two snapshots
python psxrecomp/tools/kernel_patch_diff.py --no-launch --port 4370
python psxrecomp/tools/kernel_patch_diff.py \
--bios psxrecomp/bios/SCPH1001.BIN \
--profile psxrecomp/bios/SCPH1001.toml
Snapshot twice. Some patches land at boot and some on the first card access,
and a range that only appears late is still a range.
Reading the output: a differing word inside a compiled body and OUTSIDE every
declared range is what unblesses that body. A word inside a declared range is
already handled - the verifier skips it and the body runs native anyway. A
word in no body at all is data (event tables, TCB saves, pad tables); it
unblesses nothing, so do not declare it. A word whose writer you cannot
identify is a word you should not declare either: the declaration tells the
verifier to stop checking it (CLAUDE.md rule 14).
See docs/dynamic_handler_install.md for the patch shapes, the resume kinds,
and how the three halves of the mechanism compose.
"""
import argparse
import json
import os
import struct
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import kernel_patch_common as kp
def wait_port(host, port, secs=180):
t0 = time.time()
while time.time() - t0 < secs:
try:
r = kp.q("frame", host, port, timeout=30.0)
if r.get("ok") or "frame" in r:
return time.time() - t0
except Exception:
pass
time.sleep(1)
raise SystemExit("debug port never came up")
def parse_seeds(path):
"""[(ram_addr, label)] for seeds, accepting the ROM-LMA form too."""
d = json.load(open(path, encoding="utf-8"))
rows = d if isinstance(d, list) else (d.get("functions") or d.get("seeds") or [])
return [(int(r["address"], 16) & 0x1FFFFFFF, r.get("label", "?"))
for r in rows if isinstance(r, dict)]
def main():
ap = argparse.ArgumentParser(description=__doc__.split(chr(10) + chr(10), 1)[0])
kp.add_project_args(ap, default_port=4371)
ap.add_argument("--seeds", default=None,
help="seeds json for function names; default from the profile")
ap.add_argument("--at", type=float, action="append", default=None,
help="seconds after the port is up to snapshot "
"(repeatable; default 8 and 30)")
ap.add_argument("--no-launch", action="store_true",
help="diff against an already-running game on --port")
ap.add_argument("--out", default="analysis/kernel_patch_diff.json")
a = ap.parse_args()
root = kp.resolve(a)
ats = a.at or [8.0, 30.0]
rom_rel, copies = kp.parse_profile(os.path.join(root, a.profile))
rom = open(os.path.join(root, a.framework, rom_rel), "rb").read()
bodies = kp.parse_bodies(os.path.join(root, a.dispatch))
pranges = kp.parse_patch_ranges(os.path.join(root, a.dispatch))
declared = lambda ram: any(lo <= ram < hi for lo, hi in pranges)
if pranges:
print("declared install-slot ranges (%s): %s"
% (os.path.basename(a.dispatch),
", ".join("0x%04X..0x%04X" % r for r in pranges)), flush=True)
else:
print("no install-slot ranges declared in %s - every patched word "
"unblesses its body" % os.path.basename(a.dispatch), flush=True)
if a.seeds is None:
import re
txt = open(os.path.join(root, a.profile), encoding="utf-8").read()
m = re.search(r'^\s*seeds\s*=\s*"([^"]+)"', txt, re.M)
a.seeds = os.path.join(a.framework, m.group(1)) if m else None
seeds = parse_seeds(os.path.join(root, a.seeds)) if a.seeds else []
# Map ROM-LMA seeds into RAM for each copy so a patched word can be named.
ram_names = {}
for name, rom_lo, ram_lo, ln, bless in copies:
for addr, label in seeds:
if rom_lo <= addr < rom_lo + ln:
ram_names[addr - rom_lo + ram_lo] = label
elif ram_lo <= addr < ram_lo + ln:
ram_names[addr] = label
def fn_of(ram):
best = [k for k in ram_names if k <= ram]
return ("%s+0x%X" % (ram_names[max(best)], ram - max(best))) if best else "?"
proc = None
if not a.no_launch:
env = dict(os.environ, PSX_STARVATION_TIMEOUT_US="0")
env.pop("PSX_LOAD_SLOT", None)
argv = [os.path.join(root, a.exe), "--game", a.game, "--no-launcher",
"--headless", "--debug-port", str(a.port)]
if a.bios:
argv += ["--bios", os.path.join(root, a.bios)]
proc = subprocess.Popen(argv, cwd=root, env=env,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
print("launched pid %d" % proc.pid, flush=True)
t_up = wait_port(a.host, a.port)
print("port up after %.1fs" % t_up, flush=True)
t0 = time.time()
report = {"copies": [], "snapshots": [],
"declared_ranges": ["0x%04X..0x%04X" % r for r in pranges]}
try:
for at in sorted(ats):
while time.time() - t0 < at:
time.sleep(0.5)
fr = kp.q("frame", a.host, a.port)
kb = kp.q("kernel_bless", a.host, a.port)
snap = {"t": round(time.time() - t0, 1), "frame": fr.get("frame"),
"kernel_bless": kb, "diffs": []}
print("\n=== t+%.0fs frame %s kernel_bless: %s"
% (at, fr.get("frame"),
{k: v for k, v in kb.items() if k not in ("id", "ok")}),
flush=True)
for name, rom_lo, ram_lo, ln, bless in copies:
if not bless:
continue # only the bless window gates native dispatch
live = bytes.fromhex(kp.q("read_ram", a.host, a.port,
addr="0x%08X" % ram_lo, len=ln)["hex"])
src = rom[rom_lo - 0x1FC00000: rom_lo - 0x1FC00000 + ln]
words = []
for off in range(0, ln - 3, 4):
if live[off:off + 4] != src[off:off + 4]:
ram = ram_lo + off
inb = [b for b in bodies if b[1] <= ram < b[2]]
words.append({
"ram": "0x%04X" % ram,
"rom": struct.unpack("<I", src[off:off + 4])[0],
"live": struct.unpack("<I", live[off:off + 4])[0],
"in_code_body": bool(inb),
"declared": declared(ram),
"fn": fn_of(ram)})
runs = []
for w in words:
r = int(w["ram"], 16)
if runs and r == runs[-1]["end"] and \
runs[-1]["in_code_body"] == w["in_code_body"]:
runs[-1]["end"] = r + 4
runs[-1]["n"] += 1
else:
runs.append({"start": r, "end": r + 4, "n": 1,
"in_code_body": w["in_code_body"],
"fn": w["fn"]})
code_words = sum(1 for w in words if w["in_code_body"])
undecl = sum(1 for w in words
if w["in_code_body"] and not w["declared"])
print("copy %-16s RAM 0x%04X..0x%04X: %d differing words, %d inside "
"compiled code bodies (%d declared, %d NOT declared), %d runs"
% (name, ram_lo, ram_lo + ln, len(words), code_words,
code_words - undecl, undecl, len(runs)), flush=True)
for r in runs:
tag = "CODE" if r["in_code_body"] else "data"
print(" %s 0x%04X..0x%04X (%d words) %s"
% (tag, r["start"], r["end"], r["n"], r["fn"]), flush=True)
if r["in_code_body"]:
for w in words:
if r["start"] <= int(w["ram"], 16) < r["end"]:
print(" %s rom %08X -> live %08X %s"
% (w["ram"], w["rom"], w["live"],
"declared" if w["declared"]
else "NOT DECLARED"), flush=True)
snap["diffs"].append({"copy": name, "words": words, "runs": runs})
report["snapshots"].append(snap)
# Only an UNDECLARED patched word unblesses a body.
dirty, saved = set(), set()
for sd in snap["diffs"]:
for w in sd["words"]:
r = int(w["ram"], 16)
for key, lo, hi in bodies:
if lo <= r < hi:
(saved if w["declared"] else dirty).add((lo, hi))
saved -= dirty # one undeclared word is enough to unbless
if saved:
print("compiled kernel bodies patched INSIDE a declared range "
"(these still run native):", flush=True)
for lo, hi in sorted(saved):
print(" 0x%04X..0x%04X %s" % (lo, hi, fn_of(lo)), flush=True)
if dirty:
print("compiled kernel bodies containing an UNDECLARED patched "
"word (these interpret):", flush=True)
for lo, hi in sorted(dirty):
print(" 0x%04X..0x%04X %s" % (lo, hi, fn_of(lo)), flush=True)
else:
print("no compiled kernel body carries an undeclared patched word",
flush=True)
snap["dirty_bodies"] = ["0x%04X..0x%04X %s" % (lo, hi, fn_of(lo))
for lo, hi in sorted(dirty)]
snap["blessed_patched_bodies"] = ["0x%04X..0x%04X %s" % (lo, hi, fn_of(lo))
for lo, hi in sorted(saved)]
finally:
if proc:
proc.kill()
out = os.path.join(root, a.out)
os.makedirs(os.path.dirname(out), exist_ok=True)
json.dump(report, open(out, "w", encoding="utf-8"), indent=1)
print("\nwrote " + a.out)
if __name__ == "__main__":
main()