From 1e6e43639a600c70b00742aef188c295efe2a69b Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:10:12 -0700 Subject: [PATCH 1/3] Title timing constant: [timing] guest_cycle_scale (and _gated) No CPU is emulated: recompiled code charges the guest clock a fixed cost per instruction. guest_cycle_scale = N charges 1/N of it (instruction, load, mul/div, GTE latencies, overlay DLL batches) while VBlank, timers, CD, SPU and DMA keep hardware time, so the game never runs out of frame time. A title constant in game.toml (1 = faithful, 1..64), never a player setting; guest_cycle_scale_gated applies it only while the title's mod opens the gate (psx_guest_cycle_scale_gate_open). PSX_GUEST_CYCLE_SCALE overrides for testing. A scale other than 1 is folded into the netplay content fingerprint as a safety net. Co-Authored-By: Claude Opus 5.5 --- docs/HOST_OPTIMIZATION_CONTRACT.md | 2 +- docs/config_schema.md | 28 ++++++++++ overlay_toolchain/include/psx_cyc.h | 6 +++ overlay_toolchain/include/psx_cycles.h | 36 +++++++++++++ recompiler/src/config_loader.cpp | 19 +++++++ recompiler/src/config_loader.h | 5 ++ runtime/CMakeLists.txt | 7 +++ runtime/include/psx_cyc.h | 6 +++ runtime/include/psx_cycles.h | 36 +++++++++++++ runtime/src/dirty_ram_interp.c | 7 +++ runtime/src/main.cpp | 35 +++++++++++++ runtime/src/memory.c | 6 +++ runtime/src/overlay_loader.c | 9 +++- runtime/src/psx_cycles.c | 36 +++++++++++++ runtime/tests/overlay_pair_dedup_harness.c | 3 ++ runtime/tests/test_psx_cyc_batch.c | 2 + runtime/tests/test_psx_guest_cycle_scale.c | 59 ++++++++++++++++++++++ 17 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 runtime/tests/test_psx_guest_cycle_scale.c diff --git a/docs/HOST_OPTIMIZATION_CONTRACT.md b/docs/HOST_OPTIMIZATION_CONTRACT.md index 29146544..2cd582c1 100644 --- a/docs/HOST_OPTIMIZATION_CONTRACT.md +++ b/docs/HOST_OPTIMIZATION_CONTRACT.md @@ -74,7 +74,7 @@ next guest PC, even if common images never cross that boundary. Use a deterministic workload with fixed inputs and a fixed capture or replay when possible. Keep renderer, audio backend, window state, display refresh, -vsync/pacer ownership, CPU overclock, CRTC multiplier, mods, RAM size, and +vsync/pacer ownership, guest cycle scale, CRTC multiplier, mods, RAM size, and instrumentation identical. Warm caches outside the timed region unless cold behavior is the stated metric. diff --git a/docs/config_schema.md b/docs/config_schema.md index d00d9905..07384bde 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -58,6 +58,7 @@ How the two configs relate: [recompiler] [draw_distance] # optional; opt-in far-geometry clamps (below) [runtime] +[timing] # optional; title timing constants (below) [audit] ``` @@ -724,6 +725,33 @@ Reserved future fields: - `default_disc_path` — game runtimes can pre-mount a disc - `default_game_root` — for sibling-junction setups +## Timing block (`[timing]`) + +Title constants. They live in the game's `game.toml`, are never written to +`settings.toml`, and are not shown in the launcher. + +```toml +[timing] +guest_cycle_scale = 1 # 1..64; 1 = faithful (default) +guest_cycle_scale_gated = false # scale only while the title's mod opens the gate +``` + +No CPU is emulated. Recompiled code charges the guest clock a fixed number of +cycles for each MIPS instruction it runs (plus load, mul/div and GTE +latencies), and VBlank, timers, CD, SPU and DMA are scheduled against that +clock. `guest_cycle_scale = N` charges each instruction 1/N of its cost; the +devices keep hardware time. Larger N means the game never runs out of frame +time: N times as much game code fits between two VBlanks. A game that paces +itself on VBlank keeps its speed; one that busy-waits on a frame counter +spins longer in host time, which is why `guest_cycle_scale_gated` exists. +With it set, the scale applies only while the title's trusted mod holds the +gate open (`psx_guest_cycle_scale_gate_open(1)`), e.g. R4 opens it during +races; menus and loading keep faithful timing. + +A scale other than 1 is folded into the netplay content fingerprint, so peers +that differ never match. `PSX_GUEST_CYCLE_SCALE=` overrides the scale for +testing only. + ## Audit block See `docs/internal/audit_inventory.md` for the audit pipeline. The schema here is diff --git a/overlay_toolchain/include/psx_cyc.h b/overlay_toolchain/include/psx_cyc.h index d5fb196e..1a03a1a6 100644 --- a/overlay_toolchain/include/psx_cyc.h +++ b/overlay_toolchain/include/psx_cyc.h @@ -92,6 +92,12 @@ static inline void psx_cyc_local_cleanup(uint32_t **guard) { static inline void psx_cyc_charge(uint32_t cycles) { if (cycles == 0u) return; +#if !defined(PSX_OVERLAY_DLL_BUILD) + if (PSX_GCS_ACTIVE()) { + cycles = psx_gcs_scale(cycles); + if (cycles == 0u) return; + } +#endif #if defined(PSX_OVERLAY_DLL_BUILD) psx_advance_cycles(cycles); #else diff --git a/overlay_toolchain/include/psx_cycles.h b/overlay_toolchain/include/psx_cycles.h index e2e0cbf1..204620f1 100644 --- a/overlay_toolchain/include/psx_cycles.h +++ b/overlay_toolchain/include/psx_cycles.h @@ -66,6 +66,42 @@ extern int g_psx_cyc_bb_defer; * via psx_cyc_local_publish / psx_cyc_batch_flush before IRQ/MMIO barriers. */ extern uint32_t *g_psx_cyc_local_acc; +/* [timing] guest_cycle_scale — a title constant, not a player setting. + * No CPU is emulated: recompiled code charges the guest clock a fixed number + * of cycles per MIPS instruction it runs, and that clock is what VBlank, + * timers, CD, SPU and DMA are scheduled against. guest_cycle_scale = N + * charges each instruction 1/N of its cost (instruction, load, mul/div and + * GTE latencies, overlay DLL batches); device time is unchanged. Larger N + * means the game never runs out of frame time; 1 is faithful and skips the + * scaling entirely. g_psx_gcs_recip_q16 is 65536/N. */ +extern uint32_t g_psx_gcs_recip_q16; +extern uint32_t g_psx_gcs_frac; +void psx_guest_cycle_scale_set(uint32_t scale); /* 1..64; 1 = faithful */ +/* Live scale: the configured one, or 1 while gated and the gate is shut. */ +uint32_t psx_guest_cycle_scale(void); +uint32_t psx_guest_cycle_scale_config(void); +/* guest_cycle_scale_gated: the scale applies only while the title's mod + * holds the gate open (psx_guest_cycle_scale_gate_open), e.g. only in a + * paced race loop where it pays off; menus and loading keep faithful timing + * (a scaled clock spends host time spinning in their poll loops). */ +void psx_guest_cycle_scale_set_gated(int gated); +void psx_guest_cycle_scale_gate_open(int open); +static inline uint32_t psx_gcs_recip_for(uint32_t scale) { + if (scale <= 1u) return 65536u; + if (scale > 64u) scale = 64u; + return (uint32_t)((65536u + scale / 2u) / scale); +} +static inline uint32_t psx_gcs_scale(uint32_t cpu_cycles) { + uint64_t t = (uint64_t)cpu_cycles * g_psx_gcs_recip_q16 + g_psx_gcs_frac; + g_psx_gcs_frac = (uint32_t)(t & 0xFFFFu); + return (uint32_t)(t >> 16); +} +#if defined(__GNUC__) || defined(__clang__) +#define PSX_GCS_ACTIVE() __builtin_expect(g_psx_gcs_recip_q16 != 65536u, 0) +#else +#define PSX_GCS_ACTIVE() (g_psx_gcs_recip_q16 != 65536u) +#endif + /* Advance guest time. Overlay DLLs forward this through their callback shim; * normal runtime/generated code keeps the common production path inlined. */ #if defined(PSX_OVERLAY_DLL_BUILD) diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 2443c2b4..be147d18 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -613,6 +613,25 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& rt.parappa_timing_extra_late = parse_window("extra_late"); } + // Optional [timing] block — title constants, never player settings. + // guest_cycle_scale: no CPU is emulated; recompiled code charges the + // guest clock a fixed cost per instruction, and scale N charges 1/N of + // it, so N times as much game code fits between two VBlanks while + // VBlank, timers, CD, SPU and DMA keep hardware time. 1 = faithful; + // clamped to 1..64. guest_cycle_scale_gated: the scale applies only + // while the title's mod opens the gate (psx_guest_cycle_scale_gate_open). + if (cfg.contains("timing")) { + const toml::value& tm = toml::find(cfg, "timing"); + if (tm.contains("guest_cycle_scale")) { + long long n = toml::find(tm, "guest_cycle_scale"); + if (n < 1) n = 1; + if (n > 64) n = 64; + rt.guest_cycle_scale = (int)n; + } + if (tm.contains("guest_cycle_scale_gated")) + rt.guest_cycle_scale_gated = toml::find(tm, "guest_cycle_scale_gated"); + } + // Optional [video] block — visual enhancement options. Kept on the same // RuntimeConfig so main.cpp consumes them alongside the other knobs. if (cfg.contains("video")) { diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index b7536c85..5f91ae27 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -583,6 +583,11 @@ struct RuntimeConfig { // PSX_FRAME_GEN=0/1 overrides. bool video_frame_generation = false; + // [timing] guest_cycle_scale (1 = faithful, 1..64) and its gate; title + // constants from game.toml only. See config_loader.cpp. + int guest_cycle_scale = 1; + bool guest_cycle_scale_gated = false; + // present_thread: with render_thread, composed frames go to offscreen // slots and a present thread (second, shared GL context on the window) // does the copy and the swap, so the window compositor's wait does not diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 70786db9..c4f78652 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -313,6 +313,13 @@ if(BUILD_TESTING) PSX_ENABLE_BLOCK_CYCLES=1) add_test(NAME psx_cyc_batch_test COMMAND psx_cyc_batch_test) + # [timing] guest_cycle_scale divides instruction charges only (1 = faithful). + add_executable(psx_guest_cycle_scale_test tests/test_psx_guest_cycle_scale.c) + target_include_directories(psx_guest_cycle_scale_test PRIVATE include) + target_compile_definitions(psx_guest_cycle_scale_test PRIVATE + PSX_ENABLE_BLOCK_CYCLES=1) + add_test(NAME psx_guest_cycle_scale_test COMMAND psx_guest_cycle_scale_test) + # Segment misses in static game code (docs/SEGMENT_AWARE_CODE.md §5.5): # classification against the dispatch rows, the ring and the per-PC # summary the TCP command and the exit report read. diff --git a/runtime/include/psx_cyc.h b/runtime/include/psx_cyc.h index d5fb196e..1a03a1a6 100644 --- a/runtime/include/psx_cyc.h +++ b/runtime/include/psx_cyc.h @@ -92,6 +92,12 @@ static inline void psx_cyc_local_cleanup(uint32_t **guard) { static inline void psx_cyc_charge(uint32_t cycles) { if (cycles == 0u) return; +#if !defined(PSX_OVERLAY_DLL_BUILD) + if (PSX_GCS_ACTIVE()) { + cycles = psx_gcs_scale(cycles); + if (cycles == 0u) return; + } +#endif #if defined(PSX_OVERLAY_DLL_BUILD) psx_advance_cycles(cycles); #else diff --git a/runtime/include/psx_cycles.h b/runtime/include/psx_cycles.h index e2e0cbf1..204620f1 100644 --- a/runtime/include/psx_cycles.h +++ b/runtime/include/psx_cycles.h @@ -66,6 +66,42 @@ extern int g_psx_cyc_bb_defer; * via psx_cyc_local_publish / psx_cyc_batch_flush before IRQ/MMIO barriers. */ extern uint32_t *g_psx_cyc_local_acc; +/* [timing] guest_cycle_scale — a title constant, not a player setting. + * No CPU is emulated: recompiled code charges the guest clock a fixed number + * of cycles per MIPS instruction it runs, and that clock is what VBlank, + * timers, CD, SPU and DMA are scheduled against. guest_cycle_scale = N + * charges each instruction 1/N of its cost (instruction, load, mul/div and + * GTE latencies, overlay DLL batches); device time is unchanged. Larger N + * means the game never runs out of frame time; 1 is faithful and skips the + * scaling entirely. g_psx_gcs_recip_q16 is 65536/N. */ +extern uint32_t g_psx_gcs_recip_q16; +extern uint32_t g_psx_gcs_frac; +void psx_guest_cycle_scale_set(uint32_t scale); /* 1..64; 1 = faithful */ +/* Live scale: the configured one, or 1 while gated and the gate is shut. */ +uint32_t psx_guest_cycle_scale(void); +uint32_t psx_guest_cycle_scale_config(void); +/* guest_cycle_scale_gated: the scale applies only while the title's mod + * holds the gate open (psx_guest_cycle_scale_gate_open), e.g. only in a + * paced race loop where it pays off; menus and loading keep faithful timing + * (a scaled clock spends host time spinning in their poll loops). */ +void psx_guest_cycle_scale_set_gated(int gated); +void psx_guest_cycle_scale_gate_open(int open); +static inline uint32_t psx_gcs_recip_for(uint32_t scale) { + if (scale <= 1u) return 65536u; + if (scale > 64u) scale = 64u; + return (uint32_t)((65536u + scale / 2u) / scale); +} +static inline uint32_t psx_gcs_scale(uint32_t cpu_cycles) { + uint64_t t = (uint64_t)cpu_cycles * g_psx_gcs_recip_q16 + g_psx_gcs_frac; + g_psx_gcs_frac = (uint32_t)(t & 0xFFFFu); + return (uint32_t)(t >> 16); +} +#if defined(__GNUC__) || defined(__clang__) +#define PSX_GCS_ACTIVE() __builtin_expect(g_psx_gcs_recip_q16 != 65536u, 0) +#else +#define PSX_GCS_ACTIVE() (g_psx_gcs_recip_q16 != 65536u) +#endif + /* Advance guest time. Overlay DLLs forward this through their callback shim; * normal runtime/generated code keeps the common production path inlined. */ #if defined(PSX_OVERLAY_DLL_BUILD) diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 4b7a81b0..80bb6bc8 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -100,6 +100,13 @@ static inline void interp_cyc_step(CPUState *cpu, uint32_t reg_mask) { if (cpu->read_absorb[w]) { cpu->read_absorb[w]--; } else if (!g_ls_replay_active) { + if (PSX_GCS_ACTIVE()) { + uint32_t t = psx_gcs_scale(1u); + if (t) psx_advance_cycles(t); + psx_cyc_deps(cpu, reg_mask); + psx_cyc_lds(cpu); + return; + } uint64_t next = psx_cycle_count + 1u; if (!g_event_step_conservative && g_psx_cycle_fast_limit != 0u && next <= g_psx_cycle_fast_limit) { diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 2df8a9a4..c1583065 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -15782,6 +15782,21 @@ int main(int argc, char** argv) { gc.runtime.video_texture_window_batching ? 1 : 0); g_render_thread = gc.runtime.video_render_thread ? 1 : 0; g_frame_generation = gc.runtime.video_frame_generation ? 1 : 0; + /* [timing] guest_cycle_scale is a title constant from game.toml + * (no player setting). PSX_GUEST_CYCLE_SCALE overrides it for + * testing only. */ + psx_guest_cycle_scale_set_gated(gc.runtime.guest_cycle_scale_gated ? 1 : 0); + psx_guest_cycle_scale_set((uint32_t)gc.runtime.guest_cycle_scale); + if (const char* gcs = getenv("PSX_GUEST_CYCLE_SCALE")) { + const int v = atoi(gcs); + if (v > 0) psx_guest_cycle_scale_set((uint32_t)v); + } + if (psx_guest_cycle_scale_config() != 1u) + fprintf(stderr, "[timing] guest_cycle_scale %u%s (instructions charge 1/%u " + "of their guest cycles; VBlank/timers/CD/SPU/DMA unchanged)\n", + psx_guest_cycle_scale_config(), + gc.runtime.guest_cycle_scale_gated ? ", gated by the title's mod" : "", + psx_guest_cycle_scale_config()); g_present_thread = gc.runtime.video_present_thread ? 1 : 0; g_video_vsync = gc.runtime.video_vsync; g_frame_interpolation = gc.runtime.video_frame_interpolation ? 1 : 0; @@ -18878,6 +18893,26 @@ session_reboot: (net_cfg.transport == 2 || !psx_lobby_match_caps() || !psx_lobby_match_caps()->valid)) std::snprintf(net_cfg.content_fingerprint, sizeof(net_cfg.content_fingerprint), "%s", PSXRecompV4::mod_runtime_session_plan_fp().c_str()); + // [timing] guest_cycle_scale changes guest timing, so every peer must + // run the same one. It is a title constant, but fold it into the + // content gate as a safety net (a peer that differs, e.g. through + // the test env override, never matches and the session does not + // start). 1 (faithful) leaves the fingerprint, and vanilla sessions, + // exactly as before. + if (psx_guest_cycle_scale_config() != 1u) { + char tag[9]; + std::snprintf(tag, sizeof tag, "%08x", 0x6C000000u | psx_guest_cycle_scale_config()); + if (std::strlen(net_cfg.content_fingerprint) != 64) + std::snprintf(net_cfg.content_fingerprint, sizeof(net_cfg.content_fingerprint), + "%s", "0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c00000000"); + for (int i = 0; i < 8; ++i) { + char* d = &net_cfg.content_fingerprint[56 + i]; + auto hv = [](char c) { return c <= '9' ? c - '0' : (c | 0x20) - 'a' + 10; }; + *d = "0123456789abcdef"[hv(*d) ^ hv(tag[i])]; + } + std::printf("psxrecomp: netplay requires guest_cycle_scale %u on every peer\n", + psx_guest_cycle_scale_config()); + } const int nrc = psx_netplay_start(&net_cfg); if (nrc != 0) { const char* const why = netplay_start_failure(nrc, net_cfg); diff --git a/runtime/src/memory.c b/runtime/src/memory.c index c50d5b26..17777dcc 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -2301,6 +2301,11 @@ extern int g_event_step_conservative; extern int g_ls_replay_active; static inline void psx_load_charge_cycles(uint32_t cycles) { if (g_ls_replay_active || cycles == 0u) return; + if (PSX_GCS_ACTIVE()) { + cycles = psx_gcs_scale(cycles); + if (cycles) psx_advance_cycles(cycles); + return; + } uint64_t next = psx_cycle_count + (uint64_t)cycles; if (!g_event_step_conservative && g_psx_cycle_fast_limit != 0u && next >= psx_cycle_count && next <= g_psx_cycle_fast_limit) { @@ -2311,6 +2316,7 @@ static inline void psx_load_charge_cycles(uint32_t cycles) { } #else static inline void psx_load_charge_cycles(uint32_t cycles) { + if (PSX_GCS_ACTIVE()) cycles = psx_gcs_scale(cycles); psx_advance_cycles(cycles); } #endif diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index b69b0e5b..3a3f59b4 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -8,6 +8,13 @@ #include "interrupts.h" #include "debug_server.h" #include "psx_cycles.h" + +/* Overlay DLLs batch their instruction charges and publish them through this + * callback; scale them like psx_cyc_charge for [timing] guest_cycle_scale. */ +static void overlay_advance_cpu_cycles(uint32_t cycles) { + if (PSX_GCS_ACTIVE()) cycles = psx_gcs_scale(cycles); + psx_advance_cycles(cycles); +} #include "lockstep.h" #include "overlay_posix.h" #include "psx_memory.h" @@ -2493,7 +2500,7 @@ static void init_callbacks(void) { s_callbacks.check_interrupts = overlay_ci_wrapper; s_callbacks.check_interrupts_at = overlay_ci_at_wrapper; /* Address of the header inline → out-of-line copy in this TU (host side). */ - s_callbacks.advance_cycles = psx_advance_cycles; + s_callbacks.advance_cycles = overlay_advance_cpu_cycles; s_callbacks.gte_execute = gte_execute; s_callbacks.psx_syscall = psx_syscall; s_callbacks.psx_native_bad_entry = psx_native_bad_entry; diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 1869205d..3c920d0c 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -22,6 +22,40 @@ #endif uint64_t psx_cycle_count = 0; +uint32_t g_psx_gcs_recip_q16 = 65536u; +uint32_t g_psx_gcs_frac = 0u; +static uint32_t s_psx_gcs_cfg = 1u; /* [timing] guest_cycle_scale */ +static uint32_t s_psx_gcs_live = 1u; +static int s_psx_gcs_gated = 0; /* guest_cycle_scale_gated */ +static int s_psx_gcs_open = 0; + +static void psx_guest_cycle_scale_apply(void) { + uint32_t n = (!s_psx_gcs_gated || s_psx_gcs_open) ? s_psx_gcs_cfg : 1u; + if (n == s_psx_gcs_live) return; + s_psx_gcs_live = n; + g_psx_gcs_recip_q16 = psx_gcs_recip_for(n); + g_psx_gcs_frac = 0u; +} + +void psx_guest_cycle_scale_set(uint32_t scale) { + if (scale < 1u) scale = 1u; + if (scale > 64u) scale = 64u; + s_psx_gcs_cfg = scale; + psx_guest_cycle_scale_apply(); +} + +void psx_guest_cycle_scale_set_gated(int gated) { + s_psx_gcs_gated = gated ? 1 : 0; + psx_guest_cycle_scale_apply(); +} + +void psx_guest_cycle_scale_gate_open(int open) { + s_psx_gcs_open = open ? 1 : 0; + psx_guest_cycle_scale_apply(); +} + +uint32_t psx_guest_cycle_scale(void) { return s_psx_gcs_live; } +uint32_t psx_guest_cycle_scale_config(void) { return s_psx_gcs_cfg; } uint32_t g_psx_cyc_batch = 0; uint32_t g_psx_cyc_batch_limit = 0; int g_psx_cyc_bb_defer = 0; @@ -721,6 +755,7 @@ void psx_muldiv_set(CPUState* cpu, uint32_t latency) { /* The deadline belongs to this instruction, including unpublished CPU * work from generated blocks and local charge accumulators. */ psx_cyc_batch_flush(); + if (PSX_GCS_ACTIVE()) latency = psx_gcs_scale(latency); cpu->muldiv_ts_done = psx_cycle_count + (uint64_t)latency; } @@ -822,6 +857,7 @@ void psx_gte_set(CPUState* cpu, uint32_t latency) { if (cpu->gte_ts_done > psx_cycle_count) { psx_advance_cycles((uint32_t)(cpu->gte_ts_done - psx_cycle_count)); } + if (PSX_GCS_ACTIVE()) latency = psx_gcs_scale(latency); cpu->gte_ts_done = psx_cycle_count + (uint64_t)latency; } diff --git a/runtime/tests/overlay_pair_dedup_harness.c b/runtime/tests/overlay_pair_dedup_harness.c index f1da260d..860d0387 100644 --- a/runtime/tests/overlay_pair_dedup_harness.c +++ b/runtime/tests/overlay_pair_dedup_harness.c @@ -81,6 +81,9 @@ void psx_unknown_dispatch(CPUState *cpu, uint32_t addr, uint32_t phys) { } void psx_advance_cycles(uint32_t cycles) { (void)cycles; } +/* [timing] guest_cycle_scale state the loader scales overlay charges with: 1. */ +uint32_t g_psx_gcs_recip_q16 = 65536u; +uint32_t g_psx_gcs_frac = 0u; uint64_t psx_get_cycle_count(void) { return 0; } int psx_cycle_replay_begin(uint64_t cycle) { (void)cycle; return 1; } uint64_t psx_cycle_replay_end(void) { return 0; } diff --git a/runtime/tests/test_psx_cyc_batch.c b/runtime/tests/test_psx_cyc_batch.c index 2c583409..46f29744 100644 --- a/runtime/tests/test_psx_cyc_batch.c +++ b/runtime/tests/test_psx_cyc_batch.c @@ -16,6 +16,8 @@ int g_ls_mode = 0; volatile int g_ds_recording = 0; uint8_t *g_psx_ram = 0; int g_psx_load_delay = 1; +uint32_t g_psx_gcs_recip_q16 = 65536u; +uint32_t g_psx_gcs_frac = 0u; static int service_count; diff --git a/runtime/tests/test_psx_guest_cycle_scale.c b/runtime/tests/test_psx_guest_cycle_scale.c new file mode 100644 index 00000000..0f5d6906 --- /dev/null +++ b/runtime/tests/test_psx_guest_cycle_scale.c @@ -0,0 +1,59 @@ +/* [timing] guest_cycle_scale: instruction charges reach the guest clock at + * 1/N of their cost; 1 leaves every charge exactly as it was. */ +#include "psx_cyc.h" + +#include +#include + +uint64_t psx_cycle_count = 0; +uint64_t psx_next_service_cycle = 0; +uint32_t g_psx_cyc_batch = 0; +uint32_t g_psx_cyc_batch_limit = 0; +int g_psx_cyc_bb_defer = 0; +uint32_t *g_psx_cyc_local_acc = 0; +int psx_in_device_service = 0; +int g_event_step_conservative = 0; +int g_ls_replay_active = 0; +int g_ls_mode = 0; +volatile int g_ds_recording = 0; +uint8_t *g_psx_ram = 0; +int g_psx_load_delay = 1; +uint32_t g_psx_gcs_recip_q16 = 65536u; +uint32_t g_psx_gcs_frac = 0u; + +void psx_devices_service_to_now(void) { psx_next_service_cycle = psx_cycle_count + 100000u; } +void psx_advance_cycles_slow(uint32_t cycles) { psx_cycle_count += cycles; } + +static uint64_t run(uint32_t scale, uint32_t charges, uint32_t each) { + g_psx_gcs_recip_q16 = psx_gcs_recip_for(scale); + g_psx_gcs_frac = 0; + psx_cycle_count = 0; + psx_next_service_cycle = 1; + g_psx_cyc_batch = 0; + g_psx_cyc_batch_limit = 0; + for (uint32_t i = 0; i < charges; i++) psx_cyc_charge(each); + psx_cyc_batch_flush(); + return psx_cycle_count; +} + +int main(void) { + assert(psx_gcs_recip_for(1) == 65536u); + assert(psx_gcs_recip_for(0) == 65536u); /* never slower than faithful */ + assert(psx_gcs_recip_for(2) == 32768u); + assert(psx_gcs_recip_for(16) == 4096u); + assert(psx_gcs_recip_for(999) == 1024u); /* clamped to 64 */ + + /* 1 (faithful): identical totals for every charge size. */ + assert(run(1, 1000, 1) == 1000u); + assert(run(1, 1000, 7) == 7000u); + + /* Power-of-two scales: no drift across many 1-cycle charges. */ + assert(run(2, 1000, 1) == 500u); + assert(run(4, 1000, 5) == 1250u); + assert(run(16, 160000, 1) == 10000u); + + /* Non-power-of-two keeps the fraction: scale 3 of 3000 cycles is ~1000. */ + uint64_t t = run(3, 3000, 1); + assert(t >= 999u && t <= 1001u); + return 0; +} From 91cd6db70584c76f8b8ac6bd079632bdac7b9b3f Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:13:26 -0700 Subject: [PATCH 2/3] guest_cycle_scale: one CPU charge for every tier, declarative gate, rollback Review of #561. Guest time now has two entry points (psx_cycles.h): the CPU charge psx_cpu_charge() (instruction base, load fudge/region/completion, i-cache refills, BIOS HLE costs), which applies [timing] guest_cycle_scale, and the unscaled device advance psx_advance_cycles() (DMA, idle skips, mul/div and GTE deadline waits). The emitters' block-mode charges emit psx_cpu_charge; overlay DLLs accumulate it raw and publish through the cpu_charge callback (was advance_cycles, same slot) into the same host function, so nothing is scaled at the DLL boundary. The batched psx_cyc_charge path, the dirty-RAM interpreter, psx_cyc_readmem, i-cache refills and bios_hle.c all charge through it. At scale 1 it is exactly psx_advance_cycles. Gate in psxrecomp: [timing] guest_cycle_scale_gate, RAM predicates judged at every VBlank edge (deterministic, works online, no mod needed), plus psx_mod_set_guest_cycle_scale_gate() in mod_plugins.h for custom logic or to tie the scale to a mod (guest_cycle_scale_gated = true; every mod session reset shuts it). The carried fraction and both gate states are a boot_state section (BS_SEC_GCS, written only when the scale is not 1), so savestates and the netplay rollback snapshot re-simulate the same cycles. psx_guest_cycle_scale_gate_test pins tier identity, the gates and rollback re-simulation against the real psx_cycles.c. OpenBIOS regenerated (emitter stamp); retail SCPH1001 is not. Co-Authored-By: Claude Opus 5.5 --- docs/config_schema.md | 57 +++- generated/OpenBIOS_full.c | 2 +- overlay_toolchain/include/boot_state.h | 4 + overlay_toolchain/include/overlay_api.h | 9 +- .../include/overlay_dispatch_preamble.c.inc | 10 +- overlay_toolchain/include/psx_cyc.h | 15 +- overlay_toolchain/include/psx_cycles.h | 70 ++++- recompiler/src/code_generator.cpp | 6 +- recompiler/src/config_loader.cpp | 27 +- recompiler/src/config_loader.h | 9 + recompiler/src/full_function_emitter.cpp | 4 +- .../tests/test_segment_aware_codegen.py | 1 + .../tests/test_uncached_fetch_charge.py | 1 + .../tests/video_enhancement_settings_test.cpp | 42 +++ runtime/CMakeLists.txt | 10 + runtime/include/boot_state.h | 4 + runtime/include/mod_plugins.h | 10 + runtime/include/overlay_api.h | 9 +- .../include/overlay_dispatch_preamble.c.inc | 10 +- runtime/include/psx_cyc.h | 15 +- runtime/include/psx_cycles.h | 70 ++++- runtime/src/bios_hle.c | 10 +- runtime/src/boot_state.c | 26 +- runtime/src/dirty_ram_interp.c | 3 +- runtime/src/interrupts.c | 3 + runtime/src/main.cpp | 26 +- runtime/src/memory.c | 8 +- runtime/src/overlay_loader.c | 12 +- runtime/src/psx_cycles.c | 76 +++++- runtime/src/psx_icache.c | 4 +- runtime/tests/overlay_pair_dedup_harness.c | 5 +- runtime/tests/psx_ram_runtime_map_stubs.c | 6 + runtime/tests/test_psx_guest_cycle_scale.c | 1 + .../tests/test_psx_guest_cycle_scale_gate.c | 247 ++++++++++++++++++ runtime/tests/test_psx_icache_elision_guard.c | 2 + runtime/tests/test_psx_icache_fastpath.c | 2 + .../test_overlay_widescreen_callbacks.py | 2 +- 37 files changed, 725 insertions(+), 93 deletions(-) create mode 100644 runtime/tests/test_psx_guest_cycle_scale_gate.c diff --git a/docs/config_schema.md b/docs/config_schema.md index 07384bde..16102b0d 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -733,24 +733,53 @@ Title constants. They live in the game's `game.toml`, are never written to ```toml [timing] guest_cycle_scale = 1 # 1..64; 1 = faithful (default) -guest_cycle_scale_gated = false # scale only while the title's mod opens the gate +# Optional declarative gate: the scale applies only while every predicate +# holds, judged at each VBlank edge (one inline table or an array, max 8). +guest_cycle_scale_gate = [ + { addr = 0x800AC794, value = 0x180 }, # size = 4, mask = ~0 + # { addr = 0x80010003, size = 1, mask = 0x0F, value = 5 }, +] +guest_cycle_scale_gated = false # also require the mod gate (custom logic) ``` No CPU is emulated. Recompiled code charges the guest clock a fixed number of -cycles for each MIPS instruction it runs (plus load, mul/div and GTE -latencies), and VBlank, timers, CD, SPU and DMA are scheduled against that -clock. `guest_cycle_scale = N` charges each instruction 1/N of its cost; the -devices keep hardware time. Larger N means the game never runs out of frame -time: N times as much game code fits between two VBlanks. A game that paces -itself on VBlank keeps its speed; one that busy-waits on a frame counter -spins longer in host time, which is why `guest_cycle_scale_gated` exists. -With it set, the scale applies only while the title's trusted mod holds the -gate open (`psx_guest_cycle_scale_gate_open(1)`), e.g. R4 opens it during -races; menus and loading keep faithful timing. +cycles for each MIPS instruction it runs, and VBlank, timers, CD, SPU and DMA +are scheduled against that clock. `guest_cycle_scale = N` charges each +instruction 1/N of its cost; the devices keep hardware time. Larger N means +the game never runs out of frame time: N times as much game code fits +between two VBlanks. A game that paces itself on VBlank keeps its speed; one +that busy-waits on a frame counter spins longer in host time, which is what +the gates are for. -A scale other than 1 is folded into the netplay content fingerprint, so peers -that differ never match. `PSX_GUEST_CYCLE_SCALE=` overrides the scale for -testing only. +Accounting is the same on every tier. Guest time has two entry points +(`psx_cycles.h`): the **CPU charge** `psx_cpu_charge()` (instruction base +cost, load fudge/region wait/completion, i-cache refills, BIOS HLE costs), +which applies the scale, and the **device advance** `psx_advance_cycles()` +(DMA, idle skips, mul/div and GTE deadline waits), which never does. +Native generated code, overlay DLLs (their batched charge publishes through +the `cpu_charge` callback into the same function), the dirty-RAM +interpreter and the BIOS all charge CPU work through it; mul/div and GTE +latencies are scaled where their deadline is set. At 1 the CPU charge is +exactly the device advance and nothing else changes. + +Gates (all present gates must be open; with none the scale always applies): + +- `guest_cycle_scale_gate` — RAM predicates on aligned main-RAM words + (`(word & mask) == value`, `size` 1/2/4), judged at every VBlank edge, so + every netplay peer and every rollback re-simulation sees the same verdict. + Shut until the first VBlank that sees them hold. Needs no mod. +- `guest_cycle_scale_gated = true` — a trusted plugin opens the gate with + `psx_mod_set_guest_cycle_scale_gate(1)` (`mod_plugins.h`) for logic a + predicate cannot express, or to tie the scale to a mod that must be active + for it (every mod session reset shuts the gate; mod plans are cleared + online, so a mod-gated scale stays out of netplay). With only the + declarative gate the scale applies online too. + +The carried fraction of the scaled clock and both gate states are saved in +savestates and the netplay rollback snapshot (`boot_state` section +`BS_SEC_GCS`, written only when the scale is not 1). A scale other than 1 is +folded into the netplay content fingerprint, so peers that differ never +match. `PSX_GUEST_CYCLE_SCALE=` overrides the scale for testing only. ## Audit block diff --git a/generated/OpenBIOS_full.c b/generated/OpenBIOS_full.c index eb2ecf2a..aa6ac929 100644 --- a/generated/OpenBIOS_full.c +++ b/generated/OpenBIOS_full.c @@ -31,7 +31,7 @@ extern uint32_t g_debug_last_store_pc; * Compile generated code with -DPSX_ENABLE_BLOCK_CYCLES=1 to * activate cycle advancement at every block leader. */ #ifdef PSX_ENABLE_BLOCK_CYCLES -extern void psx_advance_cycles(uint32_t cycles); +extern void psx_cpu_charge(uint32_t cycles); #endif void OpenBIOS_func_1FC00000(CPUState* cpu); diff --git a/overlay_toolchain/include/boot_state.h b/overlay_toolchain/include/boot_state.h index 27a94b44..7561540b 100644 --- a/overlay_toolchain/include/boot_state.h +++ b/overlay_toolchain/include/boot_state.h @@ -103,6 +103,10 @@ enum { BS_SEC_SIO = 0x0D, /* SIO regs + pad-config FSM + memcard FSM */ BS_SEC_DIRTY = 0x0E, /* dirty-RAM page bitmap (guest-written code pages) */ BS_SEC_MDEC = 0x0F, /* MDEC command/FIFOs/quant/scale (FMV decode resume) */ + BS_SEC_GCS = 0x13, /* [timing] guest_cycle_scale: carried fraction + gate + state (3 u32). Written only when the title's + scale is not 1, so faithful states are unchanged; + absent => fraction 0, gates as they are. */ BS_SEC_ICACHE = 0x10, /* R3000A I-cache tag/valid words (1024 u32) — fetch cost model. Host-persistent otherwise: a warm load without it replays with the pre-load timeline's diff --git a/overlay_toolchain/include/overlay_api.h b/overlay_toolchain/include/overlay_api.h index 237b854b..fc19550f 100644 --- a/overlay_toolchain/include/overlay_api.h +++ b/overlay_toolchain/include/overlay_api.h @@ -230,11 +230,14 @@ typedef struct OverlayCallbacks { /* Interrupt check with the guest PC that should resume if a game-installed * handler later RFEs to the sentinel outside the synchronous host window. */ void (*check_interrupts_at)(CPUState *cpu, uint32_t resume_pc); - /* Guest-cycle accounting (ABI v7): block-cycle charge. Overlay code built + /* Guest-cycle accounting (ABI v7): the CPU charge. Overlay code built * with PSX_ENABLE_BLOCK_CYCLES must charge the SAME shared host cycle/timer * timeline as the dirty-RAM interpreter and the BIOS, or timer-sensitive - * code reads different values per backend (Tomba2 logo Timer1 fork). */ - void (*advance_cycles)(uint32_t cycles); + * code reads different values per backend (Tomba2 logo Timer1 fork). The + * host binds its psx_cpu_charge (psx_cycles.h), so [timing] + * guest_cycle_scale applies to overlay code exactly as to native code. + * (Named advance_cycles before; same slot, same arguments.) */ + void (*cpu_charge)(uint32_t cycles); /* GTE coprocessor 2 execution */ void (*gte_execute)(CPUState *cpu, uint32_t cmd); /* MIPS syscall (break/syscall instructions). Returns 1 if control diff --git a/overlay_toolchain/include/overlay_dispatch_preamble.c.inc b/overlay_toolchain/include/overlay_dispatch_preamble.c.inc index 9a0dca67..bc7d1c64 100644 --- a/overlay_toolchain/include/overlay_dispatch_preamble.c.inc +++ b/overlay_toolchain/include/overlay_dispatch_preamble.c.inc @@ -8,7 +8,7 @@ static uint32_t s_pending_cycles; PSX_OVERLAY_EXPORT void overlay_flush_cycles(void) { uint32_t cycles = s_pending_cycles; s_pending_cycles = 0; - if (cycles && g_cbs.advance_cycles) g_cbs.advance_cycles(cycles); + if (cycles && g_cbs.cpu_charge) g_cbs.cpu_charge(cycles); } /* Call-contract state (ABI v2): generated code reads/bumps the runtime's @@ -53,10 +53,16 @@ void psx_check_interrupts_at(CPUState *cpu, uint32_t resume_pc) { if (g_cbs.check_interrupts_at) g_cbs.check_interrupts_at(cpu, resume_pc); else g_cbs.check_interrupts(cpu); } -void psx_advance_cycles(uint32_t cycles) { +/* The CPU charge (psx_cycles.h): overlay code is guest CPU work, so its + * charges accumulate here raw and publish through cpu_charge, the host's + * psx_cpu_charge, which applies [timing] guest_cycle_scale exactly as it does + * for native code. psx_advance_cycles is kept as the same accumulator for + * code generated before the emitter named the CPU charge. */ +void psx_cpu_charge(uint32_t cycles) { if (cycles > UINT32_MAX - s_pending_cycles) overlay_flush_cycles(); s_pending_cycles += cycles; } +void psx_advance_cycles(uint32_t cycles) { psx_cpu_charge(cycles); } void gte_execute(CPUState *cpu, uint32_t cmd) { overlay_flush_cycles(); g_cbs.gte_execute(cpu, cmd); diff --git a/overlay_toolchain/include/psx_cyc.h b/overlay_toolchain/include/psx_cyc.h index 1a03a1a6..0a95f0ff 100644 --- a/overlay_toolchain/include/psx_cyc.h +++ b/overlay_toolchain/include/psx_cyc.h @@ -92,15 +92,16 @@ static inline void psx_cyc_local_cleanup(uint32_t **guard) { static inline void psx_cyc_charge(uint32_t cycles) { if (cycles == 0u) return; -#if !defined(PSX_OVERLAY_DLL_BUILD) - if (PSX_GCS_ACTIVE()) { - cycles = psx_gcs_scale(cycles); - if (cycles == 0u) return; - } -#endif #if defined(PSX_OVERLAY_DLL_BUILD) - psx_advance_cycles(cycles); + psx_cpu_charge(cycles); #else + /* [timing] guest_cycle_scale: a scaled charge is the CPU charge itself + * (the one place CPU work is scaled); the batch below is the scale-1 + * production path, unchanged. */ + if (PSX_GCS_ACTIVE()) { + psx_cpu_charge(cycles); + return; + } #if defined(__GNUC__) || defined(__clang__) if (__builtin_expect(g_ls_replay_active | g_event_step_conservative, 0)) { #else diff --git a/overlay_toolchain/include/psx_cycles.h b/overlay_toolchain/include/psx_cycles.h index 204620f1..dd72f819 100644 --- a/overlay_toolchain/include/psx_cycles.h +++ b/overlay_toolchain/include/psx_cycles.h @@ -70,22 +70,53 @@ extern uint32_t *g_psx_cyc_local_acc; * No CPU is emulated: recompiled code charges the guest clock a fixed number * of cycles per MIPS instruction it runs, and that clock is what VBlank, * timers, CD, SPU and DMA are scheduled against. guest_cycle_scale = N - * charges each instruction 1/N of its cost (instruction, load, mul/div and - * GTE latencies, overlay DLL batches); device time is unchanged. Larger N - * means the game never runs out of frame time; 1 is faithful and skips the - * scaling entirely. g_psx_gcs_recip_q16 is 65536/N. */ + * charges each instruction 1/N of its cost; device time is unchanged. + * Larger N means the game never runs out of frame time; 1 is faithful and + * skips the scaling entirely. g_psx_gcs_recip_q16 is 65536/N, g_psx_gcs_frac + * the carried fraction (part of the rollback/savestate snapshot). + * + * Two kinds of guest-time charge, and only these two: + * psx_cpu_charge(n) CPU work: instruction base cost, load costs + * (fudge, region wait, completion), i-cache refills, + * BIOS HLE costs. Scaled. Every tier (native + * generated code, overlay DLLs, the dirty-RAM + * interpreter, the BIOS) charges CPU work through + * it; the batched psx_cyc_charge hot path hands its + * charge to it whenever the scale is active. + * psx_advance_cycles(n) device time and stalls already measured in guest + * time (DMA, idle skips, mul/div and GTE deadline + * waits). Never scaled. + * Mul/div and GTE latencies are CPU work too: psx_cpu_cycles() scales the + * deadline they set, so the later stall waits the scaled time. */ extern uint32_t g_psx_gcs_recip_q16; extern uint32_t g_psx_gcs_frac; void psx_guest_cycle_scale_set(uint32_t scale); /* 1..64; 1 = faithful */ /* Live scale: the configured one, or 1 while gated and the gate is shut. */ uint32_t psx_guest_cycle_scale(void); uint32_t psx_guest_cycle_scale_config(void); -/* guest_cycle_scale_gated: the scale applies only while the title's mod - * holds the gate open (psx_guest_cycle_scale_gate_open), e.g. only in a - * paced race loop where it pays off; menus and loading keep faithful timing - * (a scaled clock spends host time spinning in their poll loops). */ +/* Gates (all must be open for the scale to apply; with none it always does): + * - RAM gate: [timing] guest_cycle_scale_gate, predicates on guest RAM + * words evaluated at every VBlank edge (deterministic, works online). + * - Mod gate: guest_cycle_scale_gated = true; a trusted plugin opens it + * with psx_mod_set_guest_cycle_scale_gate() (mod_plugins.h). */ void psx_guest_cycle_scale_set_gated(int gated); void psx_guest_cycle_scale_gate_open(int open); +enum { PSX_GCS_RAM_GATE_MAX = 8 }; +/* addr: guest main-RAM address (any segment), size 1/2/4, open while + * (word & mask) == value. Returns 0 for a bad predicate. Clear with + * psx_guest_cycle_scale_ram_gate_clear(). */ +int psx_guest_cycle_scale_ram_gate_add(uint32_t addr, uint32_t size, + uint32_t mask, uint32_t value); +void psx_guest_cycle_scale_ram_gate_clear(void); +/* How the RAM gate reads guest RAM (main.cpp installs a main-RAM reader; + * phys is masked physical, aligned to size). */ +void psx_guest_cycle_scale_set_ram_reader(uint32_t (*read)(uint32_t phys, uint32_t size)); +/* Called on each VBlank edge (interrupts.c); no-op without a RAM gate. */ +void psx_guest_cycle_scale_vblank(void); +/* Rollback/savestate: carried fraction + gate state (boot_state BS_SEC_GCS). */ +enum { PSX_GCS_SNAPSHOT_BYTES = 12 }; +void psx_guest_cycle_scale_snapshot(uint32_t out[3]); +void psx_guest_cycle_scale_restore(const uint32_t in[3]); static inline uint32_t psx_gcs_recip_for(uint32_t scale) { if (scale <= 1u) return 65536u; if (scale > 64u) scale = 64u; @@ -101,6 +132,12 @@ static inline uint32_t psx_gcs_scale(uint32_t cpu_cycles) { #else #define PSX_GCS_ACTIVE() (g_psx_gcs_recip_q16 != 65536u) #endif +#if !defined(PSX_OVERLAY_DLL_BUILD) +/* CPU cycles -> guest cycles. Identity at scale 1 (no state touched). */ +static inline uint32_t psx_cpu_cycles(uint32_t cpu_cycles) { + return PSX_GCS_ACTIVE() ? psx_gcs_scale(cpu_cycles) : cpu_cycles; +} +#endif /* Advance guest time. Overlay DLLs forward this through their callback shim; * normal runtime/generated code keeps the common production path inlined. */ @@ -159,6 +196,23 @@ static inline void psx_advance_cycles(uint32_t cycles) { } #endif +/* The CPU charge (see above): every tier's CPU work is scaled in this one + * function (psx_cpu_cycles does the same for mul/div and GTE deadlines, + * which are set, not charged). At scale 1 it is exactly psx_advance_cycles(cycles). Overlay DLLs + * accumulate it locally (overlay_dispatch_preamble.c.inc) and publish the + * raw total through the cpu_charge callback, which lands in this function. */ +#if defined(PSX_OVERLAY_DLL_BUILD) +void psx_cpu_charge(uint32_t cycles); +#else +static inline void psx_cpu_charge(uint32_t cycles) { + if (PSX_GCS_ACTIVE()) { + cycles = psx_gcs_scale(cycles); + if (cycles == 0u) return; + } + psx_advance_cycles(cycles); +} +#endif + /* Publish deferred charges (IRQ edge / MMIO / savestate). Overlay DLLs keep * their pending total in the callback shim rather than these host globals. */ #if defined(PSX_OVERLAY_DLL_BUILD) diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 0baaf4aa..9e280940 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -165,7 +165,9 @@ std::string CodeGenerator::emit_mid_block_cycle_charge(uint32_t addr, std::stringstream ss; ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - ss << indent << fmt::format("psx_advance_cycles({}u);\n", cycles); + // The CPU charge (psx_cycles.h): the same call in native and overlay + // DLL code; [timing] guest_cycle_scale is applied inside it. + ss << indent << fmt::format("psx_cpu_charge({}u);\n", cycles); ss << "#endif\n"; return ss.str(); } @@ -2087,7 +2089,7 @@ std::string CodeGenerator::translate_basic_block( }; if (!cycle_per_insn && block_exec_cycles > 0) { ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - ss << config_.indent << fmt::format("psx_advance_cycles({}u);\n", + ss << config_.indent << fmt::format("psx_cpu_charge({}u);\n", block_exec_cycles); ss << "#endif\n"; } diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index be147d18..ccf1e466 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -619,7 +619,8 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& // it, so N times as much game code fits between two VBlanks while // VBlank, timers, CD, SPU and DMA keep hardware time. 1 = faithful; // clamped to 1..64. guest_cycle_scale_gated: the scale applies only - // while the title's mod opens the gate (psx_guest_cycle_scale_gate_open). + // while the mod gate is open (psx_mod_set_guest_cycle_scale_gate); see + // guest_cycle_scale_gate below for the declarative RAM gate. if (cfg.contains("timing")) { const toml::value& tm = toml::find(cfg, "timing"); if (tm.contains("guest_cycle_scale")) { @@ -630,6 +631,30 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& } if (tm.contains("guest_cycle_scale_gated")) rt.guest_cycle_scale_gated = toml::find(tm, "guest_cycle_scale_gated"); + // guest_cycle_scale_gate: one inline table or an array of them, + // { addr, value, size = 4, mask = 0xFFFFFFFF }; all must hold. + if (tm.contains("guest_cycle_scale_gate")) { + const toml::value& g = toml::find(tm, "guest_cycle_scale_gate"); + std::vector preds; + if (g.is_array()) preds = g.as_array(); + else preds.push_back(g); + if (preds.size() > 8) + throw std::runtime_error("[timing] guest_cycle_scale_gate: at most 8 predicates"); + for (const toml::value& pv : preds) { + RuntimeConfig::GuestCycleScaleGatePred p; + p.addr = (uint32_t)toml::find(pv, "addr"); + p.value = (uint32_t)toml::find(pv, "value"); + if (pv.contains("size")) p.size = (uint32_t)toml::find(pv, "size"); + if (pv.contains("mask")) p.mask = (uint32_t)toml::find(pv, "mask"); + const uint32_t phys = p.addr & 0x1FFFFFFFu; + if ((p.size != 1 && p.size != 2 && p.size != 4) || + (phys & (p.size - 1u)) != 0 || phys >= 0x00800000u) + throw std::runtime_error( + "[timing] guest_cycle_scale_gate: addr must be an aligned " + "main-RAM address and size 1, 2 or 4"); + rt.guest_cycle_scale_gate.push_back(p); + } + } } // Optional [video] block — visual enhancement options. Kept on the same diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 5f91ae27..302b0908 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -587,6 +587,15 @@ struct RuntimeConfig { // constants from game.toml only. See config_loader.cpp. int guest_cycle_scale = 1; bool guest_cycle_scale_gated = false; + // guest_cycle_scale_gate: declarative RAM gate, judged at every VBlank; + // the scale applies only while every predicate holds. + struct GuestCycleScaleGatePred { + uint32_t addr = 0; // guest main-RAM address (any segment) + uint32_t size = 4; // 1, 2 or 4 bytes, aligned + uint32_t mask = 0xFFFFFFFFu; + uint32_t value = 0; // open while (word & mask) == value + }; + std::vector guest_cycle_scale_gate; // present_thread: with render_thread, composed frames go to offscreen // slots and a present thread (second, shared GL context on the window) diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index 78dd7d53..515ded2d 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -954,7 +954,7 @@ bool FullFunctionEmitter::emit_function( if (bcit != block_cycles.end()) bcyc = bcit->second; if (bcyc > 0) { out += "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - out += fmt::format(" psx_advance_cycles({}u);\n", bcyc); + out += fmt::format(" psx_cpu_charge({}u);\n", bcyc); out += "#endif\n"; } } @@ -2644,7 +2644,7 @@ EmitStats FullFunctionEmitter::emit( full_c += " * Compile generated code with -DPSX_ENABLE_BLOCK_CYCLES=1 to\n"; full_c += " * activate cycle advancement at every block leader. */\n"; full_c += "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - full_c += "extern void psx_advance_cycles(uint32_t cycles);\n"; + full_c += "extern void psx_cpu_charge(uint32_t cycles);\n"; full_c += "#endif\n\n"; // Forward declare ALL functions so intra-file calls resolve. diff --git a/recompiler/tests/test_segment_aware_codegen.py b/recompiler/tests/test_segment_aware_codegen.py index d0bd982b..9593329a 100644 --- a/recompiler/tests/test_segment_aware_codegen.py +++ b/recompiler/tests/test_segment_aware_codegen.py @@ -128,6 +128,7 @@ ICACHE_HARNESS = r""" int g_ls_replay_active = 0; static unsigned long long cycles; void psx_advance_cycles(uint32_t c) { cycles += c; } +void psx_cpu_charge(uint32_t c) { cycles += c; } int main(void) { CPUState cpu; char line[64]; memset(&cpu, 0, sizeof cpu); diff --git a/recompiler/tests/test_uncached_fetch_charge.py b/recompiler/tests/test_uncached_fetch_charge.py index 67a4b29f..bab47136 100644 --- a/recompiler/tests/test_uncached_fetch_charge.py +++ b/recompiler/tests/test_uncached_fetch_charge.py @@ -105,6 +105,7 @@ HARNESS = r""" int g_ls_replay_active = 0; static unsigned long long cycles; void psx_advance_cycles(uint32_t c) { cycles += c; } +void psx_cpu_charge(uint32_t c) { cycles += c; } int main(void) { static CPUState cpu; char line[64]; diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp index 587b5f20..160ac3f1 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -460,6 +460,47 @@ static void test_pgxp_title_keys() { fs::remove(p); } +/* [timing] guest_cycle_scale and its declarative RAM gate (title constants). */ +static void test_timing_gate() { + fs::path p = write_game_toml("ves_timing_none.toml", ""); + auto gc = PSXRecompV4::load_game_config(p); + check(gc.runtime.guest_cycle_scale == 1, "timing: default scale 1"); + check(gc.runtime.guest_cycle_scale_gate.empty(), "timing: no gate by default"); + check(!gc.runtime.guest_cycle_scale_gated, "timing: mod gate off by default"); + fs::remove(p); + + p = write_game_toml("ves_timing_one.toml", + "[timing]\nguest_cycle_scale = 64\n" + "guest_cycle_scale_gate = { addr = 0x800AC794, value = 0x180 }\n"); + gc = PSXRecompV4::load_game_config(p); + check(gc.runtime.guest_cycle_scale == 64, "timing: scale 64"); + check(gc.runtime.guest_cycle_scale_gate.size() == 1 && + gc.runtime.guest_cycle_scale_gate[0].addr == 0x800AC794u && + gc.runtime.guest_cycle_scale_gate[0].value == 0x180u && + gc.runtime.guest_cycle_scale_gate[0].size == 4u && + gc.runtime.guest_cycle_scale_gate[0].mask == 0xFFFFFFFFu, + "timing: single inline-table gate with defaults"); + fs::remove(p); + + p = write_game_toml("ves_timing_arr.toml", + "[timing]\nguest_cycle_scale = 8\nguest_cycle_scale_gated = true\n" + "guest_cycle_scale_gate = [ { addr = 0x800AC794, value = 0x180 },\n" + " { addr = 0x00010003, size = 1, mask = 0x0F, value = 5 } ]\n"); + gc = PSXRecompV4::load_game_config(p); + check(gc.runtime.guest_cycle_scale_gate.size() == 2 && + gc.runtime.guest_cycle_scale_gate[1].size == 1u && + gc.runtime.guest_cycle_scale_gate[1].mask == 0x0Fu && + gc.runtime.guest_cycle_scale_gated, "timing: gate array + mod gate"); + fs::remove(p); + + p = write_game_toml("ves_timing_bad.toml", + "[timing]\nguest_cycle_scale_gate = { addr = 0x1F801070, value = 1 }\n"); + bool rejected = false; + try { (void)PSXRecompV4::load_game_config(p); } catch (const std::exception&) { rejected = true; } + check(rejected, "timing: non-RAM gate address rejected"); + fs::remove(p); +} + int main() { test_internal_resolution_game_toml(); test_internal_resolution_settings(); @@ -477,6 +518,7 @@ int main() { test_present_thread(); test_pipeline_user_settings(); test_pgxp_title_keys(); + test_timing_gate(); if (failures) { std::fprintf(stderr, "video_enhancement_settings_test: %d failure(s)\n", diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index c4f78652..68d7ba4f 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -320,6 +320,16 @@ if(BUILD_TESTING) PSX_ENABLE_BLOCK_CYCLES=1) add_test(NAME psx_guest_cycle_scale_test COMMAND psx_guest_cycle_scale_test) + # One CPU charge for every tier, the declarative RAM gate and the mod gate, + # and rollback re-simulation, against the real psx_cycles.c. + add_executable(psx_guest_cycle_scale_gate_test + tests/test_psx_guest_cycle_scale_gate.c + src/psx_cycles.c) + target_include_directories(psx_guest_cycle_scale_gate_test PRIVATE include) + target_compile_definitions(psx_guest_cycle_scale_gate_test PRIVATE + PSX_ENABLE_BLOCK_CYCLES=1) + add_test(NAME psx_guest_cycle_scale_gate_test COMMAND psx_guest_cycle_scale_gate_test) + # Segment misses in static game code (docs/SEGMENT_AWARE_CODE.md §5.5): # classification against the dispatch rows, the ring and the per-PC # summary the TCP command and the exit report read. diff --git a/runtime/include/boot_state.h b/runtime/include/boot_state.h index 27a94b44..7561540b 100644 --- a/runtime/include/boot_state.h +++ b/runtime/include/boot_state.h @@ -103,6 +103,10 @@ enum { BS_SEC_SIO = 0x0D, /* SIO regs + pad-config FSM + memcard FSM */ BS_SEC_DIRTY = 0x0E, /* dirty-RAM page bitmap (guest-written code pages) */ BS_SEC_MDEC = 0x0F, /* MDEC command/FIFOs/quant/scale (FMV decode resume) */ + BS_SEC_GCS = 0x13, /* [timing] guest_cycle_scale: carried fraction + gate + state (3 u32). Written only when the title's + scale is not 1, so faithful states are unchanged; + absent => fraction 0, gates as they are. */ BS_SEC_ICACHE = 0x10, /* R3000A I-cache tag/valid words (1024 u32) — fetch cost model. Host-persistent otherwise: a warm load without it replays with the pre-load timeline's diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 87da26f5..353d298b 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -93,6 +93,16 @@ void psx_mod_set_texture_filter(int mode); * Save/load and rewind must wait until that host context has returned. */ int psx_mod_function_entry_active(void); +/* [timing] guest_cycle_scale mod gate (docs/config_schema.md, Timing block). + * With guest_cycle_scale_gated = true in game.toml the scale applies only + * while a trusted plugin holds this gate open (and any declarative + * guest_cycle_scale_gate RAM predicates hold). Every mod session reset + * shuts it; open it from the activation callback. Mod plans are cleared + * online, so this gate keeps a scale that depends on a mod out of netplay. + * The gate state is part of savestates and the netplay rollback snapshot. */ +void psx_mod_set_guest_cycle_scale_gate(int open); +/* Live scale: the configured one while every gate is open, else 1. */ +uint32_t psx_mod_guest_cycle_scale(void); /* Narrow guest services available to trusted plugin callbacks. */ int psx_mod_game_started(void); /* Read an original mounted-disc file without changing guest CD state/timing. diff --git a/runtime/include/overlay_api.h b/runtime/include/overlay_api.h index 237b854b..fc19550f 100644 --- a/runtime/include/overlay_api.h +++ b/runtime/include/overlay_api.h @@ -230,11 +230,14 @@ typedef struct OverlayCallbacks { /* Interrupt check with the guest PC that should resume if a game-installed * handler later RFEs to the sentinel outside the synchronous host window. */ void (*check_interrupts_at)(CPUState *cpu, uint32_t resume_pc); - /* Guest-cycle accounting (ABI v7): block-cycle charge. Overlay code built + /* Guest-cycle accounting (ABI v7): the CPU charge. Overlay code built * with PSX_ENABLE_BLOCK_CYCLES must charge the SAME shared host cycle/timer * timeline as the dirty-RAM interpreter and the BIOS, or timer-sensitive - * code reads different values per backend (Tomba2 logo Timer1 fork). */ - void (*advance_cycles)(uint32_t cycles); + * code reads different values per backend (Tomba2 logo Timer1 fork). The + * host binds its psx_cpu_charge (psx_cycles.h), so [timing] + * guest_cycle_scale applies to overlay code exactly as to native code. + * (Named advance_cycles before; same slot, same arguments.) */ + void (*cpu_charge)(uint32_t cycles); /* GTE coprocessor 2 execution */ void (*gte_execute)(CPUState *cpu, uint32_t cmd); /* MIPS syscall (break/syscall instructions). Returns 1 if control diff --git a/runtime/include/overlay_dispatch_preamble.c.inc b/runtime/include/overlay_dispatch_preamble.c.inc index 9a0dca67..bc7d1c64 100644 --- a/runtime/include/overlay_dispatch_preamble.c.inc +++ b/runtime/include/overlay_dispatch_preamble.c.inc @@ -8,7 +8,7 @@ static uint32_t s_pending_cycles; PSX_OVERLAY_EXPORT void overlay_flush_cycles(void) { uint32_t cycles = s_pending_cycles; s_pending_cycles = 0; - if (cycles && g_cbs.advance_cycles) g_cbs.advance_cycles(cycles); + if (cycles && g_cbs.cpu_charge) g_cbs.cpu_charge(cycles); } /* Call-contract state (ABI v2): generated code reads/bumps the runtime's @@ -53,10 +53,16 @@ void psx_check_interrupts_at(CPUState *cpu, uint32_t resume_pc) { if (g_cbs.check_interrupts_at) g_cbs.check_interrupts_at(cpu, resume_pc); else g_cbs.check_interrupts(cpu); } -void psx_advance_cycles(uint32_t cycles) { +/* The CPU charge (psx_cycles.h): overlay code is guest CPU work, so its + * charges accumulate here raw and publish through cpu_charge, the host's + * psx_cpu_charge, which applies [timing] guest_cycle_scale exactly as it does + * for native code. psx_advance_cycles is kept as the same accumulator for + * code generated before the emitter named the CPU charge. */ +void psx_cpu_charge(uint32_t cycles) { if (cycles > UINT32_MAX - s_pending_cycles) overlay_flush_cycles(); s_pending_cycles += cycles; } +void psx_advance_cycles(uint32_t cycles) { psx_cpu_charge(cycles); } void gte_execute(CPUState *cpu, uint32_t cmd) { overlay_flush_cycles(); g_cbs.gte_execute(cpu, cmd); diff --git a/runtime/include/psx_cyc.h b/runtime/include/psx_cyc.h index 1a03a1a6..0a95f0ff 100644 --- a/runtime/include/psx_cyc.h +++ b/runtime/include/psx_cyc.h @@ -92,15 +92,16 @@ static inline void psx_cyc_local_cleanup(uint32_t **guard) { static inline void psx_cyc_charge(uint32_t cycles) { if (cycles == 0u) return; -#if !defined(PSX_OVERLAY_DLL_BUILD) - if (PSX_GCS_ACTIVE()) { - cycles = psx_gcs_scale(cycles); - if (cycles == 0u) return; - } -#endif #if defined(PSX_OVERLAY_DLL_BUILD) - psx_advance_cycles(cycles); + psx_cpu_charge(cycles); #else + /* [timing] guest_cycle_scale: a scaled charge is the CPU charge itself + * (the one place CPU work is scaled); the batch below is the scale-1 + * production path, unchanged. */ + if (PSX_GCS_ACTIVE()) { + psx_cpu_charge(cycles); + return; + } #if defined(__GNUC__) || defined(__clang__) if (__builtin_expect(g_ls_replay_active | g_event_step_conservative, 0)) { #else diff --git a/runtime/include/psx_cycles.h b/runtime/include/psx_cycles.h index 204620f1..dd72f819 100644 --- a/runtime/include/psx_cycles.h +++ b/runtime/include/psx_cycles.h @@ -70,22 +70,53 @@ extern uint32_t *g_psx_cyc_local_acc; * No CPU is emulated: recompiled code charges the guest clock a fixed number * of cycles per MIPS instruction it runs, and that clock is what VBlank, * timers, CD, SPU and DMA are scheduled against. guest_cycle_scale = N - * charges each instruction 1/N of its cost (instruction, load, mul/div and - * GTE latencies, overlay DLL batches); device time is unchanged. Larger N - * means the game never runs out of frame time; 1 is faithful and skips the - * scaling entirely. g_psx_gcs_recip_q16 is 65536/N. */ + * charges each instruction 1/N of its cost; device time is unchanged. + * Larger N means the game never runs out of frame time; 1 is faithful and + * skips the scaling entirely. g_psx_gcs_recip_q16 is 65536/N, g_psx_gcs_frac + * the carried fraction (part of the rollback/savestate snapshot). + * + * Two kinds of guest-time charge, and only these two: + * psx_cpu_charge(n) CPU work: instruction base cost, load costs + * (fudge, region wait, completion), i-cache refills, + * BIOS HLE costs. Scaled. Every tier (native + * generated code, overlay DLLs, the dirty-RAM + * interpreter, the BIOS) charges CPU work through + * it; the batched psx_cyc_charge hot path hands its + * charge to it whenever the scale is active. + * psx_advance_cycles(n) device time and stalls already measured in guest + * time (DMA, idle skips, mul/div and GTE deadline + * waits). Never scaled. + * Mul/div and GTE latencies are CPU work too: psx_cpu_cycles() scales the + * deadline they set, so the later stall waits the scaled time. */ extern uint32_t g_psx_gcs_recip_q16; extern uint32_t g_psx_gcs_frac; void psx_guest_cycle_scale_set(uint32_t scale); /* 1..64; 1 = faithful */ /* Live scale: the configured one, or 1 while gated and the gate is shut. */ uint32_t psx_guest_cycle_scale(void); uint32_t psx_guest_cycle_scale_config(void); -/* guest_cycle_scale_gated: the scale applies only while the title's mod - * holds the gate open (psx_guest_cycle_scale_gate_open), e.g. only in a - * paced race loop where it pays off; menus and loading keep faithful timing - * (a scaled clock spends host time spinning in their poll loops). */ +/* Gates (all must be open for the scale to apply; with none it always does): + * - RAM gate: [timing] guest_cycle_scale_gate, predicates on guest RAM + * words evaluated at every VBlank edge (deterministic, works online). + * - Mod gate: guest_cycle_scale_gated = true; a trusted plugin opens it + * with psx_mod_set_guest_cycle_scale_gate() (mod_plugins.h). */ void psx_guest_cycle_scale_set_gated(int gated); void psx_guest_cycle_scale_gate_open(int open); +enum { PSX_GCS_RAM_GATE_MAX = 8 }; +/* addr: guest main-RAM address (any segment), size 1/2/4, open while + * (word & mask) == value. Returns 0 for a bad predicate. Clear with + * psx_guest_cycle_scale_ram_gate_clear(). */ +int psx_guest_cycle_scale_ram_gate_add(uint32_t addr, uint32_t size, + uint32_t mask, uint32_t value); +void psx_guest_cycle_scale_ram_gate_clear(void); +/* How the RAM gate reads guest RAM (main.cpp installs a main-RAM reader; + * phys is masked physical, aligned to size). */ +void psx_guest_cycle_scale_set_ram_reader(uint32_t (*read)(uint32_t phys, uint32_t size)); +/* Called on each VBlank edge (interrupts.c); no-op without a RAM gate. */ +void psx_guest_cycle_scale_vblank(void); +/* Rollback/savestate: carried fraction + gate state (boot_state BS_SEC_GCS). */ +enum { PSX_GCS_SNAPSHOT_BYTES = 12 }; +void psx_guest_cycle_scale_snapshot(uint32_t out[3]); +void psx_guest_cycle_scale_restore(const uint32_t in[3]); static inline uint32_t psx_gcs_recip_for(uint32_t scale) { if (scale <= 1u) return 65536u; if (scale > 64u) scale = 64u; @@ -101,6 +132,12 @@ static inline uint32_t psx_gcs_scale(uint32_t cpu_cycles) { #else #define PSX_GCS_ACTIVE() (g_psx_gcs_recip_q16 != 65536u) #endif +#if !defined(PSX_OVERLAY_DLL_BUILD) +/* CPU cycles -> guest cycles. Identity at scale 1 (no state touched). */ +static inline uint32_t psx_cpu_cycles(uint32_t cpu_cycles) { + return PSX_GCS_ACTIVE() ? psx_gcs_scale(cpu_cycles) : cpu_cycles; +} +#endif /* Advance guest time. Overlay DLLs forward this through their callback shim; * normal runtime/generated code keeps the common production path inlined. */ @@ -159,6 +196,23 @@ static inline void psx_advance_cycles(uint32_t cycles) { } #endif +/* The CPU charge (see above): every tier's CPU work is scaled in this one + * function (psx_cpu_cycles does the same for mul/div and GTE deadlines, + * which are set, not charged). At scale 1 it is exactly psx_advance_cycles(cycles). Overlay DLLs + * accumulate it locally (overlay_dispatch_preamble.c.inc) and publish the + * raw total through the cpu_charge callback, which lands in this function. */ +#if defined(PSX_OVERLAY_DLL_BUILD) +void psx_cpu_charge(uint32_t cycles); +#else +static inline void psx_cpu_charge(uint32_t cycles) { + if (PSX_GCS_ACTIVE()) { + cycles = psx_gcs_scale(cycles); + if (cycles == 0u) return; + } + psx_advance_cycles(cycles); +} +#endif + /* Publish deferred charges (IRQ edge / MMIO / savestate). Overlay DLLs keep * their pending total in the callback shim rather than these host globals. */ #if defined(PSX_OVERLAY_DLL_BUILD) diff --git a/runtime/src/bios_hle.c b/runtime/src/bios_hle.c index 0d82b7b9..f44808e8 100644 --- a/runtime/src/bios_hle.c +++ b/runtime/src/bios_hle.c @@ -182,7 +182,7 @@ static void hle_open_event(CPUState* cpu, uint32_t base, uint32_t n) cpu->write_word(ev + EV_STATUS, EVST_DISABLED); cpu->gpr[2] = 0xF1000000u | slot; } - psx_advance_cycles(40u + 6u * (slot == 0xFFFFFFFFu ? n : slot + 1u)); + psx_cpu_charge(40u + 6u * (slot == 0xFFFFFFFFu ? n : slot + 1u)); } /* CloseEvent (kernel 0x1E1C): status=free, return 1. */ @@ -191,7 +191,7 @@ static void hle_close_event(CPUState* cpu, uint32_t base) uint32_t ev = base + (cpu->gpr[4] & 0xFFFFu) * EVCB_STRIDE; cpu->write_word(ev + EV_STATUS, EVST_FREE); cpu->gpr[2] = 1u; - psx_advance_cycles(36u); + psx_cpu_charge(36u); } /* TestEvent (kernel 0x1EC8): ready => re-arm to enabled and return 1, else 0. */ @@ -204,7 +204,7 @@ static void hle_test_event(CPUState* cpu, uint32_t base) } else { cpu->gpr[2] = 0u; } - psx_advance_cycles(40u); + psx_cpu_charge(40u); } /* EnableEvent (kernel 0x1F10) / DisableEvent (kernel 0x1F4C): a non-free @@ -216,7 +216,7 @@ static void hle_set_event_status(CPUState* cpu, uint32_t base, uint32_t status) if (cpu->read_word(ev + EV_STATUS) != EVST_FREE) cpu->write_word(ev + EV_STATUS, status); cpu->gpr[2] = 1u; - psx_advance_cycles(40u); + psx_cpu_charge(40u); } /* DeliverEvent (kernel 0x1B44): for every ENABLED EvCB matching class+spec, @@ -260,7 +260,7 @@ static void hle_deliver_event(CPUState* cpu, uint32_t base, uint32_t n) * caller may depend on it (libapi types DeliverEvent void). Set 0 for a * stable, observable value. */ cpu->gpr[2] = 0u; - psx_advance_cycles(36u + 12u * n); + psx_cpu_charge(36u + 12u * n); } /* B0 vector service. Returns 1 iff serviced in HLE. */ diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c index c48e929e..5bfb6ea9 100644 --- a/runtime/src/boot_state.c +++ b/runtime/src/boot_state.c @@ -391,7 +391,8 @@ static int boot_state_save_to(BsOut* o, const CPUState* cpu, h.codegen_hash = (uint32_t)PSX_OVERLAY_CODEGEN_HASH; h.abi_tag = (int32_t)PSX_OVERLAY_ABI_TAG; h.codegen_ver = (uint32_t)PSX_OVERLAY_CODEGEN_VER; - h.section_count = 17 + (psx_mod_memory_snapshot_bytes() ? 1u : 0u); + h.section_count = 17 + (psx_mod_memory_snapshot_bytes() ? 1u : 0u) + + (psx_guest_cycle_scale_config() != 1u ? 1u : 0u); ok = write_header_le(o, &h); @@ -489,6 +490,17 @@ static int boot_state_save_to(BsOut* o, const CPUState* cpu, ok = pst_w_u32(&w, fntrace_is_game_started() ? 1u : 0u) && write_section(o, BS_SEC_GAME_START, handoff, sizeof handoff); } + if (ok && psx_guest_cycle_scale_config() != 1u) { + /* Rollback re-simulation must resume with the same carried fraction + * and gate verdicts, or scaled charges land a cycle apart. */ + uint32_t g[3]; + uint8_t gb[PSX_GCS_SNAPSHOT_BYTES]; + PstW w; + psx_guest_cycle_scale_snapshot(g); + pst_w_init(&w, gb, sizeof gb); + ok = pst_w_u32(&w, g[0]) && pst_w_u32(&w, g[1]) && pst_w_u32(&w, g[2]) && + write_section(o, BS_SEC_GCS, gb, sizeof gb); + } return ok; } @@ -625,6 +637,7 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, pst_r_init(&r, p, len); if (!pst_r_u64(&r, &cyc)) return 0; psx_cycle_count = cyc; + g_psx_gcs_frac = 0u; /* BS_SEC_GCS (if present) restores it */ return 1; } case BS_SEC_GPU: @@ -704,6 +717,16 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, case BS_SEC_GAME_START: fntrace_restore_game_started(p[0]); return 1; + case BS_SEC_GCS: { + PstR r; + uint32_t g[3]; + if (len != PSX_GCS_SNAPSHOT_BYTES) return 0; + pst_r_init(&r, p, len); + if (!pst_r_u32(&r, &g[0]) || !pst_r_u32(&r, &g[1]) || !pst_r_u32(&r, &g[2])) + return 0; + psx_guest_cycle_scale_restore(g); + return 1; + } case BS_SEC_ICACHE: { PstR r; if (len != 1024u * 4u) return 0; @@ -763,6 +786,7 @@ static int validate_section(uint32_t tag, const uint8_t* p, uint32_t len) { case BS_SEC_GAME_START: return len == 4u && p[0] <= 1u && !p[1] && !p[2] && !p[3]; case BS_SEC_ICACHE: return len == 1024u * 4u; + case BS_SEC_GCS: return len == PSX_GCS_SNAPSHOT_BYTES; default: return 1; } } diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 80bb6bc8..434f22a5 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -101,8 +101,7 @@ static inline void interp_cyc_step(CPUState *cpu, uint32_t reg_mask) { cpu->read_absorb[w]--; } else if (!g_ls_replay_active) { if (PSX_GCS_ACTIVE()) { - uint32_t t = psx_gcs_scale(1u); - if (t) psx_advance_cycles(t); + psx_cpu_charge(1u); /* the CPU charge, scaled */ psx_cyc_deps(cpu, reg_mask); psx_cyc_lds(cpu); return; diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index de5bccec..073ef35b 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -496,6 +496,9 @@ static void fire_vblank_edge(void) { g_vblank_raise_count++; event_ring_record(EV_ISTAT_RAISE, IRQ_VBLANK); gpu_vblank_tick(); /* Toggle LCF (GPUSTAT bit 31) */ + /* [timing] guest_cycle_scale_gate: RAM predicates are judged here, at a + * guest-time point (no-op without one). */ + psx_guest_cycle_scale_vblank(); #ifndef PSX_ENABLE_BLOCK_CYCLES timers_tick(33868); /* ~1 NTSC frame worth of cycles */ cdrom_tick(); /* Process pending CDROM responses */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c1583065..d3c2326c 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1728,6 +1728,10 @@ 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); + /* [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). */ + psx_mod_set_guest_cycle_scale_gate(0); g_bezel_path.clear(); g_frame_interpolation_blend = g_frame_interpolation_blend_default; g_frame_interpolation_source = PSX_MOD_FRAME_SOURCE_VBLANK; @@ -15304,6 +15308,19 @@ namespace { } // namespace #endif +/* [timing] guest_cycle_scale_gate reader: main RAM only, side-effect free + * (aligned little-endian word of the given size). */ +extern "C" uint8_t* memory_get_ram_ptr(void); +static uint32_t gcs_gate_read_ram(uint32_t phys, uint32_t size) { + const uint8_t* ram = (const uint8_t*)memory_get_ram_ptr(); + const uint32_t bytes = (uint32_t)memory_get_ram_bytes(); + if (!ram || !bytes) return 0; + const uint32_t a = phys & (bytes - 1u); + uint32_t v = 0; + for (uint32_t i = 0; i < size; ++i) v |= (uint32_t)ram[a + i] << (8u * i); + return v; +} + int main(int argc, char** argv) { /* Force line-buffered output so messages appear even if killed. */ std::setvbuf(stdout, nullptr, _IOLBF, BUFSIZ); @@ -15786,16 +15803,21 @@ int main(int argc, char** argv) { * (no player setting). PSX_GUEST_CYCLE_SCALE overrides it for * testing only. */ psx_guest_cycle_scale_set_gated(gc.runtime.guest_cycle_scale_gated ? 1 : 0); + psx_guest_cycle_scale_ram_gate_clear(); + psx_guest_cycle_scale_set_ram_reader(gcs_gate_read_ram); + for (const auto& gp : gc.runtime.guest_cycle_scale_gate) + (void)psx_guest_cycle_scale_ram_gate_add(gp.addr, gp.size, gp.mask, gp.value); psx_guest_cycle_scale_set((uint32_t)gc.runtime.guest_cycle_scale); if (const char* gcs = getenv("PSX_GUEST_CYCLE_SCALE")) { const int v = atoi(gcs); if (v > 0) psx_guest_cycle_scale_set((uint32_t)v); } if (psx_guest_cycle_scale_config() != 1u) - fprintf(stderr, "[timing] guest_cycle_scale %u%s (instructions charge 1/%u " + fprintf(stderr, "[timing] guest_cycle_scale %u%s%s (instructions charge 1/%u " "of their guest cycles; VBlank/timers/CD/SPU/DMA unchanged)\n", psx_guest_cycle_scale_config(), - gc.runtime.guest_cycle_scale_gated ? ", gated by the title's mod" : "", + gc.runtime.guest_cycle_scale_gate.empty() ? "" : ", RAM gate", + gc.runtime.guest_cycle_scale_gated ? ", mod gate" : "", psx_guest_cycle_scale_config()); g_present_thread = gc.runtime.video_present_thread ? 1 : 0; g_video_vsync = gc.runtime.video_vsync; diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 17777dcc..bbb82abf 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -2302,8 +2302,7 @@ extern int g_ls_replay_active; static inline void psx_load_charge_cycles(uint32_t cycles) { if (g_ls_replay_active || cycles == 0u) return; if (PSX_GCS_ACTIVE()) { - cycles = psx_gcs_scale(cycles); - if (cycles) psx_advance_cycles(cycles); + psx_cpu_charge(cycles); return; } uint64_t next = psx_cycle_count + (uint64_t)cycles; @@ -2316,8 +2315,7 @@ static inline void psx_load_charge_cycles(uint32_t cycles) { } #else static inline void psx_load_charge_cycles(uint32_t cycles) { - if (PSX_GCS_ACTIVE()) cycles = psx_gcs_scale(cycles); - psx_advance_cycles(cycles); + psx_cpu_charge(cycles); } #endif @@ -2377,7 +2375,7 @@ static inline void psx_cyc_readmem(CPUState* cpu, uint32_t phys, uint32_t size, uint32_t cost = region + compl_cost; /* LDAbsorb = region + completion */ uint32_t fudge = (uint32_t)((cpu->read_fudge >> 4) & 2u); cpu->ld_absorb = cost; - psx_advance_cycles(fudge + cost); + psx_cpu_charge(fudge + cost); /* CPU charge: [timing] guest_cycle_scale */ cpu->ld_which_t = (uint8_t)arm_rt; /* PROOF GATE (PSX_POLL_PROOF=N, default 0/off): a FLAT, non-absorbed extra N * cycles per main-RAM data read — replicates the historical "+6 cyc/main-RAM diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index 3a3f59b4..a831bf9f 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -9,12 +9,10 @@ #include "debug_server.h" #include "psx_cycles.h" -/* Overlay DLLs batch their instruction charges and publish them through this - * callback; scale them like psx_cyc_charge for [timing] guest_cycle_scale. */ -static void overlay_advance_cpu_cycles(uint32_t cycles) { - if (PSX_GCS_ACTIVE()) cycles = psx_gcs_scale(cycles); - psx_advance_cycles(cycles); -} +/* 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 + * same function native code charges (no scaling at this boundary). */ +static void overlay_cpu_charge(uint32_t cycles) { psx_cpu_charge(cycles); } #include "lockstep.h" #include "overlay_posix.h" #include "psx_memory.h" @@ -2500,7 +2498,7 @@ static void init_callbacks(void) { s_callbacks.check_interrupts = overlay_ci_wrapper; s_callbacks.check_interrupts_at = overlay_ci_at_wrapper; /* Address of the header inline → out-of-line copy in this TU (host side). */ - s_callbacks.advance_cycles = overlay_advance_cpu_cycles; + s_callbacks.cpu_charge = overlay_cpu_charge; s_callbacks.gte_execute = gte_execute; s_callbacks.psx_syscall = psx_syscall; s_callbacks.psx_native_bad_entry = psx_native_bad_entry; diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 3c920d0c..849a8828 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -26,11 +26,19 @@ uint32_t g_psx_gcs_recip_q16 = 65536u; uint32_t g_psx_gcs_frac = 0u; static uint32_t s_psx_gcs_cfg = 1u; /* [timing] guest_cycle_scale */ static uint32_t s_psx_gcs_live = 1u; -static int s_psx_gcs_gated = 0; /* guest_cycle_scale_gated */ -static int s_psx_gcs_open = 0; +static int s_psx_gcs_gated = 0; /* guest_cycle_scale_gated (mod gate) */ +static int s_psx_gcs_open = 0; /* mod gate state */ +/* [timing] guest_cycle_scale_gate: RAM predicates, all must hold. */ +typedef struct { uint32_t phys, size, mask, value; } PsxGcsRamPred; +static PsxGcsRamPred s_psx_gcs_ram[PSX_GCS_RAM_GATE_MAX]; +static uint32_t s_psx_gcs_ram_n = 0; +static int s_psx_gcs_ram_open = 0; /* last VBlank's verdict */ +static uint32_t (*s_psx_gcs_ram_read)(uint32_t phys, uint32_t size) = NULL; static void psx_guest_cycle_scale_apply(void) { - uint32_t n = (!s_psx_gcs_gated || s_psx_gcs_open) ? s_psx_gcs_cfg : 1u; + const int open = (!s_psx_gcs_gated || s_psx_gcs_open) && + (s_psx_gcs_ram_n == 0u || s_psx_gcs_ram_open); + uint32_t n = open ? s_psx_gcs_cfg : 1u; if (n == s_psx_gcs_live) return; s_psx_gcs_live = n; g_psx_gcs_recip_q16 = psx_gcs_recip_for(n); @@ -54,6 +62,64 @@ void psx_guest_cycle_scale_gate_open(int open) { psx_guest_cycle_scale_apply(); } +void psx_guest_cycle_scale_set_ram_reader(uint32_t (*read)(uint32_t phys, uint32_t size)) { + s_psx_gcs_ram_read = read; +} + +int psx_guest_cycle_scale_ram_gate_add(uint32_t addr, uint32_t size, + uint32_t mask, uint32_t value) { + const uint32_t phys = addr & 0x1FFFFFFFu; + if (s_psx_gcs_ram_n >= (uint32_t)PSX_GCS_RAM_GATE_MAX) return 0; + if (size != 1u && size != 2u && size != 4u) return 0; + if ((phys & (size - 1u)) != 0u || phys >= 0x00800000u) return 0; /* main RAM */ + if (size < 4u) mask &= (1u << (8u * size)) - 1u; + s_psx_gcs_ram[s_psx_gcs_ram_n].phys = phys; + s_psx_gcs_ram[s_psx_gcs_ram_n].size = size; + s_psx_gcs_ram[s_psx_gcs_ram_n].mask = mask; + s_psx_gcs_ram[s_psx_gcs_ram_n].value = value & mask; + s_psx_gcs_ram_n++; + s_psx_gcs_ram_open = 0; /* shut until the first VBlank */ + psx_guest_cycle_scale_apply(); + return 1; +} + +void psx_guest_cycle_scale_ram_gate_clear(void) { + s_psx_gcs_ram_n = 0u; + s_psx_gcs_ram_open = 0; + psx_guest_cycle_scale_apply(); +} + +/* VBlank edge (interrupts.c fire_vblank_edge): a guest-time point, so every + * peer and every rollback re-simulation sees the same verdict. */ +void psx_guest_cycle_scale_vblank(void) { + if (s_psx_gcs_ram_n == 0u || !s_psx_gcs_ram_read) return; + int open = 1; + for (uint32_t i = 0; open && i < s_psx_gcs_ram_n; i++) { + const PsxGcsRamPred *p = &s_psx_gcs_ram[i]; + open = (s_psx_gcs_ram_read(p->phys, p->size) & p->mask) == p->value; + } + if (open == s_psx_gcs_ram_open) return; + s_psx_gcs_ram_open = open; + psx_guest_cycle_scale_apply(); +} + +/* Public mod API (mod_plugins.h): the mod gate. */ +void psx_mod_set_guest_cycle_scale_gate(int open) { psx_guest_cycle_scale_gate_open(open); } +uint32_t psx_mod_guest_cycle_scale(void) { return s_psx_gcs_live; } + +void psx_guest_cycle_scale_snapshot(uint32_t out[3]) { + out[0] = g_psx_gcs_frac; + out[1] = (uint32_t)s_psx_gcs_open; + out[2] = (uint32_t)s_psx_gcs_ram_open; +} + +void psx_guest_cycle_scale_restore(const uint32_t in[3]) { + s_psx_gcs_open = in[1] ? 1 : 0; + s_psx_gcs_ram_open = in[2] ? 1 : 0; + psx_guest_cycle_scale_apply(); + g_psx_gcs_frac = PSX_GCS_ACTIVE() ? (in[0] & 0xFFFFu) : 0u; +} + uint32_t psx_guest_cycle_scale(void) { return s_psx_gcs_live; } uint32_t psx_guest_cycle_scale_config(void) { return s_psx_gcs_cfg; } uint32_t g_psx_cyc_batch = 0; @@ -755,7 +821,7 @@ void psx_muldiv_set(CPUState* cpu, uint32_t latency) { /* The deadline belongs to this instruction, including unpublished CPU * work from generated blocks and local charge accumulators. */ psx_cyc_batch_flush(); - if (PSX_GCS_ACTIVE()) latency = psx_gcs_scale(latency); + latency = psx_cpu_cycles(latency); /* [timing] guest_cycle_scale */ cpu->muldiv_ts_done = psx_cycle_count + (uint64_t)latency; } @@ -857,7 +923,7 @@ void psx_gte_set(CPUState* cpu, uint32_t latency) { if (cpu->gte_ts_done > psx_cycle_count) { psx_advance_cycles((uint32_t)(cpu->gte_ts_done - psx_cycle_count)); } - if (PSX_GCS_ACTIVE()) latency = psx_gcs_scale(latency); + latency = psx_cpu_cycles(latency); /* [timing] guest_cycle_scale */ cpu->gte_ts_done = psx_cycle_count + (uint64_t)latency; } diff --git a/runtime/src/psx_icache.c b/runtime/src/psx_icache.c index 840e508d..60749aa4 100644 --- a/runtime/src/psx_icache.c +++ b/runtime/src/psx_icache.c @@ -85,7 +85,7 @@ void psx_icache_fetch_miss(CPUState* cpu, uint32_t addr) { cpu->read_absorb_which = 0u; if (psx_fetch_uncached(addr)) { /* KSEG1 / uncached (BIOS ROM): every fetch */ - psx_advance_cycles(4u); + psx_cpu_charge(4u); /* CPU charge: [timing] guest_cycle_scale */ return; } @@ -101,7 +101,7 @@ void psx_icache_fetch_miss(CPUState* cpu, uint32_t addr) { g_psx_icache_tv[bidx + i] &= ~0x2u; cost++; } - psx_advance_cycles(cost); + psx_cpu_charge(cost); #else (void)cpu; (void)addr; diff --git a/runtime/tests/overlay_pair_dedup_harness.c b/runtime/tests/overlay_pair_dedup_harness.c index 860d0387..c7354672 100644 --- a/runtime/tests/overlay_pair_dedup_harness.c +++ b/runtime/tests/overlay_pair_dedup_harness.c @@ -81,9 +81,8 @@ void psx_unknown_dispatch(CPUState *cpu, uint32_t addr, uint32_t phys) { } void psx_advance_cycles(uint32_t cycles) { (void)cycles; } -/* [timing] guest_cycle_scale state the loader scales overlay charges with: 1. */ -uint32_t g_psx_gcs_recip_q16 = 65536u; -uint32_t g_psx_gcs_frac = 0u; +/* The CPU charge the loader binds as the overlay cpu_charge callback. */ +void psx_cpu_charge(uint32_t cycles) { (void)cycles; } uint64_t psx_get_cycle_count(void) { return 0; } int psx_cycle_replay_begin(uint64_t cycle) { (void)cycle; return 1; } uint64_t psx_cycle_replay_end(void) { return 0; } diff --git a/runtime/tests/psx_ram_runtime_map_stubs.c b/runtime/tests/psx_ram_runtime_map_stubs.c index 92e86fa6..06e3c780 100644 --- a/runtime/tests/psx_ram_runtime_map_stubs.c +++ b/runtime/tests/psx_ram_runtime_map_stubs.c @@ -69,6 +69,12 @@ void gte_canonicalize_cpu_state(CPUState *cpu) { (void)cpu; } /* ---- scheduler / interrupts ------------------------------------------------ */ void psx_advance_cycles_slow(uint32_t c) { psx_cycle_count += c; } +/* [timing] guest_cycle_scale at 1 (faithful) for the real memory.c/icache. */ +uint32_t g_psx_gcs_recip_q16 = 65536u; +uint32_t g_psx_gcs_frac = 0u; +uint32_t psx_guest_cycle_scale_config(void) { return 1u; } +void psx_guest_cycle_scale_snapshot(uint32_t out[3]) { out[0] = out[1] = out[2] = 0u; } +void psx_guest_cycle_scale_restore(const uint32_t in[3]) { (void)in; } void psx_devices_mmio_sync(void) {} void psx_devices_service_to_now(void) {} int psx_get_in_exception(void) { return 0; } diff --git a/runtime/tests/test_psx_guest_cycle_scale.c b/runtime/tests/test_psx_guest_cycle_scale.c index 0f5d6906..1a38c790 100644 --- a/runtime/tests/test_psx_guest_cycle_scale.c +++ b/runtime/tests/test_psx_guest_cycle_scale.c @@ -1,5 +1,6 @@ /* [timing] guest_cycle_scale: instruction charges reach the guest clock at * 1/N of their cost; 1 leaves every charge exactly as it was. */ +#undef NDEBUG /* the checks below must run in Release too */ #include "psx_cyc.h" #include diff --git a/runtime/tests/test_psx_guest_cycle_scale_gate.c b/runtime/tests/test_psx_guest_cycle_scale_gate.c new file mode 100644 index 00000000..44a57526 --- /dev/null +++ b/runtime/tests/test_psx_guest_cycle_scale_gate.c @@ -0,0 +1,247 @@ +/* [timing] guest_cycle_scale against the real psx_cycles.c: + * - identical accounting across tiers: native per-instruction CPU charges, + * the batched psx_cyc_charge hot path and an overlay DLL's raw batch + * published once all reach the same guest total; at 1 the CPU charge is + * exactly psx_advance_cycles; + * - the declarative RAM gate (judged at VBlank) and the mod gate; + * - rollback: restoring the snapshot (fraction + gate state) makes a + * re-simulation land on the same cycle. + * + * Build/run: ctest -R psx_guest_cycle_scale_gate_test + */ +#include "psx_cyc.h" +#include "mod_plugins.h" + +#include +#include +#include +#include + +volatile int g_ds_recording = 0; +uint8_t *g_psx_ram = 0; +uint32_t g_psx_ram_mask = 0x1FFFFFu; +int g_psx_load_delay = 1; + +/* assert() is compiled out in Release (NDEBUG); this check is not. */ +#define assert(c) do { if (!(c)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #c); \ + exit(1); } } while (0) + +#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 s_cd_cycles_remaining = 5; +static int s_cd_ready = 0; +static uint32_t s_dma_ready_cycles = 0; + +void sio_advance(uint32_t cycles) { (void)cycles; } + +void cdrom_advance(uint32_t cycles) { + if (s_cd_ready) return; + if (cycles >= s_cd_cycles_remaining) { + s_cd_cycles_remaining = 0; + s_cd_ready = 1; + } else { + s_cd_cycles_remaining -= cycles; + } +} + +void dma_advance(uint32_t cycles) { + if (s_cd_ready) s_dma_ready_cycles += cycles; +} + +void timers_advance(uint32_t cycles) { (void)cycles; } +void interrupts_advance_cycles(uint32_t cycles) { (void)cycles; } +void interrupts_service_scheduled_events(void) {} + +uint32_t interrupts_cycles_to_vblank(void) { return UINT32_MAX; } +uint32_t timers_cycles_to_irq(uint32_t mask) { (void)mask; return UINT32_MAX; } +uint32_t cdrom_cycles_to_irq(uint32_t mask) { + (void)mask; + return s_cd_ready ? UINT32_MAX : s_cd_cycles_remaining; +} +uint32_t dma_cycles_to_internal_event(void) { return UINT32_MAX; } +uint32_t dma_cycles_to_deliverable_irq(uint32_t mask) { + (void)mask; + return UINT32_MAX; +} +uint32_t sio_cycles_to_irq(uint32_t mask) { (void)mask; return UINT32_MAX; } +/* SPU sample-event scheduler (golden 1a973806): psx_cycles.c consults it; stub here. */ +static uint32_t s_spu_next_sample = UINT32_MAX; +uint32_t psx_spu_sample_event_cycles_to_next(void) { return s_spu_next_sample; } +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) {} + +static uint8_t s_ram[0x200000]; +static uint32_t read_ram(uint32_t phys, uint32_t size) { + uint32_t v = 0; + for (uint32_t i = 0; i < size; i++) v |= (uint32_t)s_ram[(phys + i) & 0x1FFFFFu] << (8u * i); + return v; +} +static void wr32(uint32_t addr, uint32_t v) { + uint32_t a = addr & 0x1FFFFFu; + for (int i = 0; i < 4; i++) s_ram[a + (uint32_t)i] = (uint8_t)(v >> (8 * i)); +} + +static void reset_clock(void) { + psx_cycles_reset_for_boot(); + g_psx_gcs_frac = 0u; +} + +/* The same instruction stream, charged the three ways tiers charge it. */ +static const uint32_t k_stream[] = { 1, 1, 7, 1, 2, 5, 1, 1, 1, 4, 3, 1, 9, 1, 1, 6 }; +enum { K_N = sizeof k_stream / sizeof k_stream[0], K_REPS = 997 }; + +static uint64_t run_native(void) { /* psx_cpu_charge per charge */ + reset_clock(); + for (int r = 0; r < K_REPS; r++) + for (int i = 0; i < K_N; i++) psx_cpu_charge(k_stream[i]); + return psx_get_cycle_count(); +} +static uint64_t run_batched(void) { /* psx_cyc_charge hot path */ + reset_clock(); + for (int r = 0; r < K_REPS; r++) + for (int i = 0; i < K_N; i++) psx_cyc_charge(k_stream[i]); + psx_cyc_batch_flush(); + return psx_get_cycle_count(); +} +static uint64_t run_dll(void) { /* raw DLL batch, published per rep */ + reset_clock(); + for (int r = 0; r < K_REPS; r++) { + uint32_t pending = 0; + for (int i = 0; i < K_N; i++) pending += k_stream[i]; + psx_cpu_charge(pending); /* the cpu_charge callback */ + } + return psx_get_cycle_count(); +} +static uint64_t run_device(void) { /* the unscaled device advance */ + reset_clock(); + for (int r = 0; r < K_REPS; r++) + for (int i = 0; i < K_N; i++) psx_advance_cycles(k_stream[i]); + return psx_get_cycle_count(); +} + +int main(void) { + uint64_t raw = 0; + for (int i = 0; i < K_N; i++) raw += k_stream[i]; + raw *= K_REPS; + + /* Scale 1: every path is the faithful count, and the CPU charge is the + * device advance (pass-through, fraction untouched). */ + psx_guest_cycle_scale_set(1); + assert(run_native() == raw && run_batched() == raw && + run_dll() == raw && run_device() == raw); + assert(g_psx_gcs_frac == 0u); + + /* Scaled: identical across tiers; device time is never scaled. */ + for (uint32_t s = 2; s <= 64; s = s * 2 + 1) { + psx_guest_cycle_scale_set(s); + uint64_t n = run_native(); + assert(run_batched() == n); + assert(run_dll() == n); + assert(n <= raw / s + 1 && n + 1 >= raw / s); + assert(run_device() == raw); + } + psx_guest_cycle_scale_set(1); + + /* Declarative RAM gate: shut until a VBlank sees the predicate hold. */ + psx_guest_cycle_scale_set_ram_reader(read_ram); + assert(!psx_guest_cycle_scale_ram_gate_add(0x800AC796u, 4, ~0u, 0x180u)); /* unaligned */ + assert(!psx_guest_cycle_scale_ram_gate_add(0x1F800000u, 4, ~0u, 0x180u)); /* not RAM */ + assert(!psx_guest_cycle_scale_ram_gate_add(0x800AC794u, 3, ~0u, 0x180u)); /* size */ + assert(psx_guest_cycle_scale_ram_gate_add(0x800AC794u, 4, ~0u, 0x180u)); + psx_guest_cycle_scale_set(64); + assert(psx_guest_cycle_scale() == 1u); + wr32(0x800AC794u, 0x180u); + assert(psx_guest_cycle_scale() == 1u); /* judged at VBlank only */ + psx_guest_cycle_scale_vblank(); + assert(psx_guest_cycle_scale() == 64u); + wr32(0x800AC794u, 0x100u); + psx_guest_cycle_scale_vblank(); + assert(psx_guest_cycle_scale() == 1u); + /* Masked byte predicate ANDed with the first. */ + assert(psx_guest_cycle_scale_ram_gate_add(0x00010003u, 1, 0x0Fu, 0x05u)); + wr32(0x800AC794u, 0x180u); + s_ram[0x10003] = 0xF4; + psx_guest_cycle_scale_vblank(); + assert(psx_guest_cycle_scale() == 1u); + s_ram[0x10003] = 0xA5; + psx_guest_cycle_scale_vblank(); + assert(psx_guest_cycle_scale() == 64u); + + /* Mod gate on top: both must be open. */ + psx_guest_cycle_scale_set_gated(1); + assert(psx_guest_cycle_scale() == 1u); + psx_mod_set_guest_cycle_scale_gate(1); + assert(psx_mod_guest_cycle_scale() == 64u); + psx_guest_cycle_scale_ram_gate_clear(); + assert(psx_guest_cycle_scale() == 64u); /* mod gate alone */ + psx_mod_set_guest_cycle_scale_gate(0); + assert(psx_guest_cycle_scale() == 1u); + psx_guest_cycle_scale_set_gated(0); + + /* Rollback: snapshot mid-stream (odd scale leaves a fraction), run a + * tail, restore, re-simulate: the same cycle. Restoring the clock alone + * (fraction lost) does not reproduce it. */ + assert(psx_guest_cycle_scale_ram_gate_add(0x800AC794u, 4, ~0u, 0x180u)); + wr32(0x800AC794u, 0x180u); + psx_guest_cycle_scale_set(3); + psx_guest_cycle_scale_vblank(); + assert(psx_guest_cycle_scale() == 3u); + reset_clock(); + for (int i = 0; i < 5; i++) psx_cpu_charge(1); + assert(g_psx_gcs_frac != 0u); + uint32_t snap[3]; + psx_guest_cycle_scale_snapshot(snap); + const uint64_t snap_cyc = psx_get_cycle_count(); + for (int i = 0; i < 1000; i++) psx_cpu_charge(k_stream[i % K_N]); + wr32(0x800AC794u, 0x100u); /* the tail shuts the gate */ + psx_guest_cycle_scale_vblank(); + for (int i = 0; i < 100; i++) psx_cpu_charge(1); + const uint64_t live = psx_get_cycle_count(); + + wr32(0x800AC794u, 0x180u); /* guest RAM is restored too */ + psx_cycle_count = snap_cyc; + psx_guest_cycle_scale_restore(snap); + assert(psx_guest_cycle_scale() == 3u); + for (int i = 0; i < 1000; i++) psx_cpu_charge(k_stream[i % K_N]); + wr32(0x800AC794u, 0x100u); + psx_guest_cycle_scale_vblank(); + for (int i = 0; i < 100; i++) psx_cpu_charge(1); + assert(psx_get_cycle_count() == live); + + /* The fraction is load-bearing: two 1-cycle charges after the restore + * carry a cycle with it and none without it. */ + wr32(0x800AC794u, 0x180u); + psx_guest_cycle_scale_vblank(); + psx_cycle_count = snap_cyc; + psx_guest_cycle_scale_restore(snap); + psx_cpu_charge(1); psx_cpu_charge(1); + const uint64_t with_frac = psx_get_cycle_count(); + psx_cycle_count = snap_cyc; + { + uint32_t lost[3] = { 0u, snap[1], snap[2] }; + psx_guest_cycle_scale_restore(lost); + } + psx_cpu_charge(1); psx_cpu_charge(1); + assert(psx_get_cycle_count() != with_frac); + + puts("psx_guest_cycle_scale_gate_test: ok"); + return 0; +} diff --git a/runtime/tests/test_psx_icache_elision_guard.c b/runtime/tests/test_psx_icache_elision_guard.c index 7c1de1d1..c1cc7491 100644 --- a/runtime/tests/test_psx_icache_elision_guard.c +++ b/runtime/tests/test_psx_icache_elision_guard.c @@ -9,6 +9,8 @@ int g_ls_replay_active = 0; static uint64_t cycles; void psx_advance_cycles(uint32_t count) { cycles += count; } +/* I-cache refills are CPU work: they go through the CPU charge. */ +void psx_cpu_charge(uint32_t count) { psx_advance_cycles(count); } static uint64_t run(int omit_body, uint32_t *giveback, uint32_t *tag) { CPUState cpu; diff --git a/runtime/tests/test_psx_icache_fastpath.c b/runtime/tests/test_psx_icache_fastpath.c index baadf18a..dda5fb7f 100644 --- a/runtime/tests/test_psx_icache_fastpath.c +++ b/runtime/tests/test_psx_icache_fastpath.c @@ -8,6 +8,8 @@ int g_ls_replay_active = 0; static uint64_t test_cycles = 0; void psx_advance_cycles(uint32_t cycles) { test_cycles += cycles; } +/* I-cache refills are CPU work: they go through the CPU charge. */ +void psx_cpu_charge(uint32_t cycles) { psx_advance_cycles(cycles); } static int expect(int condition, const char *message) { if (condition) return 1; diff --git a/tools/tests/test_overlay_widescreen_callbacks.py b/tools/tests/test_overlay_widescreen_callbacks.py index 49e94713..08448138 100644 --- a/tools/tests/test_overlay_widescreen_callbacks.py +++ b/tools/tests/test_overlay_widescreen_callbacks.py @@ -36,7 +36,7 @@ int main(void) { OverlayCallbacks callbacks = {0}; callbacks.ws_screen_x_bound = bound; callbacks.mod_function_entry = entry; - callbacks.advance_cycles = advance; + callbacks.cpu_charge = advance; overlay_init(&callbacks); assert(overlay_abi() == PSX_OVERLAY_ABI_TAG); assert(psx_ws_screen_x_bound(-256) == -512); From cba31b19239209149ac22d31b021506bc3e53353 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:49:37 -0700 Subject: [PATCH 3/3] guest_cycle_scale: range-check TOML integers before narrowing Reject negative and >32-bit addr/value/size/mask in guest_cycle_scale_gate and out-of-range guest_cycle_scale instead of wrapping or clamping; add regression cases. Regenerate the OpenBIOS emitter stamp. Co-Authored-By: Claude Opus 5.5 --- generated/OpenBIOS.emitter.sha | 2 +- recompiler/src/config_loader.cpp | 28 +++++++++++++------ .../tests/video_enhancement_settings_test.cpp | 23 +++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 5f71563e..5b4ab659 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -69641913e358f4c3128a93dadf5824c7929afeaf8df6c5b9a6cc645c0f43d0f5 +01cfc201a7c71e2555e5828c67aeaa29f295bf4ebfb4e182a3c606e2a4812857 diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index ccf1e466..f6214cfc 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -618,16 +618,16 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& // guest clock a fixed cost per instruction, and scale N charges 1/N of // it, so N times as much game code fits between two VBlanks while // VBlank, timers, CD, SPU and DMA keep hardware time. 1 = faithful; - // clamped to 1..64. guest_cycle_scale_gated: the scale applies only + // values outside 1..64 are rejected. guest_cycle_scale_gated: the scale applies only // while the mod gate is open (psx_mod_set_guest_cycle_scale_gate); see // guest_cycle_scale_gate below for the declarative RAM gate. if (cfg.contains("timing")) { const toml::value& tm = toml::find(cfg, "timing"); if (tm.contains("guest_cycle_scale")) { - long long n = toml::find(tm, "guest_cycle_scale"); - if (n < 1) n = 1; - if (n > 64) n = 64; - rt.guest_cycle_scale = (int)n; + const long long n = toml::find(tm, "guest_cycle_scale"); + if (n < 1 || n > 64) + throw std::runtime_error("[timing] guest_cycle_scale must be 1..64"); + rt.guest_cycle_scale = static_cast(n); } if (tm.contains("guest_cycle_scale_gated")) rt.guest_cycle_scale_gated = toml::find(tm, "guest_cycle_scale_gated"); @@ -640,12 +640,22 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& else preds.push_back(g); if (preds.size() > 8) throw std::runtime_error("[timing] guest_cycle_scale_gate: at most 8 predicates"); + // Range-check the signed TOML integer before narrowing, so an + // oversized or negative value cannot wrap into a valid one. + auto u32_field = [](const toml::value& pv, const char* key) -> uint32_t { + const long long v = toml::find(pv, key); + if (v < 0 || v > 0xFFFFFFFFLL) + throw std::runtime_error( + std::string("[timing] guest_cycle_scale_gate: ") + key + + " must be 0..0xFFFFFFFF"); + return static_cast(v); + }; for (const toml::value& pv : preds) { RuntimeConfig::GuestCycleScaleGatePred p; - p.addr = (uint32_t)toml::find(pv, "addr"); - p.value = (uint32_t)toml::find(pv, "value"); - if (pv.contains("size")) p.size = (uint32_t)toml::find(pv, "size"); - if (pv.contains("mask")) p.mask = (uint32_t)toml::find(pv, "mask"); + p.addr = u32_field(pv, "addr"); + p.value = u32_field(pv, "value"); + if (pv.contains("size")) p.size = u32_field(pv, "size"); + if (pv.contains("mask")) p.mask = u32_field(pv, "mask"); const uint32_t phys = p.addr & 0x1FFFFFFFu; if ((p.size != 1 && p.size != 2 && p.size != 4) || (phys & (p.size - 1u)) != 0 || phys >= 0x00800000u) diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp index 160ac3f1..21f7d88e 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -499,6 +499,29 @@ static void test_timing_gate() { try { (void)PSXRecompV4::load_game_config(p); } catch (const std::exception&) { rejected = true; } check(rejected, "timing: non-RAM gate address rejected"); fs::remove(p); + + /* Oversized and negative values must be rejected, not wrapped to 32 bits. */ + const struct { const char* body; const char* what; } bad[] = { + { "guest_cycle_scale_gate = { addr = 0x100010000, value = 1 }", "oversized addr" }, + { "guest_cycle_scale_gate = { addr = -4, value = 1 }", "negative addr" }, + { "guest_cycle_scale_gate = { addr = 0x80010000, value = 0x100000001 }", "oversized value" }, + { "guest_cycle_scale_gate = { addr = 0x80010000, value = -1 }", "negative value" }, + { "guest_cycle_scale_gate = { addr = 0x80010000, value = 1, size = 0x100000004 }", "oversized size" }, + { "guest_cycle_scale_gate = { addr = 0x80010000, value = 1, size = -4 }", "negative size" }, + { "guest_cycle_scale_gate = { addr = 0x80010000, value = 1, mask = 0x1FFFFFFFF }", "oversized mask" }, + { "guest_cycle_scale_gate = { addr = 0x80010000, value = 1, mask = -1 }", "negative mask" }, + { "guest_cycle_scale = 0", "scale 0" }, + { "guest_cycle_scale = 65", "scale 65" }, + { "guest_cycle_scale = -8", "negative scale" }, + { "guest_cycle_scale = 0x100000008", "oversized scale" }, + }; + for (const auto& b : bad) { + p = write_game_toml("ves_timing_range.toml", std::string("[timing]\n") + b.body + "\n"); + rejected = false; + try { (void)PSXRecompV4::load_game_config(p); } catch (const std::exception&) { rejected = true; } + check(rejected, (std::string("timing: rejected ") + b.what).c_str()); + fs::remove(p); + } } int main() {