From 2952c2a4590fa703eff96b5b3da6b06973b7a525 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 11 Oct 2026 23:47:01 -0700 Subject: [PATCH 01/17] display_ring_get: decode whole display (480i, depth24) from the frame's VRAM snapshot Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/include/gpu.h | 5 +++ runtime/src/debug_server.c | 92 +++++++++++++++----------------------- runtime/src/gpu.c | 31 +++++++------ 3 files changed, 60 insertions(+), 68 deletions(-) diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index b2821fcd..868b8506 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -64,6 +64,11 @@ void gpu_texture_correction_stats(uint64_t *attempts, uint64_t *armed, int gpu_display_is_depth24(void); void gpu_display_pixel_rgb(const GpuDisplayInfo* di, uint32_t x, uint32_t y, uint8_t* r, uint8_t* g, uint8_t* b); +/* Same decode against any 1024x512 VRAM image (e.g. a display-ring snapshot) + * instead of live VRAM: 15-bit or depth24 per `di`. */ +void gpu_display_pixel_rgb_in(const uint16_t* image, const GpuDisplayInfo* di, + uint32_t x, uint32_t y, + uint8_t* r, uint8_t* g, uint8_t* b); uint32_t gpu_display_pixel_argb(const GpuDisplayInfo* di, uint32_t x, uint32_t y); /* Batch equivalent of calling gpu_display_pixel_argb(di, x, y) for x in * [0, count) on a depth24 (FMV) scanline — same output, far less per-pixel diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 27b495ea..3e4b6009 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9925,23 +9925,22 @@ static void handle_get_snapshots(int id, const char *json) * deterministic runs (GL vs software) can each serve the display for the * SAME frame number, giving a pixel-exact renderer-divergence census. * GL reads GPU-side truth via the fbo peek; software reads CPU VRAM (its - * authoritative surface). depth24 scanout frames are tagged and refused - * (15-bit only). */ -#define DISP_RING_CAP 64 /* full-VRAM entries are 1MB each; 32 frames of - * lookback (~0.3-0.5s) for exact-frame forensics */ -#define DISP_RING_MAX_W 640 -#define DISP_RING_MAX_H 256 -/* Full-VRAM aux capture alongside the display: the entire 1024x512 raw VRAM - * as the renderer sees it at this frame (GL: FBO truth; SW: CPU truth), so an - * offline client can micro-cosim ANY prim of the frame — rasterize it from - * the GP0 ring against the exact same-frame texels/CLUTs — and attribute a - * bad pixel to sampling vs content. Streamed texture pages and palette - * cycling make later peeks worthless; only a same-frame capture is evidence. */ + * authoritative surface). Each entry is the full 1024x512 VRAM plus the + * display geometry of that frame, and the PNG is decoded from it with the + * same scanout decode as screenshot_file — so 480-line interlaced displays + * and depth24 (FMV) frames are served whole, not cropped or refused. */ +#define DISP_RING_CAP 64 /* full-VRAM entries are 1MB each; ~1s of + * lookback for exact-frame forensics */ +/* The entire 1024x512 raw VRAM as the renderer sees it at this frame (GL: FBO + * truth; SW and depth24: CPU truth), so an offline client can micro-cosim ANY + * prim of the frame — rasterize it from the GP0 ring against the exact + * same-frame texels/CLUTs — and attribute a bad pixel to sampling vs content. + * Streamed texture pages and palette cycling make later peeks worthless; only + * a same-frame capture is evidence. */ typedef struct { uint32_t frame; - uint16_t w, h; - uint8_t depth24, valid; - uint16_t *px; /* DISP_RING_MAX_W*DISP_RING_MAX_H halfwords */ + uint8_t valid; + GpuDisplayInfo di; /* display geometry/depth at capture */ uint16_t *vram; /* full 1024x512 */ } DispRingEntry; static PSX_BSS DispRingEntry s_disp_ring[DISP_RING_CAP]; @@ -9966,15 +9965,11 @@ static void disp_ring_capture(void) } if (!enabled || s_capture_frozen) return; if (!s_disp_ring_px) { - size_t per = (size_t)DISP_RING_MAX_W * DISP_RING_MAX_H; size_t vram = (size_t)1024 * 512; - s_disp_ring_px = (uint16_t *)malloc(DISP_RING_CAP * (per + vram) * sizeof(uint16_t)); + s_disp_ring_px = (uint16_t *)malloc(DISP_RING_CAP * vram * sizeof(uint16_t)); if (!s_disp_ring_px) return; - for (int i = 0; i < DISP_RING_CAP; i++) { - uint16_t *base = s_disp_ring_px + (size_t)i * (per + vram); - s_disp_ring[i].px = base; - s_disp_ring[i].vram = base + per; - } + for (int i = 0; i < DISP_RING_CAP; i++) + s_disp_ring[i].vram = s_disp_ring_px + (size_t)i * vram; } DispRingEntry *e = &s_disp_ring[(uint32_t)(s_frame_count % DISP_RING_CAP)]; e->frame = (uint32_t)s_frame_count; @@ -9983,30 +9978,15 @@ static void disp_ring_capture(void) gpu_get_display_info(&di); if (di.disabled || di.width == 0 || di.height == 0) return; gpu_timeline_note(GTL_DISP_CAPTURE, 0, di.display_x | (di.display_y << 16)); - uint32_t w = di.width, h = di.height; - if (w > DISP_RING_MAX_W) w = DISP_RING_MAX_W; - if (h > DISP_RING_MAX_H) h = DISP_RING_MAX_H; - e->w = (uint16_t)w; e->h = (uint16_t)h; - e->depth24 = (uint8_t)(di.depth24 ? 1 : 0); - /* GPU-side truth when the GL raster pipeline is live (scissored pack of - * just the display rect + one small readback); CPU VRAM otherwise (the - * software backend's authoritative surface). Runs on the present thread, - * where the GL context is current. */ - /* Deferred: with the render thread on, the readback is recorded at this - * point of the command stream and filled in by the render thread (ring - * readers sync first), instead of a per-frame sync point. */ - int got = 0; - if (di.display_x + w <= 1024 && di.display_y + h <= 512) - got = gl_renderer_fbo_peek_deferred((int)di.display_x, (int)di.display_y, - (int)w, (int)h, e->px); - if (!got) { - for (uint32_t y = 0; y < h; y++) - for (uint32_t x = 0; x < w; x++) - e->px[y * w + x] = - gpu_vram_peek((int)(di.display_x + x), (int)(di.display_y + y)); - } - /* Full-VRAM aux capture (same GL-truth/CPU-truth split as the display). */ - if (!gl_renderer_fbo_peek_deferred(0, 0, 1024, 512, e->vram)) { + e->di = di; + /* GPU-side truth when the GL raster pipeline is live; CPU VRAM otherwise + * (the software backend's authoritative surface). depth24 always reads + * the CPU mirror: packed RGB888 MDEC bytes live there, not in the FBO + * (see screenshot_file). Runs on the present thread, where the GL context + * is current. Deferred: with the render thread on, the readback is + * recorded at this point of the command stream and filled in by the + * render thread (ring readers sync first). */ + if (di.depth24 || !gl_renderer_fbo_peek_deferred(0, 0, 1024, 512, e->vram)) { const uint16_t *v = gpu_get_vram(); memcpy(e->vram, v, (size_t)1024 * 512 * sizeof(uint16_t)); } @@ -10069,16 +10049,17 @@ static void handle_display_ring_get(int id, const char *json) if (!e->valid || e->frame != (uint32_t)f) { send_err(id, "frame not in display ring"); return; } - if (e->depth24) { send_err(id, "frame is 24bpp scanout (unsupported)"); return; } - uint32_t w = e->w, h = e->h; + /* Same clamp and scanout decode as screenshot_file, against this frame's + * VRAM snapshot. */ + uint32_t w = e->di.width; if (w > 640) w = 640; + uint32_t h = e->di.height; if (h > 512) h = 512; uint8_t *rgb = (uint8_t *)malloc((size_t)w * h * 3); if (!rgb) { send_err(id, "alloc failed"); return; } - for (uint32_t i = 0; i < w * h; i++) { - uint16_t p = e->px[i]; - rgb[i * 3 + 0] = (uint8_t)((p & 0x1F) << 3); - rgb[i * 3 + 1] = (uint8_t)(((p >> 5) & 0x1F) << 3); - rgb[i * 3 + 2] = (uint8_t)(((p >> 10) & 0x1F) << 3); - } + for (uint32_t y = 0; y < h; y++) + for (uint32_t x = 0; x < w; x++) { + uint8_t *p = rgb + ((size_t)y * w + x) * 3; + gpu_display_pixel_rgb_in(e->vram, &e->di, x, y, &p[0], &p[1], &p[2]); + } FILE *fp = fopen(path, "wb"); if (!fp) { free(rgb); send_err(id, "cannot open file"); return; } int ok = png_write_rgb(fp, rgb, w, h); @@ -10086,7 +10067,8 @@ static void handle_display_ring_get(int id, const char *json) fclose(fp); if (!ok) { send_err(id, "png encode failed"); return; } send_fmt("{\"id\":%d,\"ok\":true,\"frame\":%d,\"path\":\"%s\"," - "\"width\":%u,\"height\":%u}", id, f, path, w, h); + "\"width\":%u,\"height\":%u,\"depth24\":%d}", id, f, path, w, h, + e->di.depth24 ? 1 : 0); } static void handle_screenshot_file(int id, const char *json) diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 8101a9e8..cab5a093 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -4198,11 +4198,6 @@ const uint16_t* gpu_get_vram(void) { return vram; } -static uint8_t gpu_vram_byte(uint32_t byte_x, uint32_t y) { - uint16_t hw = vram[((y & 511u) * 1024u) + ((byte_x >> 1) & 1023u)]; - return (byte_x & 1u) ? (uint8_t)(hw >> 8) : (uint8_t)hw; -} - /* Depth24: note/query/reset the CPU→VRAM upload span tracked above. Used to * hide trailing RGB columns when a movie blit doesn't fill the full CRTC * width — MotK's Star Wars crawl leaves ~8px of stale VRAM on the right. @@ -4308,8 +4303,9 @@ static void gpu_rgb555_to_rgb888(uint16_t c, uint8_t* r, uint8_t* g, uint8_t* b) *b = (uint8_t)(((c >> 10) & 0x1Fu) << 3); } -void gpu_display_pixel_rgb(const GpuDisplayInfo* di, uint32_t x, uint32_t y, - uint8_t* r, uint8_t* g, uint8_t* b) { +void gpu_display_pixel_rgb_in(const uint16_t* image, const GpuDisplayInfo* di, + uint32_t x, uint32_t y, + uint8_t* r, uint8_t* g, uint8_t* b) { if (di->depth24) { uint32_t byte_x = ((di->display_x & 1023u) * 2u) + x * 3u; uint32_t vy = (di->display_y + y) & 511u; @@ -4320,15 +4316,24 @@ void gpu_display_pixel_rgb(const GpuDisplayInfo* di, uint32_t x, uint32_t y, *r = *g = *b = 0; return; } - *r = gpu_vram_byte(byte_x + 0u, vy); - *g = gpu_vram_byte(byte_x + 1u, vy); - *b = gpu_vram_byte(byte_x + 2u, vy); + const uint16_t* row = image + (size_t)vy * 1024u; + uint8_t px[3]; + for (uint32_t i = 0; i < 3u; i++) { + uint16_t hw = row[((byte_x + i) >> 1) & 1023u]; + px[i] = ((byte_x + i) & 1u) ? (uint8_t)(hw >> 8) : (uint8_t)hw; + } + *r = px[0]; *g = px[1]; *b = px[2]; return; } uint32_t vx = (di->display_x + x) & 1023u; uint32_t vy = (di->display_y + y) & 511u; - gpu_rgb555_to_rgb888(vram[vy * 1024u + vx], r, g, b); + gpu_rgb555_to_rgb888(image[vy * 1024u + vx], r, g, b); +} + +void gpu_display_pixel_rgb(const GpuDisplayInfo* di, uint32_t x, uint32_t y, + uint8_t* r, uint8_t* g, uint8_t* b) { + gpu_display_pixel_rgb_in(vram, di, x, y, r, g, b); } uint32_t gpu_display_pixel_argb(const GpuDisplayInfo* di, uint32_t x, uint32_t y) { @@ -4342,11 +4347,11 @@ uint32_t gpu_display_pixel_argb(const GpuDisplayInfo* di, uint32_t x, uint32_t y * output, including the black-fill past the 2048-byte VRAM row), but hoists * the per-row invariants (vy, base_byte_x, the "how many pixels are past the * row edge" test) out of the per-pixel path instead of recomputing them - * `count` times and paying three gpu_vram_byte() calls per pixel. The + * `count` times and paying three byte extractions per pixel. The * per-pixel path was the dominant present-side cost of FMV frames (3x the * VRAM touches of the 16-bit path, done as a function-call chain instead of * a straight-line loop). Still scalar C — no host-endianness assumptions, - * same byte-order shifts as gpu_vram_byte. */ + * same byte-order shifts as gpu_display_pixel_rgb_in. */ void gpu_depth24_present_row(const GpuDisplayInfo* di, uint32_t y, uint32_t* out, uint32_t count) { uint32_t vy = (di->display_y + y) & 511u; From b6c30dc87026961a7ebdc7736327aedc837fcae4 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 01:16:46 -0700 Subject: [PATCH 02/17] overlay: deliver IRQs inside call units; unconditional deferral hung polling callees The CI wrappers skipped every interrupt check while g_call_unit_depth > 0 (interp caller -> native callee). A callee polling for an IRQ-set flag (libcard MemCardSync) then spun forever: LoD "Checking MEMORY CARD". Deliver by default (thread switches are already deferred by psx_check_interrupts); legacy skip kept behind PSX_OVERLAY_UNIT_IRQ_DEFER=1. overlay_loader_status now reports call_unit_depth and ci_skip_*. Co-Authored-By: Claude Sonnet 5.5 --- runtime/CMakeLists.txt | 9 ++++ runtime/src/debug_server.c | 13 +++++ runtime/src/overlay_loader.c | 50 ++++++++++++++----- runtime/src/overlay_unit_defer.h | 41 +++++++++++++++ runtime/tests/test_overlay_unit_defer.c | 31 ++++++++++++ .../tests/test_overlay_unit_defer_wiring.py | 48 ++++++++++++++++++ 6 files changed, 179 insertions(+), 13 deletions(-) create mode 100644 runtime/src/overlay_unit_defer.h create mode 100644 runtime/tests/test_overlay_unit_defer.c create mode 100644 runtime/tests/test_overlay_unit_defer_wiring.py diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index f0732674..1e675197 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -242,6 +242,8 @@ if(BUILD_TESTING) "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_mirror_pc_preserved_guards.py") add_test(NAME segment_miss_wiring_test COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_segment_miss_wiring.py") + add_test(NAME overlay_unit_defer_wiring_test COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_overlay_unit_defer_wiring.py") if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") add_test(NAME ws_screen_tags_exec_test COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_ws_screen_tags_exec.py" @@ -1683,6 +1685,13 @@ if(BUILD_TESTING) add_test(NAME netplay_input_hist_test COMMAND netplay_input_hist_test) endif() + # IRQ policy for nested overlay call units: IRQs are delivered inside a + # unit (a callee that polls for an IRQ-set flag, libcard MemCardSync, must + # not starve: LoD "Checking MEMORY CARD" hang). Header-only, no runtime link. + add_executable(overlay_unit_defer_test tests/test_overlay_unit_defer.c) + target_include_directories(overlay_unit_defer_test PRIVATE src) + add_test(NAME overlay_unit_defer_test COMMAND overlay_unit_defer_test) + add_executable(overlay_store_cycle_barrier_test tests/test_overlay_store_cycle_barrier.c) target_include_directories(overlay_store_cycle_barrier_test PRIVATE include) diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 3e4b6009..73b20a6f 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -14202,6 +14202,19 @@ static void handle_overlay_loader_status(int id, const char *json) (unsigned long long)nd, (unsigned long long)ni, (unsigned long long)sn, (unsigned long long)ss, (unsigned long long)sc, (unsigned long long)sx); + { + /* Call-unit IRQ starvation signature: call_unit_depth stuck > 0 with + * ci_skip_unit climbing while an unmasked IRQ stays pending. */ + uint64_t cu=0, cs=0, cn=0, cr=0, cd=0, ce=0; + overlay_loader_get_ci_skip_diag(&cu, &cs, &cn, &cr, &cd, &ce); + n += snprintf(buf + n, sizeof(buf) - n, + ",\"ci_skip_unit\":%llu,\"ci_skip_supp\":%llu,\"ci_skip_none\":%llu," + "\"ci_skip_sr\":%llu,\"ci_skip_deliv\":%llu,\"ci_enter\":%llu," + "\"call_unit_depth\":%d", + (unsigned long long)cu, (unsigned long long)cs, (unsigned long long)cn, + (unsigned long long)cr, (unsigned long long)cd, (unsigned long long)ce, + overlay_loader_call_unit_depth()); + } extern volatile long g_overlay_image_warm_loaded; extern volatile long g_overlay_image_warm_pending; n += snprintf(buf + n, sizeof(buf) - n, diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index a831bf9f..bceb61d9 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -8,6 +8,7 @@ #include "interrupts.h" #include "debug_server.h" #include "psx_cycles.h" +#include "overlay_unit_defer.h" /* Out-of-line copy of the header-inline CPU charge for the overlay callback * table: overlay DLLs publish their accumulated CPU charge here, into the @@ -2283,12 +2284,17 @@ extern uint32_t i_mask; * nested unit suspended the interrupted thread with an INCONSISTENT snapshot * (resume PC at the caller's post-call point, sp still mid-callee) -> a leaked * stack frame that compounded across cooperative cycles into a smeared jumptable - * index (the Ape "Checking MEMORY CARD" softlock/fatal). While this depth is >0 - * both backends defer the IRQ check; the callee runs to completion, then the - * enclosing top-level dirty pump / outermost dispatch return delivers the IRQ at - * a consistent (pc, sp) boundary. The TOP-LEVEL flow (depth 0) still pumps IRQs, - * so the consumer's poll loop and long overlay game-loops keep their - * responsiveness. Incremented only when the A/B toggle is on (below). */ + * index (the Ape "Checking MEMORY CARD" softlock/fatal). The depth is what + * psx_check_interrupts keys its deferred cooperative-switch path on (a switch + * is honored only at depth 0, the outermost dispatch boundary, where the + * outgoing thread's state is fully materialized). + * + * The overlay CI wrappers used to ALSO skip every interrupt check while depth + * > 0. That starves a callee that polls for an IRQ-set flag (libcard + * MemCardSync: LoD "Checking MEMORY CARD" hang), so IRQs are now delivered + * inside units; the blanket skip survives only behind + * PSX_OVERLAY_UNIT_IRQ_DEFER=1 for A/B bisection (overlay_unit_defer.h). + * Incremented only when the A/B toggle is on (below). */ int g_call_unit_depth = 0; /* A/B toggle for the nested-unit IRQ deferral (PSX_OVERLAY_UNIT_DEFER, default @@ -2304,6 +2310,18 @@ int overlay_unit_defer_enabled(void) { return cached; } +/* Legacy blanket IRQ deferral inside call units (overlay_unit_defer.h): default + * OFF; PSX_OVERLAY_UNIT_IRQ_DEFER=1 restores it for A/B bisection. */ +static int overlay_unit_irq_defer_enabled(void) { + static int cached = -1; + if (cached < 0) { + const char *e = getenv("PSX_OVERLAY_UNIT_IRQ_DEFER"); + cached = (e && e[0]) ? (e[0] == '1' || e[0] == 'y' || e[0] == 'Y') + : OVERLAY_UNIT_IRQ_DEFER_DEFAULT; + } + return cached; +} + void overlay_loader_set_irq_suppress(int mode, uint32_t ratelimit) { s_suppress_irq = mode ? 1 : 0; s_irq_ratelimit = ratelimit; @@ -2383,9 +2401,12 @@ static int overlay_irq_suppressed_now(void) { } static void overlay_ci_wrapper(CPUState *cpu) { - /* Defer while inside a nested call unit — a callee must not interrupt - * mid-call (static-call atomicity). See g_call_unit_depth. */ - if (g_call_unit_depth > 0) { s_ci_skip_unit++; return; } + /* Legacy blanket deferral inside a nested call unit (A/B knob only; default + * is to deliver — see g_call_unit_depth / overlay_unit_defer.h). */ + if (overlay_unit_irq_must_defer(g_call_unit_depth, overlay_unit_irq_defer_enabled())) { + s_ci_skip_unit++; + return; + } if (overlay_irq_suppressed_now()) { s_ci_skip_supp++; return; } /* psx_advance_cycles() has already raised every device edge due at this * block. Avoid entering the full scheduler/diagnostic path when COP0 could @@ -2426,10 +2447,13 @@ static int overlay_idle_note_is_internal_or_return(const CPUState *cpu, } static void overlay_ci_at_wrapper(CPUState *cpu, uint32_t resume_pc) { - /* Defer while inside a nested call unit (see g_call_unit_depth): suspending - * here would save resume_pc at the callee's block leader while the enclosing - * dirty caller expects an atomic unit — the resume-desync bug. */ - if (g_call_unit_depth > 0) { s_ci_skip_unit++; return; } + /* Legacy blanket deferral inside a nested call unit (A/B knob only; default + * is to deliver — see g_call_unit_depth / overlay_unit_defer.h). A thread + * switch landing here is deferred by psx_check_interrupts, not by skipping. */ + if (overlay_unit_irq_must_defer(g_call_unit_depth, overlay_unit_irq_defer_enabled())) { + s_ci_skip_unit++; + return; + } if (overlay_irq_suppressed_now()) { s_ci_skip_supp++; return; } if ((i_stat & i_mask) == 0) { s_ci_skip_none++; return; } if ((cpu->cop0[12] & ((1u << 10) | 1u)) != ((1u << 10) | 1u)) { diff --git a/runtime/src/overlay_unit_defer.h b/runtime/src/overlay_unit_defer.h new file mode 100644 index 00000000..5b286117 --- /dev/null +++ b/runtime/src/overlay_unit_defer.h @@ -0,0 +1,41 @@ +/* overlay_unit_defer.h - IRQ policy for nested overlay call units. + * + * A callee run as a UNIT (overlay_loader_call_native: the dirty interpreter + * jal's into an overlay-native shard) used to defer EVERY interrupt check while + * g_call_unit_depth > 0 (5478d83, an early Ape memcard "partial fix": a + * cooperative ChangeThread landing inside a nested unit saved an inconsistent + * (resume_pc, sp) snapshot). + * + * That blanket deferral is a liveness hole. A callee that busy-waits on a flag + * only an interrupt handler can set (libcard MemCardSync polling 0x800BF178, + * set by the HwCARD event callback; the card protocol itself is SIO-IRQ driven + * per byte) never sees an IRQ, spins forever natively, and the game hangs on + * "Checking MEMORY CARD" (Legend of Dragoon Continue / Save). It needs an + * INTERPRETED caller with a NATIVE callee, so it appears or vanishes with the + * overlay cache contents: cold cache = both interpreted, fully warm = both + * native, partially warm = wedge. Bounding the deferral by age does not fix it + * (a one-frame bound still delivers one IRQ per frame, far below the per-byte + * SIO rate; ~5k guest cycles is needed) - the deferral itself is the wrong tool. + * + * Hardware delivers the IRQ immediately, and so does the interpreter at every + * entry. What a unit really needs is protection from a cooperative thread + * SWITCH landing inside it, and psx_check_interrupts already provides that: + * the "Fix #2" path keys on g_call_unit_depth (at_outermost) and defers the + * switch to the outermost dispatch boundary. So the default is: deliver. + * The legacy blanket deferral stays available as an A/B knob + * (PSX_OVERLAY_UNIT_IRQ_DEFER=1) to bisect any title that regressed with it + * gone. Header-only so the policy is unit-testable + * (runtime/tests/test_overlay_unit_defer.c). */ +#ifndef OVERLAY_UNIT_DEFER_H +#define OVERLAY_UNIT_DEFER_H + +/* Default for the legacy blanket deferral: OFF (deliver IRQs inside units). */ +#define OVERLAY_UNIT_IRQ_DEFER_DEFAULT 0 + +/* 1 = a block-leader interrupt check made at `unit_depth` must be skipped. */ +static inline int overlay_unit_irq_must_defer(int unit_depth, int legacy_blanket_defer) +{ + return unit_depth > 0 && legacy_blanket_defer; +} + +#endif /* OVERLAY_UNIT_DEFER_H */ diff --git a/runtime/tests/test_overlay_unit_defer.c b/runtime/tests/test_overlay_unit_defer.c new file mode 100644 index 00000000..149baeb4 --- /dev/null +++ b/runtime/tests/test_overlay_unit_defer.c @@ -0,0 +1,31 @@ +/* IRQ policy for nested overlay call units (overlay_unit_defer.h). + * + * Regression for the Legend of Dragoon "Checking MEMORY CARD" hang: libcard's + * MemCardSync busy-waits on a flag set by an IRQ-driven event callback. Called + * from an interpreted caller into a native shard it ran as an atomic call unit + * and the overlay CI wrappers skipped every interrupt check for the whole call, + * so the flag was never set. The default must be to DELIVER inside units; the + * blanket skip exists only as an explicit A/B knob. + * + * Run: `ctest -R overlay_unit_defer_test`. */ +#include +#include + +#include "../src/overlay_unit_defer.h" + +int main(void) +{ + /* Default policy: never defer, at any nesting depth. */ + assert(OVERLAY_UNIT_IRQ_DEFER_DEFAULT == 0); + for (int depth = -1; depth <= 8; depth++) + assert(overlay_unit_irq_must_defer(depth, OVERLAY_UNIT_IRQ_DEFER_DEFAULT) == 0); + + /* Legacy A/B knob: defers exactly while inside a unit. */ + assert(overlay_unit_irq_must_defer(0, 1) == 0); + assert(overlay_unit_irq_must_defer(1, 1) == 1); + assert(overlay_unit_irq_must_defer(3, 1) == 1); + assert(overlay_unit_irq_must_defer(-1, 1) == 0); + + printf("overlay_unit_defer_test: ok\n"); + return 0; +} diff --git a/runtime/tests/test_overlay_unit_defer_wiring.py b/runtime/tests/test_overlay_unit_defer_wiring.py new file mode 100644 index 00000000..901a639e --- /dev/null +++ b/runtime/tests/test_overlay_unit_defer_wiring.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Wiring guard for the call-unit IRQ policy (overlay_unit_defer.h). + +Both overlay CI wrappers must route the call-unit deferral through +overlay_unit_irq_must_defer(depth, overlay_unit_irq_defer_enabled()), and the +env knob must default to OVERLAY_UNIT_IRQ_DEFER_DEFAULT. A raw +`g_call_unit_depth > 0 -> return` in either wrapper restores the unconditional +deferral that wedged Legend of Dragoon on "Checking MEMORY CARD" (libcard +MemCardSync polling for an IRQ-set flag from a native unit entered by an +interpreted caller; call_unit_depth stuck at 1, ci_skip_unit in the tens of +millions). + +Usage: python runtime/tests/test_overlay_unit_defer_wiring.py Exit 0 = PASS. +""" +from pathlib import Path +import re +import sys + +ROOT = Path(__file__).resolve().parents[2] +SRC = (ROOT / "runtime" / "src" / "overlay_loader.c").read_text(encoding="utf-8") + + +def body(name: str) -> str: + m = re.search(rf"\bstatic\s+(?:int|void)\s+{re.escape(name)}\s*\([^;]*?\)\s*\{{", SRC, re.S) + assert m, f"missing {name}" + depth, i = 1, m.end() + while depth: + c = SRC[i] + depth += (c == "{") - (c == "}") + i += 1 + return SRC[m.end():i] + + +def main() -> int: + for fn in ("overlay_ci_wrapper", "overlay_ci_at_wrapper"): + b = body(fn) + assert "overlay_unit_irq_must_defer(g_call_unit_depth, overlay_unit_irq_defer_enabled())" in b, \ + f"{fn} must gate the unit deferral through the policy helper" + assert not re.search(r"if\s*\(\s*g_call_unit_depth\s*>\s*0", b), \ + f"{fn} must not skip interrupt checks on g_call_unit_depth directly" + knob = body("overlay_unit_irq_defer_enabled") + assert "PSX_OVERLAY_UNIT_IRQ_DEFER" in knob and "OVERLAY_UNIT_IRQ_DEFER_DEFAULT" in knob + print("overlay_unit_defer_wiring: ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 7b88260fe5a8f8558ae10d0180fde28b25fb1ea2 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 02:25:23 -0700 Subject: [PATCH 03/17] aot: prepared_files (bpe_container), file_aliases, disc_set, generic exclusions Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/aot_overlay_pipeline.py | 90 +++++++++++--- tools/aot_overlay_spike/extract_generic.py | 6 + tools/bpe_container.py | 112 +++++++++++++++++ tools/prepared_files.py | 138 +++++++++++++++++++++ 4 files changed, 330 insertions(+), 16 deletions(-) create mode 100644 tools/bpe_container.py create mode 100644 tools/prepared_files.py diff --git a/tools/aot_overlay_pipeline.py b/tools/aot_overlay_pipeline.py index 9438b99b..7be0ced7 100644 --- a/tools/aot_overlay_pipeline.py +++ b/tools/aot_overlay_pipeline.py @@ -27,6 +27,7 @@ from indexed_lzss_pack import members as extract_pack_members from mips_tagged_relocations import parse as parse_tagged_relocations, relocate as relocate_tagged_image from counted_mips_relocations import parse as parse_counted_relocations, relocate as relocate_counted_image from mod_package_images import ModPackageView +import prepared_files FRAMEWORK = Path(__file__).resolve().parents[1] # KSEG0 main-RAM decode window. Retail 2 MiB DRAM mirrors across all of it and @@ -68,9 +69,29 @@ def load_profile(profile_path, config): profile = json.loads(Path(profile_path).read_text(encoding='utf-8-sig')) require(profile.get('schema') == 'psxrecomp AOT methods v1', 'Unsupported AOT profile') require(config['game']['id'] == profile['game_id'], 'AOT profile/game mismatch') + require(('disc_hashes' in profile) != ('disc_set' in profile), + 'An AOT profile declares exactly one of disc_hashes (one disc) and disc_set (several)') return profile +def disc_identities(profile): + """[(name, {algorithm: digest})] for every disc this profile accepts. + + `disc_hashes` is one disc. `disc_set` is a list of {name, disc_hashes}: a + release spread over several discs whose executable images are identical. + The profile's image sources are pinned by content hashes, so they - not the + disc - decide what is built; the disc identity only gates applicability.""" + if 'disc_hashes' in profile: + return [('disc', profile['disc_hashes'])] + entries = profile['disc_set'] + require(isinstance(entries, list) and entries, 'disc_set needs at least one disc') + for entry in entries: + require(entry.get('name') and entry.get('disc_hashes'), + 'Every disc_set entry needs a name and disc_hashes') + require(len({entry['name'] for entry in entries}) == len(entries), 'Duplicate disc_set name') + return [(entry['name'], entry['disc_hashes']) for entry in entries] + + def static_output(profile, project_root): """The overlays_static.c this title's build links, or None. @@ -214,8 +235,10 @@ def verify_evidence(disc, checks, views=None): actual = struct.unpack_from('= 4 and stride % 4 == 0, 'Invalid pointer table stride') for index, expected in enumerate(check['strings']): - offset = origin + number(check['table']) - base + index * 4 + offset = origin + number(check['table']) - base + index * stride pointer = struct.unpack_from('2_000_000: continue if exe_base and os.path.basename(p).upper()==exe_base: continue # skip the statically-compiled base executable diff --git a/tools/bpe_container.py b/tools/bpe_container.py new file mode 100644 index 00000000..63569af7 --- /dev/null +++ b/tools/bpe_container.py @@ -0,0 +1,112 @@ +"""Block-wise byte-pair-expansion ("BPE") containers. + +Some PlayStation engines pack their executable image and overlays with a +byte-pair coder: a table of 256 optional (left, right) pairs per block, then a +stream of symbols each expanded through that table. The container is + + u32 decoded size (all blocks together) + u32 magic (a title-declared constant, "BPE\\x1a" in the + titles seen so far) + block*: + u32 symbol count (0 ends the container) + table 256 entries, run-length coded (below) + symbols `symbol count` bytes, expanded through the table + +The decoder reads its input as little-endian 32-bit words, handing the words +out as bytes. Anything left unread in the last word of a table or block is +discarded, so every block header starts on a word boundary; `consumed` counts +whole words including the terminating zero count. + +Table coding, entry index `c` starting at 0 until it reaches 256: + * a byte `n` >= 0x80 first skips `n - 0x7F` entries (they stay identity + pairs) and is then treated as `n = 0`; + * the next `n + 1` entries are given in turn, each by a byte `b` that sets + `left[c] = b` and, if `b != c`, a second byte that sets `right[c]`. An + entry with `left[c] == c` is a literal. +Expanding a symbol `b`: while `left[b] != b`, the byte `right[b]` is +queued to follow, and `b` becomes `left[b]`; the final `b` is emitted, then the +queued bytes are expanded the same way, nearest first. A queued byte is +processed before any unread input. + +This module is a pure function of its input. It rejects truncated input, a +declared size that the blocks do not produce, a different magic, and tables +that never finish expanding, instead of returning partial output. +""" +import struct + + +class BpeError(ValueError): + """A container is truncated, inconsistent, or not the declared format.""" + + +def decode(data, *, magic, offset=0, max_output=1 << 28): + """Decode the container at `offset`. Returns (output bytes, consumed bytes). + + `magic` is the second header word the title's loader checks; it is a + required argument because it is evidence about the title, not a default. + """ + if offset < 0 or offset % 4 or len(data) - offset < 12: + raise BpeError('BPE container is truncated or misaligned') + declared, found = struct.unpack_from(' max_output: + raise BpeError('BPE declared size exceeds the output limit') + end = len(data) + position = offset + 8 + output = bytearray() + + def word(): + nonlocal position + if position + 4 > end: + raise BpeError('BPE container is truncated') + value = struct.unpack_from(' declared: + raise BpeError('BPE blocks decode past the declared size') + left, right = list(range(256)), [0] * 256 + window = [] # next byte to hand out is window[-1] + + def take(): + if not window: + window.extend(reversed(data[position:position + 4])) + advance = word # consume the word through the bounds check + advance() + return window.pop() + + index = 0 + while index < 256: + run = take() + if run >= 0x80: + index += run - 0x7F + run = 0 + if index >= 256: + break + for _ in range(run + 1): + if index >= 256: + raise BpeError('BPE table run passes its last entry') + symbol = take() + left[index] = symbol + if symbol != index: + right[index] = take() + index += 1 + budget = (count + 1) * 256 + for _ in range(count): + symbol = take() + while left[symbol] != symbol: + window.append(right[symbol]) + symbol = left[symbol] + budget -= 1 + if budget < 0: + raise BpeError('BPE table does not terminate') + output.append(symbol) + window.clear() + if len(output) != declared: + raise BpeError(f'BPE decoded {len(output):#x} bytes, header declares {declared:#x}') + return bytes(output), position - offset diff --git a/tools/prepared_files.py b/tools/prepared_files.py new file mode 100644 index 00000000..33ef9410 --- /dev/null +++ b/tools/prepared_files.py @@ -0,0 +1,138 @@ +"""Virtual files an AOT profile derives from the original disc. + +An engine can run code that no disc file contains verbatim: a boot EXE that +unpacks an inner image, overlays stored compressed. The loader's transform is +deterministic, so its output is as much an original input as a raw file, as +long as the transform and every byte it consumes and produces are pinned. + +A profile declares such outputs in `prepared_files`; each is then an ordinary +named file for every image method and evidence check (`fixed_address_files`, +`words`, `pointer_strings`, ...). Nothing derived is stored: it is rebuilt from +the user's own disc whenever the profile is used, and the profile carries only +hashes, offsets and sizes. + + "prepared_files": [{ + "name": "ENGINE.IMG", # unique; never a real disc file + "method": "bpe_container", + "source": {"file": "BOOT.EXE", "file_offset": "0xA00"}, + "magic": "0x1A455042", # declared by the method + "consumed": "0x36228", # input bytes the decoder reads + "source_sha256": "...", # of exactly those bytes + "size": "0xD7800", # decoded size + "sha256": "..." # of the decoded bytes + }] + +`file_aliases` maps a logical name to the per-disc names of one file, for sets +whose discs name an identical file differently (SCUS_944.91, SCUS_945.84, ...). +Exactly one candidate must exist on the disc being read. + +Methods are decoders over bytes only; they never see a path, the disc, or the +profile's other sections. +""" +import hashlib + +import bpe_container + + +class PreparedError(ValueError): + """A declared derived file did not reproduce from the disc.""" + + +def number(value): + return int(value, 0) if isinstance(value, str) else int(value) + + +def _bpe_container(data, spec): + return bpe_container.decode(data, magic=number(spec['magic']), offset=0) + + +# method -> (decoder(data_from_source_offset, spec) -> (output, consumed), +# keys the spec must declare beyond the common ones) +METHODS = {'bpe_container': (_bpe_container, ('magic',))} +COMMON_KEYS = ('name', 'method', 'source', 'consumed', 'source_sha256', 'size', 'sha256') + + +class PreparedDisc: + """An original disc plus the profile's aliases and derived files. + + Reads of a declared alias or prepared name are answered here; everything + else (`files`, `binary`, `reader`, ...) is the underlying disc's.""" + + def __init__(self, disc, aliases=None, prepared=None, receipts=None): + self.disc = disc + self.aliases = dict(aliases or {}) + self.prepared = dict(prepared or {}) + self.receipts = list(receipts or []) + + def __getattr__(self, name): + # Only reached for attributes this wrapper does not define. + return getattr(self.disc, name) + + def read(self, name): + key = name.upper() + if key in self.prepared: + return self.prepared[key] + return self.disc.read(self.aliases.get(key, key)) + + +def resolve_aliases(disc, profile): + """logical name -> the one candidate that exists on this disc.""" + resolved = {} + for logical, candidates in profile.get('file_aliases', {}).items(): + key = logical.upper() + if key in disc.files: + raise PreparedError(f'File alias {logical} is the name of a real disc file') + if not candidates or not all(isinstance(c, str) and c for c in candidates): + raise PreparedError(f'File alias {logical} needs candidate file names') + present = [c.upper() for c in candidates if c.upper() in disc.files] + if len(present) != 1: + raise PreparedError( + f'File alias {logical} needs exactly one of {sorted(c.upper() for c in candidates)} ' + f'on the disc, found {present}') + resolved[key] = present[0] + return resolved + + +def prepare(disc, profile): + """The disc as the profile sees it: aliases resolved, derived files built. + + Every derived file is verified (input extent, decoded size and hash) before + any image method can read it; a mismatch is a PreparedError.""" + aliases = resolve_aliases(disc, profile) + view = PreparedDisc(disc, aliases) + for spec in profile.get('prepared_files', []): + method = METHODS.get(spec.get('method')) + if method is None: + raise PreparedError(f"Unknown prepared file method: {spec.get('method')}") + decoder, extra = method + missing = [key for key in COMMON_KEYS + extra if key not in spec] + if missing: + raise PreparedError(f"Prepared file {spec.get('name')} lacks {missing}") + name = spec['name'].upper() + if name in view.prepared or name in aliases or name in disc.files: + raise PreparedError(f'Prepared file name is not unique: {name}') + source = spec['source'] + data = view.read(source['file']) + start = number(source.get('file_offset', 0)) + if not 0 <= start < len(data): + raise PreparedError(f'Prepared file {name}: source offset outside {source["file"]}') + try: + output, consumed = decoder(data[start:], spec) + except ValueError as exc: + raise PreparedError(f'Prepared file {name} does not decode: {exc}') from exc + if consumed != number(spec['consumed']): + raise PreparedError(f'Prepared file {name}: decoder consumed {consumed:#x} bytes, ' + f"profile pins {number(spec['consumed']):#x}") + if hashlib.sha256(data[start:start + consumed]).hexdigest() != spec['source_sha256']: + raise PreparedError(f'Prepared file {name}: source bytes changed') + if len(output) != number(spec['size']): + raise PreparedError(f'Prepared file {name}: decoded {len(output):#x} bytes, ' + f"profile pins {number(spec['size']):#x}") + if hashlib.sha256(output).hexdigest() != spec['sha256']: + raise PreparedError(f'Prepared file {name}: decoded bytes changed') + view.prepared[name] = output + view.receipts.append(dict(name=name, method=spec['method'], source=source['file'].upper(), + source_offset=start, consumed=consumed, + source_sha256=spec['source_sha256'], size=len(output), + sha256=spec['sha256'])) + return view From b28af43b95cf6c5199489ed4afe62e73615c70ed Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 02:40:12 -0700 Subject: [PATCH 04/17] aot: tests, docs and test registration for prepared images Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/AOT_SHARDING.md | 45 ++++- recompiler/CMakeLists.txt | 1 + tools/bpe_container.py | 11 +- tools/tests/test_aot_overlay_pipeline.py | 206 +++++++++++++++++++++++ tools/tests/test_bpe_container.py | 141 ++++++++++++++++ 5 files changed, 399 insertions(+), 5 deletions(-) create mode 100644 tools/tests/test_bpe_container.py diff --git a/docs/AOT_SHARDING.md b/docs/AOT_SHARDING.md index bbd4eed9..a646b479 100644 --- a/docs/AOT_SHARDING.md +++ b/docs/AOT_SHARDING.md @@ -57,6 +57,10 @@ Profiles declare these reusable methods: | Field / method | Contract | | --- | --- | | `disc_hashes` | Hash the cue's original data track before extracting. | +| `disc_set` | Instead of `disc_hashes`: `[{name, disc_hashes}]`, every disc of a release whose executable images are identical. Any one matches; the images are pinned by their own content hashes, so which disc was read does not change the output. | +| `file_aliases` | `{logical: [per-disc names]}` for one file that discs name differently (`SCUS_944.91`, `SCUS_945.84`, ...). Exactly one candidate must exist; the logical name reads as that file everywhere in the profile. | +| `prepared_files[]` | Virtual files derived from disc bytes by a declared transform (see below). Each is verified, then usable as a `file` by every method and check. | +| `generic_exclude_files[]` | `{files, reason}`: disc files the generic detector must not consider because the profile owns them (aliased program files are excluded automatically). | | `images[].method = fixed_address_files` | Whole original files placed at a verified `load_addr`. Optional duplicate-leaf checks require identical bytes. | | `images[].method = fixed_address_extents` | Declared `{file, file_offset, base, address, size, sha256}` byte extents placed at a verified `load_addr`. Offset rules match `words` checks; size and SHA-256 are required. | | `mod_packages[]` / `mod_package` | A named, verified selection of a trusted mod package. Images and checks carrying `mod_package` read the original disc with that selection applied; see below. | @@ -71,7 +75,7 @@ Profiles declare these reusable methods: | `images[].method = indexed_lzss_members` | Read a u32 count and `{offset, size, stored}` descriptors, decode every member (verbatim when `size == stored`, otherwise bit-stream LZSS with declared parameters), require exact sizes, zero padding and a fully accounted file, and classify every member as an executable image at a verified `load_addr` or an explained exclusion. | | `checks[].method = words` | Verify loader instructions or descriptors at explicit file offsets / virtual addresses. | | `checks[].method = tagged_relocations` | Validate a complete relocation stream, retained `image_size` and `relocation_count`, including modules whose heap placement remains unresolved and which are excluded from native production. | -| `checks[].method = pointer_strings` | Verify a pointer-indexed filename table against exact expected strings. | +| `checks[].method = pointer_strings` | Verify a pointer-indexed filename table against exact expected strings. Optional `stride` (default 4, a multiple of 4) reads tables of `{id, name*}` records. | | `checks[].method = bcd_extent_table` | Verify an indexed BCD-MSF/size table against an ISO file's actual extent. | | `checks[].method = adjacent_files` | Require the named ISO extents to be sector aligned and physically adjacent, in order. | | `compositions` | Combine declared simultaneous producers in address order. `max_gap` bounds their separation; gap and alignment bytes remain unowned. | @@ -133,6 +137,45 @@ Current consumers: linked with `static` (see below). The other 88 packs on the disc (`TRACK*.PBP`) hold data only. +### Prepared images: packed executables and overlays + +Some engines run code that no disc file holds verbatim: a launcher EXE that +unpacks the real executable, overlays stored compressed. The loader's transform +is deterministic, so its output is an original input like any other, provided the +transform and every byte it reads and writes are pinned. `prepared_files` declares +such outputs; the pipeline rebuilds them from the user's own disc every time and +nothing derived is stored or committed. The profile carries hashes, offsets, sizes +and addresses only. + +```json +"prepared_files": [{ + "name": "PREPARED/ENGINE", "method": "bpe_container", + "source": {"file": "BOOT.EXE", "file_offset": "0xA00"}, "magic": "0x1A455042", + "consumed": "0x36228", "source_sha256": "...", "size": "0xD7800", "sha256": "..." +}] +``` + +The decoder must consume exactly `consumed` bytes, whose SHA-256 is +`source_sha256`, and produce exactly `size` bytes with SHA-256 `sha256`; any +difference stops the run. A prepared name may not be a real disc file or alias, and +a later entry may read an earlier one. Prepared names then work in +`fixed_address_files`, `fixed_address_extents`, `words` and `pointer_strings`, so the +loader code that fixes the destination is verified against the very bytes that run. + +Methods: `bpe_container` (`tools/bpe_container.py`): block-wise byte-pair +expansion, `u32 size, u32 magic, {u32 block size, pair table, symbols}*, 0`. The +magic is a required parameter. + +A packed image is rarely all code (its BSS, tables and replaced overlay area are +data). Use `fixed_address_extents` on the prepared file to name only the code runs, +each with its `sha256`, so data is never a producer: a data run decoded as code +fails the generated-C audit and costs the whole image. + +Legend of Dragoon (`aot/overlays.json`): the launcher's embedded container becomes +the engine image at 0x80010000 (two code runs), and each of the ten `OVL/*.OV_` +files becomes an image in overlay slot A (0x800C6688) or slot B (0x800FB778). One +`disc_set` profile covers all four discs. + ### Executable images from a mod package A trusted package can add code that no disc file contains: patched boot-EXE diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 7058f495..81640cd2 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -1092,6 +1092,7 @@ if(BUILD_TESTING) # template, scaffold, check_generated.sh, Project Studio audit/ops). foreach(_t IN ITEMS aligned_lzss_banks + bpe_container bundled_release_layout collect_game_misses cursor_writers diff --git a/tools/bpe_container.py b/tools/bpe_container.py index 63569af7..1ed1ad81 100644 --- a/tools/bpe_container.py +++ b/tools/bpe_container.py @@ -8,9 +8,11 @@ stream of symbols each expanded through that table. The container is u32 magic (a title-declared constant, "BPE\\x1a" in the titles seen so far) block*: - u32 symbol count (0 ends the container) + u32 block size bytes this block decodes to (0 ends the container) table 256 entries, run-length coded (below) - symbols `symbol count` bytes, expanded through the table + symbols as many input bytes as it takes to produce + `block size` output bytes, each expanded + through the table The decoder reads its input as little-endian 32-bit words, handing the words out as bytes. Anything left unread in the last word of a table or block is @@ -26,7 +28,8 @@ Table coding, entry index `c` starting at 0 until it reaches 256: Expanding a symbol `b`: while `left[b] != b`, the byte `right[b]` is queued to follow, and `b` becomes `left[b]`; the final `b` is emitted, then the queued bytes are expanded the same way, nearest first. A queued byte is -processed before any unread input. +processed before any unread input. Each emitted byte counts against the block +size, whether it came from a symbol or from the queue. This module is a pure function of its input. It rejects truncated input, a declared size that the blocks do not produce, a different magic, and tables @@ -96,7 +99,7 @@ def decode(data, *, magic, offset=0, max_output=1 << 28): if symbol != index: right[index] = take() index += 1 - budget = (count + 1) * 256 + budget = (count + 1) * 256 # a table that never reaches a literal for _ in range(count): symbol = take() while left[symbol] != symbol: diff --git a/tools/tests/test_aot_overlay_pipeline.py b/tools/tests/test_aot_overlay_pipeline.py index f3481e9f..49c3fca7 100644 --- a/tools/tests/test_aot_overlay_pipeline.py +++ b/tools/tests/test_aot_overlay_pipeline.py @@ -15,6 +15,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import aot_overlay_pipeline as pipeline import audit_aot_cache as auditor import indexed_lzss_pack as lzss_pack +import prepared_files +from test_bpe_container import MAGIC as BPE_MAGIC, container as bpe_container_bytes, encode_block class PgxpReleaseNamespaceTests(unittest.TestCase): @@ -904,6 +906,210 @@ class IndexedLzssPackTest(unittest.TestCase): pipeline.positioned_sources(disc, [self.spec(**change)]) +class PreparedImageTests(unittest.TestCase): + """Images a loader unpacks: derived files, per-disc aliases, disc sets.""" + CODE = bytes.fromhex('27bdffe8afbf001000000000') * 4 # invented words + OVERLAY = bytes.fromhex('3c028001244200108c430000') * 3 + + def boot(self): + """A launcher: 8 bytes of header, then a container holding CODE.""" + packed = bpe_container_bytes([encode_block(list(self.CODE), len(self.CODE))], len(self.CODE)) + return b'LAUNCH!!' + packed + b'tail', 8, len(packed), packed + + def profile(self, **overrides): + boot, offset, consumed, packed = self.boot() + profile = dict( + game_id='TEST', + file_aliases={'BOOT.EXE': ['SLUS_001.01', 'SLUS_002.02']}, + prepared_files=[dict(name='PREPARED/ENGINE', method='bpe_container', + source=dict(file='BOOT.EXE', file_offset=offset), magic=hex(BPE_MAGIC), + consumed=consumed, source_sha256=sha(packed), size=len(self.CODE), + sha256=sha(self.CODE))]) + return {**profile, **overrides} + + def disc(self, boot_name='SLUS_001.01', extra=None): + return FakeDisc({boot_name: self.boot()[0], **(extra or {})}) + + def test_prepared_file_reads_like_a_disc_file_for_images_and_evidence(self): + view = prepared_files.prepare(self.disc(), self.profile()) + self.assertEqual(view.read('prepared/engine'), self.CODE) + self.assertEqual(view.read('BOOT.EXE'), self.boot()[0]) + self.assertEqual(view.receipts[0]['sha256'], sha(self.CODE)) + source, = pipeline.positioned_sources(view, [dict( + method='fixed_address_files', files=['PREPARED/ENGINE'], load_addr='0x80010000')]) + self.assertEqual((source['name'], source['base'], source['body']), + ('PREPARED/ENGINE', 0x80010000, self.CODE)) + first, second = struct.unpack_from('= 3: + out.append(0x7E + stretch) # skips stretch - 1 entries, then one entry + out.append(c + stretch - 1) # the last identity entry of the stretch + c += stretch + continue + run = 0 + while c + run < 256 and run < 128 and not ( + left[c + run] == c + run and + all(left[c + run + k] == c + run + k for k in range(1, 3) if c + run + k < 256) and + c + run + 3 <= 256): + run += 1 + run = max(run, 1) + out.append(run - 1) + for k in range(c, c + run): + out.append(left[k]) + if left[k] != k: + out.append(right[k]) + c += run + return bytes(out) + + +def encode_block(symbols, decoded_size, left=None, right=None): + """A block of input `symbols` that decodes to `decoded_size` bytes.""" + left = list(range(256)) if left is None else left + right = [0] * 256 if right is None else right + body = encode_table(left, right) + bytes(symbols) + body += bytes(-len(body) % 4) + return struct.pack(' "AB" + left[0xF1], right[0xF1] = 0xF0, ord('C') # 0xF1 -> "ABC" (chained) + left[0x10], right[0x10] = 0x20, 0x30 # 0x10 -> 0x20 0x30 + return left, right + + def one(self, symbols, size, left=None, right=None): + return container([encode_block(symbols, size, left, right)], size) + + def test_literals_pairs_chains_and_multiple_blocks(self): + left, right = self.pairs() + first = encode_block([0xF1, ord('x'), 0xF0, 0x10], 8, left, right) + second = encode_block(list(b'plain'), 5) + data = container([first, second], 13) + body, consumed = decode(data, magic=MAGIC) + self.assertEqual(body, b'ABCxAB' + bytes([0x20, 0x30]) + b'plain') + self.assertEqual(consumed, len(data)) + + def test_consumed_counts_words_and_offset_selects_the_container(self): + data = self.one(list(b'abc'), 3) + for lead in (b'', bytes(8)): + with self.subTest(lead=len(lead)): + body, consumed = decode(lead + data + b'trailing', magic=MAGIC, offset=len(lead)) + self.assertEqual((body, consumed), (b'abc', len(data))) + + def test_skip_coded_identity_stretches_keep_tables_short_and_decode_correctly(self): + left, right = self.pairs() + table = encode_table(left, right) + self.assertLess(len(table), 40, 'identity stretches must be skip-coded') + body, _ = decode(self.one([0xF1, 0x10, 0x41, 0xF0], 8, left, right), magic=MAGIC) + self.assertEqual(body, b'ABC' + bytes([0x20, 0x30]) + b'A' + b'AB') + + def test_a_skip_that_reaches_the_last_entry_ends_the_table(self): + # 128 explicit entries (entry 0 is a pair), then a 128-entry skip: index 256. + table = bytes([127, ord('Z'), ord('Y')]) + bytes(range(1, 128)) + bytes([0xFF]) + block = struct.pack(' 255, entry 255 + block = struct.pack(' 256. + table = (bytes([127]) + bytes(range(128)) + bytes([100]) + bytes(range(128, 229)) + + bytes([40]) + bytes(range(229, 256)) + bytes(16)) + block = struct.pack(' Date: Mon, 12 Oct 2026 03:06:37 -0700 Subject: [PATCH 05/17] aot: cross_image_call_roots - jal targets other images call are roots of the image holding them Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/AOT_SHARDING.md | 10 +++--- tools/aot_overlay_pipeline.py | 25 ++++++++++++-- tools/aot_overlay_spike/extract_generic.py | 9 +++-- tools/tests/test_aot_overlay_pipeline.py | 38 ++++++++++++++++++++++ 4 files changed, 72 insertions(+), 10 deletions(-) diff --git a/docs/AOT_SHARDING.md b/docs/AOT_SHARDING.md index a646b479..6d4c67cf 100644 --- a/docs/AOT_SHARDING.md +++ b/docs/AOT_SHARDING.md @@ -60,6 +60,7 @@ Profiles declare these reusable methods: | `disc_set` | Instead of `disc_hashes`: `[{name, disc_hashes}]`, every disc of a release whose executable images are identical. Any one matches; the images are pinned by their own content hashes, so which disc was read does not change the output. | | `file_aliases` | `{logical: [per-disc names]}` for one file that discs name differently (`SCUS_944.91`, `SCUS_945.84`, ...). Exactly one candidate must exist; the logical name reads as that file everywhere in the profile. | | `prepared_files[]` | Virtual files derived from disc bytes by a declared transform (see below). Each is verified, then usable as a `file` by every method and check. | +| `cross_image_call_roots` | Opt in (`true`): a `jal` target that other images call becomes a static discovery root of the image holding it, when the evidence at the target is the same as for local calls and exactly one image occupies that address. Finds routines only an overlay (or boot code) calls. | | `generic_exclude_files[]` | `{files, reason}`: disc files the generic detector must not consider because the profile owns them (aliased program files are excluded automatically). | | `images[].method = fixed_address_files` | Whole original files placed at a verified `load_addr`. Optional duplicate-leaf checks require identical bytes. | | `images[].method = fixed_address_extents` | Declared `{file, file_offset, base, address, size, sha256}` byte extents placed at a verified `load_addr`. Offset rules match `words` checks; size and SHA-256 are required. | @@ -131,6 +132,10 @@ Current consumers: - Ape Escape USA: `ApeEscapeRecomp/aot/overlays.json`, 47 established packed sector-table overlays plus two minigame PS-X EXEs. Two additional archive members have unresolved load paths and are explicitly excluded. +- The Legend of Dragoon USA: `aot/overlays.json`, `disc_set` over all four discs + (identical executable images). The BPE launcher's embedded container is the engine + (`prepared_files`, two code extents at 0x80010000) and the ten BPE overlays fill + slot A (0x800C6688) or slot B (0x800FB778); all linked with `static`. - WipEout 3 SE PAL: `aot/overlays.json`, the front-end menu overlay (member 5 of `WIPEOUT3/NFE.PB`, `indexed_lzss_members`) from the stock disc and from the NTSC / PAL Mode package's patched disc, plus that package's engine image; all @@ -171,11 +176,6 @@ data). Use `fixed_address_extents` on the prepared file to name only the code ru each with its `sha256`, so data is never a producer: a data run decoded as code fails the generated-C audit and costs the whole image. -Legend of Dragoon (`aot/overlays.json`): the launcher's embedded container becomes -the engine image at 0x80010000 (two code runs), and each of the ten `OVL/*.OV_` -files becomes an image in overlay slot A (0x800C6688) or slot B (0x800FB778). One -`disc_set` profile covers all four discs. - ### Executable images from a mod package A trusted package can add code that no disc file contains: patched boot-EXE diff --git a/tools/aot_overlay_pipeline.py b/tools/aot_overlay_pipeline.py index 7be0ced7..2b490724 100644 --- a/tools/aot_overlay_pipeline.py +++ b/tools/aot_overlay_pipeline.py @@ -579,10 +579,31 @@ def declared_entries(source, disc): return entries -def make_fixed_record(source, disc): +def foreign_call_roots(source, sources): + """Entries of `source` that other images call directly (`jal`). + + A call target is a function entry by construction, but an image's own scan + cannot see calls made from elsewhere: an overlay calling a frameless engine + routine, boot code calling its constructors. Only targets inside exactly one + image count: an address that several images occupy alternatively (overlays + sharing a load slot) names no particular one. The same boundary evidence as + for local calls applies to the target bytes.""" + lo, hi = source['base'], source['base'] + len(source['body']) + others = [other for other in sources if other is not source] + targets = set() + for other in others: + targets |= {t for t in extractor.jal_targets(other['body']) if lo <= t < hi} + targets = {t for t in targets + if not any(o['base'] <= t < o['base'] + len(o['body']) for o in others)} + return extractor.direct_jal_roots(source['body'], source['base'], targets) + + +def make_fixed_record(source, disc, sources=(), cross_image=False): body, base, spec = source['body'], source['base'], source['spec'] require(spec.get('allow_missing'), f"Generic extractor missed required image: {source['name']}") direct = set(extractor.direct_jal_roots(body, base)) + if cross_image: + direct |= set(foreign_call_roots(source, sources)) seeds = set(extractor.prologues(body, base)) | extractor.frameless_leaf_entries(body, base) if spec.get('supplemental_entries', True): seeds |= extractor.supplemental_callable_seeds(body, base) @@ -665,7 +686,7 @@ def prepare(profile, disc, records, output, views=None): singles[source['name']] = record for source in sources: if source['spec']['method'] in ('fixed_address_files', 'fixed_address_extents', 'packed_sector_members', 'sector_extent_members', 'aligned_lzss_banks', 'indexed_lzss_members', 'tagged_relocated_files', 'counted_relocated_members') and source['name'] not in singles: - record = make_fixed_record(source, disc) + record = make_fixed_record(source, disc, sources, bool(profile.get('cross_image_call_roots'))) singles[source['name']] = record records.append(record) # Loader-established exports remain required even when heuristic discovery diff --git a/tools/aot_overlay_spike/extract_generic.py b/tools/aot_overlay_spike/extract_generic.py index 075aaca7..81d073fc 100644 --- a/tools/aot_overlay_spike/extract_generic.py +++ b/tools/aot_overlay_spike/extract_generic.py @@ -216,10 +216,13 @@ def jal_targets(data): ts.add(0x80000000 | ((w & 0x03FFFFFF) << 2)) return ts -def direct_jal_roots(data, base): - """Locally bounded call targets with independent boundary evidence.""" +def direct_jal_roots(data, base, targets=None): + """Call targets inside [base, base+len) with independent boundary evidence. + + `targets` are the jal targets to consider: this image's own by default; an + AOT profile can pass the targets other images call into this one.""" lo,hi=base,base+len(data) - return sorted(t for t in jal_targets(data) + return sorted(t for t in (jal_targets(data) if targets is None else targets) if lo <= t < hi and (t&3)==0 and not _dense_local_pointer_table(data,base,t-base) and (_callable_boundary(data,t-base) or diff --git a/tools/tests/test_aot_overlay_pipeline.py b/tools/tests/test_aot_overlay_pipeline.py index 49c3fca7..46bf98d5 100644 --- a/tools/tests/test_aot_overlay_pipeline.py +++ b/tools/tests/test_aot_overlay_pipeline.py @@ -1110,6 +1110,44 @@ class PreparedImageTests(unittest.TestCase): self.assertEqual(inventory['source_images'][0]['sha256'], sha(self.CODE)) +class CrossImageRootsTests(unittest.TestCase): + """Calls made from one image into another are entries of the callee image.""" + + @staticmethod + def image(name, base, words): + body = b''.join(struct.pack('> 2) + self.caller = self.image('CALLER', 0x80100000, [jal(self.target), nop, ret, nop]) + + def test_a_target_only_another_image_calls_becomes_a_root(self): + engine, caller = self.engine, self.caller + self.assertEqual(pipeline.foreign_call_roots(engine, [engine, caller]), [self.target]) + self.assertEqual(pipeline.foreign_call_roots(caller, [engine, caller]), []) + local = pipeline.make_fixed_record(engine, None, [engine, caller], False) + cross = pipeline.make_fixed_record(engine, None, [engine, caller], True) + self.assertNotIn(hex(self.target), local.get('static_discovery_entry_pcs', [])) + self.assertIn(hex(self.target), cross['static_discovery_entry_pcs']) + + def test_an_address_several_images_occupy_names_no_one_of_them(self): + engine = self.engine + twin = self.image('TWIN', 0x80010000, [0x03E00008, 0, 0, 0, 0x27BDFFE8, 0, 0x03E00008, 0]) + self.assertEqual(pipeline.foreign_call_roots(engine, [engine, twin, self.caller]), []) + + def test_a_target_without_boundary_evidence_is_not_a_root(self): + data = self.image('DATA', 0x80010000, [0x11111111] * 8) + self.assertEqual(pipeline.foreign_call_roots(data, [data, self.caller]), []) + + class StaticGenerationTest(unittest.TestCase): """The `static` action as `psxrecomp_cli.py generate` drives it.""" From 8079ba5e6617128827d6884dc3f978a5cbf588b0 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 04:11:49 -0700 Subject: [PATCH 06/17] freeze_heartbeat: spin_freeze requires the pinned signature at every sample in the window Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/src/freeze_dump_policy.c | 29 +++++++++++++ runtime/src/freeze_dump_policy.h | 27 ++++++++++++ runtime/src/freeze_heartbeat.c | 57 ++++++++----------------- runtime/tests/test_freeze_dump_policy.c | 51 ++++++++++++++++++++-- 4 files changed, 121 insertions(+), 43 deletions(-) diff --git a/runtime/src/freeze_dump_policy.c b/runtime/src/freeze_dump_policy.c index 8197dae4..0803ea95 100644 --- a/runtime/src/freeze_dump_policy.c +++ b/runtime/src/freeze_dump_policy.c @@ -89,3 +89,32 @@ int freeze_dump_format_path(char *out, size_t cap, const char *backend, backend, wall, sequence); return n > 0 && (size_t)n < cap; } + +uint32_t freeze_wedge_classify(const FreezeWedgeSample *window, uint32_t count, + int (*mdec_recently_active)(uint32_t frames)) { + if (!window || count < 2u) return 0; + const FreezeWedgeSample *oldest = &window[0]; + const FreezeWedgeSample *newest = &window[count - 1u]; + uint64_t frame_delta = (newest->frame_count >= oldest->frame_count) + ? (newest->frame_count - oldest->frame_count) : 0; + uint64_t excre_delta = (newest->exc_reentry >= oldest->exc_reentry) + ? (newest->exc_reentry - oldest->exc_reentry) : 0; + + if (frame_delta == 0) return FREEZE_WEDGE_HARD; + if (excre_delta / frame_delta > FREEZE_WEDGE_EXC_REENTRY_PER_FRAME_THRESHOLD) + return FREEZE_WEDGE_REENTRY_STORM; + if (frame_delta < FREEZE_WEDGE_SLOW_FRAMES_MAX_DELTA) return FREEZE_WEDGE_SLOW_FRAMES; + + for (uint32_t i = 1; i < count; i++) { + if (window[i].current_func != oldest->current_func || + window[i].last_store_pc != oldest->last_store_pc || + window[i].dirty_ram_insns != oldest->dirty_ram_insns) + return 0; + } + /* During an FMV the guest parks in BIOS MDEC code with every pinned + * signal legitimately still while frames advance. */ + if (mdec_recently_active && + mdec_recently_active((uint32_t)(frame_delta > 0xFFFFFFFEu ? 0xFFFFFFFEu : frame_delta) + 1u)) + return 0; + return FREEZE_WEDGE_SPIN; +} \ No newline at end of file diff --git a/runtime/src/freeze_dump_policy.h b/runtime/src/freeze_dump_policy.h index 22f2c8e6..e7da0da5 100644 --- a/runtime/src/freeze_dump_policy.h +++ b/runtime/src/freeze_dump_policy.h @@ -39,6 +39,33 @@ int freeze_dump_policy_observe(FreezeDumpPolicy *policy, uint32_t wedge_kind, * bounded number of failed attempts before the policy consumes that slot. */ void freeze_dump_policy_record_result(FreezeDumpPolicy *policy, int written); +/* Wedge kinds reported by freeze_wedge_classify (0 = healthy). */ +#define FREEZE_WEDGE_HARD 1u /* frame_count did not advance */ +#define FREEZE_WEDGE_REENTRY_STORM 2u /* exception re-entries per frame over threshold */ +#define FREEZE_WEDGE_SLOW_FRAMES 3u /* frames advanced below the slow-frames floor */ +#define FREEZE_WEDGE_SPIN 5u /* guest pinned in one loop while frames advance */ + +#define FREEZE_WEDGE_EXC_REENTRY_PER_FRAME_THRESHOLD 20000u /* ~10x chronic 2K/frame */ +#define FREEZE_WEDGE_SLOW_FRAMES_MAX_DELTA 10u /* <5 fps avg over the 2s window */ + +/* One heartbeat sample, as far as wedge classification needs it. */ +typedef struct FreezeWedgeSample { + uint64_t frame_count; + uint64_t exc_reentry; + uint64_t dirty_ram_insns; + uint32_t current_func; + uint32_t last_store_pc; +} FreezeWedgeSample; + +/* Classify a window of consecutive samples, oldest first. A spin requires the + * executing function, last store PC and dirty-RAM instruction count to hold + * one value at EVERY sample: statically compiled code leaves dirty_ram_insns + * flat, so a per-frame main loop that happens to store at the same PC at both + * window ends is not a spin. mdec_recently_active (may be NULL) vetoes a spin + * while FMV decoding parks the guest in BIOS MDEC code. */ +uint32_t freeze_wedge_classify(const FreezeWedgeSample *window, uint32_t count, + int (*mdec_recently_active)(uint32_t frames)); + /* Format a unique dump path. The sequence prevents same-second truncation. */ int freeze_dump_format_path(char *out, size_t cap, const char *backend, long long wall, uint32_t sequence); diff --git a/runtime/src/freeze_heartbeat.c b/runtime/src/freeze_heartbeat.c index 8b4d73c6..1cefe9ec 100644 --- a/runtime/src/freeze_heartbeat.c +++ b/runtime/src/freeze_heartbeat.c @@ -125,8 +125,7 @@ static int s_sym_initialized = 0; * activity (boot, FMV decode, save load) doesn't trip it; short enough * that real wedges dump within seconds. */ #define WEDGE_WINDOW_TICKS 20u -#define WEDGE_EXC_REENTRY_PER_FRAME_THRESHOLD 20000u /* ~10x chronic 2K/frame */ -#define WEDGE_SLOW_FRAMES_MAX_DELTA 10u /* <5 fps avg over the 2s window */ +/* Thresholds and the classifier live in freeze_dump_policy.{h,c} (unit-tested). */ /* Per-ring caps for auto-dump. Newest-first window. The old 4-16K caps * spanned well under a second of activity — too short to cross a @@ -823,43 +822,23 @@ static void heartbeat_write(void) { s_ring_count = 0; s_last_wedge_kind = 0; } else if (s_ring_count >= WEDGE_WINDOW_TICKS) { - /* The just-pushed tick is at (s_ring_head - 1). The oldest in - * our window is WEDGE_WINDOW_TICKS - 1 ticks before it. */ - uint32_t newest_idx = (s_ring_head + RING_CAP - 1u) % RING_CAP; - uint32_t oldest_idx = (s_ring_head + RING_CAP - WEDGE_WINDOW_TICKS) % RING_CAP; - uint64_t newest_frame = s_ring[newest_idx].frame_count; - uint64_t oldest_frame = s_ring[oldest_idx].frame_count; - uint64_t newest_excre = s_ring[newest_idx].exc_reentry; - uint64_t oldest_excre = s_ring[oldest_idx].exc_reentry; - uint64_t frame_delta = (newest_frame >= oldest_frame) - ? (newest_frame - oldest_frame) : 0; - uint64_t excre_delta = (newest_excre >= oldest_excre) - ? (newest_excre - oldest_excre) : 0; - - /* Logical-hang (kind D) signature: the executing function, the last - * store PC, and the retired dirty-RAM instruction count are all - * unchanged across the whole window. Requiring all three pinned makes - * this specific to a guest spin loop — a legitimate long native - * compute would still move last_store_pc, and any interpreted/overlay - * work would advance dirty_insns. Checked only when frames are - * advancing healthily (kinds 1/2/3 take precedence below). */ - int logic_pinned = - (s_ring[newest_idx].current_func == s_ring[oldest_idx].current_func) && - (s_ring[newest_idx].last_store_pc == s_ring[oldest_idx].last_store_pc) && - (s_ring[newest_idx].dirty_ram_insns == s_ring[oldest_idx].dirty_ram_insns); - - if (frame_delta == 0) - wedge_kind = 1; - else if (excre_delta / frame_delta > WEDGE_EXC_REENTRY_PER_FRAME_THRESHOLD) - wedge_kind = 2; - else if (frame_delta < WEDGE_SLOW_FRAMES_MAX_DELTA) - wedge_kind = 3; - else if (logic_pinned && !mdec_recently_active((uint32_t)frame_delta + 1u)) - wedge_kind = 5; /* spin freeze: game wedged while frames advance */ - /* Wave-5 F9: during an FMV the guest parks in BIOS MDEC code with the dirty-RAM - * counters legitimately still while frames advance -- exactly the spin signature. - * Every boot FMV wrote a 47-76 MB dump (1.8 GB per kit in one session). MDEC - * activity inside the window rules the spin classification out. */ + /* The just-pushed tick is at (s_ring_head - 1); the window is the + * WEDGE_WINDOW_TICKS samples ending there, passed oldest first. A + * spin must hold at every sample, not just the window ends: static + * code leaves dirty_ram_insns flat, and a per-frame main loop often + * stores at the same PC when two ticks happen to align. */ + FreezeWedgeSample window[WEDGE_WINDOW_TICKS]; + for (uint32_t i = 0; i < WEDGE_WINDOW_TICKS; i++) { + const HbRingEntry *src = + &s_ring[(s_ring_head + RING_CAP - WEDGE_WINDOW_TICKS + i) % RING_CAP]; + window[i].frame_count = src->frame_count; + window[i].exc_reentry = src->exc_reentry; + window[i].dirty_ram_insns = src->dirty_ram_insns; + window[i].current_func = src->current_func; + window[i].last_store_pc = src->last_store_pc; + } + wedge_kind = freeze_wedge_classify(window, WEDGE_WINDOW_TICKS, + mdec_recently_active); } if (!s_wedge_classification_paused && diff --git a/runtime/tests/test_freeze_dump_policy.c b/runtime/tests/test_freeze_dump_policy.c index 89b0013b..b237b1a3 100644 --- a/runtime/tests/test_freeze_dump_policy.c +++ b/runtime/tests/test_freeze_dump_policy.c @@ -23,8 +23,21 @@ static void observe_healthy(FreezeDumpPolicy *policy, uint32_t ticks) { for (uint32_t i = 0; i < ticks; i++) EXPECT_EQ(observe(policy, 0), 0); } -int main(void) { - FreezeDumpPolicy policy = {0}; +static int mdec_idle(uint32_t frames) { (void)frames; return 0; } +static int mdec_busy(uint32_t frames) { (void)frames; return 1; } + +/* 20 samples, frames advancing 9 per tick, guest pinned in one kernel loop. */ +static void spin_window(FreezeWedgeSample *w, uint32_t n) { + for (uint32_t i = 0; i < n; i++) { + w[i].frame_count = 1746u + 9u * i; + w[i].exc_reentry = 2000u + i; + w[i].dirty_ram_insns = 908846u; + w[i].current_func = 0x00000F40u; + w[i].last_store_pc = 0x8003A230u; + } +} + +int main(void) { FreezeDumpPolicy policy = {0}; /* Authorization does not consume a slot until the file reaches disk. */ EXPECT_EQ(observe(&policy, 5), 1); @@ -114,8 +127,38 @@ int main(void) { EXPECT_EQ(strcmp(path0, path1) != 0, 1); EXPECT_EQ(freeze_dump_format_path(path0, 4, "psx-runtime", 1234, 0), 0); - if (failures != 0) { - fprintf(stderr, "freeze dump policy: %d failure(s)\n", failures); + /* Wedge classification over the whole window. */ + FreezeWedgeSample w[20]; + spin_window(w, 20); + EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_idle), FREEZE_WEDGE_SPIN); + EXPECT_EQ(freeze_wedge_classify(w, 20, NULL), FREEZE_WEDGE_SPIN); + /* FMV decode parks the guest the same way; MDEC activity vetoes it. */ + EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_busy), 0); + /* Static-code main loop (LoD 2026-10-12 dump): the window ends agree but + * intermediate ticks store elsewhere. Not a spin. */ + w[3].last_store_pc = 0x800461F0u; + w[11].last_store_pc = 0x800FBD44u; + EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_idle), 0); + spin_window(w, 20); + w[7].current_func = 0x1FC02918u; + EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_idle), 0); + spin_window(w, 20); + w[19].dirty_ram_insns += 4u; + EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_idle), 0); + /* Frame-progress kinds take precedence over the spin test. */ + spin_window(w, 20); + for (uint32_t i = 0; i < 20; i++) w[i].frame_count = 500u; + EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_busy), FREEZE_WEDGE_HARD); + for (uint32_t i = 0; i < 20; i++) w[i].frame_count = 500u + (i == 19 ? 5u : 0u); + EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_idle), FREEZE_WEDGE_SLOW_FRAMES); + spin_window(w, 20); + w[19].exc_reentry = w[0].exc_reentry + + (uint64_t)(FREEZE_WEDGE_EXC_REENTRY_PER_FRAME_THRESHOLD + 1u) * 171u; + EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_idle), FREEZE_WEDGE_REENTRY_STORM); + EXPECT_EQ(freeze_wedge_classify(w, 1, mdec_idle), 0); + EXPECT_EQ(freeze_wedge_classify(NULL, 20, mdec_idle), 0); + + if (failures != 0) { fprintf(stderr, "freeze dump policy: %d failure(s)\n", failures); return 1; } puts("freeze dump policy: all checks passed"); From 21aad805cbb20956754096206248d8a9a8853fcf Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 04:25:36 -0700 Subject: [PATCH 07/17] freeze_heartbeat: spin_freeze also requires low function-entry diversity from the guest-cycle profile ring Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/src/freeze_dump_policy.c | 12 ++++++++++ runtime/src/freeze_dump_policy.h | 29 ++++++++++++++++++++---- runtime/src/freeze_heartbeat.c | 30 +++++++++++++++++++++++++ runtime/tests/test_freeze_dump_policy.c | 24 +++++++++++--------- 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/runtime/src/freeze_dump_policy.c b/runtime/src/freeze_dump_policy.c index 0803ea95..4cb55f4d 100644 --- a/runtime/src/freeze_dump_policy.c +++ b/runtime/src/freeze_dump_policy.c @@ -90,7 +90,16 @@ int freeze_dump_format_path(char *out, size_t cap, const char *backend, return n > 0 && (size_t)n < cap; } +int freeze_distinct_add(FreezeDistinct *set, uint32_t value) { + if (set->count > FREEZE_WEDGE_SPIN_MAX_FNS) return 1; + for (uint32_t i = 0; i < set->count; i++) + if (set->values[i] == value) return 0; + set->values[set->count++] = value; + return set->count > FREEZE_WEDGE_SPIN_MAX_FNS; +} + uint32_t freeze_wedge_classify(const FreezeWedgeSample *window, uint32_t count, + uint32_t fn_diversity, int (*mdec_recently_active)(uint32_t frames)) { if (!window || count < 2u) return 0; const FreezeWedgeSample *oldest = &window[0]; @@ -111,6 +120,9 @@ uint32_t freeze_wedge_classify(const FreezeWedgeSample *window, uint32_t count, window[i].dirty_ram_insns != oldest->dirty_ram_insns) return 0; } + if (fn_diversity != FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN && + fn_diversity > FREEZE_WEDGE_SPIN_MAX_FNS) + return 0; /* During an FMV the guest parks in BIOS MDEC code with every pinned * signal legitimately still while frames advance. */ if (mdec_recently_active && diff --git a/runtime/src/freeze_dump_policy.h b/runtime/src/freeze_dump_policy.h index e7da0da5..2ce93c10 100644 --- a/runtime/src/freeze_dump_policy.h +++ b/runtime/src/freeze_dump_policy.h @@ -57,13 +57,34 @@ typedef struct FreezeWedgeSample { uint32_t last_store_pc; } FreezeWedgeSample; +/* A spinning guest enters only its wait loop plus whatever the interrupt + * handlers last stamped; a running game loop enters dozens of functions per + * window (LoD title/field/battle >= 13 per 120 frames, measured 2026-10-12). */ +#define FREEZE_WEDGE_SPIN_MAX_FNS 8u +#define FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN UINT32_MAX + +/* Distinct-value counter that stops counting past its capacity. */ +typedef struct FreezeDistinct { + uint32_t values[FREEZE_WEDGE_SPIN_MAX_FNS + 1u]; + uint32_t count; +} FreezeDistinct; + +/* Add one value; returns nonzero once more than FREEZE_WEDGE_SPIN_MAX_FNS + * distinct values have been seen (the caller can stop scanning). */ +int freeze_distinct_add(FreezeDistinct *set, uint32_t value); + /* Classify a window of consecutive samples, oldest first. A spin requires the * executing function, last store PC and dirty-RAM instruction count to hold - * one value at EVERY sample: statically compiled code leaves dirty_ram_insns - * flat, so a per-frame main loop that happens to store at the same PC at both - * window ends is not a spin. mdec_recently_active (may be NULL) vetoes a spin - * while FMV decoding parks the guest in BIOS MDEC code. */ + * one value at EVERY sample, AND (when known) at most FREEZE_WEDGE_SPIN_MAX_FNS + * distinct guest functions entered outside exceptions over the window. + * Statically compiled code stamps neither current_func nor dirty_ram_insns, so + * the pinned signature alone also matches a healthy static main loop; the + * function-entry stamp is maintained by all generated code. fn_diversity is + * FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN when no profile samples cover the window. + * mdec_recently_active (may be NULL) vetoes a spin while FMV decoding parks + * the guest in BIOS MDEC code. */ uint32_t freeze_wedge_classify(const FreezeWedgeSample *window, uint32_t count, + uint32_t fn_diversity, int (*mdec_recently_active)(uint32_t frames)); /* Format a unique dump path. The sequence prevents same-second truncation. */ diff --git a/runtime/src/freeze_heartbeat.c b/runtime/src/freeze_heartbeat.c index 1cefe9ec..68b3138c 100644 --- a/runtime/src/freeze_heartbeat.c +++ b/runtime/src/freeze_heartbeat.c @@ -4,6 +4,7 @@ #include "psx_memory.h" #include "freeze_heartbeat.h" #include "freeze_dump_policy.h" +#include "guest_cycle_profile.h" #include "debug_server.h" #include "crash_trace.h" /* g_psx_fatal_reason */ #include "cpu_state.h" /* g_psx_bail_* call-contract counters */ @@ -162,6 +163,7 @@ typedef struct { uint64_t tcp_stall_ms; uint16_t t1_count; /* Timer1/RCnt1 counter — must advance if the wait is to complete */ uint64_t t1_irq_fired; /* Timer1 IRQ-fire count — flat == RCnt IRQ never fires */ + uint64_t gcp_seq; /* guest_cycle_profile_total() at this tick */ } HbRingEntry; static HbRingEntry s_ring[RING_CAP]; static uint32_t s_ring_head = 0; @@ -754,6 +756,24 @@ void freeze_heartbeat_fatal_dump(const char *reason) { sio_get_mc_max_state(), sio_get_tx_writes()); } +/* Distinct guest functions entered outside exceptions in profile samples + * [lo, hi), read from the always-on guest-cycle profile ring. Stops counting + * past FREEZE_WEDGE_SPIN_MAX_FNS; UNKNOWN when no sample covers the window. */ +static uint32_t window_fn_diversity(uint64_t lo, uint64_t hi) { + uint64_t oldest = guest_cycle_profile_oldest(); + if (lo < oldest) lo = oldest; + FreezeDistinct set = {{0}, 0}; + int any = 0; + GcpSample s; + for (uint64_t seq = lo; seq < hi; seq++) { + if (!guest_cycle_profile_get(seq, &s)) continue; + if (s.flags & GCP_FLAG_EXCEPTION) continue; + any = 1; + if (freeze_distinct_add(&set, s.fn)) break; + } + return any ? set.count : FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN; +} + static void heartbeat_write(void) { uint64_t cyc = psx_get_cycle_count(); uint64_t frame = s_frame_count; @@ -804,6 +824,7 @@ static void heartbeat_write(void) { { uint16_t _t1c = 0; uint64_t _t1f = 0; timers_get_debug(1, &_t1c, NULL, NULL, &_t1f); re->t1_count = _t1c; re->t1_irq_fired = _t1f; } + re->gcp_seq = guest_cycle_profile_total(); s_ring_head = (s_ring_head + 1) % RING_CAP; if (s_ring_count < RING_CAP) s_ring_count++; @@ -838,7 +859,16 @@ static void heartbeat_write(void) { window[i].last_store_pc = src->last_store_pc; } wedge_kind = freeze_wedge_classify(window, WEDGE_WINDOW_TICKS, + FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN, mdec_recently_active); + if (wedge_kind == FREEZE_WEDGE_SPIN) { + /* Only a pinned window pays for the profile scan. */ + uint64_t lo = s_ring[(s_ring_head + RING_CAP - WEDGE_WINDOW_TICKS) % RING_CAP].gcp_seq; + uint64_t hi = s_ring[(s_ring_head + RING_CAP - 1u) % RING_CAP].gcp_seq; + wedge_kind = freeze_wedge_classify(window, WEDGE_WINDOW_TICKS, + window_fn_diversity(lo, hi), + mdec_recently_active); + } } if (!s_wedge_classification_paused && diff --git a/runtime/tests/test_freeze_dump_policy.c b/runtime/tests/test_freeze_dump_policy.c index b237b1a3..ffa05359 100644 --- a/runtime/tests/test_freeze_dump_policy.c +++ b/runtime/tests/test_freeze_dump_policy.c @@ -23,6 +23,8 @@ static void observe_healthy(FreezeDumpPolicy *policy, uint32_t ticks) { for (uint32_t i = 0; i < ticks; i++) EXPECT_EQ(observe(policy, 0), 0); } +#define UNK FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN + static int mdec_idle(uint32_t frames) { (void)frames; return 0; } static int mdec_busy(uint32_t frames) { (void)frames; return 1; } @@ -130,33 +132,33 @@ int main(void) { FreezeDumpPolicy policy = {0}; /* Wedge classification over the whole window. */ FreezeWedgeSample w[20]; spin_window(w, 20); - EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_idle), FREEZE_WEDGE_SPIN); - EXPECT_EQ(freeze_wedge_classify(w, 20, NULL), FREEZE_WEDGE_SPIN); + EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_idle), FREEZE_WEDGE_SPIN); + EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, NULL), FREEZE_WEDGE_SPIN); /* FMV decode parks the guest the same way; MDEC activity vetoes it. */ - EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_busy), 0); + EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_busy), 0); /* Static-code main loop (LoD 2026-10-12 dump): the window ends agree but * intermediate ticks store elsewhere. Not a spin. */ w[3].last_store_pc = 0x800461F0u; w[11].last_store_pc = 0x800FBD44u; - EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_idle), 0); + EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_idle), 0); spin_window(w, 20); w[7].current_func = 0x1FC02918u; - EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_idle), 0); + EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_idle), 0); spin_window(w, 20); w[19].dirty_ram_insns += 4u; - EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_idle), 0); + EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_idle), 0); /* Frame-progress kinds take precedence over the spin test. */ spin_window(w, 20); for (uint32_t i = 0; i < 20; i++) w[i].frame_count = 500u; - EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_busy), FREEZE_WEDGE_HARD); + EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_busy), FREEZE_WEDGE_HARD); for (uint32_t i = 0; i < 20; i++) w[i].frame_count = 500u + (i == 19 ? 5u : 0u); - EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_idle), FREEZE_WEDGE_SLOW_FRAMES); + EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_idle), FREEZE_WEDGE_SLOW_FRAMES); spin_window(w, 20); w[19].exc_reentry = w[0].exc_reentry + (uint64_t)(FREEZE_WEDGE_EXC_REENTRY_PER_FRAME_THRESHOLD + 1u) * 171u; - EXPECT_EQ(freeze_wedge_classify(w, 20, mdec_idle), FREEZE_WEDGE_REENTRY_STORM); - EXPECT_EQ(freeze_wedge_classify(w, 1, mdec_idle), 0); - EXPECT_EQ(freeze_wedge_classify(NULL, 20, mdec_idle), 0); + EXPECT_EQ(freeze_wedge_classify(w, 20, UNK, mdec_idle), FREEZE_WEDGE_REENTRY_STORM); + EXPECT_EQ(freeze_wedge_classify(w, 1, UNK, mdec_idle), 0); + EXPECT_EQ(freeze_wedge_classify(NULL, 20, UNK, mdec_idle), 0); if (failures != 0) { fprintf(stderr, "freeze dump policy: %d failure(s)\n", failures); return 1; From f3457bfd73bd419df0819391e60cca14abab6245 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 04:40:33 -0700 Subject: [PATCH 08/17] freeze_dump_policy: unit-test the profile-ring diversity scan Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/src/freeze_dump_policy.c | 15 +++++++++++++++ runtime/src/freeze_dump_policy.h | 8 ++++++++ runtime/src/freeze_heartbeat.c | 26 +++++++++----------------- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/runtime/src/freeze_dump_policy.c b/runtime/src/freeze_dump_policy.c index 4cb55f4d..b3ebec64 100644 --- a/runtime/src/freeze_dump_policy.c +++ b/runtime/src/freeze_dump_policy.c @@ -98,6 +98,21 @@ int freeze_distinct_add(FreezeDistinct *set, uint32_t value) { return set->count > FREEZE_WEDGE_SPIN_MAX_FNS; } +uint32_t freeze_window_fn_diversity(uint64_t lo, uint64_t hi, uint64_t oldest, + int (*get)(uint64_t seq, uint32_t *fn, + int *in_exception)) { + if (lo < oldest) lo = oldest; + FreezeDistinct set = {{0}, 0}; + int any = 0; + for (uint64_t seq = lo; seq < hi; seq++) { + uint32_t fn = 0; + int in_exception = 0; + if (!get(seq, &fn, &in_exception) || in_exception) continue; + any = 1; + if (freeze_distinct_add(&set, fn)) break; + } + return any ? set.count : FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN; +} uint32_t freeze_wedge_classify(const FreezeWedgeSample *window, uint32_t count, uint32_t fn_diversity, int (*mdec_recently_active)(uint32_t frames)) { diff --git a/runtime/src/freeze_dump_policy.h b/runtime/src/freeze_dump_policy.h index 2ce93c10..aa2c4184 100644 --- a/runtime/src/freeze_dump_policy.h +++ b/runtime/src/freeze_dump_policy.h @@ -73,6 +73,14 @@ typedef struct FreezeDistinct { * distinct values have been seen (the caller can stop scanning). */ int freeze_distinct_add(FreezeDistinct *set, uint32_t value); +/* Distinct functions entered outside exceptions over profile samples + * [lo, hi), clamped to oldest (the ring's oldest held sample). get() returns + * 0 for an evicted/unavailable sample. Stops counting past + * FREEZE_WEDGE_SPIN_MAX_FNS; FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN when no + * non-exception sample covers the window. */ +uint32_t freeze_window_fn_diversity(uint64_t lo, uint64_t hi, uint64_t oldest, + int (*get)(uint64_t seq, uint32_t *fn, + int *in_exception)); /* Classify a window of consecutive samples, oldest first. A spin requires the * executing function, last store PC and dirty-RAM instruction count to hold * one value at EVERY sample, AND (when known) at most FREEZE_WEDGE_SPIN_MAX_FNS diff --git a/runtime/src/freeze_heartbeat.c b/runtime/src/freeze_heartbeat.c index 68b3138c..f3fb810e 100644 --- a/runtime/src/freeze_heartbeat.c +++ b/runtime/src/freeze_heartbeat.c @@ -756,24 +756,14 @@ void freeze_heartbeat_fatal_dump(const char *reason) { sio_get_mc_max_state(), sio_get_tx_writes()); } -/* Distinct guest functions entered outside exceptions in profile samples - * [lo, hi), read from the always-on guest-cycle profile ring. Stops counting - * past FREEZE_WEDGE_SPIN_MAX_FNS; UNKNOWN when no sample covers the window. */ -static uint32_t window_fn_diversity(uint64_t lo, uint64_t hi) { - uint64_t oldest = guest_cycle_profile_oldest(); - if (lo < oldest) lo = oldest; - FreezeDistinct set = {{0}, 0}; - int any = 0; +/* Adapter from the guest-cycle profile ring to freeze_window_fn_diversity. */ +static int gcp_sample_fn(uint64_t seq, uint32_t *fn, int *in_exception) { GcpSample s; - for (uint64_t seq = lo; seq < hi; seq++) { - if (!guest_cycle_profile_get(seq, &s)) continue; - if (s.flags & GCP_FLAG_EXCEPTION) continue; - any = 1; - if (freeze_distinct_add(&set, s.fn)) break; - } - return any ? set.count : FREEZE_WEDGE_FN_DIVERSITY_UNKNOWN; + if (!guest_cycle_profile_get(seq, &s)) return 0; + *fn = s.fn; + *in_exception = (s.flags & GCP_FLAG_EXCEPTION) != 0; + return 1; } - static void heartbeat_write(void) { uint64_t cyc = psx_get_cycle_count(); uint64_t frame = s_frame_count; @@ -866,7 +856,9 @@ static void heartbeat_write(void) { uint64_t lo = s_ring[(s_ring_head + RING_CAP - WEDGE_WINDOW_TICKS) % RING_CAP].gcp_seq; uint64_t hi = s_ring[(s_ring_head + RING_CAP - 1u) % RING_CAP].gcp_seq; wedge_kind = freeze_wedge_classify(window, WEDGE_WINDOW_TICKS, - window_fn_diversity(lo, hi), + freeze_window_fn_diversity( + lo, hi, guest_cycle_profile_oldest(), + gcp_sample_fn), mdec_recently_active); } } From 819d164c720f6948568eab6d6c76372c7ae84ccd Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 04:57:46 -0700 Subject: [PATCH 09/17] resident: memory_only specs skip the disk cache Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/include/mod_resident.h | 6 ++++++ runtime/src/mod_resident.cpp | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/runtime/include/mod_resident.h b/runtime/include/mod_resident.h index 1a861d9e..f63d9ae3 100644 --- a/runtime/include/mod_resident.h +++ b/runtime/include/mod_resident.h @@ -76,6 +76,12 @@ typedef struct PSXResidentSpec { void* derive_user; uint32_t keep_packs; /* packs of this format kept besides the * active one; 0 = 3 */ + uint32_t memory_only; /* nonzero: never read or write a disk + * cache; the pack is built from the + * effective disc at every prepare. For + * packs that are plain disc files, where + * verifying a copy costs as much as + * reading the disc again. */ } PSXResidentSpec; typedef struct PSXResidentPack PSXResidentPack; diff --git a/runtime/src/mod_resident.cpp b/runtime/src/mod_resident.cpp index 08a69f03..4fcca2c2 100644 --- a/runtime/src/mod_resident.cpp +++ b/runtime/src/mod_resident.cpp @@ -405,7 +405,7 @@ extern "C" const PSXResidentPack* psx_resident_prepare(const PSXResidentSpec* sp const Digest key = cache_key(spec, fingerprint); /* Without a committed plan there is no disc identity to key on: build * in memory and never reuse or publish. */ - bool cacheable = !fingerprint.empty(); + bool cacheable = !fingerprint.empty() && !spec->memory_only; std::string memory_only; /* why a planned cache is not used */ std::filesystem::path path; std::unique_ptr pack; @@ -448,7 +448,9 @@ extern "C" const PSXResidentPack* psx_resident_prepare(const PSXResidentSpec* sp rec.state = "prepared (memory only: " + memory_only + ")"; psx_mod_counter_add("resident.cache_write_failed", 1); } else { - rec.state = cacheable ? "prepared" : "prepared (uncached: no mod plan)"; + rec.state = cacheable ? "prepared" + : spec->memory_only ? "prepared (memory only)" + : "prepared (uncached: no mod plan)"; } psx_mod_counter_add("resident.prepared", 1); } From a7e7ddb9a24dd72fa661ceddaf679ef7cda5e0e6 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 04:57:46 -0700 Subject: [PATCH 10/17] mod: host disc reads follow the mounted disc, psx_mod_mounted_disc; fix resident test link and cover both Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/include/mod_plugins.h | 4 +++ runtime/include/mod_runtime.h | 4 +++ runtime/src/main.cpp | 4 +++ runtime/src/mod_runtime.cpp | 33 ++++++++++++++++-- runtime/tests/test_mod_resident.cpp | 52 +++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 3 deletions(-) diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 81e92287..73c80fb8 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -110,6 +110,10 @@ int psx_mod_game_started(void); * otherwise capacity must hold the entire file. Active sector mods apply. */ int psx_mod_read_disc_file(const char* path, void* buffer, uint32_t capacity, uint32_t* size); +/* 1-based roster index of the disc image the guest drive has mounted, or 0 + * for a single-image session. It changes when the player swaps discs; the + * psx_mod_read_disc_file / psx_mod_disc_file_extent calls read that image. */ +int psx_mod_mounted_disc(void); /* Effective-disc LBA and byte size of a file (directory records patched by * active mods apply, so a relocated file reports its new extent). */ int psx_mod_disc_file_extent(const char* path, uint32_t* lba, uint32_t* size); diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index 4f51a2df..c4975cfb 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -108,6 +108,10 @@ void mod_runtime_on_savestate_loaded(void); * launcher commit and before renderer/window initialization. */ void mod_runtime_activate_plugins(void); void mod_runtime_on_vblank(void); +/* Host-side disc reads for plugins (psx_mod_read_disc_file and friends) follow + * the image the guest drive has mounted. `path` returns the mounted image's + * path, `index` its 1-based roster position. NULL restores the boot disc. */ +void mod_runtime_set_mounted_disc_resolver(const char* (*path)(void), int (*index)(void)); /* Host-only callback continuation. Exception/render landings restore the live * outer callback; scheduler escapes restore an empty context after abandoning * all callbacks. Never serialize this into guest saves. */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index cf33df28..1df8db28 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -18966,6 +18966,10 @@ session_reboot: const int mounted = roster_index_for_disc(game_discs, disc_path_str); cdrom_disc_roster_set(roster.data(), (int)roster.size(), mounted >= 0 ? mounted + 1 : selected_disc_index); + /* Host-side disc reads by plugins (resident packs) follow a mid-session swap. */ + mod_runtime_set_mounted_disc_resolver( + [](void) -> const char* { return cdrom_disc_roster_path(cdrom_disc_selected()); }, + [](void) -> int { return cdrom_disc_selected(); }); /* --disc names a disc of the set, but the earlier index derivation is * skipped for an override, so selected_disc_index kept its default of * 1. That put the savestate disc token at _disc1 whatever was mounted, diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 343186b9..b91f4fe7 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -2693,6 +2693,33 @@ static bool find_effective_file(PS1::ISOReader& reader, const std::string& path, return true; } +/* The disc image the guest drive currently reads. A multi-disc title swaps + * images mid-session; host reads that stand in for the drive must follow the + * swap. The runtime registers a resolver for the roster; without one (or with + * a patched effective disc, which exists only for the boot image) the boot + * disc is read, exactly as before. */ +static const char* (*g_mounted_disc_path)(void) = nullptr; +static int (*g_mounted_disc_index)(void) = nullptr; + +static std::filesystem::path mounted_disc_path(const PSXRecompV4::RuntimeMods& s) { + if (!s.effective_disc_path.empty()) return s.effective_disc_path; + if (g_mounted_disc_path) { + const char* p = g_mounted_disc_path(); + if (p && *p) return p; + } + return s.disc_path; +} + +extern "C" void mod_runtime_set_mounted_disc_resolver(const char* (*path)(void), + int (*index)(void)) { + g_mounted_disc_path = path; + g_mounted_disc_index = index; +} + +extern "C" int psx_mod_mounted_disc(void) { + return g_mounted_disc_index ? g_mounted_disc_index() : 0; +} + extern "C" int psx_mod_read_disc_file(const char* path, void* buffer, uint32_t capacity, uint32_t* size) { using namespace PSXRecompV4; @@ -2700,7 +2727,7 @@ extern "C" int psx_mod_read_disc_file(const char* path, void* buffer, if (!path || !*path || !size || (!buffer && capacity)) return 0; try { const auto& s = state(); - const auto& mount = s.effective_disc_path.empty() ? s.disc_path : s.effective_disc_path; + const auto mount = mounted_disc_path(s); if (mount.empty()) return 0; PS1::ISOReader reader; uint32_t lba = 0, bytes = 0; @@ -2730,7 +2757,7 @@ extern "C" int psx_mod_disc_file_extent(const char* path, uint32_t* lba, if (!path || !*path || !lba || !size) return 0; try { const auto& s = state(); - const auto& mount = s.effective_disc_path.empty() ? s.disc_path : s.effective_disc_path; + const auto mount = mounted_disc_path(s); if (mount.empty()) return 0; PS1::ISOReader reader; uint32_t first = 0, bytes = 0; @@ -2760,7 +2787,7 @@ bool mod_runtime_read_disc_file_sectors(const std::string& path, uint32_t max_by }; try { const auto& s = state(); - const auto& mount = s.effective_disc_path.empty() ? s.disc_path : s.effective_disc_path; + const auto mount = mounted_disc_path(s); if (mount.empty()) return fail("no disc mounted"); PS1::ISOReader reader; bool directory = false; diff --git a/runtime/tests/test_mod_resident.cpp b/runtime/tests/test_mod_resident.cpp index de3d90d9..db07b040 100644 --- a/runtime/tests/test_mod_resident.cpp +++ b/runtime/tests/test_mod_resident.cpp @@ -57,6 +57,7 @@ extern "C" int psx_ws_x_margin(void) { return 0; } extern "C" void gpu_get_display_info(GpuDisplayInfo* out) { *out = GpuDisplayInfo{}; } extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {} extern "C" int fntrace_is_game_started(void) { return 1; } +extern "C" int psx_netplay_active(void) { return 0; } extern "C" void gpu_ws_tag_hud_primitive(uint32_t, int) {} extern "C" void gpu_ws_tag_world_primitive(uint32_t, int) {} extern "C" void gpu_ws_set_adaptive_backdrop_preload(int) {} @@ -168,6 +169,31 @@ int main() { "extent query reports the effective record"); check(!psx_mod_disc_file_extent("DATA", &lba, &size) && !lba && !size, "extent rejects directories"); + /* A multi-disc title swaps images mid-session: host reads follow the + * resolver the runtime registers, and the boot disc is read again once it + * is cleared. The second image holds B.BIN with a different extent. */ + { + std::vector iso2 = iso; + const size_t b2 = a_rec + (size_t)iso2[a_rec]; + for (unsigned i = 0; i < 4; ++i) { + iso2[b2 + 2 + i] = (uint8_t)(25u >> (i * 8)); + iso2[b2 + 10 + i] = (uint8_t)(1234u >> (i * 8)); + } + const fs::path iso2_path = root / "disc2.iso"; + write_bytes(iso2_path, iso2); + static std::string swapped = iso2_path.string(); + static int selected = 2; + mod_runtime_set_mounted_disc_resolver( + [](void) -> const char* { return swapped.c_str(); }, [](void) -> int { return selected; }); + check(psx_mod_mounted_disc() == 2, "mounted disc index reported"); + check(psx_mod_disc_file_extent("DATA/B.BIN", &lba, &size) && lba == 25 && size == 1234, + "extent follows the mounted disc"); + mod_runtime_set_mounted_disc_resolver(nullptr, nullptr); + check(psx_mod_mounted_disc() == 0, "no resolver: single-image session"); + check(psx_mod_disc_file_extent("DATA/B.BIN", &lba, &size) && lba == 24 && size == 5000, + "extent reads the boot disc again"); + } + PSXResidentFile files[2] = {{"DATA/A.BIN", 3000, a_sha.c_str()}, {"data/b.bin", 5000, b_sha.c_str()}}; PSXResidentSpec spec{}; @@ -296,6 +322,32 @@ int main() { setenv("PSX_RESIDENT_CACHE", (root / "cache").string().c_str(), 1); #endif + /* A memory-only spec never touches the cache folder: no pack is read from + * or written to it, even though a verified pack of this format sits there. */ + { + const fs::path memdir = root / "cache" / "ResidentTest"; + size_t packs_before = 0; + for (const auto& e : fs::directory_iterator(memdir)) + packs_before += e.path().extension() == ".pack"; + PSXResidentSpec mem = spec; + mem.format = "rt-mem-v1"; + mem.memory_only = 1; + mem.derive_user = (void*)&a_padded; + const int derived_before = derive_calls; + pack = psx_resident_prepare(&mem); + check(pack && psx_resident_file(pack, 1, nullptr, nullptr), "memory-only spec prepares"); + check(status().find("prepared (memory only)") != std::string::npos, "memory-only state reported"); + size_t packs_after = 0; + bool wrote = false; + for (const auto& e : fs::directory_iterator(memdir)) { + packs_after += e.path().extension() == ".pack"; + wrote |= e.path().filename().string().rfind("rt-mem-v1-", 0) == 0; + } + check(!wrote && packs_after == packs_before, "memory-only spec writes no cache file"); + pack = psx_resident_prepare(&mem); + check(pack && derive_calls == derived_before + 4, "memory-only spec rebuilds on every prepare"); + } + /* Service ring: frame window, newest-first selection, oldest-first * output, eviction past capacity. */ check(events(0, UINT64_MAX, 10).find("\"total\":0") != std::string::npos, "empty ring"); From ece2cc8fbe3f99bf053ad4503b8d1e7fe8eb0ab6 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 06:42:06 -0700 Subject: [PATCH 11/17] mod: host disc reads pet the starvation watchdog (progress hook) Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/include/mod_runtime.h | 3 +++ runtime/src/main.cpp | 1 + runtime/src/mod_runtime.cpp | 7 ++++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index c4975cfb..45ebb076 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -112,6 +112,9 @@ void mod_runtime_on_vblank(void); * the image the guest drive has mounted. `path` returns the mounted image's * path, `index` its 1-based roster position. NULL restores the boot disc. */ void mod_runtime_set_mounted_disc_resolver(const char* (*path)(void), int (*index)(void)); +/* Long host-side disc reads (resident pack preparation) call `hook` about every + * 2 MiB so the emulation thread's starvation watchdog sees progress. NULL clears. */ +void mod_runtime_set_progress_hook(void (*hook)(void)); /* Host-only callback continuation. Exception/render landings restore the live * outer callback; scheduler escapes restore an empty context after abandoning * all callbacks. Never serialize this into guest saves. */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 1df8db28..4f3d92d1 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -18955,6 +18955,7 @@ session_reboot: * game runs (debug server `disc_select`). Take the current index from what * actually mounted rather than the requested one: a --disc override can * mount an image the persisted index does not name. */ + mod_runtime_set_progress_hook(starvation_watchdog_heartbeat); if (game_discs.size() > 1) { std::vector roster_storage; std::vector roster; diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index b91f4fe7..bdb8a889 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -2700,6 +2700,9 @@ static bool find_effective_file(PS1::ISOReader& reader, const std::string& path, * disc is read, exactly as before. */ static const char* (*g_mounted_disc_path)(void) = nullptr; static int (*g_mounted_disc_index)(void) = nullptr; +static void (*g_progress_hook)(void) = nullptr; + +extern "C" void mod_runtime_set_progress_hook(void (*hook)(void)) { g_progress_hook = hook; } static std::filesystem::path mounted_disc_path(const PSXRecompV4::RuntimeMods& s) { if (!s.effective_disc_path.empty()) return s.effective_disc_path; @@ -2798,9 +2801,11 @@ bool mod_runtime_read_disc_file_sectors(const std::string& path, uint32_t max_by return fail("unsupported size " + std::to_string(size)); const uint32_t sectors = (size + 2047u) / 2048u; padded.resize(size_t(sectors) * 2048u); - for (uint32_t i = 0; i < sectors; ++i) + for (uint32_t i = 0; i < sectors; ++i) { + if (g_progress_hook && !(i & 1023u)) g_progress_hook(); if (!read_effective_user_sector(reader, lba + i, padded.data() + size_t(i) * 2048u)) return fail("unreadable sector " + std::to_string(lba + i)); + } return true; } catch (const std::exception& e) { return fail(e.what()); From 8867ed0c3c5ebc84cef7d0dd6b0f1b43e88974f7 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 05:00:36 -0700 Subject: [PATCH 12/17] Mod immediates: runtime-selectable instruction immediates that keep static code native Co-Authored-By: Claude Sonnet 5.5 --- docs/MOD_PACKAGES.md | 12 + docs/config_schema.md | 43 +++ overlay_toolchain/include/overlay_api.h | 7 +- .../include/overlay_dispatch_preamble.c.inc | 3 + recompiler/CMakeLists.txt | 41 +++ recompiler/include/code_generator.h | 11 + recompiler/src/code_generator.cpp | 59 ++- recompiler/src/config_loader.cpp | 72 ++++ recompiler/src/config_loader.h | 23 ++ recompiler/src/main_psx.cpp | 12 + .../tests/mod_immediate_codegen_test.cpp | 346 ++++++++++++++++++ runtime/CMakeLists.txt | 2 + runtime/include/mod_immediate.h | 65 ++++ runtime/include/mod_plugins.h | 14 + runtime/include/overlay_api.h | 7 +- .../include/overlay_dispatch_preamble.c.inc | 3 + runtime/runtime.cmake | 1 + runtime/src/dirty_ram_interp.c | 4 + runtime/src/main.cpp | 20 + runtime/src/mod_immediate.c | 93 +++++ runtime/src/overlay_loader.c | 2 + .../tests/test_interp_mod_immediate_guards.py | 25 ++ runtime/tests/test_mod_immediate.c | 156 ++++++++ 23 files changed, 1015 insertions(+), 6 deletions(-) create mode 100644 recompiler/tests/mod_immediate_codegen_test.cpp create mode 100644 runtime/include/mod_immediate.h create mode 100644 runtime/src/mod_immediate.c create mode 100644 runtime/tests/test_interp_mod_immediate_guards.py create mode 100644 runtime/tests/test_mod_immediate.c diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 2c2ea70a..e2410948 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -999,6 +999,18 @@ re-enter guest execution, because pending branch/load state is live. do not change guest behavior. Function entry replacement remains the separate `psx_mod_register_function_entry_plugin` interface. +### Guarded immediate selectors + +A plugin that must change a constant, offset or address inside compiled game +code (a capacity compare, a relocated table) lists the instruction under +`[mod_immediate]` in `game.toml` and picks the variant with +`psx_mod_set_immediate_selector(group, selector)` from its activation +callback. The instruction bytes stay original, so static AOT code, cached +overlays and the interpreter keep matching their guards and run the same +variant; selector 0, every session start and inactive plans run the stock +instruction. Config rules and the emitted switch: docs/config_schema.md "Mod +immediates". + ### Presentation bezel packages The framework registers `psx.bezel`, a trusted presentation plugin for OpenGL diff --git a/docs/config_schema.md b/docs/config_schema.md index 6950f926..dd022064 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -447,6 +447,49 @@ max = 0x1BF - Regenerate after changing the list. With no sites the generated code is unchanged. +## Mod immediates (`[mod_immediate]`) + +A trusted mod sometimes needs the same compiled instruction to behave +differently from one session to the next without changing the guest bytes +(a capacity compared against `slti v0, a0, 32`, a table address formed by +`addiu a1, a1, 0x6988`, a struct offset in `lbu v0, 0x2E9(v1)`). Patching RAM +fails for statically compiled code: every function carries a CRC of its +original bytes and a patched function drops to the interpreter. Listing the +instruction as a mod immediate site keeps the bytes and the CRC, and moves the +choice into the generated code: + +```toml +[mod_immediate] +sites = [ + { address = "0x80023500", expected = "0x28820020", group = 1, values = [0x40, 0x80] }, # slti v0, a0, 0x20 + { address = "0x80023B14", expected = "0x24A5AEB1", group = 1, values = [0xAEB1, 0x9000] }, +] +``` + +- `expected` is the complete word at `address` and must be an I-type ALU + instruction (`ADDI`..`LUI`, opcodes 0x08-0x0F) or a byte/half/word load or + store (`LB`..`SW`, 0x20-0x2B). Branches, jumps and R-type instructions are + refused. `group` is 0..63; `values` lists up to 15 replacement 16-bit + immediates (decimal, hex, or negative), selector 1 first. +- Selector 0 is the original instruction. A trusted plugin selects variant N of + every site in a group with `psx_mod_set_immediate_selector(group, N)` from its + activation callback; the selector is 0 again at every session start, so an + inactive mod, a disabled feature and netplay all run the stock code. +- Generated code emits `switch (psx_mod_immediate_select(group))` with one case + per value, each translated exactly like the instruction it stands for + (cycle accounting, PGXP hooks, load-delay handling), plus the original as the + default. The dirty-RAM interpreter substitutes the same word + (`psx_mod_immediate_patch`), so native and interpreted execution agree. The + forwarder is part of the overlay ABI (v31). +- A site is keyed by physical address and word. Overlays share load addresses, + so one address may carry several sites with different words; a variant whose + word differs is left alone in overlay mode and is an error in a main EXE. +- The sites enter the overlay-cache identity (the hash changes only when the list + is non-empty). Regenerate after changing the list; with no sites the generated + code is unchanged. +- The mod owns the consequences: a relocated table needs its own free memory and + a save written under one selector is not interchangeable with another's. + ## Runtime block Consumed by the cmake macro `psxrecomp_v4_add_runtime_target` (eventually) diff --git a/overlay_toolchain/include/overlay_api.h b/overlay_toolchain/include/overlay_api.h index fc19550f..5a511237 100644 --- a/overlay_toolchain/include/overlay_api.h +++ b/overlay_toolchain/include/overlay_api.h @@ -124,8 +124,9 @@ * entry hooks, guarded pre-instruction callback. * v30: the two lines merged. OverlayCallbacks keeps the master layout, which * already holds every release-line callback; shards built against either - * v28 or v29 are rejected and recompiled. */ -#define PSX_OVERLAY_ABI_VERSION 30 + * v28 or v29 are rejected and recompiled. + * v31: mod_immediate_select ([[mod_immediate.sites]] selector forwarder). */ +#define PSX_OVERLAY_ABI_VERSION 31 /* Process-lifetime overlay candidate capacity. Every accepted manifest F * record consumes one slot, even when another DLL carries an identical @@ -387,6 +388,8 @@ typedef struct OverlayCallbacks { int32_t (*nclip_exact_sign)(int32_t native_mac0, uint32_t pc); /* Guarded pre-instruction mod callback (ABI v27). */ void (*mod_instruction)(CPUState*, uint32_t, uint32_t); + /* [[mod_immediate.sites]] selector of a group (ABI v31); NULL = stock. */ + uint32_t (*mod_immediate_select)(uint32_t group); } OverlayCallbacks; #ifdef __cplusplus diff --git a/overlay_toolchain/include/overlay_dispatch_preamble.c.inc b/overlay_toolchain/include/overlay_dispatch_preamble.c.inc index bc7d1c64..1d0c0bb1 100644 --- a/overlay_toolchain/include/overlay_dispatch_preamble.c.inc +++ b/overlay_toolchain/include/overlay_dispatch_preamble.c.inc @@ -266,6 +266,9 @@ void psx_mod_instruction(CPUState *cpu, uint32_t address, uint32_t word) { overlay_flush_cycles(); if (g_cbs.mod_instruction) g_cbs.mod_instruction(cpu, address, word); } +uint32_t psx_mod_immediate_select(uint32_t group) { + return g_cbs.mod_immediate_select ? g_cbs.mod_immediate_select(group) : 0u; +} int psx_mod_function_entry(CPUState *cpu, uint32_t address) { overlay_flush_cycles(); return g_cbs.mod_function_entry ? g_cbs.mod_function_entry(cpu, address) : 0; diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 81640cd2..6772d111 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -588,6 +588,35 @@ if(BUILD_TESTING) add_test(NAME draw_distance_codegen_test COMMAND draw_distance_codegen_test) + # [[mod_immediate.sites]]: config rules, the per-selector switch emitted + # for loads, stores, ALU immediates and delay slots, the guards and the + # overlay-cache identity. + add_executable(mod_immediate_codegen_test + tests/mod_immediate_codegen_test.cpp + src/ps1_exe_parser.cpp + src/mips_decoder.cpp + src/control_flow.cpp + src/function_analysis.cpp + src/code_generator.cpp + src/pgxp_hook_emitter.cpp + src/annotations.cpp + src/bios_address_model.cpp + src/config_loader.cpp + src/recompiler_patch.cpp + ) + target_include_directories(mod_immediate_codegen_test PRIVATE + include + src + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include + lib/toml11 + lib/ELFIO + ) + target_link_libraries(mod_immediate_codegen_test PRIVATE fmt rabbitizer) + add_test(NAME mod_immediate_codegen_test + COMMAND mod_immediate_codegen_test) + # The same explicit cull kinds in the dirty-RAM interpreter: gpu.c's # sorted, capped site store and real interpreted bgez/bltz/branch-keep/ # clip-edge instructions at margin 0 and at a forced margin. The test @@ -644,6 +673,18 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_BINARY_DIR}/draw_distance_interp_stderr.txt) endif() + # [[mod_immediate.sites]] runtime store, selectors and word substitution + # (mod_immediate.c). The interpreter call site is pinned by + # runtime/tests/test_interp_mod_immediate_guards.py. + add_executable(mod_immediate_unit_test + ../runtime/tests/test_mod_immediate.c) + target_include_directories(mod_immediate_unit_test PRIVATE + ../runtime/include + ../runtime/src) + add_test(NAME mod_immediate_unit_test + COMMAND mod_immediate_unit_test + ${CMAKE_CURRENT_BINARY_DIR}/mod_immediate_unit_stderr.txt) + # Uncached (KSEG1) code charges a fetch before every instruction; cached # code keeps the line-leader rule (test_uncached_fetch_charge.py checks # the BIOS emitter on OpenBIOS and the cycle model). diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index bfb40bda..2cad9a17 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -67,6 +67,9 @@ struct CodeGenConfig { // Only explicitly listed guest functions call the runtime dispatcher. std::set mod_function_entry_funcs; std::set mod_instruction_sites; + // [[mod_immediate.sites]]: guarded instructions whose immediate a trusted mod + // selects at run time; emitted as a selector switch over every variant. + std::vector mod_immediate_sites; // [recompiler] hot_funcs: emit __attribute__((hot)) on these guest // addresses (MotK VLC leaves, etc.). Host locality hint only. @@ -494,6 +497,14 @@ private: // The [[draw_distance.clamp]] statement for (addr, instr), or "". std::string draw_distance_clamp_prefix(uint32_t addr, uint32_t instr) const; std::string mod_instruction_call(uint32_t addr, uint32_t instr) const; + // The [[mod_immediate.sites]] entry for (addr, instr), or nullptr. In a main + // EXE a word mismatch at a listed address is a hard error; overlay code may + // hold unrelated words at a shared load address and is left unchanged. + const PSXRecompV4::ModImmediateSite* find_mod_immediate_site(uint32_t addr, uint32_t instr) const; + // The instruction translated once per selector: `switch (psx_mod_immediate_select(group))` + // with case N running the word whose imm16 is values[N-1] and default the original. + std::string mod_immediate_translation(uint32_t addr, uint32_t instr, + const PSXRecompV4::ModImmediateSite& site); // Register name mapping static std::string reg_name(int reg_num); diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 9e280940..4daf1f21 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -924,6 +924,42 @@ std::string CodeGenerator::mod_instruction_call(uint32_t addr, uint32_t instr) c }); return selected ? fmt::format("psx_mod_instruction(cpu, 0x{:08X}u, 0x{:08X}u);\n", addr, instr) : ""; } +const PSXRecompV4::ModImmediateSite* CodeGenerator::find_mod_immediate_site( + uint32_t addr, uint32_t instr) const { + const uint32_t phys = addr & 0x1FFFFFFFu; + bool address_listed = false; + for (const auto& site : config_.mod_immediate_sites) { + if ((site.address & 0x1FFFFFFFu) != phys) continue; + address_listed = true; + if (site.expected == instr) return &site; + } + if (address_listed && !config_.overlay_mode) { + fmt::print(stderr, + "ERROR: [[mod_immediate.sites]] expected word does not match " + "0x{:08X} at 0x{:08X}\n", instr, addr); + std::exit(1); + } + return nullptr; +} + +std::string CodeGenerator::mod_immediate_translation( + uint32_t addr, uint32_t instr, const PSXRecompV4::ModImmediateSite& site) { + const std::string& ind = config_.indent; + std::string out = ind + fmt::format( + "switch (psx_mod_immediate_select({}u)) {{ /* mod immediate 0x{:08X} */\n", + site.group, addr); + for (size_t i = 0; i < site.values.size(); ++i) { + const uint32_t word = (instr & 0xFFFF0000u) | site.values[i]; + out += ind + fmt::format("case {}u: {{ /* 0x{:08X} */\n", i + 1u, word); + out += translate_instruction_core(addr, word) + "\n"; + out += ind + " break;\n" + ind + "}\n"; + } + out += ind + "default: {\n"; + out += translate_instruction_core(addr, instr) + "\n"; + out += ind + " break;\n" + ind + "}\n" + ind + "}"; + return out; +} + // [[draw_distance.clamp]]: a main-EXE ordering-table range guard. While a // mod has the clamps on, the site's register is clamped to `max` before the // instruction runs, so the guard keeps a far primitive in the last OT slot @@ -965,9 +1001,12 @@ std::string CodeGenerator::draw_distance_clamp_prefix(uint32_t addr, std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) { std::string clamp = draw_distance_clamp_prefix(addr, instr); const std::string hook = mod_instruction_call(addr, instr); - if (clamp.empty()) return hook + translate_instruction_core(addr, instr); + const PSXRecompV4::ModImmediateSite* imm_site = find_mod_immediate_site(addr, instr); + const std::string body = imm_site ? mod_immediate_translation(addr, instr, *imm_site) + : translate_instruction_core(addr, instr); + if (clamp.empty()) return hook + body; // The core translation carries its own indent; give the clamp the same. - return hook + config_.indent + clamp + "\n" + translate_instruction_core(addr, instr); + return hook + config_.indent + clamp + "\n" + body; } std::string CodeGenerator::translate_instruction_core(uint32_t addr, uint32_t instr) { @@ -2238,6 +2277,10 @@ std::string CodeGenerator::translate_basic_block( if (defer_load) { const std::string lhs = fmt::format("cpu->gpr[{}] =", load_dest); const size_t pos = emitted.find(lhs); + // A [[mod_immediate.sites]] load carries one translation per + // selector; every copy must write the deferred temporary. + const bool per_selector = + emitted.find("psx_mod_immediate_select(") != std::string::npos; if (pos != std::string::npos) { const std::string temp = fmt::format("psx_ldd_{:08X}", addr); // Declare the temp in the pair block, not inline at the @@ -2254,6 +2297,16 @@ std::string CodeGenerator::translate_basic_block( if (hook != std::string::npos) emitted.replace(hook, pgxp_tail.size(), fmt::format(", _pgxa, {});", temp)); + if (per_selector) { + // The remaining selector copies, same two rewrites. + for (size_t at = emitted.find(lhs); at != std::string::npos; + at = emitted.find(lhs, at + temp.size())) + emitted.replace(at, lhs.size(), temp + " ="); + for (size_t at = emitted.find(pgxp_tail); at != std::string::npos; + at = emitted.find(pgxp_tail, at + 1)) + emitted.replace(at, pgxp_tail.size(), + fmt::format(", _pgxa, {});", temp)); + } ss << config_.indent << "{ /* MIPS-I load-delay pair */\n"; ss << config_.indent << fmt::format(" uint32_t {} = 0;\n", temp); @@ -3603,6 +3656,8 @@ void CodeGenerator::emit_runtime_externs(std::ostream& ss) const { ss << "extern int psx_game_text_native_ok(uint32_t addr); /* stale-static guard (dispatch shard) */\n"; ss << "extern int psx_datashard_enter(CPUState* cpu, uint32_t key); /* data-shard replay/capture (data_shards.c) */\n"; ss << "extern void psx_mod_instruction(CPUState*, uint32_t, uint32_t);\n"; + if (!config_.mod_immediate_sites.empty()) + ss << "extern uint32_t psx_mod_immediate_select(uint32_t group); /* [[mod_immediate.sites]] selector (mod_immediate.c) */\n"; ss << "extern int psx_mod_function_entry(CPUState* cpu, uint32_t address); /* trusted opt-in game-mod hook */\n"; ss << "extern int psx_ws_masked_reject(uint32_t flags, uint32_t mask); /* guarded packed-coordinate reject */\n"; ss << "extern int psx_ws_nclip_branch(uint32_t pc, uint32_t instr, int32_t mac0, int vanilla);\n"; diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 3024b8ea..38e12167 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -81,6 +81,23 @@ uint32_t overlay_codegen_config_hash(const GameConfig& c) { // does not use the feature keeps the hash (and every overlay cache) it had. if (!c.mod_instruction_sites.empty()) h.words("mod_instruction_sites", c.mod_instruction_sites); + if (!c.mod_immediate_sites.empty()) { + // Appended only when used (same rule as above). Order-independent. + auto sites = c.mod_immediate_sites; + std::sort(sites.begin(), sites.end(), [](const auto& x, const auto& y) { + const uint32_t ax = x.address & 0x1FFFFFFFu, ay = y.address & 0x1FFFFFFFu; + return ax != ay ? ax < ay : x.expected < y.expected; + }); + h.tag("mod_immediate_sites"); + h.u32((uint32_t)sites.size()); + for (const auto& site : sites) { + h.u32(site.address & 0x1FFFFFFFu); + h.u32(site.expected); + h.u32(site.group); + h.u32((uint32_t)site.values.size()); + for (const uint16_t v : site.values) h.u32(v); + } + } h.words("cull_bias", c.ws_cull_bias_sites); if (!c.ws_cull_bias_lower_sites.empty()) h.words("cull_bias_lower", c.ws_cull_bias_lower_sites); @@ -1128,6 +1145,60 @@ bool draw_distance_clamp_reads(uint32_t instr, uint32_t reg) { return false; } +std::string mod_immediate_site_error(const ModImmediateSite& site) { + if ((site.address & 3u) != 0u) return "address is not instruction-aligned"; + if ((site.address & 0x1FFFFFFFu) >= 0x00800000u || site.address >= 0xC0000000u) + return "address is not main RAM"; + const uint32_t op = site.expected >> 26; + const bool alu_imm = op >= 0x08u && op <= 0x0Fu; + const bool load_store = op >= 0x20u && op <= 0x2Bu && op != 0x27u; + if (!alu_imm && !load_store) + return "expected is not an I-type ALU/load/store instruction " + "(ADDI..LUI or LB..SW)"; + if (site.group >= 64u) return "group must be 0..63"; + if (site.values.empty()) return "values must list at least one immediate"; + if (site.values.size() > 15u) return "at most 15 values"; + return {}; +} + +// [[mod_immediate.sites]] (docs/config_schema.md "Mod immediates"). +static std::vector parse_mod_immediate_sites( + const toml::value& cfg, const fs::path& config_path) { + std::vector sites; + if (!cfg.contains("mod_immediate")) return sites; + const toml::value& mi = toml::find(cfg, "mod_immediate"); + if (!mi.contains("sites")) return sites; + std::set> seen; + for (const auto& item : toml::find(mi, "sites")) { + ModImmediateSite site; + site.address = parse_hex(toml::find(item, "address"), + "mod_immediate.sites.address"); + site.expected = parse_hex(toml::find(item, "expected"), + "mod_immediate.sites.expected"); + site.group = (uint32_t)toml::find(item, "group"); + for (const auto& v : toml::find(item, "values")) { + const int64_t n = v.as_integer(); + if (n < -32768 || n > 65535) + throw std::runtime_error(fmt::format( + "{}: mod_immediate site 0x{:08X}: value {} does not fit " + "16 bits", config_path.string(), site.address, n)); + site.values.push_back((uint16_t)n); + } + if (const std::string why = mod_immediate_site_error(site); !why.empty()) + throw std::runtime_error(fmt::format( + "{}: mod_immediate site 0x{:08X}: {}", config_path.string(), + site.address, why)); + // Overlays share load addresses, so one address may carry several + // sites; only the (address, word) pair must be unique. + if (!seen.insert({site.address & 0x1FFFFFFFu, site.expected}).second) + throw std::runtime_error(fmt::format( + "{}: duplicate mod_immediate site 0x{:08X}", + config_path.string(), site.address)); + sites.push_back(std::move(site)); + } + return sites; +} + // [[draw_distance.clamp]] (docs/config_schema.md "Draw-distance clamps"). static std::vector parse_draw_distance_clamps( const toml::value& cfg, const fs::path& config_path) { @@ -2752,6 +2823,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { loaded.ws_cull_masked_reject_sites = std::move(ws_cull_masked_reject_sites); loaded.draw_distance_clamp_sites = parse_draw_distance_clamps(cfg, config_path); + loaded.mod_immediate_sites = parse_mod_immediate_sites(cfg, config_path); return loaded; } diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 1cefbd03..d1209790 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -132,6 +132,27 @@ struct DrawDistanceClampSite { int32_t max = 0; }; +// [[mod_immediate.sites]]: a guarded I-type instruction whose 16-bit immediate a +// trusted mod may swap at run time. The complete word at `address` must equal +// `expected` (an unrelated overlay word at the same address is left alone). +// Selector 0 always runs `expected` unchanged; selector N (1-based) runs the +// same instruction with `values[N-1]` in the immediate field. The selector +// belongs to `group` (0..63) and is set by the mod +// (psx_mod_set_immediate_selector). Because the instruction bytes in RAM never +// change, static / cached native code keeps matching its original-bytes guard; +// generated code, the interpreter and every overlay path pick the immediate +// from the same selector. Only ADDI..LUI (0x08-0x0F) and the byte/half/word +// loads and stores (0x20-0x2B) qualify. +struct ModImmediateSite { + uint32_t address = 0; + uint32_t expected = 0; + uint32_t group = 0; + std::vector values; // imm16 per selector 1..N +}; + +// Empty on success; otherwise why `site` cannot be a mod-immediate site. +std::string mod_immediate_site_error(const ModImmediateSite& site); + // True when `instr` is an ALU instruction (I-type or SPECIAL R-type, not a // load, store, branch or jump) that reads GPR `reg`. These are the only // instructions a draw-distance clamp may precede. @@ -1443,6 +1464,8 @@ struct GameConfig { // [[draw_distance.clamp]] -- opt-in "keep far geometry" clamps. Empty by // default; inert until a mod switches them on; regen required. std::vector draw_distance_clamp_sites; + // [[mod_immediate.sites]]: run-time-selectable immediates (ModImmediateSite). + std::vector mod_immediate_sites; }; // Effective clip_edge_width: explicit value, else screen_w_imms[0], else 320. diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index d341770d..83069d63 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -220,6 +220,7 @@ static int psxrecomp_game_main(int argc, char** argv) { std::set ws_tag_funcs; // [widescreen] sprite_tag_funcs std::set ds_funcs; // [data_shards] funcs std::set mod_instruction_sites; + std::vector mod_immediate_sites; // [[mod_immediate.sites]] std::set mod_entry_funcs; // trusted game-mod entry hooks std::vector ws_cull_masked_reject; std::set hot_funcs; // [recompiler] hot_funcs @@ -289,6 +290,7 @@ static int psxrecomp_game_main(int argc, char** argv) { mod_entry_funcs.insert(cfg.mod_function_entry_funcs.begin(), cfg.mod_function_entry_funcs.end()); mod_instruction_sites.insert(cfg.mod_instruction_sites.begin(), cfg.mod_instruction_sites.end()); + mod_immediate_sites = cfg.mod_immediate_sites; hot_funcs.insert(cfg.hot_funcs.begin(), cfg.hot_funcs.end()); if (cfg.load_charge_batch) { load_charge_batch_funcs.insert(cfg.load_charge_batch_funcs.begin(), @@ -407,6 +409,15 @@ static int psxrecomp_game_main(int argc, char** argv) { mod_entry_funcs.insert(wscfg.mod_function_entry_funcs.begin(), wscfg.mod_function_entry_funcs.end()); mod_instruction_sites.insert(wscfg.mod_instruction_sites.begin(), wscfg.mod_instruction_sites.end()); + // Same TOML as --config adds nothing; a distinct one adds its own sites. + for (const auto& site : wscfg.mod_immediate_sites) { + const bool known = std::any_of(mod_immediate_sites.begin(), mod_immediate_sites.end(), + [&](const PSXRecompV4::ModImmediateSite& have) { + return (have.address & 0x1FFFFFFFu) == (site.address & 0x1FFFFFFFu) && + have.expected == site.expected; + }); + if (!known) mod_immediate_sites.push_back(site); + } ws_cull_bias.insert(wscfg.ws_cull_bias_sites.begin(), wscfg.ws_cull_bias_sites.end()); ws_cull_bias_lower.insert(wscfg.ws_cull_bias_lower_sites.begin(), wscfg.ws_cull_bias_lower_sites.end()); ws_cull_range.insert(wscfg.ws_cull_range_sites.begin(), wscfg.ws_cull_range_sites.end()); @@ -1474,6 +1485,7 @@ static int psxrecomp_game_main(int argc, char** argv) { codegen_config.mod_function_entry_funcs = mod_entry_funcs; codegen_config.ws_cull_masked_reject_sites = ws_cull_masked_reject; codegen_config.mod_instruction_sites = mod_instruction_sites; + codegen_config.mod_immediate_sites = mod_immediate_sites; codegen_config.hot_funcs = hot_funcs; codegen_config.load_charge_batch_funcs = load_charge_batch_funcs; codegen_config.vsync_query_hle_funcs = vsync_query_hle_funcs; diff --git a/recompiler/tests/mod_immediate_codegen_test.cpp b/recompiler/tests/mod_immediate_codegen_test.cpp new file mode 100644 index 00000000..2ef9b4c7 --- /dev/null +++ b/recompiler/tests/mod_immediate_codegen_test.cpp @@ -0,0 +1,346 @@ +// [[mod_immediate.sites]]: config parsing and validation, the per-selector +// switch emitted for a listed instruction (ALU immediate, load, store, branch +// delay slot, deferred load), main-EXE vs overlay guard behaviour, the shared +// declaration and the overlay-cache identity. +#include "code_generator.h" +#include "config_loader.h" +#include "control_flow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#include +#endif + +namespace fs = std::filesystem; + +namespace { + +constexpr uint32_t kBase = 0x80010000u; +int failures = 0; + +void check(bool condition, const char* message) { + if (!condition) { + std::fprintf(stderr, "FAIL: %s\n", message); + ++failures; + } +} + +void append_word(std::vector& bytes, uint32_t word) { + for (int i = 0; i < 4; i++) bytes.push_back(static_cast(word >> (8 * i))); +} + +size_t count_of(const std::string& hay, const std::string& needle) { + size_t n = 0; + for (size_t at = hay.find(needle); at != std::string::npos; + at = hay.find(needle, at + needle.size())) + ++n; + return n; +} + +fs::path write_temp_config(const char* stem, const std::string& body) { + const auto nonce = std::chrono::high_resolution_clock::now().time_since_epoch().count(); + fs::path path = fs::temp_directory_path() / + (std::string(stem) + "-" + std::to_string(nonce) + ".toml"); + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out << body; + return path; +} + +std::string base_config() { + return R"toml([game] +name = "Mod Immediate Test" +id = "TEST-00000" +exe = "TEST.EXE" +load_address = "0x80010000" +entry_pc = "0x80010000" +text_size = "0x1000" +stack_base = "0x801FFFF0" + +[recompiler] +seeds = "seeds.txt" +out_dir = "generated" +)toml"; +} + +std::string sites_block(std::initializer_list entries) { + std::string out = "\n[mod_immediate]\nsites = [\n"; + for (const auto& e : entries) out += " " + e + ",\n"; + return out + "]\n"; +} + +std::string site(const char* address, const char* expected, int group, + const char* values) { + return std::string("{ address = \"") + address + "\", expected = \"" + expected + + "\", group = " + std::to_string(group) + ", values = [" + values + "] }"; +} + +PSXRecompV4::GameConfig load(const std::string& body) { + fs::path path = write_temp_config("mod-immediate", body); + auto config = PSXRecompV4::load_game_config(path); + fs::remove(path); + return config; +} + +bool load_throws_with(const std::string& body, const char* needle) { + fs::path path = write_temp_config("mod-immediate-bad", body); + bool matched = false; + try { + (void)PSXRecompV4::load_game_config(path); + } catch (const std::exception& e) { + matched = std::string(e.what()).find(needle) != std::string::npos; + if (!matched) std::fprintf(stderr, " (threw: %s)\n", e.what()); + } + fs::remove(path); + return matched; +} + +PSXRecomp::GeneratedFunction generate(std::initializer_list words, + const PSXRecomp::CodeGenConfig& config) { + PSXRecomp::PS1Executable exe{}; + exe.header.load_address = kBase; + exe.header.initial_pc = kBase; + for (uint32_t w : words) append_word(exe.code_data, w); + append_word(exe.code_data, 0x03E00008u); + append_word(exe.code_data, 0x00000000u); + const uint32_t size = static_cast(exe.code_data.size()); + exe.header.file_size = size; + PSXRecomp::Function function{}; + function.start_addr = kBase; + function.end_addr = kBase + size; + function.size = size; + function.name = "imm_site_test"; + PSXRecomp::ControlFlowAnalyzer analyzer(exe); + const auto cfg = analyzer.analyze_function(function); + PSXRecomp::CodeGenerator generator(exe, config); + return generator.generate_function(function, cfg); +} + +// Windows has no fork: the Nth probe re-runs this binary as `--exit-probe N`. +const char* g_self = nullptr; +int g_exit_probe = -1; +int g_probe_seq = 0; + +template +bool exits_with_failure(F&& body) { +#if defined(_WIN32) + const int seq = g_probe_seq++; + if (g_exit_probe >= 0) { + if (seq != g_exit_probe) return false; + body(); + std::fflush(nullptr); + std::_Exit(0); + } + std::fflush(nullptr); + const std::string quoted = std::string("\"") + g_self + "\""; + const std::string n = std::to_string(seq); + const intptr_t rc = _spawnl(_P_WAIT, g_self, quoted.c_str(), "--exit-probe", n.c_str(), nullptr); + return rc > 0; +#else + (void)g_probe_seq; + std::fflush(nullptr); + const pid_t pid = fork(); + if (pid == 0) { + if (!std::freopen("/dev/null", "w", stderr)) _exit(3); + body(); + _exit(0); + } + int status = 0; + if (pid < 0 || waitpid(pid, &status, 0) != pid) return false; + return WIFEXITED(status) && WEXITSTATUS(status) != 0; +#endif +} + +constexpr uint32_t kSltiV0V0_32 = 0x28420020u; // slti v0, v0, 0x20 +constexpr uint32_t kAddiuA0V1 = 0x246402E9u; // addiu a0, v1, 0x2e9 +constexpr uint32_t kLbuV0V1 = 0x906202E9u; // lbu v0, 0x2e9(v1) +constexpr uint32_t kSbA1V1 = 0xA0650308u; // sb a1, 0x308(v1) +constexpr uint32_t kUseV0 = 0x00423021u; // addu a2, v0, v0 (reads v0, writes another) +constexpr uint32_t kBeqZero = 0x10000002u; // b +2 +constexpr uint32_t kNop = 0x00000000u; + +PSXRecomp::CodeGenConfig with_sites(std::vector sites, + bool overlay = false) { + PSXRecomp::CodeGenConfig config{}; + config.mod_immediate_sites = std::move(sites); + config.overlay_mode = overlay; + return config; +} + +PSXRecompV4::ModImmediateSite make(uint32_t address, uint32_t expected, uint32_t group, + std::vector values) { + PSXRecompV4::ModImmediateSite s; + s.address = address; + s.expected = expected; + s.group = group; + s.values = std::move(values); + return s; +} + +void loader() { + check(load(base_config()).mod_immediate_sites.empty(), "no sites by default"); + + const auto cfg = load(base_config() + sites_block({ + site("0x80023500", "0x28820020", 1, "64, 128"), + site("0x8002336C", "0xA0650308", 1, "0x328, 0x368"), + site("0x80023500", "0x28820021", 2, "-1"), // same address, other overlay word + })); + check(cfg.mod_immediate_sites.size() == 3, "three sites parsed"); + if (cfg.mod_immediate_sites.size() == 3) { + const auto& a = cfg.mod_immediate_sites[0]; + check(a.address == 0x80023500u && a.expected == 0x28820020u && a.group == 1u && + a.values.size() == 2 && a.values[0] == 64 && a.values[1] == 128, + "first site fields"); + check(cfg.mod_immediate_sites[1].values[1] == 0x368, "hex values"); + check(cfg.mod_immediate_sites[2].values[0] == 0xFFFF, "negative value wraps to imm16"); + } + + check(load_throws_with(base_config() + sites_block({site("0x80023502", "0x28820020", 1, "64")}), + "not instruction-aligned"), "unaligned address refused"); + check(load_throws_with(base_config() + sites_block({site("0x80023500", "0x10400002", 1, "64")}), + "not an I-type"), "a branch is refused"); + check(load_throws_with(base_config() + sites_block({site("0x80023500", "0x0043102B", 1, "64")}), + "not an I-type"), "an R-type is refused"); + check(load_throws_with(base_config() + sites_block({site("0x80023500", "0x28820020", 64, "64")}), + "group must be 0..63"), "group 64 refused"); + check(load_throws_with(base_config() + sites_block({site("0x80023500", "0x28820020", 1, "")}), + "at least one"), "empty values refused"); + check(load_throws_with(base_config() + sites_block({site("0x80023500", "0x28820020", 1, "70000")}), + "does not fit 16 bits"), "value out of range refused"); + check(load_throws_with(base_config() + sites_block({ + site("0x80023500", "0x28820020", 1, "64"), + site("0xA0023500", "0x28820020", 1, "128")}), + "duplicate mod_immediate"), "duplicate (address, word) refused"); + check(load_throws_with(base_config() + sites_block({site("0x80800000", "0x28820020", 1, "64")}), + "not main RAM"), "address past main RAM refused"); +} + +void emission() { + // slti: one case per value plus the stock default, each with its own immediate. + const auto out = generate({kNop, kSltiV0V0_32, kNop}, + with_sites({make(kBase + 4u, kSltiV0V0_32, 1, {64, 128})})).full_code; + check(out.find("switch (psx_mod_immediate_select(1u))") != std::string::npos, + "switch on the group selector"); + check(count_of(out, "case 1u:") == 1 && count_of(out, "case 2u:") == 1 && + count_of(out, "default:") >= 1, + "one case per value plus default"); + check(out.find("< 64") != std::string::npos && out.find("< 128") != std::string::npos && + out.find("< 32") != std::string::npos, + "each variant compares against its own immediate"); + + // lbu: offsets differ per variant; deferred loads rewrite every copy. + const auto load_out = generate({kNop, kLbuV0V1, kUseV0, kNop}, + with_sites({make(kBase + 4u, kLbuV0V1, 3, {0x2E9 + 0x100, 0x2E9 - 0x100})})).full_code; + check(count_of(load_out, "switch (psx_mod_immediate_select(3u))") == 1, "load site switched"); + check(load_out.find("1001") != std::string::npos, + "relocated load offset emitted (0x2E9+0x100 = 1001)"); + check(count_of(load_out, "psx_ldd_80010004") >= 4, + "a deferred load writes the temporary in every selector copy"); + check(load_out.find("cpu->gpr[2] = psx_ldd_80010004;") != std::string::npos, + "the deferred load is still written back"); + + // sb store + addiu pointer formation. + const auto st = generate({kSbA1V1, kAddiuA0V1, kNop}, + with_sites({make(kBase, kSbA1V1, 4, {0x328}), + make(kBase + 4u, kAddiuA0V1, 4, {0x400})})).full_code; + check(count_of(st, "switch (psx_mod_immediate_select(4u))") == 2, "store and addiu switched"); + + // A site in a branch delay slot. + const auto ds = generate({kBeqZero, kSltiV0V0_32, kNop, kNop}, + with_sites({make(kBase + 4u, kSltiV0V0_32, 1, {64})})).full_code; + check(ds.find("switch (psx_mod_immediate_select(1u))") != std::string::npos, + "delay-slot site is switched"); + + // No sites: vanilla. + PSXRecomp::CodeGenConfig plain{}; + const auto vanilla = generate({kNop, kSltiV0V0_32, kNop}, plain).full_code; + check(vanilla.find("psx_mod_immediate_select") == std::string::npos, "no sites: vanilla code"); + + // A site elsewhere leaves this instruction alone. + const auto other = generate({kNop, kSltiV0V0_32, kNop}, + with_sites({make(kBase + 0x100u, kSltiV0V0_32, 1, {64})})).full_code; + check(other.find("psx_mod_immediate_select") == std::string::npos, "unlisted address: vanilla"); + + // Overlay mode: a different word at a listed address stays vanilla. + const auto ov = generate({kNop, kLbuV0V1, kNop}, + with_sites({make(kBase + 4u, kSltiV0V0_32, 1, {64})}, true)).full_code; + check(ov.find("psx_mod_immediate_select") == std::string::npos, + "overlay word mismatch stays vanilla"); + const auto ov_match = generate({kNop, kSltiV0V0_32, kNop}, + with_sites({make(kBase + 4u, kSltiV0V0_32, 1, {64})}, true)).full_code; + check(ov_match.find("psx_mod_immediate_select(1u)") != std::string::npos, + "overlay word match is switched"); +} + +void guards() { + const auto cfg = with_sites({make(kBase + 4u, kSltiV0V0_32, 1, {64})}); + check(!exits_with_failure([&] { generate({kNop, kSltiV0V0_32, kNop}, cfg); }), + "matching word generates"); + check(exits_with_failure([&] { generate({kNop, kLbuV0V1, kNop}, cfg); }), + "a main-EXE word mismatch fails generation"); +} + +void shared_decls() { + PSXRecomp::PS1Executable exe{}; + exe.header.load_address = kBase; + std::vector functions; + { + PSXRecomp::CodeGenerator plain(exe); + check(plain.build_shared_decls_header(functions).find("psx_mod_immediate_select") == + std::string::npos, "no sites: the declarations do not change"); + } + { + const auto cfg = with_sites({make(kBase + 4u, kSltiV0V0_32, 1, {64})}); + PSXRecomp::CodeGenerator gen(exe, cfg); + check(gen.build_shared_decls_header(functions).find( + "extern uint32_t psx_mod_immediate_select(uint32_t group);") != std::string::npos, + "sites: the selector is declared"); + } +} + +void overlay_identity() { + auto none = load(base_config()); + auto some = load(base_config() + sites_block({site("0x80023500", "0x28820020", 1, "64")})); + auto other = load(base_config() + sites_block({site("0x80023500", "0x28820020", 1, "65")})); + auto ordered = load(base_config() + sites_block({ + site("0x80023500", "0x28820020", 1, "64"), site("0x80023504", "0x28820020", 1, "64")})); + auto reversed = load(base_config() + sites_block({ + site("0x80023504", "0x28820020", 1, "64"), site("0x80023500", "0x28820020", 1, "64")})); + using PSXRecompV4::overlay_codegen_config_hash; + check(overlay_codegen_config_hash(none) != overlay_codegen_config_hash(some), + "sites enter the overlay-cache identity"); + check(overlay_codegen_config_hash(some) != overlay_codegen_config_hash(other), + "a changed value changes the identity"); + check(overlay_codegen_config_hash(ordered) == overlay_codegen_config_hash(reversed), + "site order does not"); +} + +} // namespace + +int main(int argc, char** argv) { + g_self = argv[0]; + if (argc >= 3 && std::string(argv[1]) == "--exit-probe") g_exit_probe = std::atoi(argv[2]); + loader(); + emission(); + guards(); + shared_decls(); + overlay_identity(); + if (failures) { + std::fprintf(stderr, "mod_immediate_codegen_test: %d failure(s)\n", failures); + return 1; + } + std::printf("mod_immediate_codegen_test: OK\n"); + return 0; +} diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 1e675197..3debc78b 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -238,6 +238,8 @@ if(BUILD_TESTING) "${PSXRECOMP_ROOT}/tools/tests/test_counted_mips_relocations.py") add_test(NAME interp_mod_entry_guards_test COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_interp_mod_entry_guards.py") + add_test(NAME interp_mod_immediate_guards_test COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_interp_mod_immediate_guards.py") add_test(NAME mirror_pc_preserved_guards_test COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_mirror_pc_preserved_guards.py") add_test(NAME segment_miss_wiring_test COMMAND "${Python3_EXECUTABLE}" diff --git a/runtime/include/mod_immediate.h b/runtime/include/mod_immediate.h new file mode 100644 index 00000000..60ea6359 --- /dev/null +++ b/runtime/include/mod_immediate.h @@ -0,0 +1,65 @@ +/* mod_immediate.h - [[mod_immediate.sites]]: instruction immediates a trusted + * mod selects at run time. + * + * A title lists guarded I-type instructions in game.toml: + * + * [mod_immediate] + * sites = [ + * { address = "0x80023500", expected = "0x28820020", group = 1, values = [64, 128] }, + * ] + * + * Selector 0 (the default, restored at every session start) runs the original + * instruction. Selector N runs the same instruction with values[N-1] as its + * 16-bit immediate. A mod picks the selector of each group at activation with + * psx_mod_set_immediate_selector (mod_plugins.h). + * + * The instruction bytes in RAM never change, so static AOT code, cached overlays + * and the interpreter keep matching their original-bytes guards: generated code + * switches on psx_mod_immediate_select(group) per listed instruction, and the + * dirty-RAM interpreter substitutes the word with psx_mod_immediate_patch. + * docs/config_schema.md "Mod immediates" has the config rules. + */ +#ifndef PSX_MOD_IMMEDIATE_H +#define PSX_MOD_IMMEDIATE_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define PSX_MOD_IMMEDIATE_GROUPS 64 +#define PSX_MOD_IMMEDIATE_VALUES_MAX 15 +#define PSX_MOD_IMMEDIATE_SITES_MAX 4096 + +typedef struct PSXModImmediateSite { + uint32_t address; /* any segment; stored physical */ + uint32_t expected; /* the complete original instruction word */ + uint32_t group; /* 0..63 */ + uint32_t count; /* 1..PSX_MOD_IMMEDIATE_VALUES_MAX */ + uint16_t values[PSX_MOD_IMMEDIATE_VALUES_MAX]; /* imm16 for selector 1..count */ +} PSXModImmediateSite; + +/* Nonzero while a site exists and any selector is nonzero: the interpreter's + * per-instruction gate. */ +extern int g_psx_mod_immediate_live; + +/* The title's sites (game config). Copied, sorted by (address, word) and capped + * at PSX_MOD_IMMEDIATE_SITES_MAX. Returns the number kept. */ +int psx_mod_immediate_set_sites(const PSXModImmediateSite *sites, int count); +int psx_mod_immediate_site_count(void); + +/* Selector of `group`; 0 for an out-of-range group. Called by generated code. */ +uint32_t psx_mod_immediate_select(uint32_t group); +/* Session reset: every selector back to 0 (stock instructions). */ +void psx_mod_immediate_reset(void); + +/* The instruction the interpreter executes at `pc`: `insn` itself unless a + * listed site with that exact word has a nonzero selector. */ +uint32_t psx_mod_immediate_patch(uint32_t pc, uint32_t insn); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 73c80fb8..9fb3386c 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -854,6 +854,20 @@ int psx_mod_set_disc_speed(uint32_t divisor, int psx_mod_set_draw_distance_clamp(int enabled); int psx_mod_draw_distance_clamp_enabled(void); +/* + * Select the immediate variant of one [[mod_immediate.sites]] group + * (mod_immediate.h). Selector 0 runs the stock instructions; N runs + * values[N-1] of every site in `group`. The instruction bytes in RAM never + * change, so static and cached native code stay valid. Returns 1 when the + * title configured sites, 0 otherwise (nothing the guest sees changes), and + * rejects a group >= 64 or a selector > 15. Reset to 0 at every session start: + * call it from activation, before the guest runs. The caller owns the memory + * layout the selector implies (a savestate or card written under another + * selector is not interchangeable). + */ +int psx_mod_set_immediate_selector(uint32_t group, uint32_t selector); +uint32_t psx_mod_immediate_selector(uint32_t group); + /* Controller presentation values exposed to trusted game-owned plugins. */ enum { PSX_MOD_CONTROLLER_ANALOG = 1, diff --git a/runtime/include/overlay_api.h b/runtime/include/overlay_api.h index fc19550f..5a511237 100644 --- a/runtime/include/overlay_api.h +++ b/runtime/include/overlay_api.h @@ -124,8 +124,9 @@ * entry hooks, guarded pre-instruction callback. * v30: the two lines merged. OverlayCallbacks keeps the master layout, which * already holds every release-line callback; shards built against either - * v28 or v29 are rejected and recompiled. */ -#define PSX_OVERLAY_ABI_VERSION 30 + * v28 or v29 are rejected and recompiled. + * v31: mod_immediate_select ([[mod_immediate.sites]] selector forwarder). */ +#define PSX_OVERLAY_ABI_VERSION 31 /* Process-lifetime overlay candidate capacity. Every accepted manifest F * record consumes one slot, even when another DLL carries an identical @@ -387,6 +388,8 @@ typedef struct OverlayCallbacks { int32_t (*nclip_exact_sign)(int32_t native_mac0, uint32_t pc); /* Guarded pre-instruction mod callback (ABI v27). */ void (*mod_instruction)(CPUState*, uint32_t, uint32_t); + /* [[mod_immediate.sites]] selector of a group (ABI v31); NULL = stock. */ + uint32_t (*mod_immediate_select)(uint32_t group); } OverlayCallbacks; #ifdef __cplusplus diff --git a/runtime/include/overlay_dispatch_preamble.c.inc b/runtime/include/overlay_dispatch_preamble.c.inc index bc7d1c64..1d0c0bb1 100644 --- a/runtime/include/overlay_dispatch_preamble.c.inc +++ b/runtime/include/overlay_dispatch_preamble.c.inc @@ -266,6 +266,9 @@ void psx_mod_instruction(CPUState *cpu, uint32_t address, uint32_t word) { overlay_flush_cycles(); if (g_cbs.mod_instruction) g_cbs.mod_instruction(cpu, address, word); } +uint32_t psx_mod_immediate_select(uint32_t group) { + return g_cbs.mod_immediate_select ? g_cbs.mod_immediate_select(group) : 0u; +} int psx_mod_function_entry(CPUState *cpu, uint32_t address) { overlay_flush_cycles(); return g_cbs.mod_function_entry ? g_cbs.mod_function_entry(cpu, address) : 0; diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index fb0b1988..57467624 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -416,6 +416,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/debug_trace_ranges.c ${PSXRECOMP_ROOT}/runtime/src/dirty_ram_interp.c ${PSXRECOMP_ROOT}/runtime/src/draw_distance.c + ${PSXRECOMP_ROOT}/runtime/src/mod_immediate.c ${PSXRECOMP_ROOT}/runtime/src/game_dispatch_compat.c ${PSXRECOMP_ROOT}/runtime/src/psx_segment_miss.c ${PSXRECOMP_ROOT}/runtime/src/fntrace.c diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 346bd612..87993324 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -38,6 +38,7 @@ #include "fntrace.h" /* fntrace_is_game_started / fntrace_mark_game_started */ #include "psx_segment_miss.h" /* segment misses in static game code (§5.5) */ #include "draw_distance.h" /* [[draw_distance.clamp]] sites */ +#include "mod_immediate.h" /* [[mod_immediate.sites]] selectors */ #include #include @@ -1530,6 +1531,9 @@ static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn, extern uint32_t g_psx_mod_instruction_hooks; extern void psx_mod_instruction(CPUState*, uint32_t, uint32_t); if (g_psx_mod_instruction_hooks) psx_mod_instruction(cpu, pc, insn); + /* [[mod_immediate.sites]]: execute the selected immediate variant. The + * guest's bytes (and the guard above) stay the original instruction. */ + if (g_psx_mod_immediate_live) insn = psx_mod_immediate_patch(pc, insn); /* op 0x20..0x26 = LB/LH/LWL/LW/LBU/LHU/LWR. LWC2 (GTE, 0x32) targets a COP2 * register, not a GPR, so it needs no deferral here. */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 4f3d92d1..4d7be1de 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -49,6 +49,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "overlay_backend.h" #include "gpu.h" #include "draw_distance.h" +#include "mod_immediate.h" #include "display_scanout.h" #include "pgxp.h" #include "pgxp_session.h" @@ -1802,6 +1803,7 @@ static void reset_mod_owned_presentation(void) { psx_mod_set_retained_scene_predicate(nullptr); psx_mod_set_adaptive_backdrop_preload(0); (void)psx_mod_set_draw_distance_clamp(0); + psx_mod_immediate_reset(); /* [[mod_immediate.sites]] selectors */ /* [timing] guest_cycle_scale mod gate: shut until this session's * activation opens it (an online match clears the plan, so a mod-gated * scale never carries into netplay). */ @@ -16550,6 +16552,24 @@ int main(int argc, char** argv) { (void)psx_draw_distance_set_clamp_sites(sites.data(), (int)sites.size()); } + { + /* [[mod_immediate.sites]]: the interpreter's copy of the + * sites the generated code switches on. */ + std::vector sites; + sites.reserve(gc.mod_immediate_sites.size()); + for (const auto& site : gc.mod_immediate_sites) { + PSXModImmediateSite out{}; + out.address = site.address; + out.expected = site.expected; + out.group = site.group; + out.count = (uint32_t)site.values.size(); + for (size_t i = 0; i < site.values.size() && + i < PSX_MOD_IMMEDIATE_VALUES_MAX; i++) + out.values[i] = site.values[i]; + sites.push_back(out); + } + (void)psx_mod_immediate_set_sites(sites.data(), (int)sites.size()); + } { std::vector addresses, expected, thresholds; std::vector object_regs, x_regs, z_regs, y_regs; diff --git a/runtime/src/mod_immediate.c b/runtime/src/mod_immediate.c new file mode 100644 index 00000000..af5a59bc --- /dev/null +++ b/runtime/src/mod_immediate.c @@ -0,0 +1,93 @@ +/* mod_immediate.c - the [[mod_immediate.sites]] store and selectors + * (mod_immediate.h). */ +#include "mod_immediate.h" +#include "mod_plugins.h" + +#include +#include + +int g_psx_mod_immediate_live = 0; + +static PSXModImmediateSite s_imm_sites[PSX_MOD_IMMEDIATE_SITES_MAX]; +static int s_imm_site_count = 0; +static uint8_t s_selector[PSX_MOD_IMMEDIATE_GROUPS]; + +static void imm_update_live(void) { + int any = 0; + for (int g = 0; g < PSX_MOD_IMMEDIATE_GROUPS; g++) any |= s_selector[g] != 0u; + g_psx_mod_immediate_live = (any && s_imm_site_count > 0) ? 1 : 0; +} + +static int imm_site_cmp(const void *a, const void *b) { + const PSXModImmediateSite *x = (const PSXModImmediateSite *)a; + const PSXModImmediateSite *y = (const PSXModImmediateSite *)b; + if (x->address != y->address) return x->address < y->address ? -1 : 1; + return (x->expected > y->expected) - (x->expected < y->expected); +} + +int psx_mod_immediate_set_sites(const PSXModImmediateSite *sites, int count) { + if (!sites || count < 0) count = 0; + if (count > PSX_MOD_IMMEDIATE_SITES_MAX) { + fprintf(stderr, + "psxrecomp: [[mod_immediate.sites]] lists %d sites; keeping the " + "first %d\n", count, PSX_MOD_IMMEDIATE_SITES_MAX); + count = PSX_MOD_IMMEDIATE_SITES_MAX; + } + int kept = 0; + for (int i = 0; i < count; i++) { + if (sites[i].group >= PSX_MOD_IMMEDIATE_GROUPS || sites[i].count == 0u || + sites[i].count > PSX_MOD_IMMEDIATE_VALUES_MAX) + continue; + s_imm_sites[kept] = sites[i]; + s_imm_sites[kept].address &= 0x1FFFFFFFu; + kept++; + } + if (kept > 1) qsort(s_imm_sites, (size_t)kept, sizeof s_imm_sites[0], imm_site_cmp); + s_imm_site_count = kept; + imm_update_live(); + return kept; +} + +int psx_mod_immediate_site_count(void) { return s_imm_site_count; } + +uint32_t psx_mod_immediate_select(uint32_t group) { + return group < PSX_MOD_IMMEDIATE_GROUPS ? s_selector[group] : 0u; +} + +void psx_mod_immediate_reset(void) { + for (int g = 0; g < PSX_MOD_IMMEDIATE_GROUPS; g++) s_selector[g] = 0u; + imm_update_live(); +} + +uint32_t psx_mod_immediate_patch(uint32_t pc, uint32_t insn) { + const uint32_t phys = pc & 0x1FFFFFFFu; + int lo = 0, hi = s_imm_site_count; + while (lo < hi) { + const int mid = lo + (hi - lo) / 2; + if (s_imm_sites[mid].address < phys) lo = mid + 1; + else hi = mid; + } + for (; lo < s_imm_site_count && s_imm_sites[lo].address == phys; lo++) { + const PSXModImmediateSite *site = &s_imm_sites[lo]; + if (site->expected != insn) continue; + const uint32_t sel = s_selector[site->group]; + if (sel == 0u || sel > site->count) return insn; + return (insn & 0xFFFF0000u) | site->values[sel - 1u]; + } + return insn; +} + +/* mod_plugins.h. Process-wide host state: the session start resets it + * (main.cpp reset_mod_owned_presentation), so only a session whose plan + * activates a plugin that asks for it, never netplay, runs with a selector set. */ +int psx_mod_set_immediate_selector(uint32_t group, uint32_t selector) { + if (group >= PSX_MOD_IMMEDIATE_GROUPS || selector > PSX_MOD_IMMEDIATE_VALUES_MAX) + return 0; + s_selector[group] = (uint8_t)selector; + imm_update_live(); + return s_imm_site_count > 0; +} + +uint32_t psx_mod_immediate_selector(uint32_t group) { + return psx_mod_immediate_select(group); +} diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index bceb61d9..f4a0ed18 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -2633,6 +2633,8 @@ static void init_callbacks(void) { s_callbacks.mod_function_entry = psx_mod_function_entry; extern void psx_mod_instruction(CPUState*, uint32_t, uint32_t); s_callbacks.mod_instruction = psx_mod_instruction; + extern uint32_t psx_mod_immediate_select(uint32_t group); + s_callbacks.mod_immediate_select = psx_mod_immediate_select; } /* ABI v14: GTE precision-store tracker — the emitter emits a direct * gte_precision_store_word() call for every swc2 (GTE store-word), diff --git a/runtime/tests/test_interp_mod_immediate_guards.py b/runtime/tests/test_interp_mod_immediate_guards.py new file mode 100644 index 00000000..bcc7b1b2 --- /dev/null +++ b/runtime/tests/test_interp_mod_immediate_guards.py @@ -0,0 +1,25 @@ +"""Pin the dirty-RAM interpreter's [[mod_immediate.sites]] call site: the word +substitution must follow the guarded instruction hook (which compares the +original word) and precede every use of `insn` for decode and execution.""" +import pathlib +import re + +SRC = pathlib.Path(__file__).resolve().parents[1] / "src" / "dirty_ram_interp.c" + + +def test_interp_substitutes_after_instruction_hook(): + text = SRC.read_text(encoding="utf-8") + start = text.index("static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn,\n" + " uint32_t *next_pc_out) {") + body = text[start:text.index("static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn,", start + 10)] + patch = "if (g_psx_mod_immediate_live) insn = psx_mod_immediate_patch(pc, insn);" + hook = "if (g_psx_mod_instruction_hooks) psx_mod_instruction(cpu, pc, insn);" + assert body.count(patch) == 1 + assert body.index(hook) < body.index(patch), "hook guards the original word" + # decode of the load/store fields happens after the substitution + assert body.index(patch) < body.index("const uint32_t ld_op = op_field(insn);") + + +def test_interp_includes_header(): + text = SRC.read_text(encoding="utf-8") + assert re.search(r'#include "mod_immediate\.h"', text) diff --git a/runtime/tests/test_mod_immediate.c b/runtime/tests/test_mod_immediate.c new file mode 100644 index 00000000..f7abc388 --- /dev/null +++ b/runtime/tests/test_mod_immediate.c @@ -0,0 +1,156 @@ +/* [[mod_immediate.sites]] runtime store, selectors and the interpreter's word + * substitution (mod_immediate.c): physical addresses, (address, word) lookup, + * sorting, the cap and its log line, invalid entries dropped, empty and NULL + * lists, selector range checks, the live gate and session reset, and + * psx_mod_immediate_patch swapping exactly the imm16 of a listed word. + * The interpreter call site is pinned by test_interp_mod_immediate_guards.py. */ +#include "../src/mod_immediate.c" + +#include +#include +#include +#if defined(_WIN32) +#include +#define dup _dup +#define dup2 _dup2 +#define fileno _fileno +#else +#include +#endif + +static int failures; +#define CHECK(cond, ...) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \ + fprintf(stderr, __VA_ARGS__); \ + fputc('\n', stderr); \ + ++failures; \ + } \ + } while (0) + +#define SLTI_V0_V0_20 0x28420020u /* slti v0, v0, 0x20 */ +#define SLTIU_T2_A1_20 0x2CAA0020u /* sltiu t2, a1, 0x20 */ +#define LBU_V0_V1_2E9 0x906202E9u /* lbu v0, 0x2e9(v1) */ + +static const char *s_capture_path = "mod_immediate_unit_stderr.txt"; + +static void capture_stderr(void (*fn)(void), char *buf, size_t cap) { + FILE *tmp = fopen(s_capture_path, "w+b"); + buf[0] = '\0'; + if (!tmp) { + CHECK(0, "cannot open the stderr capture file %s", s_capture_path); + fn(); + return; + } + fflush(stderr); + int saved = dup(fileno(stderr)); + dup2(fileno(tmp), fileno(stderr)); + fn(); + fflush(stderr); + dup2(saved, fileno(stderr)); + close(saved); + rewind(tmp); + size_t n = fread(buf, 1, cap - 1, tmp); + buf[n] = '\0'; + fclose(tmp); + remove(s_capture_path); +} + +/* ---- store ----------------------------------------------------------------- */ +#define BIG_N (PSX_MOD_IMMEDIATE_SITES_MAX + 44) +static PSXModImmediateSite big[BIG_N]; +static void set_big(void) { (void)psx_mod_immediate_set_sites(big, BIG_N); } + +static PSXModImmediateSite mk(uint32_t addr, uint32_t word, uint32_t group, uint32_t n, + uint16_t a, uint16_t b) { + PSXModImmediateSite s; + memset(&s, 0, sizeof s); + s.address = addr; s.expected = word; s.group = group; s.count = n; + s.values[0] = a; s.values[1] = b; + return s; +} + +static void test_store(void) { + const PSXModImmediateSite sites[] = { + mk(0x80020010u, SLTI_V0_V0_20, 1, 2, 0x40, 0x80), + mk(0xA0020000u, LBU_V0_V1_2E9, 2, 1, 0x3E9, 0), /* KSEG1 spelling */ + mk(0x00020010u, SLTIU_T2_A1_20, 1, 2, 0x40, 0x80), /* same address, other word */ + mk(0x80020020u, SLTI_V0_V0_20, 64, 1, 1, 0), /* bad group: dropped */ + mk(0x80020030u, SLTI_V0_V0_20, 1, 0, 0, 0), /* no values: dropped */ + }; + CHECK(psx_mod_immediate_set_sites(sites, 5) == 3, "three valid sites stored"); + CHECK(psx_mod_immediate_site_count() == 3, "count 3"); + CHECK(s_imm_sites[0].address == 0x00020000u && s_imm_sites[1].address == 0x00020010u && + s_imm_sites[2].address == 0x00020010u && s_imm_sites[1].expected < s_imm_sites[2].expected, + "stored sorted by (physical address, word)"); + + psx_mod_immediate_reset(); + (void)psx_mod_set_immediate_selector(1, 1); + CHECK(psx_mod_immediate_patch(0x80020010u, SLTI_V0_V0_20) == 0x28420040u, + "KSEG0 lookup, selector 1 -> 0x40"); + CHECK(psx_mod_immediate_patch(0x00020010u, SLTIU_T2_A1_20) == 0x2CAA0040u, + "the other word at the shared address is its own site"); + CHECK(psx_mod_immediate_patch(0x80020010u, LBU_V0_V1_2E9) == LBU_V0_V1_2E9, + "an unlisted word at a listed address is unchanged"); + CHECK(psx_mod_immediate_patch(0x80020000u, LBU_V0_V1_2E9) == LBU_V0_V1_2E9, + "group 2 is still selector 0"); + (void)psx_mod_set_immediate_selector(2, 1); + CHECK(psx_mod_immediate_patch(0x80020000u, LBU_V0_V1_2E9) == 0x906203E9u, + "KSEG1 entry found through KSEG0, imm16 swapped"); + CHECK(psx_mod_immediate_patch(0x80020004u, LBU_V0_V1_2E9) == LBU_V0_V1_2E9, + "between entries is unchanged"); + + CHECK(psx_mod_immediate_set_sites(NULL, 5) == 0, "NULL list stores none"); + CHECK(psx_mod_immediate_set_sites(sites, -1) == 0, "negative count stores none"); + CHECK(psx_mod_immediate_patch(0x80020010u, SLTI_V0_V0_20) == SLTI_V0_V0_20, + "empty store is unchanged"); + + for (int i = 0; i < BIG_N; i++) + big[i] = mk(0x80040000u + 4u * (uint32_t)i, SLTI_V0_V0_20, 3, 1, 0x40, 0); + char log[512]; + capture_stderr(set_big, log, sizeof log); + CHECK(psx_mod_immediate_site_count() == PSX_MOD_IMMEDIATE_SITES_MAX, + "over-long list stored %d", psx_mod_immediate_site_count()); + CHECK(strstr(log, "keeping the first 4096") != NULL, "overflow not logged (got \"%s\")", log); + (void)psx_mod_immediate_set_sites(NULL, 0); +} + +/* ---- selectors ------------------------------------------------------------- */ +static void test_selectors(void) { + psx_mod_immediate_reset(); + CHECK(psx_mod_set_immediate_selector(1, 1) == 0, "no sites: the setter says so"); + CHECK(psx_mod_immediate_selector(1) == 1u, "the selector is still recorded"); + CHECK(!g_psx_mod_immediate_live, "no sites: interpreter gate stays off"); + const PSXModImmediateSite one = mk(0x80020000u, SLTI_V0_V0_20, 1, 2, 0x40, 0x80); + (void)psx_mod_immediate_set_sites(&one, 1); + CHECK(g_psx_mod_immediate_live, "sites with a nonzero selector: gate on"); + CHECK(psx_mod_set_immediate_selector(1, 0) == 1, "sites: the setter says so"); + CHECK(!g_psx_mod_immediate_live, "selector 0: gate off"); + CHECK(psx_mod_set_immediate_selector(64, 1) == 0 && psx_mod_set_immediate_selector(1, 16) == 0, + "out-of-range group / selector refused"); + CHECK(psx_mod_immediate_select(1) == 0u && psx_mod_immediate_select(99) == 0u, + "unset and out-of-range groups read 0"); + (void)psx_mod_set_immediate_selector(1, 2); + CHECK(psx_mod_immediate_select(1) == 2u && g_psx_mod_immediate_live, "selector 2 live"); + psx_mod_immediate_reset(); + CHECK(psx_mod_immediate_select(1) == 0u && !g_psx_mod_immediate_live, "reset clears"); + /* A selector past the site's value count runs stock. */ + (void)psx_mod_set_immediate_selector(1, 5); + CHECK(psx_mod_immediate_patch(0x80020000u, SLTI_V0_V0_20) == SLTI_V0_V0_20, + "selector beyond the value count is stock"); + psx_mod_immediate_reset(); + (void)psx_mod_immediate_set_sites(NULL, 0); +} + +int main(int argc, char **argv) { + if (argc > 1) s_capture_path = argv[1]; + test_store(); + test_selectors(); + if (failures) { + fprintf(stderr, "%d check(s) failed\n", failures); + return 1; + } + puts("PASS mod immediate store, selectors and patch"); + return 0; +} From a5d332e6b71e9b88f8128e2b2f0c6c2df525bc52 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 13:28:09 -0700 Subject: [PATCH 13/17] runtime: always-on wall-time rings, stale-toolchain guard, announce published shards Co-Authored-By: Claude Sonnet 5.5 --- docs/HOST_WALL_TIME_RINGS.md | 29 +++++ runtime/CMakeLists.txt | 32 ++++- runtime/include/autocompile.h | 15 +++ runtime/include/host_sampler.h | 4 +- runtime/include/host_span_ring.h | 87 +++++++++++++ runtime/include/overlay_loader.h | 4 + runtime/runtime.cmake | 1 + runtime/src/autocompile.c | Bin 66650 -> 68509 bytes runtime/src/debug_server.c | 117 +++++++++++++++++- runtime/src/host_span_ring.c | 112 +++++++++++++++++ runtime/src/main.cpp | 22 +++- runtime/src/overlay_capture.c | 19 +++ runtime/src/overlay_loader.c | 35 +++++- .../tests/test_autocompile_toolchain_hash.c | 68 ++++++++++ .../test_compile_overlays_publish_marker.py | 40 ++++++ runtime/tests/test_host_span_ring.c | 88 +++++++++++++ tools/compile_overlays.py | 17 +++ 17 files changed, 681 insertions(+), 9 deletions(-) create mode 100644 docs/HOST_WALL_TIME_RINGS.md create mode 100644 runtime/include/host_span_ring.h create mode 100644 runtime/src/host_span_ring.c create mode 100644 runtime/tests/test_autocompile_toolchain_hash.c create mode 100644 runtime/tests/test_compile_overlays_publish_marker.py create mode 100644 runtime/tests/test_host_span_ring.c diff --git a/docs/HOST_WALL_TIME_RINGS.md b/docs/HOST_WALL_TIME_RINGS.md new file mode 100644 index 00000000..efb0305a --- /dev/null +++ b/docs/HOST_WALL_TIME_RINGS.md @@ -0,0 +1,29 @@ +# Always-on host wall-time rings (hitch attribution) + +A gameplay hitch is wall time the emulation thread did not spend running the guest. These rings +record where it went, continuously from process start in every build, so a probe never arms +anything: it queries a window that already happened. + +| Ring | TCP command | Holds | +|------|-------------|-------| +| frame stamps | `frame_wall_ring {"from":SEQ,"n":N}` | one `[guest frame, wall us, interpreter insns (low 32 bits)]` per guest frame, 131072 frames (~36 min at 60 Hz) | +| spans | `host_span_ring {"from":SEQ,"n":N}` | `[seq, kind, t0_us, dur_us, frame, emu, arg]` for every named blocking-capable operation of 50 us or more, 65536 spans | +| host CPU sampler (debug-tools builds) | `host_profile {"frame_lo":A,"frame_hi":B}` | 1 kHz instruction-pointer samples of the emulation thread, 1 Mi samples (~17 min), with per-module totals for samples outside the exe | + +Wall time is microseconds since the first ring call. `emu` is 1 for the emulation thread, 0 for a +worker (compiler child, publication preparer). + +Span kinds (`runtime/include/host_span_ring.h`): `autocapture_tick`, `provider_poll`, +`publish_commit`, `loader_rescan`, `dll_register`, `lazy_load` (a synchronous first map of a shard +at a dispatch miss), `capture_on_dma`, `capture_before_dma`, `ac_request` (spawning the compiler), +`ac_done_join`, `ac_prepare_image` (worker), `ac_child` (compiler lifetime, worker). + +Reading a hitch: per frame, `wall delta - sum(emulation-thread spans overlapping the frame)` is +the time no span explains; then ask the host sampler for that frame window. A title's report tool +joins them (`LegendOfDragoonRecomp/tools/lod_hitch_report.py`). + +Findings the rings produced (LoD, 2026-10-12): a toolchain staged from another emitter than the +runtime wrote every shard where the loader never looks (now refused loudly, see +`autocompile_check_toolchain_hash`), and shards compiled during play were mapped by the emulation +thread because `compile_overlays.py` never announced them (now `PSX_SHARD_PUBLISHED`, see +`announce_published_shard`). diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 3debc78b..77567f53 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -180,7 +180,7 @@ if(BUILD_TESTING) add_test(NAME boot_state_execution_test COMMAND boot_state_execution_test) add_executable(static_match_cache_test tests/test_static_match_cache.c - src/overlay_loader.c src/crc32.c) + src/overlay_loader.c src/host_span_ring.c src/crc32.c) target_include_directories(static_match_cache_test PRIVATE include) target_compile_definitions(static_match_cache_test PRIVATE PSX_HAS_OVERLAY_DISPATCH PSX_NO_DEBUG_TOOLS) @@ -1035,6 +1035,7 @@ if(BUILD_TESTING) add_executable(overlay_capture_retry_test tests/test_overlay_capture_retry.cpp src/overlay_capture.c + src/host_span_ring.c src/psx_ram_geometry.c) target_include_directories(overlay_capture_retry_test PRIVATE include ${PSX_SDL_INCLUDE_DIRS}) @@ -1314,7 +1315,8 @@ if(BUILD_TESTING) if(WIN32) add_executable(autocompile_publication_test tests/test_autocompile_publication.c - src/autocompile.c) + src/autocompile.c + src/host_span_ring.c) target_include_directories(autocompile_publication_test PRIVATE include) target_compile_definitions(autocompile_publication_test PRIVATE PSX_AUTOCOMPILE_TEST=1) @@ -1326,12 +1328,35 @@ if(BUILD_TESTING) # broken autocompile silently degrades the whole run to the interpreter. add_executable(autocompile_degraded_test tests/test_autocompile_degraded.c - src/autocompile.c) + src/autocompile.c + src/host_span_ring.c) target_include_directories(autocompile_degraded_test PRIVATE include) add_test(NAME autocompile_degraded_test COMMAND autocompile_degraded_test) + + # A toolchain staged from a different emitter writes shards into a cache directory the + # runtime never reads (LoD playtest 2026-10-12); the runtime must refuse it loudly. + add_executable(autocompile_toolchain_hash_test + tests/test_autocompile_toolchain_hash.c + src/autocompile.c + src/host_span_ring.c) + target_include_directories(autocompile_toolchain_hash_test PRIVATE include) + add_test(NAME autocompile_toolchain_hash_test + COMMAND autocompile_toolchain_hash_test) endif() + # Always-on wall-time frame/span rings (host_span_ring.h): the hitch-attribution + # rings every runtime stall probe reads. + add_executable(host_span_ring_test + tests/test_host_span_ring.c + src/host_span_ring.c) + target_include_directories(host_span_ring_test PRIVATE include) + if(UNIX) + find_package(Threads REQUIRED) + target_link_libraries(host_span_ring_test PRIVATE Threads::Threads) + endif() + add_test(NAME host_span_ring_test COMMAND host_span_ring_test) + # ---- previously-unregistered standalone tests ------------------------- # These 16 existed in runtime/tests/ with no add_test() entry. Their only # build instructions were a gcc command line in each file's header comment, @@ -1787,6 +1812,7 @@ if(BUILD_TESTING) add_test(NAME overlay_retry_c11_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_overlay_retry_c11.py --compiler ${CMAKE_C_COMPILER}) endif() add_test(NAME setup_bios_assets_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_setup_bios_assets.py) + add_test(NAME compile_overlays_publish_marker_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_compile_overlays_publish_marker.py) if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") add_test(NAME launch_disc_path_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_launch_disc_path.py --compiler ${CMAKE_CXX_COMPILER}) add_test(NAME persisted_settings_paths_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_persisted_settings_paths.py --compiler ${CMAKE_CXX_COMPILER}) diff --git a/runtime/include/autocompile.h b/runtime/include/autocompile.h index 0db386ec..bd4f88f5 100644 --- a/runtime/include/autocompile.h +++ b/runtime/include/autocompile.h @@ -8,6 +8,8 @@ #ifndef PSXRECOMP_AUTOCOMPILE_H #define PSXRECOMP_AUTOCOMPILE_H +#include + #ifdef __cplusplus extern "C" { #endif @@ -26,6 +28,19 @@ int autocompile_configured(void); * cache location: no per-game config, no drift. */ void autocompile_set_cache_paths(const char *cache_dir, const char *captures); +/* Is the staged overlay toolchain the one THIS runtime was built with? + * + * The shard cache directory carries the codegen hash (gcc//cg__...). A toolchain + * staged from an older emitter writes its shards under its own hash, a directory this runtime + * never reads: every compile succeeds, nothing is ever loaded, every overlay stays interpreted + * and the same regions are recompiled for the whole session (LoD playtest 2026-10-12: 27 + * compile runs, 0 shards loaded). Reads /overlay_codegen_hash.h and + * compares its PSX_OVERLAY_CODEGEN_HASH with `runtime_hash`. + * Returns 1 when usable or unknown (no/unparseable header, or runtime_hash == 0); returns 0 and + * records a degraded reason (autocompile_status "degraded_reason") on a mismatch. The caller + * must then NOT configure the bundled toolchain command. */ +int autocompile_check_toolchain_hash(const char *toolchain_include_dir, uint32_t runtime_hash); + /* Probe whether a C compiler is actually reachable on PATH (gcc/cc/clang) — the * REAL "developer machine" signal, distinct from autocompile_configured() which * only reports that a command STRING is set (the shipped game.toml always sets diff --git a/runtime/include/host_sampler.h b/runtime/include/host_sampler.h index 069ff1b5..2fd3ddf2 100644 --- a/runtime/include/host_sampler.h +++ b/runtime/include/host_sampler.h @@ -8,7 +8,9 @@ extern "C" { #endif -#define HOST_SAMPLER_CAP 65536u /* ~65 s at 1 kHz */ +/* 1 Mi samples: ~17 minutes at 1 kHz, enough to keep a whole load-transition route (16 MB, + * debug-tools builds only) so a hitch found afterwards still has its host call sites. */ +#define HOST_SAMPLER_CAP 1048576u typedef struct HostSample { uint64_t rva; /* instruction pointer minus the executable's base */ diff --git a/runtime/include/host_span_ring.h b/runtime/include/host_span_ring.h new file mode 100644 index 00000000..1eb6988d --- /dev/null +++ b/runtime/include/host_span_ring.h @@ -0,0 +1,87 @@ +#ifndef PSX_HOST_SPAN_RING_H +#define PSX_HOST_SPAN_RING_H +/* Always-on host wall-time rings: where an emulation-thread stall comes from. + * + * Two rings, both written continuously from process start in EVERY build + * (Release too); probes only ever QUERY a window that already happened: + * + * - frame stamps: one {guest frame, wall us} per guest frame (131072 frames, + * ~36 min at 60 Hz). Successive deltas are the per-frame wall time, so a + * hitch is visible headless as well as windowed (no GL present ring needed). + * - spans: every named blocking-capable runtime operation (loader, capture, + * publication commit, cache rescan, autocompile spawn/prepare/compile child) + * as {kind, start wall us, duration us, guest frame, thread class, arg}. + * The emulation thread's spans are the candidates for a hitch; background + * spans show the work that fed them. + * + * Wall time is microseconds since the first call in the process (monotonic). + * Writers may be different threads: slots are claimed with an atomic counter + * and a torn read blurs one diagnostic row only. + * + * Reading: TCP "host_span_ring" / "frame_wall_ring"; a title's hitch report + * tool joins them (LegendOfDragoonRecomp tools/lod_hitch_report.py). */ +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum HostSpanKind { + HSPAN_NONE = 0, + HSPAN_AUTOCAPTURE_TICK, /* emu: overlay_autocapture_tick (whole) */ + HSPAN_PROVIDER_POLL, /* emu: code provider poll_main (whole) */ + HSPAN_PUBLISH_COMMIT, /* emu: overlay_loader_commit_published */ + HSPAN_LOADER_RESCAN, /* emu: overlay_loader_rescan */ + HSPAN_DLL_REGISTER, /* emu: parse manifest + load_overlay_dll */ + HSPAN_LAZY_LOAD, /* emu: try_load_region (miss -> cache load) */ + HSPAN_CAPTURE_ON_DMA, /* emu: overlay_capture_on_dma (CD/DMA arrival) */ + HSPAN_CAPTURE_BEFORE_DMA, /* emu: overlay_capture_before_dma (evidence save) */ + HSPAN_AC_REQUEST, /* emu: autocompile_request (spawn compiler) */ + HSPAN_AC_DONE_JOIN, /* emu: poll_main joining finished workers */ + HSPAN_AC_PREPARE_IMAGE, /* bg : publication preparer LoadLibrary */ + HSPAN_AC_CHILD, /* bg : compiler child process lifetime */ + HSPAN_OVERLAY_DISPATCH, /* emu: overlay_loader_dispatch slow path */ + HSPAN_MOD_WORK, /* emu: resident pack / mod host work */ + HSPAN_FRAME_PRESENT, /* emu: frontend present for the frame */ + HSPAN_KIND_COUNT +} HostSpanKind; + +typedef struct HostSpan { + uint64_t t0_us; /* start, wall us since process clock origin */ + uint32_t dur_us; /* duration (saturates at ~71 min) */ + uint32_t frame; /* guest frame when the span ended */ + uint32_t arg; /* kind-specific (address, ok flag, count) */ + uint16_t kind; /* HostSpanKind */ + uint16_t emu; /* 1 = emulation thread, 0 = background */ +} HostSpan; + +/* Wall microseconds since the process clock origin. */ +uint64_t host_clock_us(void); + +/* Open / close a span. begin returns the start stamp; end records it. */ +uint64_t host_span_begin(void); +void host_span_end(HostSpanKind kind, uint64_t t0_us, uint32_t arg); +/* Record a span whose duration the caller already measured. */ +void host_span_record(HostSpanKind kind, uint64_t t0_us, uint64_t dur_us, uint32_t arg); + +uint64_t host_span_total(void); +int host_span_get(uint64_t seq, HostSpan *out); +const char *host_span_kind_name(unsigned kind); +/* 65536 spans; spans shorter than HOST_SPAN_MIN_US are not recorded (the per-frame + * tick wrappers would otherwise flood the ring; a hitch is milliseconds). */ +#define HOST_SPAN_RING_CAP 65536u +#define HOST_SPAN_MIN_US 50u + +/* Stamp guest frame `frame` at the current wall time. Also marks the calling + * thread as the emulation thread for span attribution. Call once per frame. + * interp_insns is the low 32 bits of the cumulative count of guest instructions run by the + * interpreter (successive differences = interpreter work in that frame). */ +void host_frame_stamp(uint32_t frame, uint32_t interp_insns); +uint64_t host_frame_stamp_total(void); +int host_frame_stamp_get(uint64_t seq, uint32_t *frame, uint64_t *t_us, uint32_t *interp_insns); +#define HOST_FRAME_STAMP_CAP 131072u + +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime/include/overlay_loader.h b/runtime/include/overlay_loader.h index ac985513..8203211f 100644 --- a/runtime/include/overlay_loader.h +++ b/runtime/include/overlay_loader.h @@ -119,6 +119,10 @@ void overlay_loader_resync_validation_after_restore(void); * (loaded DLLs stay loaded); emu thread only. */ void overlay_loader_rescan(void); +/* The codegen hash this runtime was built with (PSX_OVERLAY_CODEGEN_HASH, 0 if unknown). It names the + * shard cache directory, so a toolchain staged from a different emitter writes where nobody reads. */ +unsigned overlay_loader_codegen_hash(void); + /* Two-phase live publication: the compile-output watcher maps a freshly * published image off the emulation thread, then the emulation thread performs * callback wiring, manifest validation, and candidate registration. The opaque diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 57467624..baa3e0d0 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -413,6 +413,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/memcard.c ${PSXRECOMP_ROOT}/runtime/src/debug_server.c ${PSXRECOMP_ROOT}/runtime/src/host_sampler.c + ${PSXRECOMP_ROOT}/runtime/src/host_span_ring.c ${PSXRECOMP_ROOT}/runtime/src/debug_trace_ranges.c ${PSXRECOMP_ROOT}/runtime/src/dirty_ram_interp.c ${PSXRECOMP_ROOT}/runtime/src/draw_distance.c diff --git a/runtime/src/autocompile.c b/runtime/src/autocompile.c index 2dad03113897e21995126ac6bfe5551257960163..da1eb7749e4c22de42b48ee139017d644d5d368a 100644 GIT binary patch delta 1728 zcma)7L2nyH6eg$z$wCM&RH`cUYGJyYY?DnW6xA}7n=DDdb%H~oRTM^(^{jWA^^BRB zt!-N5D_j7{2Ts5}7b;oe7jWPgfW(bTT=)@)H@oY^Ev_KSWoPDn?|t9<-t+HYocZqC zGY`L9crljR5Z$AKC@wma`wPFGAOCjerSae2JU22@CSgpuATiak6H1{C;DLh0(-R77uDQ@Lf>M zq)NbQH3QGe>QNK#ulj3O4)Q}SddSK|j=k_e^Us*fKU@#W6}=+wF_q9E31ypgSk7}f zE_-0hyq$BOA?W6??`}5&Sd5?sk?b*1pqJHQxp z6cLR~69*G`@H$3#*uVU>RmwtOOXpN%Z=I>9T#HqE%n1d91u=dYFQE!FVNkR*>tNQv zYf#Z3XqRay+6>mt;14SP+sOb9Qv)XEdWsBvs_~E~2KwB@P|o~i4P4Gb2G}f#P~9F+ zkfz3YXlv-pQJNrWie$I|3Ly<+2IycG8mWeuf}La#41I?NrWj}gW>VOj;R%U8%7!Tn z9U`bbL%?be&5zD{;q!EU`3 zknNlG%>bKZmzwcC;D&WCzr(v$u*T^0r$Ye}7yV{crNq znGvXvp$eUVmD1G6!i+?*Pg8V8o?-eLXQ+7X&d7PH+*OpW6?||j*!yrswCR8reJT*D zrO!*H88Tdi9Ssh>h&leShW tv+#L20khI(qe+uSVGxtBuoSbSv9~vqB HOST_SAMPLER_CAP ? seq - HOST_SAMPLER_CAP : 0; int since = json_get_int(json, "since", -1); const int frames = json_get_int(json, "frames", -1); + /* Optional absolute guest-frame window (inclusive): profile exactly one hitch. */ + const int frame_lo_abs = json_get_int(json, "frame_lo", -1); + const int frame_hi_abs = json_get_int(json, "frame_hi", -1); const int pass = json_get_int(json, "pass", -1); int top = json_get_int(json, "top", 60); if (top < 1) top = 1; @@ -5457,6 +5461,8 @@ static void handle_host_profile(int id, const char *json) HostSample smp; if (!host_sampler_get(i, &smp)) continue; if (frames > 0 && (int32_t)(smp.frame - frame_lo) < 0) continue; + if (frame_lo_abs >= 0 && smp.frame < (uint32_t)frame_lo_abs) continue; + if (frame_hi_abs >= 0 && smp.frame > (uint32_t)frame_hi_abs) continue; if (pass >= 0 && (int)smp.in_pass != pass) continue; total++; int b = 0; @@ -5467,7 +5473,41 @@ static void handle_host_profile(int id, const char *json) } counts[b]++; } - size_t cap = 256 + (size_t)top * 48; + /* Where did the samples that are NOT in this executable land? A hitch spent inside the + * GL driver, the OS loader or the audio stack shows up here by module name ("modules": + * [[name, samples], ...], the exe itself as "(exe)"), where the bare addresses above + * cannot be mapped to anything without that module's symbols. */ + char modnames[24][96]; uint64_t modcount[24]; int nmod = 0; +#ifdef _WIN32 + { + const HMODULE exe_mod = GetModuleHandleA(NULL); + const uint64_t base = host_sampler_image_base(); + for (int b = 0; b < nb; b++) { + HMODULE h = NULL; + const char *name = "(unknown)"; + char path[260]; + if (rvas[b] == 0) { + name = "(non-image pc)"; + } else if (GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + (LPCSTR)(uintptr_t)(base + rvas[b]), &h) && h) { + if (h == exe_mod) name = "(exe)"; + else if (GetModuleFileNameA(h, path, sizeof path)) { + name = strrchr(path, '\\'); + name = name ? name + 1 : path; + } + } + int m = 0; + while (m < nmod && strcmp(modnames[m], name) != 0) m++; + if (m == nmod) { + if (nmod == 24) { m = 23; } + else { nmod++; snprintf(modnames[m], sizeof modnames[m], "%s", name); modcount[m] = 0; } + } + modcount[m] += counts[b]; + } + } +#endif + size_t cap = 256 + (size_t)top * 48 + 24u * 128u; char *buf = (char *)malloc(cap); if (!buf) { send_err(id, "oom"); return; } size_t n = (size_t)snprintf(buf, cap, @@ -5485,11 +5525,82 @@ static void handle_host_profile(int id, const char *json) (unsigned long long)rvas[best], counts[best]); counts[best] = 0; } + n += (size_t)snprintf(buf + n, cap - n, "],\"modules\":["); + for (int m = 0; m < nmod; m++) + n += (size_t)snprintf(buf + n, cap - n, "%s[\"%s\",%llu]", m ? "," : "", modnames[m], + (unsigned long long)modcount[m]); snprintf(buf + n, cap - n, "]}"); send_fmt("%s", buf); free(buf); } +/* host_span_ring / frame_wall_ring - always-on wall-time rings (host_span_ring.h). + * {"cmd":"host_span_ring","from":SEQ,"n":N} spans with seq >= from (default: oldest) + * -> {total, oldest, events:[[seq, kind, t0_us, dur_us, frame, emu, arg], ...]} + * {"cmd":"frame_wall_ring","from":SEQ,"n":N} per-guest-frame wall stamps + * -> {total, oldest, now_us, f:[frame, t_us, interp_insns_lo32, ...]} + * Both read a window that already happened; nothing is armed. */ +static void handle_host_span_ring(int id, const char *json) +{ + const uint64_t total = host_span_total(); + const uint64_t oldest = total > HOST_SPAN_RING_CAP ? total - HOST_SPAN_RING_CAP : 0; + int from_i = json_get_int(json, "from", -1); + int n = json_get_int(json, "n", 4096); + if (n < 1) n = 1; + if (n > (int)HOST_SPAN_RING_CAP) n = (int)HOST_SPAN_RING_CAP; + uint64_t from = from_i >= 0 ? (uint64_t)from_i : oldest; + if (from < oldest) from = oldest; + size_t cap = 160 + (size_t)n * 96; + char *buf = (char *)malloc(cap); + if (!buf) { send_err(id, "oom"); return; } + size_t pos = (size_t)snprintf(buf, cap, + "{\"id\":%d,\"ok\":true,\"total\":%llu,\"oldest\":%llu,\"now_us\":%llu,\"events\":[", + id, (unsigned long long)total, (unsigned long long)oldest, + (unsigned long long)host_clock_us()); + int first = 1, emitted = 0; + for (uint64_t q = from; q < total && emitted < n && pos + 128 < cap; q++) { + HostSpan sp; + if (!host_span_get(q, &sp)) continue; + pos += (size_t)snprintf(buf + pos, cap - pos, "%s[%llu,\"%s\",%llu,%u,%u,%u,%u]", + first ? "" : ",", (unsigned long long)q, host_span_kind_name(sp.kind), + (unsigned long long)sp.t0_us, sp.dur_us, sp.frame, sp.emu, sp.arg); + first = 0; emitted++; + } + snprintf(buf + pos, cap - pos, "]}"); + send_fmt("%s", buf); + free(buf); +} + +static void handle_frame_wall_ring(int id, const char *json) +{ + const uint64_t total = host_frame_stamp_total(); + const uint64_t oldest = total > HOST_FRAME_STAMP_CAP ? total - HOST_FRAME_STAMP_CAP : 0; + int from_i = json_get_int(json, "from", -1); + int n = json_get_int(json, "n", 20000); + if (n < 1) n = 1; + if (n > (int)HOST_FRAME_STAMP_CAP) n = (int)HOST_FRAME_STAMP_CAP; + uint64_t from = from_i >= 0 ? (uint64_t)from_i : oldest; + if (from < oldest) from = oldest; + size_t cap = 192 + (size_t)n * 40; + char *buf = (char *)malloc(cap); + if (!buf) { send_err(id, "oom"); return; } + size_t pos = (size_t)snprintf(buf, cap, + "{\"id\":%d,\"ok\":true,\"total\":%llu,\"oldest\":%llu,\"from\":%llu,\"now_us\":%llu,\"f\":[", + id, (unsigned long long)total, (unsigned long long)oldest, + (unsigned long long)from, (unsigned long long)host_clock_us()); + int first = 1, emitted = 0; + for (uint64_t q = from; q < total && emitted < n && pos + 64 < cap; q++) { + uint32_t fr, ii; uint64_t t; + if (!host_frame_stamp_get(q, &fr, &t, &ii)) continue; + pos += (size_t)snprintf(buf + pos, cap - pos, "%s%u,%llu,%u", first ? "" : ",", fr, + (unsigned long long)t, ii); + first = 0; emitted++; + } + snprintf(buf + pos, cap - pos, "]}"); + send_fmt("%s", buf); + free(buf); +} + /* pgxp_shadow — read PGXP shadow slots: {"cmd":"pgxp_shadow","addr":A, * "count":N} walks N guest words from A (RAM / scratchpad); "space":"gpr" or * "gte" with "index"/"count" reads register shadows instead. Each slot: the @@ -15863,6 +15974,8 @@ static const CmdEntry s_commands[] = { { "pgxp_store_ring", handle_pgxp_store_ring }, { "pgxp_shadow", handle_pgxp_shadow }, { "host_profile", handle_host_profile }, + { "host_span_ring", handle_host_span_ring }, + { "frame_wall_ring", handle_frame_wall_ring }, { "pgxp_miss_ring", handle_pgxp_miss_ring }, { "ws_aspect_cone_site", handle_ws_aspect_cone_site }, { "ws_margin", handle_ws_margin }, diff --git a/runtime/src/host_span_ring.c b/runtime/src/host_span_ring.c new file mode 100644 index 00000000..e7684a32 --- /dev/null +++ b/runtime/src/host_span_ring.c @@ -0,0 +1,112 @@ +/* host_span_ring.c - always-on wall-time frame + span rings. See host_span_ring.h. */ +#include "host_span_ring.h" + +#include +#include + +#ifdef _WIN32 +#include +static uint64_t qpc_now_us(void) { + static LARGE_INTEGER freq; + LARGE_INTEGER c; + if (!freq.QuadPart) QueryPerformanceFrequency(&freq); + QueryPerformanceCounter(&c); + return (uint64_t)(c.QuadPart / freq.QuadPart) * 1000000ull + + (uint64_t)((c.QuadPart % freq.QuadPart) * 1000000ull / freq.QuadPart); +} +static uint64_t this_thread_id(void) { return (uint64_t)GetCurrentThreadId(); } +#else +#include +#include +static uint64_t qpc_now_us(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000ull + (uint64_t)ts.tv_nsec / 1000ull; +} +static uint64_t this_thread_id(void) { return (uint64_t)(uintptr_t)pthread_self(); } +#endif + +static _Atomic uint64_t s_origin_us; +static _Atomic uint64_t s_emu_tid; +static _Atomic uint32_t s_cur_frame; + +static HostSpan s_spans[HOST_SPAN_RING_CAP]; +static _Atomic uint64_t s_span_seq; + +typedef struct { uint64_t t_us; uint32_t frame; uint32_t interp; } FrameStamp; +static FrameStamp s_frames[HOST_FRAME_STAMP_CAP]; +static _Atomic uint64_t s_frame_seq; + +uint64_t host_clock_us(void) { + uint64_t now = qpc_now_us(); + uint64_t origin = atomic_load_explicit(&s_origin_us, memory_order_relaxed); + if (!origin) { + uint64_t expect = 0; + /* First caller fixes the origin; "now | 1" keeps "unset" (0) distinguishable. */ + uint64_t mine = now ? now : 1; + if (atomic_compare_exchange_strong(&s_origin_us, &expect, mine)) + origin = mine; + else + origin = expect; + } + return now >= origin ? now - origin : 0; +} + +uint64_t host_span_begin(void) { return host_clock_us(); } + +void host_span_record(HostSpanKind kind, uint64_t t0_us, uint64_t dur_us, uint32_t arg) { + if (dur_us < HOST_SPAN_MIN_US) return; + uint64_t n = atomic_fetch_add_explicit(&s_span_seq, 1, memory_order_relaxed); + HostSpan *s = &s_spans[n % HOST_SPAN_RING_CAP]; + s->t0_us = t0_us; + s->dur_us = dur_us > 0xFFFFFFFFull ? 0xFFFFFFFFu : (uint32_t)dur_us; + s->frame = atomic_load_explicit(&s_cur_frame, memory_order_relaxed); + s->arg = arg; + s->kind = (uint16_t)kind; + s->emu = this_thread_id() == atomic_load_explicit(&s_emu_tid, memory_order_relaxed); +} + +void host_span_end(HostSpanKind kind, uint64_t t0_us, uint32_t arg) { + uint64_t now = host_clock_us(); + host_span_record(kind, t0_us, now >= t0_us ? now - t0_us : 0, arg); +} + +uint64_t host_span_total(void) { return atomic_load_explicit(&s_span_seq, memory_order_relaxed); } + +int host_span_get(uint64_t seq, HostSpan *out) { + uint64_t total = host_span_total(); + if (seq >= total || total - seq > HOST_SPAN_RING_CAP) return 0; + *out = s_spans[seq % HOST_SPAN_RING_CAP]; + return 1; +} + +const char *host_span_kind_name(unsigned kind) { + static const char *const names[HSPAN_KIND_COUNT] = { + "none", "autocapture_tick", "provider_poll", "publish_commit", "loader_rescan", + "dll_register", "lazy_load", "capture_on_dma", "capture_before_dma", "ac_request", "ac_done_join", + "ac_prepare_image", "ac_child", "overlay_dispatch", "mod_work", "frame_present", + }; + return kind < HSPAN_KIND_COUNT ? names[kind] : "?"; +} + +void host_frame_stamp(uint32_t frame, uint32_t interp_insns) { + atomic_store_explicit(&s_emu_tid, this_thread_id(), memory_order_relaxed); + atomic_store_explicit(&s_cur_frame, frame, memory_order_relaxed); + uint64_t n = atomic_fetch_add_explicit(&s_frame_seq, 1, memory_order_relaxed); + FrameStamp *f = &s_frames[n % HOST_FRAME_STAMP_CAP]; + f->t_us = host_clock_us(); + f->frame = frame; + f->interp = interp_insns; +} + +uint64_t host_frame_stamp_total(void) { return atomic_load_explicit(&s_frame_seq, memory_order_relaxed); } + +int host_frame_stamp_get(uint64_t seq, uint32_t *frame, uint64_t *t_us, uint32_t *interp_insns) { + uint64_t total = host_frame_stamp_total(); + if (seq >= total || total - seq > HOST_FRAME_STAMP_CAP) return 0; + const FrameStamp *f = &s_frames[seq % HOST_FRAME_STAMP_CAP]; + if (frame) *frame = f->frame; + if (t_us) *t_us = f->t_us; + if (interp_insns) *interp_insns = f->interp; + return 1; +} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 4d7be1de..4e9068f6 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -45,6 +45,7 @@ #include "overlay_loader.h" #include "autocompile.h" #include "code_provider.h" +#include "host_span_ring.h" extern "C" void psx_event_step_conservative_env_init(void); #include "overlay_backend.h" #include "gpu.h" @@ -8346,6 +8347,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { int override = -1; int override_p2 = -1; #endif + /* Always-on wall-time stamp for this guest frame (host_span_ring.h). */ + extern uint64_t s_frame_count; + host_frame_stamp((uint32_t)s_frame_count, (uint32_t)g_dirty_ram_insns_run); psx_local_mouse_begin(sdl_window, local_mouse_live(override)); { @@ -8455,7 +8459,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * frontend is skipping presents. Both are cheap and emu-thread-only. */ { uint64_t perf_start = runtime_perf_section_begin(); + const uint64_t span_t0 = host_span_begin(); overlay_autocapture_tick(); + host_span_end(HSPAN_AUTOCAPTURE_TICK, span_t0, 0); runtime_perf_section_end(perf_start, &g_runtime_perf.autocapture_ticks); } @@ -8463,7 +8469,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * cache rescan on done). */ uint64_t perf_start = runtime_perf_section_begin(); const CodeProvider *cp = code_provider_active(); + const uint64_t span_t0 = host_span_begin(); if (cp->poll_main) cp->poll_main(); + host_span_end(HSPAN_PROVIDER_POLL, span_t0, 0); runtime_perf_section_end(perf_start, &g_runtime_perf.provider_poll_ticks); } @@ -17260,7 +17268,19 @@ int main(int argc, char** argv) { const char *tk_recompiler = "psxrecomp-game"; const char *tk_tcc = "tcc"; #endif - const bool tk_present = std::filesystem::exists(tk_py); + bool tk_present = std::filesystem::exists(tk_py); + /* A toolchain staged from a different emitter than this runtime writes shards under its + * own codegen hash, a cache directory the loader never reads (LoD playtest 2026-10-12: + * 27 compile runs, no shard ever loaded, everything interpreted). Refuse it loudly: + * autocompile_status reports degraded_reason. An explicit overlay_autocompile_cmd in the + * game config, or PSX_OVERLAY_AUTOCOMPILE_CMD, is the author's own choice and stays, and + * PSX_OVERLAY_ALLOW_STALE_TOOLCHAIN=1 reproduces the stale-toolchain condition on purpose + * (A/B measurements). */ + const char *allow_stale_tk = std::getenv("PSX_OVERLAY_ALLOW_STALE_TOOLCHAIN"); + if (tk_present && !(allow_stale_tk && allow_stale_tk[0] && allow_stale_tk[0] != '0') && + !autocompile_check_toolchain_hash((tk_dir / "include").string().c_str(), + (uint32_t)overlay_loader_codegen_hash())) + tk_present = false; /* Wave-5 F2: the CLI names the compiler it already has (the wizard's pack on * Windows, the native cc on POSIX) in overlay_toolchain/compiler.txt, so a player * with nothing on PATH still gets optimised shards instead of tcc's. */ diff --git a/runtime/src/overlay_capture.c b/runtime/src/overlay_capture.c index d525bfd3..9c69fcf4 100644 --- a/runtime/src/overlay_capture.c +++ b/runtime/src/overlay_capture.c @@ -1,5 +1,6 @@ #include "overlay_capture.h" #include "overlay_loader.h" +#include "host_span_ring.h" #include "dirty_ram_interp.h" #include "code_provider.h" #include "crc32.h" @@ -257,8 +258,18 @@ void overlay_capture_configure_history(int enabled, const char *persist_dir, } } +static void overlay_capture_on_dma_impl(uint32_t load_addr, uint32_t size, + const uint8_t *bytes); void overlay_capture_on_dma(uint32_t load_addr, uint32_t size, const uint8_t *bytes) +{ + const uint64_t span_t0 = host_span_begin(); + overlay_capture_on_dma_impl(load_addr, size, bytes); + host_span_end(HSPAN_CAPTURE_ON_DMA, span_t0, load_addr); +} + +static void overlay_capture_on_dma_impl(uint32_t load_addr, uint32_t size, + const uint8_t *bytes) { int i; OvEntry *e; @@ -888,7 +899,15 @@ void overlay_capture_write_json(void) 0, psx_ram_live_bytes(), 0); } +static void overlay_capture_before_dma_impl(uint32_t load_addr, uint32_t size); void overlay_capture_before_dma(uint32_t load_addr, uint32_t size) +{ + const uint64_t span_t0 = host_span_begin(); + overlay_capture_before_dma_impl(load_addr, size); + host_span_end(HSPAN_CAPTURE_BEFORE_DMA, span_t0, load_addr); +} + +static void overlay_capture_before_dma_impl(uint32_t load_addr, uint32_t size) { if (!s_enabled || !s_active || size == 0) return; uint32_t lo = psx_ram_canonical_offset(load_addr); diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index f4a0ed18..f4129223 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -9,6 +9,7 @@ #include "debug_server.h" #include "psx_cycles.h" #include "overlay_unit_defer.h" +#include "host_span_ring.h" /* Out-of-line copy of the header-inline CPU charge for the overlay callback * table: overlay DLLs publish their accumulated CPU charge here, into the @@ -3204,12 +3205,16 @@ void overlay_loader_resync_validation_after_restore(void) * and clear the checked-regions memo so the next dispatch into a window * region reconsiders the cache. Already-loaded DLLs stay loaded; * dll_already_loaded() makes the re-walk idempotent. */ +unsigned overlay_loader_codegen_hash(void) { return (unsigned)PSX_OVERLAY_CODEGEN_HASH; } + void overlay_loader_rescan(void) { if (!s_active || !overlay_loads_allowed()) return; + const uint64_t span_t0 = host_span_begin(); scan_cache_dir(); load_bios_resident_shards(); s_nchecked = 0; lazy_miss_invalidate_loader(); + host_span_end(HSPAN_LOADER_RESCAN, span_t0, (uint32_t)s_cache_idx_count); } /* Loaded-DLL set — the cache is ADDITIVE: a memory slot reused by several @@ -3277,8 +3282,8 @@ static void overlay_library_close(OverlayLibraryHandle handle) { /* Consumes prepared on every path. A successful registration intentionally * retains that reference for process lifetime, matching the ordinary loader. */ -static int load_one_dll(const char *dll_path, - OverlayLibraryHandle prepared) { +static int load_one_dll_impl(const char *dll_path, + OverlayLibraryHandle prepared) { /* Fail CLOSED at the tracking cap, BEFORE registering anything: past this * point dll_already_loaded() would lose track of the DLL and a later * rescan could double-register its candidates (stale-chain execution). @@ -3379,6 +3384,15 @@ static int load_one_dll(const char *dll_path, return registered; } +/* Every register-a-shard load, whichever path asked (rescan, lazy miss, + * published image), is one wall-time span in the always-on host_span_ring. */ +static int load_one_dll(const char *dll_path, OverlayLibraryHandle prepared) { + const uint64_t span_t0 = host_span_begin(); + int registered = load_one_dll_impl(dll_path, prepared); + host_span_end(HSPAN_DLL_REGISTER, span_t0, (uint32_t)registered); + return registered; +} + static int load_one_dll_prepared(const char *dll_path, OverlayLibraryHandle prepared) { /* load_one_dll performs lazy_miss_invalidate_loader(); keep this wrapper @@ -3467,7 +3481,15 @@ OverlayPreparedImage *overlay_loader_prepare_published(const char *dll_path) { return image; } +static int overlay_loader_commit_published_impl(OverlayPreparedImage *image); int overlay_loader_commit_published(OverlayPreparedImage *image) { + const uint64_t span_t0 = host_span_begin(); + int loaded = overlay_loader_commit_published_impl(image); + host_span_end(HSPAN_PUBLISH_COMMIT, span_t0, (uint32_t)loaded); + return loaded; +} + +static int overlay_loader_commit_published_impl(OverlayPreparedImage *image) { if (!image) return 0; if (!overlay_loads_allowed()) { overlay_loader_discard_prepared(image); @@ -3639,7 +3661,16 @@ static int lazy_load_selected(int li) { /* Load the cached shard of `seg` (§5.7) that serves phys. The region walkback * and the manifest indexes are physical: the bytes, and so the region, are the * same in every segment. lazy_is_loadable() admits only `seg`'s manifests. */ +static int try_load_region_impl(uint32_t seg, uint32_t phys); static int try_load_region(uint32_t seg, uint32_t phys) { + const uint64_t span_t0 = host_span_begin(); + int loaded = try_load_region_impl(seg, phys); + host_span_end(HSPAN_LAZY_LOAD, span_t0, phys); + (void)loaded; + return loaded; +} + +static int try_load_region_impl(uint32_t seg, uint32_t phys) { extern uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); if (!overlay_loads_allowed()) diff --git a/runtime/tests/test_autocompile_toolchain_hash.c b/runtime/tests/test_autocompile_toolchain_hash.c new file mode 100644 index 00000000..2d6d4838 --- /dev/null +++ b/runtime/tests/test_autocompile_toolchain_hash.c @@ -0,0 +1,68 @@ +/* A staged overlay toolchain from a different emitter than the runtime writes shards under its + * own codegen hash (a cache directory the runtime never reads). autocompile_check_toolchain_hash + * must refuse it loudly (degraded_reason), accept a matching one, and stay quiet when the header + * is absent or unparseable. Build/run: ctest -R autocompile_toolchain_hash_test */ +#include "autocompile.h" +#include "overlay_loader.h" + +#define WIN32_LEAN_AND_MEAN +#include + +#include +#include +#include + +struct OverlayPreparedImage { unsigned id; }; +OverlayPreparedImage *overlay_loader_prepare_published(const char *p) { (void)p; return NULL; } +int overlay_loader_commit_published(OverlayPreparedImage *i) { (void)i; return 0; } +void overlay_loader_discard_prepared(OverlayPreparedImage *i) { (void)i; } +void overlay_loader_rescan(void) {} + +static int failures; +#define CHECK(c) do { if (!(c)) { printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); failures++; } } while (0) + +static void write_header(const char *dir, const char *body) { + char path[600]; + snprintf(path, sizeof path, "%s/overlay_codegen_hash.h", dir); + FILE *f = fopen(path, "wb"); + if (f) { fputs(body, f); fclose(f); } +} + +int main(void) { + char dir[MAX_PATH]; + GetTempPathA(sizeof dir, dir); + strcat(dir, "psx_ac_tkhash_test"); + CreateDirectoryA(dir, NULL); + + /* No header: unknown -> usable, nothing recorded. */ + char missing[MAX_PATH + 16]; + snprintf(missing, sizeof missing, "%s/nope", dir); + CHECK(autocompile_check_toolchain_hash(missing, 0xBA11DD86u) == 1); + CHECK(autocompile_degraded_reason() == NULL); + + /* Unparseable header and runtime hash 0 (unknown build): usable. */ + write_header(dir, "// nothing here\n"); + CHECK(autocompile_check_toolchain_hash(dir, 0xBA11DD86u) == 1); + write_header(dir, "#define PSX_OVERLAY_CODEGEN_HASH 0x0bea7895u\n"); + CHECK(autocompile_check_toolchain_hash(dir, 0u) == 1); + CHECK(autocompile_degraded_reason() == NULL); + + /* Matching hash: usable. */ + write_header(dir, "#pragma once\n#define PSX_OVERLAY_CODEGEN_HASH 0xba11dd86u\n"); + CHECK(autocompile_check_toolchain_hash(dir, 0xBA11DD86u) == 1); + CHECK(autocompile_degraded_reason() == NULL); + + /* The 2026-10-12 playtest case: toolchain from an older emitter. */ + write_header(dir, "#pragma once\n#define PSX_OVERLAY_CODEGEN_HASH 0x0bea7895u\n"); + CHECK(autocompile_check_toolchain_hash(dir, 0xBA11DD86u) == 0); + const char *why = autocompile_degraded_reason(); + CHECK(why != NULL); + CHECK(why && strstr(why, "0x0bea7895") && strstr(why, "0xba11dd86") && strstr(why, "stale overlay toolchain")); + char json[4096]; + autocompile_status_json(json, (int)sizeof json); + CHECK(strstr(json, "\"degraded\":1") != NULL); + + if (failures) { printf("%d failure(s)\n", failures); return 1; } + printf("autocompile_toolchain_hash_test: ok\n"); + return 0; +} diff --git a/runtime/tests/test_compile_overlays_publish_marker.py b/runtime/tests/test_compile_overlays_publish_marker.py new file mode 100644 index 00000000..ba63a605 --- /dev/null +++ b/runtime/tests/test_compile_overlays_publish_marker.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""publish_shard_pair announces each committed shard as `PSX_SHARD_PUBLISHED ` -- but only to a +runtime that asked (PSX_OVERLAY_LIVE_AUTOCOMPILE=1). The runtime's publication pipeline +(autocompile.c) consumes the line; without it every shard compiled during play is first mapped by +the emulation thread (300 ms LoadLibrary at the first dispatch miss, measured).""" +import contextlib, importlib.util, io, os, sys, tempfile +from pathlib import Path + +root = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('compile_overlays', root / 'tools' / 'compile_overlays.py') +co = importlib.util.module_from_spec(spec) +sys.modules['compile_overlays'] = co +sys.path.insert(0, str(root / 'tools')) +spec.loader.exec_module(co) + + +def run(live): + if live: + os.environ['PSX_OVERLAY_LIVE_AUTOCOMPILE'] = '1' + else: + os.environ.pop('PSX_OVERLAY_LIVE_AUTOCOMPILE', None) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + co.announce_published_shard(str(Path(tempfile.gettempdir()) / '00012000_DEADBEEF.dll')) + return buf.getvalue() + + +quiet = run(False) +assert quiet == '', f'offline compile must keep stdout quiet, got {quiet!r}' +loud = run(True) +line = loud.strip() +assert line.startswith('PSX_SHARD_PUBLISHED '), loud +assert line.endswith('00012000_DEADBEEF.dll'), loud +assert os.path.isabs(line[len('PSX_SHARD_PUBLISHED '):]), 'path must be absolute (runtime resolves it)' +assert loud.count('\n') == 1 +# The announcement sits on the success path of the single commit point. +src = (root / 'tools' / 'compile_overlays.py').read_text(encoding='utf-8') +body = src[src.index('def publish_shard_pair('):src.index('def _dll_abi_matches(')] +assert 'announce_published_shard(dll)' in body and body.index('announce_published_shard(dll)') > body.index('os.replace(staged_dll, dll)') +print('publish marker: ok') diff --git a/runtime/tests/test_host_span_ring.c b/runtime/tests/test_host_span_ring.c new file mode 100644 index 00000000..4aa1d9ef --- /dev/null +++ b/runtime/tests/test_host_span_ring.c @@ -0,0 +1,88 @@ +/* test_host_span_ring.c - the always-on wall-time rings (host_span_ring.h): + * span filter, emulation-thread attribution, ring wrap, frame stamps. */ +#include "host_span_ring.h" + +#include +#include + +#ifdef _WIN32 +#include +static void sleep_ms(unsigned ms) { Sleep(ms); } +typedef HANDLE Thr; +static DWORD WINAPI bg_main(LPVOID p); +static Thr thr_start(void) { return CreateThread(NULL, 0, bg_main, NULL, 0, NULL); } +static void thr_join(Thr t) { WaitForSingleObject(t, INFINITE); CloseHandle(t); } +static DWORD WINAPI bg_main(LPVOID p) { + (void)p; + uint64_t t0 = host_span_begin(); + sleep_ms(3); + host_span_end(HSPAN_AC_CHILD, t0, 7); + return 0; +} +#else +#include +#include +static void sleep_ms(unsigned ms) { usleep(ms * 1000u); } +typedef pthread_t Thr; +static void *bg_main(void *p) { + (void)p; + uint64_t t0 = host_span_begin(); + sleep_ms(3); + host_span_end(HSPAN_AC_CHILD, t0, 7); + return NULL; +} +static Thr thr_start(void) { pthread_t t; pthread_create(&t, NULL, bg_main, NULL); return t; } +static void thr_join(Thr t) { pthread_join(t, NULL); } +#endif + +static int failures; +#define CHECK(c) do { if (!(c)) { printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); failures++; } } while (0) + +int main(void) { + /* Frame stamps: monotonic, carry frame number and interpreter count. */ + host_frame_stamp(100, 11); + sleep_ms(2); + host_frame_stamp(101, 25); + CHECK(host_frame_stamp_total() == 2); + uint32_t fr = 0, ii = 0; uint64_t t_a = 0, t_b = 0; + CHECK(host_frame_stamp_get(0, &fr, &t_a, &ii) && fr == 100 && ii == 11); + CHECK(host_frame_stamp_get(1, &fr, &t_b, &ii) && fr == 101 && ii == 25); + CHECK(t_b >= t_a + 1500); /* ~2 ms apart */ + CHECK(!host_frame_stamp_get(2, &fr, &t_a, &ii)); /* not written yet */ + + /* A span shorter than HOST_SPAN_MIN_US is dropped; a long one is kept and tagged emu. */ + uint64_t s0 = host_span_total(); + host_span_record(HSPAN_PROVIDER_POLL, host_clock_us(), HOST_SPAN_MIN_US - 1, 1); + CHECK(host_span_total() == s0); + uint64_t t0 = host_span_begin(); + sleep_ms(2); + host_span_end(HSPAN_LOADER_RESCAN, t0, 42); + CHECK(host_span_total() == s0 + 1); + HostSpan sp; + CHECK(host_span_get(s0, &sp)); + CHECK(sp.kind == HSPAN_LOADER_RESCAN && sp.arg == 42 && sp.emu == 1 && sp.frame == 101); + CHECK(sp.dur_us >= 1500 && sp.t0_us == t0); + + /* A span from another thread is attributed to the background. */ + Thr t = thr_start(); + thr_join(t); + CHECK(host_span_total() == s0 + 2); + CHECK(host_span_get(s0 + 1, &sp)); + CHECK(sp.kind == HSPAN_AC_CHILD && sp.emu == 0 && sp.arg == 7); + + /* Ring wrap: old sequence numbers age out, newest are readable. */ + for (unsigned i = 0; i < HOST_SPAN_RING_CAP + 10; i++) + host_span_record(HSPAN_MOD_WORK, 1000 + i, 100, i); + uint64_t total = host_span_total(); + CHECK(!host_span_get(s0, &sp)); /* evicted */ + CHECK(host_span_get(total - 1, &sp) && sp.arg == HOST_SPAN_RING_CAP + 9); + CHECK(host_span_get(total - HOST_SPAN_RING_CAP, &sp)); /* oldest still held */ + CHECK(!host_span_get(total, &sp)); + + CHECK(host_span_kind_name(HSPAN_PUBLISH_COMMIT)[0] == 'p'); + CHECK(host_span_kind_name(9999)[0] == '?'); + + if (failures) { printf("%d failure(s)\n", failures); return 1; } + printf("host_span_ring_test: ok\n"); + return 0; +} diff --git a/tools/compile_overlays.py b/tools/compile_overlays.py index 372125c5..ff1a8b70 100644 --- a/tools/compile_overlays.py +++ b/tools/compile_overlays.py @@ -5979,6 +5979,22 @@ def _runtime_valid_shard_pair_locked(dll: str, ranges: str, {entry for entry, _crc, _ranges in funcs})) +def announce_published_shard(dll: str) -> None: + """Tell a live runtime that `dll` (with its .ranges) is committed and loadable. + + The runtime's autocompile pipeline (runtime/src/autocompile.c) maps each announced + shard on a worker thread while the compiler is still running and commits it between + frames. Without this line every shard compiled during play is first mapped by the + EMULATION thread at its first dispatch miss: a synchronous LoadLibrary (measured + 300 ms on a cold Windows file, antivirus included) plus the batch-end cache rescan. + Only a runtime that asked for it (PSX_OVERLAY_LIVE_AUTOCOMPILE=1, set by + autocompile_request) receives the line; offline builds keep a quiet stdout. + """ + if os.environ.get('PSX_OVERLAY_LIVE_AUTOCOMPILE') != '1': + return + print(f'PSX_SHARD_PUBLISHED {os.path.abspath(dll)}', flush=True) + + def publish_shard_pair(staged_dll: str, staged_ranges: str, final_dll: str, preserve_existing: bool = False, expected_existing_abi: int | None = None, @@ -6065,6 +6081,7 @@ def publish_shard_pair(staged_dll: str, staged_ranges: str, except BaseException: _recover_shard_pair_locked(dll) raise + announce_published_shard(dll) return True From 7d685071272bb8dc42b1421dca60514e9b56d933 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 13:56:50 -0700 Subject: [PATCH 14/17] runtime: frame stamps carry static-dispatch counts; host_profile reports modules and frame windows Co-Authored-By: Claude Sonnet 5.5 --- runtime/include/host_span_ring.h | 9 ++++++--- runtime/src/debug_server.c | 14 +++++++------- runtime/src/host_span_ring.c | 9 ++++++--- runtime/src/main.cpp | 13 ++++++++++++- runtime/tests/test_host_span_ring.c | 12 ++++++------ 5 files changed, 37 insertions(+), 20 deletions(-) diff --git a/runtime/include/host_span_ring.h b/runtime/include/host_span_ring.h index 1eb6988d..a101e6b1 100644 --- a/runtime/include/host_span_ring.h +++ b/runtime/include/host_span_ring.h @@ -75,10 +75,13 @@ const char *host_span_kind_name(unsigned kind); /* Stamp guest frame `frame` at the current wall time. Also marks the calling * thread as the emulation thread for span attribution. Call once per frame. * interp_insns is the low 32 bits of the cumulative count of guest instructions run by the - * interpreter (successive differences = interpreter work in that frame). */ -void host_frame_stamp(uint32_t frame, uint32_t interp_insns); + * interpreter (successive differences = interpreter work in that frame); static_checks likewise + * counts static-AOT dispatch checks (guest calls into statically compiled code), the work a native + * stretch of guest code between two vblanks does. */ +void host_frame_stamp(uint32_t frame, uint32_t interp_insns, uint32_t static_checks); uint64_t host_frame_stamp_total(void); -int host_frame_stamp_get(uint64_t seq, uint32_t *frame, uint64_t *t_us, uint32_t *interp_insns); +int host_frame_stamp_get(uint64_t seq, uint32_t *frame, uint64_t *t_us, uint32_t *interp_insns, + uint32_t *static_checks); #define HOST_FRAME_STAMP_CAP 131072u #ifdef __cplusplus diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 6a7a9eea..dd6ee75e 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -5538,7 +5538,7 @@ static void handle_host_profile(int id, const char *json) * {"cmd":"host_span_ring","from":SEQ,"n":N} spans with seq >= from (default: oldest) * -> {total, oldest, events:[[seq, kind, t0_us, dur_us, frame, emu, arg], ...]} * {"cmd":"frame_wall_ring","from":SEQ,"n":N} per-guest-frame wall stamps - * -> {total, oldest, now_us, f:[frame, t_us, interp_insns_lo32, ...]} + * -> {total, oldest, now_us, f:[frame, t_us, interp_insns_lo32, static_checks_lo32, ...]} * Both read a window that already happened; nothing is armed. */ static void handle_host_span_ring(int id, const char *json) { @@ -5581,7 +5581,7 @@ static void handle_frame_wall_ring(int id, const char *json) if (n > (int)HOST_FRAME_STAMP_CAP) n = (int)HOST_FRAME_STAMP_CAP; uint64_t from = from_i >= 0 ? (uint64_t)from_i : oldest; if (from < oldest) from = oldest; - size_t cap = 192 + (size_t)n * 40; + size_t cap = 192 + (size_t)n * 52; char *buf = (char *)malloc(cap); if (!buf) { send_err(id, "oom"); return; } size_t pos = (size_t)snprintf(buf, cap, @@ -5589,11 +5589,11 @@ static void handle_frame_wall_ring(int id, const char *json) id, (unsigned long long)total, (unsigned long long)oldest, (unsigned long long)from, (unsigned long long)host_clock_us()); int first = 1, emitted = 0; - for (uint64_t q = from; q < total && emitted < n && pos + 64 < cap; q++) { - uint32_t fr, ii; uint64_t t; - if (!host_frame_stamp_get(q, &fr, &t, &ii)) continue; - pos += (size_t)snprintf(buf + pos, cap - pos, "%s%u,%llu,%u", first ? "" : ",", fr, - (unsigned long long)t, ii); + for (uint64_t q = from; q < total && emitted < n && pos + 80 < cap; q++) { + uint32_t fr, ii, ck; uint64_t t; + if (!host_frame_stamp_get(q, &fr, &t, &ii, &ck)) continue; + pos += (size_t)snprintf(buf + pos, cap - pos, "%s%u,%llu,%u,%u", first ? "" : ",", fr, + (unsigned long long)t, ii, ck); first = 0; emitted++; } snprintf(buf + pos, cap - pos, "]}"); diff --git a/runtime/src/host_span_ring.c b/runtime/src/host_span_ring.c index e7684a32..a43cada9 100644 --- a/runtime/src/host_span_ring.c +++ b/runtime/src/host_span_ring.c @@ -33,7 +33,7 @@ static _Atomic uint32_t s_cur_frame; static HostSpan s_spans[HOST_SPAN_RING_CAP]; static _Atomic uint64_t s_span_seq; -typedef struct { uint64_t t_us; uint32_t frame; uint32_t interp; } FrameStamp; +typedef struct { uint64_t t_us; uint32_t frame; uint32_t interp; uint32_t checks; uint32_t pad; } FrameStamp; static FrameStamp s_frames[HOST_FRAME_STAMP_CAP]; static _Atomic uint64_t s_frame_seq; @@ -89,7 +89,7 @@ const char *host_span_kind_name(unsigned kind) { return kind < HSPAN_KIND_COUNT ? names[kind] : "?"; } -void host_frame_stamp(uint32_t frame, uint32_t interp_insns) { +void host_frame_stamp(uint32_t frame, uint32_t interp_insns, uint32_t static_checks) { atomic_store_explicit(&s_emu_tid, this_thread_id(), memory_order_relaxed); atomic_store_explicit(&s_cur_frame, frame, memory_order_relaxed); uint64_t n = atomic_fetch_add_explicit(&s_frame_seq, 1, memory_order_relaxed); @@ -97,16 +97,19 @@ void host_frame_stamp(uint32_t frame, uint32_t interp_insns) { f->t_us = host_clock_us(); f->frame = frame; f->interp = interp_insns; + f->checks = static_checks; } uint64_t host_frame_stamp_total(void) { return atomic_load_explicit(&s_frame_seq, memory_order_relaxed); } -int host_frame_stamp_get(uint64_t seq, uint32_t *frame, uint64_t *t_us, uint32_t *interp_insns) { +int host_frame_stamp_get(uint64_t seq, uint32_t *frame, uint64_t *t_us, uint32_t *interp_insns, + uint32_t *static_checks) { uint64_t total = host_frame_stamp_total(); if (seq >= total || total - seq > HOST_FRAME_STAMP_CAP) return 0; const FrameStamp *f = &s_frames[seq % HOST_FRAME_STAMP_CAP]; if (frame) *frame = f->frame; if (t_us) *t_us = f->t_us; if (interp_insns) *interp_insns = f->interp; + if (static_checks) *static_checks = f->checks; return 1; } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 4e9068f6..e5b5ba43 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -46,6 +46,9 @@ #include "autocompile.h" #include "code_provider.h" #include "host_span_ring.h" +#ifdef PSX_HAS_OVERLAY_DISPATCH +extern "C" void psx_overlay_static_get_stats(uint64_t *, uint64_t *, uint64_t *, uint64_t *); +#endif extern "C" void psx_event_step_conservative_env_init(void); #include "overlay_backend.h" #include "gpu.h" @@ -8349,7 +8352,15 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { #endif /* Always-on wall-time stamp for this guest frame (host_span_ring.h). */ extern uint64_t s_frame_count; - host_frame_stamp((uint32_t)s_frame_count, (uint32_t)g_dirty_ram_insns_run); + uint32_t stamp_static_checks = 0; +#ifdef PSX_HAS_OVERLAY_DISPATCH + { + uint64_t sc = 0, sh = 0, sv = 0, sa = 0; + psx_overlay_static_get_stats(&sc, &sh, &sv, &sa); + stamp_static_checks = (uint32_t)sc; + } +#endif + host_frame_stamp((uint32_t)s_frame_count, (uint32_t)g_dirty_ram_insns_run, stamp_static_checks); psx_local_mouse_begin(sdl_window, local_mouse_live(override)); { diff --git a/runtime/tests/test_host_span_ring.c b/runtime/tests/test_host_span_ring.c index 4aa1d9ef..21c6e03d 100644 --- a/runtime/tests/test_host_span_ring.c +++ b/runtime/tests/test_host_span_ring.c @@ -40,15 +40,15 @@ static int failures; int main(void) { /* Frame stamps: monotonic, carry frame number and interpreter count. */ - host_frame_stamp(100, 11); + host_frame_stamp(100, 11, 5); sleep_ms(2); - host_frame_stamp(101, 25); + host_frame_stamp(101, 25, 9); CHECK(host_frame_stamp_total() == 2); - uint32_t fr = 0, ii = 0; uint64_t t_a = 0, t_b = 0; - CHECK(host_frame_stamp_get(0, &fr, &t_a, &ii) && fr == 100 && ii == 11); - CHECK(host_frame_stamp_get(1, &fr, &t_b, &ii) && fr == 101 && ii == 25); + uint32_t fr = 0, ii = 0, ck = 0; uint64_t t_a = 0, t_b = 0; + CHECK(host_frame_stamp_get(0, &fr, &t_a, &ii, &ck) && fr == 100 && ii == 11 && ck == 5); + CHECK(host_frame_stamp_get(1, &fr, &t_b, &ii, &ck) && fr == 101 && ii == 25 && ck == 9); CHECK(t_b >= t_a + 1500); /* ~2 ms apart */ - CHECK(!host_frame_stamp_get(2, &fr, &t_a, &ii)); /* not written yet */ + CHECK(!host_frame_stamp_get(2, &fr, &t_a, &ii, &ck)); /* not written yet */ /* A span shorter than HOST_SPAN_MIN_US is dropped; a long one is kept and tagged emu. */ uint64_t s0 = host_span_total(); From 2cbca744837a2f3534f91ce154c99e36dd6e777d Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 16:48:56 -0700 Subject: [PATCH 15/17] Make hitch rings concurrency safe and attribute disc read stalls --- runtime/CMakeLists.txt | 2 + runtime/include/host_span_ring.h | 12 ++-- runtime/src/autocompile.c | Bin 68509 -> 68510 bytes runtime/src/debug_server.c | 16 ++--- runtime/src/host_span_ring.c | 99 +++++++++++++++++++++++----- runtime/src/iso_reader_c.cpp | 11 +++- runtime/tests/test_host_span_ring.c | 49 ++++++++++++-- 7 files changed, 156 insertions(+), 33 deletions(-) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 77567f53..8aa5ea9c 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1199,6 +1199,7 @@ if(BUILD_TESTING) add_executable(mod_runtime_test tests/test_mod_runtime.cpp src/iso_reader_c.cpp + src/host_span_ring.c src/mod_runtime.cpp src/mod_netplay.c src/mod_packages.cpp @@ -1245,6 +1246,7 @@ if(BUILD_TESTING) add_executable(netplay_view_mods_test tests/test_netplay_view_mods.cpp src/iso_reader_c.cpp + src/host_span_ring.c src/mod_runtime.cpp src/mod_netplay.c src/mod_packages.cpp diff --git a/runtime/include/host_span_ring.h b/runtime/include/host_span_ring.h index a101e6b1..4026a07b 100644 --- a/runtime/include/host_span_ring.h +++ b/runtime/include/host_span_ring.h @@ -15,8 +15,9 @@ * spans show the work that fed them. * * Wall time is microseconds since the first call in the process (monotonic). - * Writers may be different threads: slots are claimed with an atomic counter - * and a torn read blurs one diagnostic row only. + * Writers may be different threads. Per-slot ownership and publication sequence + * validation prevent torn or overwritten rows. Contended samples are dropped; + * diagnostics never wait for a reader to release a slot. * * Reading: TCP "host_span_ring" / "frame_wall_ring"; a title's hitch report * tool joins them (LegendOfDragoonRecomp tools/lod_hitch_report.py). */ @@ -43,6 +44,7 @@ typedef enum HostSpanKind { HSPAN_OVERLAY_DISPATCH, /* emu: overlay_loader_dispatch slow path */ HSPAN_MOD_WORK, /* emu: resident pack / mod host work */ HSPAN_FRAME_PRESENT, /* emu: frontend present for the frame */ + HSPAN_DISC_READ, /* emu: one sector read from the disc image (arg lba) */ HSPAN_KIND_COUNT } HostSpanKind; @@ -77,11 +79,13 @@ const char *host_span_kind_name(unsigned kind); * interp_insns is the low 32 bits of the cumulative count of guest instructions run by the * interpreter (successive differences = interpreter work in that frame); static_checks likewise * counts static-AOT dispatch checks (guest calls into statically compiled code), the work a native - * stretch of guest code between two vblanks does. */ + * stretch of guest code between two vblanks does. Each stamp also records the process's cumulative + * page-fault count and disk-read volume (KiB), so a frame that waited on the OS (code or data pages + * paged in from disk) is told apart from one that computed. */ void host_frame_stamp(uint32_t frame, uint32_t interp_insns, uint32_t static_checks); uint64_t host_frame_stamp_total(void); int host_frame_stamp_get(uint64_t seq, uint32_t *frame, uint64_t *t_us, uint32_t *interp_insns, - uint32_t *static_checks); + uint32_t *static_checks, uint32_t *page_faults, uint32_t *io_read_kb); #define HOST_FRAME_STAMP_CAP 131072u #ifdef __cplusplus diff --git a/runtime/src/autocompile.c b/runtime/src/autocompile.c index da1eb7749e4c22de42b48ee139017d644d5d368a..22842246a0320d3abee7019e847240e341f4ce7a 100644 GIT binary patch delta 24 gcmbO`on_v1mJP;otT6`a)?AbSnTT!nl?!+a0BMs5rT_o{ delta 23 fcmbO?on`KHmJP;oEDY+_T$BHsh;8= from (default: oldest) * -> {total, oldest, events:[[seq, kind, t0_us, dur_us, frame, emu, arg], ...]} * {"cmd":"frame_wall_ring","from":SEQ,"n":N} per-guest-frame wall stamps - * -> {total, oldest, now_us, f:[frame, t_us, interp_insns_lo32, static_checks_lo32, ...]} + * -> {total, oldest, now_us, f:[frame, t_us, interp_insns_lo32, static_checks_lo32, page_faults, io_read_kb, ...]} * Both read a window that already happened; nothing is armed. */ static void handle_host_span_ring(int id, const char *json) { @@ -5581,19 +5581,19 @@ static void handle_frame_wall_ring(int id, const char *json) if (n > (int)HOST_FRAME_STAMP_CAP) n = (int)HOST_FRAME_STAMP_CAP; uint64_t from = from_i >= 0 ? (uint64_t)from_i : oldest; if (from < oldest) from = oldest; - size_t cap = 192 + (size_t)n * 52; + size_t cap = 192 + (size_t)n * 64; char *buf = (char *)malloc(cap); if (!buf) { send_err(id, "oom"); return; } size_t pos = (size_t)snprintf(buf, cap, - "{\"id\":%d,\"ok\":true,\"total\":%llu,\"oldest\":%llu,\"from\":%llu,\"now_us\":%llu,\"f\":[", + "{\"id\":%d,\"ok\":true,\"total\":%llu,\"oldest\":%llu,\"from\":%llu,\"now_us\":%llu,\"stride\":6,\"f\":[", id, (unsigned long long)total, (unsigned long long)oldest, (unsigned long long)from, (unsigned long long)host_clock_us()); int first = 1, emitted = 0; - for (uint64_t q = from; q < total && emitted < n && pos + 80 < cap; q++) { - uint32_t fr, ii, ck; uint64_t t; - if (!host_frame_stamp_get(q, &fr, &t, &ii, &ck)) continue; - pos += (size_t)snprintf(buf + pos, cap - pos, "%s%u,%llu,%u,%u", first ? "" : ",", fr, - (unsigned long long)t, ii, ck); + for (uint64_t q = from; q < total && emitted < n && pos + 96 < cap; q++) { + uint32_t fr, ii, ck, pf, io; uint64_t t; + if (!host_frame_stamp_get(q, &fr, &t, &ii, &ck, &pf, &io)) continue; + pos += (size_t)snprintf(buf + pos, cap - pos, "%s%u,%llu,%u,%u,%u,%u", first ? "" : ",", fr, + (unsigned long long)t, ii, ck, pf, io); first = 0; emitted++; } snprintf(buf + pos, cap - pos, "]}"); diff --git a/runtime/src/host_span_ring.c b/runtime/src/host_span_ring.c index a43cada9..c1c8fd79 100644 --- a/runtime/src/host_span_ring.c +++ b/runtime/src/host_span_ring.c @@ -6,17 +6,33 @@ #ifdef _WIN32 #include +#include static uint64_t qpc_now_us(void) { - static LARGE_INTEGER freq; + static _Atomic uint64_t frequency; + uint64_t freq = atomic_load_explicit(&frequency, memory_order_relaxed); LARGE_INTEGER c; - if (!freq.QuadPart) QueryPerformanceFrequency(&freq); + if (!freq) { + LARGE_INTEGER value; + QueryPerformanceFrequency(&value); + freq = (uint64_t)value.QuadPart; + atomic_store_explicit(&frequency, freq, memory_order_relaxed); + } QueryPerformanceCounter(&c); - return (uint64_t)(c.QuadPart / freq.QuadPart) * 1000000ull + - (uint64_t)((c.QuadPart % freq.QuadPart) * 1000000ull / freq.QuadPart); + return ((uint64_t)c.QuadPart / freq) * 1000000ull + + ((uint64_t)c.QuadPart % freq) * 1000000ull / freq; } static uint64_t this_thread_id(void) { return (uint64_t)GetCurrentThreadId(); } +/* Cumulative page faults and disk bytes read by this process. */ +static void proc_counters(uint32_t *faults, uint32_t *io_kb) { + PROCESS_MEMORY_COUNTERS pmc; + IO_COUNTERS io; + *faults = 0; *io_kb = 0; + if (K32GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof pmc)) *faults = (uint32_t)pmc.PageFaultCount; + if (GetProcessIoCounters(GetCurrentProcess(), &io)) *io_kb = (uint32_t)(io.ReadTransferCount >> 10); +} #else #include +#include #include static uint64_t qpc_now_us(void) { struct timespec ts; @@ -24,19 +40,50 @@ static uint64_t qpc_now_us(void) { return (uint64_t)ts.tv_sec * 1000000ull + (uint64_t)ts.tv_nsec / 1000ull; } static uint64_t this_thread_id(void) { return (uint64_t)(uintptr_t)pthread_self(); } +static void proc_counters(uint32_t *faults, uint32_t *io_kb) { + struct rusage ru; + *faults = 0; *io_kb = 0; + if (getrusage(RUSAGE_SELF, &ru) == 0) { + *faults = (uint32_t)(ru.ru_minflt + ru.ru_majflt); + *io_kb = (uint32_t)(ru.ru_inblock / 2); /* 512-byte blocks */ + } +} #endif static _Atomic uint64_t s_origin_us; static _Atomic uint64_t s_emu_tid; static _Atomic uint32_t s_cur_frame; -static HostSpan s_spans[HOST_SPAN_RING_CAP]; +/* A claimed sequence is not necessarily published yet. Slot ownership protects + * both payload and sequence from concurrent wrap/read/write. Never wait on a + * diagnostic reader: contended samples are dropped and getters skip holes. */ +typedef struct { + _Atomic uint32_t busy; + uint64_t published; + HostSpan value; +} SpanSlot; +static SpanSlot s_spans[HOST_SPAN_RING_CAP]; static _Atomic uint64_t s_span_seq; -typedef struct { uint64_t t_us; uint32_t frame; uint32_t interp; uint32_t checks; uint32_t pad; } FrameStamp; -static FrameStamp s_frames[HOST_FRAME_STAMP_CAP]; +typedef struct { uint64_t t_us; uint32_t frame; uint32_t interp; uint32_t checks; uint32_t faults; uint32_t io_kb; uint32_t pad; } FrameStamp; +typedef struct { + _Atomic uint32_t busy; + uint64_t published; + FrameStamp value; +} FrameSlot; +static FrameSlot s_frames[HOST_FRAME_STAMP_CAP]; static _Atomic uint64_t s_frame_seq; +static int slot_acquire(_Atomic uint32_t *busy) { + uint32_t expected = 0; + return atomic_compare_exchange_strong_explicit(busy, &expected, 1, + memory_order_acquire, memory_order_relaxed); +} + +static void slot_release(_Atomic uint32_t *busy) { + atomic_store_explicit(busy, 0, memory_order_release); +} + uint64_t host_clock_us(void) { uint64_t now = qpc_now_us(); uint64_t origin = atomic_load_explicit(&s_origin_us, memory_order_relaxed); @@ -57,13 +104,19 @@ uint64_t host_span_begin(void) { return host_clock_us(); } void host_span_record(HostSpanKind kind, uint64_t t0_us, uint64_t dur_us, uint32_t arg) { if (dur_us < HOST_SPAN_MIN_US) return; uint64_t n = atomic_fetch_add_explicit(&s_span_seq, 1, memory_order_relaxed); - HostSpan *s = &s_spans[n % HOST_SPAN_RING_CAP]; + SpanSlot *slot = &s_spans[n % HOST_SPAN_RING_CAP]; + if (!slot_acquire(&slot->busy)) return; + /* A suspended older writer must not replace a newer lap of this slot. */ + if (slot->published > n) { slot_release(&slot->busy); return; } + HostSpan *s = &slot->value; s->t0_us = t0_us; s->dur_us = dur_us > 0xFFFFFFFFull ? 0xFFFFFFFFu : (uint32_t)dur_us; s->frame = atomic_load_explicit(&s_cur_frame, memory_order_relaxed); s->arg = arg; s->kind = (uint16_t)kind; s->emu = this_thread_id() == atomic_load_explicit(&s_emu_tid, memory_order_relaxed); + slot->published = n + 1; + slot_release(&slot->busy); } void host_span_end(HostSpanKind kind, uint64_t t0_us, uint32_t arg) { @@ -75,16 +128,20 @@ uint64_t host_span_total(void) { return atomic_load_explicit(&s_span_seq, memory int host_span_get(uint64_t seq, HostSpan *out) { uint64_t total = host_span_total(); - if (seq >= total || total - seq > HOST_SPAN_RING_CAP) return 0; - *out = s_spans[seq % HOST_SPAN_RING_CAP]; - return 1; + if (!out || seq >= total || total - seq > HOST_SPAN_RING_CAP) return 0; + SpanSlot *slot = &s_spans[seq % HOST_SPAN_RING_CAP]; + if (!slot_acquire(&slot->busy)) return 0; + const int ready = slot->published == seq + 1; + if (ready) *out = slot->value; + slot_release(&slot->busy); + return ready; } const char *host_span_kind_name(unsigned kind) { static const char *const names[HSPAN_KIND_COUNT] = { "none", "autocapture_tick", "provider_poll", "publish_commit", "loader_rescan", "dll_register", "lazy_load", "capture_on_dma", "capture_before_dma", "ac_request", "ac_done_join", - "ac_prepare_image", "ac_child", "overlay_dispatch", "mod_work", "frame_present", + "ac_prepare_image", "ac_child", "overlay_dispatch", "mod_work", "frame_present", "disc_read", }; return kind < HSPAN_KIND_COUNT ? names[kind] : "?"; } @@ -93,23 +150,35 @@ void host_frame_stamp(uint32_t frame, uint32_t interp_insns, uint32_t static_che atomic_store_explicit(&s_emu_tid, this_thread_id(), memory_order_relaxed); atomic_store_explicit(&s_cur_frame, frame, memory_order_relaxed); uint64_t n = atomic_fetch_add_explicit(&s_frame_seq, 1, memory_order_relaxed); - FrameStamp *f = &s_frames[n % HOST_FRAME_STAMP_CAP]; + FrameSlot *slot = &s_frames[n % HOST_FRAME_STAMP_CAP]; + if (!slot_acquire(&slot->busy)) return; + if (slot->published > n) { slot_release(&slot->busy); return; } + FrameStamp *f = &slot->value; f->t_us = host_clock_us(); f->frame = frame; f->interp = interp_insns; f->checks = static_checks; + proc_counters(&f->faults, &f->io_kb); + slot->published = n + 1; + slot_release(&slot->busy); } uint64_t host_frame_stamp_total(void) { return atomic_load_explicit(&s_frame_seq, memory_order_relaxed); } int host_frame_stamp_get(uint64_t seq, uint32_t *frame, uint64_t *t_us, uint32_t *interp_insns, - uint32_t *static_checks) { + uint32_t *static_checks, uint32_t *page_faults, uint32_t *io_read_kb) { uint64_t total = host_frame_stamp_total(); if (seq >= total || total - seq > HOST_FRAME_STAMP_CAP) return 0; - const FrameStamp *f = &s_frames[seq % HOST_FRAME_STAMP_CAP]; + FrameSlot *slot = &s_frames[seq % HOST_FRAME_STAMP_CAP]; + if (!slot_acquire(&slot->busy)) return 0; + if (slot->published != seq + 1) { slot_release(&slot->busy); return 0; } + const FrameStamp *f = &slot->value; if (frame) *frame = f->frame; if (t_us) *t_us = f->t_us; if (interp_insns) *interp_insns = f->interp; if (static_checks) *static_checks = f->checks; + if (page_faults) *page_faults = f->faults; + if (io_read_kb) *io_read_kb = f->io_kb; + slot_release(&slot->busy); return 1; } diff --git a/runtime/src/iso_reader_c.cpp b/runtime/src/iso_reader_c.cpp index 78c63ba6..f26415c9 100644 --- a/runtime/src/iso_reader_c.cpp +++ b/runtime/src/iso_reader_c.cpp @@ -4,6 +4,7 @@ * Provides iso_open() / iso_read_sector() / iso_close() for use by cdrom.c */ +#include "host_span_ring.h" #include "iso_reader.h" #include "mod_runtime.h" #include @@ -23,7 +24,10 @@ void* iso_open(const char* path) { int iso_read_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { if (!handle || !buffer || size < 2048) return 0; auto* reader = static_cast(handle); - if (!mod_runtime_read_disc_extent(lba, 0, buffer, size) && !reader->ReadSector(lba, buffer)) return 0; + const uint64_t span_t0 = host_span_begin(); + const bool read_ok = mod_runtime_read_disc_extent(lba, 0, buffer, size) || reader->ReadSector(lba, buffer); + host_span_end(HSPAN_DISC_READ, span_t0, lba); /* a stalled disk shows here, not as an unexplained frame */ + if (!read_ok) return 0; mod_runtime_patch_disc_sector(lba, 0, buffer, 2048); return 1; } @@ -31,7 +35,10 @@ int iso_read_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { int iso_read_raw_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { if (!handle || !buffer || size < 2352) return 0; auto* reader = static_cast(handle); - if (!mod_runtime_read_disc_extent(lba, 1, buffer, size) && !reader->ReadRawSector(lba, buffer)) return 0; + const uint64_t span_t0 = host_span_begin(); + const bool read_ok = mod_runtime_read_disc_extent(lba, 1, buffer, size) || reader->ReadRawSector(lba, buffer); + host_span_end(HSPAN_DISC_READ, span_t0, lba); + if (!read_ok) return 0; mod_runtime_patch_disc_sector(lba, 1, buffer, 2352); return 1; } diff --git a/runtime/tests/test_host_span_ring.c b/runtime/tests/test_host_span_ring.c index 21c6e03d..cd59165d 100644 --- a/runtime/tests/test_host_span_ring.c +++ b/runtime/tests/test_host_span_ring.c @@ -4,6 +4,14 @@ #include #include +#include + +static _Atomic unsigned writers_done; +static void race_write(void) { + for (unsigned i = 1; i <= HOST_SPAN_RING_CAP * 3u; ++i) + host_span_record(HSPAN_DISC_READ, (uint64_t)i * 1000u, 50u + i, i); + atomic_fetch_add(&writers_done, 1u); +} #ifdef _WIN32 #include @@ -12,6 +20,8 @@ typedef HANDLE Thr; static DWORD WINAPI bg_main(LPVOID p); static Thr thr_start(void) { return CreateThread(NULL, 0, bg_main, NULL, 0, NULL); } static void thr_join(Thr t) { WaitForSingleObject(t, INFINITE); CloseHandle(t); } +static DWORD WINAPI race_main(LPVOID p) { (void)p; race_write(); return 0; } +static Thr race_start(void) { return CreateThread(NULL, 0, race_main, NULL, 0, NULL); } static DWORD WINAPI bg_main(LPVOID p) { (void)p; uint64_t t0 = host_span_begin(); @@ -33,6 +43,8 @@ static void *bg_main(void *p) { } static Thr thr_start(void) { pthread_t t; pthread_create(&t, NULL, bg_main, NULL); return t; } static void thr_join(Thr t) { pthread_join(t, NULL); } +static void *race_main(void *p) { (void)p; race_write(); return NULL; } +static Thr race_start(void) { pthread_t t; pthread_create(&t, NULL, race_main, NULL); return t; } #endif static int failures; @@ -44,11 +56,11 @@ int main(void) { sleep_ms(2); host_frame_stamp(101, 25, 9); CHECK(host_frame_stamp_total() == 2); - uint32_t fr = 0, ii = 0, ck = 0; uint64_t t_a = 0, t_b = 0; - CHECK(host_frame_stamp_get(0, &fr, &t_a, &ii, &ck) && fr == 100 && ii == 11 && ck == 5); - CHECK(host_frame_stamp_get(1, &fr, &t_b, &ii, &ck) && fr == 101 && ii == 25 && ck == 9); + uint32_t fr = 0, ii = 0, ck = 0, pf = 0, io = 0; uint64_t t_a = 0, t_b = 0; + CHECK(host_frame_stamp_get(0, &fr, &t_a, &ii, &ck, &pf, &io) && fr == 100 && ii == 11 && ck == 5); + CHECK(host_frame_stamp_get(1, &fr, &t_b, &ii, &ck, &pf, &io) && fr == 101 && ii == 25 && ck == 9); CHECK(t_b >= t_a + 1500); /* ~2 ms apart */ - CHECK(!host_frame_stamp_get(2, &fr, &t_a, &ii, &ck)); /* not written yet */ + CHECK(!host_frame_stamp_get(2, &fr, &t_a, &ii, &ck, &pf, &io)); /* not written yet */ /* A span shorter than HOST_SPAN_MIN_US is dropped; a long one is kept and tagged emu. */ uint64_t s0 = host_span_total(); @@ -82,6 +94,35 @@ int main(void) { CHECK(host_span_kind_name(HSPAN_PUBLISH_COMMIT)[0] == 'p'); CHECK(host_span_kind_name(9999)[0] == '?'); + /* Multiple writers wrap the ring while the emulation thread reads it. + * A row may be unavailable, but an available row must be internally + * consistent; reserving a sequence must not publish stale slot bytes. */ + uint64_t race_begin = host_span_total(), reads = 0; + Thr a = race_start(), b = race_start(); + while (atomic_load(&writers_done) < 2u) { + uint64_t end = host_span_total(); + uint64_t begin = end > 256u ? end - 256u : race_begin; + if (begin < race_begin) begin = race_begin; + for (uint64_t q = begin; q < end; ++q) { + if (!host_span_get(q, &sp)) continue; + CHECK(sp.kind == HSPAN_DISC_READ && sp.emu == 0); + CHECK(sp.t0_us == (uint64_t)sp.arg * 1000u); + CHECK(sp.dur_us == 50u + sp.arg); + ++reads; + } + } + thr_join(a); thr_join(b); + CHECK(reads != 0); + + /* The frame ring wraps independently and preserves every counter. */ + for (unsigned i = 0; i < HOST_FRAME_STAMP_CAP + 2u; ++i) + host_frame_stamp(i + 200u, i * 3u, i * 5u); + total = host_frame_stamp_total(); + CHECK(!host_frame_stamp_get(0, NULL, NULL, NULL, NULL, NULL, NULL)); + CHECK(host_frame_stamp_get(total - 1, &fr, &t_a, &ii, &ck, &pf, &io)); + CHECK(fr == HOST_FRAME_STAMP_CAP + 201u); + CHECK(ii == (HOST_FRAME_STAMP_CAP + 1u) * 3u && ck == (HOST_FRAME_STAMP_CAP + 1u) * 5u); + if (failures) { printf("%d failure(s)\n", failures); return 1; } printf("host_span_ring_test: ok\n"); return 0; From 7d985d13bcb59493ea77d9755bc9edeaa14fa7a0 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 16:58:30 -0700 Subject: [PATCH 16/17] Document frame ring schema and limits of hitch attribution --- docs/HOST_WALL_TIME_RINGS.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/HOST_WALL_TIME_RINGS.md b/docs/HOST_WALL_TIME_RINGS.md index efb0305a..f1be8b8b 100644 --- a/docs/HOST_WALL_TIME_RINGS.md +++ b/docs/HOST_WALL_TIME_RINGS.md @@ -1,25 +1,33 @@ # Always-on host wall-time rings (hitch attribution) -A gameplay hitch is wall time the emulation thread did not spend running the guest. These rings -record where it went, continuously from process start in every build, so a probe never arms +A gameplay hitch is an unusually long frame, whether from guest execution, host work, or waiting. +These rings record where the time went, continuously from process start in every build, so a probe never arms anything: it queries a window that already happened. | Ring | TCP command | Holds | |------|-------------|-------| -| frame stamps | `frame_wall_ring {"from":SEQ,"n":N}` | one `[guest frame, wall us, interpreter insns (low 32 bits)]` per guest frame, 131072 frames (~36 min at 60 Hz) | +| frame stamps | `frame_wall_ring {"from":SEQ,"n":N}` | flat `f` array with `stride: 6`: `[guest frame, wall us, interpreter insns, static checks, process page faults, process read KiB]`; counters use their low 32 bits; 131072 frames (~36 min at 60 Hz) | | spans | `host_span_ring {"from":SEQ,"n":N}` | `[seq, kind, t0_us, dur_us, frame, emu, arg]` for every named blocking-capable operation of 50 us or more, 65536 spans | | host CPU sampler (debug-tools builds) | `host_profile {"frame_lo":A,"frame_hi":B}` | 1 kHz instruction-pointer samples of the emulation thread, 1 Mi samples (~17 min), with per-module totals for samples outside the exe | Wall time is microseconds since the first ring call. `emu` is 1 for the emulation thread, 0 for a worker (compiler child, publication preparer). +Readers and writers use nonblocking slot ownership. A contended sample may be dropped or skipped; +readers only return fully published records from the requested ring generation. A missing frame +stamp leaves an interval spanning multiple frames: do not report that interval as one long frame. +Legacy frame captures had three or four columns and no `stride`; consumers must identify their +layout before interpreting them. Process counters include worker threads, and page faults include +soft faults, so neither counter alone proves a blocking disk read. + Span kinds (`runtime/include/host_span_ring.h`): `autocapture_tick`, `provider_poll`, `publish_commit`, `loader_rescan`, `dll_register`, `lazy_load` (a synchronous first map of a shard at a dispatch miss), `capture_on_dma`, `capture_before_dma`, `ac_request` (spawning the compiler), -`ac_done_join`, `ac_prepare_image` (worker), `ac_child` (compiler lifetime, worker). +`ac_done_join`, `ac_prepare_image` (worker), `ac_child` (compiler lifetime, worker), +`overlay_dispatch`, `mod_work`, `frame_present`, and `disc_read` (cooked or raw sector read). -Reading a hitch: per frame, `wall delta - sum(emulation-thread spans overlapping the frame)` is -the time no span explains; then ask the host sampler for that frame window. A title's report tool +Reading a hitch: per frame, subtract the union of overlapping emulation-thread spans from the wall +delta to find the time no span explains; nested spans must not be counted twice. Then ask the host sampler for that frame window. A title's report tool joins them (`LegendOfDragoonRecomp/tools/lod_hitch_report.py`). Findings the rings produced (LoD, 2026-10-12): a toolchain staged from another emitter than the From f34c15f18b9d61f4659948ad23001f11dcbe8028 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 12 Oct 2026 17:06:57 -0700 Subject: [PATCH 17/17] Keep AOT dispatch observations separate from callable roots --- docs/AOT_SHARDING.md | 6 +++ tools/aot_overlay_pipeline.py | 28 ++++++++++--- tools/tests/test_aot_overlay_pipeline.py | 53 ++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/docs/AOT_SHARDING.md b/docs/AOT_SHARDING.md index 6d4c67cf..4a23127c 100644 --- a/docs/AOT_SHARDING.md +++ b/docs/AOT_SHARDING.md @@ -102,6 +102,12 @@ generic detector misses it. `entry_word` or `entries` can supply independently verified loader entry points. These facts do not turn every plausible code word into a proven function. There is no recipe option to ignore a failed compiler. +Use `dispatch_entries` for independently verified dispatch PCs that may be inside +an existing function, such as jump-table case labels. They are required by the +coverage audit but do not force new function boundaries. The existing decoder and +reachability checks decide whether each PC is a callable root or a hosted interior; +an interior declaration cannot truncate the function that owns it. + Each recipe compiles in an isolated cache. `tools/audit_aot_cache.py` checks every native pair's ABI/exports, every manifest guard against known original bytes, and nonzero native coverage for every recipe. Loader entry points supplied by diff --git a/tools/aot_overlay_pipeline.py b/tools/aot_overlay_pipeline.py index 2b490724..6322c79f 100644 --- a/tools/aot_overlay_pipeline.py +++ b/tools/aot_overlay_pipeline.py @@ -427,6 +427,9 @@ def spec_sources(disc, spec): grouped[key]['aliases'].append(name) require(grouped[key]['spec']['entries'] == placement.get('entries', []), 'Duplicate bank has conflicting declared entries') + require(grouped[key]['spec'].get('dispatch_entries', []) == + placement.get('dispatch_entries', spec.get('dispatch_entries', [])), + 'Duplicate bank has conflicting dispatch entries') continue source = dict(name=name, base=base, body=body, spec={**spec, **placement, 'entries': placement.get('entries', []), @@ -579,6 +582,19 @@ def declared_entries(source, disc): return entries +def declared_dispatch_entries(source): + """Required PCs whose observation does not establish a function boundary.""" + entries = {number(x) for x in source['spec'].get('dispatch_entries', [])} + base, size = source['base'], len(source['body']) + require(all(base <= entry < base + size and entry % 4 == 0 for entry in entries), + f"Dispatch entry outside image: {source['name']}") + return entries + + +def required_entries(source, disc): + return declared_entries(source, disc) | declared_dispatch_entries(source) + + def foreign_call_roots(source, sources): """Entries of `source` that other images call directly (`jal`). @@ -612,7 +628,7 @@ def make_fixed_record(source, disc, sources=(), cross_image=False): seeds |= entries require(seeds, f"No static entries: {source['name']}") page, data = extractor.page_aligned_region(base, body) - return extractor.rec(page, data, sorted(seeds), dispatch_extra=sorted(entries), + return extractor.rec(page, data, sorted(seeds), dispatch_extra=sorted(required_entries(source, disc)), producer_ranges=[(base, base + len(body))], static_discovery=direct | entries) @@ -693,11 +709,13 @@ def prepare(profile, disc, records, output, views=None): # found the image without finding that particular callable entry. for source in sources: entries = declared_entries(source, disc) - if entries: + dispatch = required_entries(source, disc) + if dispatch: require(source['name'] in singles, 'Declared entries need a complete source recipe') record = singles[source['name']] for key in ('function_entry_pcs', 'dispatch_entry_pcs', 'static_dispatch_entry_pcs', 'seeds'): - record[key] = [hex(pc) for pc in sorted(entries | {number(pc) for pc in record[key]})] + extra = dispatch if key in ('dispatch_entry_pcs', 'static_dispatch_entry_pcs') else entries + record[key] = [hex(pc) for pc in sorted(extra | {number(pc) for pc in record[key]})] record['static_discovery_entry_pcs'] = [hex(pc) for pc in sorted(entries | {number(pc) for pc in record.get('static_discovery_entry_pcs', [])})] by_name = {source['name']: source for source in sources} @@ -730,7 +748,7 @@ def prepare(profile, disc, records, output, views=None): if any(lo <= number(alias['start']) <= number(alias['entry']) < number(alias['end']) <= hi for lo, hi in bounds)] require(all(eligible(entry) for source, _, _ in matches - for entry in declared_entries(source, disc)), 'Excluded required loader entry') + for entry in required_entries(source, disc)), 'Excluded required loader entry') if record.get('producer') == 'bios_resident_manifest': names = ['BIOS resident manifest'] bounds = [(number(r['start']), number(r['end'])) for r in record['producer_ranges']] @@ -746,7 +764,7 @@ def prepare(profile, disc, records, output, views=None): write_json(path, [record]) jobs.append(dict(name=f'{index:03d}-' + '+'.join(names), sources=names, required_entries=sorted({entry for source, _, _ in matches - for entry in declared_entries(source, disc)}), + for entry in required_entries(source, disc)}), known_ranges=bounds, input=str(path.resolve()), load_addr=hex(load), size=len(data), sha256=hashlib.sha256(data).hexdigest(), recipe_sha256=hashlib.sha256(json.dumps(record, sort_keys=True).encode()).hexdigest(), diff --git a/tools/tests/test_aot_overlay_pipeline.py b/tools/tests/test_aot_overlay_pipeline.py index 46bf98d5..175c4be5 100644 --- a/tools/tests/test_aot_overlay_pipeline.py +++ b/tools/tests/test_aot_overlay_pipeline.py @@ -1148,6 +1148,59 @@ class CrossImageRootsTests(unittest.TestCase): self.assertEqual(pipeline.foreign_call_roots(data, [data, self.caller]), []) +class DispatchEntryTests(unittest.TestCase): + """An observed switch case is not a new function boundary.""" + + def setUp(self): + self.base = base = 0x80100000 + self.body = bytearray(0x100) + words = {0x00: 0x27BDFFF0, 0x04: 0x3C088010, 0x08: 0x250800C0, + 0x0C: 0x2C810002, 0x10: 0x10200013, 0x18: 0x00044880, + 0x1C: 0x01094021, 0x20: 0x8D0A0000, 0x28: 0x01400008, + 0x40: 0x24020001, 0x44: 0x08000000 | ((base + 0x60) >> 2 & 0x3FFFFFF), + 0x50: 0x24020002, 0x54: 0x08000000 | ((base + 0x60) >> 2 & 0x3FFFFFF), + 0x60: 0x03E00008, 0x64: 0x27BD0010, + 0xC0: base + 0x40, 0xC4: base + 0x50} + for offset, word in words.items(): + struct.pack_into('