From 7376e6b04280d053c66ac0908712a5cead031d7a Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 19 Jul 2026 12:11:56 -0700 Subject: [PATCH] Harden additive overlay shards and speed interpreter fetch --- recompiler/CMakeLists.txt | 15 +- recompiler/src/code_generator.cpp | 45 +- .../test_overlay_cross_page_delay_codegen.py | 125 ++++ .../tests/test_overlay_guard_codegen.py | 15 +- runtime/CMakeLists.txt | 12 + runtime/include/overlay_loader.h | 6 + runtime/include/overlay_posix.h | 5 +- runtime/include/psx_icache.h | 38 ++ runtime/src/autocompile.c | 71 ++- runtime/src/dirty_ram_interp.c | 3 +- runtime/src/gte.cpp | 8 +- runtime/src/main.cpp | 20 +- runtime/src/overlay_capture.c | 38 ++ runtime/src/overlay_loader.c | 583 +++++++++++++++--- runtime/src/overlay_posix.c | 17 +- runtime/src/psx_icache.c | 28 +- runtime/tests/run_overlay_posix_test.sh | 1 + runtime/tests/test_interpreter_perf_guards.py | 63 ++ runtime/tests/test_overlay_init_guard.py | 2 +- runtime/tests/test_overlay_posix.c | 13 +- runtime/tests/test_psx_icache_fastpath.c | 94 +++ tools/compile_overlays.py | 455 +++++++++++--- tools/test_compile_overlays_additive.py | 278 ++++++++- 23 files changed, 1684 insertions(+), 251 deletions(-) create mode 100644 recompiler/tests/test_overlay_cross_page_delay_codegen.py create mode 100644 runtime/include/psx_icache.h create mode 100644 runtime/tests/test_psx_icache_fastpath.c diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 6f5dca2e..e27468d3 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -242,9 +242,18 @@ if(BUILD_TESTING) CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON ) - add_test(NAME recompiler_patch_cxx17_header_test - COMMAND recompiler_patch_cxx17_header_test) -endif() + add_test(NAME recompiler_patch_cxx17_header_test + COMMAND recompiler_patch_cxx17_header_test) + + find_package(Python3 COMPONENTS Interpreter) + if(Python3_Interpreter_FOUND) + add_test( + NAME overlay_cross_page_delay_codegen + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_overlay_cross_page_delay_codegen.py + --recompiler $) + endif() +endif() # Reachable main-EXE discovery test: parser-bound validation plus exact-entry # analysis behavior for direct, indirect, seeded, and out-of-bound targets. diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 53fe4380..49e24071 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include namespace PSXRecomp { @@ -1517,6 +1518,22 @@ std::string CodeGenerator::translate_basic_block( std::string delay_saved_cond; // branch condition captured before delay std::string delay_saved_target; // JR/JALR target captured before delay + const uint32_t branch_opcode = + (block.exit_instr.instruction >> 26) & 0x3Fu; + const uint32_t branch_rt = + (block.exit_instr.instruction >> 16) & 0x1Fu; + if (branch_opcode >= 0x14u && branch_opcode <= 0x17u) { + throw std::runtime_error(fmt::format( + "cannot emit reserved R3000A branch-likely opcode at 0x{:08X}", + addr)); + } + if (branch_opcode == 0x01u && + branch_rt != 0x00u && branch_rt != 0x01u && + branch_rt != 0x10u && branch_rt != 0x11u) { + throw std::runtime_error(fmt::format( + "cannot emit unsupported REGIMM branch at 0x{:08X}", addr)); + } + // Per-instruction interlock ORDER (Beetle): the branch's §1+deps+DO_LDS // runs at the branch PC, THEN the delay slot's at PC+4. Emit the branch // step FIRST (it is pure timing — does not touch GPR values, so it is @@ -1566,7 +1583,22 @@ std::string CodeGenerator::translate_basic_block( uint32_t delay_slot_addr = addr + 4; auto delay_instr_opt = exe_.read_word(delay_slot_addr); - if (delay_instr_opt.has_value()) { + /* A native control transfer without its architectural delay + * slot is never a legal partial shard. In particular, a + * page-scoped overlay capture can end with the branch at + * ...FFC while the slot lives at ...000 in the next page. + * The old code silently skipped the unavailable word and + * still emitted the transfer, corrupting guest state while + * looking like a successful native call. Fail generation + * closed; the runtime interpreter remains authoritative + * until a capture containing the guard word is available. */ + if (!delay_instr_opt.has_value()) { + throw std::runtime_error(fmt::format( + "cannot emit control flow at 0x{:08X}: mandatory " + "delay slot 0x{:08X} is outside the input image", + addr, delay_slot_addr)); + } + { uint32_t delay_instr = *delay_instr_opt; // For branch-likely variants, delay slot is conditional @@ -3052,6 +3084,17 @@ std::string CodeGenerator::generate_ranges_manifest( (void)baddr; uint32_t lo = blk.start_addr; uint32_t hi = blk.end_addr + 4u; + /* translate_basic_block clones an out-of-block delay slot when the + * control-flow instruction itself is the block's last word. That + * cloned instruction is compiled code too: include it in the exact + * validity range so its bytes participate in the candidate CRC and + * page-generation watch. Otherwise a later write to the slot could + * leave stale native semantics callable. */ + if (blk.exit_instr.has_delay_slot && + blk.exit_instr.type != ControlFlowType::None && + blk.exit_instr.address == blk.end_addr && hi <= UINT32_MAX - 4u) { + hi += 4u; + } if (hi > lo) iv.emplace_back(lo, hi); } if (iv.empty()) continue; diff --git a/recompiler/tests/test_overlay_cross_page_delay_codegen.py b/recompiler/tests/test_overlay_cross_page_delay_codegen.py new file mode 100644 index 00000000..991bb01b --- /dev/null +++ b/recompiler/tests/test_overlay_cross_page_delay_codegen.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Regression for an overlay branch whose delay slot crosses a 4 KiB page. + +The complete input must emit and hash the delay slot. A page-truncated input +must fail code generation instead of publishing a native transfer that skips it. +""" + +import argparse +import os +import struct +import subprocess +import sys +import tempfile +from typing import Optional + + +LOAD = 0x80010FF0 +BRANCH_PC = 0x80010FFC +DELAY_PC = 0x80011000 + + +def make_psxexe(data: bytes) -> bytes: + header = bytearray(2048) + header[0:8] = b"PS-X EXE" + struct.pack_into(" int: + here = os.path.dirname(os.path.abspath(__file__)) + default_recompiler = os.path.normpath( + os.path.join(here, "..", "build", "psxrecomp-game.exe") + ) + parser = argparse.ArgumentParser() + parser.add_argument("--recompiler", default=default_recompiler) + args = parser.parse_args() + if not os.path.isfile(args.recompiler): + raise SystemExit(f"recompiler not found: {args.recompiler}") + + failures = [] + with tempfile.TemporaryDirectory() as root: + complete, complete_out = run_codegen(args.recompiler, True, root) + if complete.returncode != 0: + failures.append("complete cross-page function failed generation: " + + (complete.stderr or complete.stdout)) + else: + full_c = next((name for name in os.listdir(complete_out) + if name.endswith("_full.c")), None) + ranges = next((name for name in os.listdir(complete_out) + if name.endswith("_full.ranges")), None) + if not full_c or not ranges: + failures.append("complete generation omitted C or ranges output") + else: + with open(os.path.join(complete_out, full_c), encoding="utf-8") as f: + source = f.read() + with open(os.path.join(complete_out, ranges), encoding="utf-8") as f: + manifest = f.read() + if "0x80011000" not in source or "0x24020001" not in source: + failures.append("generated C does not execute the cross-page delay slot") + + covered = False + for line in manifest.splitlines(): + fields = line.split() + if len(fields) == 3 and fields[0] == "R": + lo = int(fields[1], 16) + length = int(fields[2], 16) + if lo <= DELAY_PC and DELAY_PC + 4 <= lo + length: + covered = True + if not covered: + failures.append("range manifest does not hash the cross-page delay slot") + + truncated, _ = run_codegen(args.recompiler, False, root) + combined = (truncated.stderr or "") + (truncated.stdout or "") + if truncated.returncode == 0: + failures.append("truncated input incorrectly generated a native shard") + elif "mandatory delay slot" not in combined: + failures.append("truncated generation failed without the delay-slot diagnostic") + + likely, _ = run_codegen( + args.recompiler, True, root, branch_word=0x50800008, + case_name="reserved_branch_likely") + likely_output = (likely.stderr or "") + (likely.stdout or "") + if likely.returncode == 0: + failures.append("reserved R3000A branch-likely opcode generated native code") + elif "branch-likely" not in likely_output: + failures.append("branch-likely rejection omitted its diagnostic") + + if failures: + for failure in failures: + print("FAIL:", failure) + return 1 + print("PASS: cross-page delay slot is emitted, identity-hashed, and truncation fails closed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/recompiler/tests/test_overlay_guard_codegen.py b/recompiler/tests/test_overlay_guard_codegen.py index b3c04ca2..83f93b07 100644 --- a/recompiler/tests/test_overlay_guard_codegen.py +++ b/recompiler/tests/test_overlay_guard_codegen.py @@ -69,11 +69,18 @@ def gen_c(recompiler, overlay, tmp): if r.returncode != 0: raise SystemExit(f"recompiler failed ({'overlay' if overlay else 'static'}):\n" f"{r.stderr or r.stdout}") - full = [f for f in os.listdir(out) if f.endswith("_full.c")] + # Overlay mode emits one *_full.c; static mode may shard it as + # *_full_00.c, *_full_01.c, ... . Inspect the complete generated body in + # either representation. + full = sorted(f for f in os.listdir(out) + if "_full" in f and f.endswith(".c")) if not full: - raise SystemExit(f"no _full.c emitted in {out}") - with open(os.path.join(out, full[0])) as f: - return f.read() + raise SystemExit(f"no _full*.c emitted in {out}") + chunks = [] + for name in full: + with open(os.path.join(out, name)) as f: + chunks.append(f.read()) + return "\n".join(chunks) def main(): diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index c3b013bc..22b95e6b 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1,5 +1,6 @@ cmake_minimum_required(VERSION 3.20) project(psxrecomp-runtime C CXX) +include(CTest) set(CMAKE_C_STANDARD 99) set(CMAKE_CXX_STANDARD 17) @@ -31,6 +32,17 @@ psxrecomp_add_runtime_target(psx-runtime DEFAULT_BIOS_PATH "${PSXRECOMP_ROOT}/bios/SCPH1001.BIN" ) +if(BUILD_TESTING) + add_executable(psx_icache_fastpath_test + tests/test_psx_icache_fastpath.c + src/psx_icache.c + ) + target_include_directories(psx_icache_fastpath_test PRIVATE include) + target_compile_definitions(psx_icache_fastpath_test PRIVATE + PSX_ENABLE_BLOCK_CYCLES=1) + add_test(NAME psx_icache_fastpath_test COMMAND psx_icache_fastpath_test) +endif() + if(NOT MSVC) psxrecomp_add_runtime_target(psx-oracle ORACLE diff --git a/runtime/include/overlay_loader.h b/runtime/include/overlay_loader.h index 3ac21fe7..d82d32ff 100644 --- a/runtime/include/overlay_loader.h +++ b/runtime/include/overlay_loader.h @@ -90,6 +90,12 @@ void overlay_loader_get_counters(uint32_t *loads, uint32_t *invalidations, int *regions, uint32_t *revalidations); 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. */ +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, + uint32_t *first_divergence_pc); void overlay_loader_get_reload_debug(int *r0_valid, uint32_t *r0_writes, uint32_t *r0_fn_lo, uint32_t *r0_fn_hi, diff --git a/runtime/include/overlay_posix.h b/runtime/include/overlay_posix.h index 39381a4f..ffcfc81a 100644 --- a/runtime/include/overlay_posix.h +++ b/runtime/include/overlay_posix.h @@ -7,11 +7,12 @@ extern "C" { #endif -/* Parsed _.{dll,so} cache filename. The parser is shared by the +/* Parsed legacy _ or immutable + * __.{dll,so} cache filename. The parser is shared by the * Windows and POSIX directory walkers so both platforms reject partial hex * fields and lookalike suffixes identically. */ typedef struct PsxOverlayCacheFile { - char name[22]; + char name[40]; char path[768]; uint32_t region_start; uint32_t content_crc; diff --git a/runtime/include/psx_icache.h b/runtime/include/psx_icache.h new file mode 100644 index 00000000..337b5d41 --- /dev/null +++ b/runtime/include/psx_icache.h @@ -0,0 +1,38 @@ +#ifndef PSXRECOMP_PSX_ICACHE_H +#define PSXRECOMP_PSX_ICACHE_H + +#include "cpu_state.h" + +#ifdef __cplusplus +extern "C" { +#endif + +extern uint32_t g_psx_icache_tv[1024]; +extern int g_psx_icache_active; +extern int g_ls_replay_active; +void psx_icache_fetch_miss(CPUState *cpu, uint32_t addr); + +/* Keep the interpreter's steady-state tag hit inside its translation unit. + * Misses use the shared slow path, preserving exact cache evolution/timing. */ +static inline void psx_icache_fetch_interp(CPUState *cpu, uint32_t addr) { +#ifdef PSX_ENABLE_BLOCK_CYCLES + if (g_ls_replay_active) return; + if (g_psx_icache_active < 0) { + psx_icache_fetch(cpu, addr); + return; + } + if (!g_psx_icache_active) return; + uint32_t idx = (addr & 0xFFCu) >> 2; + if (g_psx_icache_tv[idx] == addr) return; + psx_icache_fetch_miss(cpu, addr); +#else + (void)cpu; + (void)addr; +#endif +} + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/runtime/src/autocompile.c b/runtime/src/autocompile.c index fcaa48be..a64bdcd4 100644 --- a/runtime/src/autocompile.c +++ b/runtime/src/autocompile.c @@ -71,16 +71,17 @@ static HANDLE s_proc = NULL; #define AC_PUBLISH_CAP 128 static char s_publish_paths[AC_PUBLISH_CAP][768]; static unsigned s_publish_head = 0, s_publish_count = 0; -static unsigned s_publish_seen_run = 0, s_publish_drops_run = 0; +static unsigned s_publish_drops_run = 0; +static unsigned s_publish_load_fail_run = 0, s_publish_parse_fail_run = 0; static char s_child_line[1024]; static int s_child_line_len = 0; +static int s_child_line_overflow = 0; static void publish_line_locked(void) { static const char marker[] = "PSX_SHARD_PUBLISHED "; if (strncmp(s_child_line, marker, sizeof(marker) - 1) != 0) return; const char *path = s_child_line + sizeof(marker) - 1; if (!path[0]) return; - s_publish_seen_run++; if (s_publish_count >= AC_PUBLISH_CAP) { s_publish_drops_run++; return; @@ -96,10 +97,16 @@ static void publish_parse_locked(const char *buf, int n) { if (c == '\r') continue; if (c == '\n') { s_child_line[s_child_line_len] = '\0'; - publish_line_locked(); + if (s_child_line_overflow) + s_publish_parse_fail_run++; + else + publish_line_locked(); s_child_line_len = 0; + s_child_line_overflow = 0; } else if (s_child_line_len < (int)sizeof(s_child_line) - 1) { s_child_line[s_child_line_len++] = c; + } else { + s_child_line_overflow = 1; } } } @@ -155,6 +162,16 @@ static DWORD WINAPI watch_thread(LPVOID arg) { WaitForSingleObject(ctx->proc, INFINITE); DWORD code = (DWORD)-1; GetExitCodeProcess(ctx->proc, &code); + EnterCriticalSection(&s_out_lock); + /* A marker/result line cut off by child termination is not trustworthy. + * Completion will force the idempotent directory rescan instead. */ + if (s_child_line_len || s_child_line_overflow) { + s_publish_parse_fail_run++; + s_child_line_len = 0; + s_child_line_overflow = 0; + } + if (s_proc == ctx->proc) s_proc = NULL; + LeaveCriticalSection(&s_out_lock); CloseHandle(ctx->proc); HeapFree(GetProcessHeap(), 0, ctx); InterlockedExchange(&s_exit_code, (LONG)code); @@ -227,8 +244,14 @@ int autocompile_request(void) { #ifdef _WIN32 EnterCriticalSection(&s_out_lock); s_publish_head = s_publish_count = 0; - s_publish_seen_run = s_publish_drops_run = 0; + s_publish_drops_run = 0; + s_publish_load_fail_run = s_publish_parse_fail_run = 0; s_child_line_len = 0; + s_child_line_overflow = 0; + s_out_len = 0; + s_exit_code = -1; + s_shard_ok = s_shard_fail = s_shard_skipped = 0; + s_shard_result_seen = 0; LeaveCriticalSection(&s_out_lock); SECURITY_ATTRIBUTES sa = { sizeof(sa), NULL, TRUE }; HANDLE rd = NULL, wr = NULL; @@ -273,6 +296,10 @@ int autocompile_request(void) { WatchCtx *ctx = (WatchCtx *)HeapAlloc(GetProcessHeap(), 0, sizeof(*ctx)); if (!ctx) { + /* The child is already running. Leaving it detached would permit the + * state to remain IDLE and a second writer to enter the same cache. */ + TerminateProcess(pi.hProcess, ERROR_NOT_ENOUGH_MEMORY); + WaitForSingleObject(pi.hProcess, INFINITE); CloseHandle(rd); CloseHandle(pi.hProcess); s_fails++; @@ -290,6 +317,7 @@ int autocompile_request(void) { /* Without the watcher nobody drains stdout, observes completion, or * closes the process handle. Cancel the just-created child fail-closed. */ TerminateProcess(pi.hProcess, ERROR_NOT_ENOUGH_MEMORY); + WaitForSingleObject(pi.hProcess, INFINITE); CloseHandle(rd); CloseHandle(pi.hProcess); HeapFree(GetProcessHeap(), 0, ctx); @@ -329,17 +357,19 @@ static int parse_shard_result(void) { } if (!hit) return 0; unsigned ok = 0, failed = 0, skipped = 0; - /* Tolerant of field order: scan for each key independently within the line. */ const char *ln_end = strchr(hit, '\n'); size_t span = ln_end ? (size_t)(ln_end - hit) : strlen(hit); char line[256]; size_t cp = span < sizeof(line) - 1 ? span : sizeof(line) - 1; memcpy(line, hit, cp); line[cp] = '\0'; - const char *f; - if ((f = strstr(line, "ok="))) sscanf(f, "ok=%u", &ok); - if ((f = strstr(line, "failed="))) sscanf(f, "failed=%u", &failed); - if ((f = strstr(line, "skipped="))) sscanf(f, "skipped=%u", &skipped); + int consumed = 0; + if (sscanf(line, "PSX_SHARD_RESULT ok=%u failed=%u skipped=%u %n", + &ok, &failed, &skipped, &consumed) != 3) + return 0; + for (const char *tail = line + consumed; *tail; tail++) { + if (*tail != ' ' && *tail != '\t' && *tail != '\r') return 0; + } s_shard_ok = ok; s_shard_fail = failed; s_shard_skipped = skipped; @@ -356,7 +386,8 @@ void autocompile_poll_main(void) { #ifdef _WIN32 char published[768]; if (publish_pop(published, (int)sizeof(published))) { - (void)overlay_loader_load_published(published); + if (overlay_loader_load_published(published) <= 0) + s_publish_load_fail_run++; s_rescans++; } if (ac_state_load() == AC_RUNNING) return; @@ -366,23 +397,19 @@ void autocompile_poll_main(void) { if (publish_pending()) return; #endif ac_state_store(AC_IDLE); - /* New producers report each DLL directly; a full rescan is only the - * compatibility/overflow fallback. The loader is - * idempotent — rescanning after a failed compile is harmless. */ -#ifdef _WIN32 - if (s_publish_seen_run == 0 || s_publish_drops_run != 0) { - overlay_loader_rescan(); - s_rescans++; - } -#else + s_shard_result_seen = parse_shard_result(); + /* Direct markers minimize handoff latency, then one idempotent batch-end + * rescan makes every successfully published artifact visible to additive + * cache queries and the lazy manifest index. This is intentionally once per + * compiler run, never once per DLL or interpreted instruction. */ overlay_loader_rescan(); s_rescans++; -#endif - s_shard_result_seen = parse_shard_result(); /* A run "failed" if the process exited non-zero OR it reported shard * failures. compile_overlays.py exits 2 when any shard that should have * built failed, so these usually agree; the parsed count is the detail. */ - if (s_exit_code != 0 || (s_shard_result_seen && s_shard_fail > 0)) + if (s_exit_code != 0 || !s_shard_result_seen || s_shard_fail > 0 || + s_publish_drops_run != 0 || s_publish_load_fail_run != 0 || + s_publish_parse_fail_run != 0) s_fails++; } diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 6021f1e9..c886d440 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -28,6 +28,7 @@ #include "debug_server.h" #include "interrupts.h" #include "psx_cycles.h" +#include "psx_icache.h" #include "psx_instr_cost.h" /* psx_instr_base_cycles — single-source cycle cost */ #include "gpu.h" /* psx_ws_is_backdrop_site / psx_ws_backdrop_x (interp hook) */ #include "ws_backdrop_detect.h" /* shared backdrop-window detector (auto_backdrop) */ @@ -1188,7 +1189,7 @@ static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn, /* Instruction FETCH cost (I-cache) — charged FIRST, before the §1 base, exactly * like Beetle ReadInstruction precedes the per-instruction base (cpu.cpp). HIT=+0, * KSEG1=+4, cached miss=+3+refill; a miss also clears the load give-back. */ - psx_icache_fetch(cpu, pc); + psx_icache_fetch_interp(cpu, pc); /* Per-instruction R3000A load-delay interlock (single-source: psx_cyc.h, shared * with both static emitters). §1 base + GPR_DEPRES + DO_LDS run HERE, before the diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index b6471ac6..fa8ec1ef 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -1429,8 +1429,10 @@ extern "C" uint64_t gte_get_exec_count(void) { return s_gte_exec_count; } extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { using namespace PSXRecomp::GTE; +#ifndef PSX_NO_DEBUG_TOOLS s_gte_exec_count++; s_gte_caller_ra = cpu->gpr[31]; /* dome-locate probe: game fn that issued this projection */ +#endif GTEState gte; // Skip reg 15 (SXYP: push-write, would corrupt SXY FIFO) and @@ -1447,8 +1449,9 @@ extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { * (no-op unless the game opts in — early-out on the config flag). */ if (func == 0x01 || func == 0x30) psx_ws_note_gte_project(func == 0x30 ? 3 : 1); +#ifndef PSX_NO_DEBUG_TOOLS /* INTPL ring: snapshot inputs before the op mutates IR (outputs recorded - * after the switch). */ + * after the switch). Debug tooling only; production does no probe copies. */ int16_t intpl_pre_ir[4] = {0,0,0,0}; int32_t intpl_pre_fc[3] = {0,0,0}; if (func == 0x11) { @@ -1456,6 +1459,7 @@ extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { intpl_pre_ir[2]=gte.IR2; intpl_pre_ir[3]=gte.IR3; intpl_pre_fc[0]=gte.FC[0]; intpl_pre_fc[1]=gte.FC[1]; intpl_pre_fc[2]=gte.FC[2]; } +#endif switch (func) { case 0x01: gte_rtps(>e, cmd); break; case 0x06: gte_nclip(>e, cmd); break; @@ -1484,8 +1488,10 @@ extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { break; } +#ifndef PSX_NO_DEBUG_TOOLS if (func == 0x01 || func == 0x30) gte_rtp_record(>e, cmd); if (func == 0x11) gte_intpl_record(>e, intpl_pre_ir, intpl_pre_fc); +#endif for (int i = 0; i < 32; i++) cpu->gte_data[i] = gte_mfc2(>e, i); for (int i = 0; i < 32; i++) cpu->gte_ctrl[i] = gte_cfc2(>e, i); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index e8abc542..bfca6e49 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1177,6 +1177,11 @@ struct RuntimePerfSnapshot { uint64_t overlay_interp = 0; uint64_t overlay_stale = 0; uint32_t overlay_revalidations = 0; + uint32_t overlay_hot_native_pc = 0; + uint64_t overlay_hot_native_calls = 0; + uint64_t overlay_shadow_calls = 0; + uint64_t overlay_shadow_divergences = 0; + uint32_t overlay_first_divergence_pc = 0; uint32_t capture_triggers = 0; uint64_t capture_last_dispatch_delta = 0; int capture_overlays = 0; @@ -1275,6 +1280,11 @@ static RuntimePerfSnapshot runtime_perf_snapshot(uint64_t now) { &s.overlay_interp, &s.overlay_stale, nullptr, nullptr, nullptr, nullptr, &s.overlay_revalidations); + overlay_loader_take_hot_native(&s.overlay_hot_native_pc, + &s.overlay_hot_native_calls); + overlay_loader_get_shadow_summary(&s.overlay_shadow_calls, + &s.overlay_shadow_divergences, + &s.overlay_first_divergence_pc); int capture_enabled = 0; overlay_autocapture_get_status(&capture_enabled, &s.capture_triggers, &s.capture_last_dispatch_delta); @@ -1410,7 +1420,9 @@ 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 loads=+%u revalidations=+%u " + "overlay native=+%llu interp=+%llu hot_native=0x%08X/+%llu " + "shadow=+%llu div=+%llu first_div=0x%08X " + "loads=+%u revalidations=+%u " "load_wall=%.1f ms max=%.1f last=%.1f ms; " "capture triggers=+%u overlays=+%d last_dispatch_delta=%llu\n", (double)(current.frame - last.frame) / dt, @@ -1434,6 +1446,12 @@ static void runtime_perf_diag_tick() { (double)(current.dirty_dispatches - last.dirty_dispatches) / dt, (unsigned long long)(current.overlay_native - last.overlay_native), (unsigned long long)(current.overlay_interp - last.overlay_interp), + current.overlay_hot_native_pc, + (unsigned long long)current.overlay_hot_native_calls, + (unsigned long long)(current.overlay_shadow_calls - last.overlay_shadow_calls), + (unsigned long long)(current.overlay_shadow_divergences - + last.overlay_shadow_divergences), + current.overlay_first_divergence_pc, current.overlay_loads - last.overlay_loads, current.overlay_revalidations - last.overlay_revalidations, (double)(overlay_load_us - last_overlay_load_us) / 1000.0, diff --git a/runtime/src/overlay_capture.c b/runtime/src/overlay_capture.c index 37cfcab3..bd585bde 100644 --- a/runtime/src/overlay_capture.c +++ b/runtime/src/overlay_capture.c @@ -206,6 +206,19 @@ static void write_json_window(FILE *f, uint32_t win_lo_page, } else if (!dirty && in_run) { uint32_t phys = run_start * page_sz; uint32_t size = (page - run_start) * page_sz; + /* Carry one coherent guard instruction beyond a dirty-page run. + * A MIPS branch/jump at the final word of the run (...FFC) always + * executes its delay slot in the next page (...000). Supplying + * that word lets overlay codegen emit exact semantics and lets its + * range manifest hash/watch the slot. Keep the established region + * start. A four-byte overlap across an artificial kernel/boot or + * boot/overlay capture-window boundary is required: those windows + * stabilize region keys, but they are not MIPS execution barriers. + * Adjacent dirty pages were already folded into this run. */ + const uint32_t ram_size = 2u * 1024u * 1024u; + if (phys <= ram_size && size <= ram_size - phys && + phys + size <= ram_size - 4u) + size += 4u; uint32_t virt = 0x80000000u | (phys & 0x1FFFFFu); in_run = 0; @@ -717,6 +730,31 @@ static AutocapWriteJob *capture_snapshot_create(uint32_t scope_lo, if (observed) job->bitmap[page >> 5] |= 1u << (page & 31u); } + /* Preserve a one-page EXECUTED halo on both sides of a scoped DMA + * overwrite. The overwritten page can contain either the ...FFC + * control transfer or the ...000 delay slot; the paired instruction + * may live in the neighboring page and must come from this same + * coherent pre-write RAM snapshot. Only executed neighbors qualify, + * so a sector overwrite cannot pull unrelated dirty history into an + * unbounded capture. Evidence clearing remains limited to the pages + * actually overwritten by DMA. */ + uint32_t halo_lo = first_page > 0u ? first_page - 1u : first_page; + uint32_t ram_pages = (uint32_t)(ram_size >> 12); + uint32_t halo_hi = last_page + 1u < ram_pages + ? last_page + 1u : last_page; + for (uint32_t page = halo_lo; page <= halo_hi; page++) { + if (page >= first_page && page <= last_page) continue; + uint32_t first_exec_word = page * (4096u / 4u); + int observed = 0; + for (uint32_t b = 0; b < 4096u / 4u / 32u; b++) { + if (job->exec_pc_bitmap[(first_exec_word >> 5) + b]) { + observed = 1; + break; + } + } + if (observed) + job->bitmap[page >> 5] |= 1u << (page & 31u); + } } else { for (uint32_t i = 0; i < bw; i++) job->bitmap[i] = dirty_ram_get_bitmap_word(i); diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index 77611882..347ddbe5 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -13,6 +13,7 @@ #include #include #include +#include extern void overlay_watch_set_range(uint32_t phys, uint32_t len); @@ -74,11 +75,15 @@ typedef struct { uint32_t val_gen; /* pagegen sum when last (in)validated*/ int state; /* ENTRY_VALID/INVALID/BLACKLIST */ int dll; /* source DLL index */ + uint8_t tier; /* gcc=2, tcc=1, unknown=0 */ int next; /* next candidate at same addr, -1 end*/ uint32_t diff_passes; /* clean same-state diffs (verify budget)*/ int device_touch; /* 1 = touches MMIO; never run its shard, * always interp (shadow diff can't safely * double-execute device I/O). */ +#ifndef PSX_NO_DEBUG_TOOLS + uint32_t native_rank; /* first-seen identity rank for bisection */ +#endif } Candidate; /* Same-state differential verify budget: diff a candidate this many times with @@ -111,8 +116,9 @@ static int s_range_index_overflow = 0; * hundreds of historical, currently-invalid owners in a reused region. */ #define RANGE_PC_CACHE_CAP 16384u #define RANGE_PC_CACHE_MASK (RANGE_PC_CACHE_CAP - 1u) -typedef struct { uint32_t phys; int cand; } RangePcCache; +typedef struct { uint32_t phys, generation; int cand; } RangePcCache; static RangePcCache s_range_pc_cache[RANGE_PC_CACHE_CAP]; +static uint32_t s_range_candidate_generation = 1u; static void range_index_add_candidate(int ci) { Candidate *c = &s_cand[ci]; @@ -364,6 +370,34 @@ static uint64_t s_load_last_us = 0; static int s_valid_count = 0; /* candidates currently VALID */ static uint64_t s_disp_native = 0; static uint64_t s_disp_interp = 0; +/* Small diagnostic-only native-owner sampler. A logical softlock can continue + * presenting at 60 Hz while executing a tiny bad native loop, so FPS alone is + * not correctness evidence. This is enabled only with the existing runtime + * perf environment switch and is drained once per telemetry interval. */ +#define NATIVE_HOT_CAP 256u +typedef struct { uint32_t pc; uint64_t calls; } NativeHot; +static NativeHot s_native_hot[NATIVE_HOT_CAP]; +static int s_native_hot_enabled = 0; + +#ifndef PSX_NO_DEBUG_TOOLS +/* Correctness localization without speculative/double guest execution. When + * PSX_NATIVE_RANK_LIMIT is present, each concrete loaded candidate is assigned + * a first-seen rank. Candidates through the limit execute normally; later ones + * fall back to the authoritative interpreter. Binary-searching the limit finds + * the first candidate identity required for a deterministic softlock while + * preserving exactly one guest execution path per run. Debug builds only. */ +static uint32_t s_native_rank_limit = UINT32_MAX; +static uint32_t s_native_rank_next = 0; +static uint64_t s_native_rank_blocked = 0; +#endif + +static void native_hot_note(uint32_t pc) { + if (!s_native_hot_enabled) return; + uint32_t slot = ((pc >> 2) * 2654435761u) & (NATIVE_HOT_CAP - 1u); + NativeHot *h = &s_native_hot[slot]; + if (h->pc != pc) { h->pc = pc; h->calls = 0; } + h->calls++; +} static uint64_t s_stale_blocked = 0; /* dispatches skipped (candidate !valid)*/ static uint32_t s_invalidations = 0; /* VALID -> INVALID transitions */ static uint32_t s_revalidations = 0; /* INVALID -> VALID (reload-on-return) */ @@ -516,8 +550,8 @@ void overlay_loader_static_match_stats(uint64_t *rehashes, #endif /* ---- Per-DLL code-range manifest --------------------------------------- */ -/* Minimal line format emitted by tools/compile_overlays.py beside each DLL: - * F start a function (entry = virtual export addr) +/* Strict v2 line format emitted by tools/compile_overlays.py beside each DLL: + * F start a function (entry = virtual export addr) * R a code byte-range (virtual addr, byte length) * The recompiler's per-function instruction walk yields exactly the executed * code bytes — interleaved jump tables / constant pools are excluded, which is @@ -531,6 +565,32 @@ typedef struct { int n; } ManFn; +static int man_structurally_valid(const ManFn *m) { + const uint32_t ram_size = 2u * 1024u * 1024u; + if (!m || !m->has_crc || m->n < 1 || m->n > MAX_CODE_RANGES) + return 0; + if ((m->entry & 0xFFE00000u) != 0x80000000u) return 0; + uint32_t entry = m->entry & 0x1FFFFFFFu; + if ((entry & 3u) != 0u || entry >= ram_size) return 0; + int entry_covered = 0; + for (int r = 0; r < m->n; r++) { + if ((m->lo[r] & 0xFFE00000u) != 0x80000000u) return 0; + uint32_t lo = m->lo[r] & 0x1FFFFFFFu; + uint32_t len = m->len[r]; + if ((lo & 3u) != 0u || (len & 3u) != 0u || len < 4u || + lo >= ram_size || len > ram_size - lo) + return 0; + if (entry >= lo && entry - lo <= len - 4u) entry_covered = 1; + for (int p = 0; p < r; p++) { + uint32_t prev_lo = m->lo[p] & 0x1FFFFFFFu; + uint32_t prev_hi = prev_lo + m->len[p]; + uint32_t hi = lo + len; + if (lo < prev_hi && prev_lo < hi) return 0; + } + } + return entry_covered; +} + static ManFn *parse_manifest(const char *path, int *out_n) { *out_n = 0; FILE *f = fopen(path, "r"); @@ -540,34 +600,50 @@ static ManFn *parse_manifest(const char *path, int *out_n) { if (!arr) { fclose(f); return NULL; } char line[160]; ManFn *cur = NULL; + int invalid = 0; while (fgets(line, sizeof(line), f)) { - if (line[0] == 'F') { + if (!strchr(line, '\n') && !feof(f)) { invalid = 1; break; } + char *p = line; + while (*p && isspace((unsigned char)*p)) p++; + if (!*p || *p == '#') continue; + if (*p == 'F') { + if (cur && !man_structurally_valid(cur)) { invalid = 1; break; } uint32_t e = 0, crc = 0; - int got = sscanf(line + 1, "%x %x", &e, &crc); - if (got >= 1) { + char extra = 0; + int got = sscanf(p + 1, " %x %x %c", &e, &crc, &extra); + if (got == 2) { + if (n >= CAND_CAP) { invalid = 1; break; } + for (int i = 0; i < n; i++) { + if ((arr[i].entry & 0x1FFFFFFFu) == + (e & 0x1FFFFFFFu)) { invalid = 1; break; } + } + if (invalid) break; if (n >= cap) { cap *= 2; ManFn *na = (ManFn *)realloc(arr, sizeof(ManFn) * cap); - if (!na) break; + if (!na) { invalid = 1; break; } arr = na; } cur = &arr[n++]; cur->entry = e; cur->crc = crc; - cur->has_crc = (got >= 2) ? 1 : 0; + cur->has_crc = 1; cur->n = 0; - } - } else if (line[0] == 'R' && cur) { + } else { invalid = 1; break; } + } else if (*p == 'R' && cur) { uint32_t lo, len; - if (sscanf(line + 1, "%x %x", &lo, &len) == 2 && - cur->n < MAX_CODE_RANGES && len > 0) { + char extra = 0; + if (sscanf(p + 1, " %x %x %c", &lo, &len, &extra) == 2 && + cur->n < MAX_CODE_RANGES) { cur->lo[cur->n] = lo; cur->len[cur->n] = len; cur->n++; - } - } + } else { invalid = 1; break; } + } else { invalid = 1; break; } } + if (!invalid && (n == 0 || !cur || !man_structurally_valid(cur))) invalid = 1; fclose(f); + if (invalid) { free(arr); return NULL; } *out_n = n; return arr; } @@ -578,16 +654,97 @@ static ManFn *man_find(ManFn *arr, int n, uint32_t entry) { return NULL; } +/* 1 = supported R3000A control transfer with a delay slot, 0 = ordinary + * instruction, -1 = reserved/unsupported branch encoding that native shards + * must never claim. */ +static int mips_control_kind(uint32_t instr) { + uint32_t op = instr >> 26; + if (op == 0u) { + uint32_t funct = instr & 0x3Fu; + return funct == 0x08u || funct == 0x09u; /* JR / JALR */ + } + if (op == 0x01u) { /* REGIMM */ + uint32_t rt = (instr >> 16) & 0x1Fu; + return (rt == 0x00u || rt == 0x01u || + rt == 0x10u || rt == 0x11u) ? 1 : -1; + } + if (op == 0x02u || op == 0x03u || + (op >= 0x04u && op <= 0x07u)) /* J/JAL/branches */ + return 1; + if (op >= 0x14u && op <= 0x17u) return -1; /* branch-likely: reserved */ + if (op >= 0x10u && op <= 0x13u && + ((instr >> 21) & 0x1Fu) == 0x08u) return -1; /* unsupported BCz* */ + return 0; +} + +static int ranges_contain_word(const uint32_t *lo_list, + const uint32_t *len_list, int n, + uint32_t phys) { + if ((phys & 3u) != 0u || phys > (2u * 1024u * 1024u) - 4u) return 0; + for (int r = 0; r < n; r++) { + uint32_t lo = lo_list[r] & 0x1FFFFFFFu; + uint32_t len = len_list[r]; + if (len >= 4u && phys >= lo && phys - lo <= len - 4u) return 1; + } + return 0; +} + +/* Defense in depth for caches generated before guarded capture/codegen. Called + * only after the range CRC matches. Scan every hashed instruction so a missing + * slot at any range edge, an unsupported branch form, or a nested transfer in a + * delay slot rejects the whole native identity. */ +static int ranges_delay_slots_hashed(const uint32_t *lo_list, + const uint32_t *len_list, int n) { + const uint8_t *ram = memory_get_ram_ptr(); + const uint32_t ram_size = 2u * 1024u * 1024u; + if (!ram || n < 1 || n > MAX_CODE_RANGES) return 0; + for (int r = 0; r < n; r++) { + uint32_t lo = lo_list[r] & 0x1FFFFFFFu; + uint32_t len = len_list[r]; + if ((lo & 3u) != 0u || (len & 3u) != 0u || len < 4u || + lo >= ram_size || len > ram_size - lo) + return 0; + for (uint32_t pc = lo; pc < lo + len; pc += 4u) { + uint32_t instr = (uint32_t)ram[pc] | + ((uint32_t)ram[pc + 1u] << 8) | + ((uint32_t)ram[pc + 2u] << 16) | + ((uint32_t)ram[pc + 3u] << 24); + int kind = mips_control_kind(instr); + if (kind < 0) return 0; + if (kind > 0) { + uint32_t slot = pc + 4u; + if (!ranges_contain_word(lo_list, len_list, n, slot)) return 0; + uint32_t slot_instr = (uint32_t)ram[slot] | + ((uint32_t)ram[slot + 1u] << 8) | + ((uint32_t)ram[slot + 2u] << 16) | + ((uint32_t)ram[slot + 3u] << 24); + if (mips_control_kind(slot_instr) != 0) return 0; + } + } + } + return 1; +} + +static int man_delay_slots_hashed(const ManFn *m) { + return ranges_delay_slots_hashed(m->lo, m->len, m->n); +} + +static int cand_delay_slots_hashed(const Candidate *c) { + return ranges_delay_slots_hashed(c->range_lo, c->range_len, c->nranges); +} + /* ---- Candidate registration -------------------------------------------- */ static void loader_log(const char *fmt, ...); /* defined below */ -static void cand_register(uint32_t phys, OverlayFn fn, const ManFn *m, int dll) { - if (s_cand_n >= CAND_CAP) return; +static void cand_register(uint32_t phys, OverlayFn fn, const ManFn *m, int dll, + int tier) { + if (s_cand_n >= CAND_CAP || !man_structurally_valid(m)) return; int idx = s_cand_n++; Candidate *c = &s_cand[idx]; c->addr = phys; c->fn = fn; c->dll = dll; + c->tier = (uint8_t)tier; c->state = ENTRY_VALID; c->nranges = 0; for (int i = 0; i < m->n && c->nranges < MAX_CODE_RANGES; i++) { @@ -606,17 +763,29 @@ static void cand_register(uint32_t phys, OverlayFn fn, const ManFn *m, int dll) * present yet, and other overlays sharing the merged DLL are not present at * all). A candidate is callable iff live RAM matches this compiled-from hash, * which makes validity timing-independent and reload-on-return work. */ - if (m->has_crc) { - c->crc_code = m->crc; - } else { - c->crc_code = cand_crc(c); /* legacy manifest without hashes */ - } + c->crc_code = m->crc; c->val_gen = cand_gensum(c); - c->state = (cand_crc(c) == c->crc_code) ? ENTRY_VALID : ENTRY_INVALID; - c->next = idx_head(phys); - idx_set_head(phys, idx); + c->state = (cand_crc(c) == c->crc_code && cand_delay_slots_hashed(c)) + ? ENTRY_VALID : ENTRY_INVALID; + /* Keep higher-priority compiler tiers first without discarding additive + * artifacts. New same-tier repairs precede older ones; lower-tier TCC + * remains available if every GCC candidate fails live-byte validation. */ + int head = idx_head(phys); + if (head < 0 || s_cand[head].tier <= c->tier) { + c->next = head; + idx_set_head(phys, idx); + } else { + int prev = head; + while (s_cand[prev].next >= 0 && + s_cand[s_cand[prev].next].tier > c->tier) + prev = s_cand[prev].next; + c->next = s_cand[prev].next; + s_cand[prev].next = idx; + } exact_entry_set(phys); range_index_add_candidate(idx); + s_range_candidate_generation++; + if (s_range_candidate_generation == 0u) s_range_candidate_generation = 1u; if (c->state == ENTRY_VALID) s_valid_count++; /* (sljit removed 2026-07-15: the gcc-DLL-obsoletes-older-sljit-shard block * lived here. With no sljit shards ever registered (dll == -1), there is @@ -653,16 +822,88 @@ const char *overlay_loader_last_msg(void) { return s_last_msg; } * (region ran interpreted forever, no diagnostic). Found by the ABI-sweep * negative test. scan_one_cache_dir now shouts if even 4096 is hit. */ #define CACHE_IDX_CAP 4096 +enum { CACHE_TIER_UNKNOWN = 0, CACHE_TIER_TCC = 1, CACHE_TIER_GCC = 2 }; typedef struct { uint32_t region_start; + uint32_t logical_crc; uint64_t mtime; int func_count; int indexed_func_count; + uint8_t tier; + uint8_t manifest_ok; + uint8_t load_failed; char path[768]; } CacheEntry; static CacheEntry s_cache_idx[CACHE_IDX_CAP]; static int s_cache_idx_count = 0; +static int path_component_eq(const char *path, const char *wanted) { + size_t wanted_len = strlen(wanted); + const char *p = path; + while (*p) { + while (*p == '/' || *p == '\\') p++; + const char *start = p; + while (*p && *p != '/' && *p != '\\') p++; + size_t len = (size_t)(p - start); + if (len == wanted_len) { + size_t i = 0; + for (; i < len; i++) { + unsigned char a = (unsigned char)start[i]; + unsigned char b = (unsigned char)wanted[i]; + if (tolower(a) != tolower(b)) break; + } + if (i == len) return 1; + } + } + return 0; +} + +static int cache_tier_from_path(const char *path) { + if (path_component_eq(path, "gcc")) return CACHE_TIER_GCC; + if (path_component_eq(path, "tcc")) return CACHE_TIER_TCC; + return CACHE_TIER_UNKNOWN; +} + +static int cache_name_is_immutable(const char *name) { + return name && strlen(name) == 26u + OVERLAY_SHARED_EXT_LEN && + name[8] == '_' && name[17] == '_'; +} + +static int cache_path_char_equal(unsigned char a, unsigned char b) { +#ifdef _WIN32 + if ((a == '/' || a == '\\') && (b == '/' || b == '\\')) return 1; + return tolower(a) == tolower(b); +#else + return a == b; +#endif +} + +static int cache_path_equal(const char *a, const char *b) { + if (!a || !b) return 0; + while (*a && *b) { + if (!cache_path_char_equal((unsigned char)*a, (unsigned char)*b)) + return 0; + a++; + b++; + } + return *a == '\0' && *b == '\0'; +} + +static int cache_path_has_root(const char *path, const char *root) { + if (!path || !root) return 0; + size_t root_len = strlen(root); + while (root_len > 0u && + (root[root_len - 1u] == '/' || root[root_len - 1u] == '\\')) + root_len--; + for (size_t i = 0; i < root_len; i++) { + if (!path[i] || + !cache_path_char_equal((unsigned char)path[i], + (unsigned char)root[i])) + return 0; + } + return path[root_len] == '/' || path[root_len] == '\\'; +} + #ifdef _WIN32 /* Image-only, bounded mapping for small recovery/fragment DLLs. Windows can * spend ~100 ms in its loader/security path on the first LoadLibrary, enough to @@ -876,6 +1117,9 @@ static void rebuild_lazy_manifest_index(void) { s_lazy_page_tail[i] = -1; } for (int ci = 0; ci < s_cache_idx_count; ci++) { + s_cache_idx[ci].func_count = 0; + s_cache_idx[ci].indexed_func_count = 0; + s_cache_idx[ci].manifest_ok = 0; char path[800]; snprintf(path, sizeof(path), "%s", s_cache_idx[ci].path); size_t n = strlen(path); @@ -887,8 +1131,8 @@ static void rebuild_lazy_manifest_index(void) { int man_n = 0; ManFn *man = parse_manifest(path, &man_n); if (!man) continue; + s_cache_idx[ci].manifest_ok = 1; s_cache_idx[ci].func_count = man_n; - s_cache_idx[ci].indexed_func_count = 0; for (int mi = 0; mi < man_n; mi++) { if (!man[mi].has_crc || man[mi].n <= 0) continue; uint32_t entry = man[mi].entry & 0x1FFFFFFFu; @@ -954,23 +1198,13 @@ static void rebuild_lazy_manifest_index(void) { static int cache_idx_has_path(const char *path) { for (int i = 0; i < s_cache_idx_count; i++) - if (strcmp(s_cache_idx[i].path, path) == 0) return 1; - return 0; -} - -/* Dedup by FILENAME (_.dll = region+crc). Used so a tcc/ shard for a - * region already covered by a gcc/ shard is skipped: scan_cache_dir scans gcc/ - * FIRST, so gcc always wins the tie (tier order gcc > tcc). Also subsumes the - * per-path rescan-idempotence check (same dir => same filename). */ -static int cache_idx_has_basename(const char *fname) { - for (int i = 0; i < s_cache_idx_count; i++) { - const char *b = strrchr(s_cache_idx[i].path, '/'); - b = b ? b + 1 : s_cache_idx[i].path; - if (strcmp(b, fname) == 0) return 1; - } + if (cache_path_equal(s_cache_idx[i].path, path)) return 1; return 0; } +/* Cache discovery is idempotent only by full path. Logical identities may have + * multiple immutable artifacts; validation and tier-aware selection happen + * after every artifact is indexed. */ /* Canonical cache arch-abi tag (caches are namespaced per backend AND per * target so a Windows-x64 gcc DLL and, later, a same-OS arm64 build for the * same fragment never comingle). compile_overlays.py @@ -998,19 +1232,28 @@ const char *overlay_loader_arch_abi(void) { return PSX_OVERLAY_ARCH_ABI; } #ifndef _WIN32 static int add_posix_cache_file(const PsxOverlayCacheFile *file, void *opaque) { - (void)opaque; + int tier = opaque ? *(const int *)opaque : CACHE_TIER_UNKNOWN; + /* Legacy 8_8 pairs were published by a replace protocol that could leave a + * new manifest beside an old DLL. Keep them on disk for Python seed + * migration, but never let them suppress or execute instead of a bound + * immutable 8_8_8 repair. */ + if (!cache_name_is_immutable(file->name)) return 0; if (s_cache_idx_count >= CACHE_IDX_CAP) { loader_log("*** CACHE INDEX FULL (%d): further DLLs near %s are being " "IGNORED — their regions will run interpreted. Raise " "CACHE_IDX_CAP.", CACHE_IDX_CAP, file->path); return 1; } - if (cache_idx_has_basename(file->name)) return 0; + if (cache_idx_has_path(file->path)) return 0; CacheEntry *e = &s_cache_idx[s_cache_idx_count++]; e->region_start = file->region_start; + e->logical_crc = file->content_crc; e->mtime = file->mtime; e->func_count = 0; e->indexed_func_count = 0; + e->tier = (uint8_t)tier; + e->manifest_ok = 0; + e->load_failed = 0; snprintf(e->path, sizeof(e->path), "%s", file->path); return 0; } @@ -1018,7 +1261,7 @@ static int add_posix_cache_file(const PsxOverlayCacheFile *file, void *opaque) { /* Scan one directory for _.dll cache entries into the index. * `dir` is a full directory path. Idempotent (skips already-indexed paths). */ -static void scan_one_cache_dir(const char *dir) { +static void scan_one_cache_dir(const char *dir, int tier) { #ifdef _WIN32 char pattern[768]; snprintf(pattern, sizeof(pattern), "%s/*_*.dll", dir); @@ -1028,6 +1271,7 @@ static void scan_one_cache_dir(const char *dir) { do { uint32_t addr = 0, crc = 0; if (!psx_overlay_cache_name_parse(fd.cFileName, &addr, &crc)) continue; + if (!cache_name_is_immutable(fd.cFileName)) continue; (void)crc; if (s_cache_idx_count >= CACHE_IDX_CAP) { /* Never-again (the silent-256 truncation): overflowing the index @@ -1039,20 +1283,24 @@ static void scan_one_cache_dir(const char *dir) { } char full[768]; snprintf(full, sizeof(full), "%s/%s", dir, fd.cFileName); - /* skip if this region+crc is already indexed (rescan idempotence, AND a - * higher-priority namespace already covers it — gcc/ scanned before tcc/). */ - if (cache_idx_has_basename(fd.cFileName)) continue; + /* Same full path means a rescan duplicate. Same logical name with a + * different artifact suffix is an additive repair and is retained. */ + if (cache_idx_has_path(full)) continue; CacheEntry *e = &s_cache_idx[s_cache_idx_count++]; e->region_start = addr; + e->logical_crc = crc; e->mtime = ((uint64_t)fd.ftLastWriteTime.dwHighDateTime << 32) | (uint64_t)fd.ftLastWriteTime.dwLowDateTime; e->func_count = 0; e->indexed_func_count = 0; + e->tier = (uint8_t)tier; + e->manifest_ok = 0; + e->load_failed = 0; snprintf(e->path, sizeof(e->path), "%s", full); } while (FindNextFileA(h, &fd)); FindClose(h); #else - psx_overlay_posix_scan_cache_dir(dir, add_posix_cache_file, NULL); + psx_overlay_posix_scan_cache_dir(dir, add_posix_cache_file, &tier); #endif } @@ -1118,7 +1366,7 @@ static void warn_on_cgtag_mismatch(const char *tier) { static void cache_idx_remove_path(const char *path) { for (int i = 0; i < s_cache_idx_count; i++) { - if (strcmp(s_cache_idx[i].path, path) == 0) { + if (cache_path_equal(s_cache_idx[i].path, path)) { s_cache_idx[i] = s_cache_idx[--s_cache_idx_count]; return; } @@ -1149,6 +1397,7 @@ typedef struct PosixAbiSweep { static int posix_abi_sweep_file(const PsxOverlayCacheFile *file, void *opaque) { PosixAbiSweep *sweep = (PosixAbiSweep *)opaque; + if (!cache_name_is_immutable(file->name)) return 0; char error[256] = {0}; void *handle = psx_overlay_posix_library_open(file->path, error, sizeof(error)); int abi = 0; @@ -1208,6 +1457,7 @@ static void abi_preflight_sweep(const char *dir) { HANDLE hf = FindFirstFileA(pattern, &fd); if (hf != INVALID_HANDLE_VALUE) { do { + if (!cache_name_is_immutable(fd.cFileName)) continue; char full[900]; snprintf(full, sizeof full, "%s/%s", dir, fd.cFileName); HMODULE h = LoadLibraryA(full); @@ -1229,7 +1479,7 @@ static void abi_preflight_sweep(const char *dir) { } purged++; for (int i = 0; i < s_cache_idx_count; i++) { - if (strcmp(s_cache_idx[i].path, full) == 0) { + if (cache_path_equal(s_cache_idx[i].path, full)) { s_cache_idx[i] = s_cache_idx[--s_cache_idx_count]; break; } @@ -1262,19 +1512,18 @@ static void abi_preflight_sweep(const char *dir) { static void scan_cache_dir(void) { char dir[768]; - /* Tier order: scan gcc/ FIRST (highest native priority — the dev/production - * shards), THEN tcc/ (the toolchain-free fallback). scan_one_cache_dir dedups - * by filename (region+crc), so a tcc shard for a region a gcc shard already - * covers is skipped — gcc always wins the tie. */ + /* Index both tiers and every immutable artifact. Runtime selection prefers + * usable GCC over TCC; an invalid GCC artifact cannot suppress a valid TCC + * fallback merely because its filename was enumerated first. */ snprintf(dir, sizeof(dir), "%s/%s/gcc/%s/cg%d_%08x", s_cache_dir, s_game_id, PSX_OVERLAY_ARCH_ABI, PSX_OVERLAY_CODEGEN_VER, (unsigned)PSX_OVERLAY_CODEGEN_HASH); - scan_one_cache_dir(dir); + scan_one_cache_dir(dir, CACHE_TIER_GCC); abi_preflight_sweep(dir); snprintf(dir, sizeof(dir), "%s/%s/tcc/%s/cg%d_%08x", s_cache_dir, s_game_id, PSX_OVERLAY_ARCH_ABI, PSX_OVERLAY_CODEGEN_VER, (unsigned)PSX_OVERLAY_CODEGEN_HASH); - scan_one_cache_dir(dir); + scan_one_cache_dir(dir, CACHE_TIER_TCC); abi_preflight_sweep(dir); rebuild_lazy_manifest_index(); @@ -1307,17 +1556,31 @@ static void scan_cache_dir(void) { * persist/publish writers, on-disk reload scan, and their debug accessors — * lived here. No shards are produced or reloaded anymore.) */ -/* True if the cache holds a DLL for this region compiled from an image with - * this CRC (filename _.dll). Autocapture's "unseen" test. */ +static uint32_t lazy_man_crc(const ManFn *m); /* defined with lazy matching */ + +/* True if the cache holds a usable DLL for this region/logical image CRC. + * Filename identity alone is insufficient: a structurally valid but stale or + * unsafe manifest must not suppress additive recapture of its own repair. */ int overlay_loader_has_cached_crc(uint32_t region_start, uint32_t crc) { for (int i = 0; i < s_cache_idx_count; i++) { - if (s_cache_idx[i].region_start != region_start) continue; - const char *fn = strrchr(s_cache_idx[i].path, '/'); - fn = fn ? fn + 1 : s_cache_idx[i].path; - if (strlen(fn) == 17u + OVERLAY_SHARED_EXT_LEN && - strcmp(fn + 17, OVERLAY_SHARED_EXT) == 0 && - (uint32_t)strtoul(fn + 9, NULL, 16) == crc) - return 1; + if (s_cache_idx[i].manifest_ok && + !s_cache_idx[i].load_failed && + s_cache_idx[i].region_start == region_start && + s_cache_idx[i].logical_crc == crc && + s_cache_idx[i].func_count > 0 && + s_cache_idx[i].indexed_func_count == s_cache_idx[i].func_count) { + int seen = 0, usable = 1; + for (int li = s_lazy_bundle_head[i]; li >= 0; + li = s_lazy_man[li].next_bundle) { + seen++; + if (lazy_man_crc(&s_lazy_man[li].fn) != s_lazy_man[li].fn.crc || + !man_delay_slots_hashed(&s_lazy_man[li].fn)) { + usable = 0; + break; + } + } + if (usable && seen == s_cache_idx[i].func_count) return 1; + } } return 0; } @@ -1643,6 +1906,7 @@ static void init_callbacks(void) { #ifdef _WIN32 static int load_overlay_dll(const char *dll_path, ManFn *man, int man_n, int dll) { + int tier = cache_tier_from_path(dll_path); HMODULE h = LoadLibraryA(dll_path); if (!h) { loader_log("LoadLibrary(%s) failed: %lu", dll_path, GetLastError()); @@ -1714,7 +1978,7 @@ static int load_overlay_dll(const char *dll_path, ManFn *man, int man_n, int dll * the interpreter rather than registered with a guessed extent. */ ManFn *m = man_find(man, man_n, addr); if (!m || m->n == 0) { s_no_manifest++; continue; } - cand_register(addr & 0x1FFFFFFFu, fn, m, dll); + cand_register(addr & 0x1FFFFFFFu, fn, m, dll, tier); registered++; } loader_log("loaded %s -> %d candidates (%u no-manifest)", @@ -1723,6 +1987,7 @@ static int load_overlay_dll(const char *dll_path, ManFn *man, int man_n, int dll } #else static int load_overlay_dll(const char *dll_path, ManFn *man, int man_n, int dll) { + int tier = cache_tier_from_path(dll_path); char error[256] = {0}; void *h = psx_overlay_posix_library_open(dll_path, error, sizeof(error)); if (!h) { loader_log("dlopen(%s) failed: %s", dll_path, error); return 0; } @@ -1765,7 +2030,7 @@ static int load_overlay_dll(const char *dll_path, ManFn *man, int man_n, int dll if (m->entry == 0 || m->n == 0) continue; OverlayFn fn = (OverlayFn)psx_overlay_posix_library_entry(h, m->entry); if (!fn) continue; - cand_register(m->entry & 0x1FFFFFFFu, fn, m, dll); + cand_register(m->entry & 0x1FFFFFFFu, fn, m, dll, tier); registered++; } loader_log("loaded %s -> %d manifest candidates", dll_path, registered); @@ -1783,6 +2048,10 @@ static int load_overlay_dll(const char *dll_path, ManFn *man, int man_n, int dll /* ---- Public API -------------------------------------------------------- */ void overlay_loader_init(const char *cache_dir, const char *game_id) { + { + const char *perf = getenv("PSX_RUNTIME_PERF_DIAG"); + s_native_hot_enabled = perf && perf[0] && perf[0] != '0'; + } for (uint32_t p = 0; p < RANGE_PAGE_COUNT; p++) { s_range_page_head[p] = -1; s_range_page_tail[p] = -1; @@ -1821,6 +2090,21 @@ void overlay_loader_init(const char *cache_dir, const char *game_id) { s_native_block_n, s_native_block_n == 1 ? "y" : "ies", nb); } } +#ifndef PSX_NO_DEBUG_TOOLS + { + const char *limit = getenv("PSX_NATIVE_RANK_LIMIT"); + if (limit && *limit) { + unsigned long long n = strtoull(limit, NULL, 0); + s_native_rank_limit = n >= UINT32_MAX ? UINT32_MAX - 1u : (uint32_t)n; + s_native_rank_next = 0; + s_native_rank_blocked = 0; + fprintf(stdout, + "psxrecomp: PSX_NATIVE_RANK_LIMIT=%u: " + "first-seen candidate bisection ON\n", + s_native_rank_limit); + } + } +#endif /* Boot-time full-interp override (diagnostic): PSX_OVERLAY_NATIVE_OFF=1 forces * native overlay execution off from the first instruction, so a pristine * interpreter reference can be captured without racing post-boot cmds. Same @@ -1939,9 +2223,30 @@ void overlay_loader_rescan(void) { static char s_loaded_paths[MAX_LOADED_DLLS][768]; static int s_nloaded_paths = 0; +#ifndef PSX_NO_DEBUG_TOOLS +static int native_rank_allows(Candidate *c, uint32_t pc) { + if (s_native_rank_limit == UINT32_MAX) return 1; + if (c->native_rank == 0) { + c->native_rank = ++s_native_rank_next; + const char *path = (c->dll >= 0 && c->dll < s_nloaded_paths) + ? s_loaded_paths[c->dll] : ""; + fprintf(stdout, + "psxrecomp: native-rank rank=%u candidate=%d owner=0x%08X " + "pc=0x%08X crc=0x%08X dll=%s frame=%llu cycle=%llu allow=%d\n", + c->native_rank, (int)(c - s_cand), c->addr, pc, + c->crc_code, path, (unsigned long long)s_frame_count, + (unsigned long long)psx_get_cycle_count(), + c->native_rank <= s_native_rank_limit); + } + if (c->native_rank <= s_native_rank_limit) return 1; + s_native_rank_blocked++; + return 0; +} +#endif + static int dll_already_loaded(const char *path) { for (int i = 0; i < s_nloaded_paths; i++) - if (strcmp(s_loaded_paths[i], path) == 0) return 1; + if (cache_path_equal(s_loaded_paths[i], path)) return 1; return 0; } @@ -1995,19 +2300,14 @@ int overlay_loader_load_published(const char *dll_path) { if (!s_active || !dll_path || !dll_path[0]) return 0; /* The compiler command is trusted local code, but keep its native-library * output confined to the cache root the framework injected. */ - size_t root_len = strlen(s_cache_dir); -#ifdef _WIN32 - if (_strnicmp(dll_path, s_cache_dir, root_len) != 0) return 0; -#else - if (strncmp(dll_path, s_cache_dir, root_len) != 0) return 0; -#endif - if (dll_path[root_len] != '/' && dll_path[root_len] != '\\') return 0; + if (!cache_path_has_root(dll_path, s_cache_dir)) return 0; const char *base = strrchr(dll_path, '/'); const char *win_base = strrchr(dll_path, '\\'); if (!base || (win_base && win_base > base)) base = win_base; base = base ? base + 1 : dll_path; uint32_t addr = 0, crc = 0; if (!psx_overlay_cache_name_parse(base, &addr, &crc)) return 0; + if (!cache_name_is_immutable(base)) return 0; (void)addr; (void)crc; if (dll_already_loaded(dll_path)) return 0; @@ -2039,13 +2339,18 @@ static uint32_t lazy_man_gensum(const ManFn *m) { } static int lazy_man_matches(LazyMan *lm) { + if (!man_structurally_valid(&lm->fn)) { + lm->state = ENTRY_INVALID; + return 0; + } uint32_t gen = lazy_man_gensum(&lm->fn); if (lm->state == ENTRY_VALID && lm->val_gen == gen) return 1; if (lm->state == ENTRY_INVALID && lm->val_gen == gen) return 0; uint32_t live = lazy_man_crc(&lm->fn); lm->val_gen = gen; s_last_crc = live; - lm->state = (live == lm->fn.crc) ? ENTRY_VALID : ENTRY_INVALID; + lm->state = (live == lm->fn.crc && man_delay_slots_hashed(&lm->fn)) + ? ENTRY_VALID : ENTRY_INVALID; return lm->state == ENTRY_VALID; } @@ -2055,7 +2360,7 @@ static int lazy_man_matches(LazyMan *lm) { * coherent snapshot to replace many synchronous incremental LoadLibrary calls * at a stage transition without substituting a stale CPS owner. */ static int lazy_bundle_matches(int ci) { - if (ci < 0 || ci >= s_cache_idx_count) return 0; + if (ci < 0 || ci >= s_cache_idx_count || ci >= CACHE_IDX_CAP) return 0; if (s_cache_idx[ci].func_count <= 0 || s_cache_idx[ci].indexed_func_count != s_cache_idx[ci].func_count) return 0; @@ -2068,6 +2373,27 @@ static int lazy_bundle_matches(int ci) { return seen == s_cache_idx[ci].func_count; } +static int lazy_candidate_preferred(int li, int current) { + if (current < 0) return 1; + const CacheEntry *a = &s_cache_idx[s_lazy_man[li].cache_idx]; + const CacheEntry *b = &s_cache_idx[s_lazy_man[current].cache_idx]; + if (a->tier != b->tier) return a->tier > b->tier; + if (a->mtime != b->mtime) return a->mtime > b->mtime; + return strcmp(a->path, b->path) > 0; +} + +/* Prefer GCC over TCC even when the TCC option happens to be a broader bundle. + * Within one tier, a fully coherent bundle remains stronger than a partial + * region-local function match. */ +static int lazy_choose_complete_or_fallback(int complete, int fallback) { + if (complete < 0) return fallback; + if (fallback < 0) return complete; + const CacheEntry *a = &s_cache_idx[s_lazy_man[complete].cache_idx]; + const CacheEntry *b = &s_cache_idx[s_lazy_man[fallback].cache_idx]; + if (a->tier != b->tier) return a->tier > b->tier ? complete : fallback; + return complete; +} + /* A streamed variant is not byte-coherent until immediately before its first * call, too late to hide Windows' first image-map cost. Map every small bundle * for this ONE base after boot. Tomba's mature vault has 141 here versus 712 in @@ -2094,6 +2420,7 @@ static int lazy_is_loadable(int li, uint32_t region_start, uint32_t phys, if (ci < 0 || ci >= s_cache_idx_count) return 0; return (!require_region_start || s_cache_idx[ci].region_start == region_start) && + !s_cache_idx[ci].load_failed && !dll_already_loaded(s_cache_idx[ci].path) && lazy_man_contains(&lm->fn, phys) && lazy_man_matches(lm); } @@ -2111,7 +2438,8 @@ static int lazy_better_bundle(int li, int best) { static int lazy_load_selected(int li) { if (li < 0 || li >= s_lazy_man_n) return 0; int ci = s_lazy_man[li].cache_idx; - if (ci < 0 || ci >= s_cache_idx_count || + if (ci < 0 || ci >= s_cache_idx_count || ci >= CACHE_IDX_CAP || + s_cache_idx[ci].load_failed || dll_already_loaded(s_cache_idx[ci].path) || s_cache_idx[ci].func_count <= 0) return 0; /* If proactive warming has not reached this fragment yet, prefer the @@ -2120,6 +2448,7 @@ static int lazy_load_selected(int li) { overlay_image_warm_cancel(ci); s_last_file_found = 1; int loaded = load_one_dll(s_cache_idx[ci].path) > 0; + if (!loaded) s_cache_idx[ci].load_failed = 1; overlay_image_warm_release(ci); return loaded; } @@ -2156,6 +2485,9 @@ static int try_load_region(uint32_t phys) { if (!already_checked(region_start)) mark_checked(region_start); + int select_attempts = 0; +retry_artifact: + ; /* Prefer the broadest fully coherent snapshot. If none has every function * live, retain the historical first matching-function fallback. */ int best = -1; @@ -2177,11 +2509,12 @@ static int try_load_region(uint32_t phys) { /* Cross-region recovery is safe only for a fully coherent CPS bundle. * A partial exact-function match can have snapshot-specific internal * tails, so retain it as fallback only when the heuristic base agrees. */ - if (s_cache_idx[ci].region_start == region_start && fallback < 0) + if (s_cache_idx[ci].region_start == region_start && + lazy_candidate_preferred(li, fallback)) fallback = li; /* Entry chains are newest-first/semantic. Preserve their established * order instead of substituting a larger historical bundle. */ - if (lazy_bundle_matches(ci) && best < 0) + if (lazy_bundle_matches(ci) && lazy_candidate_preferred(li, best)) best = li; } @@ -2198,14 +2531,20 @@ static int try_load_region(uint32_t phys) { * exact manifested entry, an interior PC alone is not a unique * identity for a reused-address CPS bundle. */ if (!lazy_is_loadable(li, region_start, phys, 1)) continue; - if (fallback < 0) fallback = li; + if (lazy_candidate_preferred(li, fallback)) fallback = li; int ci = s_lazy_man[li].cache_idx; - if (lazy_bundle_matches(ci) && best < 0) + if (lazy_bundle_matches(ci) && lazy_candidate_preferred(li, best)) best = li; } } - if (best >= 0 && lazy_load_selected(best)) return 1; - return fallback >= 0 && lazy_load_selected(fallback); + int selected = lazy_choose_complete_or_fallback(best, fallback); + if (selected < 0) return 0; + if (lazy_load_selected(selected)) return 1; + /* An unloadable/corrupt GCC artifact must not suppress an older immutable + * repair or the TCC fallback. lazy_load_selected marks the failed cache + * entry, so a bounded re-selection chooses the next live candidate. */ + if (++select_attempts < s_cache_idx_count) goto retry_artifact; + return 0; } /* O(1)-bucket discriminator between a real manifested function entry and an @@ -2260,7 +2599,7 @@ static int range_candidate_matches(int i, uint32_t phys) { s_rehashes++; s_last_crc = live; c->val_gen = gen; - if (live == c->crc_code) { + if (live == c->crc_code && cand_delay_slots_hashed(c)) { if (c->state != ENTRY_VALID) { c->state = ENTRY_VALID; s_valid_count++; @@ -2279,6 +2618,13 @@ static int range_candidate_matches(int i, uint32_t phys) { return 0; } +static int range_candidate_preferred(int candidate, int current) { + if (current < 0) return 1; + if (s_cand[candidate].tier != s_cand[current].tier) + return s_cand[candidate].tier > s_cand[current].tier; + return candidate > current; /* newest same-tier repair */ +} + static int overlay_find_by_range(uint32_t phys) { /* Definitive guard for the overlay-off case: the range page index * (s_range_page_head) is only initialized to -1 by overlay_loader_init, @@ -2292,29 +2638,30 @@ static int overlay_find_by_range(uint32_t phys) { RangePcCache *pc = &s_range_pc_cache[ (phys * 2654435761u) & RANGE_PC_CACHE_MASK]; - if (pc->cand >= 0 && pc->phys == phys && + if (pc->generation == s_range_candidate_generation && + pc->cand >= 0 && pc->phys == phys && range_candidate_matches(pc->cand, phys)) return pc->cand; + int best = -1; if (s_range_index_overflow) { for (int i = 0; i < s_cand_n; i++) - if (range_candidate_matches(i, phys)) { - pc->phys = phys; - pc->cand = i; - return i; - } - return -1; - } - - for (int li = s_range_page_head[page]; li >= 0; li = s_range_links[li].next) { - int i = s_range_links[li].cand; - if (range_candidate_matches(i, phys)) { - pc->phys = phys; - pc->cand = i; - return i; + if (range_candidate_matches(i, phys) && + range_candidate_preferred(i, best)) + best = i; + } else { + for (int li = s_range_page_head[page]; li >= 0; + li = s_range_links[li].next) { + int i = s_range_links[li].cand; + if (range_candidate_matches(i, phys) && + range_candidate_preferred(i, best)) + best = i; } } - return -1; + pc->phys = phys; + pc->generation = s_range_candidate_generation; + pc->cand = best; + return best; } int overlay_loader_dispatch(CPUState *cpu, uint32_t addr) { @@ -2400,7 +2747,7 @@ retry_candidates: s_rehashes++; s_last_crc = live; c->val_gen = gen; - matched = (live == c->crc_code); + matched = (live == c->crc_code && cand_delay_slots_hashed(c)); } if (_probe) s_cps_probe_matched = matched; if (matched) { @@ -2432,6 +2779,10 @@ retry_candidates: } if (!s_native_exec || overlay_native_blocked(c->addr) || overlay_native_blocked(addr)) { if (_probe) s_cps_probe_outcome = 4; s_would_run_native++; s_disp_interp++; return 0; } +#ifndef PSX_NO_DEBUG_TOOLS + if (!native_rank_allows(c, addr)) + { if (_probe) s_cps_probe_outcome = 7; s_would_run_native++; s_disp_interp++; return 0; } +#endif } if (_probe) s_cps_probe_outcome = 2; #ifndef PSX_NO_DEBUG_TOOLS @@ -2445,6 +2796,7 @@ retry_candidates: s_native_inprogress = c->addr; #endif s_native_calls_total++; + native_hot_note(c->addr); if (s_active_depth < (int)(sizeof(s_active_stack) / sizeof(s_active_stack[0]))) s_active_stack[s_active_depth++] = ci; s_disp_native++; @@ -2507,7 +2859,7 @@ retry_candidates: s_rehashes++; s_last_crc = live; c->val_gen = gen; - matched = (live == c->crc_code); + matched = (live == c->crc_code && cand_delay_slots_hashed(c)); } if (matched) { if (c->state != ENTRY_VALID) { @@ -2567,6 +2919,10 @@ retry_candidates: * routing for one function only (bisection localization). */ if (!s_native_exec || overlay_native_blocked(c->addr)) { s_would_run_native++; s_disp_interp++; return 0; } +#ifndef PSX_NO_DEBUG_TOOLS + if (!native_rank_allows(c, addr)) + { s_would_run_native++; s_disp_interp++; return 0; } +#endif /* Record into the always-on ring BEFORE the call; mark in-progress * so a freeze inside this fn is visible at dump time. */ @@ -2581,6 +2937,7 @@ retry_candidates: s_native_inprogress = c->addr; #endif s_native_calls_total++; + native_hot_note(c->addr); if (s_active_depth < (int)(sizeof(s_active_stack) / sizeof(s_active_stack[0]))) s_active_stack[s_active_depth++] = i; @@ -2704,6 +3061,22 @@ void overlay_loader_get_load_timing(uint64_t *total_us, uint64_t *max_us, if (last_us) *last_us = s_load_last_us; } +void overlay_loader_take_hot_native(uint32_t *pc, uint64_t *calls) { + uint32_t best_pc = 0; + uint64_t best_calls = 0; + if (s_native_hot_enabled) { + for (uint32_t i = 0; i < NATIVE_HOT_CAP; i++) { + if (s_native_hot[i].calls > best_calls) { + best_pc = s_native_hot[i].pc; + best_calls = s_native_hot[i].calls; + } + s_native_hot[i].calls = 0; + } + } + if (pc) *pc = best_pc; + if (calls) *calls = best_calls; +} + /* Reload diagnostics. Repurposed for the per-entry model: * r0_valid -> candidates currently VALID * r0_writes... -> entries blacklisted (self-mod) @@ -2817,6 +3190,14 @@ static uint32_t s_detail_addr = 0; static uint32_t s_detail_nat_gpr[32], s_detail_int_gpr[32]; static uint32_t s_detail_nat_hi, s_detail_nat_lo, s_detail_int_hi, s_detail_int_lo; +void overlay_loader_get_shadow_summary(uint64_t *calls, uint64_t *divergences, + uint32_t *first_divergence_pc) { + if (calls) *calls = s_shadow_calls; + if (divergences) *divergences = s_shadow_divs; + if (first_divergence_pc) + *first_divergence_pc = s_detail_captured ? s_detail_addr : 0; +} + void overlay_loader_set_diff_mode(int on) { s_diff_mode = on ? 1 : 0; } static void run_shadow_diff(CPUState *cpu, Candidate *c, uint32_t addr) { diff --git a/runtime/src/overlay_posix.c b/runtime/src/overlay_posix.c index 9862dd17..5d59197f 100644 --- a/runtime/src/overlay_posix.c +++ b/runtime/src/overlay_posix.c @@ -23,18 +23,27 @@ static int parse_hex8(const char *text, uint32_t *value) { int psx_overlay_cache_name_parse(const char *name, uint32_t *region_start, uint32_t *content_crc) { - uint32_t addr = 0, crc = 0; + uint32_t addr = 0, crc = 0, artifact = 0; #ifdef _WIN32 static const char extension[] = ".dll"; #else static const char extension[] = ".so"; #endif - if (!name || strlen(name) != 17 + sizeof(extension) - 1 || name[8] != '_' || - strcmp(name + 17, extension) != 0 || - !parse_hex8(name, &addr) || !parse_hex8(name + 9, &crc)) + if (!name) return 0; + size_t name_len = strlen(name); + size_t ext_len = sizeof(extension) - 1u; + if (name_len < ext_len) return 0; + size_t stem_len = name_len - ext_len; + if ( + (stem_len != 17u && stem_len != 26u) || name[8] != '_' || + strcmp(name + stem_len, extension) != 0 || + !parse_hex8(name, &addr) || !parse_hex8(name + 9, &crc) || + (stem_len == 26u && + (name[17] != '_' || !parse_hex8(name + 18, &artifact)))) return 0; if (region_start) *region_start = addr; if (content_crc) *content_crc = crc; + (void)artifact; return 1; } diff --git a/runtime/src/psx_icache.c b/runtime/src/psx_icache.c index a38dea5f..b8dce21d 100644 --- a/runtime/src/psx_icache.c +++ b/runtime/src/psx_icache.c @@ -28,6 +28,7 @@ * leader) so the cache evolves identically to Beetle for the same fetch stream. */ #include "cpu_state.h" +#include "psx_icache.h" #include #include @@ -51,20 +52,23 @@ int psx_icache_enabled(void) { /* Per-word tag+validity, mirroring Beetle ICache[idx].TV. Init to a value that can * never equal an aligned fetch address (aligned addrs have bits 0-1 = 0), so every * line starts cold (miss). */ -static uint32_t s_icache_tv[1024]; +uint32_t g_psx_icache_tv[1024]; +int g_psx_icache_active = -1; void psx_icache_reset(void) { - for (int i = 0; i < 1024; i++) s_icache_tv[i] = 0x1u; /* bit0 set => never matches */ + g_psx_icache_active = psx_icache_enabled(); + for (int i = 0; i < 1024; i++) g_psx_icache_tv[i] = 0x1u; } /* Charge the fetch cost for the instruction at `addr` and update the cache tags. * `addr` is the runtime guest virtual PC (aligned). Gated on PSX_ENABLE_BLOCK_CYCLES. */ -void psx_icache_fetch(CPUState* cpu, uint32_t addr) { +void psx_icache_fetch_miss(CPUState* cpu, uint32_t addr) { { extern int g_ls_replay_active; if (g_ls_replay_active) return; } /* lockstep replay: no global icache mutation */ #ifdef PSX_ENABLE_BLOCK_CYCLES - if (!psx_icache_enabled()) return; + if (g_psx_icache_active < 0) g_psx_icache_active = psx_icache_enabled(); + if (!g_psx_icache_active) return; uint32_t idx = (addr & 0xFFCu) >> 2; - if (s_icache_tv[idx] == addr) return; /* HIT: +0, no give-back clear */ + if (g_psx_icache_tv[idx] == addr) return; /* HIT: +0, no give-back clear */ /* MISS — clear the pending load give-back (Beetle cpu.cpp:542-543). */ cpu->read_absorb[cpu->read_absorb_which] = 0u; @@ -79,13 +83,13 @@ void psx_icache_fetch(CPUState* cpu, uint32_t addr) { * + count the words from the missing one to the line end. */ uint32_t line = addr & 0xFFFFFFF0u; uint32_t bidx = (addr & 0xFF0u) >> 2; /* first word index of the line */ - s_icache_tv[bidx + 0] = line | 0x0u | 0x2u; - s_icache_tv[bidx + 1] = line | 0x4u | 0x2u; - s_icache_tv[bidx + 2] = line | 0x8u | 0x2u; - s_icache_tv[bidx + 3] = line | 0xCu | 0x2u; + g_psx_icache_tv[bidx + 0] = line | 0x0u | 0x2u; + g_psx_icache_tv[bidx + 1] = line | 0x4u | 0x2u; + g_psx_icache_tv[bidx + 2] = line | 0x8u | 0x2u; + g_psx_icache_tv[bidx + 3] = line | 0xCu | 0x2u; uint32_t cost = 3u; for (uint32_t i = (addr & 0xCu) >> 2; i < 4u; i++) { - s_icache_tv[bidx + i] &= ~0x2u; /* valid: TV == that word's addr */ + g_psx_icache_tv[bidx + i] &= ~0x2u; /* valid: TV == that word's addr */ cost++; } psx_advance_cycles(cost); @@ -93,3 +97,7 @@ void psx_icache_fetch(CPUState* cpu, uint32_t addr) { (void)cpu; (void)addr; #endif } + +void psx_icache_fetch(CPUState* cpu, uint32_t addr) { + psx_icache_fetch_miss(cpu, addr); +} diff --git a/runtime/tests/run_overlay_posix_test.sh b/runtime/tests/run_overlay_posix_test.sh index d9a15015..cc38e992 100644 --- a/runtime/tests/run_overlay_posix_test.sh +++ b/runtime/tests/run_overlay_posix_test.sh @@ -15,6 +15,7 @@ cc -std=c99 -Wall -Wextra -Werror -fPIC -shared \ "$root/runtime/tests/overlay_posix_fixture.c" \ -o "$cache/80010000_DEADBEEF.so" : > "$cache/00000000_00000000.so" +: > "$cache/80010000_DEADBEEF_13579BDF.so" : > "$cache/80010000_DEADBEG0.so" : > "$base/$other/80020000_12345678.so" diff --git a/runtime/tests/test_interpreter_perf_guards.py b/runtime/tests/test_interpreter_perf_guards.py index bceb4827..29ad80f5 100644 --- a/runtime/tests/test_interpreter_perf_guards.py +++ b/runtime/tests/test_interpreter_perf_guards.py @@ -27,7 +27,9 @@ def main(): interp = (ROOT / "runtime/src/dirty_ram_interp.c").read_text(encoding="utf-8") capture = (ROOT / "runtime/src/overlay_capture.c").read_text(encoding="utf-8") loader = (ROOT / "runtime/src/overlay_loader.c").read_text(encoding="utf-8") + autocompile = (ROOT / "runtime/src/autocompile.c").read_text(encoding="utf-8") memory = (ROOT / "runtime/src/memory.c").read_text(encoding="utf-8") + gte = (ROOT / "runtime/src/gte.cpp").read_text(encoding="utf-8") cycles = (ROOT / "runtime/src/psx_cycles.c").read_text(encoding="utf-8") cyc_header = (ROOT / "runtime/include/psx_cyc.h").read_text(encoding="utf-8") starvation = (ROOT / "runtime/include/starvation_ring.h").read_text(encoding="utf-8") @@ -39,6 +41,12 @@ def main(): raise AssertionError("executed-PC recording regressed to hash probing") if "sizeof(g_dirty_ram_exec_pc_bitmap)" not in capture: raise AssertionError("autocapture does not snapshot the 64 KiB bitmap") + write_window = body(capture, "write_json_window") + if ("size += 4u" not in write_window or + "phys + size <= ram_size - 4u" not in write_window or + "window_hi" in write_window): + raise AssertionError( + "capture delay-slot guard is missing or stops at an artificial window boundary") if "g_dirty_ram_exec_pc_table" in capture: raise AssertionError("capture still references the removed execution table") before_dma = body(capture, "overlay_capture_before_dma") @@ -50,6 +58,9 @@ def main(): raise AssertionError("outgoing variant is not queued before evidence reset") if "first_page = lo >> page_shift" not in before_dma: raise AssertionError("pre-DMA capture is not scoped to touched whole pages") + for guard in ("first_page - 1u", "last_page + 1u", "exec_pc_bitmap"): + if guard not in capture: + raise AssertionError(f"pre-DMA capture lost executed-page halo: {guard}") dispatch_inner = body(interp, "dirty_ram_dispatch_inner") if "(!current_page_dirty || next_page != current_page)" not in dispatch_inner: raise AssertionError("page fast path does not preserve clean-miss behavior") @@ -64,14 +75,45 @@ def main(): raise AssertionError("cache rescan does not invalidate negative misses") if "lazy_miss_invalidate_loader();" not in body(loader, "load_one_dll"): raise AssertionError("DLL publication does not invalidate negative misses") + lazy_match = body(loader, "lazy_man_matches") + if "man_delay_slots_hashed(&lm->fn)" not in lazy_match: + raise AssertionError("legacy manifests can expose unhashed delay slots") rebuild = body(loader, "rebuild_lazy_manifest_index") if "overlay_watch_set_range(lo, lm->fn.len[r])" not in rebuild: raise AssertionError("unloaded manifest pages are not generation-watched") + if "manifest_ok = 1" not in rebuild: + raise AssertionError("strictly parsed manifests are not marked usable") + if "cache_idx_has_basename" in loader: + raise AssertionError("immutable repair artifacts are still deduped by logical name") + scan_one = body(loader, "scan_one_cache_dir") + for guard in ("cache_name_is_immutable(fd.cFileName)", + "cache_idx_has_path(full)", "e->logical_crc = crc", + "e->tier = (uint8_t)tier"): + if guard not in scan_one: + raise AssertionError(f"artifact-aware cache indexing lost: {guard}") + has_crc = body(loader, "overlay_loader_has_cached_crc") + for guard in ("manifest_ok", "!s_cache_idx[i].load_failed", + "logical_crc == crc", "lazy_man_crc(", + "man_delay_slots_hashed("): + if guard not in has_crc: + raise AssertionError(f"cached-CRC query accepts unusable artifacts: {guard}") try_load = body(loader, "try_load_region") if "lazy_is_loadable(li, region_start, phys, 0)" not in try_load: raise AssertionError("exact manifest entries are still gated by dirty-run base") if "lazy_is_loadable(li, region_start, phys, 1)" not in try_load: raise AssertionError("non-exact range fallback lost region narrowing") + if "lazy_candidate_preferred" not in try_load: + raise AssertionError("lazy artifact selection is still directory-order dependent") + for guard in ("load_failed", "goto retry_artifact"): + if guard not in try_load and guard not in body(loader, "lazy_is_loadable"): + raise AssertionError(f"failed artifact can suppress a valid fallback: {guard}") + range_lookup = body(loader, "overlay_find_by_range") + for guard in ("s_range_candidate_generation", "range_candidate_preferred"): + if guard not in range_lookup: + raise AssertionError(f"loaded range selection lost tier/new-artifact ordering: {guard}") + ac_poll = body(autocompile, "autocompile_poll_main") + if "one idempotent batch-end" not in ac_poll or "overlay_loader_rescan();" not in ac_poll: + raise AssertionError("direct shard handoff is not reconciled into the additive index") watched_write = body(memory, "overlay_watch_note_write") if "g_dirty_ram_exec_page_bitmap" not in watched_write: raise AssertionError("RAM writes do not clear stale per-page capture evidence") @@ -84,6 +126,17 @@ def main(): candidate = body(loader, "overlay_loader_is_candidate") if "exact_entry_has(phys)" not in candidate or "idx_head" in candidate: raise AssertionError("candidate presence regressed from bitmap to hash probes") + if "cache_path_equal(s_loaded_paths[i], path)" not in body( + loader, "dll_already_loaded"): + raise AssertionError("published/scanned path separator aliases can double-load DLLs") + posix_sweep = body(loader, "posix_abi_sweep_file") + if not (posix_sweep.find("cache_name_is_immutable(file->name)") < + posix_sweep.find("psx_overlay_posix_library_open")): + raise AssertionError("ABI preflight can execute a legacy POSIX constructor") + abi_sweep = body(loader, "abi_preflight_sweep") + if not (abi_sweep.find("cache_name_is_immutable(fd.cFileName)") < + abi_sweep.find("LoadLibraryA(full)")): + raise AssertionError("ABI preflight can execute a legacy Windows DllMain") interp_step = body(interp, "interp_cyc_step") if "g_psx_cycle_fast_limit" not in interp_step or "next <= g_psx_cycle_fast_limit" not in interp_step: @@ -137,6 +190,16 @@ def main(): if "defined(PSX_NO_DEBUG_TOOLS) && !defined(PSX_COSIM)" not in memory: raise AssertionError("main-RAM value fast path leaks into diagnostic/COSIM builds") + gte_exec = body(gte, "gte_execute") + for probe in ("s_gte_exec_count++", "s_gte_caller_ra =", + "int16_t intpl_pre_ir[4]", "gte_rtp_record(>e, cmd)", + "gte_intpl_record(>e, intpl_pre_ir, intpl_pre_fc)"): + pos = gte_exec.find(probe) + begin = gte_exec.rfind("#ifndef PSX_NO_DEBUG_TOOLS", 0, pos) + end = gte_exec.find("#endif", pos) + if pos < 0 or begin < 0 or end < 0: + raise AssertionError(f"production GTE path still executes diagnostic probe: {probe}") + print("PASS: capture/cache and interpreter cycle hot-path guards intact") return 0 diff --git a/runtime/tests/test_overlay_init_guard.py b/runtime/tests/test_overlay_init_guard.py index c14442ef..0301d85b 100644 --- a/runtime/tests/test_overlay_init_guard.py +++ b/runtime/tests/test_overlay_init_guard.py @@ -61,7 +61,7 @@ def main() -> int: require_before( candidate, r"if\s*\(\s*!s_active\s*\)\s*return\s+0\s*;", - r"idx_head\s*\(", + r"(?:idx_head|exact_entry_has)\s*\(", "overlay_loader_is_candidate", ) diff --git a/runtime/tests/test_overlay_posix.c b/runtime/tests/test_overlay_posix.c index e946b7fa..66689bd5 100644 --- a/runtime/tests/test_overlay_posix.c +++ b/runtime/tests/test_overlay_posix.c @@ -27,9 +27,18 @@ static void check_name_parser(void) { assert(addr == 0 && crc == 0); assert(psx_overlay_cache_name_parse("89abcdef_ABCDEF01.so", &addr, &crc)); assert(addr == 0x89ABCDEFu && crc == 0xABCDEF01u); + assert(psx_overlay_cache_name_parse( + "89abcdef_ABCDEF01_13579BDF.so", &addr, &crc)); + assert(addr == 0x89ABCDEFu && crc == 0xABCDEF01u); + assert(!psx_overlay_cache_name_parse("", &addr, &crc)); + assert(!psx_overlay_cache_name_parse(".so", &addr, &crc)); assert(!psx_overlay_cache_name_parse("G0010000_DEADBEEF.so", &addr, &crc)); assert(!psx_overlay_cache_name_parse("80010000_DEADBEG0.so", &addr, &crc)); + assert(!psx_overlay_cache_name_parse( + "80010000_DEADBEEF_DEADBEG0.so", &addr, &crc)); + assert(!psx_overlay_cache_name_parse( + "80010000_DEADBEEFDEADBEEF.so", &addr, &crc)); assert(!psx_overlay_cache_name_parse("80010000_DEADBEEF.dll", &addr, &crc)); assert(!psx_overlay_cache_name_parse("80010000-DEADBEEF.so", &addr, &crc)); assert(!psx_overlay_cache_name_parse("80010000_DEADBEEF.so.extra", &addr, &crc)); @@ -40,8 +49,8 @@ int main(int argc, char **argv) { check_name_parser(); ScanResult result = {0}; - assert(psx_overlay_posix_scan_cache_dir(argv[1], record_cache_file, &result) == 2); - assert(result.count == 2); + assert(psx_overlay_posix_scan_cache_dir(argv[1], record_cache_file, &result) == 3); + assert(result.count == 3); assert(result.addr == 0x80010000u); assert(result.crc == 0xDEADBEEFu); diff --git a/runtime/tests/test_psx_icache_fastpath.c b/runtime/tests/test_psx_icache_fastpath.c new file mode 100644 index 00000000..baadf18a --- /dev/null +++ b/runtime/tests/test_psx_icache_fastpath.c @@ -0,0 +1,94 @@ +#include "cpu_state.h" +#include "psx_icache.h" + +#include +#include + +int g_ls_replay_active = 0; +static uint64_t test_cycles = 0; + +void psx_advance_cycles(uint32_t cycles) { test_cycles += cycles; } + +static int expect(int condition, const char *message) { + if (condition) return 1; + fprintf(stderr, "FAIL: %s\n", message); + return 0; +} + +int main(void) { + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + + psx_icache_reset(); + g_psx_icache_active = 1; + test_cycles = 0; + cpu.read_absorb_which = 1; + cpu.read_absorb[0] = 77u; + cpu.read_absorb[1] = 99u; + psx_icache_fetch_interp(&cpu, 0x80010000u); + if (!expect(test_cycles == 7u, "word-0 cold refill cost")) return 1; + if (!expect(cpu.read_absorb_which == 0u && cpu.read_absorb[1] == 0u && + cpu.read_absorb[0] == 77u, + "true miss clears only the selected load give-back")) return 1; + cpu.read_absorb_which = 1; + cpu.read_absorb[1] = 55u; + psx_icache_fetch_interp(&cpu, 0x80010000u); + psx_icache_fetch_interp(&cpu, 0x80010004u); + if (!expect(test_cycles == 7u, "steady tag hits are cycle-free")) return 1; + if (!expect(cpu.read_absorb_which == 1u && cpu.read_absorb[1] == 55u, + "tag hits preserve load give-back")) return 1; + + psx_icache_reset(); + g_psx_icache_active = 1; + test_cycles = 0; + psx_icache_fetch_interp(&cpu, 0x80010008u); + if (!expect(test_cycles == 5u, "word-2 partial refill cost")) return 1; + psx_icache_fetch_interp(&cpu, 0x80010000u); + if (!expect(test_cycles == 12u, "earlier word remains invalid after partial refill")) return 1; + + psx_icache_reset(); + g_psx_icache_active = 1; + test_cycles = 0; + psx_icache_fetch_interp(&cpu, 0xA0010000u); + psx_icache_fetch_interp(&cpu, 0xA0010000u); + if (!expect(test_cycles == 8u, "KSEG1 remains uncached")) return 1; + + psx_icache_reset(); + g_psx_icache_active = 1; + test_cycles = 0; + psx_icache_fetch_interp(&cpu, 0x80010000u); + psx_icache_fetch_interp(&cpu, 0x00010000u); + if (!expect(test_cycles == 14u, + "KUSEG/KSEG0 aliases replace full virtual tags")) return 1; + psx_icache_reset(); + if (!expect(g_psx_icache_tv[0] == 1u, + "reset makes a warmed line cold")) return 1; + + g_psx_icache_active = 1; + test_cycles = 0; + psx_icache_fetch(&cpu, 0x80010000u); + if (!expect(test_cycles == 7u, + "compiled-code compatibility wrapper preserves refill")) return 1; + + psx_icache_reset(); + g_psx_icache_active = 1; + test_cycles = 0; + g_ls_replay_active = 1; + cpu.read_absorb_which = 1; + cpu.read_absorb[1] = 44u; + psx_icache_fetch_interp(&cpu, 0x80010000u); + g_ls_replay_active = 0; + if (!expect(test_cycles == 0u, "lockstep replay does not mutate cache")) return 1; + if (!expect(g_psx_icache_tv[0] == 1u, "replay preserved cold tag")) return 1; + if (!expect(cpu.read_absorb_which == 1u && cpu.read_absorb[1] == 44u, + "replay preserves load give-back")) return 1; + + g_psx_icache_active = 0; + psx_icache_fetch_interp(&cpu, 0x80010000u); + if (!expect(test_cycles == 0u, "disabled cache model is free")) return 1; + if (!expect(cpu.read_absorb_which == 1u && cpu.read_absorb[1] == 44u, + "disabled cache preserves load give-back")) return 1; + + puts("PASS: interpreter I-cache hit path preserves miss/refill semantics"); + return 0; +} diff --git a/tools/compile_overlays.py b/tools/compile_overlays.py index 18e71929..6e7b20f8 100644 --- a/tools/compile_overlays.py +++ b/tools/compile_overlays.py @@ -23,6 +23,7 @@ Each DLL exports: import argparse import base64 import binascii +import hashlib from collections import Counter, deque from concurrent.futures import ThreadPoolExecutor, as_completed import os @@ -32,6 +33,7 @@ import subprocess import sys import tempfile import threading +from typing import Optional class _ThreadLocalStdout: @@ -245,6 +247,59 @@ def overlay_ext() -> str: return '.dll' if is_windows() else '.so' +def bundle_path_is_immutable(dll_path: str) -> bool: + """True for bound __ shared-library names.""" + name = os.path.basename(dll_path) + return re.fullmatch( + rf'[0-9A-Fa-f]{{8}}_[0-9A-Fa-f]{{8}}_[0-9A-Fa-f]{{8}}' + rf'{re.escape(overlay_ext())}', name) is not None + + +def cached_bundle_pairs(logical_dll: str): + """Yield legacy and immutable pairs for one region/logical cache identity.""" + stem, ext = os.path.splitext(logical_dll) + directory = os.path.dirname(logical_dll) or '.' + base = os.path.basename(stem) + legacy_ranges = stem + '.ranges' + if os.path.isfile(logical_dll) and os.path.isfile(legacy_ranges): + yield logical_dll, legacy_ranges + try: + names = os.listdir(directory) + except OSError: + return + pattern = re.compile(rf'^{re.escape(base)}_[0-9A-Fa-f]{{8}}{re.escape(ext)}$') + for name in names: + if not pattern.fullmatch(name): + continue + dll = os.path.join(directory, name) + ranges = os.path.splitext(dll)[0] + '.ranges' + if os.path.isfile(ranges): + yield dll, ranges + + +def validated_cached_bundle_ids(logical_dll: str, data: bytes, + load_addr: int, size: int): + """Yield every artifact for a logical identity with strict validation. + + The caller deliberately sees all same-logical artifacts: immutable repair + and richer additive captures must not depend on directory enumeration order. + """ + for cached_dll, ranges_path in cached_bundle_pairs(logical_dll): + func_ids = parse_overlay_func_ids( + ranges_path, data, load_addr, size, require_stored_crc=True) + delay_errors = audit_func_id_delay_slots(func_ids, data, load_addr) + yield cached_dll, ranges_path, func_ids, delay_errors + + +def authoritative_cached_bundle_ids(logical_dll: str, data: bytes, + load_addr: int, size: int): + """Yield only artifact-bound bundles allowed to suppress a rebuild.""" + for bundle in validated_cached_bundle_ids( + logical_dll, data, load_addr, size): + if bundle_path_is_immutable(bundle[0]): + yield bundle + + def native_path(p: str) -> str: """Absolute form of `p`, in the RUNNING interpreter's own path flavor, for handing to native tools (gcc, tcc). Two hard-won rules live here @@ -1384,7 +1439,7 @@ def generate_overlay_dispatch(variants: list) -> str: # --------------------------------------------------------------------------- def parse_overlay_func_ids(src_path: str, data: bytes, load_addr: int, - size: int) -> list: + size: int, require_stored_crc: bool = False) -> list: """Parse the recompiler's _full.ranges manifest and return the list of in-overlay function identities as (ev, code_crc, ranges) tuples, where ev = virtual entry, code_crc = AUTHORITATIVE hash of the captured code-range @@ -1405,50 +1460,171 @@ def parse_overlay_func_ids(src_path: str, data: bytes, load_addr: int, return ov_lo <= (a & 0x1FFFFFFF) < ov_hi # Parse the recompiler manifest into [(entry, [(lo, len), ...]), ...]. - funcs: list[tuple[int, list[tuple[int, int]]]] = [] + funcs: list[tuple[int, Optional[int], list[tuple[int, int]]]] = [] cur = None + invalid = False with open(src_path) as f: for line in f: s = line.split() - if not s: + if not s or s[0].startswith('#'): continue if s[0] == 'F': try: + if len(s) not in (2, 3): + raise ValueError addr = int(s[1], 16) + stored_crc = int(s[2], 16) if len(s) == 3 else None + if require_stored_crc and stored_crc is None: + raise ValueError + if (require_stored_crc and + (addr & 0xFFE00000) != 0x80000000): + raise ValueError except (IndexError, ValueError): - cur = None - continue + invalid = True + break if in_ov(addr): - cur = (addr, []) + if any((old_addr & 0x1FFFFFFF) == + (addr & 0x1FFFFFFF) for old_addr, _, _ in funcs): + invalid = True + break + cur = (addr, stored_crc, []) funcs.append(cur) else: + if require_stored_crc: + invalid = True + break cur = None elif s[0] == 'R' and cur is not None: try: + if len(s) != 3: + raise ValueError lo, length = int(s[1], 16), int(s[2], 16) + if (require_stored_crc and + (lo & 0xFFE00000) != 0x80000000): + raise ValueError except (IndexError, ValueError): - continue - cur[1].append((lo, length)) + invalid = True + break + cur[2].append((lo, length)) + if len(cur[2]) > 16: + invalid = True + break + elif s[0] == 'R' and cur is None: + if require_stored_crc: + invalid = True + break + else: + invalid = True + break + + if invalid: + return [] out = [] - for entry, ranges in funcs: + for entry, stored_crc, ranges in funcs: if not ranges: - continue + return [] crc = 0 - ok = True + normalized = [] + entry_phys = entry & 0x1FFFFFFF for lo, length in ranges: - off = (lo & 0x1FFFFFFF) - ov_lo + phys = lo & 0x1FFFFFFF + off = phys - ov_lo + if ((phys | length) & 3) or length < 4: + return [] if off < 0 or off + length > len(data): - ok = False # range outside captured bytes — can't hash reliably - break + return [] crc = binascii.crc32(data[off:off + length], crc) - if not ok: - continue - ev = (entry & 0x1FFFFFFF) | 0x80000000 - out.append((ev, crc & 0xFFFFFFFF, ranges)) + hi = phys + length + if any(phys < old_hi and old_lo < hi + for old_lo, old_hi in normalized): + return [] + normalized.append((phys, hi)) + if not any(lo <= entry_phys and entry_phys + 4 <= hi + for lo, hi in normalized): + return [] + crc &= 0xFFFFFFFF + if stored_crc is not None and stored_crc != crc: + return [] + ev = entry_phys | 0x80000000 + out.append((ev, crc, ranges)) return out +def _mips_control_kind(instr: int) -> int: + """1=supported delayed transfer, 0=ordinary, -1=reserved/unsupported.""" + op = (instr >> 26) & 0x3F + if op == 0: + return int((instr & 0x3F) in (0x08, 0x09)) # JR/JALR + if op == 0x01: + return 1 if ((instr >> 16) & 0x1F) in (0x00, 0x01, 0x10, 0x11) else -1 + if op in (0x02, 0x03) or 0x04 <= op <= 0x07: + return 1 + if 0x14 <= op <= 0x17: + return -1 + if 0x10 <= op <= 0x13 and ((instr >> 21) & 0x1F) == 0x08: + return -1 + return 0 + + +def audit_func_id_delay_slots(func_ids: list, data: bytes, + load_addr: int) -> list: + """Return unsafe emitted dependency errors for exact function identities. + + Every native delay slot must exist in this coherent capture and inside the + same function's hashed range union. This packaging audit is intentionally + redundant with the recompiler and loader: it prevents an unsafe DLL/ranges + pair from being published or reused even when one layer is stale. + """ + errors = [] + base = load_addr & 0x1FFFFFFF + for entry, _crc, ranges in func_ids: + if len(ranges) > 16: + errors.append((entry, None, + f'{len(ranges)} ranges exceed loader limit 16')) + continue + normalized = [] + malformed = False + for lo, length in ranges: + phys = lo & 0x1FFFFFFF + off = phys - base + if (phys & 3) or (length & 3) or length < 4 or \ + off < 0 or off + length > len(data): + errors.append((entry, phys | 0x80000000, + 'malformed/out-of-capture code range')) + malformed = True + break + normalized.append((phys, phys + length)) + if malformed: + continue + + def contains_word(addr: int) -> bool: + return any(lo <= addr and addr + 4 <= hi + for lo, hi in normalized) + + controls = set() + for lo, hi in normalized: + for pc in range(lo, hi, 4): + off = pc - base + instr = int.from_bytes(data[off:off + 4], 'little') + kind = _mips_control_kind(instr) + if kind < 0: + errors.append((entry, pc | 0x80000000, + 'reserved/unsupported branch encoding')) + elif kind > 0: + controls.add(pc) + if not contains_word(pc + 4): + errors.append((entry, pc | 0x80000000, + f'delay slot 0x{(pc + 4) | 0x80000000:08X} ' + 'is not identity-hashed')) + delay_pcs = {pc + 4 for pc in controls} + nested = sorted(controls & delay_pcs) + for pc in nested: + errors.append((entry, pc | 0x80000000, + 'control transfer in a delay slot')) + return errors + + def write_overlay_ranges_from(func_ids: list, out_path: str) -> int: """Write the {phys}_{key}.ranges manifest (v2) from a func-id list produced by parse_overlay_func_ids. Returns the number of functions written. @@ -1474,7 +1650,8 @@ def write_overlay_ranges(src_path: str, out_path: str, parse_overlay_func_ids(src_path, data, load_addr, size), out_path) -def load_region_coverage(cache_dir: str, phys_addr: int) -> set: +def load_region_coverage(cache_dir: str, phys_addr: int, data: bytes, + load_addr: int, size: int) -> set: """Set of (ev, code_crc) function identities already provided by built DLLs for this region_start. The loader content-matches per function across ALL DLLs sharing a region_start, so a function is "covered" as soon as ANY @@ -1490,17 +1667,21 @@ def load_region_coverage(cache_dir: str, phys_addr: int) -> set: for name in names: if not (name.startswith(prefix) and name.endswith('.ranges')): continue - try: - with open(os.path.join(cache_dir, name)) as f: - for ln in f: - p = ln.split() - if len(p) >= 3 and p[0] == 'F': - try: - covered.add((int(p[1], 16), int(p[2], 16))) - except ValueError: - pass - except OSError: - pass + # A sidecar is not a committed cache entry by itself. A process can + # stop between the two same-directory renames, or an antivirus/loader + # lock can reject one of them on Windows. Never let an orphan ranges + # file poison coverage and suppress a future rebuild. + dll_name = name[:-len('.ranges')] + overlay_ext() + if not bundle_path_is_immutable(dll_name): + continue + if not os.path.isfile(os.path.join(cache_dir, dll_name)): + continue + manifest = os.path.join(cache_dir, name) + func_ids = parse_overlay_func_ids( + manifest, data, load_addr, size, require_stored_crc=True) + if not func_ids or audit_func_id_delay_slots(func_ids, data, load_addr): + continue + covered.update((ev, crc) for ev, crc, _ranges in func_ids) return covered @@ -1515,7 +1696,8 @@ def _addr_in_func_ids(addr: int, func_ids: list) -> bool: return False -def load_region_entry_set(cache_dir: str, phys_addr: int) -> set: +def load_region_entry_set(cache_dir: str, phys_addr: int, data: bytes, + load_addr: int, size: int) -> set: """Set of phys-normalized F-line ENTRY addresses provided by ALL built DLLs (region + fragment) for this region_start. This — not range coverage — is the dispatchability test: native code is enterable ONLY at F entries, so a @@ -1533,17 +1715,17 @@ def load_region_entry_set(cache_dir: str, phys_addr: int) -> set: for name in names: if not (name.startswith(prefix) and name.endswith('.ranges')): continue - try: - with open(os.path.join(cache_dir, name)) as f: - for ln in f: - p = ln.split() - if len(p) >= 2 and p[0] == 'F': - try: - out.add(int(p[1], 16) & 0x1FFFFFFF) - except ValueError: - pass - except OSError: - pass + dll_name = name[:-len('.ranges')] + overlay_ext() + if not bundle_path_is_immutable(dll_name): + continue + if not os.path.isfile(os.path.join(cache_dir, dll_name)): + continue + manifest = os.path.join(cache_dir, name) + func_ids = parse_overlay_func_ids( + manifest, data, load_addr, size, require_stored_crc=True) + if not func_ids or audit_func_id_delay_slots(func_ids, data, load_addr): + continue + out.update(ev & 0x1FFFFFFF for ev, _crc, _ranges in func_ids) return out @@ -1634,6 +1816,8 @@ def generate_interior_fragment_static(interior: int, data: bytes, if audit['unknown_bad'] or audit['unsupported_todo_addrs']: return None func_ids = parse_overlay_func_ids(ranges_src, data, load_addr, size) + if audit_func_id_delay_slots(func_ids, data, load_addr): + return None ids_by_addr = {} for ev, code_crc, ranges in func_ids: ids_by_addr.setdefault(ev, []).append((code_crc, ranges)) @@ -1748,6 +1932,12 @@ def compile_interior_fragment(interior: int, data: bytes, load_addr: int, frag_ids = parse_overlay_func_ids(ranges_src, data, load_addr, size) if not frag_ids: return None, 'no-func-ids (empty ranges manifest)' + delay_errors = audit_func_id_delay_slots(frag_ids, data, load_addr) + if delay_errors: + entry, pc, reason = delay_errors[0] + where = f' at 0x{pc:08X}' if pc is not None else '' + return None, (f'delay-slot-identity-audit: func 0x{entry:08X}' + f'{where}: {reason}') # Key the fragment DLL by its func-identity SET (dedup like a region # bundle); the loader keys DLLs by the region_start filename prefix and # content-matches each function, so a fragment is just another DLL for @@ -1756,8 +1946,12 @@ def compile_interior_fragment(interior: int, data: bytes, load_addr: int, struct.pack(' bool: + try: + with open(path, 'rb') as f: + return f.read() == expected + except OSError: + return False + + for attempt in range(256): + key_bytes = (digest[attempt * 4:attempt * 4 + 4] + if attempt < 8 else + hashlib.sha256(digest + attempt.to_bytes(4, 'little')).digest()[:4]) + key = int.from_bytes(key_bytes, 'big') + final_stem = os.path.join(directory, f'{logical}_{key:08X}') + candidate_dll = final_stem + ext + candidate_ranges = final_stem + '.ranges' + if os.path.exists(candidate_dll) or os.path.exists(candidate_ranges): + if (file_equals(candidate_dll, dll_bytes) and + file_equals(candidate_ranges, ranges_bytes)): + published_dll = candidate_dll + published_ranges = candidate_ranges + break + continue + try: + os.link(tmp_ranges, candidate_ranges) + os.link(tmp_dll, candidate_dll) # immutable commit marker, always last + except FileExistsError: + if (file_equals(candidate_dll, dll_bytes) and + file_equals(candidate_ranges, ranges_bytes)): + published_dll = candidate_dll + published_ranges = candidate_ranges + break + continue + published_dll, published_ranges = candidate_dll, candidate_ranges + break + if not published_dll or not published_ranges: + raise OSError('could not allocate an immutable shard bundle name') + # Machine-readable live handoff. flush=True is essential because stdout # is a pipe under the runtime and a completed shard must not wait in # Python's block buffer until the whole batch exits. - print(f'PSX_SHARD_PUBLISHED {out_dll}', flush=True) + print(f'PSX_SHARD_PUBLISHED {published_dll}', flush=True) return True, nfunc except OSError as exc: print(f' PUBLISH ERROR: {exc}') @@ -2185,6 +2425,10 @@ def main(): static_entry_sources[entry] = ( data, load_addr, size, phys_addr) + if not args.static: + dll_path = os.path.join( + cache_dir, f'{phys_addr:08X}_{crc32:08X}{overlay_ext()}') + # Merge evidence from a prior build of the SAME bytes: every F entry # in an existing ranges manifest for this exact image was proven # compilable before, so a fresh (poorer) capture can't regress @@ -2194,31 +2438,26 @@ def main(): # re-derives their interior/alias disposition — feeding them back as # roots would truncate their hosts. if not args.static: - ranges_name = f'{phys_addr:08X}_{crc32:08X}.ranges' - prior_ranges = os.path.join(cache_dir, ranges_name) - if os.path.exists(prior_ranges): - prior_entries = [] - with open(prior_ranges) as pf: - for ln in pf: - parts = ln.split() - if parts and parts[0] == 'F': - try: - prior_entries.append(int(parts[1], 16)) - except (IndexError, ValueError): - pass - if prior_entries: - fe = _parse_addr_list(cap.get('function_entry_pcs', [])) - de = _parse_addr_list(cap.get('dispatch_entry_pcs', [])) - for a in prior_entries: - if _callable_legacy_seed(data, load_addr, a): - fe.add(a) - else: - de.add(a) - cap['function_entry_pcs'] = sorted(fe) - cap['dispatch_entry_pcs'] = sorted(de) - cap.setdefault('schema', 'merged') - print(f' merged {len(prior_entries)} prior-manifest entries ' - f'from {prior_ranges}') + prior_entries = set() + for (_prior_dll, prior_ranges, prior_ids, + prior_errors) in validated_cached_bundle_ids( + dll_path, data, load_addr, size): + if not prior_ids or prior_errors: + continue + prior_entries.update(ev for ev, _crc, _ranges in prior_ids) + if prior_entries: + fe = _parse_addr_list(cap.get('function_entry_pcs', [])) + de = _parse_addr_list(cap.get('dispatch_entry_pcs', [])) + for a in prior_entries: + if _callable_legacy_seed(data, load_addr, a): + fe.add(a) + else: + de.add(a) + cap['function_entry_pcs'] = sorted(fe) + cap['dispatch_entry_pcs'] = sorted(de) + cap.setdefault('schema', 'merged') + print(f' merged {len(prior_entries)} validated prior-manifest ' + 'entries from additive bundles') seeds, seed_audit = classify_overlay_seeds(cap, data, load_addr, size, crc32, toml) @@ -2242,9 +2481,6 @@ def main(): interior_frag_jobs.append((phys_addr, load_addr, size, data, _interiors | _disp_roots, _executed)) - if not args.static: - dll_path = os.path.join(cache_dir, f'{phys_addr:08X}_{crc32:08X}{overlay_ext()}') - print(f'Overlay load=0x{load_addr:08X} size={size} crc32=0x{crc32:08X}') if args.static: print(f' seeds: {len(seeds)} mode: static -> {static_out}') @@ -2258,10 +2494,32 @@ def main(): stats.add_skip() return - if not args.static and os.path.exists(dll_path) and not args.force: - print(' SKIP: DLL already exists (use --force to recompile)\n') - stats.add_skip() - return + if not args.static and not args.force: + required_entries = { + int(seed.split()[-1], 16) & 0xFFFFFFFF + for seed in root_seeds + } + cached_entries = set() + for (_cached_dll, ranges_path, existing_ids, + existing_delay_errors) in authoritative_cached_bundle_ids( + dll_path, data, load_addr, size): + if existing_ids and not existing_delay_errors: + cached_entries.update( + ev for ev, _crc, _ranges in existing_ids) + continue + reason = (existing_delay_errors[0][2] + if existing_delay_errors else 'empty/unverifiable manifest') + print(f' STALE/UNSAFE existing pair: {reason}; rebuilding fail-closed') + if required_entries and required_entries <= cached_entries: + print(' SKIP: additive shard bundles cover every requested entry ' + '(use --force to recompile)\n') + stats.add_skip() + return + if cached_entries: + missing = sorted(required_entries - cached_entries) + print(' INCOMPLETE existing bundles: missing requested entries ' + + ', '.join(f'0x{entry:08X}' for entry in missing) + + '; rebuilding additively') with tempfile.TemporaryDirectory() as tmp: # Write fake PS-EXE. The header entry PC becomes a walk root in the @@ -2348,6 +2606,15 @@ def main(): func_ids = parse_overlay_func_ids(ranges_src, data, load_addr, size) + delay_errors = audit_func_id_delay_slots( + func_ids, data, load_addr) + if delay_errors: + entry, pc, reason = delay_errors[0] + where = f' at 0x{pc:08X}' if pc is not None else '' + print(f' STATIC DELAY-SLOT IDENTITY AUDIT FAILED: ' + f'func 0x{entry:08X}{where}: {reason}\n') + stats.add_fail(_label, 'delay_slot_identity', reason) + return ids_by_addr = {} for ev, code_crc, ranges in func_ids: ids_by_addr.setdefault(ev, []).append((code_crc, ranges)) @@ -2421,12 +2688,22 @@ def main(): # an endless pile of redundant DLLs. this_ids = (parse_overlay_func_ids(ranges_src, data, load_addr, size) if ranges_src else []) + delay_errors = audit_func_id_delay_slots( + this_ids, data, load_addr) + if delay_errors: + entry, pc, reason = delay_errors[0] + where = f' at 0x{pc:08X}' if pc is not None else '' + print(f' DELAY-SLOT IDENTITY AUDIT FAILED: ' + f'func 0x{entry:08X}{where}: {reason}\n') + stats.add_fail(_label, 'delay_slot_identity', reason) + return this_set = {(ev, crc) for ev, crc, _ in this_ids} with cov_lock: covered = region_coverage_cache.get(phys_addr) if covered is None: - covered = load_region_coverage(cache_dir, phys_addr) + covered = load_region_coverage( + cache_dir, phys_addr, data, load_addr, size) region_coverage_cache[phys_addr] = covered fully_covered = (bool(this_set) and this_set <= covered and not args.force) @@ -2451,8 +2728,7 @@ def main(): compiler=args.compiler, tcc=args.tcc) if success: if this_ids: - print(f' ranges: {nfn} functions -> ' - f'{os.path.splitext(dll_path)[0]}.ranges') + print(f' ranges: {nfn} functions in immutable shard bundle') # New identities are now available for this region_start; # keep the warm coverage set current so later captures in # this same run dedup against them. (Parallel note: the @@ -2463,7 +2739,7 @@ def main(): # DLLs at a region.) with cov_lock: covered |= this_set - print(f' OK -> {dll_path}\n') + print(' OK -> immutable shard bundle published\n') stats.add_ok() else: print(' WARNING: recompiler emitted no _full.ranges — ' @@ -2511,7 +2787,8 @@ def main(): # range" does NOT make a dispatch target servable — a range- # covered PC with no F entry anywhere still interps its whole # chain on every dispatch. Demand an entry at exactly this PC. - covered_entries = load_region_entry_set(cache_dir, phys_addr) + covered_entries = load_region_entry_set( + cache_dir, phys_addr, data, load_addr, size) orphans = sorted(a for a in interior_pcs if (a in executed or a in forced_interiors) and (a & 0x1FFFFFFF) not in covered_entries) diff --git a/tools/test_compile_overlays_additive.py b/tools/test_compile_overlays_additive.py index 39d5ebee..975d29a7 100644 --- a/tools/test_compile_overlays_additive.py +++ b/tools/test_compile_overlays_additive.py @@ -27,7 +27,149 @@ def region(load_addr, payload, executed=(), dispatch=(), functions=(), seeds=Non class AdditiveCaptureTests(unittest.TestCase): - def test_live_dll_publication_uses_private_output_and_commits_dll_last(self): + def test_cached_bundle_pairs_preserves_logical_key_for_immutable_names(self): + with tempfile.TemporaryDirectory() as td: + logical = Path(td) / "00010000_DEADBEEF.dll" + legacy_ranges = logical.with_suffix(".ranges") + logical.write_bytes(b"legacy") + legacy_ranges.write_text("legacy", encoding="ascii") + + immutable = Path(td) / "00010000_DEADBEEF_13579BDF.dll" + immutable_ranges = immutable.with_suffix(".ranges") + immutable.write_bytes(b"immutable") + immutable_ranges.write_text("immutable", encoding="ascii") + + # A different logical key must not be treated as the same cache + # identity merely because it shares the region prefix. + other = Path(td) / "00010000_FEEDFACE_2468ACE0.dll" + other.write_bytes(b"other") + other.with_suffix(".ranges").write_text("other", encoding="ascii") + + pairs = set(compile_overlays.cached_bundle_pairs(str(logical))) + self.assertEqual(pairs, { + (str(logical), str(legacy_ranges)), + (str(immutable), str(immutable_ranges)), + }) + + def test_validated_same_logical_artifacts_union_additive_entries(self): + with tempfile.TemporaryDirectory() as td: + load = 0x80010000 + payload = b"\0" * 8 + crc = compile_overlays.binascii.crc32(b"\0" * 4) & 0xFFFFFFFF + logical = Path(td) / "00010000_DEADBEEF.dll" + entries = set() + for artifact, entry in (("11111111", load), + ("22222222", load + 4)): + dll = Path(td) / f"00010000_DEADBEEF_{artifact}.dll" + dll.write_bytes(b"dll") + dll.with_suffix(".ranges").write_text( + "# psxrecomp overlay code-range manifest v2\n" + f"F {entry:08X} {crc:08X}\nR {entry:08X} 4\n", + encoding="ascii") + + bundles = list(compile_overlays.validated_cached_bundle_ids( + str(logical), payload, load, len(payload))) + self.assertEqual(len(bundles), 2) + for _dll, _ranges, func_ids, errors in bundles: + self.assertFalse(errors) + entries.update(ev for ev, _crc, _func_ranges in func_ids) + self.assertEqual(entries, {load, load + 4}) + + def test_legacy_pair_is_seed_evidence_not_authoritative_coverage(self): + with tempfile.TemporaryDirectory() as td: + load = 0x80010000 + payload = b"\0" * 4 + crc = compile_overlays.binascii.crc32(payload) & 0xFFFFFFFF + logical = Path(td) / "00010000_DEADBEEF.dll" + logical.write_bytes(b"old-unbound-dll") + logical.with_suffix(".ranges").write_text( + "# psxrecomp overlay code-range manifest v2\n" + f"F {load:08X} {crc:08X}\nR {load:08X} 4\n", + encoding="ascii") + + seed_evidence = list( + compile_overlays.validated_cached_bundle_ids( + str(logical), payload, load, len(payload))) + authoritative = list( + compile_overlays.authoritative_cached_bundle_ids( + str(logical), payload, load, len(payload))) + self.assertEqual(len(seed_evidence), 1) + self.assertTrue(seed_evidence[0][2]) + self.assertEqual(authoritative, []) + + def test_delay_slot_identity_audit_requires_cross_page_slot(self): + load = 0x80010FF0 + # NOPs through FF8, BEQ at FFC, ADDIU delay slot at 1000. + payload = (b"\0" * 12 + + (0x10800008).to_bytes(4, "little") + + (0x24020001).to_bytes(4, "little")) + safe = [(load, 0, [(load, len(payload))])] + unsafe = [(load, 0, [(load, len(payload) - 4)])] + self.assertEqual( + compile_overlays.audit_func_id_delay_slots(safe, payload, load), []) + errors = compile_overlays.audit_func_id_delay_slots( + unsafe, payload[:-4], load) + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0][1], 0x80010FFC) + self.assertIn("0x80011000", errors[0][2]) + + def test_delay_slot_identity_audit_rejects_nested_control_flow(self): + load = 0x80020000 + # BEQ followed by JR in its delay slot; both words are present, but the + # interpreter treats control flow in a delay slot as unsupported. + payload = ((0x10000001).to_bytes(4, "little") + + (0x03E00008).to_bytes(4, "little") + + (0x00000000).to_bytes(4, "little")) + ids = [(load, 0, [(load, len(payload))])] + errors = compile_overlays.audit_func_id_delay_slots(ids, payload, load) + self.assertTrue(any("control transfer in a delay slot" in e[2] + for e in errors)) + + def test_delay_slot_identity_audit_rejects_reserved_branch_likely(self): + load = 0x80020000 + payload = ((0x50800001).to_bytes(4, "little") + + (0x00000000).to_bytes(4, "little")) + ids = [(load, 0, [(load, len(payload))])] + errors = compile_overlays.audit_func_id_delay_slots(ids, payload, load) + self.assertTrue(any("reserved/unsupported" in e[2] for e in errors)) + + def test_published_manifest_parse_is_all_or_nothing(self): + with tempfile.TemporaryDirectory() as td: + load = 0x80010000 + payload = b"\0" * 8 + crc = compile_overlays.binascii.crc32(payload) & 0xFFFFFFFF + manifest = Path(td) / "bundle.ranges" + manifest.write_text( + f"F {load:08X} {crc:08X}\nR {load:08X} 8\n", + encoding="ascii") + parsed = compile_overlays.parse_overlay_func_ids( + str(manifest), payload, load, len(payload), + require_stored_crc=True) + self.assertEqual(len(parsed), 1) + + manifest.write_text( + f"F {load + 0x20000000:08X} {crc:08X}\n" + f"R {load + 0x20000000:08X} 8\n", + encoding="ascii") + self.assertEqual(compile_overlays.parse_overlay_func_ids( + str(manifest), payload, load, len(payload), + require_stored_crc=True), []) + + manifest.write_text( + f"F {load:08X}\nR {load:08X} 8\n", encoding="ascii") + self.assertEqual(compile_overlays.parse_overlay_func_ids( + str(manifest), payload, load, len(payload), + require_stored_crc=True), []) + + manifest.write_text( + f"F {load:08X} {crc:08X}\nR {load:08X} 8\n" + f"F {load + 4:08X} 00000000\nR {load + 8:08X} 4\n", + encoding="ascii") + self.assertEqual(compile_overlays.parse_overlay_func_ids( + str(manifest), payload, load, len(payload), + require_stored_crc=True), []) + + def test_live_dll_publication_is_immutable_and_dll_commits_last(self): with tempfile.TemporaryDirectory() as td: out_dll = Path(td) / "00010000_DEADBEEF.dll" source = Path(td) / "source.c" @@ -43,26 +185,144 @@ class AdditiveCaptureTests(unittest.TestCase): observed.append(private) return True - real_replace = compile_overlays.os.replace + real_link = compile_overlays.os.link - def observe_replace(source_path, dest_path): + def observe_link(source_path, dest_path): observed.append(Path(dest_path)) - real_replace(source_path, dest_path) + real_link(source_path, dest_path) with mock.patch.object(compile_overlays, "compile_dll", side_effect=fake_compile), \ - mock.patch.object(compile_overlays.os, "replace", - side_effect=observe_replace): + mock.patch.object(compile_overlays.os, "link", + side_effect=observe_link): ok, count = compile_overlays.compile_and_publish_dll( str(source), str(out_dll), [], func_ids) self.assertTrue(ok) self.assertEqual(count, 1) - self.assertEqual(out_dll.read_bytes(), b"complete-dll") - self.assertTrue(out_dll.with_suffix(".ranges").exists()) - self.assertEqual(observed[-1], out_dll) + published = list(Path(td).glob("00010000_*.dll")) + self.assertEqual(len(published), 1) + self.assertNotEqual(published[0], out_dll) + self.assertRegex( + published[0].name, + r"^00010000_DEADBEEF_[0-9A-F]{8}\.dll$") + self.assertEqual(published[0].read_bytes(), b"complete-dll") + self.assertTrue(published[0].with_suffix(".ranges").exists()) + self.assertEqual(observed[-2:], + [published[0].with_suffix(".ranges"), published[0]]) self.assertFalse(list(Path(td).glob("*.tmp.*"))) + def test_live_publication_first_link_failure_leaves_no_pair(self): + with tempfile.TemporaryDirectory() as td: + out_dll = Path(td) / "00010000_DEADBEEF.dll" + source = Path(td) / "source.c" + source.write_text("/* test */", encoding="utf-8") + func_ids = [(0x80010000, 0x12345678, [(0x80010000, 4)])] + + def fake_compile(_source, private_dll, _includes, **_kwargs): + Path(private_dll).write_bytes(b"complete-dll") + return True + + with mock.patch.object(compile_overlays, "compile_dll", + side_effect=fake_compile), \ + mock.patch.object(compile_overlays.os, "link", + side_effect=OSError("simulated DLL lock")): + ok, count = compile_overlays.compile_and_publish_dll( + str(source), str(out_dll), [], func_ids) + + self.assertFalse(ok) + self.assertEqual(count, 0) + self.assertFalse(list(Path(td).glob("00010000_*.dll"))) + self.assertFalse(list(Path(td).glob("00010000_*.ranges"))) + + def test_live_publication_second_link_failure_leaves_only_ignored_ranges(self): + with tempfile.TemporaryDirectory() as td: + out_dll = Path(td) / "00010000_DEADBEEF.dll" + source = Path(td) / "source.c" + source.write_text("/* test */", encoding="utf-8") + func_ids = [(0x80010000, 0x12345678, [(0x80010000, 4)])] + real_link = compile_overlays.os.link + calls = 0 + + def fake_compile(_source, private_dll, _includes, **_kwargs): + Path(private_dll).write_bytes(b"complete-dll") + return True + + def fail_second_link(source_path, dest_path): + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("simulated ranges lock") + real_link(source_path, dest_path) + + with mock.patch.object(compile_overlays, "compile_dll", + side_effect=fake_compile), \ + mock.patch.object(compile_overlays.os, "link", + side_effect=fail_second_link): + ok, count = compile_overlays.compile_and_publish_dll( + str(source), str(out_dll), [], func_ids) + + self.assertFalse(ok) + self.assertEqual(count, 0) + self.assertFalse(list(Path(td).glob("00010000_*.dll"))) + self.assertEqual(len(list(Path(td).glob("00010000_*.ranges"))), 1) + self.assertEqual(compile_overlays.load_region_coverage( + td, 0x00010000, b"\0" * 4, 0x80010000, 4), set()) + + def test_coverage_and_entries_ignore_orphan_ranges(self): + with tempfile.TemporaryDirectory() as td: + load = 0x80010000 + payload = b"\0" * 4 + crc = compile_overlays.binascii.crc32(payload) & 0xFFFFFFFF + ranges = Path(td) / "00010000_DEADBEEF_13579BDF.ranges" + ranges.write_text( + "# psxrecomp overlay code-range manifest v2\n" + f"F 80010000 {crc:08X}\nR 80010000 4\n", + encoding="utf-8") + + self.assertEqual(compile_overlays.load_region_coverage( + td, 0x00010000, payload, load, len(payload)), set()) + self.assertEqual(compile_overlays.load_region_entry_set( + td, 0x00010000, payload, load, len(payload)), set()) + + # Legacy DLL/ranges pairs predate bound publication and may be a + # strict new manifest beside an old DLL after a crash. They remain + # seed evidence, but cannot suppress the first immutable rebuild. + legacy = Path(td) / "00010000_DEADBEEF.ranges" + legacy.write_text( + "# psxrecomp overlay code-range manifest v2\n" + f"F 80010000 {crc:08X}\nR 80010000 4\n", + encoding="utf-8") + legacy.with_suffix(compile_overlays.overlay_ext()).write_bytes(b"old-dll") + self.assertFalse(compile_overlays.bundle_path_is_immutable( + str(legacy.with_suffix(compile_overlays.overlay_ext())))) + self.assertEqual(compile_overlays.load_region_coverage( + td, 0x00010000, payload, load, len(payload)), set()) + self.assertEqual(compile_overlays.load_region_entry_set( + td, 0x00010000, payload, load, len(payload)), set()) + ranges.with_suffix(compile_overlays.overlay_ext()).write_bytes(b"dll") + self.assertEqual( + compile_overlays.load_region_coverage( + td, 0x00010000, payload, load, len(payload)), + {(0x80010000, crc)}) + self.assertEqual( + compile_overlays.load_region_entry_set( + td, 0x00010000, payload, load, len(payload)), + {0x00010000}) + + # A paired manifest with a valid-looking prefix and malformed tail + # must contribute nothing. Otherwise it can poison additive + # coverage and suppress publication of its own valid repair. + ranges.write_text( + "# psxrecomp overlay code-range manifest v2\n" + f"F 80010000 {crc:08X}\nR 80010000 4\n" + "TRAILING GARBAGE\n", + encoding="utf-8") + self.assertEqual(compile_overlays.load_region_coverage( + td, 0x00010000, payload, load, len(payload)), set()) + self.assertEqual(compile_overlays.load_region_entry_set( + td, 0x00010000, payload, load, len(payload)), set()) + def test_unions_evidence_but_preserves_reused_address_variants(self): with tempfile.TemporaryDirectory() as td: base = Path(td) / "overlay_captures.json"