From 64b084e2a7f20cbe0929b132ac30df95c8d64b39 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 14 Jul 2026 22:44:31 -0700 Subject: [PATCH] load: add opt-in event horizon, warm routes, and audio sink --- ENHANCEMENTS.md | 50 ++- docs/LOAD_TIME_ZERO.md | 61 +++- docs/config_schema.md | 33 ++ recompiler/include/code_generator.h | 9 + recompiler/src/code_generator.cpp | 13 + recompiler/src/config_loader.cpp | 138 ++++++++ recompiler/src/config_loader.h | 41 +++ recompiler/src/main_psx.cpp | 6 + runtime/include/audio_trace.h | 1 + runtime/include/cdrom.h | 17 + runtime/include/load_accel.h | 27 ++ runtime/include/load_transition_ring.h | 29 ++ runtime/include/psx_cyc.h | 6 + runtime/runtime.cmake | 1 + runtime/src/cdrom.c | 444 ++++++++++++++++++++++++- runtime/src/data_shards.c | 6 +- runtime/src/debug_server.c | 90 ++++- runtime/src/load_accel.c | 228 +++++++++++++ runtime/src/main.cpp | 176 ++++++++-- runtime/src/memory.c | 4 + runtime/src/psx_cycles.c | 16 +- tools/load_probe.py | 39 ++- 22 files changed, 1351 insertions(+), 84 deletions(-) create mode 100644 runtime/include/load_accel.h create mode 100644 runtime/include/load_transition_ring.h create mode 100644 runtime/src/load_accel.c diff --git a/ENHANCEMENTS.md b/ENHANCEMENTS.md index 918ddecb..e8bbfb17 100644 --- a/ENHANCEMENTS.md +++ b/ENHANCEMENTS.md @@ -301,7 +301,7 @@ turbo), the risky axis is guest time. **Prioritized burndown (most agnostic + most likely beneficial first):** -- [ ] **L1.0 — E0 `load_probe_v2`: load-window decomposition on Tomba 1.** +- [x] **L1.0 — E0 `load_probe_v2`: load-window decomposition on Tomba 1.** 100% agnostic, zero risk, prices every bet below. Split a real pig-load window into guest time (seek / sector cadence / per-sector processing / explicit waits) and host time (native code / decompressors / interp / @@ -316,32 +316,52 @@ turbo), the risky axis is guest time. (b) audio at the HOST SINK only (drop excess samples, crossfade on exit; never guest state); (c) root-cause MMX5 dev-tools+turbo 0xE10 boot wedge (foundation timing bug). -- [ ] **L1.2 — Event-horizon acceleration + batched device ticking.** +- [x] **L1.2 — Event-horizon acceleration + batched device ticking.** Provably side-effect-free poll/idle regions jump to the next scheduled observable event with exact cycle credit + identical event ordering; devices advance to deadlines instead of per-block ticks. Attacks the ~2x ceiling directly; class-level, all titles inherit. Gate set from - L1.0's poll/idle share. Kill: <10% gain or ONE event-order divergence. -- [ ] **L1.3 — Load-path overlay coverage.** Only if L1.0 shows interp - share >~10% in-window; coverage-capture the load path, reshard. -- [ ] **L1.4 — Data shards (spike/tomba-load-shards): verify-only SHADOW + L1.0's poll/idle share. Shipped: deadline-based device servicing, six Tomba + wait sites, and proof-gated generic idle skipping. Cross-game validation is + queued at the end. Kill: <10% gain or ONE event-order divergence. +- [x] **L1.3 — Load-path overlay coverage (killed by gate).** L1.0 measured + zero in-window interpreter instructions, so there is no coverage win to buy. +- [x] **L1.4 — Data shards (rejected/quarantined): verify-only SHADOW mode, then replay.** Gated on L1.0 (decomp ≥~20-25% host share). Correctness bar: temporal write visibility — replay sound only if IRQs-off across the window OR duration < next observable event. -- [ ] **L1.5 — Authentic drive backlog (never-early + catch-up).** - Correctness work, not acceleration. Probe deadline-vs-exposure deltas - first; prereq = resolve the CD-model split (live tree Ape - direct-delivery vs master model). -- [ ] **L1.6 — Seek-only latency probe.** Bounded (seeks × ~250ms), NOT - proven safe, per-game opt-in at best. Kill: seek share <10% of window. +- [x] **L1.5 — Authentic drive backlog (killed as acceleration).** Passive + deadline-vs-exposure probe measured 1,304/1,304 data sectors available on + their exact scheduled cycle, then the intentional fixed 5,000-cycle INT1 + presentation delay. Zero early/late sectors, holds, pending/lost INT1s, or + overwrites: there is no artificial lateness for backlog/catch-up to remove. +- [x] **L1.6 — Seek-only latency probe (killed on Tomba).** The measured + New Game window issued zero seek commands. Read-start latency was only + 7,676,928 / 298,130,657 cycles (2.57%); pause latency was 8.11% but is an + authentic CPU-visible ordering contract, not a safe seek-speedup target. - [ ] **L1.7 — Phase-2 doors (open only with cause):** per-title read speedup with XA/CDDA/MDEC exclusions; decompressor HLE (only via L1.4 failing for a named reason); load-transition state cache (the only true near-zero; needs thousands-of-frames differential validation). -Method, every experiment: measure first via always-on rings; flag-gated -default-off; one per session; kill criterion written before code; corpus -gate ≥2 titles (Tomba + MMX6 minimum) before any default flips. +**Live status / decision ledger (Tomba 1, 2026-07-14):** + +| Item | Verdict | Evidence / measured result | +|---|---|---| +| L1.0 decomposition | DONE | 761 sectors, ~9.5 s baseline window; zero in-window interp; host/static execution dominated. | +| L1.1 turbo hardening | IMPLEMENTED ON TOMBA; CORPUS GATE OPEN | SDL pump remains before every turbo return; 4-frame engage + 6-frame release debounce passed live QA. Opt-in host-audio sink advances canonical SPU state while discarding only accelerated host output; Tomba listening QA passed after 1,100,752 discarded SPU frames. | +| L1.2 event horizon | IMPLEMENTED; CORPUS VALIDATION QUEUED | Production cycle advancement already batches device service at event/MMIO deadlines. Six configured PsyQ CD-wait sites delivered ~27% + ~9% stages. Generic idle-loop skip then cut warm bursts 0.53->0.34 s and 2.19->1.73 s, with 4,599 skips / 765M guest cycles and zero CD overwrites. Strictly per-game opt-in. | +| L1.3 overlay coverage | KILLED | Interpreter share was zero in the measured load window. | +| L1.4 asset/data replay | REJECTED FOR NOW | `FUN_8003EF50` replay produced title/game texture corruption despite zero verifier failures: v1 temporal verifier is unsound. Data shards default off and artifacts removed. | +| Configurable warm CD routes (L1.7 read-speed branch) | ACCEPTED, STRICTLY PER-GAME OPT-IN | Framework accepts up to 16 strict LBA routes with mismatch fallback and consumer-paced IRQ/DMA. Only data-read cadence accelerates; XA/CDDA, seek, and motor timing remain authentic. Tomba multi-route regression: 3 matches, 1,944 accelerated sectors, zero overwrites. Legacy singular config is deprecated. | +| L1.5 authentic backlog | DONE / KILLED AS ACCELERATION | 1,304/1,304 sectors exact-deadline; INT1 exposure exactly +5,000 cycles; zero holds, pending/lost, or overwrites. | +| L1.6 seek-only probe | DONE / KILLED ON TOMBA | Automated New Game: 0 seeks across 792 data sectors. Read-start latency was 2.57% of the data span, below the 10% gate. Pause was 8.11% but remains authentic because early completion is a known race/wedge class. | +| L1.7 state cache / broader HLE | DEFERRED | User excludes savestates; decompressor replay failed correctness. | + +Method, every experiment: measure first via always-on rings; start flag-gated; +one per session; kill criterion written before code. `idle_skip`, warm CD routes, +and the turbo host-audio sink are all strictly per-game opt-in. MMX6 remains the +next corpus validation. --- diff --git a/docs/LOAD_TIME_ZERO.md b/docs/LOAD_TIME_ZERO.md index 0b70f591..ffecf69a 100644 --- a/docs/LOAD_TIME_ZERO.md +++ b/docs/LOAD_TIME_ZERO.md @@ -61,7 +61,8 @@ Three levers, in ascending order of danger: ## 2. Prior-attempt ledger (do not re-litigate; verbatim outcomes) 1. **turbo_loads — SHIPPED, production path.** Engage = sustained - `cdrom_load_in_progress()` + game-started, 20-frame hysteresis + `cdrom_load_in_progress()` + game-started, 4-frame entry debounce + + 6-frame release debounce (`main.cpp:2062-2084`); presents 1-in-30; audio deliberately NOT muted (queue capped). ~2x wall on Tomba loads with a healthy overlay cache. NOTE (survey 2026-07-13): the primary SDL event pump now runs @@ -87,15 +88,44 @@ Three levers, in ascending order of danger: green-thread yield bookkeeping; ONE skipped switch is eventually fatal. Kept: the `[instant_loads]` inert scaffold (load-grid detector, window plumbing, TCP counters) and the measure-first method. -5. **Data shards — BUILT on `spike/tomba-load-shards` (2a4e574), never - regen'd/run.** Memoized pure-function replay: read-set/write-set/ +5. **Data shards — REJECTED/QUARANTINED after live Tomba test.** Memoized + pure-function replay: read-set/write-set/ reg-out/cycle-cost capture at emitter-hooked funcs, byte-verified replay, recorded cycles credited in slices through the normal advance+IRQ path. Purity poisons: MMIO, DMA write, thread switch, 4MiB budget, staleness. Persists per-shard `.dss`. Known v1 fidelity deltas (documented in DATA_SHARDS.md): ISR cycles double-credited; IRQ arriving mid-credit observes the completed write-set; GTE/COP0 - not captured. §5 adds ChatGPT's sharper version of that last flaw. + not captured. Live replay of `FUN_8003EF50` corrupted title and gameplay + textures despite zero verifier failures, proving the v1 temporal verifier + unsound. Runtime default is off and derived shards were removed. + +6. **Generic idle-loop skip — SHIPPED, strictly per-game opt-in.** Requires + repeated same-PC interrupt boundaries with no stores, no MMIO, and stable + registers; advances to the next device deadline in whole loop quanta. + Tomba live A/B: 4,599 skips / 765M guest cycles, warm main burst + 2.19 s -> 1.73 s, zero CD overwrites. Opt in per game with + `[runtime] idle_skip=true`, per process with `PSX_IDLE_SKIP=1`, or live + through the `idle_skip` debug command. Known accepted cosmetic tradeoff: + an occasional partial texture frame may flash under aggressive load turbo + and resolves on the following complete frame. + +7. **Strict warm CD routes — SHIPPED, per-game opt-in only.** The reusable + `[[runtime.warm_cd_routes]]` schema supports up to 16 exact LBA sequences. + A mismatch immediately restores normal timing. Only non-XA data-read cadence + changes; XA/CDDA, seek, motor, IRQ, DMA, callback, and decompression paths + remain authoritative. Tomba regression: three route matches, 1,944 accelerated + sectors, zero response overwrites. The legacy `[runtime.warm_cd_route]` + singular table is deprecated but remains readable with a warning. + +8. **Turbo host-audio sink — SHIPPED, per-game opt-in only.** With + `[runtime] turbo_audio_sink=true`, the canonical SPU still renders every + guest-time sample during an active turbo load, preserving voice and CD state, + but the accelerated buffers stop before SDL playback. Normal playback fades + back in from the current guest state. Tomba listening QA passed with + 1,100,752 sink-discarded SPU frames, two accelerated load-route matches, and + zero CD response overwrites. This does not revive the rejected mute/freeze + model, which paused voice state and produced audible cuts/restarts. Also relevant, shipped: FMV auto-skip (unpaced + muted + game-native teardown), HLE boot shell-skip + boot-turbo window (`bios_hle.c`), @@ -223,6 +253,14 @@ has NO acceleration value and is pure drive-model correctness work. Prereq: resolve the CD-model tree split (Ape direct-delivery vs master model) — pick ONE canonical model. +**Tomba verdict (2026-07-14): KILLED as acceleration.** The passive +`cdrom_timing` ring observed 1,304 data sectors: every buffer fill landed on +its exact scheduled cycle (zero early, zero late), and every exposed INT1 used +the intentional fixed +5,000-cycle controller-presentation latency. There were +zero read holds, pending/lost INT1s, or response overwrites. The current model +already maintains the continuous physical timeline; no catch-up work can make +this load faster. + ### E6 — `cd_seek_speedup` (Lever B, bounded, per-game opt-in) Probe first: seek/pause/spin share of window guest time. Upper bound = number_of_seeks x ~250ms — a seek-only hack cannot touch a sequential @@ -232,11 +270,26 @@ some titles, broke others). `pause_complete_delay_cycles` is a known CPU-visible ordering contract (Ape memcard wedge) — stays authentic. Kill: seek share <10% or <~250ms, or any callback/VBlank divergence. +**Tomba verdict (2026-07-14): KILLED by the gate.** An automated, authentic +New Game sample covered 792 data sectors over 298,130,657 guest cycles and +issued zero seek commands. Seventeen read-start delays totalled 7,676,928 +cycles (2.57% of the data span), below the 10% threshold. Seventeen pause +delays totalled 24,167,597 cycles (8.11%), but pause completion is the +CPU-visible ordering contract explicitly excluded above. The combined +31,844,525-cycle figure is only a theoretical upper bound: it includes that +unsafe pause term and assumes impossible zero-cost, non-overlapping removal. +No seek-speedup implementation is justified for this path. + ### E7 — `cd_read_speedup_exp` (Lever B, Phase-2 only) Per-title, with explicit XA/CDDA/MDEC and timing-sensitive exclusions (the DuckStation trait architecture). First unexplained wedge ⇒ permanently non-default. Never foundation. +**Implemented scope (2026-07-14):** strict, config-declared warm routes only. +This is not automatic global read acceleration and remains absent unless a game +opts in. Multiple routes share the same fail-closed matcher and consumer-paced +delivery path; XA/CDDA and non-read drive timing are excluded. + ### E8 — Decompressor HLE (only via E4's failure) Entered only if E0 shows decompression host-hot AND shards fail for an identifiable reason (low reuse, expensive verify, interp-resident). diff --git a/docs/config_schema.md b/docs/config_schema.md index c7eab4c1..c42c1d63 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -106,6 +106,39 @@ controller = "digital" # "digital" or "dualshock" memcard_dir = "." # memcard files location, relative to project root ``` +### Opt-in warm CD routes + +Per-game read acceleration is disabled unless the game explicitly declares +one or more strict routes. Each route arms on one `SetLoc`, then requires every +later file start to match `lbas` in order. A mismatch immediately restores the +normal configured disc timing. Only data-read cadence is accelerated; XA/CDDA, +seek, and motor timing remain authentic. + +```toml +[[runtime.warm_cd_routes]] +arm_lba = 95947 +lbas = [298, 299, 306] +instant_max_per_frame = 32 +``` + +Up to 16 routes may be declared, with 1–64 LBAs each. The old singular +`[runtime.warm_cd_route]` table is deprecated and emits a warning when loaded; +it remains readable for compatibility. This enhancement is intentionally +opt-in and must not acquire a global default. + +The other load-time accelerators are likewise opt-in: + +```toml +[runtime] +turbo_loads = true +idle_skip = true +turbo_audio_sink = true +``` + +`turbo_audio_sink` is meaningful only while `turbo_loads` is active. It keeps +the guest SPU timeline advancing but discards accelerated samples before host +playback, then fades normal output back in. + Reserved future fields: - `default_disc_path` — game runtimes can pre-mount a disc - `default_game_root` — for sibling-junction setups diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index 215990b9..db259bb8 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include "ps1_exe_parser.h" #include "function_analysis.h" #include "control_flow.h" @@ -56,6 +58,13 @@ struct CodeGenConfig { // See docs/DATA_SHARDS.md. Empty = no hooks (default). std::set data_shard_funcs; + // [load_accel.vsync_query] verified PsyQ VSync functions whose mode=-1 + // path may bypass its unused GPUSTAT/Timer1 reads. The map value is the + // guest RAM VBlank counter returned by that query path. Empty = inert. + // value = {VBlank counter, GPUSTAT-pointer global, Timer1-pointer global, + // cached Timer1 global} + std::map> vsync_query_hle_funcs; + // MMX6-class 2D tile-ring stage initializer. Emit a reveal-invalidation // callback at entry so host-only wide pixels cannot survive a stage swap. uint32_t ws_bg2d_init_func = 0; diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 797e3f5b..6e57e341 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -2196,6 +2196,18 @@ GeneratedFunction CodeGenerator::generate_function( body_ss << config_.indent << fmt::format("debug_server_log_call_entry(0x{:08X}u);\n", func.start_addr); + { + const auto vq = config_.vsync_query_hle_funcs.find(func.start_addr); + if (vq != config_.vsync_query_hle_funcs.end()) { + body_ss << config_.indent + << fmt::format( + "if (psx_vsync_query_hle_enter(cpu, 0x{:08X}u, 0x{:08X}u, " + "0x{:08X}u, 0x{:08X}u, 0x{:08X}u)) return;" + " /* load accel: cycle-faithful VSync(-1) query */\n", + func.start_addr, vq->second[0], vq->second[1], + vq->second[2], vq->second[3]); + } + } if (config_.data_shard_funcs.count(func.start_addr)) { // Data-shard hook (docs/DATA_SHARDS.md): on a verified shard hit the // runtime applies the recorded effect, credits the recorded cycles, @@ -2650,6 +2662,7 @@ std::string CodeGenerator::generate_file( ss << "#endif\n"; ss << "extern int psx_datashard_enter(CPUState* cpu, uint32_t key); /* data-shard replay/capture (data_shards.c) */\n"; ss << "extern void psx_datashard_ret(CPUState* cpu); /* data-shard capture finalize */\n"; + ss << "extern int psx_vsync_query_hle_enter(CPUState* cpu, uint32_t func, uint32_t counter_addr, uint32_t gpustat_ptr_addr, uint32_t timer1_ptr_addr, uint32_t timer1_cache_addr); /* load_accel.c */\n"; ss << "extern void psx_ws_sprite_tag(CPUState* cpu); /* widescreen prim tag (gpu.c) */\n"; ss << "extern void psx_ws_mmx6_bg_stage_init(void); /* ws 2D stage reveal invalidation (gpu.c) */\n"; ss << "extern int psx_ws_x_margin(void); /* widescreen cull-margin term (gpu.c) */\n"; diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 132d4a03..8301a6b6 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -3,6 +3,7 @@ #include "config_loader.h" #include +#include #include #include #include @@ -149,6 +150,74 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& rt.instant_max_per_frame = static_cast(n); rt.has_instant_max_per_frame = true; } + auto parse_warm_cd_route = [&](const toml::value& route, + const std::string& label) { + if (!route.contains("arm_lba") || !route.contains("lbas")) { + throw std::runtime_error( + label + " arm_lba and lbas are required"); + } + const auto arm = toml::find(route, "arm_lba"); + if (arm < 0 || arm > 0x7FFFFFFFll) { + throw std::runtime_error(fmt::format( + "{} arm_lba out of range: {}", label, arm)); + } + RuntimeConfig::WarmCdRoute parsed; + parsed.arm_lba = static_cast(arm); + const auto& lbas = toml::find>(route, "lbas"); + if (lbas.empty() || lbas.size() > 64) { + throw std::runtime_error( + label + " lbas must contain 1..64 entries"); + } + for (const auto lba : lbas) { + if (lba < 0 || lba > 0x7FFFFFFFll) { + throw std::runtime_error(fmt::format( + "{} LBA out of range: {}", label, lba)); + } + parsed.lbas.push_back(static_cast(lba)); + } + if (route.contains("instant_max_per_frame")) { + const auto n = toml::find(route, "instant_max_per_frame"); + if (n < 1 || n > 4096) { + throw std::runtime_error(fmt::format( + "{} instant_max_per_frame out of range (1..4096): {}", + label, n)); + } + parsed.instant_max_per_frame = static_cast(n); + } + for (const auto& existing : rt.warm_cd_routes) { + if (existing.arm_lba == parsed.arm_lba && + existing.lbas.front() == parsed.lbas.front()) { + throw std::runtime_error(fmt::format( + "{} duplicates route arm {} / first LBA {}", label, + parsed.arm_lba, parsed.lbas.front())); + } + } + rt.warm_cd_routes.push_back(std::move(parsed)); + }; + if (runtime.contains("warm_cd_route")) { + std::fprintf(stderr, + "psxrecomp: warning: [runtime.warm_cd_route] is deprecated; " + "use [[runtime.warm_cd_routes]]\n"); + parse_warm_cd_route(toml::find(runtime, "warm_cd_route"), + "[runtime.warm_cd_route]"); + } + if (runtime.contains("warm_cd_routes")) { + const auto& routes = toml::find(runtime, "warm_cd_routes"); + if (!routes.is_array() || routes.as_array().empty() || + routes.as_array().size() > 16) { + throw std::runtime_error( + "[[runtime.warm_cd_routes]] must contain 1..16 routes"); + } + size_t route_index = 0; + for (const auto& route : routes.as_array()) { + if (!route.is_table()) { + throw std::runtime_error( + "[[runtime.warm_cd_routes]] entries must be tables"); + } + parse_warm_cd_route(route, fmt::format( + "[[runtime.warm_cd_routes]] entry {}", route_index++)); + } + } if (runtime.contains("fast_boot")) { rt.fast_boot = toml::find(runtime, "fast_boot"); } @@ -167,6 +236,12 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (runtime.contains("turbo_loads")) { rt.turbo_loads = toml::find(runtime, "turbo_loads"); } + if (runtime.contains("turbo_audio_sink")) { + rt.turbo_audio_sink = toml::find(runtime, "turbo_audio_sink"); + } + if (runtime.contains("idle_skip")) { + rt.idle_skip = toml::find(runtime, "idle_skip"); + } if (runtime.contains("overlay_autocompile_cmd")) { rt.overlay_autocompile_cmd = toml::find(runtime, "overlay_autocompile_cmd"); @@ -735,6 +810,62 @@ GameConfig load_game_config(const fs::path& config_path_in) { data_shard_funcs.push_back(parse_hex(a, "data_shards.funcs")); } } + uint32_t vsync_query_func = 0; + uint32_t vsync_counter_addr = 0; + uint32_t vsync_gpustat_ptr_addr = 0; + uint32_t vsync_timer1_ptr_addr = 0; + uint32_t vsync_timer1_cache_addr = 0; + std::vector vsync_event_horizon_sites; + std::vector vsync_event_horizon_extra_sites; + if (cfg.contains("load_accel")) { + const toml::value& lav = toml::find(cfg, "load_accel"); + if (lav.contains("vsync_query")) { + const toml::value& vq = toml::find(lav, "vsync_query"); + const bool has_func = vq.contains("func"); + const bool has_counter = vq.contains("counter_addr"); + const bool has_gpu_ptr = vq.contains("gpustat_ptr_addr"); + const bool has_timer_ptr = vq.contains("timer1_ptr_addr"); + const bool has_timer_cache = vq.contains("timer1_cache_addr"); + const int present = (int)has_func + (int)has_counter + (int)has_gpu_ptr + + (int)has_timer_ptr + (int)has_timer_cache; + if (present != 0 && present != 5) + throw std::runtime_error(fmt::format( + "{}: [load_accel.vsync_query] func, counter_addr, gpustat_ptr_addr, " + "timer1_ptr_addr, and timer1_cache_addr must be set together", + config_path.string())); + if (has_func) { + vsync_query_func = parse_hex( + toml::find(vq, "func"), + "load_accel.vsync_query.func"); + vsync_counter_addr = parse_hex( + toml::find(vq, "counter_addr"), + "load_accel.vsync_query.counter_addr"); + vsync_gpustat_ptr_addr = parse_hex( + toml::find(vq, "gpustat_ptr_addr"), + "load_accel.vsync_query.gpustat_ptr_addr"); + vsync_timer1_ptr_addr = parse_hex( + toml::find(vq, "timer1_ptr_addr"), + "load_accel.vsync_query.timer1_ptr_addr"); + vsync_timer1_cache_addr = parse_hex( + toml::find(vq, "timer1_cache_addr"), + "load_accel.vsync_query.timer1_cache_addr"); + if (vq.contains("event_horizon_sites")) { + const auto& arr = toml::find>( + vq, "event_horizon_sites"); + for (const auto& a : arr) + vsync_event_horizon_sites.push_back(parse_hex( + a, "load_accel.vsync_query.event_horizon_sites")); + } + if (vq.contains("event_horizon_extra_sites")) { + const auto& arr = toml::find>( + vq, "event_horizon_extra_sites"); + for (const auto& a : arr) + vsync_event_horizon_extra_sites.push_back(parse_hex( + a, "load_accel.vsync_query.event_horizon_extra_sites")); + } + } + } + } if (cfg.contains("widescreen")) { const toml::value& ws = toml::find(cfg, "widescreen"); if (ws.contains("sprite_tag_funcs")) { @@ -955,6 +1086,13 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*ws_sprite_anchor_addr*/ ws_sprite_anchor_addr, /*ws_hud_sprt_squash*/ ws_hud_sprt_squash, /*data_shard_funcs*/ data_shard_funcs, + /*vsync_query_func*/ vsync_query_func, + /*vsync_counter_addr*/ vsync_counter_addr, + /*vsync_gpustat_ptr_addr*/ vsync_gpustat_ptr_addr, + /*vsync_timer1_ptr_addr*/ vsync_timer1_ptr_addr, + /*vsync_timer1_cache_addr*/ vsync_timer1_cache_addr, + /*vsync_event_horizon_sites*/ vsync_event_horizon_sites, + /*vsync_event_horizon_extra_sites*/ vsync_event_horizon_extra_sites, /*ws_cull_bias_sites*/ ws_cull_bias_sites, /*ws_cull_range_sites*/ ws_cull_range_sites, /*ws_cull_a1_sites*/ ws_cull_a1_sites, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index efef7aec..bbc79971 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -84,6 +84,19 @@ struct RuntimeConfig { bool has_instant_max_per_frame = false; int instant_max_per_frame = 0; + // Optional, game-specific warm-load route accelerators. A matching SetLoc + // at arm_lba followed by the exact ordered lbas sequence switches only + // those data reads to the bounded instant cadence. Any sequence mismatch + // fails closed to the configured disc_speed. The parser accepts the legacy + // [runtime.warm_cd_route] table and the reusable + // [[runtime.warm_cd_routes]] array-of-tables. + struct WarmCdRoute { + int arm_lba = -1; + std::vector lbas; + int instant_max_per_frame = 32; + }; + std::vector warm_cd_routes; + // fast_boot: DEPRECATED alias for the HLE boot shell-skip (see bios_hle // below). The old mechanism (snapshot BIOS state at first handoff, restore // on later launches) is gone; fast_boot=true now skips only the BIOS shell @@ -125,6 +138,18 @@ struct RuntimeConfig { // load wall-time. Streaming titles (e.g. Crash) must leave this off. bool turbo_loads = false; + // turbo_audio_sink: while turbo_loads is actively running unpaced, keep + // rendering the exact guest-time SPU sample budget (so voice/CD state + // advances) but discard those samples before the host playback queue. + // Opt-in while the experiment is under live audio QA. + bool turbo_audio_sink = false; + + // idle_skip: proof-gated fast-forward through repeated CPU polling + // loops with no stores/MMIO and stable register state. Guest time and + // device events still advance exactly. Opt-in per game; the idle_skip + // debug command and PSX_IDLE_SKIP environment variable support live A/B. + bool idle_skip = false; + // overlay_autocompile_cmd: variant-capture automation (step 2.8). A // shell command (run via cmd.exe /C, cwd = project root) that compiles // overlay_captures.json into the cache — normally the project's @@ -417,6 +442,22 @@ struct GameConfig { // out to be impure only costs a poisoned capture, never a wrong replay. std::vector data_shard_funcs; + // [load_accel.vsync_query] opt-in for a byte-verified PsyQ VSync(mode) + // implementation. mode=-1 returns vsync_counter_addr while bypassing two + // unused MMIO reads; every other mode executes the original function. + uint32_t vsync_query_func = 0; + uint32_t vsync_counter_addr = 0; + uint32_t vsync_gpustat_ptr_addr = 0; + uint32_t vsync_timer1_ptr_addr = 0; + uint32_t vsync_timer1_cache_addr = 0; + // Return addresses of verified CD wait-loop VSync(-1) calls. At these + // sites only, the runtime may advance guest time to the next deliverable + // device event while a sustained CD load is active. + std::vector vsync_event_horizon_sites; + // Separately togglable second tier for additional verified loops, allowing + // per-feature A/B without disabling the accepted base site set. + std::vector vsync_event_horizon_extra_sites; + // Cull-margin widening. The game's per-object draw classifier compares // (objX - camX + BIAS) against a RANGE derived from the 4:3 screen width; // the GTE squash shows ~33% more world, so the fixed margin collapses and diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index cb5e4f66..ef27ad85 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -121,6 +121,7 @@ int main(int argc, char** argv) { bool overlay_mode = false; std::set ws_tag_funcs; // [widescreen] sprite_tag_funcs std::set ds_funcs; // [data_shards] funcs + std::map> vsync_query_hle_funcs; std::set ws_cull_bias, ws_cull_range, ws_cull_a1; // [widescreen.cull] std::set ws_cull_screen_x; // [widescreen.cull] screen_x_sites std::set ws_cull_slti; // [widescreen.cull] slti_sites @@ -149,6 +150,10 @@ int main(int argc, char** argv) { ws_tag_funcs.insert(cfg.ws_sprite_tag_funcs.begin(), cfg.ws_sprite_tag_funcs.end()); ds_funcs.insert(cfg.data_shard_funcs.begin(), cfg.data_shard_funcs.end()); + if (cfg.vsync_query_func) + vsync_query_hle_funcs[cfg.vsync_query_func] = { + cfg.vsync_counter_addr, cfg.vsync_gpustat_ptr_addr, + cfg.vsync_timer1_ptr_addr, cfg.vsync_timer1_cache_addr }; ws_cull_bias.insert(cfg.ws_cull_bias_sites.begin(), cfg.ws_cull_bias_sites.end()); ws_cull_range.insert(cfg.ws_cull_range_sites.begin(), cfg.ws_cull_range_sites.end()); ws_cull_a1.insert(cfg.ws_cull_a1_sites.begin(), cfg.ws_cull_a1_sites.end()); @@ -780,6 +785,7 @@ int main(int argc, char** argv) { codegen_config.overlay_mode = overlay_mode; codegen_config.ws_sprite_tag_funcs = ws_tag_funcs; codegen_config.data_shard_funcs = ds_funcs; + codegen_config.vsync_query_hle_funcs = vsync_query_hle_funcs; codegen_config.ws_bg2d_init_func = ws_bg2d_init_func; codegen_config.ws_cull_bias_sites = ws_cull_bias; codegen_config.ws_cull_range_sites = ws_cull_range; diff --git a/runtime/include/audio_trace.h b/runtime/include/audio_trace.h index 60b5c8b2..d8cab71d 100644 --- a/runtime/include/audio_trace.h +++ b/runtime/include/audio_trace.h @@ -59,6 +59,7 @@ enum { b=(stage<<28)|(start_frame<<14)|run_len. stage 0=post-ADPCM-decode (native rate), stage 1=post-resample+volume (44100). */ + AUDIO_EV_SINK_DROP = 10, /* turbo host sink; a=guest SPU frames discarded */ }; typedef struct { diff --git a/runtime/include/cdrom.h b/runtime/include/cdrom.h index d63a922c..47f761f8 100644 --- a/runtime/include/cdrom.h +++ b/runtime/include/cdrom.h @@ -29,6 +29,20 @@ int cdrom_get_setloc_lba(void); void cdrom_set_instant_rate(int per_frame); int cdrom_get_instant_rate(void); +/* Strict, config-driven warm-load route accelerator. Up to 16 routes may be + * registered after cdrom_init. A route arms only after arm_lba and activates + * only if the following SetLoc values match lbas in order. Any mismatch + * restores normal disc timing. Only non-XA read cadence is accelerated. */ +void cdrom_register_warm_route(int arm_lba, const int* lbas, int count, + int instant_max_per_frame); +void cdrom_warm_route_set_enabled(int enabled); +void cdrom_warm_route_stats_json(char* out, int cap); + +/* Passive physical-deadline -> buffer -> INT1 exposure telemetry (L1.5). + * Diagnostics only: recording never changes CD scheduling or delivery. */ +void cdrom_timing_reset(void); +void cdrom_timing_stats_json(char* out, int cap); + /* FMV auto-skip detection: cdrom_xa_stream_active() lets the frontend detect * that streaming XA (FMV/CDDA) is in progress. The skip itself is done by the * frontend via uncapped pacing (it does NOT alter CD timing — flooding XA @@ -52,6 +66,9 @@ uint32_t cdrom_get_burst_total(void); * data sector delivered within the burst-gap window). XA streaming is never * a load. Drives turbo-through-loads (step 4). */ int cdrom_load_in_progress(void); +/* Physical non-XA data-read command state, without the logical load gap + * bridge used by cdrom_load_in_progress(). Diagnostics only. */ +int cdrom_data_read_active(void); /* MMIO read/write (0x1F801800-0x1F801803) */ uint32_t cdrom_read(uint32_t addr); diff --git a/runtime/include/load_accel.h b/runtime/include/load_accel.h new file mode 100644 index 00000000..7a8c8137 --- /dev/null +++ b/runtime/include/load_accel.h @@ -0,0 +1,27 @@ +/* load_accel.h -- opt-in, verified load-time acceleration primitives. */ +#ifndef PSXRECOMP_LOAD_ACCEL_H +#define PSXRECOMP_LOAD_ACCEL_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct CPUState; + +int psx_vsync_query_hle_enter(struct CPUState* cpu, uint32_t func, + uint32_t counter_addr, uint32_t gpustat_ptr_addr, + uint32_t timer1_ptr_addr, + uint32_t timer1_cache_addr); +void psx_vsync_query_hle_set_enabled(int on); +void psx_vsync_query_hle_set_horizon_enabled(int on); +void psx_vsync_query_hle_set_extra_horizon_enabled(int on); +void psx_vsync_query_hle_add_event_horizon_site(uint32_t return_pc); +void psx_vsync_query_hle_add_extra_event_horizon_site(uint32_t return_pc); +void psx_vsync_query_hle_stats_json(char* buf, int cap); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime/include/load_transition_ring.h b/runtime/include/load_transition_ring.h new file mode 100644 index 00000000..0dd4385e --- /dev/null +++ b/runtime/include/load_transition_ring.h @@ -0,0 +1,29 @@ +#ifndef PSXRECOMP_LOAD_TRANSITION_RING_H +#define PSXRECOMP_LOAD_TRANSITION_RING_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Host-side load/pacing edge telemetry. An entry is appended whenever the + * physical CD read state, bridged logical-load predicate, or turbo state + * changes. This is deliberately observational: it does not alter pacing. */ +typedef struct { + uint32_t frame; + uint32_t host_ms; + uint16_t load_run; + uint8_t read_active; + uint8_t load_active; + uint8_t turbo_active; +} LoadTransitionEntry; + +uint64_t load_transition_total(void); +int load_transition_get(uint64_t seq, LoadTransitionEntry *out); + +#ifdef __cplusplus +} +#endif + +#endif /* PSXRECOMP_LOAD_TRANSITION_RING_H */ diff --git a/runtime/include/psx_cyc.h b/runtime/include/psx_cyc.h index 0166d551..fa1ff7cb 100644 --- a/runtime/include/psx_cyc.h +++ b/runtime/include/psx_cyc.h @@ -88,6 +88,12 @@ extern uint32_t psx_cyc_load_word(CPUState* cpu, uint32_t addr, uint32_t rt, uin extern uint16_t psx_cyc_load_half(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask); extern uint8_t psx_cyc_load_byte(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask); +/* Charge the exact timing/pipeline effects of a 32-bit CPU load without + * invoking the memory handler. Enhancement HLE may use this only when static + * analysis proves the loaded value and read side effects are unobservable. */ +extern void psx_cyc_load_word_timing_only(CPUState* cpu, uint32_t addr, + uint32_t rt, uint32_t reg_mask); + /* LWC2 (GTE load) ReadMemory timing only (completion +1, NO LDWhich arm — the dest * is a GTE register). Call AFTER psx_cyc_step(cpu,0) (§1+DO_LDS) and psx_gte_stall. * Returns the raw 32-bit value. */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 63e02b80..c3cdeeaa 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -191,6 +191,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/starvation_ring.c ${PSXRECOMP_ROOT}/runtime/src/latency_ring.c ${PSXRECOMP_ROOT}/runtime/src/data_shards.c + ${PSXRECOMP_ROOT}/runtime/src/load_accel.c ${PSXRECOMP_ROOT}/runtime/src/card_read_summary.c ${PSXRECOMP_ROOT}/runtime/src/card_data_writes.c ${PSXRECOMP_ROOT}/runtime/src/overlay_capture.c diff --git a/runtime/src/cdrom.c b/runtime/src/cdrom.c index 79682f7b..b7332501 100644 --- a/runtime/src/cdrom.c +++ b/runtime/src/cdrom.c @@ -15,6 +15,8 @@ #include "spu.h" #include "event_ring.h" #include "audio_trace.h" +#include "psx_cycles.h" +#include #include #include #ifndef _WIN32 @@ -166,6 +168,43 @@ static int read_min, read_sec, read_sect; static uint8_t mode_reg; static uint8_t read_cmd; static int read_delay; + +/* ---- Sector deadline/exposure telemetry (L1.5, passive) ----------------- + * One record per physical sector deadline. It separates drive scheduling + * lateness (buffer_cycle - due_cycle) from guest/controller backpressure + * (IRQ arm/presentation after the buffer already exists). */ +#define CD_TIMING_CAP 4096u +#define CDT_DATA 0x01u +#define CDT_DMA 0x02u +#define CDT_PENDED 0x04u +#define CDT_LOST 0x08u +#define CDT_IRQ_ARMED 0x10u +#define CDT_INTC 0x20u +typedef struct { + uint64_t seq; + uint64_t due_cycle; + uint64_t buffer_cycle; + uint64_t irq_arm_cycle; + uint64_t intc_cycle; + uint32_t frame; + int32_t lba; + uint8_t flags; +} CdTimingRecord; +static CdTimingRecord s_cd_timing[CD_TIMING_CAP]; +static uint64_t s_cd_timing_total; +static uint64_t s_cd_timing_reset_seq; +static uint64_t s_cd_timing_next_due; +static uint64_t s_cd_timing_irq_seq = UINT64_MAX; +static uint64_t s_cd_timing_pending_seq = UINT64_MAX; +static uint64_t s_cd_timing_stream_starts; +static uint64_t s_cd_timing_reset_cycle; +static uint64_t s_cd_probe_read_start_count, s_cd_probe_read_start_cycles; +static uint64_t s_cd_probe_pause_count, s_cd_probe_pause_cycles; +static uint64_t s_cd_probe_seek_count, s_cd_probe_seek_cycles; +static uint64_t s_cd_probe_motor_count, s_cd_probe_motor_cycles; +static uint64_t s_cd_probe_stop_count, s_cd_probe_stop_cycles; + +static void cd_timing_note_intc(void); static uint8_t filter_file; static uint8_t filter_channel; static uint8_t cd_muted; @@ -269,6 +308,157 @@ uint32_t g_cd_overwrite_last_frame = 0; * effective ceiling is VBLANK_CYCLES_NTSC/CDROM_MIN_DELAY ≈ 1128/frame. */ static int g_instant_max_per_frame = CDROM_INSTANT_MAX_PER_FRAME_DEFAULT; +/* Config-driven, game-specific warm-load routes. These deliberately change + * only non-XA read cadence: the normal command, seek/motor timing, IRQ, + * callback, DMA, and guest decompression paths remain authoritative. */ +#define CDROM_WARM_ROUTE_MAX 64 +#define CDROM_WARM_ROUTES_MAX 16 +typedef struct CDROMWarmRoute { + int arm_lba; + int lbas[CDROM_WARM_ROUTE_MAX]; + int count; + int rate; +} CDROMWarmRoute; +static CDROMWarmRoute s_warm_routes[CDROM_WARM_ROUTES_MAX]; +static int s_warm_routes_count; +static int s_warm_route_configured; +static int s_warm_route_enabled; +static int s_warm_route_armed; +static int s_warm_route_armed_lba = -1; +static int s_warm_route_active; +static int s_warm_route_active_index = -1; +static int s_warm_route_next; +static int s_warm_route_last_lba = -1; +static uint64_t s_warm_route_matches; +static uint64_t s_warm_route_mismatches; +static uint64_t s_warm_route_sectors; +static uint64_t s_warm_route_consumer_waits; +static uint64_t s_warm_route_consumer_wait_cycles; + +void cdrom_register_warm_route(int arm_lba, const int* lbas, int count, + int instant_max_per_frame) { + if (!lbas || count < 1 || count > CDROM_WARM_ROUTE_MAX || arm_lba < 0 || + s_warm_routes_count >= CDROM_WARM_ROUTES_MAX) return; + if (instant_max_per_frame < 1) instant_max_per_frame = 1; + if (instant_max_per_frame > 4096) instant_max_per_frame = 4096; + CDROMWarmRoute *route = &s_warm_routes[s_warm_routes_count++]; + memcpy(route->lbas, lbas, (size_t)count * sizeof(lbas[0])); + route->arm_lba = arm_lba; + route->count = count; + route->rate = instant_max_per_frame; + s_warm_route_configured = 1; + s_warm_route_enabled = 1; + s_warm_route_armed = 0; + s_warm_route_armed_lba = -1; + s_warm_route_active = 0; + s_warm_route_active_index = -1; + s_warm_route_next = 0; + s_warm_route_last_lba = -1; +} + +void cdrom_warm_route_set_enabled(int enabled) { + s_warm_route_enabled = enabled ? 1 : 0; + if (!s_warm_route_enabled) { + s_warm_route_armed = 0; + s_warm_route_armed_lba = -1; + s_warm_route_active = 0; + s_warm_route_active_index = -1; + s_warm_route_next = 0; + s_warm_route_last_lba = -1; + } +} + +static const CDROMWarmRoute* warm_route_current(void) { + if (!s_warm_route_active || s_warm_route_active_index < 0 || + s_warm_route_active_index >= s_warm_routes_count) return NULL; + return &s_warm_routes[s_warm_route_active_index]; +} + +void cdrom_warm_route_stats_json(char* out, int cap) { + if (!out || cap <= 0) return; + int state_index = s_warm_route_active ? s_warm_route_active_index : -1; + const CDROMWarmRoute *route = state_index >= 0 + ? &s_warm_routes[state_index] + : (s_warm_routes_count > 0 ? &s_warm_routes[0] : NULL); + snprintf(out, (size_t)cap, + "\"configured\":%d,\"enabled\":%d,\"armed\":%d," + "\"active\":%d,\"routes\":%d,\"active_route\":%d," + "\"arm_lba\":%d,\"route_entries\":%d," + "\"next_entry\":%d,\"rate\":%d,\"matches\":%llu," + "\"mismatches\":%llu,\"sectors\":%llu," + "\"consumer_waits\":%llu,\"consumer_wait_cycles\":%llu", + s_warm_route_configured, s_warm_route_enabled, + s_warm_route_armed, s_warm_route_active, s_warm_routes_count, + state_index, route ? route->arm_lba : -1, + route ? route->count : 0, s_warm_route_next, + route ? route->rate : 0, + (unsigned long long)s_warm_route_matches, + (unsigned long long)s_warm_route_mismatches, + (unsigned long long)s_warm_route_sectors, + (unsigned long long)s_warm_route_consumer_waits, + (unsigned long long)s_warm_route_consumer_wait_cycles); +} + +static void warm_route_on_setloc(int lba) { + if (!s_warm_route_configured || !s_warm_route_enabled) return; + + for (int i = 0; i < s_warm_routes_count; i++) { + if (lba == s_warm_routes[i].arm_lba) { + s_warm_route_armed = 1; + s_warm_route_armed_lba = lba; + s_warm_route_active = 0; + s_warm_route_active_index = -1; + s_warm_route_next = 0; + s_warm_route_last_lba = lba; + return; + } + } + + if (!s_warm_route_active) { + if (s_warm_route_armed) { + int match = -1; + for (int i = 0; i < s_warm_routes_count; i++) { + if (s_warm_routes[i].arm_lba == s_warm_route_armed_lba && + lba == s_warm_routes[i].lbas[0]) { + match = i; + break; + } + } + s_warm_route_armed = 0; + s_warm_route_armed_lba = -1; + if (match >= 0) { + s_warm_route_active = 1; + s_warm_route_active_index = match; + s_warm_route_next = 1; + s_warm_route_last_lba = lba; + s_warm_route_matches++; + } else { + s_warm_route_mismatches++; + } + } + return; + } + + const CDROMWarmRoute *route = warm_route_current(); + /* Repeated SetLoc for the current file is harmless. Every transition to + * a new file must match the captured order exactly. */ + if (lba == s_warm_route_last_lba) return; + if (route && s_warm_route_next < route->count && + lba == route->lbas[s_warm_route_next]) { + s_warm_route_last_lba = lba; + s_warm_route_next++; + return; + } + + s_warm_route_active = 0; + s_warm_route_active_index = -1; + s_warm_route_armed = 0; + s_warm_route_armed_lba = -1; + s_warm_route_next = 0; + s_warm_route_last_lba = -1; + s_warm_route_mismatches++; +} + void cdrom_set_instant_rate(int per_frame) { if (per_frame < 1) per_frame = 1; if (per_frame > 4096) per_frame = 4096; @@ -281,6 +471,13 @@ static int instant_period(void) { return p < CDROM_MIN_DELAY ? CDROM_MIN_DELAY : p; } +static int warm_route_period(void) { + const CDROMWarmRoute *route = warm_route_current(); + int rate = route ? route->rate : CDROM_INSTANT_MAX_PER_FRAME_DEFAULT; + int p = VBLANK_CYCLES_NTSC / rate; + return p < CDROM_MIN_DELAY ? CDROM_MIN_DELAY : p; +} + static int apply_speed(int delay) { /* XA streaming (FMV / CDDA background music): preserve authentic timing. * FMVs interleave XA audio + MDEC video — speeding up sector delivery @@ -291,6 +488,15 @@ static int apply_speed(int delay) { return d < CDROM_MIN_DELAY ? CDROM_MIN_DELAY : d; } +static int apply_read_speed(int delay) { + /* A route is an explicit DATA-read allowlist, never a blanket drive-speed + * change. XA filter/ADPCM modes are rejected before the first streaming + * sector can set xa_stream_active; established XA remains authentic too. */ + if (xa_stream_active || (mode_reg & 0x48u)) return delay; + if (s_warm_route_active) return warm_route_period(); + return apply_speed(delay); +} + /* ---- CD load-burst ring (always-on; CLAUDE.md ring-buffer rule) ---------- * A "burst" is a run of delivered sectors with no gap longer than * CD_BURST_GAP_FRAMES — i.e. one load. Records make "load duration" a @@ -326,8 +532,10 @@ static void burst_note_sector(void) { b->end_frame = f; b->end_ms = ms; b->sectors++; - b->rate = (uint32_t)g_instant_max_per_frame; - b->divisor = (uint32_t)g_disc_speed_divisor; + const CDROMWarmRoute *route = warm_route_current(); + b->rate = (uint32_t)(route ? route->rate : g_instant_max_per_frame); + b->divisor = (uint32_t)(s_warm_route_active ? 0 + : g_disc_speed_divisor); } /* Copy out the most recent `max` bursts, newest first. Returns count. */ @@ -348,7 +556,7 @@ static int sector_delay_cycles(void) { int base = (mode_reg & 0x80) ? (CDROM_SINGLE_SPEED_SECTOR_CYCLES / 2) : CDROM_SINGLE_SPEED_SECTOR_CYCLES; - return apply_speed(base); + return apply_read_speed(base); } static int initial_read_delay_cycles(void) { @@ -358,7 +566,7 @@ static int initial_read_delay_cycles(void) { int base = (mode_reg & 0x80) ? CDROM_SINGLE_SPEED_SECTOR_CYCLES : (CDROM_SINGLE_SPEED_SECTOR_CYCLES * 2); - return apply_speed(base); + return apply_read_speed(base); } static int seek_complete_delay_cycles(void) { @@ -584,6 +792,7 @@ static void present_cdrom_irq(void) { cdrom_intc_latched_generation = cdrom_irq_generation; event_ring_record(EV_ISTAT_RAISE, 2 /* IRQ_CDROM bit */); trace_cdrom('F', 0, irq_flag, 0); + if (irq_flag == CDIRQ_DATA_READY) cd_timing_note_intc(); } } @@ -608,6 +817,158 @@ static int msf_to_lba(int m, int s, int f) { return (m * 60 + s) * 75 + f - 150; } +static CdTimingRecord *cd_timing_lookup(uint64_t seq) { + if (seq == UINT64_MAX || seq >= s_cd_timing_total || + s_cd_timing_total - seq > CD_TIMING_CAP) return NULL; + CdTimingRecord *r = &s_cd_timing[seq & (CD_TIMING_CAP - 1u)]; + return r->seq == seq ? r : NULL; +} + +static uint64_t cd_timing_begin_sector(int lba) { + uint64_t seq = s_cd_timing_total++; + CdTimingRecord *r = &s_cd_timing[seq & (CD_TIMING_CAP - 1u)]; + memset(r, 0, sizeof(*r)); + r->seq = seq; + r->due_cycle = s_cd_timing_next_due ? s_cd_timing_next_due : psx_cycle_count; + r->buffer_cycle = psx_cycle_count; + r->frame = (uint32_t)s_frame_count; + r->lba = lba; + return seq; +} + +static void cd_timing_flag(uint64_t seq, uint8_t flags) { + CdTimingRecord *r = cd_timing_lookup(seq); + if (r) r->flags |= flags; +} + +static void cd_timing_arm_irq(uint64_t seq) { + CdTimingRecord *r = cd_timing_lookup(seq); + if (!r) return; + r->flags |= CDT_IRQ_ARMED; + r->irq_arm_cycle = psx_cycle_count; + s_cd_timing_irq_seq = seq; +} + +static void cd_timing_note_intc(void) { + CdTimingRecord *r = cd_timing_lookup(s_cd_timing_irq_seq); + if (!r || (r->flags & CDT_INTC)) return; + r->flags |= CDT_INTC; + r->intc_cycle = psx_cycle_count; +} + +void cdrom_timing_reset(void) { + s_cd_timing_reset_seq = s_cd_timing_total; + s_cd_timing_stream_starts = 0; + s_cd_timing_reset_cycle = psx_cycle_count; + s_cd_probe_read_start_count = s_cd_probe_read_start_cycles = 0; + s_cd_probe_pause_count = s_cd_probe_pause_cycles = 0; + s_cd_probe_seek_count = s_cd_probe_seek_cycles = 0; + s_cd_probe_motor_count = s_cd_probe_motor_cycles = 0; + s_cd_probe_stop_count = s_cd_probe_stop_cycles = 0; +} + +void cdrom_timing_stats_json(char *out, int cap) { + if (!out || cap <= 0) return; + uint64_t start = s_cd_timing_reset_seq; + uint64_t dropped = 0; + if (s_cd_timing_total - start > CD_TIMING_CAP) { + dropped = s_cd_timing_total - start - CD_TIMING_CAP; + start = s_cd_timing_total - CD_TIMING_CAP; + } + uint64_t data = 0, exact = 0, early = 0, late = 0; + uint64_t late_sum = 0, late_max = 0; + uint64_t armed = 0, arm_sum = 0, arm_max = 0; + uint64_t exposed = 0, exposure_sum = 0, exposure_max = 0; + uint64_t pended = 0, lost = 0, dma = 0, over_sector = 0; + uint64_t first_due = UINT64_MAX, last_buffer = 0; + uint64_t bins[5] = {0, 0, 0, 0, 0}; + for (uint64_t seq = start; seq < s_cd_timing_total; seq++) { + CdTimingRecord *r = cd_timing_lookup(seq); + if (!r || !(r->flags & CDT_DATA)) continue; + data++; + if (r->due_cycle < first_due) first_due = r->due_cycle; + if (r->buffer_cycle > last_buffer) last_buffer = r->buffer_cycle; + if (r->buffer_cycle < r->due_cycle) { + early++; + } else { + uint64_t d = r->buffer_cycle - r->due_cycle; + if (d == 0) { exact++; bins[0]++; } + else { + late++; late_sum += d; if (d > late_max) late_max = d; + if (d <= 64u) bins[1]++; + else if (d <= 1024u) bins[2]++; + else if (d <= 5000u) bins[3]++; + else bins[4]++; + } + } + if (r->flags & CDT_IRQ_ARMED) { + uint64_t d = r->irq_arm_cycle - r->buffer_cycle; + armed++; arm_sum += d; if (d > arm_max) arm_max = d; + } + if (r->flags & CDT_INTC) { + uint64_t d = r->intc_cycle - r->buffer_cycle; + exposed++; exposure_sum += d; + if (d > exposure_max) exposure_max = d; + if (d > CDROM_SINGLE_SPEED_SECTOR_CYCLES) over_sector++; + } + if (r->flags & CDT_PENDED) pended++; + if (r->flags & CDT_LOST) lost++; + if (r->flags & CDT_DMA) dma++; + } + snprintf(out, (size_t)cap, + "\"stream_starts\":%llu,\"records\":%llu,\"dropped\":%llu," + "\"data_sectors\":%llu,\"schedule_exact\":%llu," + "\"schedule_early\":%llu,\"schedule_late\":%llu," + "\"late_cycles_avg\":%llu,\"late_cycles_max\":%llu," + "\"late_bins\":[%llu,%llu,%llu,%llu,%llu]," + "\"irq_armed\":%llu,\"arm_delay_avg\":%llu," + "\"arm_delay_max\":%llu,\"intc_exposed\":%llu," + "\"exposure_delay_avg\":%llu,\"exposure_delay_max\":%llu," + "\"exposure_over_1x_sector\":%llu,\"pended\":%llu," + "\"lost\":%llu,\"dma_refills\":%llu," + "\"data_span_cycles\":%llu,\"window_cycles\":%llu," + "\"read_start_count\":%llu,\"read_start_cycles\":%llu," + "\"pause_count\":%llu,\"pause_cycles\":%llu," + "\"seek_count\":%llu,\"seek_cycles\":%llu," + "\"motor_count\":%llu,\"motor_cycles\":%llu," + "\"stop_count\":%llu,\"stop_cycles\":%llu," + "\"latency_upper_cycles\":%llu", + (unsigned long long)s_cd_timing_stream_starts, + (unsigned long long)(s_cd_timing_total - s_cd_timing_reset_seq), + (unsigned long long)dropped, (unsigned long long)data, + (unsigned long long)exact, (unsigned long long)early, + (unsigned long long)late, + (unsigned long long)(late ? late_sum / late : 0), + (unsigned long long)late_max, + (unsigned long long)bins[0], (unsigned long long)bins[1], + (unsigned long long)bins[2], (unsigned long long)bins[3], + (unsigned long long)bins[4], (unsigned long long)armed, + (unsigned long long)(armed ? arm_sum / armed : 0), + (unsigned long long)arm_max, (unsigned long long)exposed, + (unsigned long long)(exposed ? exposure_sum / exposed : 0), + (unsigned long long)exposure_max, + (unsigned long long)over_sector, (unsigned long long)pended, + (unsigned long long)lost, (unsigned long long)dma, + (unsigned long long)(first_due != UINT64_MAX && last_buffer >= first_due + ? last_buffer - first_due : 0), + (unsigned long long)(psx_cycle_count - s_cd_timing_reset_cycle), + (unsigned long long)s_cd_probe_read_start_count, + (unsigned long long)s_cd_probe_read_start_cycles, + (unsigned long long)s_cd_probe_pause_count, + (unsigned long long)s_cd_probe_pause_cycles, + (unsigned long long)s_cd_probe_seek_count, + (unsigned long long)s_cd_probe_seek_cycles, + (unsigned long long)s_cd_probe_motor_count, + (unsigned long long)s_cd_probe_motor_cycles, + (unsigned long long)s_cd_probe_stop_count, + (unsigned long long)s_cd_probe_stop_cycles, + (unsigned long long)(s_cd_probe_read_start_cycles + + s_cd_probe_pause_cycles + + s_cd_probe_seek_cycles + + s_cd_probe_motor_cycles + + s_cd_probe_stop_cycles)); +} + static uint8_t bin_to_bcd(int val) { return (uint8_t)(((val / 10) << 4) | (val % 10)); } @@ -911,6 +1272,7 @@ static int read_sector_at(int min, int sec, int sect) { if (delivery.data_delivered) { memcpy(last_sector_buffer, sector_buffer, (size_t)sector_size); burst_note_sector(); + if (s_warm_route_active) s_warm_route_sectors++; } else { uint32_t copy_size = history_size < SECTOR_BUFFER_SIZE ? (uint32_t)history_size : SECTOR_BUFFER_SIZE; @@ -974,6 +1336,7 @@ static uint64_t s_int1_lost; /* pended INT1s replaced unseen */ static void cdrom_clear_pending_dataready(void) { pending_dataready = 0; pending_dataready_stat = 0; + s_cd_timing_pending_seq = UINT64_MAX; } static void start_read_stream(uint8_t cmd) { @@ -990,6 +1353,10 @@ static void start_read_stream(uint8_t cmd) { read_sect = seek_sect; read_cmd = cmd; read_delay = initial_read_delay_cycles(); + s_cd_probe_read_start_count++; + s_cd_probe_read_start_cycles += (uint64_t)read_delay; + s_cd_timing_next_due = psx_cycle_count + (uint64_t)read_delay; + s_cd_timing_stream_starts++; reading = 1; stat_reg |= CDSTAT_READ; /* ENQUEUE: sector-read stream scheduled (due in read_delay cycles). A @@ -1002,6 +1369,7 @@ static void stop_read_stream(void) { reading = 0; read_cmd = 0; read_delay = 0; + s_cd_timing_next_due = 0; cdrom_clear_pending_dataready(); } @@ -1148,6 +1516,7 @@ static void exec_command(uint8_t cmd) { seek_sect = bcd_to_bin(param_fifo[2]); s_setloc_lba = msf_to_lba(seek_min, seek_sec, seek_sect); setloc_seek_far = (abs(current_lba - s_setloc_lba) > 16) ? 1 : 0; + warm_route_on_setloc(s_setloc_lba); } response_push(stat_reg); set_irq(CDIRQ_ACK); @@ -1186,6 +1555,8 @@ static void exec_command(uint8_t cmd) { pending.cmd = 0x07; pending.pending = 1; pending.delay = apply_speed(30000); /* motor spin-up */ + s_cd_probe_motor_count++; + s_cd_probe_motor_cycles += (uint64_t)pending.delay; pending.phase = 1; break; @@ -1208,6 +1579,8 @@ static void exec_command(uint8_t cmd) { pending.cmd = 0x08; pending.pending = 1; pending.delay = apply_speed(30000); /* motor spin-down */ + s_cd_probe_stop_count++; + s_cd_probe_stop_cycles += (uint64_t)pending.delay; pending.phase = 1; break; @@ -1222,6 +1595,8 @@ static void exec_command(uint8_t cmd) { pending.cmd = 0x09; pending.pending = 1; pending.delay = complete_delay; + s_cd_probe_pause_count++; + s_cd_probe_pause_cycles += (uint64_t)complete_delay; pending.phase = 1; break; } @@ -1408,6 +1783,8 @@ static void exec_command(uint8_t cmd) { pending.cmd = cmd; /* 0x15 or 0x16 — completed in process_pending */ pending.pending = 1; pending.delay = seek_complete_delay_cycles(); + s_cd_probe_seek_count++; + s_cd_probe_seek_cycles += (uint64_t)pending.delay; pending.phase = 1; break; @@ -1590,9 +1967,30 @@ static void process_pending(uint32_t cycles) { static uint64_t s_read_hold_cycles; static uint64_t s_read_hold_events; +/* Route-only HLE producer/consumer handshake. Do not overwrite a cached + * sector or data-ready callback: make the next sector eligible only after the + * guest consumes the FIFO and acknowledges the previous notification. + * Multi-sector DMA may refill while its original data-ready IRQ is active. */ +static int warm_route_consumer_blocked(void) { + if (!s_warm_route_active) return 0; + /* Tomba's raw-sector path intentionally consumes 12 header + 2048 data + * bytes from a 2340-byte FIFO and leaves the final 280 bytes unread. The + * IRQ ack (plus an inactive DMA channel), not sector_available, is the + * authoritative consumer-complete handshake. */ + if (pending_dataready) return 1; + if (irq_flag != 0 && !dma_cdrom_transfer_active()) return 1; + return 0; +} + static void process_read_stream(uint32_t cycles) { if (!reading) return; + if (warm_route_consumer_blocked()) { + s_warm_route_consumer_waits++; + s_warm_route_consumer_wait_cycles += cycles; + return; + } + /* Disc time NEVER pauses while the drive reads (Beetle cdc.cpp * HandleSectorRead: the sector pipeline advances on cycle deadlines * regardless of INT ack state). XA-ADPCM realtime audio flows to the @@ -1603,29 +2001,38 @@ static void process_read_stream(uint32_t cycles) { } if (read_delay <= 0) { + uint64_t timing_seq = cd_timing_begin_sector( + msf_to_lba(read_min, read_sec, read_sect)); if (irq_flag == 0) { if (sector_available) { trace_cdrom('O', 0, (uint32_t)sector_read_pos, 0); } - deliver_read_sector(); + if (deliver_read_sector()) { + cd_timing_flag(timing_seq, CDT_DATA); + cd_timing_arm_irq(timing_seq); + } } else if (dma_cdrom_transfer_active()) { /* Active multi-sector CD DMA with the data-ready INT still * asserted: refill the buffer so the DMA keeps draining, no new * INT (the historical shape; games start the DMA inside the * data-ready callback before acking). */ - deliver_read_sector_without_irq(); + if (deliver_read_sector_without_irq()) + cd_timing_flag(timing_seq, CDT_DATA | CDT_DMA); } else { /* Guest hasn't acked the previous INT yet. Read the sector on * schedule (XA audio + buffer overwrite happen inside), and * pend its data-ready INT1 one deep. */ int delivered = deliver_read_sector_without_irq(); if (delivered) { + cd_timing_flag(timing_seq, CDT_DATA | CDT_PENDED); if (pending_dataready) { s_int1_lost++; trace_cdrom('P', 0, (uint32_t)last_sector_lba, 0); + cd_timing_flag(s_cd_timing_pending_seq, CDT_LOST); } pending_dataready = 1; pending_dataready_stat = stat_reg; + s_cd_timing_pending_seq = timing_seq; s_int1_pended++; } } @@ -1635,6 +2042,7 @@ static void process_read_stream(uint32_t cycles) { if (read_delay <= 0) { read_delay = sector_delay_cycles(); } + s_cd_timing_next_due = psx_cycle_count + (uint64_t)read_delay; } } @@ -1643,11 +2051,14 @@ static void process_read_stream(uint32_t cycles) { * register clears). Called from the irq_flag ack write. */ static void present_pending_dataready(void) { if (!pending_dataready || irq_flag != 0) return; + uint64_t timing_seq = s_cd_timing_pending_seq; pending_dataready = 0; + s_cd_timing_pending_seq = UINT64_MAX; response_clear(); response_push(pending_dataready_stat); set_irq(CDIRQ_DATA_READY); fire_cdrom_irq(); + cd_timing_arm_irq(timing_seq); s_dataready_fires++; } @@ -1692,6 +2103,20 @@ void cdrom_init(const char* cue_path) { memset(&queued_cmd, 0, sizeof(queued_cmd)); seek_min = seek_sec = seek_sect = 0; setloc_seek_far = 0; + s_warm_routes_count = 0; + s_warm_route_configured = 0; + s_warm_route_enabled = 0; + s_warm_route_armed = 0; + s_warm_route_armed_lba = -1; + s_warm_route_active = 0; + s_warm_route_active_index = -1; + s_warm_route_next = 0; + s_warm_route_last_lba = -1; + s_warm_route_matches = 0; + s_warm_route_mismatches = 0; + s_warm_route_sectors = 0; + s_warm_route_consumer_waits = 0; + s_warm_route_consumer_wait_cycles = 0; cdrom_debug_clear_sector_history(); if (cue_path) { @@ -1849,7 +2274,8 @@ uint32_t cdrom_cycles_to_irq(uint32_t i_mask) { if (pending.pending && pending.delay > 0 && (uint32_t)pending.delay < best) best = (uint32_t)pending.delay; /* Active sector read: next data-ready in read_delay cycles. */ - if (reading && read_delay > 0 && (uint32_t)read_delay < best) + if (reading && !warm_route_consumer_blocked() && + read_delay > 0 && (uint32_t)read_delay < best) best = (uint32_t)read_delay; return best; } @@ -2039,6 +2465,10 @@ int cdrom_load_in_progress(void) { return 0; } +int cdrom_data_read_active(void) { + return reading && !xa_stream_active; +} + /* ---- boot snapshot: complete CD-ROM controller FSM (see boot_state.h) ---- */ /* Every functional controller/drive/FIFO/IRQ-latch/XA-decode/timing global is * listed here exactly once. The X-macro guarantees bytes()/write()/read() can diff --git a/runtime/src/data_shards.c b/runtime/src/data_shards.c index 853fd7b9..6c3be641 100644 --- a/runtime/src/data_shards.c +++ b/runtime/src/data_shards.c @@ -82,7 +82,11 @@ static DsShard* s_buckets[DS_KEY_BUCKETS]; static int s_loaded_keys[256]; static int s_loaded_n = 0; /* keys with disk load done */ volatile int g_ds_recording = 0; -static int s_enabled = 1; +/* Fail closed. A configured hook is only a candidate boundary; replay must be + * enabled explicitly after capture/verification for the current experiment. + * This prevents an unsafe persisted candidate from mutating boot assets before + * the debug/control plane is available to disable it. */ +static int s_enabled = 0; /* ---- recorder state ---- */ static CPUState* s_arm_cpu = 0; diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index b5d8df67..49e4005e 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -27,6 +27,7 @@ #include "dma.h" #include "gpu.h" #include "present_ring.h" +#include "load_transition_ring.h" #include "cdrom.h" #include "sio.h" #include "memcard.h" @@ -5969,7 +5970,7 @@ static void handle_audio_events(int id, const char *json) uint64_t total = audio_trace_events_total(); static const char *kind_names[] = { "?", "REG", "RENDER", "SKIP", "UNDERRUN", - "MUTE", "UNMUTE", "CD_PUSH", "DMA", "XA_ZERO" + "MUTE", "UNMUTE", "CD_PUSH", "DMA", "XA_ZERO", "SINK_DROP" }; size_t cap = 256u + (size_t)got * 192u; @@ -10516,6 +10517,46 @@ static void handle_turbo_loads(int id, const char *json) (unsigned long long)g_turbo_loads_frames); } +/* turbo_audio_sink: opt-in host-output discard while turbo loads run. Guest + * SPU state continues advancing; only accelerated playback samples are sunk. */ +static void handle_turbo_audio_sink(int id, const char *json) +{ + extern int g_turbo_audio_sink_enabled; + extern int g_turbo_audio_sink_active; + extern uint64_t g_turbo_audio_sink_frames; + int n = json_get_int(json, "n", -1); + if (n == 0 || n == 1) g_turbo_audio_sink_enabled = n; + send_fmt("{\"id\":%d,\"ok\":true,\"enabled\":%d,\"active\":%d," + "\"discarded_spu_frames\":%llu}\n", id, + g_turbo_audio_sink_enabled, g_turbo_audio_sink_active, + (unsigned long long)g_turbo_audio_sink_frames); +} + +/* load_transitions: state-edge log for physical reads, the bridged logical + * load predicate, and host-side turbo pacing. Newest entries are returned + * last so adjacent edges can be read as a timeline. */ +static void handle_load_transitions(int id, const char *json) +{ + int count = json_get_int(json, "count", 128); + if (count < 1) count = 1; + if (count > 512) count = 512; + uint64_t total = load_transition_total(); + uint64_t start = total > (uint64_t)count ? total - (uint64_t)count : 0; + send_fmt("{\"id\":%d,\"ok\":true,\"total\":%llu,\"edges\":[", + id, (unsigned long long)total); + int emitted = 0; + for (uint64_t seq = start; seq < total; seq++) { + LoadTransitionEntry e; + if (!load_transition_get(seq, &e)) continue; + send_fmt("%s{\"seq\":%llu,\"frame\":%u,\"host_ms\":%u," + "\"read\":%u,\"load\":%u,\"turbo\":%u,\"load_run\":%u}", + emitted++ ? "," : "", (unsigned long long)seq, e.frame, + e.host_ms, e.read_active, e.load_active, e.turbo_active, + e.load_run); + } + send_fmt("]}\n"); +} + /* cdrom_bursts: dump the always-on CD load-burst ring, newest first. Each * record is one gap-separated run of delivered data sectors — i.e. one load. * Param "count" (optional, default 32, max 128). */ @@ -10542,6 +10583,18 @@ static void handle_cdrom_bursts(int id, const char *json) send_fmt("]}\n"); } +/* cdrom_timing: passive L1.5 physical deadline -> buffer -> INT1 exposure + * summary. `reset:1` starts a fresh measurement window without touching the + * controller or its schedules. late_bins are exact, 1..64, 65..1024, + * 1025..5000, and >5000 guest cycles. */ +static void handle_cdrom_timing(int id, const char *json) +{ + if (json_get_int(json, "reset", 0) == 1) cdrom_timing_reset(); + char stats[2048]; + cdrom_timing_stats_json(stats, (int)sizeof(stats)); + send_fmt("{\"id\":%d,\"ok\":true,%s}\n", id, stats); +} + /* autocompile_status: variant-capture automation state — autocapture * enable/trigger counters + the background compile's state and output tail * (in-memory ring; no log files). */ @@ -12028,10 +12081,42 @@ static void handle_data_shards(int id, const char *json) send_fmt("{\"id\":%d,\"ok\":true,%s}", id, body); } +/* vsync_query_hle: cycle-faithful VSync(-1) query acceleration counters. */ +static void handle_vsync_query_hle(int id, const char *json) +{ + extern void psx_vsync_query_hle_stats_json(char* buf, int cap); + extern void psx_vsync_query_hle_set_enabled(int on); + extern void psx_vsync_query_hle_set_horizon_enabled(int on); + extern void psx_vsync_query_hle_set_extra_horizon_enabled(int on); + int en = json_get_int(json, "enable", -1); + int horizon = json_get_int(json, "horizon", -1); + int extra = json_get_int(json, "extra", -1); + if (en == 0 || en == 1) psx_vsync_query_hle_set_enabled(en); + if (horizon == 0 || horizon == 1) + psx_vsync_query_hle_set_horizon_enabled(horizon); + if (extra == 0 || extra == 1) + psx_vsync_query_hle_set_extra_horizon_enabled(extra); + char body[512]; + psx_vsync_query_hle_stats_json(body, sizeof(body)); + send_fmt("{\"id\":%d,\"ok\":true,%s}", id, body); +} + +/* warm_cd_route: live A/B toggle and fail-closed route counters. */ +static void handle_warm_cd_route(int id, const char *json) +{ + int en = json_get_int(json, "enable", -1); + if (en == 0 || en == 1) cdrom_warm_route_set_enabled(en); + char body[512]; + cdrom_warm_route_stats_json(body, sizeof(body)); + send_fmt("{\"id\":%d,\"ok\":true,%s}\n", id, body); +} + static const CmdEntry s_commands[] = { { "phase_profile", handle_phase_profile }, { "starv_ring", handle_starv_ring }, { "data_shards", handle_data_shards }, + { "vsync_query_hle", handle_vsync_query_hle }, + { "warm_cd_route", handle_warm_cd_route }, { "phase_hot", handle_phase_hot }, { "idle_skip", handle_idle_skip }, { "lockstep", handle_lockstep }, @@ -12302,7 +12387,10 @@ static const CmdEntry s_commands[] = { { "cdrom_instant_rate", handle_cdrom_instant_rate }, { "cd_overwrite", handle_cd_overwrite }, { "cdrom_bursts", handle_cdrom_bursts }, + { "cdrom_timing", handle_cdrom_timing }, { "turbo_loads", handle_turbo_loads }, + { "turbo_audio_sink", handle_turbo_audio_sink }, + { "load_transitions", handle_load_transitions }, { "autocompile_status", handle_autocompile_status }, { "sljit_status", handle_sljit_status }, { "sljit_async", handle_sljit_async }, diff --git a/runtime/src/load_accel.c b/runtime/src/load_accel.c new file mode 100644 index 00000000..d280f62e --- /dev/null +++ b/runtime/src/load_accel.c @@ -0,0 +1,228 @@ +/* load_accel.c -- verified enhancement-phase load-time acceleration. + * + * PsyQ's VSync(-1) is used as a timeout clock by CD_sync/CD_ready loops. The + * library routine returns its RAM VBlank counter, but its common prologue first + * reads GPUSTAT and Timer1 even though neither value reaches the mode=-1 result. + * Those reads are architecturally side-effect-free yet expensive in the host + * runtime because every MMIO access catches all devices up to the current cycle. + * + * This helper reproduces the verified Tomba/PsyQ query path instruction by + * instruction: load-delay timing, stack traffic, I-cache fetches, and all three + * interrupt checkpoints. Only the two unused MMIO handlers are suppressed. + * Non-query calls, precise diagnostics, and cosim always use the original LLE. + * Config-verified CD wait-loop call sites may additionally advance guest time + * to the next deliverable device event. That elides empty host-side polling + * while preserving the device clock, event order, and subsequent IRQ delivery. + */ +#include "load_accel.h" + +#include "cdrom.h" +#include "cpu_state.h" +#include "interrupts.h" +#include "psx_cyc.h" + +#include + +extern uint32_t g_debug_last_store_pc; +extern int g_precise_mode; + +static int s_enabled = 1; +static int s_horizon_enabled = 1; +static int s_extra_horizon_enabled = 1; +static uint64_t s_calls; +static uint64_t s_handled; +static uint64_t s_fallback_mode; +static uint64_t s_fallback_diagnostic; +static uint64_t s_fallback_layout; +static uint64_t s_horizon_hits; +static uint64_t s_horizon_cycles; +static uint32_t s_horizon_sites[16]; +static uint32_t s_horizon_site_count; +static uint64_t s_extra_horizon_hits; +static uint64_t s_extra_horizon_cycles; +static uint32_t s_extra_horizon_sites[16]; +static uint32_t s_extra_horizon_site_count; + +#ifdef PSX_ENABLE_BLOCK_CYCLES +#define VQ_STEP(cpu_, mask_) psx_cyc_step((cpu_), (mask_)) +#else +#define VQ_STEP(cpu_, mask_) do { (void)(cpu_); (void)(mask_); } while (0) +#endif + +void psx_vsync_query_hle_set_enabled(int on) { s_enabled = on ? 1 : 0; } +void psx_vsync_query_hle_set_horizon_enabled(int on) { + s_horizon_enabled = on ? 1 : 0; +} +void psx_vsync_query_hle_set_extra_horizon_enabled(int on) { + s_extra_horizon_enabled = on ? 1 : 0; +} + +void psx_vsync_query_hle_add_event_horizon_site(uint32_t return_pc) { + if (return_pc == 0u) return; + for (uint32_t i = 0; i < s_horizon_site_count; i++) + if (s_horizon_sites[i] == return_pc) return; + if (s_horizon_site_count < + (uint32_t)(sizeof(s_horizon_sites) / sizeof(s_horizon_sites[0]))) + s_horizon_sites[s_horizon_site_count++] = return_pc; +} + +void psx_vsync_query_hle_add_extra_event_horizon_site(uint32_t return_pc) { + if (return_pc == 0u) return; + for (uint32_t i = 0; i < s_extra_horizon_site_count; i++) + if (s_extra_horizon_sites[i] == return_pc) return; + if (s_extra_horizon_site_count < + (uint32_t)(sizeof(s_extra_horizon_sites) / + sizeof(s_extra_horizon_sites[0]))) + s_extra_horizon_sites[s_extra_horizon_site_count++] = return_pc; +} + +static int is_event_horizon_site(uint32_t return_pc) { + for (uint32_t i = 0; i < s_horizon_site_count; i++) + if (s_horizon_sites[i] == return_pc) return 1; + for (uint32_t i = 0; i < s_extra_horizon_site_count; i++) + if (s_extra_horizon_sites[i] == return_pc) return 2; + return 0; +} + +int psx_vsync_query_hle_enter(CPUState* cpu, uint32_t func, + uint32_t counter_addr, uint32_t gpustat_ptr_addr, + uint32_t timer1_ptr_addr, + uint32_t timer1_cache_addr) { + s_calls++; + if (!s_enabled || cpu->gpr[4] != 0xFFFFFFFFu) { + s_fallback_mode++; + return 0; + } +#ifdef PSX_COSIM + s_fallback_diagnostic++; + return 0; +#else + if (g_precise_mode) { + s_fallback_diagnostic++; + return 0; + } +#endif + + /* Fail closed if the verified PsyQ globals were repointed at runtime. */ + if (cpu->read_word(gpustat_ptr_addr) != 0x1F801814u || + cpu->read_word(timer1_ptr_addr) != 0x1F801110u) { + s_fallback_layout++; + return 0; + } + + s_handled++; + + /* These sites were statically verified as the clock sample inside a CD + * wait loop. While a sustained read is active, nothing useful happens + * between samples until a deliverable device event. Advance the same + * guest clock/device model to that boundary in one host operation; the + * original VSync path below then reaches its normal interrupt checkpoint + * and returns the post-event VBlank counter. A defensive one-VBlank-ish + * cap prevents a malformed per-game site list from making a large jump. */ + int horizon_kind = is_event_horizon_site(cpu->gpr[31]); + if (s_horizon_enabled && + (horizon_kind == 1 || + (horizon_kind == 2 && s_extra_horizon_enabled)) && + cdrom_load_in_progress()) { + uint32_t dist = cycles_to_next_event(); + if (dist > 64u && dist != 0xFFFFFFFFu && dist <= 1200000u) { + psx_advance_cycles(dist); + if (horizon_kind == 2) { + s_extra_horizon_hits++; + s_extra_horizon_cycles += dist; + } else { + s_horizon_hits++; + s_horizon_cycles += dist; + } + } + } + +#ifdef PSX_ENABLE_BLOCK_CYCLES + psx_icache_fetch(cpu, func + 0x00u); +#endif + VQ_STEP(cpu, 0x4u); cpu->gpr[2] = 0x80090000u; /* +00 lui v0 */ + cpu->gpr[2] = psx_cyc_load_word(cpu, gpustat_ptr_addr, 2u, 0x4u); /* +04 lw */ + VQ_STEP(cpu, 0x8u); cpu->gpr[3] = 0x80090000u; /* +08 lui v1 */ + cpu->gpr[3] = psx_cyc_load_word(cpu, timer1_ptr_addr, 3u, 0x8u); /* +0C lw */ + +#ifdef PSX_ENABLE_BLOCK_CYCLES + psx_icache_fetch(cpu, func + 0x10u); +#endif + VQ_STEP(cpu, 0x20000000u); cpu->gpr[29] -= 32u; /* +10 addiu sp */ + VQ_STEP(cpu, 0xA0000000u); g_debug_last_store_pc = func + 0x14u; + cpu->write_word(cpu->gpr[29] + 24u, cpu->gpr[31]); /* +14 sw ra */ + VQ_STEP(cpu, 0x20020000u); g_debug_last_store_pc = func + 0x18u; + cpu->write_word(cpu->gpr[29] + 20u, cpu->gpr[17]); /* +18 sw s1 */ + VQ_STEP(cpu, 0x20010000u); g_debug_last_store_pc = func + 0x1Cu; + cpu->write_word(cpu->gpr[29] + 16u, cpu->gpr[16]); /* +1C sw s0 */ + +#ifdef PSX_ENABLE_BLOCK_CYCLES + psx_icache_fetch(cpu, func + 0x20u); +#endif + psx_cyc_load_word_timing_only(cpu, 0x1F801814u, 16u, 0x4u); /* +20 GPUSTAT */ + cpu->gpr[16] = 0u; /* value unused */ + psx_cyc_load_word_timing_only(cpu, 0x1F801110u, 2u, 0x8u); /* +24 Timer1 */ + cpu->gpr[2] = 0u; /* value unused */ + VQ_STEP(cpu, 0x8u); cpu->gpr[3] = 0x80090000u; /* +28 lui v1 */ + cpu->gpr[3] = psx_cyc_load_word(cpu, timer1_cache_addr, 3u, 0x8u); /* +2C */ + +#ifdef PSX_ENABLE_BLOCK_CYCLES + psx_icache_fetch(cpu, func + 0x30u); +#endif + VQ_STEP(cpu, 0x1u); /* +30 nop */ + VQ_STEP(cpu, 0xCu); cpu->gpr[2] -= cpu->gpr[3]; /* +34 subu */ + VQ_STEP(cpu, 0x10u); /* +38 bgez a0 */ + VQ_STEP(cpu, 0x20004u); cpu->gpr[17] = cpu->gpr[2] & 0xFFFFu; /* +3C */ + psx_check_interrupts_at(cpu, func + 0x40u); + +#ifdef PSX_ENABLE_BLOCK_CYCLES + psx_icache_fetch(cpu, func + 0x40u); +#endif + VQ_STEP(cpu, 0x4u); cpu->gpr[2] = 0x80090000u; /* +40 lui v0 */ + cpu->gpr[2] = psx_cyc_load_word(cpu, counter_addr, 2u, 0x4u); /* +44 counter */ + VQ_STEP(cpu, 0x0u); /* +48 j epilogue */ + VQ_STEP(cpu, 0x1u); /* +4C nop */ + psx_check_interrupts_at(cpu, func + 0x130u); + +#ifdef PSX_ENABLE_BLOCK_CYCLES + psx_icache_fetch(cpu, func + 0x130u); +#endif + cpu->gpr[31] = psx_cyc_load_word(cpu, cpu->gpr[29] + 24u, 31u, 0x20000000u); + cpu->gpr[17] = psx_cyc_load_word(cpu, cpu->gpr[29] + 20u, 17u, 0x20000000u); + cpu->gpr[16] = psx_cyc_load_word(cpu, cpu->gpr[29] + 16u, 16u, 0x20000000u); + VQ_STEP(cpu, 0x20000000u); cpu->gpr[29] += 32u; +#ifdef PSX_ENABLE_BLOCK_CYCLES + psx_icache_fetch(cpu, func + 0x140u); +#endif + { + uint32_t ra = cpu->gpr[31]; + VQ_STEP(cpu, 0x80000001u); /* jr ra */ + VQ_STEP(cpu, 0x1u); /* delay nop */ + psx_check_interrupts_at(cpu, ra); + cpu->pc = ra; + } + return 1; +} + +void psx_vsync_query_hle_stats_json(char* buf, int cap) { + snprintf(buf, (size_t)cap, + "\"enabled\":%d,\"horizon_enabled\":%d," + "\"extra_horizon_enabled\":%d," + "\"calls\":%llu,\"handled\":%llu," + "\"fallback_mode\":%llu,\"fallback_diagnostic\":%llu," + "\"fallback_layout\":%llu,\"horizon_sites\":%u," + "\"horizon_hits\":%llu,\"horizon_cycles\":%llu," + "\"extra_horizon_sites\":%u,\"extra_horizon_hits\":%llu," + "\"extra_horizon_cycles\":%llu", + s_enabled, s_horizon_enabled, s_extra_horizon_enabled, + (unsigned long long)s_calls, + (unsigned long long)s_handled, + (unsigned long long)s_fallback_mode, + (unsigned long long)s_fallback_diagnostic, + (unsigned long long)s_fallback_layout, s_horizon_site_count, + (unsigned long long)s_horizon_hits, + (unsigned long long)s_horizon_cycles, + s_extra_horizon_site_count, + (unsigned long long)s_extra_horizon_hits, + (unsigned long long)s_extra_horizon_cycles); +} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 9668c6ec..37e23b22 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -15,6 +15,7 @@ #include "text_xlate.h" #include "boot_state.h" #include "bios_hle.h" +#include "load_accel.h" #include "savestate.h" #include "overlay_capture.h" #include "overlay_loader.h" @@ -24,6 +25,7 @@ #include "overlay_backend.h" #include "gpu.h" #include "present_ring.h" +#include "load_transition_ring.h" #include "gpu_sw_renderer.h" #include "gpu_render.h" #include "gpu_gl_renderer.h" @@ -395,14 +397,18 @@ int g_present_vsync_disabled = 0; /* 1 once self-heal tripped */ extern "C" { int g_turbo_loads_enabled = 0; uint64_t g_turbo_loads_frames = 0; /* vblanks run unpaced (observability) */ +int g_turbo_audio_sink_enabled = 0; +int g_turbo_audio_sink_active = 0; +uint64_t g_turbo_audio_sink_frames = 0; /* guest SPU frames advanced, not queued */ } -/* Engage turbo only after a load has been continuously in progress for this many - * frames. Filters brief incidental reads (e.g. a boss/stage-select screen that - * streams XA music and reads a few preview sectors as you hover): each 1-2 frame - * blip would otherwise flip turbo on, muting the music for the audio hangover and - * stuttering the frame rate. Real loads hold for hundreds of frames, so they - * lose only this brief authentic-paced prefix. */ -#define TURBO_LOADS_ENGAGE_FRAMES 20 +/* The CD predicate already excludes XA and holds across ordinary inter-file + * gaps. A short engage debounce rejects a one-frame controller blip without + * leaving a visible authentic-paced prefix on every real load. Once engaged, + * a symmetric release debounce rides through tiny late-sector/IRQ gaps. This + * changes host presentation/pacing only; guest cycles and input sampling keep + * advancing normally. */ +#define TURBO_LOADS_ENGAGE_FRAMES 4 +#define TURBO_LOADS_RELEASE_FRAMES 6 static SDL_AudioDeviceID sdl_audio_device; static int16_t sdl_audio_buf[2048 * 2]; @@ -837,14 +843,17 @@ static void sdl_audio_gain_ramp(int16_t* buf, int frames, float g0, float g1) { static const int sdl_audio_fade_samples = 44100 * 40 / 1000; /* 40 ms */ static int sdl_audio_fadein_left = 0; -static void sdl_audio_pump(void) { +static void sdl_audio_pump(bool discard_output = false) { if (!sdl_audio_device) return; const uint32_t bytes_per_frame = sizeof(int16_t) * 2u; const bool legacy = audio_legacy_mode(); static int had_audio = 0; uint32_t queued = 0; /* RENDER event b: bytes (legacy) / fill ms (bridge) */ - if (legacy) { + if (discard_output) { + /* Host-only sink: skip all queue/bridge interaction, but continue to + * the guest-cycle sample budget and spu_render below. */ + } else if (legacy) { /* Historical push model + baseline measurement: a drained (==0) queue * means the device was silence-filling since the last pump = a gap. * Only meaningful after the first audio has been queued. */ @@ -910,6 +919,11 @@ static void sdl_audio_pump(void) { audio_trace_event(AUDIO_EV_RENDER, (uint32_t)frames, queued); spu_render(sdl_audio_buf, frames); + if (discard_output) { + g_turbo_audio_sink_frames += (uint64_t)frames; + audio_trace_event(AUDIO_EV_SINK_DROP, (uint32_t)frames, 0); + return; + } if (sdl_audio_fadein_left > 0) { const float g0 = 1.0f - (float)sdl_audio_fadein_left / (float)sdl_audio_fade_samples; @@ -1049,7 +1063,7 @@ static void runtime_perf_diag_tick() { for (int i = 0; i < 6; i++) last_up[i] = up[i]; } -static void sdl_audio_update(int turbo_active) { +static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) { if (!sdl_audio_device) return; { /* Tag audio events with the vblank frame counter. */ extern uint64_t s_frame_count; @@ -1058,8 +1072,9 @@ static void sdl_audio_update(int turbo_active) { const int HANGOVER_FRAMES = 8; /* ~133 ms at 60 fps */ static int muted = 0; static int hangover = 0; + static int sink_was_active = 0; - if (turbo_active) { + if (hard_mute_active) { if (!muted) { int tail = sdl_audio_fade_samples; const int buf_cap = (int)(sizeof(sdl_audio_buf) / (2 * sizeof(int16_t))); @@ -1094,7 +1109,24 @@ static void sdl_audio_update(int turbo_active) { extern int g_audio_unmute_resync; g_audio_unmute_resync = 1; } - sdl_audio_pump(); + if (turbo_sink_active) { + if (!sink_was_active) { + sink_was_active = 1; + audio_trace_event(AUDIO_EV_MUTE, 0, 2); /* b=2: discard-only sink */ + } + g_turbo_audio_sink_active = 1; + sdl_audio_pump(true); + return; + } + g_turbo_audio_sink_active = 0; + if (sink_was_active) { + sink_was_active = 0; + sdl_audio_fadein_left = sdl_audio_fade_samples; + audio_trace_event(AUDIO_EV_UNMUTE, + (uint32_t)sdl_audio_fadein_left, 2); + g_audio_unmute_resync = 1; + } + sdl_audio_pump(false); } /* PS1 digital pad button bits (active-low: 0=pressed, 1=released). @@ -2009,6 +2041,44 @@ static PresRingEntry* present_ring_commit(uint8_t path, uint16_t disp_w, return e; } +/* ---- Load-transition ring --------------------------------------------- + * State edges only, rather than one record per frame. This makes the exact + * CD-read -> bridged-load -> turbo -> paced transitions cheap and queryable + * without changing any runtime behavior. */ +#define LOAD_TRANSITION_CAP 512u +static LoadTransitionEntry s_load_transitions[LOAD_TRANSITION_CAP]; +static uint64_t s_load_transition_total = 0; + +extern "C" uint64_t load_transition_total(void) { + return s_load_transition_total; +} +extern "C" int load_transition_get(uint64_t seq, LoadTransitionEntry *out) { + if (seq >= s_load_transition_total) return 0; + if (s_load_transition_total - seq > LOAD_TRANSITION_CAP) return 0; + *out = s_load_transitions[seq & (LOAD_TRANSITION_CAP - 1u)]; + return 1; +} + +static void load_transition_note(int read_active, int load_active, + int turbo_active, int load_run) { + static int prev_read = -1, prev_load = -1, prev_turbo = -1; + if (read_active == prev_read && load_active == prev_load && + turbo_active == prev_turbo) return; + extern uint64_t s_frame_count; + LoadTransitionEntry *e = + &s_load_transitions[s_load_transition_total & (LOAD_TRANSITION_CAP - 1u)]; + s_load_transition_total++; + e->frame = (uint32_t)s_frame_count; + e->host_ms = (uint32_t)SDL_GetTicks(); + e->load_run = (uint16_t)(load_run > 0xFFFF ? 0xFFFF : load_run); + e->read_active = (uint8_t)(read_active != 0); + e->load_active = (uint8_t)(load_active != 0); + e->turbo_active = (uint8_t)(turbo_active != 0); + prev_read = read_active; + prev_load = load_active; + prev_turbo = turbo_active; +} + /* Called from gpu_vblank_tick() at each simulated vblank. */ static void sdl_vblank_present(void) { #ifndef PSX_NO_DEBUG_TOOLS @@ -2097,26 +2167,33 @@ static void sdl_vblank_present(void) { * sampled into SIO. Always-on; queried via the debug server "latency". */ latency_ring_frame_begin(); - /* Turbo-active test, shared by the audio gate here and the pacing/ - * present gate below. sdl_audio_update owns the mute + fade-in/out + - * debounce across turbo transitions (see its comment). */ + /* Turbo-active test shared by the pacing/present gate below. */ int turbo_loads_active = 0; + extern int fntrace_is_game_started(void); + int logical_load_active = fntrace_is_game_started() && cdrom_load_in_progress(); + int load_run_value = 0; + static int load_run = 0; + static int release_run = 0; if (g_turbo_loads_enabled) { - extern int fntrace_is_game_started(void); - /* Hysteresis: count consecutive frames the load has held, and only - * engage turbo once it is SUSTAINED (TURBO_LOADS_ENGAGE_FRAMES). This - * stops brief incidental reads on music screens (boss/stage select) from - * flickering turbo on and chopping the audio. cdrom_load_in_progress() - * already bridges short intra-load gaps (CD_BURST_GAP_FRAMES), so a real - * load's counter does not reset mid-load. */ - static int load_run = 0; - if (fntrace_is_game_started() && cdrom_load_in_progress()) { + /* Require a short sustained predicate on entry, then retain turbo over + * a short false gap after it has engaged. */ + if (logical_load_active) { if (load_run < (1 << 20)) load_run++; + if (load_run >= TURBO_LOADS_ENGAGE_FRAMES || release_run > 0) { + turbo_loads_active = 1; + release_run = TURBO_LOADS_RELEASE_FRAMES; + } } else { load_run = 0; + if (release_run > 0) { + turbo_loads_active = 1; + release_run--; + } } - if (load_run >= TURBO_LOADS_ENGAGE_FRAMES) - turbo_loads_active = 1; + load_run_value = load_run; + } else { + load_run = 0; + release_run = 0; } /* HLE boot-skip window: from reset until the game entry PC first * dispatches, run unpaced so the (shell-skipped) BIOS kernel init + @@ -2125,6 +2202,9 @@ static void sdl_vblank_present(void) { if (psx_bios_hle_boot_turbo_active()) turbo_loads_active = 1; + load_transition_note(cdrom_data_read_active(), logical_load_active, + turbo_loads_active, load_run_value); + /* FMV auto-skip ([video] auto_skip_fmv). A streaming FMV is XA audio + MDEC * video together. Detect "MDEC produced a frame since the last vblank AND XA * is streaming"; a short hold rides out brief inter-frame gaps. The present/ @@ -2170,13 +2250,12 @@ static void sdl_vblank_present(void) { } #ifndef PSX_SDL_NO_AUDIO - /* Turbo-loads no longer mutes audio. Loads at 1x+turbo are sub-second, and - * the mute/fade/hangover model produced more audible disruption (cuts and - * "restarts") than just letting the stream play through; the audio queue is - * capped (sdl_audio_pump max_queue_bytes) so a brief turbo burst can't build - * unbounded latency. FMV-skip still mutes: it fast-forwards an entire movie, - * where rendering the time-compressed audio would be pure noise. */ - sdl_audio_update(fmv_skip_active); + /* Optional turbo host sink advances the canonical SPU on the exact guest + * sample budget but drops accelerated output before SDL. This is distinct + * from the old mute/freeze model: voice and CD state never pause. FMV skip + * retains the hard mute because it deliberately fast-forwards a movie. */ + sdl_audio_update(fmv_skip_active, + turbo_loads_active && g_turbo_audio_sink_enabled); #endif if (g_headless) return; @@ -2688,6 +2767,7 @@ int main(int argc, char** argv) { uint32_t game_disc_crc = 0; std::string disc_speed; /* "1x" | "2x" | "4x" | "instant" */ int instant_rate = 0; /* 0 = cdrom.c built-in default */ + std::vector warm_cd_routes; uint32_t game_entry_pc = 0; bool fast_boot = false; /* DEPRECATED alias: HLE boot-skip only */ bool bios_hle = false; /* HLE kernel-service tier (bios_hle.c) */ @@ -2719,10 +2799,30 @@ int main(int argc, char** argv) { if (gc.runtime.has_disc_speed) disc_speed = gc.runtime.disc_speed; if (gc.runtime.has_instant_max_per_frame) instant_rate = gc.runtime.instant_max_per_frame; + warm_cd_routes = gc.runtime.warm_cd_routes; if (gc.runtime.turbo_loads) { g_turbo_loads_enabled = 1; std::fprintf(stdout, "psxrecomp: turbo_loads enabled (opt-in)\n"); } + if (gc.runtime.turbo_audio_sink) { + g_turbo_audio_sink_enabled = 1; + std::fprintf(stdout, + "psxrecomp: turbo_audio_sink enabled (opt-in)\n"); + } + { + extern int g_idle_skip_enabled; + const char *idle_env = std::getenv("PSX_IDLE_SKIP"); + g_idle_skip_enabled = idle_env + ? (idle_env[0] == '1' ? 1 : 0) + : (gc.runtime.idle_skip ? 1 : 0); + std::fprintf(stdout, "psxrecomp: idle_skip %s%s\n", + g_idle_skip_enabled ? "enabled" : "disabled", + idle_env ? " (environment override)" : ""); + } + for (uint32_t site : gc.vsync_event_horizon_sites) + psx_vsync_query_hle_add_event_horizon_site(site); + for (uint32_t site : gc.vsync_event_horizon_extra_sites) + psx_vsync_query_hle_add_extra_event_horizon_site(site); g_video_scale = gc.runtime.video_supersampling; g_video_aa = gc.runtime.video_antialiasing; g_video_texfilter = gc.runtime.video_texture_filter; @@ -3386,6 +3486,16 @@ int main(int argc, char** argv) { std::fprintf(stdout, "psxrecomp: SPU float-shadow enabled (verified-enhancement)\n"); spu_init(); cdrom_init(disc_path_str.empty() ? NULL : disc_path_str.c_str()); + for (const auto& route : warm_cd_routes) { + cdrom_register_warm_route(route.arm_lba, route.lbas.data(), + (int)route.lbas.size(), + route.instant_max_per_frame); + std::fprintf(stdout, + "psxrecomp: warm CD route armed at LBA %d (%zu entries, " + "%d sectors/frame)\n", + route.arm_lba, route.lbas.size(), + route.instant_max_per_frame); + } if (!disc_path_str.empty()) { /* GetID must report the inserted disc's license region (the BIOS CD * driver revalidates it mid-game). Derive it from the disc's boot diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 28e046a3..20042fab 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1678,6 +1678,10 @@ uint32_t psx_cyc_load_word(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t r psx_cyc_load_timing(cpu, addr, 4u, rt, reg_mask); return psx_read_word(addr); } +void psx_cyc_load_word_timing_only(CPUState* cpu, uint32_t addr, + uint32_t rt, uint32_t reg_mask) { + psx_cyc_load_timing(cpu, addr, 4u, rt, reg_mask); +} uint16_t psx_cyc_load_half(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) { psx_cyc_load_timing(cpu, addr, 2u, rt, reg_mask); return psx_read_half(addr); diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 1d92a7e5..e3fe7607 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -259,7 +259,7 @@ uint64_t psx_get_cycle_count(void) { * lockstep record/replay, and PSX_COSIM builds entirely (the cosim oracle * compares per-instruction streams; elided iterations would false-diverge). * - * Off switch: PSX_IDLE_SKIP=0 (env) or {"cmd":"idle_skip","enable":0} (TCP). + * Opt in: game config, PSX_IDLE_SKIP=1 (env), or the `idle_skip` TCP command. * The always-on counters below are the observability surface. */ enum { @@ -298,17 +298,11 @@ static void idle_snapshot_regs(const CPUState *cpu) { static int idle_skip_on(void) { if (g_idle_skip_enabled < 0) { - /* DISABLED BY DEFAULT (2026-07-06). The skip fast-forwards guest time - * to the next INTERNAL device event, but an SIO/memory-card transfer - * in flight is not currently modeled as a bounding deadline, so the - * skip jumps past the write-completion the card driver polls for at - * boot — Tomba2's "checking memory card" livelocks (frame rate - * collapses to a few fps; the game never advances). Opt in with - * PSX_IDLE_SKIP=1. Re-enable by default only once the skip is bounded - * by the SIO transfer/IRQ deadline (devices_cycles_to_next_internal_ - * event must include the in-flight card-byte completion). */ + /* No game config reached this process (for example a BIOS-only + * runtime). Keep the enhancement inert unless the environment opts in; + * normal game launches set g_idle_skip_enabled from RuntimeConfig. */ const char *e = getenv("PSX_IDLE_SKIP"); - g_idle_skip_enabled = (e && e[0] == '1'); + g_idle_skip_enabled = e ? (e[0] == '1') : 0; } return g_idle_skip_enabled; } diff --git a/tools/load_probe.py b/tools/load_probe.py index 13be9b40..dea4d809 100644 --- a/tools/load_probe.py +++ b/tools/load_probe.py @@ -75,15 +75,23 @@ def delta_top(before, after, k=10): return items[:k] -def snap(c): - fz = c.cmd("freeze_check", window=1) - return { - "t": time.monotonic(), - "wall": time.time(), - "cycles": int(fz["psx_cycle_count"]), - "frame": int(fz["frame_count"]), - "dirty_insns": int(fz["dirty_ram_insns"]), - } +def snap(c, retries=3): + # freeze_check occasionally returns a short/err response; retry rather + # than kill a long monitoring run over one bad sample. + for attempt in range(retries): + try: + fz = c.cmd("freeze_check", window=1) + return { + "t": time.monotonic(), + "wall": time.time(), + "cycles": int(fz["psx_cycle_count"]), + "frame": int(fz["frame_count"]), + "dirty_insns": int(fz["dirty_ram_insns"]), + } + except (KeyError, ValueError, json.JSONDecodeError): + if attempt == retries - 1: + raise + time.sleep(0.1) def run(c, secs, tick, out): @@ -92,12 +100,14 @@ def run(c, secs, tick, out): t0 = time.monotonic() bursts0 = c.cmd("cdrom_bursts", count=1).get("total", 0) phot0 = phot_map(c.cmd("phase_hot", top=64)) + stat0 = phot_map(c.cmd("phase_hot", set="static", top=64)) perpc0 = perpc_map(c.cmd("dirty_ram_stats")) series = [snap(c)] while time.monotonic() - t0 < secs: time.sleep(tick) series.append(snap(c)) phot1 = phot_map(c.cmd("phase_hot", top=64)) + stat1 = phot_map(c.cmd("phase_hot", set="static", top=64)) perpc1 = perpc_map(c.cmd("dirty_ram_stats")) bursts = c.cmd("cdrom_bursts", count=64) span_s = series[-1]["t"] - series[0]["t"] @@ -124,6 +134,7 @@ def run(c, secs, tick, out): report = {"span_s": span_s, "span_mcyc_per_s": rate, "bursts": [], "phot_delta": delta_top(phot0, phot1), + "static_delta": delta_top(stat0, stat1, 15), "perpc_delta": delta_top(perpc0, perpc1)} now_t = time.monotonic() @@ -158,11 +169,15 @@ def run(c, secs, tick, out): print(f" guest {gcyc/1e6:.0f} Mcyc = {rec['guest_s_at_1x']}s at 1x" f" -> {rec['multiplier_vs_1x']}x effective") print(f" interp insns in window: {gint}") - keys = ("interp", "native", "static", "gpu", "other", "exception") - w = ph.get("window", ph) - shares = {k: w.get(k) for k in keys if k in w} + shares = {k.replace("_share", ""): v for k, v in ph.items() + if k.endswith("_share")} print(f" phase shares (covering secs, approximate): {shares}") + if report["static_delta"]: + tot = sum(v for _, v in report["static_delta"]) or 1 + print("\ntop STATIC funcs by wall samples (span delta):") + for a, v in report["static_delta"]: + print(f" {a} {v} ({100.0*v/tot:.1f}% of top)") if report["phot_delta"]: print("\ntop native-overlay funcs by wall samples (span delta):") for a, v in report["phot_delta"]: