From 9515cb7dfeee43eb8a6816fd21269306e129b2da Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:02:20 -0700 Subject: [PATCH 1/7] idle_skip: elide store-counter wait loops (PsyQ v_wait) idle_skip only skipped loops with zero stores and a single interrupt-check edge, so it never fired on PsyQ libetc's v_wait -- the VSync(0)/VSync(n) spin most PsyQ titles run each frame -- which decrements a stack timeout word every pass and crosses two block edges. New opt-in on top of idle_skip, [runtime] idle_skip_store_counters (PSX_IDLE_SKIP_EXT=0/1 for one run): - an iteration may span up to 8 block edges; the first edge is the anchor and the edge count must be stable; - one word store per iteration to the same RAM/scratchpad address whose value drops by exactly 1 (a register holding it may drop with it). Skips lower the counter by k (never to <= 1) and, for multi-edge loops, land on the anchor strictly before the next device event so an IRQ is taken at the same edge as without the skip. memory.c records the last word store's address and value for the detector. R4 race, 3600 frames from one savestate, held input: cyc, mmio, mc, sp, sc and qc identical every frame; wc differs only by the elided counter stores. Release build on an Apple M4: emulation-thread busy 47.5% -> 34.9% at 60 Hz. Co-Authored-By: Claude Opus 5.5 --- docs/config_schema.md | 14 ++++ recompiler/src/config_loader.cpp | 4 + recompiler/src/config_loader.h | 5 ++ runtime/src/main.cpp | 7 ++ runtime/src/memory.c | 9 ++ runtime/src/psx_cycles.c | 137 ++++++++++++++++++++++++++----- 6 files changed, 157 insertions(+), 19 deletions(-) diff --git a/docs/config_schema.md b/docs/config_schema.md index 4f2c8975..e22162fc 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -493,6 +493,20 @@ overlay_region_floor = "0x10000" # optional: lowest RAM address treated as ove # eligible. Clamped >= 0x10000; PSX_OVERLAY_REGION_FLOOR overrides. ``` +`idle_skip` only elides loops with no stores and one interrupt-check edge. +`idle_skip_store_counters = true` (with `idle_skip`; `PSX_IDLE_SKIP_EXT=0/1` +overrides it for one run) also elides loops that count a stack timeout down +by one each pass and span up to eight block edges: PsyQ libetc's `v_wait`, +the `VSync(0)`/`VSync(n)` spin most PsyQ titles run every frame. The skip +lowers the counter by the number of passes it skips (never to 1 or below, so +the real timeout test still runs) and lands on the loop's first edge strictly +before the next device event, so an interrupt is taken at the same edge as +without it. Guest cycles, device-register writes and scratchpad writes are +unchanged; the only difference is that the skipped passes' stores to the +counter word are not individually made (`frame_fingerprint` `wc`/`ws` count +fewer writes, its judge columns match). In R4 races it removes about a quarter +of the emulation thread's time. Off under netplay, like `idle_skip`. + ### `turbo_loads` / `offer_turbo_loads` — deprecated and ignored **Do not use these keys.** Load acceleration is owned by the Mods catalog: diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 78df8ade..e809208c 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -544,6 +544,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (runtime.contains("turbo_audio_sink")) { rt.turbo_audio_sink = toml::find(runtime, "turbo_audio_sink"); } + if (runtime.contains("idle_skip_store_counters")) { + rt.idle_skip_store_counters = + toml::find(runtime, "idle_skip_store_counters"); + } if (runtime.contains("idle_skip")) { rt.idle_skip = toml::find(runtime, "idle_skip"); } diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index ffbe6f88..90511e5f 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -354,6 +354,11 @@ struct RuntimeConfig { // device events still advance exactly. Opt-in per game; the idle_skip // debug command and PSX_IDLE_SKIP environment variable support live A/B. bool idle_skip = false; + // idle_skip_store_counters: with idle_skip, also skip loops that count a + // stack timeout down once per iteration and span several block edges -- + // PsyQ libetc's v_wait, the VSync(0) spin. Bit-exact (psx_cycles.c); + // PSX_IDLE_SKIP_EXT=0/1 overrides it for one run. + bool idle_skip_store_counters = false; // overlay_autocompile_cmd: variant-capture automation (step 2.8). A // shell command (run via cmd.exe /C, cwd = project root) that compiles diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index db52ae0b..dd792d73 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -16317,6 +16317,13 @@ int main(int argc, char** argv) { std::fprintf(stdout, "psxrecomp: idle_skip %s%s\n", g_idle_skip_enabled ? "enabled" : "disabled", idle_env ? " (environment override)" : ""); + extern int g_idle_skip_ext; + const char *ext_env = std::getenv("PSX_IDLE_SKIP_EXT"); + g_idle_skip_ext = ext_env ? (ext_env[0] == '1' ? 1 : 0) + : (gc.runtime.idle_skip_store_counters ? 1 : 0); + if (g_idle_skip_enabled && g_idle_skip_ext) + std::fprintf(stdout, "psxrecomp: idle_skip store-counter loops on%s\n", + ext_env ? " (environment override)" : ""); psx_precise_slice_init_from_env(); } for (uint32_t site : gc.vsync_event_horizon_sites) diff --git a/runtime/src/memory.c b/runtime/src/memory.c index b1e8235b..24e408b0 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1042,6 +1042,11 @@ uint64_t g_mmio_access_count = 0; * ANY store is not a pure poll loop. CPU stores and in-flight DMA writes * both funnel through the psx_write_*_raw chokepoints. */ uint64_t g_guest_store_count = 0; +/* The last guest store, for the idle-loop detector's store-counter loops + * (psx_cycles.c): its address and value when it was a word store, else + * address 0xFFFFFFFF. Two plain stores on the store path. */ +uint32_t g_guest_last_store_addr = 0xFFFFFFFFu; +uint32_t g_guest_last_store_val = 0; /* ---- Card protocol trace: tracks I_MASK bit 7 transitions ---- */ #define IMASK_TRACE_CAP 4096 @@ -1976,6 +1981,8 @@ void psx_write_word(uint32_t addr, uint32_t val) { } static void psx_write_word_raw(uint32_t addr, uint32_t val) { g_guest_store_count++; + g_guest_last_store_addr = addr; + g_guest_last_store_val = val; /* (pgxp) CPU stores carry their shadow through the PGXP_STORE hook; * DMA and host stores drop it below (pgxp_untracked_store). */ /* IsC first, before any decode (cpu_store_isolated). */ @@ -2187,6 +2194,7 @@ void psx_write_half(uint32_t addr, uint16_t val) { } static void psx_write_half_raw(uint32_t addr, uint16_t val) { g_guest_store_count++; + g_guest_last_store_addr = 0xFFFFFFFFu; if (cpu_store_isolated()) { isc_store(addr, val, 2); return; } /* KSEG2 guard — see psx_read_word_raw. */ @@ -2556,6 +2564,7 @@ void psx_host_write_byte(uint32_t addr, uint8_t val) { static void psx_write_byte_raw(uint32_t addr, uint8_t val) { g_guest_store_count++; + g_guest_last_store_addr = 0xFFFFFFFFu; if (cpu_store_isolated()) { isc_store(addr, val, 1); return; } /* KSEG2 guard — see psx_read_word_raw. */ diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index a4e39015..043409ff 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -12,6 +12,7 @@ #include "dma.h" #include "interrupts.h" #include "sio.h" +#include "psx_memory.h" #include "starvation_ring.h" #include "timers.h" #if defined(PSX_HAS_RECOMP_NET) @@ -623,6 +624,67 @@ static int idle_skip_on(void) { int psx_idle_skip_is_enabled(void) { return idle_skip_on(); } +/* Store-counter loops (opt-in on top of idle_skip: [runtime] + * idle_skip_store_counters, PSX_IDLE_SKIP_EXT=1). PsyQ libetc's v_wait -- + * the VSync(0)/VSync(n) spin every PsyQ title runs each frame -- is + * + * do { if (--timeout == -1) { timeout path } } while (VSync_count < target); + * + * with `timeout` a stack word: one guest store per iteration, and two block + * edges (interrupt-check boundaries) per iteration. The base detector needs + * zero stores and one edge, so it never fires there. The extension accepts + * - several edges per iteration: the first edge seen is the anchor and up + * to IDLE_EDGES_MAX other edges between two anchor visits are one + * iteration (their count must be stable too); + * - exactly one word store per iteration, to the same RAM/scratchpad + * address, whose value drops by exactly 1 each iteration (a register + * holding the same value may drop with it). + * Skips stay bit-exact: k whole iterations, landing on the anchor strictly + * BEFORE the next device event when the loop has more than one edge (an IRQ + * may be taken at any of them, so the last iteration runs for real), the + * counter (and its register) lowered by k and never to 1 or below, so the + * real timeout test still runs. */ +int g_idle_skip_ext = -1; /* -1 = read PSX_IDLE_SKIP_EXT once */ +enum { IDLE_EDGES_MAX = 8 }; +static uint32_t s_idle_edges = 0; /* non-anchor edges since the anchor */ +static uint32_t s_idle_iter_edges = 0; /* edges in the last whole iteration */ +static uint32_t s_idle_store_addr = 0xFFFFFFFFu; +static uint32_t s_idle_store_val = 0; +static int s_idle_mem_progress = 0; + +static int idle_skip_ext_on(void) { + if (g_idle_skip_ext < 0) { + const char *e = getenv("PSX_IDLE_SKIP_EXT"); + g_idle_skip_ext = e ? (e[0] == '1') : 0; + } + return g_idle_skip_ext > 0; +} + +/* Stores since the last edge: 0, or (extension) one word store to RAM or + * the scratchpad. *addr/*val name it; addr 0xFFFFFFFF when none. */ +static int idle_stores_ok(uint64_t stores_now, int ext, uint32_t *addr, uint32_t *val) { + extern uint32_t g_guest_last_store_addr, g_guest_last_store_val; + *addr = 0xFFFFFFFFu; + *val = 0; + const uint64_t d = stores_now - s_idle_last_stores; + if (d == 0) return 1; + if (!ext || d != 1 || g_guest_last_store_addr == 0xFFFFFFFFu) return 0; + const uint32_t phys = g_guest_last_store_addr & 0x1FFFFFFFu; + if ((phys & 3u) != 0u) return 0; + if (!(phys < 0x00800000u || (phys >= 0x1F800000u && phys < 0x1F800400u))) return 0; + *addr = g_guest_last_store_addr; + *val = g_guest_last_store_val; + return 1; +} + +static void idle_reset_progress(void) { + s_idle_streak = 0; + s_idle_quantum = 0; + s_idle_progress_reg = -2; + s_idle_progress_delta = 0; + s_idle_mem_progress = 0; +} + void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { #ifdef PSX_COSIM (void)cpu; (void)check_pc; @@ -641,12 +703,23 @@ void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { s_idle_pc = 0; s_idle_streak = 0; s_idle_have_snap = 0; + s_idle_edges = 0; + return; + } + const int ext = idle_skip_ext_on(); + + /* Extension: an edge inside the anchor's iteration. Only MMIO is checked + * here; stores are counted over the whole iteration at the anchor. */ + if (ext && s_idle_pc != 0 && check_pc != s_idle_pc && s_idle_edges < IDLE_EDGES_MAX && + g_mmio_access_count == s_idle_last_mmio) { + s_idle_edges++; return; } uint64_t cyc = psx_cycle_count; - if (check_pc == s_idle_pc && s_idle_have_snap && - g_guest_store_count == s_idle_last_stores && + uint32_t st_addr, st_val; + const int stores_ok = idle_stores_ok(g_guest_store_count, ext, &st_addr, &st_val); + if (check_pc == s_idle_pc && s_idle_have_snap && stores_ok && g_mmio_access_count == s_idle_last_mmio) { int changed = -1, changed_count = 0; for (int i = 1; i < 32; i++) { @@ -665,27 +738,42 @@ void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { * executes. No stores/MMIO are allowed, so there is no hidden work. */ int progress_reg = changed_count == 0 ? -1 : (changed_count == 1 && progress_delta == -1 ? changed : -2); + /* Extension: the one store must be the same counter word, one lower + * than last iteration, positive; a progressing register must hold it. */ + int mem_progress = 0; + if (st_addr != 0xFFFFFFFFu) { + if (st_addr == s_idle_store_addr && st_val == s_idle_store_val - 1u && + (int32_t)st_val > 1 && + (progress_reg <= 0 || cpu->gpr[progress_reg] == st_val)) + mem_progress = 1; + else + progress_reg = -2; + } else if (s_idle_store_addr != 0xFFFFFFFFu) { + progress_reg = -2; /* the store pattern changed */ + } uint32_t quantum = (uint32_t)(cyc - s_idle_last_cycle); if (progress_reg != -2 && quantum > 0 && quantum <= IDLE_QUANTUM_MAX) { if (quantum == s_idle_quantum && progress_reg == s_idle_progress_reg && - progress_delta == s_idle_progress_delta) { + progress_delta == s_idle_progress_delta && + mem_progress == s_idle_mem_progress && + s_idle_edges == s_idle_iter_edges) { if (s_idle_streak < 1000000u) s_idle_streak++; } else { s_idle_quantum = quantum; s_idle_progress_reg = progress_reg; s_idle_progress_delta = progress_delta; + s_idle_mem_progress = mem_progress; s_idle_streak = 1; } } else { - s_idle_streak = 0; - s_idle_quantum = 0; - s_idle_progress_reg = -2; - s_idle_progress_delta = 0; + idle_reset_progress(); } + s_idle_store_addr = st_addr; + s_idle_store_val = st_val; + s_idle_iter_edges = s_idle_edges; idle_snapshot_regs(cpu); - } else if (check_pc == s_idle_pc && - g_guest_store_count == s_idle_last_stores && + } else if (check_pc == s_idle_pc && stores_ok && g_mmio_access_count == s_idle_last_mmio) { /* Second consecutive observation of this PC with no stores/MMIO — * take the baseline snapshot; the next hit can compare. Defers the @@ -693,18 +781,17 @@ void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { * PC every check (pure host cost, never skipped). */ idle_snapshot_regs(cpu); s_idle_have_snap = 1; - s_idle_streak = 0; - s_idle_quantum = 0; - s_idle_progress_reg = -2; - s_idle_progress_delta = 0; + s_idle_store_addr = st_addr; + s_idle_store_val = st_val; + s_idle_iter_edges = s_idle_edges; + idle_reset_progress(); } else { s_idle_pc = check_pc; - s_idle_streak = 0; - s_idle_quantum = 0; - s_idle_progress_reg = -2; - s_idle_progress_delta = 0; + idle_reset_progress(); s_idle_have_snap = 0; + s_idle_store_addr = 0xFFFFFFFFu; } + s_idle_edges = 0; s_idle_last_cycle = cyc; s_idle_last_stores = g_guest_store_count; s_idle_last_mmio = g_mmio_access_count; @@ -713,18 +800,28 @@ void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { uint32_t q = s_idle_quantum; uint32_t dist = devices_cycles_to_next_idle_event(); if (dist <= q) return; /* one real iteration reaches it */ - uint32_t k = (dist + q - 1u) / q; /* first check boundary >= event */ + uint32_t k; + if (s_idle_iter_edges == 0) + k = (dist + q - 1u) / q; /* first check boundary >= event */ + else + k = (dist - 1u) / q; /* land on the anchor before it */ if (s_idle_progress_reg > 0) { uint32_t value = cpu->gpr[s_idle_progress_reg]; uint32_t max_k = value > 1u ? value - 1u : 0u; if (k > max_k) k = max_k; - if (k == 0) return; } + if (s_idle_mem_progress) { + uint32_t max_k = (int32_t)s_idle_store_val > 1 ? s_idle_store_val - 1u : 0u; + if (k > max_k) k = max_k; + } + if (k == 0) return; uint64_t skip = (uint64_t)k * q; if (skip > IDLE_SKIP_MAX_CYCLES) return; if (s_idle_progress_reg > 0) cpu->gpr[s_idle_progress_reg] -= k; + if (s_idle_mem_progress) + psx_host_write_word(s_idle_store_addr, s_idle_store_val - k); g_idle_skip_count++; g_idle_skip_cycles += skip; @@ -738,6 +835,8 @@ void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { s_idle_streak = 0; idle_snapshot_regs(cpu); s_idle_have_snap = 1; + s_idle_store_addr = s_idle_mem_progress ? s_idle_store_addr : 0xFFFFFFFFu; + s_idle_store_val = s_idle_store_val - (s_idle_mem_progress ? k : 0u); s_idle_last_cycle = psx_cycle_count; s_idle_last_stores = g_guest_store_count; s_idle_last_mmio = g_mmio_access_count; From f2570a8f76071633e4c8be5e7c510e362b874123 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:01:31 -0700 Subject: [PATCH 2/7] idle_skip store counters: review fixes, exactness from boot, pinning test - Last-store tracking: memory.c owns the record and writes it only while the extension is on (g_psx_track_last_store); main.cpp hands it and the host writer (psx_host_write_word) to psx_cycles.c through psx_idle_skip_set_store_tracker / _set_host_writer, so psx_cycles.c no longer references memory.c (fixes the tests that link one without the other). - Exact from boot: the device distance is now measured from the current cycle (devices report it from their last service, up to a few hundred cycles behind), and several edges per pass are accepted only together with a counter store. Before this, R4's boot diverged at frame 962 (an OpenBIOS multi-edge poll and a late VBlank IRQ after a v_wait skip). - tests/test_idle_skip_store_counter.c: a v_wait trace skips (whole passes, counter lowered by the host writer, never to the timeout, lands before the event); an extra store, an MMIO read, a decrement of 2, a store to MMIO, the option off and no host writer do not. - generated/OpenBIOS.emitter.sha re-stamped. R4 frame_fingerprint off vs on, 9000 frames each: boot through intro and attract, a 1P race and a 2P VS race: cyc, mmio, mc, sp, sc and qc identical on every frame, 0 dispatch misses. Co-Authored-By: Claude Opus 5.5 --- docs/config_schema.md | 4 +- runtime/CMakeLists.txt | 7 + runtime/include/psx_cycles.h | 8 + runtime/src/main.cpp | 10 +- runtime/src/memory.c | 18 ++- runtime/src/psx_cycles.c | 46 +++++- runtime/tests/test_idle_skip_store_counter.c | 156 +++++++++++++++++++ 7 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 runtime/tests/test_idle_skip_store_counter.c diff --git a/docs/config_schema.md b/docs/config_schema.md index e22162fc..d415e01d 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -498,7 +498,9 @@ overlay_region_floor = "0x10000" # optional: lowest RAM address treated as ove overrides it for one run) also elides loops that count a stack timeout down by one each pass and span up to eight block edges: PsyQ libetc's `v_wait`, the `VSync(0)`/`VSync(n)` spin most PsyQ titles run every frame. The skip -lowers the counter by the number of passes it skips (never to 1 or below, so +lowers the counter by the number of passes it skips (several edges per pass are +accepted only together with such a counter store; the device distance is +measured from the current cycle, not from the devices' last service) (never to 1 or below, so the real timeout test still runs) and lands on the loop's first edge strictly before the next device event, so an interrupt is taken at the same edge as without it. Guest cycles, device-register writes and scratchpad writes are diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 517121c6..1b3dc374 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1756,6 +1756,13 @@ if(BUILD_TESTING) add_test(NAME psx_cycle_event_boundaries_test COMMAND psx_cycle_event_boundaries_test) + # idle_skip store-counter loops: PsyQ v_wait skips, near misses do not. + add_executable(idle_skip_store_counter_test + tests/test_idle_skip_store_counter.c + src/psx_cycles.c) + target_include_directories(idle_skip_store_counter_test PRIVATE include) + add_test(NAME idle_skip_store_counter_test COMMAND idle_skip_store_counter_test) + add_executable(psx_vblank_clock_test tests/test_psx_vblank_clock.c src/psx_vblank_clock.c) diff --git a/runtime/include/psx_cycles.h b/runtime/include/psx_cycles.h index dd72f819..c0750018 100644 --- a/runtime/include/psx_cycles.h +++ b/runtime/include/psx_cycles.h @@ -257,6 +257,14 @@ int psx_idle_skip_is_enabled(void); /* Cycles until the nearest IRQ-observable device event (mask-aware); the bound an idle skip may not cross. */ uint32_t psx_idle_cycles_to_next_observable_event(void); extern int g_idle_skip_enabled; +/* [runtime] idle_skip_store_counters (psx_cycles.c): 1 on, 0 off, -1 env. */ +extern int g_idle_skip_ext; +/* The host write that lowers a skipped loop counter (psx_host_write_word); + * the store-counter extension stays off until one is installed. */ +void psx_idle_skip_set_host_writer(void (*w)(uint32_t addr, uint32_t val)); +/* The store path's last-word-store record (memory.c): the gate the detector + * sets while the extension is on, and the address/value it reads. */ +void psx_idle_skip_set_store_tracker(int *gate, const uint32_t *addr, const uint32_t *val); extern uint64_t g_idle_skip_count; extern uint64_t g_idle_skip_cycles; extern uint32_t g_idle_skip_last_pc; diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index dd792d73..01f4afcc 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -46,6 +46,9 @@ #include "autocompile.h" #include "code_provider.h" extern "C" void psx_event_step_conservative_env_init(void); +/* memory.c's last-word-store record for the idle-loop detector. */ +extern "C" int g_psx_track_last_store; +extern "C" uint32_t g_guest_last_store_addr, g_guest_last_store_val; #include "overlay_backend.h" #include "gpu.h" #include "draw_distance.h" @@ -16317,7 +16320,12 @@ int main(int argc, char** argv) { std::fprintf(stdout, "psxrecomp: idle_skip %s%s\n", g_idle_skip_enabled ? "enabled" : "disabled", idle_env ? " (environment override)" : ""); - extern int g_idle_skip_ext; + { + psx_idle_skip_set_host_writer(psx_host_write_word); + psx_idle_skip_set_store_tracker(&g_psx_track_last_store, + &g_guest_last_store_addr, + &g_guest_last_store_val); + } const char *ext_env = std::getenv("PSX_IDLE_SKIP_EXT"); g_idle_skip_ext = ext_env ? (ext_env[0] == '1' ? 1 : 0) : (gc.runtime.idle_skip_store_counters ? 1 : 0); diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 24e408b0..f994d3cc 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1042,9 +1042,11 @@ uint64_t g_mmio_access_count = 0; * ANY store is not a pure poll loop. CPU stores and in-flight DMA writes * both funnel through the psx_write_*_raw chokepoints. */ uint64_t g_guest_store_count = 0; -/* The last guest store, for the idle-loop detector's store-counter loops - * (psx_cycles.c): its address and value when it was a word store, else - * address 0xFFFFFFFF. Two plain stores on the store path. */ +/* The last guest word store, for the idle-loop detector's store-counter + * loops (psx_cycles.c, [runtime] idle_skip_store_counters). Recorded only + * while that option is on (g_psx_track_last_store); main.cpp hands these to + * the detector with psx_idle_skip_set_store_tracker. */ +int g_psx_track_last_store = 0; uint32_t g_guest_last_store_addr = 0xFFFFFFFFu; uint32_t g_guest_last_store_val = 0; @@ -1981,8 +1983,10 @@ void psx_write_word(uint32_t addr, uint32_t val) { } static void psx_write_word_raw(uint32_t addr, uint32_t val) { g_guest_store_count++; - g_guest_last_store_addr = addr; - g_guest_last_store_val = val; + if (g_psx_track_last_store) { + g_guest_last_store_addr = addr; + g_guest_last_store_val = val; + } /* (pgxp) CPU stores carry their shadow through the PGXP_STORE hook; * DMA and host stores drop it below (pgxp_untracked_store). */ /* IsC first, before any decode (cpu_store_isolated). */ @@ -2194,7 +2198,7 @@ void psx_write_half(uint32_t addr, uint16_t val) { } static void psx_write_half_raw(uint32_t addr, uint16_t val) { g_guest_store_count++; - g_guest_last_store_addr = 0xFFFFFFFFu; + if (g_psx_track_last_store) g_guest_last_store_addr = 0xFFFFFFFFu; if (cpu_store_isolated()) { isc_store(addr, val, 2); return; } /* KSEG2 guard — see psx_read_word_raw. */ @@ -2564,7 +2568,7 @@ void psx_host_write_byte(uint32_t addr, uint8_t val) { static void psx_write_byte_raw(uint32_t addr, uint8_t val) { g_guest_store_count++; - g_guest_last_store_addr = 0xFFFFFFFFu; + if (g_psx_track_last_store) g_guest_last_store_addr = 0xFFFFFFFFu; if (cpu_store_isolated()) { isc_store(addr, val, 1); return; } /* KSEG2 guard — see psx_read_word_raw. */ diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 043409ff..9d06e35f 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -12,7 +12,6 @@ #include "dma.h" #include "interrupts.h" #include "sio.h" -#include "psx_memory.h" #include "starvation_ring.h" #include "timers.h" #if defined(PSX_HAS_RECOMP_NET) @@ -645,6 +644,20 @@ int psx_idle_skip_is_enabled(void) { return idle_skip_on(); } * counter (and its register) lowered by k and never to 1 or below, so the * real timeout test still runs. */ int g_idle_skip_ext = -1; /* -1 = read PSX_IDLE_SKIP_EXT once */ +/* Last-store tracking (memory.c writes it only while this is set) and the + * host writer used to lower a skipped counter. Both live here so every + * target that links the cycle model links them too. */ +static int s_track_dummy; +static const uint32_t s_no_store = 0xFFFFFFFFu; +static int *s_track = &s_track_dummy; +static const uint32_t *s_last_addr = &s_no_store, *s_last_val = &s_no_store; +static void (*s_idle_host_write)(uint32_t addr, uint32_t val) = NULL; +void psx_idle_skip_set_host_writer(void (*w)(uint32_t, uint32_t)) { s_idle_host_write = w; } +void psx_idle_skip_set_store_tracker(int *gate, const uint32_t *addr, const uint32_t *val) { + s_track = gate ? gate : &s_track_dummy; + s_last_addr = addr ? addr : &s_no_store; + s_last_val = val ? val : &s_no_store; +} enum { IDLE_EDGES_MAX = 8 }; static uint32_t s_idle_edges = 0; /* non-anchor edges since the anchor */ static uint32_t s_idle_iter_edges = 0; /* edges in the last whole iteration */ @@ -657,23 +670,24 @@ static int idle_skip_ext_on(void) { const char *e = getenv("PSX_IDLE_SKIP_EXT"); g_idle_skip_ext = e ? (e[0] == '1') : 0; } - return g_idle_skip_ext > 0; + /* Without a host writer the counter cannot be lowered: no extension. */ + *s_track = g_idle_skip_ext > 0 && s_idle_host_write != NULL && s_track != &s_track_dummy; + return *s_track; } /* Stores since the last edge: 0, or (extension) one word store to RAM or * the scratchpad. *addr/*val name it; addr 0xFFFFFFFF when none. */ static int idle_stores_ok(uint64_t stores_now, int ext, uint32_t *addr, uint32_t *val) { - extern uint32_t g_guest_last_store_addr, g_guest_last_store_val; *addr = 0xFFFFFFFFu; *val = 0; const uint64_t d = stores_now - s_idle_last_stores; if (d == 0) return 1; - if (!ext || d != 1 || g_guest_last_store_addr == 0xFFFFFFFFu) return 0; - const uint32_t phys = g_guest_last_store_addr & 0x1FFFFFFFu; + if (!ext || d != 1 || *s_last_addr == 0xFFFFFFFFu) return 0; + const uint32_t phys = *s_last_addr & 0x1FFFFFFFu; if ((phys & 3u) != 0u) return 0; if (!(phys < 0x00800000u || (phys >= 0x1F800000u && phys < 0x1F800400u))) return 0; - *addr = g_guest_last_store_addr; - *val = g_guest_last_store_val; + *addr = *s_last_addr; + *val = *s_last_val; return 1; } @@ -797,8 +811,24 @@ void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { s_idle_last_mmio = g_mmio_access_count; if (s_idle_streak < IDLE_STREAK_MIN) return; + /* Several edges per pass are accepted only for the store-counter shape + * (v_wait). Multi-edge loops with no counter store -- OpenBIOS's + * 0xBFC057C0 poll, kernel 0x16BC -- moved R4's boot timing when skipped + * (frame 192, cyc +3857), so they keep running for real. */ + if (s_idle_iter_edges && !s_idle_mem_progress) return; uint32_t q = s_idle_quantum; uint32_t dist = devices_cycles_to_next_idle_event(); + /* The devices report distances from where they were last serviced + * (s_devices_synced_cycle), which can trail psx_cycle_count by a few + * hundred cycles. For the store-counter shape, measure from now: an + * overstated distance lands the skip after the event and delivers the + * IRQ late (R4 boot, frame 961: +402 cycles). */ + if (s_idle_iter_edges || s_idle_mem_progress) { + const uint64_t lag = psx_cycle_count > s_devices_synced_cycle + ? psx_cycle_count - s_devices_synced_cycle : 0; + if ((uint64_t)dist <= lag) return; + dist -= (uint32_t)lag; + } if (dist <= q) return; /* one real iteration reaches it */ uint32_t k; if (s_idle_iter_edges == 0) @@ -821,7 +851,7 @@ void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { if (s_idle_progress_reg > 0) cpu->gpr[s_idle_progress_reg] -= k; if (s_idle_mem_progress) - psx_host_write_word(s_idle_store_addr, s_idle_store_val - k); + s_idle_host_write(s_idle_store_addr, s_idle_store_val - k); g_idle_skip_count++; g_idle_skip_cycles += skip; diff --git a/runtime/tests/test_idle_skip_store_counter.c b/runtime/tests/test_idle_skip_store_counter.c new file mode 100644 index 00000000..c58c723c --- /dev/null +++ b/runtime/tests/test_idle_skip_store_counter.c @@ -0,0 +1,156 @@ +/* + * Pin the idle-loop detector's store-counter extension + * ([runtime] idle_skip_store_counters, psx_cycles.c). + * + * The positive case is PsyQ libetc's v_wait as R4 runs it (0x8008B4A8): two + * interrupt-check edges per pass (0x8008B4D0 and 0x8008B518), one word store + * per pass to a stack counter that drops by exactly 1, no MMIO, registers + * identical at the anchor edge. It must skip whole passes, lower the counter + * by the same number of passes through the host writer, never to 1 or below, + * and land on the anchor strictly before the next device event. + * + * Near misses that must NOT skip: an extra store per pass, an MMIO read per + * pass, a counter that drops by 2, a store to MMIO space, and the same loop + * with the option off. + * + * Build/run: ctest -R idle_skip_store_counter_test + */ +#include "cpu_state.h" +#include "psx_cycles.h" + +#include +#include +#include + +int g_ls_replay_active = 0; +int g_ls_mode = 0; +int g_precise_mode = 0; +int g_psx_call_bail = 0; +uint32_t i_mask = 0; +uint64_t g_guest_store_count = 0; +uint64_t g_mmio_access_count = 0; +static uint32_t g_guest_last_store_addr = 0xFFFFFFFFu, g_guest_last_store_val; +static int g_psx_track_last_store; + +static uint32_t s_event_at = 0; /* absolute cycle of the next device event */ + +void sio_advance(uint32_t c) { (void)c; } +void cdrom_advance(uint32_t c) { (void)c; } +void dma_advance(uint32_t c) { (void)c; } +void timers_advance(uint32_t c) { (void)c; } +void interrupts_advance_cycles(uint32_t c) { (void)c; } +void interrupts_service_scheduled_events(void) {} +uint32_t interrupts_cycles_to_vblank(void) { + uint64_t now = psx_get_cycle_count(); + return now >= s_event_at ? 1u : (uint32_t)(s_event_at - now); +} +uint32_t timers_cycles_to_irq(uint32_t m) { (void)m; return UINT32_MAX; } +uint32_t cdrom_cycles_to_irq(uint32_t m) { (void)m; return UINT32_MAX; } +uint32_t dma_cycles_to_internal_event(void) { return UINT32_MAX; } +uint32_t dma_cycles_to_deliverable_irq(uint32_t m) { (void)m; return UINT32_MAX; } +uint32_t sio_cycles_to_irq(uint32_t m) { (void)m; return UINT32_MAX; } +uint32_t psx_spu_sample_event_cycles_to_next(void) { return UINT32_MAX; } +void psx_spu_sample_event_service(void) {} +int psx_get_in_exception(void) { return 0; } +void starvation_watchdog_check(void) {} +void starvation_ring_pc_sample(void) {} +int psx_netplay_active(void) { return 0; } +int psx_selfcheck_enabled(void) { return 0; } +void dirty_ram_ld_delay_discard(void) {} +void dirty_ram_irq_ambient_resync_after_restore(void) {} + +/* Guest RAM word the loop counts down, and the host writer under test. */ +static uint32_t s_counter; +static int s_host_writes; +static void host_write(uint32_t addr, uint32_t val) { + (void)addr; s_counter = val; s_host_writes++; +} + +#define ANCHOR 0x8008B4D0u +#define EDGE2 0x8008B518u +#define COUNTER_ADDR 0x801FFF10u /* stack word in main RAM */ + +enum { EXTRA_STORE = 1, MMIO_READ = 2, STEP2 = 4, MMIO_STORE = 8 }; + +static void guest_store(uint32_t addr, uint32_t val) { + g_guest_store_count++; + if (g_psx_track_last_store) { g_guest_last_store_addr = addr; g_guest_last_store_val = val; } +} + +static int fails; +static void check(int ok, const char *what) { + printf(" %s %s\n", ok ? "ok " : "FAIL", what); + if (!ok) fails++; +} + +static uint64_t s_start, s_after_first_skip; +static uint32_t s_counter_after_first_skip; + +static uint64_t run(int ext, int quirks, int passes, uint32_t start_counter) { + static CPUState cpu; + memset(&cpu, 0, sizeof cpu); + cpu.gpr[2] = 1u; /* slt result at the edges: still waiting */ + cpu.gpr[29] = 0x801FFF00u; + psx_cycles_resync_after_restore(NULL); + s_start = psx_get_cycle_count(); + s_event_at = (uint32_t)s_start + 200000u; + s_after_first_skip = 0; + s_counter = start_counter; + s_host_writes = 0; + g_idle_skip_enabled = 1; + g_idle_skip_ext = ext; + g_idle_skip_count = 0; + for (int i = 0; i < passes && psx_get_cycle_count() < s_event_at; i++) { + psx_idle_note_check(&cpu, ANCHOR); + if (g_idle_skip_count && !s_after_first_skip) { + s_after_first_skip = psx_get_cycle_count(); + s_counter_after_first_skip = s_counter; + } + psx_advance_cycles(9); + if (s_counter <= 1) break; + s_counter -= (quirks & STEP2) ? 2u : 1u; + guest_store((quirks & MMIO_STORE) ? 0x1F801070u : COUNTER_ADDR, s_counter); + if (quirks & EXTRA_STORE) guest_store(COUNTER_ADDR + 4u, 0); + if (quirks & MMIO_READ) g_mmio_access_count++; + psx_advance_cycles(7); + psx_idle_note_check(&cpu, EDGE2); + } + return g_idle_skip_count; +} + +int main(void) { + psx_idle_skip_set_host_writer(host_write); + psx_idle_skip_set_store_tracker(&g_psx_track_last_store, &g_guest_last_store_addr, + &g_guest_last_store_val); + + uint64_t skips = run(1, 0, 64, 100000u); + check(skips > 0, "v_wait: passes are skipped"); + check(s_host_writes > 0, "v_wait: the counter is lowered through the host writer"); + check(s_counter > 1u, "v_wait: the counter never reaches the timeout value"); + check(s_after_first_skip < s_event_at, + "v_wait: the skip lands on the anchor before the device event " + "(the IRQ is taken by a real pass)"); + check(s_event_at - s_after_first_skip < 200000u / 2u, + "v_wait: the skip covers most of the wait"); + check((s_after_first_skip - s_start) % 16u == 0, + "v_wait: cycles advanced in whole 16-cycle passes"); + check((uint64_t)(100000u - s_counter_after_first_skip) * 16u + 16u == + s_after_first_skip - s_start + 16u, + "v_wait: the counter dropped by exactly one per pass, skipped passes included"); + + uint64_t short_skips = run(1, 0, 4000, 40u); + check(s_counter >= 1u && s_counter <= 2u && short_skips > 0, + "v_wait with a small timeout: skips stop before the timeout test"); + + check(run(0, 0, 64, 100000u) == 0, "option off: no skip"); + check(run(1, EXTRA_STORE, 64, 100000u) == 0, "extra store per pass: no skip"); + check(run(1, MMIO_READ, 64, 100000u) == 0, "MMIO read per pass: no skip"); + check(run(1, STEP2, 64, 100000u) == 0, "counter drops by 2: no skip"); + check(run(1, MMIO_STORE, 64, 100000u) == 0, "store to MMIO space: no skip"); + + psx_idle_skip_set_host_writer(NULL); + check(run(1, 0, 64, 100000u) == 0, "no host writer installed: no skip"); + + printf("%s (%d failure%s)\n", fails ? "FAILED" : "passed", fails, fails == 1 ? "" : "s"); + return fails ? 1 : 0; +} From cb670217bdf0fdb291fd06a985a4626c6dcad6c8 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:32:39 -0700 Subject: [PATCH 3/7] idle_skip store counter test: stub g_boot_state_cpu_timing_restored for psx_cycles.c Master's psx_cycles.c now reads the boot-state flag; the test links psx_cycles.c alone. Co-Authored-By: Claude Opus 5.5 --- runtime/tests/test_idle_skip_store_counter.c | 1 + 1 file changed, 1 insertion(+) diff --git a/runtime/tests/test_idle_skip_store_counter.c b/runtime/tests/test_idle_skip_store_counter.c index c58c723c..18502d8b 100644 --- a/runtime/tests/test_idle_skip_store_counter.c +++ b/runtime/tests/test_idle_skip_store_counter.c @@ -26,6 +26,7 @@ int g_ls_replay_active = 0; int g_ls_mode = 0; int g_precise_mode = 0; int g_psx_call_bail = 0; +int g_boot_state_cpu_timing_restored = 0; /* boot_state.c, read by psx_cycles.c */ uint32_t i_mask = 0; uint64_t g_guest_store_count = 0; uint64_t g_mmio_access_count = 0; From d3047ede2711858fbafefbf50a2ba65cae27bf31 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:17:00 -0700 Subject: [PATCH 4/7] docs: developer contract for idle_skip_store_counters Co-Authored-By: Claude Opus 5.5 --- docs/config_schema.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/config_schema.md b/docs/config_schema.md index d415e01d..26140df8 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -509,6 +509,39 @@ counter word are not individually made (`frame_fingerprint` `wc`/`ws` count fewer writes, its judge columns match). In R4 races it removes about a quarter of the emulation thread's time. Off under netplay, like `idle_skip`. +#### Developer contract for `idle_skip_store_counters` + +A skip leaves out stores to the counter word that the guest would have made. +That is invisible only if nothing observes the word between those stores. The +assumptions, and how each one is enforced: + +| Assumption | Enforced by | +|---|---| +| The loop makes exactly one guest store per pass: a 4-byte-aligned word in RAM or the scratchpad, at the same address every pass, one lower each pass. | `idle_stores_ok` plus the address/value check at the anchor. Any other store count, any device or BIOS-area address, or a changed pattern resets the detector. | +| The loop touches no MMIO, so it cannot start DMA, acknowledge an IRQ or poll a device mid-span. | `g_mmio_access_count` must be unchanged across the pass. | +| Everything else in the pass is a pure function of the registers. Only the counter register and the counter word progress; every other GPR is invariant at the anchor. | The register snapshot is compared at the anchor; one progress register is allowed, and only if it moves in step with the word. | +| No other observer (IRQ handler, DMA, timer, SPU, CD, GPU, host-side mod or debugger code) runs between two device events. | The skip lands on the anchor strictly before the next scheduled device event, measured from the current cycle. Every IRQ, DMA completion and VBlank callback therefore sees the counter value that unskipped execution would have stored at that same edge. Host mod hooks run only at VBlank or at function entry, and neither happens inside the span. | +| The loop's own exit test still runs. | The counter is never lowered to 1 or below, so the `--timeout == -1` path executes for real. | +| The game does not alias the counter word through another pointer that it reads inside the loop. | Implied by the above: any such read sits on a code path that also runs between edges, and its result can only feed registers that must be invariant. A loop that reads the word through an alias and branches on it shows up as a register or edge-count change, and the detector rejects it. | + +What differs from unskipped execution: the guest store count (`frame_fingerprint` +`wc`/`ws`) and the host time. Guest cycles, device-register writes, scratchpad +and RAM contents at every device event are the same. That is why the judge +columns `cyc`, `mmio`, `mc`, `sp`, `sc` and `qc` must match. A title that +breaks one of the assumptions in a way the detector cannot see (for example, a +second CPU-side writer driven by a device it does not schedule) must leave +the key off. + +Evidence (R4, OpenBIOS, M4 release build with debug tools, `frame_fingerprint`, key off vs on): + +- boot into attract: 9000 frames +- race from a savestate: 3600 frames +- all judge columns identical, 0 dispatch misses +- `wc`/`ws` lower with the key on + +The other psxrecomp titles have no disc in this test setup, so they keep the +key off until they are run the same way. + ### `turbo_loads` / `offer_turbo_loads` — deprecated and ignored **Do not use these keys.** Load acceleration is owned by the Mods catalog: From 7a4a1a004ac7971e72227b2ad90362e7942899dc Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:48:57 -0700 Subject: [PATCH 5/7] idle_skip store counters: only audited sites (idle_skip_store_counter_sites) The detector cannot prove a loop has no later counter-dependent exit, so the extension now skips only at block-edge PCs the title lists after an audit. Unlisted sites are never skipped; an empty list disables it. Docs record R4's v_wait audit as the reference; tests cover unlisted sites. Co-Authored-By: Claude Opus 5.5 --- docs/config_schema.md | 40 +++++++++++++++++++- recompiler/src/config_loader.cpp | 6 +++ recompiler/src/config_loader.h | 4 ++ runtime/include/psx_cycles.h | 3 ++ runtime/src/main.cpp | 11 +++++- runtime/src/psx_cycles.c | 24 +++++++++++- runtime/tests/test_idle_skip_store_counter.c | 11 ++++++ 7 files changed, 94 insertions(+), 5 deletions(-) diff --git a/docs/config_schema.md b/docs/config_schema.md index 26140df8..988fa4a2 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -511,6 +511,43 @@ of the emulation thread's time. Off under netplay, like `idle_skip`. #### Developer contract for `idle_skip_store_counters` +**Eligibility is an audited allowlist, not an automatic guarantee.** The +detector can check a loop's shape over the passes it observes. It cannot prove +that the loop has no later exit that depends on the counter (for example, an +exit at `counter == 500`): a skip would jump past it. So the extension only +skips at sites the title's developer has audited and declared: + +```toml +[runtime] +idle_skip = true +idle_skip_store_counters = true +# Block-edge PCs of each audited loop (the detector anchors on either). +idle_skip_store_counter_sites = ["0x8008B4D0", "0x8008B518"] +``` + +Unlisted sites are never skipped. An empty or missing list turns the +extension off, and the runtime log says so. Before adding a site, read the +loop's code and record its exit structure next to the list: every exit that +depends on the counter must be the final timeout test (the counter reaching +the value the skip stops short of), and no other code may read the counter. + +R4 (SLUS-00797) is the reference audit. PsyQ libetc `v_wait` sits at +`0x8008B4A8`: + +1. It stores `timeout << 15` to `0x10(sp)` and returns at once if the VSync + count at `0x800A6548` has already reached the target. +2. **`0x8008B4D0`:** it loads the counter, subtracts 1, stores it and reloads + it. If it equals -1 it takes the timeout path (`0x8008B4F0`: prints + "VSync: timeout", calls `ChangeClearPAD(0)` and `ChangeClearRCnt(3, 0)`, + then jumps to the exit). +3. **`0x8008B518`:** it reloads the VSync count and branches back to + `0x8008B4D0` while the count is still below the target. +4. **`0x8008B530`:** the exit. + +The only exit that depends on the counter is the `== -1` timeout test. The skip +never takes the counter to 1 or below, so that test always runs for real. The +counter is a private stack slot of this frame. + A skip leaves out stores to the counter word that the guest would have made. That is invisible only if nothing observes the word between those stores. The assumptions, and how each one is enforced: @@ -521,8 +558,9 @@ assumptions, and how each one is enforced: | The loop touches no MMIO, so it cannot start DMA, acknowledge an IRQ or poll a device mid-span. | `g_mmio_access_count` must be unchanged across the pass. | | Everything else in the pass is a pure function of the registers. Only the counter register and the counter word progress; every other GPR is invariant at the anchor. | The register snapshot is compared at the anchor; one progress register is allowed, and only if it moves in step with the word. | | No other observer (IRQ handler, DMA, timer, SPU, CD, GPU, host-side mod or debugger code) runs between two device events. | The skip lands on the anchor strictly before the next scheduled device event, measured from the current cycle. Every IRQ, DMA completion and VBlank callback therefore sees the counter value that unskipped execution would have stored at that same edge. Host mod hooks run only at VBlank or at function entry, and neither happens inside the span. | +| The loop has no exit that depends on the counter, except the final timeout test. | **Not checkable by the detector.** It is the caller's audit: only the sites listed in `idle_skip_store_counter_sites` are eligible. | | The loop's own exit test still runs. | The counter is never lowered to 1 or below, so the `--timeout == -1` path executes for real. | -| The game does not alias the counter word through another pointer that it reads inside the loop. | Implied by the above: any such read sits on a code path that also runs between edges, and its result can only feed registers that must be invariant. A loop that reads the word through an alias and branches on it shows up as a register or edge-count change, and the detector rejects it. | +| The game does not read the counter word through another pointer, inside or outside the loop. | Partly checked: a read that changes a register or the edge count resets the detector. Otherwise this is part of the site audit. | What differs from unskipped execution: the guest store count (`frame_fingerprint` `wc`/`ws`) and the host time. Guest cycles, device-register writes, scratchpad diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index e809208c..21894ba7 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -548,6 +548,12 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& rt.idle_skip_store_counters = toml::find(runtime, "idle_skip_store_counters"); } + if (runtime.contains("idle_skip_store_counter_sites")) { + for (const auto& a : toml::find>( + runtime, "idle_skip_store_counter_sites")) + rt.idle_skip_store_counter_sites.push_back( + parse_hex(a, "runtime.idle_skip_store_counter_sites")); + } if (runtime.contains("idle_skip")) { rt.idle_skip = toml::find(runtime, "idle_skip"); } diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 90511e5f..daf20859 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -359,6 +359,10 @@ struct RuntimeConfig { // PsyQ libetc's v_wait, the VSync(0) spin. Bit-exact (psx_cycles.c); // PSX_IDLE_SKIP_EXT=0/1 overrides it for one run. bool idle_skip_store_counters = false; + // idle_skip_store_counter_sites: the audited loop sites (block-edge PCs + // the detector anchors on) where the store-counter extension may skip. + // Unlisted sites are never skipped; an empty list disables the extension. + std::vector idle_skip_store_counter_sites; // overlay_autocompile_cmd: variant-capture automation (step 2.8). A // shell command (run via cmd.exe /C, cwd = project root) that compiles diff --git a/runtime/include/psx_cycles.h b/runtime/include/psx_cycles.h index c0750018..67b7f36d 100644 --- a/runtime/include/psx_cycles.h +++ b/runtime/include/psx_cycles.h @@ -259,6 +259,9 @@ uint32_t psx_idle_cycles_to_next_observable_event(void); extern int g_idle_skip_enabled; /* [runtime] idle_skip_store_counters (psx_cycles.c): 1 on, 0 off, -1 env. */ extern int g_idle_skip_ext; +/* [runtime] idle_skip_store_counter_sites: the audited anchor PCs where the + * store-counter extension may skip (at most 16). Unlisted sites never are. */ +void psx_idle_skip_set_store_counter_sites(const uint32_t *pcs, int n); /* The host write that lowers a skipped loop counter (psx_host_write_word); * the store-counter extension stays off until one is installed. */ void psx_idle_skip_set_host_writer(void (*w)(uint32_t addr, uint32_t val)); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 01f4afcc..c641dc58 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -16329,9 +16329,16 @@ int main(int argc, char** argv) { const char *ext_env = std::getenv("PSX_IDLE_SKIP_EXT"); g_idle_skip_ext = ext_env ? (ext_env[0] == '1' ? 1 : 0) : (gc.runtime.idle_skip_store_counters ? 1 : 0); + psx_idle_skip_set_store_counter_sites( + gc.runtime.idle_skip_store_counter_sites.data(), + (int)gc.runtime.idle_skip_store_counter_sites.size()); if (g_idle_skip_enabled && g_idle_skip_ext) - std::fprintf(stdout, "psxrecomp: idle_skip store-counter loops on%s\n", - ext_env ? " (environment override)" : ""); + std::fprintf(stdout, "psxrecomp: idle_skip store-counter loops on%s, " + "%zu audited site(s)%s\n", + ext_env ? " (environment override)" : "", + gc.runtime.idle_skip_store_counter_sites.size(), + gc.runtime.idle_skip_store_counter_sites.empty() + ? " -- none listed, so nothing is skipped" : ""); psx_precise_slice_init_from_env(); } for (uint32_t site : gc.vsync_event_horizon_sites) diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 9d06e35f..ecb376c6 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -658,7 +658,24 @@ void psx_idle_skip_set_store_tracker(int *gate, const uint32_t *addr, const uint s_last_addr = addr ? addr : &s_no_store; s_last_val = val ? val : &s_no_store; } -enum { IDLE_EDGES_MAX = 8 }; +enum { IDLE_EDGES_MAX = 8, IDLE_SITES_MAX = 16 }; +/* Audited sites. The detector proves the loop's shape over the passes it + * sees; it cannot prove that no later counter-dependent exit exists (an exit + * at counter == 500 would be skipped over). Only sites a developer audited + * and declared are eligible. */ +static uint32_t s_idle_sites[IDLE_SITES_MAX]; +static int s_idle_site_count = 0; +void psx_idle_skip_set_store_counter_sites(const uint32_t *pcs, int n) { + if (n < 0 || !pcs) n = 0; + if (n > IDLE_SITES_MAX) n = IDLE_SITES_MAX; + for (int i = 0; i < n; i++) s_idle_sites[i] = pcs[i]; + s_idle_site_count = n; +} +static int idle_site_listed(uint32_t pc) { + for (int i = 0; i < s_idle_site_count; i++) + if (s_idle_sites[i] == pc) return 1; + return 0; +} static uint32_t s_idle_edges = 0; /* non-anchor edges since the anchor */ static uint32_t s_idle_iter_edges = 0; /* edges in the last whole iteration */ static uint32_t s_idle_store_addr = 0xFFFFFFFFu; @@ -720,7 +737,10 @@ void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { s_idle_edges = 0; return; } - const int ext = idle_skip_ext_on(); + /* The extension applies only at an audited site: the anchor while + * counting its inner edges, else the PC being checked. */ + const uint32_t site = (s_idle_pc != 0 && check_pc != s_idle_pc) ? s_idle_pc : check_pc; + const int ext = idle_skip_ext_on() && idle_site_listed(site); /* Extension: an edge inside the anchor's iteration. Only MMIO is checked * here; stores are counted over the whole iteration at the anchor. */ diff --git a/runtime/tests/test_idle_skip_store_counter.c b/runtime/tests/test_idle_skip_store_counter.c index 18502d8b..93bd6406 100644 --- a/runtime/tests/test_idle_skip_store_counter.c +++ b/runtime/tests/test_idle_skip_store_counter.c @@ -124,6 +124,17 @@ int main(void) { psx_idle_skip_set_store_tracker(&g_psx_track_last_store, &g_guest_last_store_addr, &g_guest_last_store_val); + /* No audited site: the extension never skips. */ + psx_idle_skip_set_store_counter_sites(NULL, 0); + check(run(1, 0, 64, 100000u) == 0, "no audited site listed: no skip"); + /* A different audited site: this loop is still not eligible. */ + const uint32_t other[] = {0x80012340u}; + psx_idle_skip_set_store_counter_sites(other, 1); + check(run(1, 0, 64, 100000u) == 0, "loop not at a listed site: no skip"); + /* R4's audited v_wait site (both block edges of the loop). */ + const uint32_t sites[] = {ANCHOR, EDGE2}; + psx_idle_skip_set_store_counter_sites(sites, 2); + uint64_t skips = run(1, 0, 64, 100000u); check(skips > 0, "v_wait: passes are skipped"); check(s_host_writes > 0, "v_wait: the counter is lowered through the host writer"); From 55bd76a494de07e6ab67cdbc5878cbf08c218e0d Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:31:45 -0700 Subject: [PATCH 6/7] Re-stamp OpenBIOS emitter fingerprint after rebase on master Co-Authored-By: Claude Opus 5.5 --- generated/OpenBIOS.emitter.sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 3273ab53..cbb9d5d9 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -77a1ea51cf0498290de3f78b4fb42ed51640dcee62b379ff44cde8b47cc5714e +ad06939d1795d8a832885c34bd5e3d3edfcc4782873a603b5eeff8592f210186 From 684c671d10b098ec89717e5f2ebc7efbcf9cafd8 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:33:42 -0700 Subject: [PATCH 7/7] idle_skip store counter test: stub psx_mod_host_output_scope for psx_cycles.c Co-Authored-By: Claude Opus 5.5 --- runtime/tests/test_idle_skip_store_counter.c | 1 + 1 file changed, 1 insertion(+) diff --git a/runtime/tests/test_idle_skip_store_counter.c b/runtime/tests/test_idle_skip_store_counter.c index 93bd6406..2a44c349 100644 --- a/runtime/tests/test_idle_skip_store_counter.c +++ b/runtime/tests/test_idle_skip_store_counter.c @@ -27,6 +27,7 @@ int g_ls_mode = 0; int g_precise_mode = 0; int g_psx_call_bail = 0; int g_boot_state_cpu_timing_restored = 0; /* boot_state.c, read by psx_cycles.c */ +int psx_mod_host_output_scope(void) { return 0; } /* mod_runtime.cpp */ uint32_t i_mask = 0; uint64_t g_guest_store_count = 0; uint64_t g_mmio_access_count = 0;