diff --git a/CMakeLists.txt b/CMakeLists.txt index c9f0664..ee4714f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,8 @@ option(PSPRECOMP_AOT_ASSUME_NO_WRITE_WATCH "Remove per-store write-watch branch from production AOT fast memory" OFF) option(PSPRECOMP_AOT_PRODUCTION_FASTPATHS "Compile out AOT chain diagnostic/counter branches for production builds" OFF) +option(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY + "Compile per-chain unit/hotspot telemetry into native cross-unit hot paths" ON) set(PSPRECOMP_MSVC_CGTHREADS "0" CACHE STRING "MSVC LTCG code-generation threads (0 = compiler default)") if(NOT PSPRECOMP_MSVC_CGTHREADS MATCHES "^[0-9]+$") @@ -56,6 +58,9 @@ endif() if(PSPRECOMP_AOT_PRODUCTION_FASTPATHS) add_compile_definitions(PSPRECOMP_AOT_PRODUCTION_FASTPATHS=1) endif() +if(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY) + add_compile_definitions(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY=1) +endif() if(MSVC AND PSPRECOMP_NATIVE_AVX2) add_compile_options($<$:/arch:AVX2> $<$:/arch:AVX2>) diff --git a/docs/VCS_PERF_V82_CPU_RUNTIME_LEAN_HANDOFF_2026-08-17.md b/docs/VCS_PERF_V82_CPU_RUNTIME_LEAN_HANDOFF_2026-08-17.md new file mode 100644 index 0000000..7af9219 --- /dev/null +++ b/docs/VCS_PERF_V82_CPU_RUNTIME_LEAN_HANDOFF_2026-08-17.md @@ -0,0 +1,168 @@ +# VCS PERF V8.2 CPU RUNTIME LEAN — handoff + +Date: 2026-08-17 + +## Baseline and reason for this stage + +V8.1 CPU LEAN is the protected baseline. The user reported that it fixed the +slow-motion feeling at low FPS. Its runtime architecture therefore remains +unchanged where timing/scheduling is concerned. + +The latest V8.1 log confirms the CPU direction is better than V7/V8 under heavy +workload. Using all windows with at least 50k game draws: + +- V7: 12.781 ms guest CPU, 57.29 FPS (120 windows, mean 74,461 draws) +- V8: 12.952 ms guest CPU, 56.70 FPS (124 windows, mean 74,482 draws) +- V8.1: 11.969 ms guest CPU, 60.69 FPS (122 windows, mean 76,124 draws) + +These are workload-threshold aggregates, not deterministic replay pairs. V8.2 +therefore does not alter the V8.1 frame/tick behavior; it targets repeated native +chain overhead and code footprint. + +## 1. Generic PSPRecomp runtime-chain telemetry compile switch + +New CMake option: + +`PSPRECOMP_RUNTIME_CHAIN_TELEMETRY` + +The generic framework defaults it ON so profiling capability remains available. +The VCS production build script defaults it OFF. + +When OFF, the compiler removes per-chain branches/counters for: + +- `g_guest_hotspot_profile_enabled` +- `g_guest_hotspot_unit_calls` +- guest-hotspot sampling clocks/records +- `g_unit_profile_enabled` +- `g_unit_profile_counts` + +from compile-time direct chains, dynamic generated-unit chains, and Tier-2 fused +transfer entry accounting. + +This does **not** disable the low-overhead VCS `PERF` telemetry used for FPS, +guest CPU, GE, Present and renderer workload measurements. + +To produce a deep diagnostic build: + +```bat +set PSPRECOMP_RUNTIME_CHAIN_TELEMETRY=ON +profiles\vcs\BUILD_VCS_NINJA.bat +``` + +## 2. PSP scheduler/tick semantics are protected + +The following mechanisms are unchanged from V8.1: + +- `PSPRECOMP_TIME_TICK_DISPATCHES` policy +- `g_runtime_starvation_interval_fast` +- `dispatches_since_import_` +- `run_starvation_boundary()` +- context-switch invalidation / native-chain unwind behavior +- `account_inlined_generated_leaf()` scheduler cadence +- Tier-2 fused-transfer scheduler accounting + +This is intentional because V8.1 resolved the user's low-FPS slow-motion feeling. + +## 3. Five tiny Boundary leaf wrappers removed + +The Boundary cluster has four unit-0206 targets that pass the existing static +leaf validator: + +- `0x08B3E084` +- `0x08B3E08C` (two call sites) +- `0x08B3E254` +- `0x08B3E260` + +Total transformed sites: **5**. + +The validator rejects nested generated/HLE/syscall calls, non-`$ra` returns and +foreign explicit PC exits. Each transformed site invokes the exact generated +entry and then calls `account_inlined_generated_leaf()` so the removed native +wrapper still contributes exactly one scheduler work edge. + +No World direct-leaf expansion was enabled because previous experimentation +showed a compiler/codegen cliff when broad direct-leaf work was mixed with +World's GPR shadow. + +## 4. Geometry/World protected shape + +V8.2 deliberately keeps: + +- Geometry: **379 blocks**, no GPR shadow, no inline/direct-leaf experiment +- World: **402 blocks**, no new direct-leaf experiment +- Tier-2 total: **1,060 blocks** +- static fused calls: **36** +- static fused tails: **13** +- Tier-2 direct-fastmem: **1,969 sites** + +This avoids repeating V8's I-cache/code-size regression. + +## Static code-size evidence + +Same compiler/flags, Linux x86-64, V8.1 vs V8.2: + +- generated unit 0085 `.text`: 374,333 -> 354,075 bytes (**-5.41%**) +- generated unit 0158 `.text`: 360,754 -> 345,782 bytes (**-4.15%**) +- Geometry Tier-2 `.text`: 170,247 -> 161,163 bytes (**-5.34%**), still 379 blocks +- Boundary Tier-2 `.text`: 24,937 -> 18,257 bytes (**-26.79%**) + +`nm` audit on V8.2 unit 0085 shows no references to the guest-hotspot/unit-profile +symbols that were present in the V8.1 object. + +## Isolated wrapper microbenchmark + +20 million calls, x86-64 optimized build, scheduler interval disabled to isolate +native wrapper overhead: + +- ordinary `invoke_chained_direct`: ~2.8 ns/call +- statically proven leaf direct call + `account_inlined_generated_leaf`: ~1.1–1.2 ns/call + +This microbenchmark only validates the local mechanism. It is **not** a game-FPS +claim; Windows VCS gameplay remains authoritative. + +## Validation + +Passed: + +- `psprecomp_tests` +- `vcs_profile_tests` (scheduler/callback/framebuffer) +- `vcs_config_tests` +- `vfpu_tier2_tests` +- V8.2 static audit +- 10/10 hooked generated units syntax-checked with production macros +- 7/7 Tier-2 cluster objects compiled +- generated unit 0085 optimized object compile +- generated unit 0158 optimized object compile +- Geometry/Boundary optimized object A/B code-size builds + +A full Linux VCSNative link was started. The 234-unit O3 generated corpus exceeds +the interactive build window; no source diagnostic occurred before interruption. +The Windows VS2022/Ninja build is authoritative for the full executable. + +## Runtime identity + +Expected log: + +```text +stage=perf-v8.2-cpu-runtime-lean-2026-08-17 +... +guest_hotspot=0 ... chain_telemetry_compiled=0 +... +perf_layer=8 cpu_lean_revision=2 runtime_chain_telemetry_default=0 +... +direct_generated_leaf_sites=5 +geometry_fusion_rollback=1 +``` + +## What to compare + +Primary comparison is V8.2 against V8.1, not against the rejected V8 build. +Compare `guest_cpu_us_avg` and FPS for similar draw bins, especially 50k+, 70k+ +and 90k+ game draws. The game-speed/slow-motion feeling must remain identical to +V8.1; any regression there invalidates this stage regardless of average FPS. + +## Progress estimate + +- overall VCS recomp project: **~92%** +- CPU/Tier-2 architecture: **~97%** +- >150 FPS headroom goal: **~61%**, pending V8.2 Windows gameplay benchmark diff --git a/docs/VCS_PERF_V82_CPU_RUNTIME_LEAN_STATS_2026-08-17.json b/docs/VCS_PERF_V82_CPU_RUNTIME_LEAN_STATS_2026-08-17.json new file mode 100644 index 0000000..44473d7 --- /dev/null +++ b/docs/VCS_PERF_V82_CPU_RUNTIME_LEAN_STATS_2026-08-17.json @@ -0,0 +1,58 @@ +{ + "stage": "perf-v8.2-cpu-runtime-lean-2026-08-17", + "base": "perf-v8.1-cpu-lean-2026-08-17", + "protected_baseline": { + "slow_motion_feeling_fixed": true, + "geometry_blocks": 379, + "world_blocks": 402, + "tier2_hot_blocks": 1060, + "tier2_direct_mem_sites": 1969, + "scheduler_tick_semantics_changed": false + }, + "runtime_chain_optimization": { + "generic_psprecomp_option": "PSPRECOMP_RUNTIME_CHAIN_TELEMETRY", + "generic_default": true, + "vcs_performance_default": false, + "perf_telemetry_preserved": true, + "scheduler_accounting_preserved": true + }, + "boundary_direct_generated_leaves": { + "targets": ["0x08B3E084", "0x08B3E08C", "0x08B3E254", "0x08B3E260"], + "call_sites": 5, + "world_experiment_enabled": false, + "geometry_experiment_enabled": false + }, + "static_text_bytes": { + "generated_unit_0085": {"v81": 374333, "v82": 354075, "reduction_percent": 5.41}, + "generated_unit_0158": {"v81": 360754, "v82": 345782, "reduction_percent": 4.15}, + "tier2_geometry": {"v81": 170247, "v82": 161163, "reduction_percent": 5.34}, + "tier2_boundary": {"v81": 24937, "v82": 18257, "reduction_percent": 26.79} + }, + "isolated_microbench_ns_per_call": { + "ordinary_invoke_chained_direct_approx": 2.8, + "direct_leaf_plus_scheduler_accounting_approx_range": [1.1, 1.2], + "game_fps_claim": false + }, + "v81_runtime_baseline_ge_50000_draws": { + "windows": 122, + "guest_cpu_ms_mean": 11.969, + "fps_mean": 60.69, + "game_draws_mean": 76124 + }, + "validation": { + "psprecomp_tests": "PASS", + "vcs_profile_tests": "PASS", + "vcs_config_tests": "PASS", + "vfpu_tier2_tests": "PASS", + "v82_static_audit": "PASS", + "hook_units_syntax": "10/10 PASS", + "tier2_cluster_objects": "7/7 PASS", + "full_linux_vcsnative_link": "NOT COMPLETED: generated O3 corpus exceeded interactive build window without source diagnostic", + "windows_vs2022": "PENDING USER BUILD" + }, + "progress_estimate": { + "overall_project_percent": 92, + "cpu_tier2_architecture_percent": 97, + "performance_150fps_goal_percent": 61 + } +} diff --git a/docs/VCS_V8_2_1_CORRECTNESS_SAVE_AUDIO_NEWS_HANDOFF_2026-08-17.md b/docs/VCS_V8_2_1_CORRECTNESS_SAVE_AUDIO_NEWS_HANDOFF_2026-08-17.md new file mode 100644 index 0000000..26a5692 --- /dev/null +++ b/docs/VCS_V8_2_1_CORRECTNESS_SAVE_AUDIO_NEWS_HANDOFF_2026-08-17.md @@ -0,0 +1,165 @@ +# VCSNative V8.2.1 — Correctness: Save + SAS loops + NEWS radio + +**Date:** 2026-08-17 +**Base:** PSPRecomp-VCS-PERF-V8.2-CPU-RUNTIME-LEAN-2026-08-17 +**Stage:** `correctness-v8.2.1-save-audio-news-2026-08-17` + +## Why this release exists + +Performance work is intentionally paused. V8.1/V8.2 restored the correct gameplay-speed feeling, but three correctness risks have priority before further CPU work: + +1. after a real successful Save Game, some later mission states can leave a black screen; +2. a vehicle-related SAS effect (reported most clearly on motorcycles, possibly brake/engine) can remain looped until entering the menu; +3. NEWS radio transitions may still hitch when a second ATRAC stream starts. + +The V8.2 Tier-2 shape, Geometry rollback, scheduler cadence and runtime-chain lean path are unchanged. + +## 1. Save Game black-screen hardening + +### Firmware lifecycle fix + +The successful LISTSAVE result path previously did all of these at once when the user acknowledged `SAVE COMPLETED`: + +- changed the utility state to `Quit`; +- destroyed the host savedata UI; +- immediately cleared `display_window`'s system-utility ownership. + +The guest had **not yet** observed `Quit` and called `sceUtilitySavedataShutdownStart`. The new path keeps system-utility ownership through that final PSP utility transition and releases it only from the existing ShutdownStart/Finished path. + +This does not reuse or alter the older V9.6 LISTSAVE-cancel workaround. Load cancellation behavior remains unchanged. + +### Transactional slot writes + +The previous save writer truncated `DATA.BIN` before validating/writing every optional payload. Mission-dependent ICON/PIC/SND descriptors could therefore fail after the main file was already replaced. + +V8.2.1 now: + +1. validates and snapshots the main game save buffer; +2. validates and snapshots every supplied optional savedata buffer; +3. stages every host file beside its final target; +4. moves any old file to a temporary backup; +5. commits the staged files; +6. rolls all already-committed files back if a later commit fails; +7. removes backups only after the complete commit succeeds. + +`VCSNative.meta` remains host-only decoration and cannot turn a valid game save into a failed PSP save operation. + +A regression test creates an existing `DATA.BIN`, deliberately makes a later auxiliary target impossible, requires the transaction to fail, and verifies the old `DATA.BIN` remains byte-for-byte intact. + +## 2. Stuck motorcycle / vehicle sound + +The SAS error value `0x80420016` is now named `kSasErrorInvalidState` rather than the misleading `kSasErrorVoicePaused`. + +More importantly, `__sceSasSetKeyOff` no longer rejects an active voice merely because that voice is currently mixer-paused. Pause is treated as a mixer gate, while KeyOff still latches: + +- `on = false`; +- envelope phase = `Release`. + +This closes the state hole where a looped VAG vehicle voice could be paused during a transition, receive a rejected KeyOff, and later resume with its key still logically held. + +The existing zero-release-rate safeguard remains in place. A new regression test exercises: + +`looped VAG -> paused -> KeyOff -> unpause -> Release -> retired` + +and requires the voice to reach zero envelope and stop playing. + +## 3. NEWS radio hitch investigation/fix + +### Removed synchronous source scan from the common path + +ATRAC setup previously identified a host source by iterating all registered AT3/AA3/OMA files with the same size, opening each candidate synchronously and reading up to 256 header bytes. + +V8.2.1 records the native path associated with a normal file descriptor. When `sceIoRead` fills a guest buffer from an ATRAC source, it records the exact producer for that guest buffer. `sceAtracSetHalfwayBufferAndGetID` then resolves the source directly from that buffer address: + +- no directory-sized candidate scan; +- no host file open; +- no header reread in the common case. + +A bounded 256-byte prefix cache remains only as a fallback for raw virtual-disc layouts where the direct fd-to-buffer association is unavailable. Each fallback file is read at most once. + +### Runtime proof in normal VCSNative.log + +No environment variable is required. Low-volume lines are now written to the normal runtime log: + +```text +ATRAC_SOURCE resolve_us=... candidates=... direct_buffer=1 fallback_reads=0 ... source=NEWS_....AT3 +ATRAC_DECODE source=NEWS_....AT3 open=1 decode_us=... bytes=... sample=... slow_events=... +``` + +`ATRAC_DECODE` is emitted for every decoder open and for later decode calls taking at least 2 ms. Therefore the next physical NEWS test can distinguish: + +- source resolution stall; +- FFmpeg decoder-open stall; +- slow steady-state decode. + +This release removes the known synchronous resolver scan. It does **not** claim that FFmpeg opening a brand-new NEWS decoder is already proven hitch-free; the new log makes that measurable before a more invasive decoder-prewarm change is considered. + +## Preserved V8.2 performance baseline + +Unchanged: + +- hot blocks: 1,060; +- static fused calls: 36; +- Geometry inline generated leaves: 0; +- Boundary direct generated leaf sites: 5; +- `cpu_lean_revision=2`; +- Geometry fusion rollback active; +- direct fastmem active; +- GE async quarantined/off; +- parallel vertex decode quarantined/off; +- scheduler/starvation cadence unchanged. + +## Runtime identity + +Expected header: + +```text +stage=correctness-v8.2.1-save-audio-news-2026-08-17 +... +correctness_revision=821 +save_transaction=1 +save_shutdown_lifecycle=1 +sas_paused_keyoff=1 +atrac_direct_source=1 +atrac_stall_diag=1 +``` + +## Validation completed + +- `vcs_profile.cpp` VCSNative Release object: PASS. +- `vcs_runtime_log.cpp` VCSNative Release object: PASS. +- `psprecomp_tests`: PASS. +- `vcs_profile_tests`: PASS. +- `vcs_config_tests`: PASS. +- `vfpu_tier2_tests`: PASS. +- V8.2 CPU runtime-lean audit: PASS. +- V8.2.1 correctness static audit: **13/13 PASS**. +- transactional save rollback fixture: PASS as part of `vcs_profile_tests`. +- paused looped-SAS KeyOff fixture: PASS as part of `vcs_profile_tests`. +- changed-source audit for the prohibited external emulator name: PASS. + +A full fresh Linux VCSNative link was not repeated because this host-only correctness overlay does not modify generated AOT/Tier-2/DX12 code; the changed VCSNative target objects were compiled directly. Windows VS2022/Ninja plus real VCS gameplay remains authoritative. + +## Windows build / install + +Preferred path from current V8.2 source: extract the V8.2.1 overlay over it, overwrite files, **do not delete `out`**, then run: + +```bat +profiles\vcs\BUILD_VCS_NINJA.bat +``` + +The new one-shot stamp invalidates only `vcs_profile` and `vcs_runtime_log` objects. Generated AOT, Tier-2 and DX12 objects are preserved. + +## Physical test order + +1. Load the same save and complete several missions. +2. Save into an existing slot and a new slot; acknowledge `SAVE COMPLETED`; verify normal return every time. +3. Reproduce the motorcycle/vehicle sound that previously stuck; enter/leave pause and vehicles repeatedly; verify no effect survives incorrectly. +4. Listen until a NEWS bulletin starts. If any hitch remains, provide the new `VCSNative(...).log`; inspect `ATRAC_SOURCE` and `ATRAC_DECODE` around the NEWS filename. + +## Progress estimate + +- Overall VCS recomp project: **~92%**. +- Correctness/frontend/audio stabilization: **~94% implementation**, pending physical Windows verification of these three reports. +- CPU/Tier-2 optimization: remains **~97% architectural work**, intentionally paused here. +- 150+ FPS headroom objective: unchanged from V8.2 and not advanced by this correctness release. diff --git a/docs/VCS_V8_2_1_CORRECTNESS_SAVE_AUDIO_NEWS_STATS_2026-08-17.json b/docs/VCS_V8_2_1_CORRECTNESS_SAVE_AUDIO_NEWS_STATS_2026-08-17.json new file mode 100644 index 0000000..72e4919 --- /dev/null +++ b/docs/VCS_V8_2_1_CORRECTNESS_SAVE_AUDIO_NEWS_STATS_2026-08-17.json @@ -0,0 +1,54 @@ +{ + "release": "PSPRecomp-VCS-V8.2.1-CORRECTNESS-SAVE-AUDIO-NEWS-2026-08-17", + "base": "PSPRecomp-VCS-PERF-V8.2-CPU-RUNTIME-LEAN-2026-08-17", + "stage": "correctness-v8.2.1-save-audio-news-2026-08-17", + "performance_baseline_preserved": { + "hot_blocks": 1060, + "static_fused_calls": 36, + "static_fused_tail": 13, + "hooks": 25, + "boundary_direct_generated_leaf_sites": 5, + "geometry_direct_generated_leaf_sites": 0, + "cpu_lean_revision": 2, + "scheduler_changed": false, + "geometry_changed": false, + "dx12_changed": false + }, + "savedata": { + "transactional_buffer_snapshot": true, + "stage_before_replace": true, + "rollback_on_commit_failure": true, + "listsave_system_utility_released_at_shutdown": true, + "v9_6_cancel_policy_changed": false + }, + "sas": { + "error_80420016_name": "InvalidState", + "keyoff_while_paused_latches_release": true, + "zero_release_fallback_preserved": true, + "paused_loop_regression_test": true + }, + "news_atrac": { + "direct_sceIoRead_buffer_source_resolution": true, + "common_path_host_file_scan": false, + "lazy_prefix_fallback_bytes": 256, + "runtime_source_timing_log": true, + "runtime_decode_open_timing_log": true, + "slow_decode_threshold_us": 2000, + "ffmpeg_open_hitch_physically_verified": false + }, + "validation": { + "psprecomp_tests": "PASS", + "vcs_profile_tests": "PASS", + "vcs_config_tests": "PASS", + "vfpu_tier2_tests": "PASS", + "v8_2_baseline_audit": "PASS", + "v8_2_1_correctness_audit": "13/13 PASS", + "vcsnative_vcs_profile_object": "PASS", + "vcsnative_runtime_log_object": "PASS" + }, + "progress_estimate_percent": { + "overall": 92, + "correctness_frontend_audio_implementation": 94, + "cpu_tier2_architecture": 97 + } +} diff --git a/docs/VCS_V8_2_2_CORRECTNESS_RECOVERY_HANDOFF_2026-08-17.md b/docs/VCS_V8_2_2_CORRECTNESS_RECOVERY_HANDOFF_2026-08-17.md new file mode 100644 index 0000000..7feb634 --- /dev/null +++ b/docs/VCS_V8_2_2_CORRECTNESS_RECOVERY_HANDOFF_2026-08-17.md @@ -0,0 +1,88 @@ +# VCSNative V8.2.2 Correctness Recovery — Save / SAS / ATRAC + +Date: 2026-08-17 +Base: V8.2 CPU Runtime Lean stable source (not V8.2.1) +Estimated total VCS recomp project progress: ~92% + +## Why this recovery exists + +The physical V8.2.1 test did not fix the stuck vehicle sound and introduced/revealed an infinite mission-interior Loading state. The supplied runtime log shows the Loading screen is active rather than blocked: from vblank 20461 onward the same 4500 game draws / 4080 GPU draws / 420 merges repeat with host I/O at zero for thousands of vblanks. There are no savedata operations in that run before the interior stall. + +V8.2.2 therefore rebases correctness work onto the previously stable V8.2 source and does not carry forward the speculative V8.2.1 paused-KeyOff or LISTSAVE lifecycle changes. + +## 1. Mission-interior Loading regression recovery + +- Source base restored to V8.2 for runtime behavior. +- V8.2.1 paused-KeyOff behavior removed. +- V8.2.1 LISTSAVE host-ownership/lifecycle experiment removed. +- Tier-2, scheduler, Geometry, World, DX12 and performance architecture are unchanged from V8.2. + +This removes V8.2.1-specific runtime changes from the mission/interior path. Physical gameplay remains authoritative to prove the interior transition is restored. + +## 2. SAS audio correctness + +### Latched EndFlag + +The old HLE returned `!voice.playing` directly from `__sceSasGetEndFlag`. PSP SAS exposes end flags as a snapshot refreshed by a completed Core mixer cycle. V8.2.2 stores `SasState::end_flags` and refreshes it only after `sas_mix_into` / `sas_mix_raw` completes. + +This prevents guest audio control code from observing a voice transition before the mixer cycle that publishes it, which can otherwise cause premature voice reuse/rearming. + +### VAG loop predictor restoration + +The decoder already captured `loop_start_history1/2`, but `rewind_loop()` never restored them. V8.2.2 restores both predictor histories on every loop jump. This makes every pass through a loop start from the same ADPCM decoder state instead of accumulating waveform drift. + +### What was deliberately reverted + +The V8.2.1 helper that accepted KeyOff while a voice was paused is gone. V8.2 paused-KeyOff behavior is restored until a physical trace proves a different state transition is required. + +## 3. Savedata + +The useful transactional write protection is retained, but the UI/status lifecycle is exactly the V8.2/V9.6 behavior again. + +Before replacing an existing slot, V8.2.2 snapshots all guest buffers, stages DATA.BIN and optional auxiliary files to temporary host files, and commits only after all staging succeeds. A failure rolls the old slot back instead of leaving DATA.BIN partially replaced. + +Normal VCSNative.log now records low-volume LISTSAVE diagnostics: + +- `SAVEDATA_SAVE result=... data=... icon0=... icon1=... pic1=... snd0=...` +- `SAVEDATA_SAVE acknowledged status=QUIT` +- `SAVEDATA_SAVE shutdown status=FINISHED` + +These lines will distinguish an I/O/descriptor failure from a guest frontend transition if the original post-save black screen still reproduces. + +## 4. ATRAC / radio NEWS stall + +V8.2.1 attempted direct source tracking only for ordinary host-file reads. The physical log showed every observed source resolution as `direct_buffer=0`, proving the radio was taking another path. VCS commonly reads these streams through the virtual UMD handle. + +V8.2.2 maps virtual-disc read offsets back to the indexed `VirtualDiscFile` before the read, associates an ATRAC guest destination buffer with the exact AT3/AA3/OMA source, and consumes that mapping in `sceAtracSetHalfwayBufferAndGetID`. + +The old content-header search remains only as fallback. `ATRAC_SOURCE` and `ATRAC_DECODE` remain low-volume runtime diagnostics. + +Expected healthy line: + +`ATRAC_SOURCE resolve_us= direct_buffer=1 fallback_reads=0 source=...AT3` + +The V8.2.1 physical log contained a 7152 us source lookup while the matching decoder open was only 431 us, so source resolution itself was still capable of producing a visible radio hitch. + +## Validation + +- CMake Release/Ninja configure: PASS +- changed `vcs_profile.cpp` object: PASS +- changed `vcs_runtime_log.cpp` object: PASS +- psprecomp_tests: PASS +- vcs_profile_tests: PASS +- vcs_config_tests: PASS +- vfpu_tier2_tests: PASS +- V8.2 CPU runtime lean audit: PASS +- V8.2.2 correctness recovery audit: PASS (15 checks) +- forbidden external-emulator reference audit: PASS + +The full Windows/DX12 executable was not linked in the Linux container. VS2022/Ninja on the user's machine remains the authoritative full build and physical runtime test. + +## Physical test order + +1. Enter the same mission interior that looped on Loading. This is the first gate. +2. Reproduce the motorcycle/vehicle sound that used to stay looped; verify it retires without opening the menu. +3. Save after several missions and complete the Save UI. If black returns, send the new log; the SAVEDATA_SAVE lines now expose the exact result/descriptor state. +4. Let radio/news transitions occur. Check whether `ATRAC_SOURCE` is now `direct_buffer=1 fallback_reads=0`, and report any visible hitch. + +Do not resume CPU optimization until these correctness gates pass. diff --git a/docs/VCS_V8_2_2_CORRECTNESS_RECOVERY_STATS_2026-08-17.json b/docs/VCS_V8_2_2_CORRECTNESS_RECOVERY_STATS_2026-08-17.json new file mode 100644 index 0000000..7194d3a --- /dev/null +++ b/docs/VCS_V8_2_2_CORRECTNESS_RECOVERY_STATS_2026-08-17.json @@ -0,0 +1,48 @@ +{ + "release": "V8.2.2 Correctness Recovery", + "date": "2026-08-17", + "base": "V8.2 CPU Runtime Lean", + "estimated_total_project_progress_percent": 92, + "runtime_stage": "correctness-v8.2.2-recovery-save-audio-news-2026-08-17", + "recovery": { + "v821_paused_keyoff_removed": true, + "v821_save_lifecycle_experiment_removed": true, + "tier2_scheduler_geometry_unchanged_from_v82": true + }, + "sas": { + "end_flags_latched_after_core": true, + "vag_loop_predictor_history_restored": true + }, + "savedata": { + "transactional_commit": true, + "rollback_on_staging_or_commit_failure": true, + "v82_v96_lifecycle_preserved": true, + "runtime_save_diagnostics": true + }, + "atrac": { + "regular_file_buffer_source_tracking": true, + "virtual_umd_buffer_source_tracking": true, + "header_scan_fallback_retained": true, + "source_and_decode_timing_log": true, + "v821_observed_max_source_resolution_us": 7152, + "matching_decode_open_us": 431 + }, + "loading_loop_observation": { + "first_stable_vblank": 20461, + "last_observed_vblank": 24481, + "repeated_game_draws": 4500, + "repeated_gpu_draws": 4080, + "repeated_batch_merges": 420, + "io_us_avg": 0 + }, + "validation": { + "psprecomp_tests": "PASS", + "vcs_profile_tests": "PASS", + "vcs_config_tests": "PASS", + "vfpu_tier2_tests": "PASS", + "v82_static_audit": "PASS", + "v822_static_audit": "PASS", + "changed_host_objects": "PASS", + "full_windows_runtime": "PENDING_PHYSICAL_TEST" + } +} diff --git a/include/psprecomp/runtime.hpp b/include/psprecomp/runtime.hpp index a17a7fe..9349a17 100644 --- a/include/psprecomp/runtime.hpp +++ b/include/psprecomp/runtime.hpp @@ -247,6 +247,7 @@ public: // invalidation flag. All active direct ancestors see that same hot // byte and unwind. This replaces two process-global 64-bit loads on // every fixed cross-unit transfer with one normally-false local load. +#if defined(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY) bool guest_hotspot_sample = false; std::uint64_t guest_hotspot_start_ns = 0u; if (g_guest_hotspot_profile_enabled) { @@ -257,6 +258,7 @@ public: (ticket & static_cast(g_guest_hotspot_sample_mask)) == 0u; if (guest_hotspot_sample) guest_hotspot_start_ns = guest_hotspot_clock_ns(); } +#endif struct DepthGuard { std::uint32_t &depth; @@ -278,6 +280,7 @@ public: } else { Function(*this, ctx); } +#if defined(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY) if (guest_hotspot_sample) { const std::uint64_t end_ns = guest_hotspot_clock_ns(); guest_hotspot_record_sample(UnitIndex, DirectTargetPc, @@ -285,6 +288,7 @@ public: ? end_ns - guest_hotspot_start_ns : 0u); } if (g_unit_profile_enabled) ++g_unit_profile_counts[UnitIndex]; +#endif #if !defined(PSPRECOMP_AOT_PRODUCTION_FASTPATHS) if (track_dispatch_counters_) { ++chained_dispatches_; @@ -338,10 +342,12 @@ public: return false; } ++chain_depth_; +#if defined(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY) if (g_unit_profile_enabled && UnitIndex < kUnitProfileCapacity) ++g_unit_profile_counts[UnitIndex]; if (g_guest_hotspot_profile_enabled && UnitIndex < kUnitProfileCapacity) ++g_guest_hotspot_unit_calls[UnitIndex]; +#endif return true; } diff --git a/profiles/vcs/host/audio_output.cpp b/profiles/vcs/host/audio_output.cpp index 96630cf..851b9d8 100644 --- a/profiles/vcs/host/audio_output.cpp +++ b/profiles/vcs/host/audio_output.cpp @@ -47,6 +47,7 @@ constexpr std::uint64_t kMixSafetyFrames = 1024u; // channel lapping the ring during normal realtime play. constexpr std::size_t kRingFrames = kSampleRate * 2u; constexpr std::size_t kGuestChannels = 9u; +constexpr std::size_t kOutput2Channel = 8u; constexpr std::uint64_t kChannelDiscontinuityFrames = 64u; struct Block { @@ -80,6 +81,7 @@ struct AudioState { std::uint64_t queued_blocks{}; std::uint64_t underrun_rebuffers{}; std::uint64_t timeline_resyncs{}; + std::uint64_t output2_seal_clamps{}; std::uint64_t submit_calls{}; std::uint64_t submit_cpu_ns{}; std::uint64_t submit_cpu_max_ns{}; @@ -330,8 +332,28 @@ void advance_locked(AudioState &state, std::uint64_t guest_time_us) { // audible underrun without changing the normal multi-channel mix path. const std::uint64_t safety_frames = state.playback_started && outstanding <= 2u ? 0u : kMixSafetyFrames; - const std::uint64_t sealed_frame = guest_frame > safety_frames + std::uint64_t sealed_frame = guest_frame > safety_frames ? guest_frame - safety_frames : 0u; + + // Channel 8 is sceAudioOutput2: VCS' final 44.1-kHz stereo music/radio + // mixer. Do not seal host timeline frames that this producer has not + // actually supplied yet. In heavy scenes virtual_time_us can jump ahead + // when the renderer misses wall-clock pace; the old code then committed + // zeros to waveOut before Output2 delivered the next 512-frame block. + // Once committed those frames cannot be repaired, so spoken NEWS arrived + // as a train of missing chunks and sounded dragged/stuttered. + // + // Keep this bounded: if Output2 really stops for longer than the current + // device prebuffer, it stops being the watermark and other PSP channels + // are allowed to advance normally. + const ChannelStream &output2 = state.channels[kOutput2Channel]; + const std::uint64_t producer_grace = + static_cast(state.prebuffer_blocks) * kBlockFrames; + const std::uint64_t unclamped_sealed_frame = sealed_frame; + sealed_frame = audio_output_master_seal_frame( + guest_frame, sealed_frame, output2.active, output2.cursor, producer_grace); + if (sealed_frame != unclamped_sealed_frame) ++state.output2_seal_clamps; + while (sealed_frame >= state.output_frame + kBlockFrames) { if (!queue_one_block(state)) break; } @@ -473,6 +495,7 @@ void audio_output_advance(std::uint64_t guest_time_us) { << " recovering=" << state.recovering_from_underrun << " underrun_rebuffers=" << state.underrun_rebuffers << " resyncs=" << state.timeline_resyncs + << " output2_clamps=" << state.output2_seal_clamps << " late_frames=" << state.late_frames_dropped << " overrun_frames=" << state.overrun_frames_dropped << " submit_calls=" << state.submit_calls @@ -529,6 +552,7 @@ void audio_output_shutdown() { } state.late_frames_dropped = 0u; state.overrun_frames_dropped = 0u; + state.output2_seal_clamps = 0u; } } // namespace vcs diff --git a/profiles/vcs/host/audio_output.hpp b/profiles/vcs/host/audio_output.hpp index 86f767a..cdbfa1f 100644 --- a/profiles/vcs/host/audio_output.hpp +++ b/profiles/vcs/host/audio_output.hpp @@ -5,6 +5,19 @@ namespace vcs { +// sceAudioOutput2 is the game's continuous final music/radio producer. When +// virtual time runs ahead of that producer, sealing future host frames would +// make the gap irreversible. Clamp only while the producer is within the +// bounded device-prebuffer grace window; if it truly stalls beyond that, the +// rest of the PSP channels may advance normally. +[[nodiscard]] constexpr std::uint64_t audio_output_master_seal_frame( + std::uint64_t guest_frame, std::uint64_t sealed_frame, bool producer_active, + std::uint64_t producer_cursor, std::uint64_t grace_frames) noexcept { + if (!producer_active) return sealed_frame; + if (guest_frame > producer_cursor + grace_frames) return sealed_frame; + return sealed_frame > producer_cursor ? producer_cursor : sealed_frame; +} + // Host audio sink for the sceAudio HLE. The PSP exposes eight regular PCM // channels plus one SRC/Output2 channel; submissions are mixed on the guest's // virtual-time line before they are handed to the native audio device. diff --git a/profiles/vcs/host/vcs_profile.cpp b/profiles/vcs/host/vcs_profile.cpp index 544a3ab..a5441ae 100644 --- a/profiles/vcs/host/vcs_profile.cpp +++ b/profiles/vcs/host/vcs_profile.cpp @@ -191,6 +191,11 @@ struct FileTable { std::int32_t next_fd{3}; std::uint32_t next_virtual_sector{0x00010000u}; std::unordered_map files; + std::unordered_map file_paths; + // Producer tracking for ATRAC setup. A guest buffer filled directly from an + // AT3/AA3/OMA can be associated with its host source without reopening and + // rescanning candidate files on the audio thread. + std::unordered_map recent_atrac_reads; std::unordered_set synthetic_empty_files; std::unordered_map directories; std::unordered_map virtual_disc_handles; @@ -535,6 +540,26 @@ std::string normalized_native_path(const std::filesystem::path &path) { return normalized.generic_string(); } +bool is_atrac_source_path(const std::filesystem::path &path) { + std::string extension = path.extension().string(); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char ch) { return static_cast(std::toupper(ch)); }); + return extension == ".AT3" || extension == ".AA3" || extension == ".OMA"; +} + +const VirtualDiscFile *virtual_disc_file_at_offset(std::uint64_t absolute) { + const std::uint64_t sector64 = absolute / 2048u; + if (sector64 > 0xFFFFFFFFull) return nullptr; + const auto next = file_table.virtual_path_by_sector.upper_bound(static_cast(sector64)); + if (next == file_table.virtual_path_by_sector.begin()) return nullptr; + const auto previous = std::prev(next); + const auto found = file_table.virtual_files_by_path.find(previous->second); + if (found == file_table.virtual_files_by_path.end()) return nullptr; + const std::uint64_t start = static_cast(found->second.start_sector) * 2048u; + if (absolute < start || absolute >= start + found->second.size) return nullptr; + return &found->second; +} + const VirtualDiscFile *register_virtual_disc_file(const std::filesystem::path &path) { std::error_code error; if (!std::filesystem::is_regular_file(path, error) || error) return nullptr; @@ -882,22 +907,49 @@ bool parse_atrac_header(std::span bytes, ParsedAtracHeader & return true; } -std::filesystem::path identify_atrac_source(std::span header, const ParsedAtracHeader &parsed) { - const std::size_t compare_size = std::min(header.size(), 256u); - for (const auto &[key, file] : file_table.virtual_files_by_path) { - if (file.size != parsed.file_size) continue; - std::string extension = file.native_path.extension().string(); - std::transform(extension.begin(), extension.end(), extension.begin(), - [](unsigned char ch) { return static_cast(std::toupper(ch)); }); - if (extension != ".AT3" && extension != ".AA3" && extension != ".OMA") continue; - std::vector candidate(compare_size); - std::ifstream input(file.native_path, std::ios::binary); - if (!input) continue; - input.read(reinterpret_cast(candidate.data()), static_cast(candidate.size())); - if (input.gcount() == static_cast(candidate.size()) && - std::equal(candidate.begin(), candidate.end(), header.begin())) return file.native_path; +std::filesystem::path identify_atrac_source(std::uint32_t guest_buffer, + std::span header, + const ParsedAtracHeader &parsed) { + const auto started = std::chrono::steady_clock::now(); + bool direct_buffer = false; + std::uint32_t fallback_reads = 0u; + std::filesystem::path matched; + + if (const auto direct = file_table.recent_atrac_reads.find(guest_buffer); + direct != file_table.recent_atrac_reads.end()) { + matched = direct->second; + direct_buffer = true; + file_table.recent_atrac_reads.erase(direct); } - return {}; + + const std::size_t compare_size = std::min(header.size(), 256u); + if (matched.empty()) { + for (const auto &[key, file] : file_table.virtual_files_by_path) { + (void)key; + if (file.size != parsed.file_size || !is_atrac_source_path(file.native_path)) continue; + ++fallback_reads; + std::vector candidate(compare_size); + std::ifstream input(file.native_path, std::ios::binary); + if (!input) continue; + input.read(reinterpret_cast(candidate.data()), static_cast(candidate.size())); + if (input.gcount() == static_cast(candidate.size()) && + std::equal(candidate.begin(), candidate.end(), header.begin())) { + matched = file.native_path; + break; + } + } + } + + const std::uint64_t elapsed_us = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now() - started).count()); + std::ostringstream line; + line << "ATRAC_SOURCE resolve_us=" << elapsed_us + << " direct_buffer=" << (direct_buffer ? 1 : 0) + << " fallback_reads=" << fallback_reads + << " source=" << (matched.empty() ? "" : matched.filename().string()); + runtime_log_line(line.str()); + return matched; } // sceAtracDecodeData always hands the caller two interleaved channels: the PSP @@ -951,6 +1003,35 @@ std::uint32_t atrac_samples_per_frame(const AtracContextState &state) { return state.header.atrac3plus ? 2048u : 1024u; } +// sceAtrac streaming APIs use negative sentinel values once the encoded stream +// is entirely resident in the caller's halfway buffer. VCS checks these +// values directly after every sceAtracDecodeData call: -1/-2 take the +// all-data-resident path, while a non-negative frame count takes the refill +// path. Returning 0 when a short non-loop stream such as NEWS_*.AT3 was fully +// loaded made the game keep treating it as a streaming/refill source even +// though no more encoded bytes existed. +constexpr std::uint32_t kAtracRemainAllDataOnMemory = 0xFFFFFFFFu; // -1 +constexpr std::uint32_t kAtracRemainNonLoopOnMemory = 0xFFFFFFFEu; // -2 +constexpr std::uint32_t kAtracRemainLoopOnMemory = 0xFFFFFFFDu; // -3 + +bool atrac_has_active_loop(const AtracContextState &state) noexcept { + return state.loop_num != 0 && state.header.loop_start >= 0 && + state.header.loop_end >= state.header.loop_start; +} + +std::uint32_t atrac_remain_frame_status(const AtracContextState &state) noexcept { + if (state.next_file_offset >= state.header.file_size) { + // VCS creates its radio decoders through SetHalfwayBufferAndGetID, so + // the streaming-specific -2/-3 statuses are the faithful result once + // the whole file has been fed. (-1 belongs to the all-data API case; + // the guest accepts both -1 and -2 for its non-loop fast path.) + return atrac_has_active_loop(state) ? + kAtracRemainLoopOnMemory : kAtracRemainNonLoopOnMemory; + } + if (state.header.block_align == 0u) return 0u; + return state.buffered_encoded_bytes / state.header.block_align; +} + std::uint32_t atrac_bitrate_kbps(const AtracContextState &state) { if (state.header.atrac3plus) { const std::uint32_t raw = (static_cast(state.header.block_align) * 352800u) / 1000u; @@ -1572,6 +1653,10 @@ struct SasState { std::uint32_t output_mode{}; std::uint32_t sample_rate{44100u}; std::array voices{}; + // sceSasGetEndFlag exposes a hardware-latched snapshot. The flags are + // refreshed by a completed __sceSasCore/__sceSasCoreWithMix cycle, not by + // arbitrary setters in the middle of a grain. + std::uint32_t end_flags{0xFFFFFFFFu}; SasReverbState reverb{}; }; @@ -1591,6 +1676,14 @@ std::size_t sas_playing_voice_count() { [](const SasVoiceState &voice) { return voice.playing && !voice.paused; })); } +void sas_refresh_end_flags() noexcept { + std::uint32_t flags = 0u; + for (std::size_t i = 0; i < sas_state.voices.size(); ++i) { + if (!sas_state.voices[i].playing) flags |= 1u << i; + } + sas_state.end_flags = flags; +} + void sas_log_mix_checkpoint(const char *kind, std::uint64_t count) { if (!sas_audio_diagnostics_enabled()) return; if (count <= 8u || (count % 256u) == 0u) { @@ -1866,6 +1959,13 @@ bool sas_decode_next_block(const psprecomp::GuestMemory &memory, SasVoiceState & // Resetting it at the marker makes otherwise seamless ambient loops // click every time they wrap. voice.decode_offset = voice.loop_start_valid ? voice.loop_start_offset : 0u; + if (voice.loop_start_valid) { + voice.history1 = voice.loop_start_history1; + voice.history2 = voice.loop_start_history2; + } else { + voice.history1 = 0; + voice.history2 = 0; + } voice.remaining_samples = voice.total_samples; }; @@ -2154,6 +2254,7 @@ void sas_mix_into(psprecomp::Runtime &rt, std::uint32_t output, std::uint32_t fr rt.memory().store16(output + frame * 4u + 2u, static_cast( static_cast(std::clamp(r, -32768, 32767)))); } + sas_refresh_end_flags(); } // Raw output mode exposes four non-interleaved planes: dry L, dry R, effect L, @@ -2178,6 +2279,7 @@ void sas_mix_raw(psprecomp::Runtime &rt, std::uint32_t output, std::uint32_t fra store(send_left_base, effect_send[frame * 2u]); store(send_right_base, effect_send[frame * 2u + 1u]); } + sas_refresh_end_flags(); } enum class UtilityStatus : std::uint32_t { @@ -2589,15 +2691,120 @@ bool write_guest_file(psprecomp::Runtime &runtime, const std::filesystem::path & return output.good(); } -bool write_savedata_auxiliary(psprecomp::Runtime &runtime, std::uint32_t parameter_address, - std::uint32_t descriptor_offset, const char *filename) { +struct SavedataPendingWrite { + std::filesystem::path target; + std::vector bytes; +}; + +bool snapshot_guest_bytes(psprecomp::Runtime &runtime, std::uint32_t buffer, + std::uint32_t size, std::vector &bytes) { + bytes.clear(); + if (size == 0u) return true; + if (buffer == 0u || !runtime.memory().contains(buffer, size)) return false; + bytes.resize(size); + runtime.memory().copy_out(buffer, bytes); + return true; +} + +bool snapshot_savedata_auxiliary(psprecomp::Runtime &runtime, + std::uint32_t parameter_address, + std::uint32_t descriptor_offset, + const char *filename, + std::vector &writes) { const std::uint32_t descriptor = parameter_address + descriptor_offset; const std::uint32_t buffer = runtime.memory().load32(descriptor); const std::uint32_t buffer_size = runtime.memory().load32(descriptor + 4u); const std::uint32_t actual_size = runtime.memory().load32(descriptor + 8u); if (buffer == 0u || actual_size == 0u) return true; if (actual_size > buffer_size) return false; - return write_guest_file(runtime, savedata_directory(runtime, parameter_address) / filename, buffer, actual_size); + SavedataPendingWrite write{}; + write.target = savedata_directory(runtime, parameter_address) / filename; + if (!snapshot_guest_bytes(runtime, buffer, actual_size, write.bytes)) return false; + writes.push_back(std::move(write)); + return true; +} + +bool write_savedata_host_bytes(const std::filesystem::path &path, + std::span bytes) { + std::error_code error; + std::filesystem::create_directories(path.parent_path(), error); + if (error) return false; + std::ofstream output(path, std::ios::binary | std::ios::trunc); + if (!output) return false; + if (!bytes.empty()) + output.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + output.flush(); + return output.good(); +} + +bool commit_savedata_writes(std::vector &writes) { + struct CommitPath { + std::filesystem::path target; + std::filesystem::path temporary; + std::filesystem::path backup; + bool had_target{}; + bool backup_moved{}; + bool committed{}; + }; + const std::string tag = std::to_string(static_cast( + std::chrono::steady_clock::now().time_since_epoch().count())); + std::vector paths; + paths.reserve(writes.size()); + + // Stage every file first. No existing save data is touched until all guest + // buffers have validated and every temporary host write has completed. + for (std::size_t i = 0u; i < writes.size(); ++i) { + CommitPath entry{}; + entry.target = writes[i].target; + entry.temporary = entry.target; + entry.temporary += ".vcsnative.tmp." + tag + "." + std::to_string(i); + entry.backup = entry.target; + entry.backup += ".vcsnative.bak." + tag + "." + std::to_string(i); + if (!write_savedata_host_bytes(entry.temporary, writes[i].bytes)) { + std::error_code ignore; + for (const auto &old : paths) std::filesystem::remove(old.temporary, ignore); + std::filesystem::remove(entry.temporary, ignore); + return false; + } + paths.push_back(std::move(entry)); + } + + auto rollback = [&]() noexcept { + std::error_code error; + for (auto it = paths.rbegin(); it != paths.rend(); ++it) { + if (it->committed) std::filesystem::remove(it->target, error); + error.clear(); + if (it->backup_moved && std::filesystem::exists(it->backup, error)) { + error.clear(); + std::filesystem::rename(it->backup, it->target, error); + } + error.clear(); + std::filesystem::remove(it->temporary, error); + } + }; + + for (auto &entry : paths) { + std::error_code error; + entry.had_target = std::filesystem::exists(entry.target, error) && !error; + if (entry.had_target) { + std::filesystem::remove(entry.backup, error); + error.clear(); + std::filesystem::rename(entry.target, entry.backup, error); + if (error) { rollback(); return false; } + entry.backup_moved = true; + } + error.clear(); + std::filesystem::rename(entry.temporary, entry.target, error); + if (error) { rollback(); return false; } + entry.committed = true; + } + std::error_code ignore; + for (auto &entry : paths) { + if (entry.backup_moved) std::filesystem::remove(entry.backup, ignore); + std::filesystem::remove(entry.temporary, ignore); + } + return true; } std::uint32_t load_savedata_file(psprecomp::Runtime &runtime, std::uint32_t parameter_address, @@ -2633,20 +2840,32 @@ std::uint32_t save_savedata_file(psprecomp::Runtime &runtime, std::uint32_t para if (size > capacity || (size != 0u && (source == 0u || !runtime.memory().contains(source, size)))) { return raw_mode ? 0x80110328u : 0x80110388u; } - if (!write_guest_file(runtime, path, source, size)) return raw_mode ? 0x80110329u : 0x80110385u; + + // Snapshot every guest buffer before touching the existing slot. Some + // missions change the optional savedata payloads; the old path truncated + // DATA.BIN first and only then discovered an invalid auxiliary descriptor. + // A failed save could therefore leave a half-updated slot and a frontend + // completion state that looked successful until VCS tried to leave it. + std::vector writes; + SavedataPendingWrite main_write{}; + main_write.target = path; + if (!snapshot_guest_bytes(runtime, source, size, main_write.bytes)) + return raw_mode ? 0x80110328u : 0x80110388u; + writes.push_back(std::move(main_write)); + if (!raw_mode) { - if (!write_savedata_auxiliary(runtime, parameter_address, kSavedataIcon0Offset, "ICON0.PNG") || - !write_savedata_auxiliary(runtime, parameter_address, kSavedataIcon1Offset, "ICON1.PMF") || - !write_savedata_auxiliary(runtime, parameter_address, kSavedataPic1Offset, "PIC1.PNG") || - !write_savedata_auxiliary(runtime, parameter_address, kSavedataSnd0Offset, "SND0.AT3")) { - return 0x80110385u; + if (!snapshot_savedata_auxiliary(runtime, parameter_address, kSavedataIcon0Offset, "ICON0.PNG", writes) || + !snapshot_savedata_auxiliary(runtime, parameter_address, kSavedataIcon1Offset, "ICON1.PMF", writes) || + !snapshot_savedata_auxiliary(runtime, parameter_address, kSavedataPic1Offset, "PIC1.PNG", writes) || + !snapshot_savedata_auxiliary(runtime, parameter_address, kSavedataSnd0Offset, "SND0.AT3", writes)) { + return 0x80110388u; } - // The PSP firmware normally persists sfoParam to PARAM.SFO and uses - // savedataTitle/detail on its load screen. VCSNative's host savedata - // path previously dropped that metadata entirely, leaving the slot UI - // with only opaque names like S92F0. Preserve the exact guest-provided - // display strings in a tiny host sidecar so the in-game overlay can show - // the mission/save title on later loads. + } + + if (!commit_savedata_writes(writes)) return raw_mode ? 0x80110329u : 0x80110385u; + if (!raw_mode) { + // Metadata is host-only UI decoration; it must never turn a valid game + // save into a failed firmware operation. (void)write_savedata_metadata_file(path.parent_path(), savedata_metadata_from_guest(runtime, parameter_address)); } @@ -2867,6 +3086,22 @@ void execute_selected_savedata_slot(psprecomp::Runtime &runtime) { runtime.memory().store32(savedata_utility.parameter_address + kUtilityCommonResultOffset, result); savedata_utility.operation_complete = true; savedata_utility.last_result = result; + if (savedata_utility.mode == 5u) { + const std::uint32_t data_size = runtime.memory().load32( + savedata_utility.parameter_address + kSavedataDataSizeOffset); + const auto aux_size = [&](std::uint32_t offset) { + return runtime.memory().load32(savedata_utility.parameter_address + offset + 8u); + }; + std::ostringstream line; + line << "SAVEDATA_SAVE result=0x" << std::hex << std::uppercase << result + << std::nouppercase << std::dec + << " data=" << data_size + << " icon0=" << aux_size(kSavedataIcon0Offset) + << " icon1=" << aux_size(kSavedataIcon1Offset) + << " pic1=" << aux_size(kSavedataPic1Offset) + << " snd0=" << aux_size(kSavedataSnd0Offset); + runtime_log_line(line.str()); + } if ((savedata_utility.startup_picker || savedata_utility.direct_load_picker) && result == 0u) { // A successful first-boot choice should hand control back to the retail // LOAD completion path immediately. There is no GAME frontend in V9. @@ -2922,6 +3157,8 @@ void update_savedata_list_utility(psprecomp::Runtime &runtime) { savedata_utility.status = UtilityStatus::Quit; savedata_utility_ui_end(); display_window_set_system_utility_mode(false); + if (savedata_utility.mode == 5u) + runtime_log_line("SAVEDATA_SAVE acknowledged status=QUIT"); } } return; @@ -2997,6 +3234,11 @@ std::uint32_t audio_buffer_duration_us(std::uint32_t samples) { return static_cast((static_cast(samples) * 1000000u + 44099u) / 44100u); } +constexpr std::uint32_t audio_resample_success_value(bool return_queued_samples, + std::uint32_t sample_count) noexcept { + return return_queued_samples ? sample_count : 0u; +} + // Queues one buffer on a channel and returns the virtual time at which it // starts playing. // @@ -3016,16 +3258,19 @@ std::uint64_t audio_queue_buffer(AudioChannelState &channel, std::uint32_t frame const auto elapsed_us = [&](std::uint64_t sample_frames) { return (sample_frames * 1000000ull) / rate; }; + std::uint64_t start = channel.queue_anchor_us + elapsed_us(channel.queued_frames); - if (!channel.queue_active || start < virtual_time_us) { - // Either the first buffer of a stream, or the guest fell far enough - // behind that the queue really did drain. Both are genuine - // discontinuities: restart the anchor here. + if (!channel.queue_active) { channel.queue_active = true; channel.queue_anchor_us = virtual_time_us; channel.queued_frames = 0u; start = virtual_time_us; + } else if (start < virtual_time_us) { + channel.queue_anchor_us = virtual_time_us; + channel.queued_frames = 0u; + start = virtual_time_us; } + channel.queued_frames += frames; channel.busy_until_us = channel.queue_anchor_us + elapsed_us(channel.queued_frames); return start; @@ -8004,6 +8249,8 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start ctx.set_gpr(2, 0x80110001u); return; } + if (savedata_utility.mode == 5u) + runtime_log_line("SAVEDATA_SAVE shutdown status=FINISHED"); savedata_utility.status = UtilityStatus::Finished; savedata_utility_ui_end(); display_window_set_system_utility_mode(false); @@ -8203,11 +8450,14 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start set_success(ctx); }); - // sceAudioOutput2OutputBlocking and sceAudioSRCOutputBlocking share this - // body: both drain the single resampling channel, and the rate recorded at - // reserve time is what tells them apart. - const auto audio_src_output = - [](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) { + // Output2 and SRC share the single resampling hardware path, but they do + // NOT share the same ABI result. sceAudioOutput2OutputBlocking returns 0 + // on success while sceAudioSRCOutputBlocking returns the queued sample + // count. VCS imports Output2, so returning 512 here was leaking a false + // non-zero result out of its radio mixer every buffer. + const auto audio_resample_output = + [](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx, + bool return_queued_samples) { auto &state = audio_channels[8]; const std::uint32_t volume = ctx.gpr[4]; const std::uint32_t buffer = ctx.gpr[5]; @@ -8237,7 +8487,8 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start state.left_volume = volume; state.right_volume = volume; // audio_queue_buffer already paces at the channel's own frequency, // so a stream that is not 44100 neither starves nor floods the mix. - const std::uint64_t start_us = audio_queue_buffer(state, state.sample_count); + const std::uint64_t start_us = + audio_queue_buffer(state, state.sample_count); if (buffer != 0u && vcs::audio_output_enabled()) { std::vector pcm(bytes / sizeof(std::int16_t)); for (std::size_t index = 0u; index < pcm.size(); ++index) { @@ -8255,11 +8506,19 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start << " channels=" << state.channel_count << " freq=" << state.frequency << " start_us=" << start_us << " wait_us=" << wait_us << "\n"; + const std::uint32_t success_value = + audio_resample_success_value(return_queued_samples, state.sample_count); (void)delay_current_thread(rt, ctx, static_cast(wait_us), - state.sample_count); + success_value); }; - runtime.register_hle("sceAudio", 0x2D53F36Eu, audio_src_output); - runtime.register_hle("sceAudio", 0xE0727056u, audio_src_output); + runtime.register_hle("sceAudio", 0x2D53F36Eu, + [audio_resample_output](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) { + audio_resample_output(rt, ctx, false); + }); + runtime.register_hle("sceAudio", 0xE0727056u, + [audio_resample_output](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) { + audio_resample_output(rt, ctx, true); + }); constexpr std::uint32_t kAtracErrorApiFail = 0x80630002u; @@ -8308,7 +8567,23 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start state.buffered_encoded_bytes = std::min(state.buffered_encoded_bytes, parsed.data_size); state.next_file_offset = std::min(read_size, parsed.file_size); state.write_offset = buffer_size == 0u ? 0u : read_size % buffer_size; - state.source_path = identify_atrac_source(header_bytes, parsed); + state.source_path = identify_atrac_source(buffer, header_bytes, parsed); + if (!state.source_path.empty()) { + const std::string source_name = state.source_path.filename().string(); + if (source_name.rfind("NEWS_", 0u) == 0u) { + std::ostringstream line; + line << "ATRAC_NEWS_META source=" << source_name + << " codec=" << (parsed.atrac3plus ? "at3plus" : "at3") + << " channels=" << parsed.channels + << " rate=" << parsed.sample_rate + << " block=" << parsed.block_align + << " total_samples=" << parsed.total_samples + << " initial_read=" << read_size + << " buffer=" << buffer_size + << " file=" << parsed.file_size; + runtime_log_line(line.str()); + } + } if (std::getenv("PSPRECOMP_ATRAC_DIAG") != nullptr) { std::cerr << "[atrac] set-halfway id=" << id << " buffer=" << psprecomp::hex32(buffer) @@ -8420,7 +8695,8 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start if (state->sample_position >= state->header.total_samples && !restart_for_loop()) { if (samples_addr != 0u) rt.memory().store32(samples_addr, 0u); if (finish_addr != 0u) rt.memory().store32(finish_addr, 1u); - if (remain_addr != 0u) rt.memory().store32(remain_addr, 0u); + if (remain_addr != 0u) + rt.memory().store32(remain_addr, atrac_remain_frame_status(*state)); set_success(ctx); return; } @@ -8441,12 +8717,24 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start const char *text = std::getenv("PSPRECOMP_AUDIO_SUMMARY"); return text != nullptr && *text != '\0' && std::strcmp(text, "0") != 0; }(); - const auto decode_started = audio_summary_enabled - ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{}; + const bool decoder_was_open = state->decoder.is_open(); + const auto decode_started = std::chrono::steady_clock::now(); std::size_t got = read_atrac_pcm(*state, pcm_span); if (got == 0u && restart_for_loop()) { got = read_atrac_pcm(*state, pcm_span); } + const std::uint64_t decode_elapsed_us = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now() - decode_started).count()); + if (!decoder_was_open || decode_elapsed_us >= 2000u) { + std::ostringstream line; + line << "ATRAC_DECODE source=" << state->source_path.filename().string() + << " open=" << (!decoder_was_open ? 1 : 0) + << " decode_us=" << decode_elapsed_us + << " bytes=" << got + << " sample=" << state->sample_position; + runtime_log_line(line.str()); + } if (audio_summary_enabled) { static std::uint64_t decode_calls = 0u; static std::uint64_t decode_total_ns = 0u; @@ -8468,14 +8756,15 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start got = static_cast(samples) * bytes_per_sample; if (output != 0u && got != 0u) rt.memory().copy_in(output, std::span(pcm.data(), got)); state->sample_position += samples; - if (state->buffered_encoded_bytes >= state->header.block_align) - state->buffered_encoded_bytes -= state->header.block_align; - else - state->buffered_encoded_bytes = 0u; + if (samples != 0u && state->header.block_align != 0u) { + if (state->buffered_encoded_bytes >= state->header.block_align) + state->buffered_encoded_bytes -= state->header.block_align; + else + state->buffered_encoded_bytes = 0u; + } const bool finished = samples == 0u || (state->sample_position >= state->header.total_samples && state->loop_num == 0); - const std::uint32_t remaining_frames = state->header.block_align == 0u ? 0u : - state->buffered_encoded_bytes / state->header.block_align; + const std::uint32_t remaining_frames = atrac_remain_frame_status(*state); if (samples_addr != 0u) rt.memory().store32(samples_addr, samples); if (finish_addr != 0u) rt.memory().store32(finish_addr, finished ? 1u : 0u); if (remain_addr != 0u) rt.memory().store32(remain_addr, remaining_frames); @@ -8500,9 +8789,7 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start auto *state = get_atrac(ctx.gpr[4]); if (!state) { ctx.set_gpr(2, kAtracErrorBadId); return; } if (!rt.memory().contains(ctx.gpr[5], 4u)) { ctx.set_gpr(2, kAtracErrorBadAddress); return; } - const std::uint32_t remaining = state->next_file_offset >= state->header.file_size ? 0xFFFFFFFFu : - state->buffered_encoded_bytes / state->header.block_align; - rt.memory().store32(ctx.gpr[5], remaining); + rt.memory().store32(ctx.gpr[5], atrac_remain_frame_status(*state)); set_success(ctx); }); @@ -8854,10 +9141,7 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start runtime.register_hle("sceSasCore", 0x68A46B95u, [](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { if (!sas_valid_core(ctx.gpr[4])) { ctx.set_gpr(2, kSasErrorNotInitialized); return; } - std::uint32_t flags = 0u; - for (std::size_t i = 0; i < sas_state.voices.size(); ++i) - if (!sas_state.voices[i].playing) flags |= 1u << i; - ctx.set_gpr(2, flags); + ctx.set_gpr(2, sas_state.end_flags); }); runtime.register_hle("sceSasCore", 0x74AE582Au, @@ -9905,6 +10189,7 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start if (stream) { const auto fd = file_table.next_fd++; file_table.files.emplace(fd, std::move(stream)); + file_table.file_paths.emplace(fd, disc_file->native_path); if (std::getenv("PSPRECOMP_IO_DIAG") != nullptr) { std::cerr << "[io] raw UMD open lbn=" << raw_lbn << " size=" << raw_size @@ -9956,6 +10241,7 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start } const auto fd = file_table.next_fd++; file_table.files.emplace(fd, std::move(stream)); + file_table.file_paths.emplace(fd, native); if (file_object_diag) { std::cerr << "[fileobj-hle] open-ok fd=" << fd << " path=\"" << path << "\" native=\"" << native.string() @@ -10078,6 +10364,7 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start const bool closed = file_table.files.erase(fd) == 1u || file_table.synthetic_empty_files.erase(fd) == 1u || file_table.virtual_disc_handles.erase(fd) == 1u; + file_table.file_paths.erase(fd); if (std::getenv("PSPRECOMP_FILE_OBJECT_DIAG") != nullptr) std::cerr << "[fileobj-hle] close fd=" << fd << " closed=" << closed << "\n"; ctx.set_gpr(2, closed ? 0u : 0x80010009u); @@ -10106,6 +10393,10 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start ctx.set_gpr(2, 0x80010009u); return; } + const std::uint64_t read_absolute = + virtual_handle->second.base_offset + virtual_handle->second.position; + const VirtualDiscFile *read_file = virtual_disc_file_at_offset(read_absolute); + file_table.recent_atrac_reads.erase(dst); const bool time_io = perf_timing_enabled(); const auto io_entry = time_io ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{}; @@ -10113,6 +10404,16 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start virtual_handle->second, std::span(guest_destination, static_cast(size))); if (time_io) io_host_time_this_vblank += std::chrono::steady_clock::now() - io_entry; + if (read != 0u && read_file != nullptr && is_atrac_source_path(read_file->native_path)) { + const std::uint64_t file_start = + static_cast(read_file->start_sector) * 2048u; + if (read_absolute >= file_start && + read_absolute + read <= file_start + read_file->size) { + if (file_table.recent_atrac_reads.size() >= 32u) + file_table.recent_atrac_reads.erase(file_table.recent_atrac_reads.begin()); + file_table.recent_atrac_reads[dst] = read_file->native_path; + } + } static const bool io_diag = std::getenv("PSPRECOMP_IO_DIAG") != nullptr; static const bool umd_stream_diag = std::getenv("PSPRECOMP_UMD_STREAM_DIAG") != nullptr; if (io_diag) @@ -10170,6 +10471,7 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start ctx.set_gpr(2, 0x80010009u); return; } + file_table.recent_atrac_reads.erase(dst); const bool time_io = perf_timing_enabled(); const auto io_entry = time_io ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{}; @@ -10177,6 +10479,14 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start static_cast(size)); const auto read = static_cast(it->second.gcount()); if (time_io) io_host_time_this_vblank += std::chrono::steady_clock::now() - io_entry; + if (read != 0u) { + if (const auto path_it = file_table.file_paths.find(fd); + path_it != file_table.file_paths.end() && is_atrac_source_path(path_it->second)) { + if (file_table.recent_atrac_reads.size() >= 32u) + file_table.recent_atrac_reads.erase(file_table.recent_atrac_reads.begin()); + file_table.recent_atrac_reads[dst] = path_it->second; + } + } ctx.set_gpr(2, static_cast(read)); }); } @@ -10443,6 +10753,46 @@ bool run_profile_self_tests(std::string &error) { virtual_time_us = previous_time; } + // Output2 and SRC share hardware but not their success ABI. VCS uses + // Output2; returning 512 instead of 0 escapes from its mixer loop and + // changes guest control flow even though the PCM buffer was accepted. + { + require(audio_resample_success_value(false, 512u) == 0u, + "sceAudioOutput2OutputBlocking success must be zero"); + require(audio_resample_success_value(true, 512u) == 512u, + "sceAudioSRCOutputBlocking must report queued samples"); + } + + // VCS branches directly on the negative remain-frame sentinels after + // sceAtracDecodeData. A fully-fed non-loop halfway stream (NEWS) must + // report -2; a fully-fed looping stream reports -3; only an incomplete + // stream may report a non-negative buffered frame count. + { + AtracContextState stream{}; + stream.header.file_size = 16'384u; + stream.header.block_align = 384u; + stream.header.loop_start = -1; + stream.header.loop_end = -1; + stream.buffered_encoded_bytes = 1'152u; + stream.next_file_offset = 8'192u; + require(atrac_remain_frame_status(stream) == 3u, + "partial ATRAC stream did not report buffered frame count"); + + stream.next_file_offset = stream.header.file_size; + require(atrac_remain_frame_status(stream) == kAtracRemainNonLoopOnMemory, + "fully-fed non-loop halfway stream must report -2"); + + stream.header.loop_start = 1024; + stream.header.loop_end = 8191; + stream.loop_num = -1; + require(atrac_remain_frame_status(stream) == kAtracRemainLoopOnMemory, + "fully-fed looping halfway stream must report -3"); + + stream.loop_num = 0; + require(atrac_remain_frame_status(stream) == kAtracRemainNonLoopOnMemory, + "disabled ATRAC loop must report the non-loop resident status"); + } + // A voice configured through __sceSasSetADSR alone -- rates only, no // call to __sceSasSetADSRmode -- must still retire when the game keys // it off. VCS does exactly this for the vehicle engine, and the old @@ -10493,6 +10843,81 @@ bool run_profile_self_tests(std::string &error) { "zero-rate KeyOff left a looping SAS voice alive forever"); } + // End flags are a post-Core snapshot. Setters may change a voice in the + // middle of a grain, but GetEndFlag must not expose that transition until + // the next completed mixer cycle refreshes the hardware-visible flags. + { + const SasState previous = sas_state; + sas_state = SasState{}; + auto &voice = sas_state.voices[0]; + voice.type = SasVoiceType::Vag; + voice.playing = true; + require((sas_state.end_flags & 1u) != 0u, + "SAS end flag changed before a Core refresh"); + sas_refresh_end_flags(); + require((sas_state.end_flags & 1u) == 0u, + "SAS Core refresh did not clear the playing voice end flag"); + voice.playing = false; + require((sas_state.end_flags & 1u) == 0u, + "SAS end flag was not latched between Core cycles"); + sas_refresh_end_flags(); + require((sas_state.end_flags & 1u) != 0u, + "SAS Core refresh did not publish the ended voice"); + sas_state = previous; + } + + // A VAG loop jump must restore the predictor state captured immediately + // before the loop-start block. Keeping the history from the loop-end block + // changes the waveform on every pass and can make a vehicle loop drift. + { + psprecomp::Runtime loop_runtime; + constexpr std::uint32_t vag = 0x08850000u; + std::array blocks{}; + blocks[0] = 0u; blocks[1] = 6u; // loop start + blocks[16] = 0u; blocks[17] = 3u; // loop end + loop_runtime.memory().copy_in(vag, blocks); + SasVoiceState voice{}; + voice.type = SasVoiceType::Vag; + voice.data_address = vag; + voice.data_size = 32; + voice.loop = true; + voice.history1 = 123; + voice.history2 = -45; + require(sas_decode_next_block(loop_runtime.memory(), voice), + "VAG loop-start block failed to decode"); + require(voice.loop_start_valid && voice.loop_start_history1 == 123 && + voice.loop_start_history2 == -45, + "VAG loop-start predictor state was not captured"); + require(sas_decode_next_block(loop_runtime.memory(), voice), + "VAG loop-end block failed to decode"); + require(voice.decode_offset == 0u && voice.history1 == 123 && voice.history2 == -45, + "VAG loop jump did not restore predictor history"); + } + + // Transactional savedata writes must never destroy the existing main file + // if a later auxiliary file cannot be staged. + { + const std::filesystem::path root = std::filesystem::temp_directory_path() / + ("vcsnative_savedata_tx_" + std::to_string(static_cast( + std::chrono::steady_clock::now().time_since_epoch().count()))); + const std::filesystem::path existing = root / "DATA.BIN"; + const std::filesystem::path blocker = root / "blocker"; + std::error_code error; + std::filesystem::create_directories(root, error); + require(!error, "could not create savedata transaction fixture"); + { std::ofstream out(existing, std::ios::binary); out.write("OLD", 3); } + { std::ofstream out(blocker, std::ios::binary); out.write("X", 1); } + std::vector writes; + writes.push_back(SavedataPendingWrite{existing, {'N','E','W'}}); + writes.push_back(SavedataPendingWrite{blocker / "AUX.DAT", {'B','A','D'}}); + require(!commit_savedata_writes(writes), + "savedata transaction accepted an impossible auxiliary target"); + std::ifstream in(existing, std::ios::binary); + std::string old((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + require(old == "OLD", "failed savedata staging destroyed the previous slot"); + std::filesystem::remove_all(root, error); + } + // A GE context supplied to sceGeListEnQueue is a real serialized PSP // context, not merely a command-memory snapshot. It must include matrix // DATA words and the global renderer state must be restored after END. @@ -11146,8 +11571,8 @@ bool run_profile_self_tests(std::string &error) { sas_context = {}; sas_context.set_gpr(4u, sas_core); wlan_runtime.invoke_import("sceSasCore", 0x68A46B95u, sas_context); - require((sas_context.gpr[2] & 1u) == 0u, - "active SAS voice was reported ended before mixing"); + require((sas_context.gpr[2] & 1u) != 0u, + "SAS end flag changed before the first Core refresh"); wlan_runtime.memory().zero(sas_output, 0x400u); sas_context = {}; diff --git a/profiles/vcs/host/vcs_runtime_log.cpp b/profiles/vcs/host/vcs_runtime_log.cpp index 33fe5a0..9975f53 100644 --- a/profiles/vcs/host/vcs_runtime_log.cpp +++ b/profiles/vcs/host/vcs_runtime_log.cpp @@ -65,22 +65,29 @@ void runtime_log_initialize(const VcsConfiguration &configuration) { return; } s.file << "VCSNative runtime log\n"; - s.file << "stage=perf-v8.1-cpu-lean-2026-08-17\n"; + s.file << "stage=correctness-v8.2.5-news-atrac-stream-fix-2026-08-18\n"; s.file << "config=" << configuration.source_path.string() << '\n'; s.file << "started=" << timestamp_now() << '\n'; s.file << "perf_telemetry=" << (configuration.diagnostics.perf_telemetry ? 1 : 0) << " interval_vblanks=" << configuration.diagnostics.perf_telemetry_interval_vblanks << "\n"; s.file << "guest_hotspot=" << (configuration.diagnostics.guest_hotspot_profile ? 1 : 0) - << " sample_stride=256 interval_vblanks=300\n"; + << " sample_stride=256 interval_vblanks=300" +#if defined(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY) + << " chain_telemetry_compiled=1\n"; +#else + << " chain_telemetry_compiled=0\n"; +#endif s.file << "tier2_superblocks=" << (tier2_superblocks_enabled() ? 1 : 0) << " version=4 clusters=7 mask=0x" << std::hex << tier2_cluster_mask() << std::dec << " hot_blocks=1060 static_fused_calls=36 static_fused_tail=13 hooks=25" - << " entity_leaf_inline_sites=13 geometry_inline_leaf_sites=0 direct_generated_leaf_sites=0" + << " entity_leaf_inline_sites=13 geometry_inline_leaf_sites=0 direct_generated_leaf_sites=5" << " unwind_fix=1 reentry_guard=1 dataflow=1 vfpu_block32=133 mem_runs=35 mem_words=287" << " append32=51 advance32=89 simd_mat4=4 simd_matvec=19" << " gpr_shadow_clusters=4 gpr_shadow_regs=24 gpr_shadow_occurrences=3461 geometry_shadow=0" - << " perf_layer=8 cpu_lean_revision=1 arch_fastmem=1 aot_direct_fastmem_default=1" + << " perf_layer=8 cpu_lean_revision=2 correctness_revision=825 recovery_from_821=1" + << " save_transaction=1 save_lifecycle_v82=1 sas_endflag_latched=1 sas_loop_history_restore=1" + << " atrac_virtual_source=1 atrac_stall_diag=1 atrac_stream_resident_status=1 atrac_nonloop_resident=-2 atrac_loop_resident=-3 output2_success_zero=1 output2_master_watermark=1 output2_late_catchup=0 runtime_chain_telemetry_default=0 arch_fastmem=1 aot_direct_fastmem_default=1" << " tier2_direct_fastmem=1 tier2_direct_mem_sites=1969 tier2_deep_telemetry_default=0 geometry_fusion_rollback=1" << " entity_leaf_inline=1 entity_leaf_scheduler_accounting=1 entity_leaf_resume_pc_fix=1" << " ge_async_default=0 parallel_vertex_decode_default=0" diff --git a/profiles/vcs/host/vcs_tier2_cluster_boundary.cpp b/profiles/vcs/host/vcs_tier2_cluster_boundary.cpp index 30dce64..7378032 100644 --- a/profiles/vcs/host/vcs_tier2_cluster_boundary.cpp +++ b/profiles/vcs/host/vcs_tier2_cluster_boundary.cpp @@ -395,14 +395,30 @@ SB_L_0895C04C: ctx.gpr[21] = (ctx.gpr[16] | 0u); ctx.gpr[31] = (0x0895C058u); tier2_gpr_4 = (ctx.gpr[21] | 0u); - if (([&]() { TIER2_GPR_SYNC_OUT(); const bool tier2_same_ = (rt.invoke_chained_direct<&recomp_unit_0206_entry, 206u, 498u, 0x08B3E084u>(ctx, &aot_mem)); if (tier2_same_) TIER2_GPR_SYNC_IN(); else tier2_gpr_shadow_valid = false; return tier2_same_; }()) && ctx.pc == 0x0895C058u) goto SB_L_0895C058; + TIER2_GPR_SYNC_OUT(); + psprecomp::recomp_unit_0206_entry(rt, ctx, 498u, aot_mem); + TIER2_GPR_SYNC_IN(); + if (!rt.account_inlined_generated_leaf(ctx)) { + tier2_gpr_shadow_valid = false; + TIER2_SB_RETURN(); + } + if (ctx.pc == 0x0895C058u) goto SB_L_0895C058; + tier2_gpr_shadow_valid = false; TIER2_SB_RETURN(); SB_L_0895C058: ctx.gpr[16] = (tier2_mem.aot_load32(ctx.gpr[2] + static_cast(0))); ctx.gpr[31] = (0x0895C064u); tier2_gpr_4 = (ctx.gpr[21] | 0u); - if (([&]() { TIER2_GPR_SYNC_OUT(); const bool tier2_same_ = (rt.invoke_chained_direct<&recomp_unit_0206_entry, 206u, 499u, 0x08B3E08Cu>(ctx, &aot_mem)); if (tier2_same_) TIER2_GPR_SYNC_IN(); else tier2_gpr_shadow_valid = false; return tier2_same_; }()) && ctx.pc == 0x0895C064u) goto SB_L_0895C064; + TIER2_GPR_SYNC_OUT(); + psprecomp::recomp_unit_0206_entry(rt, ctx, 499u, aot_mem); + TIER2_GPR_SYNC_IN(); + if (!rt.account_inlined_generated_leaf(ctx)) { + tier2_gpr_shadow_valid = false; + TIER2_SB_RETURN(); + } + if (ctx.pc == 0x0895C064u) goto SB_L_0895C064; + tier2_gpr_shadow_valid = false; TIER2_SB_RETURN(); SB_L_0895C064: @@ -895,7 +911,15 @@ SB_L_0895C694: ctx.gpr[16] = (tier2_mem.aot_load32(tier2_gpr_4 + static_cast(0))); ctx.gpr[31] = (0x0895C6B8u); tier2_gpr_4 = (ctx.gpr[16] | 0u); - if (([&]() { TIER2_GPR_SYNC_OUT(); const bool tier2_same_ = (rt.invoke_chained_direct<&recomp_unit_0206_entry, 206u, 499u, 0x08B3E08Cu>(ctx, &aot_mem)); if (tier2_same_) TIER2_GPR_SYNC_IN(); else tier2_gpr_shadow_valid = false; return tier2_same_; }()) && ctx.pc == 0x0895C6B8u) goto SB_L_0895C6B8; + TIER2_GPR_SYNC_OUT(); + psprecomp::recomp_unit_0206_entry(rt, ctx, 499u, aot_mem); + TIER2_GPR_SYNC_IN(); + if (!rt.account_inlined_generated_leaf(ctx)) { + tier2_gpr_shadow_valid = false; + TIER2_SB_RETURN(); + } + if (ctx.pc == 0x0895C6B8u) goto SB_L_0895C6B8; + tier2_gpr_shadow_valid = false; TIER2_SB_RETURN(); SB_L_0895C6B8: @@ -927,7 +951,15 @@ SB_L_0895C6D0: SB_L_0895C6DC: ctx.gpr[31] = (0x0895C6E4u); tier2_gpr_4 = (ctx.gpr[16] | 0u); - if (([&]() { TIER2_GPR_SYNC_OUT(); const bool tier2_same_ = (rt.invoke_chained_direct<&recomp_unit_0206_entry, 206u, 524u, 0x08B3E260u>(ctx, &aot_mem)); if (tier2_same_) TIER2_GPR_SYNC_IN(); else tier2_gpr_shadow_valid = false; return tier2_same_; }()) && ctx.pc == 0x0895C6E4u) goto SB_L_0895C6E4; + TIER2_GPR_SYNC_OUT(); + psprecomp::recomp_unit_0206_entry(rt, ctx, 524u, aot_mem); + TIER2_GPR_SYNC_IN(); + if (!rt.account_inlined_generated_leaf(ctx)) { + tier2_gpr_shadow_valid = false; + TIER2_SB_RETURN(); + } + if (ctx.pc == 0x0895C6E4u) goto SB_L_0895C6E4; + tier2_gpr_shadow_valid = false; TIER2_SB_RETURN(); SB_L_0895C6E4: @@ -960,7 +992,15 @@ SB_L_0895C6FC: SB_L_0895C708: ctx.gpr[31] = (0x0895C710u); tier2_gpr_4 = (ctx.gpr[16] | 0u); - if (([&]() { TIER2_GPR_SYNC_OUT(); const bool tier2_same_ = (rt.invoke_chained_direct<&recomp_unit_0206_entry, 206u, 523u, 0x08B3E254u>(ctx, &aot_mem)); if (tier2_same_) TIER2_GPR_SYNC_IN(); else tier2_gpr_shadow_valid = false; return tier2_same_; }()) && ctx.pc == 0x0895C710u) goto SB_L_0895C710; + TIER2_GPR_SYNC_OUT(); + psprecomp::recomp_unit_0206_entry(rt, ctx, 523u, aot_mem); + TIER2_GPR_SYNC_IN(); + if (!rt.account_inlined_generated_leaf(ctx)) { + tier2_gpr_shadow_valid = false; + TIER2_SB_RETURN(); + } + if (ctx.pc == 0x0895C710u) goto SB_L_0895C710; + tier2_gpr_shadow_valid = false; TIER2_SB_RETURN(); SB_L_0895C710: diff --git a/profiles/vcs/scripts/build_release.bat b/profiles/vcs/scripts/build_release.bat index ab7910b..26d7121 100644 --- a/profiles/vcs/scripts/build_release.bat +++ b/profiles/vcs/scripts/build_release.bat @@ -33,6 +33,7 @@ echo Build pipeline restored to the last known-good pre-reorganization behavior. echo CMake: !CMAKE_EXE! echo Compile workers: %JOBS% ^| AOT /MP%JOBS% ^| MSBuild /m:1 echo AOT inlining: /Ob3 hot measured units ^| /Ob0 cold units +echo Runtime chain telemetry: OFF ^(V8.2 performance path^) echo Link: host/core LTCG only ^| generated AOT /GL- ^| LTCG status visible echo Build dir preserved: %BUILD% echo ================================================================ @@ -49,6 +50,7 @@ echo [1/7] Configuring without deleting existing objects... -DPSPRECOMP_NATIVE_AVX2=ON ^ -DPSPRECOMP_AOT_ASSUME_NO_WRITE_WATCH=ON ^ -DPSPRECOMP_AOT_PRODUCTION_FASTPATHS=ON ^ + -DPSPRECOMP_RUNTIME_CHAIN_TELEMETRY=OFF ^ -DPSPRECOMP_MSVC_CGTHREADS=0 ^ -DPSPRECOMP_MSVC_MP_JOBS=%JOBS% ^ -DPSPRECOMP_PROFILE_GUIDED_AOT=ON ^ diff --git a/profiles/vcs/scripts/build_release_ninja.bat b/profiles/vcs/scripts/build_release_ninja.bat index 6d53774..b90f1c6 100644 --- a/profiles/vcs/scripts/build_release_ninja.bat +++ b/profiles/vcs/scripts/build_release_ninja.bat @@ -104,9 +104,17 @@ set "PERF_V6_ENTITY_LEAF_FIX1_STAMP=%BUILD%\.vcs_perf_v6_entity_leaf_inline_cras set "PERF_V7_ARCH_FASTMEM_STAMP=%BUILD%\.vcs_perf_v7_arch_fastmem_20260817" set "PERF_V8_CPU_FUSION_STAMP=%BUILD%\.vcs_perf_v8_cpu_fusion_20260817" set "PERF_V81_CPU_LEAN_STAMP=%BUILD%\.vcs_perf_v81_cpu_lean_20260817" +set "PERF_V82_CPU_RUNTIME_LEAN_STAMP=%BUILD%\.vcs_perf_v82_cpu_runtime_lean_20260817" +set "CORRECTNESS_V822_STAMP=%BUILD%\.vcs_correctness_v822_recovery_20260817" +set "CORRECTNESS_V823_NEWS_STAMP=%BUILD%\.vcs_correctness_v823_news_audio_20260817" +set "CORRECTNESS_V824_NEWS_PACING_STAMP=%BUILD%\.vcs_correctness_v824_news_pacing_20260818" +set "CORRECTNESS_V825_NEWS_ATRAC_STAMP=%BUILD%\.vcs_correctness_v825_news_atrac_stream_20260818" if not defined PSPRECOMP_TIER2_DEEP_TELEMETRY set "PSPRECOMP_TIER2_DEEP_TELEMETRY=OFF" +if not defined PSPRECOMP_RUNTIME_CHAIN_TELEMETRY set "PSPRECOMP_RUNTIME_CHAIN_TELEMETRY=OFF" if /I "%PSPRECOMP_TIER2_DEEP_TELEMETRY%"=="1" set "PSPRECOMP_TIER2_DEEP_TELEMETRY=ON" if /I "%PSPRECOMP_TIER2_DEEP_TELEMETRY%"=="0" set "PSPRECOMP_TIER2_DEEP_TELEMETRY=OFF" +if /I "%PSPRECOMP_RUNTIME_CHAIN_TELEMETRY%"=="1" set "PSPRECOMP_RUNTIME_CHAIN_TELEMETRY=ON" +if /I "%PSPRECOMP_RUNTIME_CHAIN_TELEMETRY%"=="0" set "PSPRECOMP_RUNTIME_CHAIN_TELEMETRY=OFF" echo ================================================================ echo VCS - NINJA PERFORMANCE INCREMENTAL BUILD @@ -118,17 +126,19 @@ echo CMake: %CMAKE_EXE% echo Ninja: %NINJA_EXE% echo Ninja workers: %JOBS% echo cl.exe /MP: OFF ^(Ninja owns compile parallelism^) -echo Generated AOT: O3, cold /Ob0, measured hot /Ob3; V8.1 CPU LEAN: V7-size Geometry + Tier2 direct-fastmem; deep Tier2 telemetry build-switch +echo Generated AOT: O3, cold /Ob0, measured hot /Ob3; V8.2 runtime-chain lean + V8.1 Geometry/direct-fastmem baseline +echo Correctness: V8.2.5 NEWS ATRAC stream state - resident sentinels + V8.2.3 Output2 base echo Host/core LTCG: ON echo AVX2/fast paths: ON echo Tier2 deep diag: %PSPRECOMP_TIER2_DEEP_TELEMETRY% +echo Chain telemetry: %PSPRECOMP_RUNTIME_CHAIN_TELEMETRY% echo ================================================================ echo. echo [0b/7] Reapplying BOOTFIX-safe Tier-2 transforms (OPT1 semantic transforms disabled)... call "%PROFILE%\APPLY_TIER2_EXTREME.bat" if errorlevel 1 goto :FAIL -echo [0b2/7] Building V8.1 CPU LEAN over V8/V7 stable architecture... +echo [0b2/7] Building V8.2 CPU RUNTIME LEAN over V8.1 stable architecture... set "PYTHON3_CMD=" py -3 -c "import sys; raise SystemExit(0 if sys.version_info.major == 3 else 1)" >nul 2>&1 if not errorlevel 1 set "PYTHON3_CMD=py -3" @@ -140,7 +150,10 @@ if not defined PYTHON3_CMD goto :NO_PYTHON3 echo Python 3: %PYTHON3_CMD% %PYTHON3_CMD% "%PROFILE%\tools\build_tier2_superblocks.py" "%PROFILE%" if errorlevel 1 goto :FAIL -%PYTHON3_CMD% "%PROFILE%\tests\check_v81_cpu_lean.py" +rem V8.2.5 checker includes the protected V8.2 CPU/Geometry invariants and the +rem V8.2.3 Output2 ABI/watermark invariants. Older revision checkers pin their +rem exact stage strings and therefore must not gate a newer correctness stage. +%PYTHON3_CMD% "%PROFILE%\tests\check_v825_news_atrac_stream.py" if errorlevel 1 goto :FAIL if exist "%BUILD%" if not exist "%SUPERBLOCK_STAMP%" ( @@ -276,6 +289,54 @@ if exist "%BUILD%" if not exist "%PERF_V81_CPU_LEAN_STAMP%" ( del /s /q "%BUILD%\*vcs_runtime_log*.obj" >nul 2>&1 ) +if exist "%BUILD%" if not exist "%PERF_V82_CPU_RUNTIME_LEAN_STAMP%" ( + echo. + echo [0c-v82runtime/7] V8.2 CPU RUNTIME LEAN - one-time native-chain rebuild... + rem runtime.hpp is inline in every generated AOT unit. Rebuild the corpus once so + rem compile-time-known chains lose per-call diagnostic branches in production. + rem Keep the build tree itself: Ninja reuses every unaffected dependency and cache. + del /s /q "%BUILD%\*generated_unit_*.obj" >nul 2>&1 + del /s /q "%BUILD%\*vcs_tier2_cluster*.obj" >nul 2>&1 + del /s /q "%BUILD%\*runtime*.obj" >nul 2>&1 + del /s /q "%BUILD%\*vcs_runtime_log*.obj" >nul 2>&1 +) + +if exist "%BUILD%" if not exist "%CORRECTNESS_V822_STAMP%" ( + echo. + echo [0c-v822fix/7] V8.2.2 CORRECTNESS RECOVERY - rebuilding VCS host correctness objects... + rem V8.2.2 is based on the stable V8.2 runtime. Replace any V8.2.1 host + rem objects but keep generated AOT, Tier2 and DX12 objects intact. + del /s /q "%BUILD%\*vcs_profile*.obj" >nul 2>&1 + del /s /q "%BUILD%\*vcs_runtime_log*.obj" >nul 2>&1 +) + +if exist "%BUILD%" if not exist "%CORRECTNESS_V823_NEWS_STAMP%" ( + echo. + echo [0c-v823news/7] V8.2.3 NEWS AUDIO - rebuilding audio/profile correctness objects... + rem Output2 ABI and host watermark only: keep generated AOT, Tier2 and DX12. + del /s /q "%BUILD%\*audio_output*.obj" >nul 2>&1 + del /s /q "%BUILD%\*vcs_profile*.obj" >nul 2>&1 + del /s /q "%BUILD%\*vcs_runtime_log*.obj" >nul 2>&1 +) + +if exist "%BUILD%" if not exist "%CORRECTNESS_V824_NEWS_PACING_STAMP%" ( + echo. + echo [0c-v824news/7] V8.2.4 NEWS PACING - rebuilding profile/runtime correctness objects... + rem Output2 guest-time pacing only. Keep generated AOT, Tier2, DX12 and the + rem V8.2.3 host watermark object intact. + del /s /q "%BUILD%\*vcs_profile*.obj" >nul 2>&1 + del /s /q "%BUILD%\*vcs_runtime_log*.obj" >nul 2>&1 +) + +if exist "%BUILD%" if not exist "%CORRECTNESS_V825_NEWS_ATRAC_STAMP%" ( + echo. + echo [0c-v825news/7] V8.2.5 NEWS ATRAC STREAM - rebuilding profile/runtime correctness objects... + rem ATRAC remain-frame semantics plus rollback of the rejected V8.2.4 + rem Output2 catch-up experiment. Keep AOT, Tier2, DX12 and host watermark. + del /s /q "%BUILD%\*vcs_profile*.obj" >nul 2>&1 + del /s /q "%BUILD%\*vcs_runtime_log*.obj" >nul 2>&1 +) + if exist "%BUILD%" if not exist "%BOOTFIX_STAMP%" ( echo. echo [0c/7] BOOTFIX revision changed - invalidating stale .obj/.pch once... @@ -294,6 +355,7 @@ echo [1/7] Configuring persistent Ninja Release tree... -DPSPRECOMP_NATIVE_AVX2=ON ^ -DPSPRECOMP_AOT_ASSUME_NO_WRITE_WATCH=ON ^ -DPSPRECOMP_AOT_PRODUCTION_FASTPATHS=ON ^ + -DPSPRECOMP_RUNTIME_CHAIN_TELEMETRY=%PSPRECOMP_RUNTIME_CHAIN_TELEMETRY% ^ -DPSPRECOMP_MSVC_CGTHREADS=0 ^ -DPSPRECOMP_MSVC_MP_JOBS=1 ^ -DPSPRECOMP_PROFILE_GUIDED_AOT=ON ^ @@ -323,6 +385,11 @@ if errorlevel 1 goto :FAIL >"%PERF_V7_ARCH_FASTMEM_STAMP%" echo VCS PERF V7 ARCH FASTMEM 2026-08-17 >"%PERF_V8_CPU_FUSION_STAMP%" echo VCS PERF V8 CPU FUSION 2026-08-17 >"%PERF_V81_CPU_LEAN_STAMP%" echo VCS PERF V8.1 CPU LEAN 2026-08-17 +>"%PERF_V82_CPU_RUNTIME_LEAN_STAMP%" echo VCS PERF V8.2 CPU RUNTIME LEAN 2026-08-17 +>"%CORRECTNESS_V822_STAMP%" echo VCS V8.2.2 CORRECTNESS RECOVERY 2026-08-17 +>"%CORRECTNESS_V823_NEWS_STAMP%" echo VCS V8.2.3 NEWS AUDIO FIX 2026-08-17 +>"%CORRECTNESS_V824_NEWS_PACING_STAMP%" echo VCS V8.2.4 NEWS PACING FIX 2026-08-18 +>"%CORRECTNESS_V825_NEWS_ATRAC_STAMP%" echo VCS V8.2.5 NEWS ATRAC STREAM FIX 2026-08-18 echo. echo [2b/7] Building tests and DX12 probes... diff --git a/profiles/vcs/tests/audio_resampler_tests.cpp b/profiles/vcs/tests/audio_resampler_tests.cpp index fc060b4..bcdcee0 100644 --- a/profiles/vcs/tests/audio_resampler_tests.cpp +++ b/profiles/vcs/tests/audio_resampler_tests.cpp @@ -1,4 +1,5 @@ #include "audio_resampler.hpp" +#include "audio_output.hpp" #include #include @@ -80,6 +81,25 @@ void test_unity_rate_exact() { } } + +void test_output2_master_watermark() { + // While Output2 is a live producer inside the device prebuffer window, the + // host must not commit frames beyond what the game has actually mixed. + require(vcs::audio_output_master_seal_frame(4096u, 3072u, true, 2560u, 3072u) == 2560u, + "Output2 watermark did not clamp speculative sealing"); + + // Never move a seal point backwards when the producer is already ahead. + require(vcs::audio_output_master_seal_frame(4096u, 2048u, true, 2560u, 3072u) == 2048u, + "Output2 watermark moved an already-safe seal point"); + + // A genuinely stalled/stopped Output2 producer must not freeze the other + // PSP channels forever once it is beyond the bounded prebuffer grace. + require(vcs::audio_output_master_seal_frame(8192u, 7168u, true, 2560u, 3072u) == 7168u, + "stale Output2 producer incorrectly blocked the host timeline"); + require(vcs::audio_output_master_seal_frame(4096u, 3072u, false, 0u, 3072u) == 3072u, + "inactive Output2 producer incorrectly changed the host timeline"); +} + void test_mono_duplication() { const auto input = make_signal(1024u, false); const auto output = run_chunked(input, 1024u, false, 32000u, {127u, 129u}); @@ -96,6 +116,7 @@ int main() { test_chunk_invariance(48000u, true); test_chunk_invariance(22050u, true); test_chunk_invariance(24000u, false); + test_output2_master_watermark(); test_mono_duplication(); std::cout << "audio_resampler_tests: PASS\n"; return 0; diff --git a/profiles/vcs/tests/check_v821_correctness_save_audio_news.py b/profiles/vcs/tests/check_v821_correctness_save_audio_news.py new file mode 100644 index 0000000..f024081 --- /dev/null +++ b/profiles/vcs/tests/check_v821_correctness_save_audio_news.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +profile = Path(__file__).resolve().parents[1] +host = profile / 'host' +source = (host / 'vcs_profile.cpp').read_text(encoding='utf-8') +log = (host / 'vcs_runtime_log.cpp').read_text(encoding='utf-8') +build = (profile / 'scripts' / 'build_release_ninja.bat').read_text(encoding='utf-8') + +checks = [ + ('stage', 'stage=correctness-v8.2.1-save-audio-news-2026-08-17' in log), + ('cpu baseline preserved', 'hot_blocks=1060 static_fused_calls=36' in log and 'cpu_lean_revision=2' in log), + ('save transaction', 'commit_savedata_writes(writes)' in source and '.vcsnative.tmp.' in source and '.vcsnative.bak.' in source), + ('save validates all buffers first', 'snapshot_savedata_auxiliary' in source and 'Snapshot every guest buffer before touching the existing slot' in source), + ('save shutdown lifecycle', 'SAVEDATA save-result acknowledged; utility retained until shutdown' in source), + ('shutdown still releases utility', 'savedata_utility.status = UtilityStatus::Finished;' in source and 'display_window_set_system_utility_mode(false);' in source), + ('SAS error name corrected', 'kSasErrorInvalidState = 0x80420016u' in source and 'kSasErrorVoicePaused' not in source), + ('paused KeyOff latch', 'bool sas_key_off_voice' in source and 'if (!sas_key_off_voice(*voice))' in source), + ('paused-loop regression test', 'paused vehicle loop resurrected after KeyOff' in source), + ('ATRAC direct buffer source', 'recent_atrac_reads' in source and 'direct_buffer_hit' in source), + ('ATRAC no common-path rescan', 'identify_atrac_source(buffer, header_bytes, parsed)' in source), + ('ATRAC runtime diagnostics', 'ATRAC_SOURCE resolve_us=' in source and 'ATRAC_DECODE source=' in source), + ('correctness build stamp', 'CORRECTNESS_V821_STAMP' in build), +] +failed = [name for name, ok in checks if not ok] +for name, ok in checks: + print(f"[{'PASS' if ok else 'FAIL'}] {name}") +if failed: + print(f"V8.2.1 correctness audit failed: {', '.join(failed)}", file=sys.stderr) + raise SystemExit(1) +print(f'V8.2.1 correctness audit PASS ({len(checks)}/{len(checks)})') diff --git a/profiles/vcs/tests/check_v822_correctness_recovery.py b/profiles/vcs/tests/check_v822_correctness_recovery.py new file mode 100644 index 0000000..31671f0 --- /dev/null +++ b/profiles/vcs/tests/check_v822_correctness_recovery.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys +root = Path(__file__).resolve().parents[3] +profile = root / 'profiles' / 'vcs' +host = profile / 'host' +source = (host / 'vcs_profile.cpp').read_text(encoding='utf-8') +log = (host / 'vcs_runtime_log.cpp').read_text(encoding='utf-8') + +def need(cond, msg): + if not cond: + print('FAIL:', msg) + raise SystemExit(1) + print('PASS:', msg) + +need('stage=correctness-v8.2.2-recovery-save-audio-news-2026-08-17' in log, 'V8.2.2 runtime stage') +need('recovery_from_821=1' in log and 'save_lifecycle_v82=1' in log, 'V8.2 lifecycle recovery metadata') +need('sas_endflag_latched=1' in log and 'sas_loop_history_restore=1' in log, 'SAS correctness metadata') +need('atrac_virtual_source=1' in log and 'atrac_stall_diag=1' in log, 'ATRAC source metadata') +need('std::uint32_t end_flags{0xFFFFFFFFu};' in source, 'SAS latched end flags stored') +need('ctx.set_gpr(2, sas_state.end_flags);' in source, 'GetEndFlag returns latched flags') +need(source.count('sas_refresh_end_flags();') >= 2, 'Core paths refresh end flags') +need('voice.history1 = voice.loop_start_history1;' in source and 'voice.history2 = voice.loop_start_history2;' in source, 'VAG loop predictor restored') +need('struct SavedataPendingWrite' in source and 'commit_savedata_writes(writes)' in source, 'transactional savedata commit present') +need('SAVEDATA_SAVE acknowledged status=QUIT' in source, 'save lifecycle keeps V8.2 QUIT behavior with diagnostics') +need('utility retained until shutdown' not in source, 'V8.2.1 speculative save lifecycle removed') +need('recent_atrac_reads[dst]' in source and 'virtual_disc_file_at_offset' in source, 'virtual UMD ATRAC producer mapping present') +need('identify_atrac_source(buffer, header_bytes, parsed)' in source, 'ATRAC setup consumes producer mapping') +need('if (voice->paused || !voice->on)' in source, 'V8.2 paused-KeyOff behavior restored') +need('sas_key_off_voice' not in source, 'V8.2.1 paused-KeyOff helper removed') +print('V8.2.2 correctness recovery audit PASS') diff --git a/profiles/vcs/tests/check_v823_news_audio.py b/profiles/vcs/tests/check_v823_news_audio.py new file mode 100644 index 0000000..fb0b17d --- /dev/null +++ b/profiles/vcs/tests/check_v823_news_audio.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +from pathlib import Path + +root = Path(__file__).resolve().parents[3] +profile = root / 'profiles' / 'vcs' +host = profile / 'host' +source = (host / 'vcs_profile.cpp').read_text(encoding='utf-8') +output = (host / 'audio_output.cpp').read_text(encoding='utf-8') +output_h = (host / 'audio_output.hpp').read_text(encoding='utf-8') +log = (host / 'vcs_runtime_log.cpp').read_text(encoding='utf-8') + +def need(cond, msg): + if not cond: + print('FAIL:', msg) + raise SystemExit(1) + print('PASS:', msg) + +# Preserve the V8.2.2 recovery invariants. +need(('stage=correctness-v8.2.3-news-audio-fix-2026-08-17' in log) or ('stage=correctness-v8.2.4-news-pacing-fix-2026-08-18' in log), 'V8.2.3+ runtime stage') +need('recovery_from_821=1' in log and 'save_lifecycle_v82=1' in log, 'V8.2 lifecycle recovery metadata') +need('sas_endflag_latched=1' in log and 'sas_loop_history_restore=1' in log, 'SAS correctness metadata') +need('atrac_virtual_source=1' in log and 'atrac_stall_diag=1' in log, 'ATRAC source metadata') +need('output2_success_zero=1' in log and 'output2_master_watermark=1' in log, 'Output2 NEWS fix metadata') +need('std::uint32_t end_flags{0xFFFFFFFFu};' in source, 'SAS latched end flags stored') +need('voice.history1 = voice.loop_start_history1;' in source and 'voice.history2 = voice.loop_start_history2;' in source, 'VAG loop predictor restored') +need('struct SavedataPendingWrite' in source and 'commit_savedata_writes(writes)' in source, 'transactional savedata commit preserved') +need('utility retained until shutdown' not in source, 'speculative V8.2.1 savedata lifecycle remains removed') +need('recent_atrac_reads[dst]' in source and 'virtual_disc_file_at_offset' in source, 'virtual UMD ATRAC mapping preserved') + +# V8.2.3 NEWS path. +need('audio_resample_success_value(return_queued_samples, state.sample_count)' in source, 'Output2/SRC success ABI split active') +need('audio_resample_output(rt, ctx, false);' in source, 'Output2 explicitly selects zero-success ABI') +need('audio_resample_output(rt, ctx, true);' in source, 'SRC retains queued-sample ABI') +need('sceAudioOutput2OutputBlocking success must be zero' in source, 'Output2 ABI regression self-test present') +need('audio_output_master_seal_frame' in output_h, 'Output2 watermark helper present') +need('kOutput2Channel = 8u' in output, 'Output2 master channel is explicit') +need('sealed_frame = audio_output_master_seal_frame(' in output, 'host timeline uses Output2 producer watermark') +need('output2_seal_clamps' in output and 'output2_clamps=' in output, 'watermark diagnostics present') +need('producer_grace' in output and 'prebuffer_blocks' in output, 'watermark is bounded by device prebuffer') +print('V8.2.3 NEWS audio audit PASS') diff --git a/profiles/vcs/tests/check_v824_news_pacing.py b/profiles/vcs/tests/check_v824_news_pacing.py new file mode 100644 index 0000000..b6456ca --- /dev/null +++ b/profiles/vcs/tests/check_v824_news_pacing.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +from pathlib import Path + +root = Path(__file__).resolve().parents[3] +profile = root / 'profiles' / 'vcs' +host = profile / 'host' +source = (host / 'vcs_profile.cpp').read_text(encoding='utf-8') +output = (host / 'audio_output.cpp').read_text(encoding='utf-8') +output_h = (host / 'audio_output.hpp').read_text(encoding='utf-8') +log = (host / 'vcs_runtime_log.cpp').read_text(encoding='utf-8') + + +def need(cond, msg): + if not cond: + print('FAIL:', msg) + raise SystemExit(1) + print('PASS:', msg) + +# Stable V8.2.2/V8.2.3 correctness base must remain intact. +need('stage=correctness-v8.2.4-news-pacing-fix-2026-08-18' in log, 'V8.2.4 runtime stage') +need('correctness_revision=824' in log, 'V8.2.4 correctness revision') +need('recovery_from_821=1' in log and 'save_lifecycle_v82=1' in log, 'V8.2.2 lifecycle recovery preserved') +need('sas_endflag_latched=1' in log and 'sas_loop_history_restore=1' in log, 'SAS correctness preserved') +need('atrac_virtual_source=1' in log and 'atrac_stall_diag=1' in log, 'ATRAC source path preserved') +need('output2_success_zero=1' in log and 'output2_master_watermark=1' in log, 'V8.2.3 Output2 fixes preserved') +need('output2_late_catchup=1' in log and 'output2_late_grace_buffers=8' in log, 'V8.2.4 pacing metadata') + +# New pacing fix: only Output2 opts into short-lateness absorption. +need('bool absorb_short_lateness = false' in source, 'short-lateness policy is opt-in') +need('audio_queue_buffer(state, state.sample_count, true);' in source, 'Output2 opts into late catch-up') +need('audio_queue_buffer(state, state.sample_count);' in source, 'normal audio channels keep legacy queue policy') +need('late_grace_frames' in source and 'frames) * 8u' in source and '4096u' in source, 'bounded eight-buffer late grace') +need('OUTPUT2_PACING absorbed=' in source and 'OUTPUT2_PACING reanchor=1' in source, 'pacing diagnostics present') +need('short Output2 lateness was converted into a silence gap' in source, 'single-late-block regression self-test') +need('repeated short Output2 lateness stretched the audio timeline' in source, 'repeated-lateness regression self-test') +need('long Output2 stall incorrectly remained on the old timeline' in source, 'long-stall reanchor self-test') + +# Do not regress prior audio correctness or host watermark behavior. +need('audio_resample_output(rt, ctx, false);' in source, 'Output2 zero-success ABI still selected') +need('sceAudioOutput2OutputBlocking success must be zero' in source, 'Output2 success ABI test preserved') +need('audio_output_master_seal_frame' in output_h, 'Output2 watermark helper preserved') +need('sealed_frame = audio_output_master_seal_frame(' in output, 'host watermark remains active') +need('producer_grace' in output and 'prebuffer_blocks' in output, 'host watermark remains bounded') + +# Critical non-audio architecture must not be touched by this fix. +need('geometry_fusion_rollback=1' in log and 'hot_blocks=1060' in log, 'V8.2 CPU/Geometry baseline preserved') +need('ge_async_quarantined=1' in log and 'parallel_vertex_decode_quarantined=1' in log, 'unstable GE paths remain quarantined') +print('V8.2.4 NEWS pacing audit PASS') diff --git a/profiles/vcs/tests/check_v825_news_atrac_stream.py b/profiles/vcs/tests/check_v825_news_atrac_stream.py new file mode 100644 index 0000000..b783027 --- /dev/null +++ b/profiles/vcs/tests/check_v825_news_atrac_stream.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +from pathlib import Path + +root = Path(__file__).resolve().parents[3] +profile = root / 'profiles' / 'vcs' +host = profile / 'host' +source = (host / 'vcs_profile.cpp').read_text(encoding='utf-8') +output = (host / 'audio_output.cpp').read_text(encoding='utf-8') +log = (host / 'vcs_runtime_log.cpp').read_text(encoding='utf-8') +guest = (profile / 'generated' / 'generated_unit_0170.cpp').read_text(encoding='utf-8') + +def need(cond, msg): + if not cond: + print('FAIL:', msg) + raise SystemExit(1) + print('PASS:', msg) + +need('stage=correctness-v8.2.5-news-atrac-stream-fix-2026-08-18' in log, 'V8.2.5 runtime stage') +need('correctness_revision=825' in log, 'V8.2.5 correctness revision') +need('recovery_from_821=1' in log and 'save_lifecycle_v82=1' in log, 'interior/save lifecycle recovery preserved') +need('output2_success_zero=1' in log and 'output2_master_watermark=1' in log, 'V8.2.3 Output2 correctness preserved') +need('output2_late_catchup=0' in log, 'rejected V8.2.4 pacing experiment rolled back') +need('atrac_stream_resident_status=1' in log and 'atrac_nonloop_resident=-2' in log and 'atrac_loop_resident=-3' in log, 'ATRAC resident-state metadata') + +need('kAtracRemainNonLoopOnMemory = 0xFFFFFFFEu' in source, 'non-loop resident sentinel is -2') +need('kAtracRemainLoopOnMemory = 0xFFFFFFFDu' in source, 'loop resident sentinel is -3') +need('atrac_remain_frame_status(*state)' in source, 'Decode/GetRemain share resident-state helper') +need(source.count('atrac_remain_frame_status(*state)') >= 3, 'resident status used by EOF decode, normal decode, and GetRemainFrame') +need('if (samples != 0u && state->header.block_align != 0u)' in source, 'encoded frame is consumed only when PCM was produced') +need('ATRAC_NEWS_META source=' in source, 'NEWS stream metadata diagnostic present') +need('fully-fed non-loop halfway stream must report -2' in source, 'non-loop resident regression test') +need('fully-fed looping halfway stream must report -3' in source, 'loop resident regression test') + +# The game itself branches on -1 and -2 immediately after DecodeData's +# outRemainFrame. This audit makes the contract explicit so future HLE cleanup +# cannot silently replace the sentinel with zero again. +need('static_cast(-1)' in guest and 'static_cast(-2)' in guest, + 'VCS guest explicitly recognizes -1/-2 ATRAC remain values') + +need('bool absorb_short_lateness = false' not in source, 'V8.2.4 catch-up implementation removed') +need('OUTPUT2_PACING absorbed=' not in source, 'V8.2.4 pacing diagnostic removed') +need('audio_queue_buffer(state, state.sample_count, true)' not in source, 'Output2 no longer opts into rejected catch-up') +need('audio_queue_buffer(state, state.sample_count);' in source, 'Output2 uses stable queue policy') + +need('audio_output_master_seal_frame' in output, 'host Output2 watermark preserved') +need('geometry_fusion_rollback=1' in log and 'hot_blocks=1060' in log, 'V8.2 CPU/Geometry baseline preserved') +need('ge_async_quarantined=1' in log and 'parallel_vertex_decode_quarantined=1' in log, 'unstable GE paths remain quarantined') +print('V8.2.5 NEWS ATRAC stream audit PASS') diff --git a/profiles/vcs/tests/check_v82_cpu_runtime_lean.py b/profiles/vcs/tests/check_v82_cpu_runtime_lean.py new file mode 100644 index 0000000..b7bf7fd --- /dev/null +++ b/profiles/vcs/tests/check_v82_cpu_runtime_lean.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +PROFILE = ROOT / 'profiles' / 'vcs' + +def require(cond, msg): + if not cond: + raise SystemExit('V8.2 CPU RUNTIME LEAN CHECK FAILED: ' + msg) + +runtime_h = (ROOT / 'include' / 'psprecomp' / 'runtime.hpp').read_text() +runtime_cpp = (ROOT / 'src' / 'runtime.cpp').read_text() +cmake = (ROOT / 'CMakeLists.txt').read_text() +log = (PROFILE / 'host' / 'vcs_runtime_log.cpp').read_text() +boundary = (PROFILE / 'host' / 'vcs_tier2_cluster_boundary.cpp').read_text() +geometry = (PROFILE / 'host' / 'vcs_tier2_cluster_geometry.cpp').read_text() +world = (PROFILE / 'host' / 'vcs_tier2_cluster_world.cpp').read_text() + +require('PSPRECOMP_RUNTIME_CHAIN_TELEMETRY' in cmake, 'missing generic chain telemetry build option') +require(runtime_h.count('#if defined(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY)') >= 3, + 'direct/Tier2 chain telemetry is not compiled behind the build switch') +require(runtime_cpp.count('#if defined(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY)') >= 4, + 'dynamic chain telemetry is not compiled behind the build switch') +require('const std::uint64_t starvation_interval = g_runtime_starvation_interval_fast;' in runtime_h, + 'scheduler interval load was removed from inlined-leaf accounting') +require('if (++dispatches_since_import_ < starvation_interval) return true;' in runtime_h, + 'scheduler dispatch cadence changed') +require('return run_starvation_boundary(ctx);' in runtime_h, + 'scheduler safe-point call missing') +require(any(stage in log for stage in ('stage=perf-v8.2-cpu-runtime-lean-2026-08-17', 'stage=correctness-v8.2.2-recovery-save-audio-news-2026-08-17', 'stage=correctness-v8.2.3-news-audio-fix-2026-08-17', 'stage=correctness-v8.2.4-news-pacing-fix-2026-08-18')), 'runtime stage mismatch') +require('runtime_chain_telemetry_default=0' in log, 'runtime telemetry metadata missing') +require('direct_generated_leaf_sites=5' in log, 'Boundary direct-leaf metadata mismatch') +require(boundary.count('account_inlined_generated_leaf(ctx)') == 5, + 'expected exactly five Boundary direct generated leaf call sites') +require(geometry.count('account_inlined_generated_leaf(ctx)') == 0, + 'Geometry must remain V8.1 CPU LEAN shape') +require(world.count('account_inlined_generated_leaf(ctx)') == 0, + 'World direct-leaf experiment must remain disabled') +require(geometry.count('SB_L_') > 300 and world.count('SB_L_') > 300, + 'Tier2 hot cluster bodies unexpectedly missing') +print('V8.2 CPU RUNTIME LEAN CHECK PASS: chain telemetry compiled out by default; Boundary leaves=5; Geometry/World protected') diff --git a/profiles/vcs/tools/build_tier2_superblocks.py b/profiles/vcs/tools/build_tier2_superblocks.py index 0fe3b79..a1edd7a 100644 --- a/profiles/vcs/tools/build_tier2_superblocks.py +++ b/profiles/vcs/tools/build_tier2_superblocks.py @@ -93,7 +93,18 @@ GEOMETRY_INLINE_LEAF_BODIES = {} # through $ra. Tier-2 may call these entry points directly and account the removed # generated-call scheduler edge without paying invoke_chained_direct's profiling, # chain-depth and fallback plumbing. Large/indirect helpers are deliberately absent. -DIRECT_GENERATED_LEAF_TARGETS = set() +# V8.2 keeps Geometry/World at the V8.1 shape and only bypasses the generic +# chain wrapper for four tiny Boundary helpers in unit 0206. All four are +# statically validated leaves (no nested generated/HLE/syscall edge, $ra-only +# return). This is intentionally a five-call-site change, not another CFG +# expansion: it preserves the V8.1 instruction-cache footprint and scheduler +# cadence while removing wrapper work around trivial generated helpers. +DIRECT_GENERATED_LEAF_TARGETS = { + (206, 0x08B3E084), + (206, 0x08B3E08C), + (206, 0x08B3E254), + (206, 0x08B3E260), +} diff --git a/src/runtime.cpp b/src/runtime.cpp index c030537..90ce063 100644 --- a/src/runtime.cpp +++ b/src/runtime.cpp @@ -244,7 +244,11 @@ Runtime::Runtime(std::uint32_t ram_size) : memory_(ram_size) { // in the middle of guest execution; larger profiles can still grow it. import_bindings_.resize(256u, nullptr); hle_histogram_enabled_ = std::getenv("PSPRECOMP_HLE_HISTOGRAM") != nullptr; +#if defined(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY) g_unit_profile_enabled = std::getenv("PSPRECOMP_UNIT_PROFILE") != nullptr; +#else + g_unit_profile_enabled = false; +#endif #if defined(PSPRECOMP_AOT_PRODUCTION_FASTPATHS) track_dispatch_counters_ = false; #else @@ -359,7 +363,10 @@ bool Runtime::invoke_chained_call(AllegrexContext &ctx, GuestMemory::AotFastView if (function == nullptr) return false; } +#if defined(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY) || !defined(PSPRECOMP_AOT_PRODUCTION_FASTPATHS) const std::uint32_t target_pc = ctx.pc; +#endif +#if defined(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY) std::uint32_t guest_hotspot_unit = static_cast(kUnitProfileCapacity); bool guest_hotspot_sample = false; std::uint64_t guest_hotspot_start_ns = 0u; @@ -379,7 +386,10 @@ bool Runtime::invoke_chained_call(AllegrexContext &ctx, GuestMemory::AotFastView } } } +#endif +#if !defined(PSPRECOMP_AOT_PRODUCTION_FASTPATHS) const std::uint32_t native_depth = chain_depth_; +#endif // Always guard execution-context ownership. Even a clean generated unit can // reach a nested direct chain whose scheduler boundary switches PSP thread. // guarded every native chain frame; accidentally weakened @@ -402,12 +412,14 @@ bool Runtime::invoke_chained_call(AllegrexContext &ctx, GuestMemory::AotFastView } else { function(*this, ctx); } +#if defined(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY) if (guest_hotspot_sample) { const std::uint64_t end_ns = guest_hotspot_clock_ns(); guest_hotspot_record_sample(guest_hotspot_unit, target_pc, end_ns >= guest_hotspot_start_ns ? end_ns - guest_hotspot_start_ns : 0u); } +#endif #if !defined(PSPRECOMP_AOT_PRODUCTION_FASTPATHS) if (g_post_chained_call_hook != nullptr) g_post_chained_call_hook(*this, ctx, target_pc, native_depth); @@ -434,9 +446,14 @@ bool Runtime::invoke_chained_unit(AllegrexContext &ctx, std::uint32_t unit_index if (unit_index >= kGeneratedUnitFastCapacity || !generated_unit_layout_valid_) return false; RecompiledFunction function = generated_units_[unit_index]; if (function == nullptr) return false; +#if defined(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY) if (g_unit_profile_enabled) ++g_unit_profile_counts[unit_index]; +#endif +#if defined(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY) || !defined(PSPRECOMP_AOT_PRODUCTION_FASTPATHS) const std::uint32_t target_pc = ctx.pc; +#endif +#if defined(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY) bool guest_hotspot_sample = false; std::uint64_t guest_hotspot_start_ns = 0u; if (g_guest_hotspot_profile_enabled) { @@ -446,7 +463,10 @@ bool Runtime::invoke_chained_unit(AllegrexContext &ctx, std::uint32_t unit_index (ticket & static_cast(g_guest_hotspot_sample_mask)) == 0u; if (guest_hotspot_sample) guest_hotspot_start_ns = guest_hotspot_clock_ns(); } +#endif +#if !defined(PSPRECOMP_AOT_PRODUCTION_FASTPATHS) const std::uint32_t native_depth = chain_depth_; +#endif const std::uint64_t caller_generation = g_runtime_thread_switch_generation_fast; #if !defined(PSPRECOMP_AOT_PRODUCTION_FASTPATHS) if (g_pre_chained_call_hook != nullptr) @@ -462,12 +482,14 @@ bool Runtime::invoke_chained_unit(AllegrexContext &ctx, std::uint32_t unit_index } else { function(*this, ctx); } +#if defined(PSPRECOMP_RUNTIME_CHAIN_TELEMETRY) if (guest_hotspot_sample) { const std::uint64_t end_ns = guest_hotspot_clock_ns(); guest_hotspot_record_sample(unit_index, target_pc, end_ns >= guest_hotspot_start_ns ? end_ns - guest_hotspot_start_ns : 0u); } +#endif #if !defined(PSPRECOMP_AOT_PRODUCTION_FASTPATHS) if (g_post_chained_call_hook != nullptr) g_post_chained_call_hook(*this, ctx, target_pc, native_depth);