From 652be7ba020caaa5b54dbcf75ff638b6dae976ad Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Thu, 6 Aug 2026 00:52:14 -0400 Subject: [PATCH] rollback FMV patches and overlay --- docs/LOCAL_CODEGEN_SDK.md | 4 + docs/ROLLBACK_MOTK_HOOKUP.md | 188 ++++++++-- host/psxrecomp_codegen_host.c | 224 +++++++++--- lib/recomp-net | 2 +- recompiler/include/code_generator.h | 5 + recompiler/src/code_generator.cpp | 13 +- recompiler/src/config_loader.cpp | 25 ++ recompiler/src/config_loader.h | 6 + recompiler/src/main_psx.cpp | 6 + runtime/include/boot_state.h | 8 + runtime/include/psx_cyc.h | 40 +++ runtime/include/psx_cycles.h | 23 ++ runtime/include/psx_netplay_rb.h | 19 +- runtime/include/savestate.h | 4 + runtime/runtime.cmake | 2 + runtime/src/boot_state.c | 183 +++++++++- runtime/src/gpu.c | 4 + runtime/src/gpu_gl_renderer.c | 98 +++++- runtime/src/gpu_sw_renderer.c | 12 + runtime/src/gpu_vk_renderer.c | 125 ++++++- runtime/src/main.cpp | 79 ++++- runtime/src/netplay_snap_ring.c | 51 ++- runtime/src/psx_cycles.c | 9 +- runtime/src/psx_netplay.c | 127 ++++++- runtime/src/psx_netplay_rb.c | 521 ++++++++++++++++++++++++---- runtime/src/psx_netplay_sched.c | 30 +- runtime/src/savestate.c | 7 + runtime/src/traps.c | 3 + runtime/tests/test_psx_cyc_batch.c | 19 + 29 files changed, 1617 insertions(+), 220 deletions(-) diff --git a/docs/LOCAL_CODEGEN_SDK.md b/docs/LOCAL_CODEGEN_SDK.md index be06d12c..0d5c798d 100644 --- a/docs/LOCAL_CODEGEN_SDK.md +++ b/docs/LOCAL_CODEGEN_SDK.md @@ -147,6 +147,10 @@ host present paths in the profile. | `PSXRECOMP_PROJECT_ROOT` / game-specific | project root | | `PSXRECOMP_BUILD_DIR` / game-specific | cmake build dir | | `PSXRECOMP_FORCE_SETUP` / game-specific | force setup wizard | + +Project-root discovery order: env → walk **cwd** → walk **exe dir** +(`$APPIMAGE` parent or `/proc/self/exe` / `GetModuleFileName`). GUI launches +that leave cwd as `$HOME` still find a setup zip next to the binary. | `PSXRECOMP_GAME` | path to `psxrecomp-game` binary | | `PSX_HOST_MUTE=1` | discard host SDL audio (SPU still runs; set by PGO train) | | `PSX_HEADLESS=1` | no SDL window (set by PGO train when `hide_video`) | diff --git a/docs/ROLLBACK_MOTK_HOOKUP.md b/docs/ROLLBACK_MOTK_HOOKUP.md index 6121a455..c336ab6e 100644 --- a/docs/ROLLBACK_MOTK_HOOKUP.md +++ b/docs/ROLLBACK_MOTK_HOOKUP.md @@ -24,7 +24,7 @@ episode. Menu releases rewind (FMV soft-promote release-only only). Host | Keep delay-sync `RNetSession` working behind a flag | Lobby / ICE / save-xfer stay useful; Disable Rollback opts out | | Predicted rows promote only via `hash_confirm` (or host protect) | Library invariant; NULL `hash_confirm_promote` = always rewind | | Digests must be bit-identical across peers for the same sealed inputs | Otherwise every invent becomes an episode storm | -| **Same bit-identical binary on both peers** | Mixed `build-release` vs packaged `motk-*` can match live digests for hundreds of ticks then fork GPRs/timers in Replay (pin zlib skew is the symptom) | +| **Same game version + guest semantics** | Prefer matching commits/deps. **Per-peer PGO is OK** (Settings → Optimize FMV Playback). Mixed trees/zlib still fork — size alone is not proof of match | | Snapshots must restore at the exact sim tick requested | Ring load is the only rewind mechanism | | Single-thread ownership of session + rings | Same as delay-sync | @@ -914,30 +914,22 @@ Adaptive mid-match delay bumps are always on (no lobby disable). comparison value never compared across differently-built peers, and both peers already have to run the identical binary. - **Tier 2 — dirty-region digest (planned, not started).** Convert - "CRC the whole buffer" into "CRC only what changed since the last - digest." **VRAM is the tractable target**: audited every write to - the 1 MiB `vram[]` array in `gpu.c` and found only **3 call sites** - in the whole GPU emulation (CPU→VRAM transfer ~L2071, pixel - plot/fill/line/poly ~L2930, depth24/16bpp write ~L4744) — a small, - fully auditable surface where each site could mark a per-scanline - or per-tile "touched" bitmap, and `netplay_av_digest()` would CRC - only touched regions since the last checkpoint. This is also the - cheaper alternative flagged above as the unblocker for "one-sided - episodes" (exact per-tick VRAM state without a permanent per-frame - CRC tax). **RAM is not a good near-term target the same way**: guest - stores happen from essentially every recompiled `sw`/`sh`/`sb` site - across the whole program — thousands of call sites, not 3 — so - reliable RAM dirty-tracking means threading write-tracking through - the recompiler/interpreter's store path itself, a much larger - change. (Note: `dirty_ram_interp.h`'s existing per-page dirty - bitmap is an unrelated subsystem — self-modifying-code detection for - the static recompiler, CLAUDE.md Rule 18 — not a digest optimization - and not reusable for this without its own risk analysis.) **Risk to - respect:** a missed write site doesn't crash, it silently produces a - digest that doesn't reflect true state — masking a real divergence - or manufacturing a phantom one. Should ship with a "shadow-verify - against a full recompute" mode during bring-up, not on faith. + **Tier 2 — dirty-region digest (tracking landed for snaps §96; + digest fold still planned).** Convert "CRC the whole buffer" into + "CRC only what changed since the last digest." **VRAM tracking is + in:** `gpu_vram_dirty.*` marks per-scanline dirtiness from + `gpu.c` (raster_pixel / GP0 A0) and `gpu_sw_renderer.c` (put_*/ + fill/copy/transfer). Raw ring snaps already consume this (§96). + Next: `netplay_av_digest()` CRC only touched rows since the last + checkpoint (unblocker for one-sided episodes). **RAM is not a good + near-term target the same way**: guest stores happen from essentially + every recompiled `sw`/`sh`/`sb` site — thousands of call sites, not a + handful — so reliable RAM dirty-tracking means threading write-tracking + through the recompiler/interpreter store path. (Note: + `dirty_ram_interp.h`'s per-page bitmap is SMC detection — CLAUDE.md + Rule 18 — not a digest optimization.) **Risk:** a missed write site + silently produces a wrong digest. Shadow-verify: + `PSX_NET_VRAM_DIRTY_VERIFY=1`. **Tier 3 — Merkle/hash-tree digest (planned, longer-horizon idea).** Replace the flat CRC with fixed-size blocks (e.g. 4 KiB) each hashed, @@ -979,8 +971,8 @@ Adaptive mid-match delay bumps are always on (no lobby disable). 4. ~~Episode resim wiring~~ 5. ~~Lobby flag + UI~~ 6. ~~Rate-limit live snaps (`PSX_NET_SNAP_INTERVAL`) + safe episode resume~~ -7. ~~Thinner snap / FMV policy~~ — interval **16**, settle invent ok, digest - fold (skip-VRAM on live snaps unsafe for rewind; optional later) +7. ~~Thinner snap / FMV policy~~ — interval **16**, settle invent ok; §96 + dirty-VRAM mirror + FMV media snap interval 4 (skip-VRAM still unsafe) 8. **Dual-instance soak** — prove Done-when items --- @@ -4438,7 +4430,8 @@ FMV savestate completeness is not the bug — cross-peer determinism / policy is raises fork_cap so the doomed load is not reused. 3. **MAX unmatched RELEASE:** if lockstep hits MAX with `streak < CONFIRM`, arm `g_fmv_unmatched_desync` + storm cooldown — invent/begin stay held - until cores rematch CONFIRM ticks (or session reset). Log + until cores rematch CONFIRM ticks, invent-hold expires (~600 ticks / + §94), netplay SAVE completes (§94), or session reset. Log `rb DESYNC — FMV lockstep MAX unmatched`. **Re-soak watch:** @@ -4454,3 +4447,142 @@ FMV savestate completeness is not the bug — cross-peer determinism / policy is **Still open (P1/P2):** mid-session host state-transfer on storm; CD/MDEC cycle determinism for matched-baseline sim 184. + +## 94. Netplay SAVE under FMV DESYNC invent-hold — tip-hole deadlock (2026-08-05) + +**Soak (rb-diag1):** after FMV `DESYNC — MAX unmatched` at sim=1044 (streak=0, +never rematched), fight ran wire-only (~60 fps, lead≈-1). Host Shift+F2 SAVE +at sim=2598 completed (`hashes match, skip transfer`), then admit froze at +sim=2608 `stall=fmv_settle lead=-1` for 20s → `admit stall timeout` → lobby. +Guest tip stuck at 2607. Save itself was fine; resume needed gap1 invent and +could not get it. + +**Cause:** `g_fmv_unmatched_desync` keeps `lockstep_no_invent` true forever when +soft-fork digests never hit CONFIRM. Scheduler mis-tagged that stall as +`fmv_settle` (media inactive). SAVE coord freezes both peers → tip hole → +neither invents → mutual wait → watchdog. + +**Fixes (§94):** + +1. **Stall tag:** `fmv_desync_hold` when DESYNC invent-hold is the gate + (`psx_netplay_rb_fmv_desync_hold`); keep `fmv_media` / `fmv_settle`. +2. **Invent-hold expire:** `RB_FMV_DESYNC_INVENT_EXPIRE` (600 ticks) after arm — + clear invent-hold if rematch never comes. Log + `FMV DESYNC invent-hold cleared (invent-hold expired…)`. +3. **Save complete clear:** host/guest after SAVE hash-match or transfer done + call `psx_netplay_rb_clear_fmv_desync_hold("netplay save complete")` so tip + invent can refill immediately. + +**Re-soak watch:** SAVE mid-fight after post-FMV DESYNC does not stall-timeout; +expect invent-hold cleared (expire or save) and gap1 invent resumes. Stall +lines should say `fmv_desync_hold` not `fmv_settle` while the hold is live. + +## 95. Netplay LOAD — host hung `wait_confirm`, guest-only apply (2026-08-05) + +**Soak (rb-diag1/2 session 19):** SAVE transfer OK. Host Shift+F1 LOAD hash-match +→ guest `LOADED` + `waiting for host`; host stuck +`load_applying+wait_confirm` ~14s (never `LOADED`) → peer disconnect / desync +(guest on .pst, host still live timeline). + +**Cause:** `LOAD_APPLYING` used delay-sync `rnet_session_try_admit` (INPUT_CONFIRM). +Rollback live does not confirm. Faster peer applies → `LOAD_READY` freezes admit +→ slower peer never gets confirm for the next tick → `savestate_poll` never runs. + +**Fixes (§95):** + +1. **Rollback load-barrier admit:** while `LOAD_APPLYING` + `savestate_pending` + (and when exiting `LOAD_READY` after mutual sync), use tip + hold-last invent + with **no** confirm (`np_try_admit_load_barrier_rb`). Always invent missing + remotes — peer may already be frozen in LOAD_READY. +2. **Clear DESYNC invent-hold** at `np_commit_load_sync` (hard resync). + +**Re-soak watch:** both peers log `LOADED` / `applied, waiting…` then +`peer ready, resuming lockstep`. No host-only `load_applying+wait_confirm` +stall after hash-match apply. + +## 96. FMV snap cost — dirty VRAM mirror + media interval (2026-08-05) + +**Goal:** keep invent/episode policy for FMV media (§50/§93) but make the +rollback snap machinery cheaper while the movie runs. + +**What landed:** + +1. **Per-scanline VRAM dirty tracking** (`gpu_vram_dirty.*`) on every CPU-VRAM + write path (gpu.c A0/raster + sw fill/copy/put/transfer). **Gated:** + `gpu_vram_dirty_set_tracking(1)` only in `psx_netplay_rb_start`; off in + `shutdown`. Offline / delay-sync: `g_psx_vram_dirty_tracking==0` so + `mark_row` is an inlined no-op (zero gameplay impact). +2. **Incremental raw snap VRAM** (`boot_state_save_buffer_raw` + tracking on): + persistent 1 MiB mirror; only dirty scanlines are copied from live VRAM + before emitting a still-complete `BS_SEC_VRAM` (loads stay independent / + no delta chain). Offline raw/zlib saves keep full `gr_vram_transfer_out`. +3. **FMV media snap interval** (default **4**, `PSX_NET_FMV_SNAP_INTERVAL`): + during media, live snaps every N ticks. Settle + post-FMV lockstep remain + every-tick dense (cutover near-tip load). Episodes into media still refused. +4. **Telemetry:** `rb snap tele` every 64 saves — `last/avg ms`, `dirty_rows`, + `incr%%`. Bring-up verify: `PSX_NET_VRAM_DIRTY_VERIFY=1` memcmp vs full + transfer_out. + +**Not changed (still accept):** no invent through media; SW@1× CPU-auth VRAM; +soft-fork / DESYNC invent-hold after unmatched FMV. + +**Re-soak watch:** during FMV media, `rb snap tele` shows `incr=1` with +`dirty_rows` ≪ 512 (typical depth24 band), lower `avg ms` than pre-change, +and media snap cadence ~interval 4. Settle/lockstep still dense. Optional +verify run once with `PSX_NET_VRAM_DIRTY_VERIFY=1` (no VERIFY FAIL lines). + +## 97. FMV media keyframe episodes — invent into media (2026-08-05) + +**Goal:** abandon blanket “no invent / no episode into FMV media” (§93) by +healing stream soft-forks the same way SAVE/LOAD heals state: **host seals a +raw snap keyframe at `load_tick` before Replay**. + +**Default ON** (`PSX_NET_FMV_MEDIA_KF=0` restores §93 refuse + invent-off). + +**What landed:** + +1. **Invent during live media** when MEDIA_KF on (settle + DESYNC hold still + block invent). Media snap interval defaults to **1** (every tick) so + `load_tick` hits a ring snap. +2. **Begin/follow into media-range** allowed when a snap exists (initiator) or + peer flags `RNET_RB_SYNC_FLAG_MEDIA_KF` (follower may lack the snap). +3. **Host keyframe transfer** (`RNET_STATE_OP_RB_KF`): probe CRC of raw snap → + skip xfer on match; else chunked transfer. Both peers install into pin+ring + (`rb MEDIA-KF …`). No LIGHT_TIP into media KF episodes. +4. **Apply waits** for `g_media_kf_ready`; prefers pin; **skips** + `episode load into FMV media` abort for KF episodes. +5. Restore epilogue unchanged (`cdrom_resync_*`, depth24 cutover, XA FIFO). + +**Re-soak watch:** + +- Mid-FMV: invent can fire; logs `rb begin … media_kf=1` / + `MEDIA-KF probe` / `hash match` or `transfer start` / `installed`. +- No resim-diverge storm on first media episode; baseline digests match after KF. +- `PSX_NET_FMV_MEDIA_KF=0` restores prior refuse / invent-off behavior. + +## 98. FMV admit/FPS — freeze D + timesync during media (2026-08-05) + +**Diag (post-§97):** snaps were already cheap (`rb snap tele avg≈0.7 ms`, +`incr=1`, `dirty_rows≈128`) but FMV sat ~43–55 fps. Ceiling was +`guest≈13–16 ms/f` + `admit≈4–8 ms/f` (timesync debt + D ratcheted to 6–7 +under invent-through-media). `present_gap_p95≈80–100 ms` was mostly the +hardcoded present-1/4 skip (~67 ms+ between Swaps), not snap cost. + +**What landed:** + +1. **No timesync debt during FMV media** — `np_timesync_note_late` skips; + `np_timesync_throttle` clears debt and never stalls admit while media. +2. **Freeze adaptive D during media** — both `np_adapt_delay_on_pcap_enter` + and `np_auto_delay_tick` no-op while `psx_netplay_rb_fmv_media_active()`. +3. **No LAN gap1 micro-grace during media** — invent immediately when the + MEDIA_KF path allows invent (avoid admit wait for a peer that is also + inventing through the movie). +4. **Media snap interval default 2** with MEDIA_KF (was 1). Near-tip loads + still work; halves snap tax vs every-tick. Override + `PSX_NET_FMV_SNAP_INTERVAL`. +5. **Present every 2nd depth24 vblank** during hot MDEC (was every 4th). + Override `PSX_NET_FMV_PRESENT_DIV` (`1`=every frame, `4`=legacy). + +**Re-soak watch:** during FMV media, `admit=` near ~0–2 ms/f (not 5–11), +`D` stable (no mid-movie 6→7), `present_gap_p95` roughly half of §97, +`rb snap tele` cadence ~interval 2, sim FPS closer to guest ceiling. diff --git a/host/psxrecomp_codegen_host.c b/host/psxrecomp_codegen_host.c index f2b12beb..474a4110 100644 --- a/host/psxrecomp_codegen_host.c +++ b/host/psxrecomp_codegen_host.c @@ -702,26 +702,12 @@ static int find_cmake(char* out, size_t cap) { #endif } -static int discover_project_root(char* out, size_t cap) { - const char* env_name = - cfg_or(g_cfg->project_root_env, "PSXRECOMP_PROJECT_ROOT"); - const char* env = getenv(env_name); - if (env && env[0] && looks_like_project_root(env)) { - snprintf(out, cap, "%s", env); - return 1; - } - - char start[1024]; -#if defined(_WIN32) - if (!GetCurrentDirectoryA((DWORD)sizeof(start), start)) - start[0] = '\0'; -#else - if (!getcwd(start, sizeof(start))) - start[0] = '\0'; -#endif - +/* Walk start and up to 10 parents for game.toml + psxrecomp_cli.py. */ +static int walk_up_for_project_root(const char* start, char* out, size_t cap) { char cur[1024]; - snprintf(cur, sizeof(cur), "%s", start[0] ? start : "."); + if (!start || !start[0]) + return 0; + snprintf(cur, sizeof(cur), "%s", start); for (int i = 0; i < 10; ++i) { if (looks_like_project_root(cur)) { snprintf(out, cap, "%s", cur); @@ -737,6 +723,71 @@ static int discover_project_root(char* out, size_t cap) { return 0; } +/* Directory containing this process's executable (not cwd). Dolphin / .desktop + * launches leave cwd as $HOME — VIDEO/PGO/setup must still find the zip tree. + * Linux: $APPIMAGE parent, else /proc/self/exe. Windows: GetModuleFileName. */ +static int resolve_host_exe_dir(char* out, size_t cap) { + char exe[1100]; + if (!out || cap < 2) + return 0; + out[0] = '\0'; + exe[0] = '\0'; + +#if defined(_WIN32) + { + DWORD n = GetModuleFileNameA(NULL, exe, (DWORD)sizeof(exe)); + if (n == 0 || n >= (DWORD)sizeof(exe)) + return 0; + } +#else + { + const char* appimg = getenv("APPIMAGE"); + if (appimg && appimg[0] && path_is_file(appimg)) { + snprintf(exe, sizeof(exe), "%s", appimg); + } else { + char* rp = realpath("/proc/self/exe", NULL); + if (!rp) + return 0; + snprintf(exe, sizeof(exe), "%s", rp); + free(rp); + } + } +#endif + if (!exe[0]) + return 0; + return dirname_copy(out, cap, exe); +} + +static int discover_project_root(char* out, size_t cap) { + const char* env_name = + cfg_or(g_cfg->project_root_env, "PSXRECOMP_PROJECT_ROOT"); + const char* env = getenv(env_name); + if (env && env[0] && looks_like_project_root(env)) { + snprintf(out, cap, "%s", env); + return 1; + } + + /* 1) cwd (terminal launches from the unzip / repo root). */ + char start[1024]; +#if defined(_WIN32) + if (!GetCurrentDirectoryA((DWORD)sizeof(start), start)) + start[0] = '\0'; +#else + if (!getcwd(start, sizeof(start))) + start[0] = '\0'; +#endif + if (start[0] && walk_up_for_project_root(start, out, cap)) + return 1; + + /* 2) exe dir (GUI double-click: cwd is often $HOME / Desktop). */ + char exe_dir[1024]; + if (resolve_host_exe_dir(exe_dir, sizeof(exe_dir)) && + walk_up_for_project_root(exe_dir, out, cap)) + return 1; + + return 0; +} + static int resolve_build_paths(void) { const char* env_name = cfg_or(g_cfg->build_dir_env, "PSXRECOMP_BUILD_DIR"); @@ -2112,7 +2163,12 @@ static void bat_write_set(FILE* f, const char* name, const char* value) { fprintf(f, "\"\r\n"); } -static int write_windows_deferred_rebuild_helper(char* err_msg, size_t err_cap) { +/* force_pgo: Settings Optimize FMV (instrument → train → use). Else setup + * rebuild with --no-pgo. disc_path required when force_pgo. */ +static int write_windows_deferred_rebuild_helper(int force_pgo, + const char* disc_path, + char* err_msg, + size_t err_cap) { if (!join_path(g_helper_path, sizeof(g_helper_path), g_build_dir, "recomp_deferred_rebuild.cmd")) { snprintf(err_msg, err_cap, "Failed to form helper path."); @@ -2135,7 +2191,8 @@ static int write_windows_deferred_rebuild_helper(char* err_msg, size_t err_cap) (unsigned long)GetCurrentProcessId()); fprintf(f, "@echo off\r\n"); fprintf(f, "setlocal EnableExtensions\r\n"); - fprintf(f, "title %s - rebuilding\r\n", g_display); + fprintf(f, "title %s - %s\r\n", g_display, + force_pgo ? "PGO optimize" : "rebuilding"); bat_write_set(f, "PARENT_PID", pid_buf); bat_write_set(f, "PYTHON", g_python); bat_write_set(f, "CLI", g_cli_path); @@ -2146,6 +2203,8 @@ static int write_windows_deferred_rebuild_helper(char* err_msg, size_t err_cap) bat_write_set(f, "EXE_BASE", g_exe_basename); bat_write_set(f, "EXE", g_exe_path); bat_write_set(f, "DISPLAY", g_display); + if (force_pgo && disc_path && disc_path[0]) + bat_write_set(f, "DISC", disc_path); if (g_toolchain_bin[0]) bat_write_set(f, "TC_BIN", g_toolchain_bin); fprintf(f, @@ -2167,12 +2226,23 @@ static int write_windows_deferred_rebuild_helper(char* err_msg, size_t err_cap) " echo RETCOMM_TOOLCHAIN_DIR / pass --toolchain-zip on rebuild.\r\n" " pause\r\n" " exit /b 1\r\n" - ")\r\n" - "echo Building...\r\n" - "\"%%PYTHON%%\" \"%%CLI%%\" rebuild --project-root \"%%ROOT%%\" " - "--config \"%%CONFIG%%\" --build-dir \"%%BUILD_DIR%%\" " - "--target \"%%TARGET%%\" --exe-basename \"%%EXE_BASE%%\" " - "--no-pgo --prune-after build-intermediates\r\n" + ")\r\n"); + if (force_pgo) { + fprintf(f, + "echo PGO optimize (instrument + train + rebuild)...\r\n" + "\"%%PYTHON%%\" \"%%CLI%%\" rebuild --project-root \"%%ROOT%%\" " + "--config \"%%CONFIG%%\" --build-dir \"%%BUILD_DIR%%\" " + "--target \"%%TARGET%%\" --exe-basename \"%%EXE_BASE%%\" " + "--disc \"%%DISC%%\" --force-pgo --pgo-video\r\n"); + } else { + fprintf(f, + "echo Building...\r\n" + "\"%%PYTHON%%\" \"%%CLI%%\" rebuild --project-root \"%%ROOT%%\" " + "--config \"%%CONFIG%%\" --build-dir \"%%BUILD_DIR%%\" " + "--target \"%%TARGET%%\" --exe-basename \"%%EXE_BASE%%\" " + "--no-pgo --prune-after build-intermediates\r\n"); + } + fprintf(f, "if errorlevel 1 (\r\n" " echo.\r\n" " echo Build failed. Fix the errors above, then rebuild manually.\r\n" @@ -2187,41 +2257,60 @@ static int write_windows_deferred_rebuild_helper(char* err_msg, size_t err_cap) } #endif -static int host_rebuild_game(const char* disc_path, char* out_exe_path, - size_t out_cap, char* err_msg, size_t err_cap, - RecompLauncherCPrepareProgressFn on_progress, - void* progress_ctx) { +static int host_rebuild_game_ex(const char* disc_path, int force_pgo, + char* out_exe_path, size_t out_cap, + char* err_msg, size_t err_cap, + RecompLauncherCPrepareProgressFn on_progress, + void* progress_ctx) { g_relaunch_is_helper = 0; if (!g_ready || !g_build_dir[0]) { snprintf(err_msg, err_cap, "CMake build environment is not available."); return 0; } + if (force_pgo) { + if (psxrecomp_codegen_host_sources_missing(g_cfg)) { + snprintf(err_msg, err_cap, + "Generated game C is missing — finish Generate & rebuild " + "in the setup wizard first."); + return 0; + } + if (!disc_path || !disc_path[0] || !path_is_file(disc_path)) { + snprintf(err_msg, err_cap, + "Select a playable disc image before optimizing FMV."); + return 0; + } + } if (!host_ensure_toolchain(on_progress, progress_ctx, err_msg, err_cap)) return 0; #if defined(_WIN32) - (void)disc_path; activate_toolchain_path(); if (on_progress) on_progress(progress_ctx, 0.4f, - "Scheduling Windows rebuild after exit…"); - if (!write_windows_deferred_rebuild_helper(err_msg, err_cap)) + force_pgo ? "Scheduling Windows PGO optimize after exit…" + : "Scheduling Windows rebuild after exit…"); + if (!write_windows_deferred_rebuild_helper(force_pgo, disc_path, err_msg, + err_cap)) return 0; g_relaunch_is_helper = 1; snprintf(out_exe_path, out_cap, "%s", g_helper_path); if (on_progress) on_progress(progress_ctx, 1.0f, - "Exiting so Windows can rebuild safely…"); + force_pgo + ? "Exiting so Windows can run PGO train + rebuild…" + : "Exiting so Windows can rebuild safely…"); return 1; #else if (on_progress) - on_progress(progress_ctx, 0.05f, "Starting rebuild (cmake)…"); + on_progress(progress_ctx, 0.05f, + force_pgo ? "Starting FMV PGO optimize…" + : "Starting rebuild (cmake)…"); activate_toolchain_path(); char disc_arg_storage[1100]; - char* argv[36]; + char* argv[40]; int argc = 0; argv[argc++] = g_python; argv[argc++] = g_cli_path; @@ -2241,14 +2330,20 @@ static int host_rebuild_game(const char* disc_path, char* out_exe_path, argv[argc++] = "--disc"; argv[argc++] = disc_arg_storage; } - argv[argc++] = "--no-pgo"; - argv[argc++] = "--prune-after"; - argv[argc++] = "build-intermediates"; + if (force_pgo) { + argv[argc++] = "--force-pgo"; + argv[argc++] = "--pgo-video"; + } else { + argv[argc++] = "--no-pgo"; + argv[argc++] = "--prune-after"; + argv[argc++] = "build-intermediates"; + } argv[argc++] = "--json-progress"; argv[argc] = NULL; if (!run_cli_posix(argv, on_progress, progress_ctx, err_msg, err_cap, - "psxrecomp rebuild")) + force_pgo ? "psxrecomp PGO optimize" + : "psxrecomp rebuild")) return 0; if (!path_is_file(g_exe_path)) { snprintf(err_msg, err_cap, "Build succeeded but binary missing: %s", @@ -2257,11 +2352,43 @@ static int host_rebuild_game(const char* disc_path, char* out_exe_path, } snprintf(out_exe_path, out_cap, "%s", g_exe_path); if (on_progress) - on_progress(progress_ctx, 1.0f, "Build complete"); + on_progress(progress_ctx, 1.0f, + force_pgo ? "FMV optimize complete" : "Build complete"); return 1; #endif } +static int host_rebuild_game(const char* disc_path, char* out_exe_path, + size_t out_cap, char* err_msg, size_t err_cap, + RecompLauncherCPrepareProgressFn on_progress, + void* progress_ctx) { + return host_rebuild_game_ex(disc_path, 0, out_exe_path, out_cap, err_msg, + err_cap, on_progress, progress_ctx); +} + +static int host_pgo_optimize(const char* disc_path, char* out_exe_path, + size_t out_cap, char* err_msg, size_t err_cap, + RecompLauncherCPrepareProgressFn on_progress, + void* progress_ctx) { + return host_rebuild_game_ex(disc_path, 1, out_exe_path, out_cap, err_msg, + err_cap, on_progress, progress_ctx); +} + +/* Settings → VIDEO → Apply FMV Timing Opt: regenerate C (picks up + * load_charge_batch) then rebuild without PGO training. */ +static int host_fmv_timing_optimize(const char* disc_path, char* out_exe_path, + size_t out_cap, char* err_msg, + size_t err_cap, + RecompLauncherCPrepareProgressFn on_progress, + void* progress_ctx) { + char gen_out[512]; + if (!host_prepare_generate(disc_path, gen_out, sizeof(gen_out), err_msg, + err_cap, on_progress, progress_ctx)) + return 0; + return host_rebuild_game_ex(disc_path, 0, out_exe_path, out_cap, err_msg, + err_cap, on_progress, progress_ctx); +} + void psxrecomp_codegen_host_relaunch_or_exit(const char* disc_path) { char exe[512]; const char* near_exe; @@ -2415,6 +2542,19 @@ void psxrecomp_codegen_host_apply(RecompLauncherCGameInfo* gi, gi->setup_needs_toolchain = 1; gi->toolchain_is_ready = host_toolchain_is_ready; gi->ensure_toolchain_with_progress = host_ensure_toolchain_with_progress; + /* Settings → SYSTEM: PGO on existing generated C (no wizard). */ + gi->pgo_optimize_with_progress = host_pgo_optimize; +#if defined(_WIN32) + gi->pgo_busy_status = "Scheduling FMV PGO optimize…"; + gi->pgo_success_status = + "Exiting for Windows PGO train + rebuild…"; +#else + gi->pgo_busy_status = + "Optimizing FMV (instrument → train → rebuild)…"; + gi->pgo_success_status = "FMV optimize complete — restarting…"; +#endif + /* FMV timing (emitter load_charge_batch) generate+rebuild: host kept + * but not exposed in Settings for now. */ } else { gi->prepare_disc_label = "Generate sources…"; gi->prepare_disc_note = diff --git a/lib/recomp-net b/lib/recomp-net index 7f004756..eebd43b5 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 7f00475673a5ea46f8c8ec44f671e49daf69ad8e +Subproject commit eebd43b5bf2869242f8b927495e9a70488f37145 diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index 7e280bfb..2dc850d1 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -67,6 +67,11 @@ struct CodeGenConfig { // addresses (MotK VLC leaves, etc.). Host locality hint only. std::set hot_funcs; + // [recompiler] load_charge_batch(_funcs): install a function-local + // cycle accumulator so load charges skip deadline probes until IRQ/MMIO + // / function exit. Guest totals at those barriers are unchanged. + std::set load_charge_batch_funcs; + // [load_accel.vsync_query] verified PsyQ VSync functions whose mode=-1 // path may bypass its unused GPUSTAT/Timer1 reads. The map value is the // guest RAM VBlank counter returned by that query path. Empty = inert. diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 093f6d8a..3159b03f 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -2415,8 +2415,17 @@ GeneratedFunction CodeGenerator::generate_function( << config_.indent << "__attribute__((cleanup(psx_cyc_bb_defer_cleanup))) " "int _psx_cyc_bb_guard = 1;\n" - << config_.indent << "psx_cyc_bb_defer_begin();\n" - << "#endif\n"; + << config_.indent << "psx_cyc_bb_defer_begin();\n"; + // Declare local-acc AFTER bb_guard so cleanup runs local_end first + // (publish), then bb_defer_end (flush). Gated VLC leaves only. + if (config_.load_charge_batch_funcs.count(func.start_addr)) { + body_ss << config_.indent << "uint32_t _psx_lc_acc = 0;\n" + << config_.indent + << "__attribute__((cleanup(psx_cyc_local_cleanup))) " + "uint32_t *_psx_lc_guard = &_psx_lc_acc;\n" + << config_.indent << "psx_cyc_local_begin(&_psx_lc_acc);\n"; + } + body_ss << "#endif\n"; // CPS entry-switch: when the unified flat trampoline dispatches a // continuation address (a callee published cpu->pc = $ra back to us), route diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 2791b131..400ab744 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -169,6 +169,15 @@ uint32_t overlay_codegen_config_hash(const GameConfig& c) { h.u32(patch.expected); h.u32(patch.replacement); } + + h.tag("load_charge_batch"); + h.u32(c.load_charge_batch ? 1u : 0u); + { + std::vector lcb = c.load_charge_batch_funcs; + std::sort(lcb.begin(), lcb.end()); + h.u32((uint32_t)lcb.size()); + for (uint32_t pc : lcb) h.u32(pc); + } return h.value; } @@ -1266,6 +1275,20 @@ GameConfig load_game_config(const fs::path& config_path_in) { for (const auto& a : arr) hot_funcs.push_back(parse_hex(a, "recompiler.hot_funcs")); } + // Optional emitter-level load-charge batching for VLC leaves. + bool load_charge_batch = false; + std::vector load_charge_batch_funcs; + if (recomp.contains("load_charge_batch")) + load_charge_batch = toml::find(recomp, "load_charge_batch"); + if (recomp.contains("load_charge_batch_funcs")) { + const auto& arr = + toml::find>(recomp, "load_charge_batch_funcs"); + for (const auto& a : arr) + load_charge_batch_funcs.push_back( + parse_hex(a, "recompiler.load_charge_batch_funcs")); + } + if (load_charge_batch && load_charge_batch_funcs.empty()) + load_charge_batch_funcs = hot_funcs; uint32_t vsync_query_func = 0; uint32_t vsync_counter_addr = 0; uint32_t vsync_gpustat_ptr_addr = 0; @@ -1858,6 +1881,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*ws_auto_ui_squash*/ ws_auto_ui_squash, /*data_shard_funcs*/ data_shard_funcs, /*hot_funcs*/ hot_funcs, + /*load_charge_batch*/ load_charge_batch, + /*load_charge_batch_funcs*/ load_charge_batch_funcs, /*vsync_query_func*/ vsync_query_func, /*vsync_counter_addr*/ vsync_counter_addr, /*vsync_gpustat_ptr_addr*/ vsync_gpustat_ptr_addr, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 0edab02f..869395db 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -654,6 +654,12 @@ struct GameConfig { // on their generated C bodies (profile/host locality; no guest semantics). std::vector hot_funcs; + // [recompiler] load_charge_batch: when true, emit function-local cycle + // accumulators for load_charge_batch_funcs (or hot_funcs if that list is + // empty). Requires regen; guest totals at IRQ/MMIO barriers unchanged. + bool load_charge_batch = false; + std::vector load_charge_batch_funcs; + // [load_accel.vsync_query] opt-in for a byte-verified PsyQ VSync(mode) // implementation. mode=-1 returns vsync_counter_addr while bypassing two // unused MMIO reads; every other mode executes the original function. diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index ab022aab..682b2df9 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -163,6 +163,7 @@ int main(int argc, char** argv) { std::set ws_tag_funcs; // [widescreen] sprite_tag_funcs std::set ds_funcs; // [data_shards] funcs std::set hot_funcs; // [recompiler] hot_funcs + std::set load_charge_batch_funcs; // [recompiler] load_charge_batch* std::map> vsync_query_hle_funcs; std::set ws_cull_bias, ws_cull_range, ws_cull_a1; // [widescreen.cull] std::set ws_cull_screen_x; // [widescreen.cull] screen_x_sites @@ -211,6 +212,10 @@ int main(int argc, char** argv) { cfg.ws_sprite_tag_funcs.end()); ds_funcs.insert(cfg.data_shard_funcs.begin(), cfg.data_shard_funcs.end()); hot_funcs.insert(cfg.hot_funcs.begin(), cfg.hot_funcs.end()); + if (cfg.load_charge_batch) { + load_charge_batch_funcs.insert(cfg.load_charge_batch_funcs.begin(), + cfg.load_charge_batch_funcs.end()); + } if (cfg.vsync_query_func) vsync_query_hle_funcs[cfg.vsync_query_func] = { cfg.vsync_counter_addr, cfg.vsync_gpustat_ptr_addr, @@ -1131,6 +1136,7 @@ int main(int argc, char** argv) { codegen_config.ws_sprite_tag_funcs = ws_tag_funcs; codegen_config.data_shard_funcs = ds_funcs; codegen_config.hot_funcs = hot_funcs; + codegen_config.load_charge_batch_funcs = load_charge_batch_funcs; codegen_config.vsync_query_hle_funcs = vsync_query_hle_funcs; codegen_config.ws_bg2d_init_func = ws_bg2d_init_func; codegen_config.ws_cull_bias_sites = ws_cull_bias; diff --git a/runtime/include/boot_state.h b/runtime/include/boot_state.h index 6b846d67..4da5e4b0 100644 --- a/runtime/include/boot_state.h +++ b/runtime/include/boot_state.h @@ -118,6 +118,14 @@ int boot_state_save_buffer_raw(const CPUState* cpu, uint32_t bios_checksum, uint32_t entry_pc, uint8_t** out_data, size_t* out_len); +/* §96 telemetry: after the latest save, how many VRAM scanlines were dirty + * and whether the incremental mirror path patched (vs full memcpy). */ +uint32_t boot_state_last_vram_dirty_rows(void); +int boot_state_last_vram_incremental(void); + +/* Drop the §96 VRAM mirror (RB shutdown / before re-enable). */ +void boot_state_vram_mirror_reset(void); + /* Load + validate (integrity key) + restore the full machine. On any mismatch * or incompleteness returns 0 (caller then boots normally and recaptures). */ int boot_state_load(const char* path, uint32_t bios_checksum, diff --git a/runtime/include/psx_cyc.h b/runtime/include/psx_cyc.h index a7e79f81..4239c123 100644 --- a/runtime/include/psx_cyc.h +++ b/runtime/include/psx_cyc.h @@ -50,6 +50,8 @@ enum { PSX_CYC_BATCH_SOFT = 64u }; static inline void psx_cyc_bb_defer_begin(void) { } static inline void psx_cyc_bb_defer_end(void) { } static inline void psx_cyc_bb_defer_flush(void) { overlay_flush_cycles(); } +static inline void psx_cyc_local_begin(uint32_t *acc) { (void)acc; } +static inline void psx_cyc_local_end(void) { } #else static inline void psx_cyc_bb_defer_begin(void) { g_psx_cyc_bb_defer++; } static inline void psx_cyc_bb_defer_end(void) { @@ -57,6 +59,23 @@ static inline void psx_cyc_bb_defer_end(void) { if (g_psx_cyc_bb_defer == 0) psx_cyc_batch_flush(); } static inline void psx_cyc_bb_defer_flush(void) { psx_cyc_batch_flush(); } + +/* Install a function-local charge sink (MotK VLC leaves). Soft-cap publish + * at 1<<28 so uint32 cannot wrap; IRQ/MMIO still go through batch_flush. */ +static inline void psx_cyc_local_begin(uint32_t *acc) { +#if !defined(PSX_COSIM) + if (acc) *acc = 0; + g_psx_cyc_local_acc = acc; +#else + (void)acc; +#endif +} +static inline void psx_cyc_local_end(void) { +#if !defined(PSX_COSIM) + psx_cyc_local_publish(); + g_psx_cyc_local_acc = NULL; +#endif +} #endif #if defined(__GNUC__) || defined(__clang__) @@ -64,6 +83,10 @@ static inline void psx_cyc_bb_defer_cleanup(int *guard) { (void)guard; psx_cyc_bb_defer_end(); } +static inline void psx_cyc_local_cleanup(uint32_t **guard) { + (void)guard; + psx_cyc_local_end(); +} #endif static inline void psx_cyc_charge(uint32_t cycles) { @@ -84,6 +107,23 @@ static inline void psx_cyc_charge(uint32_t cycles) { return; } #if !defined(PSX_COSIM) + /* Emitter-gated VLC path: stack local, no deadline probe. */ + if (g_psx_cyc_local_acc) { + uint32_t prior = *g_psx_cyc_local_acc; + if (cycles <= UINT32_MAX - prior && (prior + cycles) < (1u << 28)) { + *g_psx_cyc_local_acc = prior + cycles; + return; + } + *g_psx_cyc_local_acc = 0; + { + uint32_t *saved = g_psx_cyc_local_acc; + g_psx_cyc_local_acc = NULL; + if (prior) psx_advance_cycles(prior); + g_psx_cyc_local_acc = saved; + } + *g_psx_cyc_local_acc = cycles; + return; + } { uint32_t prior = g_psx_cyc_batch; uint32_t sum = prior + cycles; diff --git a/runtime/include/psx_cycles.h b/runtime/include/psx_cycles.h index 529c5583..cc954306 100644 --- a/runtime/include/psx_cycles.h +++ b/runtime/include/psx_cycles.h @@ -1,6 +1,7 @@ #ifndef PSXRECOMP_PSX_CYCLES_H #define PSXRECOMP_PSX_CYCLES_H +#include #include #ifdef __cplusplus @@ -60,6 +61,11 @@ extern uint32_t g_psx_cyc_batch_limit; * block. Interrupt/MMIO edges still publish the accumulated guest cycles. */ extern int g_psx_cyc_bb_defer; +/* Emitter-level VLC load-charge batching: when non-NULL, psx_cyc_charge + * accumulates into *g_psx_cyc_local_acc instead of g_psx_cyc_batch. Publish + * via psx_cyc_local_publish / psx_cyc_batch_flush before IRQ/MMIO barriers. */ +extern uint32_t *g_psx_cyc_local_acc; + /* Advance guest time. Overlay DLLs forward this through their callback shim; * normal runtime/generated code keeps the common production path inlined. */ #if defined(PSX_OVERLAY_DLL_BUILD) @@ -121,10 +127,27 @@ static inline void psx_advance_cycles(uint32_t cycles) { * their pending total in the callback shim rather than these host globals. */ #if defined(PSX_OVERLAY_DLL_BUILD) void overlay_flush_cycles(void); +static inline void psx_cyc_local_publish(void) { } static inline void psx_cyc_batch_flush(void) { overlay_flush_cycles(); } #else +/* Publish function-local charges into the normal batch/advance path while + * keeping the local pointer installed (nested charges resume locally). */ +static inline void psx_cyc_local_publish(void) { +#if !defined(PSX_COSIM) + uint32_t *acc = g_psx_cyc_local_acc; + if (!acc) return; + uint32_t v = *acc; + if (!v) return; + *acc = 0; + g_psx_cyc_local_acc = NULL; + psx_advance_cycles(v); + g_psx_cyc_local_acc = acc; +#endif +} + static inline void psx_cyc_batch_flush(void) { #if !defined(PSX_COSIM) + psx_cyc_local_publish(); uint32_t b = g_psx_cyc_batch; if (!b) return; g_psx_cyc_batch = 0; diff --git a/runtime/include/psx_netplay_rb.h b/runtime/include/psx_netplay_rb.h index 392b7951..9131bae3 100644 --- a/runtime/include/psx_netplay_rb.h +++ b/runtime/include/psx_netplay_rb.h @@ -6,6 +6,7 @@ * Delay-sync path never calls these. */ +#include #include #ifdef __cplusplus @@ -80,13 +81,27 @@ int psx_netplay_rb_fmv_media_active(void); /* 1 during FMV media + short settle (§26) — admit waits for remote wire. * Post-FMV digest lockstep no longer blocks invent. Ticks FMV→settle. - * Also 1 while §93 MAX-unmatched DESYNC hold is armed. */ + * Also 1 while §93 MAX-unmatched DESYNC invent-hold is armed (expires). */ int psx_netplay_rb_lockstep_no_invent(void); +/* §93/§94: 1 while MAX-unmatched DESYNC invent-hold is armed (not media/settle). + * Stall tag should say fmv_desync_hold, not fmv_settle. */ +int psx_netplay_rb_fmv_desync_hold(void); + +/* §94: drop invent-hold after a successful netplay SAVE (tip hole refill) or + * when the hold expires. Episode media-range refuse is unchanged. */ +void psx_netplay_rb_clear_fmv_desync_hold(const char *why); + /* §93: 1 if tick sits in the last FMV media bout / settle tail (or DESYNC - * hold). Begin/follow/hc-fork must not open episodes that load there. */ + * hold). Begin/follow/hc-fork must not open episodes that load there. + * §97 MEDIA_KF (default on): returns 0 for media-range — episodes allowed. */ int psx_netplay_rb_fmv_episode_unsafe(uint32_t tick); +/* §97: guest probe reply — 1 if local snap at episode load matches size/crc. */ +int psx_netplay_rb_media_kf_probe_match(uint32_t size, uint32_t crc); +/* §97: apply RB_KF state blob (or host finish). Returns 1 if handled. */ +int psx_netplay_rb_media_kf_on_ready(const void *data, size_t size); + /* Mid-guest resim pump: abort if Replay has made no finish_frame progress. * Full rb_pump stays admit/present-edge only (host-asymmetric). */ void psx_netplay_rb_poll_replay_stall(void); diff --git a/runtime/include/savestate.h b/runtime/include/savestate.h index 60f8364f..8e845633 100644 --- a/runtime/include/savestate.h +++ b/runtime/include/savestate.h @@ -82,6 +82,10 @@ void psx_frontend_on_savestate_loaded(void); /* Rollback snap apply: depth24 hold clear + restage without FMV cutover thrash. */ void psx_frontend_on_rb_snap_loaded(void); +/* Frontend hook (main.cpp): host OSD toast after a user save/load settles. + * is_load: 0 = save, 1 = load. slot is 0-based (F1 = 0). ok: 1 on success. */ +void psx_frontend_on_savestate_notify(int is_load, int slot, int ok); + /* Called every block from psx_check_interrupts (in_exception == 0). If a save is * pending, serialize with cpu->pc = resume_pc; if a load is pending, restore and * longjmp to the scheduler (never returns in that case). Near-free when idle. */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 52fddf48..e0d20f44 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -185,6 +185,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/ws_ui_group.c ${PSXRECOMP_ROOT}/runtime/src/ws_aspect_cone_math.c ${PSXRECOMP_ROOT}/runtime/src/gpu_sw_renderer.c + ${PSXRECOMP_ROOT}/runtime/src/gpu_vram_dirty.c ${PSXRECOMP_ROOT}/runtime/src/gpu_render.c ${PSXRECOMP_ROOT}/runtime/src/gpu_gl_renderer.c ${PSXRECOMP_ROOT}/runtime/src/gpu_vk_renderer.c @@ -215,6 +216,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/bios_hle.c ${PSXRECOMP_ROOT}/runtime/src/bios_hle_plan.c ${PSXRECOMP_ROOT}/runtime/src/savestate.c + ${PSXRECOMP_ROOT}/runtime/src/host_osd.c ${PSXRECOMP_ROOT}/runtime/src/cosim_state.c ${PSXRECOMP_ROOT}/runtime/src/cosim.c ${PSXRECOMP_ROOT}/runtime/src/traps.c diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c index cded889a..83a44da6 100644 --- a/runtime/src/boot_state.c +++ b/runtime/src/boot_state.c @@ -1,7 +1,9 @@ #include "boot_state.h" #include "overlay_api.h" /* PSX_OVERLAY_CODEGEN_HASH / _ABI_TAG / _CODEGEN_VER */ #include "dirty_ram_interp.h" +#include "gpu.h" /* gpu_get_vram — CPU-auth mirror under dual-raster */ #include "gpu_render.h" /* gr_vram_transfer_in / gr_vram_transfer_out */ +#include "gpu_vram_dirty.h" #include "cpu_state.h" /* gte_canonicalize_cpu_state after CPU wire restore */ #include "interrupts.h" #include "psx_cycles.h" @@ -86,6 +88,103 @@ static char s_capture_path[512]; static uint32_t s_capture_checksum; static uint32_t s_capture_entry_pc; +/* §96: persistent VRAM mirror for raw ring snaps — patch dirty scanlines only. */ +static uint16_t s_vram_mirror[VRAM_W * VRAM_H]; +static int s_vram_mirror_valid; +static uint32_t s_last_vram_dirty_rows; +static int s_last_vram_incremental; + +uint32_t boot_state_last_vram_dirty_rows(void) +{ + return s_last_vram_dirty_rows; +} + +int boot_state_last_vram_incremental(void) +{ + return s_last_vram_incremental; +} + +void boot_state_vram_mirror_reset(void) +{ + s_vram_mirror_valid = 0; + s_last_vram_dirty_rows = VRAM_H; + s_last_vram_incremental = 0; +} + +/* Build s_vram_mirror from live CPU VRAM using dirty rows when possible. + * Always emits a full 1 MiB BS_SEC_VRAM (loads stay independent). + * Only used while gpu_vram_dirty_tracking() (rollback netplay). */ +static int sync_vram_mirror_for_save(void) +{ + const uint16_t *live = gpu_get_vram(); + uint32_t dirty_n; + uint32_t y; + + if (!gpu_vram_dirty_tracking()) { + /* Should not be called offline — full refresh fallback. */ + if (live) + memcpy(s_vram_mirror, live, VRAM_SIZE); + else + gr_vram_transfer_out(0, 0, VRAM_W, VRAM_H, s_vram_mirror); + s_vram_mirror_valid = 1; + s_last_vram_dirty_rows = VRAM_H; + s_last_vram_incremental = 0; + return 1; + } + + dirty_n = gpu_vram_dirty_row_count(); + s_last_vram_dirty_rows = dirty_n; + + if (!live) { + gr_vram_transfer_out(0, 0, VRAM_W, VRAM_H, s_vram_mirror); + s_vram_mirror_valid = 1; + s_last_vram_dirty_rows = VRAM_H; + s_last_vram_incremental = 0; + gpu_vram_dirty_clear(); + return 1; + } + + if (!s_vram_mirror_valid || dirty_n >= VRAM_H) { + memcpy(s_vram_mirror, live, VRAM_SIZE); + s_vram_mirror_valid = 1; + s_last_vram_incremental = 0; + } else if (dirty_n == 0u) { + /* Mirror already matches live. */ + s_last_vram_incremental = 1; + } else { + const uint64_t *mask = gpu_vram_dirty_mask(); + for (y = 0; y < VRAM_H; y++) { + if (mask[y >> 6] & ((uint64_t)1u << (y & 63u))) { + memcpy(s_vram_mirror + (size_t)y * VRAM_W, + live + (size_t)y * VRAM_W, + (size_t)VRAM_W * sizeof(uint16_t)); + } + } + s_last_vram_incremental = 1; + } + + if (gpu_vram_dirty_verify_enabled()) { + uint16_t *full = (uint16_t *)malloc(VRAM_SIZE); + if (full) { + gr_vram_transfer_out(0, 0, VRAM_W, VRAM_H, full); + if (memcmp(full, s_vram_mirror, VRAM_SIZE) != 0) { + fprintf(stderr, + "psxrecomp: VRAM dirty VERIFY FAIL dirty_rows=%u " + "incr=%d — forcing full mirror\n", + (unsigned)dirty_n, s_last_vram_incremental); + fflush(stderr); + memcpy(s_vram_mirror, full, VRAM_SIZE); + s_last_vram_incremental = 0; + s_last_vram_dirty_rows = VRAM_H; + } + free(full); + } + } + + gpu_vram_dirty_clear(); + return 1; +} + /* File or growable memory sink — both save paths share one serializer. */ typedef struct BsOut { FILE* f; /* non-NULL => write to file */ @@ -227,6 +326,38 @@ static int write_timer_section(BsOut* o) { /* ============================ SAVE ============================ */ +/* Classic full VRAM section (offline / zlib / tracking off). */ +static int write_vram_section_full(BsOut *o) +{ + uint16_t *vbuf = (uint16_t *)malloc(VRAM_SIZE); + int ok; + if (!vbuf) + return 0; + gr_vram_transfer_out(0, 0, VRAM_W, VRAM_H, vbuf); + s_last_vram_dirty_rows = VRAM_H; + s_last_vram_incremental = 0; +#if defined(__BYTE_ORDER__) && (__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) + ok = write_section(o, BS_SEC_VRAM, vbuf, VRAM_SIZE); +#else + { + uint8_t *wire = (uint8_t *)malloc(VRAM_SIZE); + if (!wire) { + free(vbuf); + return 0; + } + { + PstW w; + pst_w_init(&w, wire, VRAM_SIZE); + ok = pst_w_pod(&w, vbuf, VRAM_SIZE, 2) && + write_section(o, BS_SEC_VRAM, wire, VRAM_SIZE); + } + free(wire); + } +#endif + free(vbuf); + return ok; +} + static int boot_state_save_to(BsOut* o, const CPUState* cpu, uint32_t bios_checksum, uint32_t entry_pc) { BootStateHeader h; @@ -269,29 +400,29 @@ static int boot_state_save_to(BsOut* o, const CPUState* cpu, } if (ok) ok = write_module_section(o, BS_SEC_GPU, gpu_snapshot_bytes, gpu_snapshot_write); if (ok) { - uint16_t* vbuf = (uint16_t*)malloc(VRAM_SIZE); - if (!vbuf) ok = 0; - else { - gr_vram_transfer_out(0, 0, VRAM_W, VRAM_H, vbuf); - /* VRAM is uint16 LE guest pixels — emit as LE u16 stream. - * pst_w_pod is a memcpy on LE hosts (see pst_wire.h); skip the - * extra wire alloc there (netplay ring snaps every few ticks). */ + /* §96 incremental mirror only while RB dirty-tracking is on. + * Offline / delay-sync / zlib disk: classic full transfer_out. */ + if (o->no_zlib && gpu_vram_dirty_tracking()) { + ok = sync_vram_mirror_for_save(); + if (ok) { #if defined(__BYTE_ORDER__) && (__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) - ok = write_section(o, BS_SEC_VRAM, vbuf, VRAM_SIZE); + ok = write_section(o, BS_SEC_VRAM, s_vram_mirror, VRAM_SIZE); #else - { - uint8_t* wire = (uint8_t*)malloc(VRAM_SIZE); - if (!wire) ok = 0; - else { - PstW w; - pst_w_init(&w, wire, VRAM_SIZE); - ok = pst_w_pod(&w, vbuf, VRAM_SIZE, 2) && - write_section(o, BS_SEC_VRAM, wire, VRAM_SIZE); - free(wire); + { + uint8_t* wire = (uint8_t*)malloc(VRAM_SIZE); + if (!wire) ok = 0; + else { + PstW w; + pst_w_init(&w, wire, VRAM_SIZE); + ok = pst_w_pod(&w, s_vram_mirror, VRAM_SIZE, 2) && + write_section(o, BS_SEC_VRAM, wire, VRAM_SIZE); + free(wire); + } } - } #endif - free(vbuf); + } + } else { + ok = write_vram_section_full(o); } } if (ok) ok = write_module_section(o, BS_SEC_SPU, spu_snapshot_bytes, spu_snapshot_write); @@ -472,6 +603,13 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, #if defined(__BYTE_ORDER__) && (__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) /* Wire == host layout: upload straight from the section buffer. */ gr_vram_transfer_in(0, 0, VRAM_W, VRAM_H, (const uint16_t*)p); + if (gpu_vram_dirty_tracking()) { + memcpy(s_vram_mirror, p, VRAM_SIZE); + s_vram_mirror_valid = 1; + gpu_vram_dirty_clear(); + } else { + s_vram_mirror_valid = 0; + } return 1; #else { @@ -485,6 +623,13 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, return 0; } gr_vram_transfer_in(0, 0, VRAM_W, VRAM_H, vbuf); + if (gpu_vram_dirty_tracking()) { + memcpy(s_vram_mirror, vbuf, VRAM_SIZE); + s_vram_mirror_valid = 1; + gpu_vram_dirty_clear(); + } else { + s_vram_mirror_valid = 0; + } free(vbuf); return 1; } diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 27e3771c..a2edf08b 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -13,6 +13,7 @@ #include "gpu.h" #include "gpu_primitive_reject.h" #include "gpu_sw_renderer.h" +#include "gpu_vram_dirty.h" #include "gpu_render.h" #include "text_xlate.h" #include "crash_trace.h" @@ -2258,6 +2259,7 @@ uint64_t g_pollhack_vblank_count = 0; /* instrumentation: poll-fallback VBlank static void gpu_reset_state(int clear_vram) { if (clear_vram) { memset(vram, 0, sizeof(vram)); + gpu_vram_dirty_mark_all(); } gr_init(vram); @@ -2928,6 +2930,7 @@ static void raster_pixel(int32_t x, int32_t y, uint16_t color) { uint32_t idx = vy * 1024 + vx; if (check_mask_bit && (vram[idx] & 0x8000u)) return; vram[idx] = color | (set_mask_bit ? 0x8000u : 0u); + gpu_vram_dirty_mark_row(vy); } /* Inclusive draw-area reject (same predicate as raster_pixel / hardware clip). @@ -4742,6 +4745,7 @@ static void gpu_write_gp0_body(uint32_t val) { * reads/savestates. The renderer mirror is committed in bulk when * the GP0 payload completes. */ vram[(uint32_t)wy * 1024u + wx] = pixel; + gpu_vram_dirty_mark_row((uint32_t)wy); next_pixel: if (++vram_write_col == vram_write_w) { diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 43ebd2cf..69055064 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -67,6 +67,7 @@ #include "gpu_render.h" #include "gpu_sw_renderer.h" #include "gpu_gl_renderer.h" +#include "host_osd.h" #include "latency_ring.h" #include "psx_sdl.h" @@ -312,7 +313,10 @@ static int s_req_scale = 1; /* requested before context init */ static GLuint s_present_tex = 0; /* CPU-readout present path (24bpp) */ static int s_present_w = 0, s_present_h = 0; +static GLuint s_osd_tex = 0; +static int s_osd_tw = 0, s_osd_th = 0; static GLuint s_present_prog = 0, s_present_vao = 0; +static void gl_swap_with_osd(void); static GLint s_present_uTex = -1, s_present_uUvRect = -1; static GLuint s_interp_prog = 0, s_interp_tex[3]; static GLsync s_interp_fence[3]; @@ -2716,6 +2720,9 @@ void gl_renderer_shutdown(void) { * (rematch 24-bit FMV → black picture, audio still runs). */ s_present_w = 0; s_present_h = 0; + s_osd_tex = 0; + s_osd_tw = 0; + s_osd_th = 0; s_depth24_skip_up = 0; rect_clear(&s_d24_skip_fb); hold_invalidate(); @@ -2792,7 +2799,7 @@ void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linea pres_record(GL_PRES_CPU, 0, 0, src_w, src_h, lx, ly, lw, lh); hold_capture_drawable(); latency_ring_mark(LAT_SWAP_BEGIN); - SDL_GL_SwapWindow(s_win); + gl_swap_with_osd(); latency_ring_mark(LAT_SWAP_END); s_probe_swap++; present_force_consumed(); @@ -2808,7 +2815,7 @@ void gl_renderer_present_blank(void) { pres_record(GL_PRES_BLANK, 0, 0, 0, 0, 0, 0, ww, wh); hold_capture_drawable(); latency_ring_mark(LAT_SWAP_BEGIN); - SDL_GL_SwapWindow(s_win); + gl_swap_with_osd(); latency_ring_mark(LAT_SWAP_END); s_probe_swap++; present_force_consumed(); @@ -3695,7 +3702,7 @@ static int interp_present(void) { glFlush(); pres_record(GL_PRES_INTERP, 0, 0, s_interp_w, s_interp_h, lx, ly, lw, lh); - SDL_GL_SwapWindow(s_win); + gl_swap_with_osd(); s_interp_swaps++; return 1; } @@ -3758,6 +3765,79 @@ static int interp_thread_main(void *opaque) { return 0; } +/* Draw one host OSD ARGB image into the default framebuffer at (vx,vy) + * in top-left window coordinates (y down). */ +static void gl_draw_osd_image(const uint32_t *px, int ow, int oh, + int vx, int vy, int ww, int wh) { + if (!px || ow <= 0 || oh <= 0 || !s_present_prog || ww <= 0 || wh <= 0) + return; + if (!s_osd_tex) glGenTextures(1, &s_osd_tex); + p_glActiveTexture(PSXGL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, s_osd_tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + if (s_osd_tw != ow || s_osd_th != oh) { + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, ow, oh, 0, + GL_BGRA, GL_UNSIGNED_BYTE, px); + s_osd_tw = ow; + s_osd_th = oh; + } else { + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, ow, oh, + GL_BGRA, GL_UNSIGNED_BYTE, px); + } + int dw = ow, dh = oh; + if (vx + dw > ww) dw = ww - vx; + if (vy + dh > wh) dh = wh - vy; + if (dw < 1 || dh < 1) return; + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_STENCIL_TEST); + glDisable(GL_DEPTH_TEST); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + /* GL viewport origin is bottom-left. */ + glViewport(vx, wh - vy - dh, dw, dh); + p_glUseProgram(s_present_prog); + p_glUniform1i(s_present_uTex, 0); + /* Cancel PRESENT_VS V-flip — CPU row 0 is the top of the image. */ + p_glUniform4f(s_present_uUvRect, 0.f, 1.f, 1.f, 0.f); + { + GLuint vao = s_present_vao; + if (s_interp_ctx && SDL_GL_GetCurrentContext() == s_interp_ctx && + s_interp_thread_vao) + vao = s_interp_thread_vao; + p_glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, 3); + p_glBindVertexArray(0); + } + p_glUseProgram(0); + glDisable(GL_BLEND); +} + +/* Composite host toast + volume bar into the default framebuffer, then swap. */ +static void gl_swap_with_osd(void) { + if (s_present_prog && s_ctx) { + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + if (ww > 0 && wh > 0) { + const uint32_t *px = NULL; + int ow = 0, oh = 0; + const int margin = 8; + if (host_osd_image(&px, &ow, &oh) && px) + gl_draw_osd_image(px, ow, oh, margin, margin, ww, wh); + if (host_osd_volume_image(&px, &ow, &oh) && px) { + int vx = (ww > ow + margin) ? (ww - ow - margin) : margin; + int vy = (wh > oh) ? ((wh - oh) / 2) : margin; + gl_draw_osd_image(px, ow, oh, vx, vy, ww, wh); + } + } + } + host_osd_present_done(); + SDL_GL_SwapWindow(s_win); +} + static void present_target_quad(GLuint tex, int tex_w, int tex_h, int x, int y, int w, int h, int linear, int lx, int ly, int lw, int lh, int v_flip) { @@ -3847,7 +3927,7 @@ int gl_renderer_present_hold_last(void) { hold_capture_drawable(); } latency_ring_mark(LAT_SWAP_BEGIN); - SDL_GL_SwapWindow(s_win); + gl_swap_with_osd(); latency_ring_mark(LAT_SWAP_END); s_probe_swap++; return 1; @@ -3863,7 +3943,8 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, s_last_present_path == GL_PRES_VRAM && s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == w && s_last_dh == h && - !present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1)) { + !present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1) && + !host_osd_needs_present()) { s_probe_skip++; gl_perf_present_enter(); gl_perf_present_exit(0); @@ -3904,7 +3985,7 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, pres_record(GL_PRES_VRAM, disp_x, disp_y, w, h, lx, ly, lw, lh); hold_capture_drawable(); latency_ring_mark(LAT_SWAP_BEGIN); - SDL_GL_SwapWindow(s_win); + gl_swap_with_osd(); latency_ring_mark(LAT_SWAP_END); s_probe_swap++; gl_perf_present_exit(0); @@ -3969,7 +4050,8 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) s_last_present_path == GL_PRES_WIDE && s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == g_wide_w && s_last_dh == disp_h && - !present_dirty_test(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1)) { + !present_dirty_test(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1) && + !host_osd_needs_present()) { s_probe_skip++; gl_perf_present_enter(); gl_perf_present_exit(1); @@ -4004,7 +4086,7 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) pres_record(GL_PRES_WIDE, disp_x, disp_y, g_wide_w, disp_h, lx, ly, lw, lh); hold_capture_drawable(); latency_ring_mark(LAT_SWAP_BEGIN); - SDL_GL_SwapWindow(s_win); + gl_swap_with_osd(); latency_ring_mark(LAT_SWAP_END); s_probe_swap++; gl_perf_present_exit(1); diff --git a/runtime/src/gpu_sw_renderer.c b/runtime/src/gpu_sw_renderer.c index 51e6484c..07f54688 100644 --- a/runtime/src/gpu_sw_renderer.c +++ b/runtime/src/gpu_sw_renderer.c @@ -28,6 +28,7 @@ */ #include "gpu_sw_renderer.h" +#include "gpu_vram_dirty.h" #include "gpu_sw_edges.h" #include #include @@ -274,6 +275,9 @@ static inline void put_opaque(const RTarget *t, int x, int y, uint16_t color) { if (g_mask_set_bit) color |= 0x8000; t->buf[idx] = color; + /* Canonical VRAM only (hi-res / wide mirrors are present-side). */ + if (t->buf == g_vram) + gpu_vram_dirty_mark_row((uint32_t)y); } /* Write a textured pixel — semi-trans only if texel bit 15 is set */ @@ -316,6 +320,8 @@ static inline void put_textured(const RTarget *t, int x, int y, uint16_t texel, if (g_mask_set_bit) color |= 0x8000; t->buf[idx] = color; + if (t->buf == g_vram) + gpu_vram_dirty_mark_row((uint32_t)y); } /* ------------------------------------------------------------------ */ @@ -626,6 +632,7 @@ void sw_fill_rect(int x, int y, int w, int h, uint16_t color) { g_vram[py * VRAM_WIDTH + px] = color; } } + gpu_vram_dirty_mark_rect(x0, y0, w, h); if (g_hr) hr_fill_block(x, y, w, h, color); } @@ -691,6 +698,7 @@ void sw_copy_rect(int src_x, int src_y, int dst_x, int dst_y, int w, int h) { } } } + gpu_vram_dirty_mark_row((uint32_t)dy); } if (hr_rows) free(hr_rows); @@ -1514,6 +1522,7 @@ void sw_vram_write(int x, int y, uint16_t pixel) { x &= (VRAM_WIDTH - 1); y &= (VRAM_HEIGHT - 1); g_vram[y * VRAM_WIDTH + x] = pixel; + gpu_vram_dirty_mark_row((uint32_t)y); if (g_hr) { int s = g_scale; @@ -1580,6 +1589,7 @@ void sw_vram_transfer_in(int x, int y, int w, int h, const uint16_t *data) { * does s² stores per guest texel — multi-second hitches on 2×/4×. */ if (x == 0 && y == 0 && w == VRAM_WIDTH && h == VRAM_HEIGHT) { memcpy(g_vram, data, (size_t)VRAM_WIDTH * (size_t)VRAM_HEIGHT * sizeof(uint16_t)); + gpu_vram_dirty_mark_all(); if (g_hr) hr_rebuild_from_vram(); return; } @@ -1593,6 +1603,7 @@ void sw_vram_transfer_in(int x, int y, int w, int h, const uint16_t *data) { data + (size_t)row * (size_t)w, (size_t)w * sizeof(uint16_t)); } + gpu_vram_dirty_mark_rect(x, y, w, h); return; } @@ -1614,6 +1625,7 @@ void sw_vram_transfer_in(int x, int y, int w, int h, const uint16_t *data) { } } } + gpu_vram_dirty_mark_row((uint32_t)py); } } diff --git a/runtime/src/gpu_vk_renderer.c b/runtime/src/gpu_vk_renderer.c index 36a19b50..321c318c 100644 --- a/runtime/src/gpu_vk_renderer.c +++ b/runtime/src/gpu_vk_renderer.c @@ -21,6 +21,7 @@ #include "gpu_render.h" #include "gpu_vk_renderer.h" #include "gpu_sw_renderer.h" +#include "host_osd.h" #include "crash_trace.h" #include "gpu_vk_upload.h" @@ -1394,11 +1395,13 @@ int vk_renderer_init_context(SDL_Window *win) { } static void cpres_cache_free(void); /* fwd: CPU-present resource cache */ +static void osd_staging_free(void); void vk_renderer_shutdown(void) { if (!s_dev) return; p_vkDeviceWaitIdle(s_dev); vk_gpu_sync_internal(); /* reclaim deferred staging before tearing down */ cpres_cache_free(); /* FMV CPU-present cached image + staging */ + osd_staging_free(); /* host toast OSD staging */ for (int i = 0; i < STAGING_CACHE_MAX; ++i) { if (s_staging_cache[i].buf) staging_destroy(s_staging_cache[i].buf, s_staging_cache[i].mem); @@ -1491,6 +1494,108 @@ static int acquire_present(VkImage *out_sc, VkCommandBuffer *out_cb, return 1; } +/* Copy host toast into the swapchain (must still be TRANSFER_DST). Opaque + * overwrite — host_osd bakes an opaque panel so no blend pass is needed. */ +static VkBuffer s_osd_buf = VK_NULL_HANDLE; +static VkDeviceMemory s_osd_mem = VK_NULL_HANDLE; +static void *s_osd_map = NULL; +static VkDeviceSize s_osd_cap = 0; + +static void osd_staging_free(void) { + if (s_osd_buf) { + staging_destroy(s_osd_buf, s_osd_mem); + s_osd_buf = VK_NULL_HANDLE; + s_osd_mem = VK_NULL_HANDLE; + } + s_osd_map = NULL; + s_osd_cap = 0; +} + +static void vk_osd_copy_rect(VkCommandBuffer cb, VkImage sc, + const uint32_t *px, int w, int h, + int x, int y, VkDeviceSize buf_off) { + if (!px || w <= 0 || h <= 0) return; + int dw = w, dh = h; + if (x + dw > (int)s_sc_extent.width) dw = (int)s_sc_extent.width - x; + if (y + dh > (int)s_sc_extent.height) dh = (int)s_sc_extent.height - y; + if (dw < 1 || dh < 1) return; + memcpy((uint8_t *)s_osd_map + (size_t)buf_off, px, + (size_t)w * (size_t)h * 4u); + VkBufferImageCopy bc = {0}; + bc.bufferOffset = buf_off; + bc.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + bc.imageSubresource.layerCount = 1; + bc.imageOffset.x = x; + bc.imageOffset.y = y; + bc.imageExtent.width = (uint32_t)dw; + bc.imageExtent.height = (uint32_t)dh; + bc.imageExtent.depth = 1; + bc.bufferRowLength = (uint32_t)w; + bc.bufferImageHeight = (uint32_t)h; + p_vkCmdCopyBufferToImage(cb, s_osd_buf, sc, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &bc); +} + +static void vk_osd_blit(VkCommandBuffer cb, VkImage sc) { + const uint32_t *text_px = NULL, *vol_px = NULL; + int tw = 0, th = 0, vw = 0, vh = 0; + const int have_text = host_osd_image(&text_px, &tw, &th) && text_px; + const int have_vol = host_osd_volume_image(&vol_px, &vw, &vh) && vol_px; + if (!have_text && !have_vol) { + host_osd_present_done(); + return; + } + /* Only BGRA swapchains match ARGB8888 LE packing used by host_osd. */ + if (s_sc_format != VK_FORMAT_B8G8R8A8_UNORM && + s_sc_format != VK_FORMAT_B8G8R8A8_SRGB) { + host_osd_present_done(); + return; + } + VkDeviceSize text_bytes = + have_text ? (VkDeviceSize)tw * (VkDeviceSize)th * 4u : 0; + VkDeviceSize vol_bytes = + have_vol ? (VkDeviceSize)vw * (VkDeviceSize)vh * 4u : 0; + VkDeviceSize bytes = text_bytes + vol_bytes; + if (bytes > s_osd_cap) { + p_vkQueueWaitIdle(s_queue); + osd_staging_free(); + if (!make_staging(bytes, &s_osd_buf, &s_osd_mem, &s_osd_map)) { + host_osd_present_done(); + return; + } + s_osd_cap = bytes; + } + const int margin = 8; + VkDeviceSize off = 0; + if (have_text) { + vk_osd_copy_rect(cb, sc, text_px, tw, th, margin, margin, off); + off += text_bytes; + } + if (have_vol) { + int x = ((int)s_sc_extent.width > vw + margin) + ? ((int)s_sc_extent.width - vw - margin) + : margin; + int y = ((int)s_sc_extent.height > vh) + ? (((int)s_sc_extent.height - vh) / 2) + : margin; + vk_osd_copy_rect(cb, sc, vol_px, vw, vh, x, y, off); + } + host_osd_present_done(); +} + +static void submit_present(VkCommandBuffer cb, uint32_t img_idx, uint32_t fr); + +static void finish_present(VkCommandBuffer cb, VkImage sc, + uint32_t img_idx, uint32_t fr) { + vk_osd_blit(cb, sc); + img_barrier(cb, sc, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + VK_ACCESS_TRANSFER_WRITE_BIT, 0, + VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT); + submit_present(cb, img_idx, fr); +} + static void submit_present(VkCommandBuffer cb, uint32_t img_idx, uint32_t fr) { p_vkEndCommandBuffer(cb); /* Present command buffers write the acquired swapchain image with @@ -1562,10 +1667,7 @@ int vk_renderer_present_vram(int disp_x, int disp_y, int w, int h, sc, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, linear ? VK_FILTER_LINEAR : VK_FILTER_NEAREST); - img_barrier(cb, sc, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, - VK_ACCESS_TRANSFER_WRITE_BIT, 0, - VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT); - submit_present(cb, idx, fr); + finish_present(cb, sc, idx, fr); perf_snapshot_present(); return 1; } @@ -1581,10 +1683,7 @@ void vk_renderer_present_blank(void) { VkClearColorValue black = {{0,0,0,1}}; VkImageSubresourceRange rng = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }; p_vkCmdClearColorImage(cb, sc, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &black, 1, &rng); - img_barrier(cb, sc, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, - VK_ACCESS_TRANSFER_WRITE_BIT, 0, - VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT); - submit_present(cb, idx, fr); + finish_present(cb, sc, idx, fr); perf_snapshot_present(); } @@ -1677,10 +1776,7 @@ void vk_renderer_present_cpu(const uint32_t *pixels, int src_w, int src_h, blit.dstOffsets[0] = dst[0]; blit.dstOffsets[1] = dst[1]; p_vkCmdBlitImage(cb, img, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, sc, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, linear ? VK_FILTER_LINEAR : VK_FILTER_NEAREST); - img_barrier(cb, sc, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, - VK_ACCESS_TRANSFER_WRITE_BIT, 0, - VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT); - submit_present(cb, idx, fr); + finish_present(cb, sc, idx, fr); /* Cached resources are rewritten next frame: wait for this frame's copy + * blit to complete first (FMV cadence is 15-24 fps; one idle-wait per @@ -1738,10 +1834,7 @@ int vk_renderer_present_wide(int disp_x, int disp_y, int disp_h, int linear) { 1, &blit, linear ? VK_FILTER_LINEAR : VK_FILTER_NEAREST); img_to(cb, s_wide_img[i], &s_wide_layout[i], VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); - img_barrier(cb, sc, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, - VK_ACCESS_TRANSFER_WRITE_BIT, 0, - VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT); - submit_present(cb, idx, fr); + finish_present(cb, sc, idx, fr); perf_snapshot_present(); return 1; } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index cb1fa511..c9c4e7c4 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -20,6 +20,7 @@ #include "starvation_ring.h" #include "load_accel.h" #include "savestate.h" +#include "host_osd.h" #include "overlay_capture.h" #include "overlay_loader.h" #include "autocompile.h" @@ -843,6 +844,23 @@ static void post_load_probe_on_vblank(int turbo_active, int present_reached) { /* Called from savestate_poll after a successful restore (before scheduler * longjmp). Clears present latches and forces the next vblank to show the * restored VRAM — including a blank if display was disabled in the snapshot. */ +extern "C" void psx_frontend_on_savestate_notify(int is_load, int slot, int ok) { + char buf[64]; + const int disp = slot + 1; /* F1 = slot 1 */ + if (is_load) { + if (ok) + snprintf(buf, sizeof(buf), "Loaded slot %d", disp); + else + snprintf(buf, sizeof(buf), "Load failed slot %d", disp); + } else { + if (ok) + snprintf(buf, sizeof(buf), "Saved slot %d", disp); + else + snprintf(buf, sizeof(buf), "Save failed slot %d", disp); + } + host_osd_push(buf, 2000); +} + extern "C" void psx_frontend_on_savestate_loaded(void) { s_disabled_frame_presented = false; s_force_present_after_load = true; @@ -2084,6 +2102,15 @@ static void sdl_audio_pump(bool discard_output = false) { sdl_audio_gain_ramp(sdl_audio_buf, ramp, g0, g1); sdl_audio_fadein_left -= ramp; } + /* Host master volume (launcher / numpad +/-). Applied after fade so mute + * edges stay continuous and volume steps take effect immediately. */ + { + const int vol = host_volume_get(); + if (vol < 100) { + const float g = (float)vol / 100.0f; + sdl_audio_gain_ramp(sdl_audio_buf, frames, g, g); + } + } if (legacy) { /* T3 tap: the exact post-fade bytes handed to the host audio queue. */ audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, frames); @@ -2474,8 +2501,9 @@ static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) { if (tail > buf_cap) tail = buf_cap; /* An unmute ramp may still be in progress; start the down-ramp * from its current gain so the edge stays continuous. */ - const float g0 = 1.0f - (float)sdl_audio_fadein_left - / (float)sdl_audio_fade_samples; + const float vol = (float)host_volume_get() / 100.0f; + const float g0 = (1.0f - (float)sdl_audio_fadein_left + / (float)sdl_audio_fade_samples) * vol; sdl_audio_fadein_left = 0; spu_render(sdl_audio_buf, tail); sdl_audio_gain_ramp(sdl_audio_buf, tail, g0, 0.0f); @@ -4088,6 +4116,7 @@ static void netplay_hold_last_present_tick(void) { SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255); SDL_RenderClear(sdl_renderer); SDL_RenderCopy(sdl_renderer, sdl_texture, &s_sw_hold_src, &s_sw_hold_dst); + host_osd_draw_sdl(sdl_renderer); SDL_RenderPresent(sdl_renderer); did = 1; } @@ -4534,6 +4563,13 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { else if (key == SDLK_c && (mod & KMOD_CTRL)) { std::fprintf(stdout, "[DEBUG] Forzando reinserción de CD...\n"); debug_force_cd_reinsert(); + host_osd_push("CD reinsert", 1500); + } + /* Host volume: numpad + / - (5% steps). Shows right-side bar. */ + else if (key == SDLK_KP_PLUS) { + host_volume_adjust(+5); + } else if (key == SDLK_KP_MINUS) { + host_volume_adjust(-5); } /* Fullscreen toggle: Alt+Enter or Cmd/Ctrl+F. Toggles between * windowed and the CONFIGURED tri-state mode (g_fullscreen: 1 @@ -4549,6 +4585,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { SDL_WINDOW_FULLSCREEN; if (is_fs) { SDL_SetWindowFullscreen(sdl_window, 0); + host_osd_push("Windowed", 1500); } else { /* If the configured mode is "off", the hotkey still * needs a mode to switch INTO — default to borderless, @@ -4556,6 +4593,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { Uint32 target = psx_fullscreen_flag_for_mode(g_fullscreen); if (target == 0) target = SDL_WINDOW_FULLSCREEN_DESKTOP; SDL_SetWindowFullscreen(sdl_window, target); + host_osd_push("Fullscreen", 1500); } } } @@ -4790,19 +4828,32 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { } } - /* Netplay FMV: present 1 of every 4 depth24 vblanks while MDEC is hot. - * SW RGB888 scanout dominates guest ms; admit + wall pace still run every - * tick in the epilogue. Do NOT skip pace — MotK ran ~90fps when decode was - * fast and pace was skipped here. During MDEC-idle cutover (FMV1→FMV2) - * present every frame so the next stream can handshake (1/4 skip left the - * gap looking frozen with cheap guest + TURN admit wait). */ + /* Netplay FMV: present 1 of every N depth24 vblanks while MDEC is hot. + * §98: default N=2 (was 4). 1/4 made present_gap_p95≈80–100 ms look like + * hitches; SW scanout is batched so 1/2 is affordable. Admit + wall pace + * still run every tick. During MDEC-idle cutover present every frame. + * Override: PSX_NET_FMV_PRESENT_DIV (1=every frame, 2=default, 4=legacy). */ if (psx_netplay_active() && gpu_display_is_depth24() && mdec_recently_active(8)) { - if (s_netplay_depth24_present_skip > 0) { + static int s_fmv_present_div = -1; + int div; + if (s_fmv_present_div < 0) { + const char *e = getenv("PSX_NET_FMV_PRESENT_DIV"); + unsigned v = 2u; + if (e && e[0] && sscanf(e, "%u", &v) == 1 && v >= 1u && v <= 8u) + s_fmv_present_div = (int)v; + else + s_fmv_present_div = 2; + } + div = s_fmv_present_div; + if (div <= 1) { + s_netplay_depth24_present_skip = 0; + } else if (s_netplay_depth24_present_skip > 0) { s_netplay_depth24_present_skip--; return ep; + } else { + s_netplay_depth24_present_skip = div - 1; } - s_netplay_depth24_present_skip = 3; /* skip next three presents */ } else { s_netplay_depth24_present_skip = 0; } @@ -4927,6 +4978,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { if (sdl_renderer) { SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255); SDL_RenderClear(sdl_renderer); + host_osd_draw_sdl(sdl_renderer); SDL_RenderPresent(sdl_renderer); } } @@ -5233,6 +5285,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255); SDL_RenderClear(sdl_renderer); SDL_RenderCopy(sdl_renderer, sdl_texture, &src, &dst); + host_osd_draw_sdl(sdl_renderer); /* §33: remember active rect for resim hold-last (not full 640x512). */ s_sw_hold_src = src; s_sw_hold_dst = dst; @@ -8986,7 +9039,7 @@ std::string player_device[PSX_MAX_PLAYERS]; ls.widescreen_hud = ls.widescreen; ls.enable_audio = 1; ls.audio_freq = 44100; - ls.volume = 100; + ls.volume = host_volume_get(); { const int n = std::min(PSX_MAX_PLAYERS, RECOMP_LAUNCHER_MAX_PLAYERS); for (int i = 0; i < n; ++i) { @@ -9278,6 +9331,7 @@ std::string player_device[PSX_MAX_PLAYERS]; seed.spu_hq = ls.spu_hq != 0; seed.has_spu_hq = true; seed.auto_skip_fmv = ls.auto_skip_fmv != 0; seed.has_auto_skip_fmv = true; seed.turbo_loads = ls.turbo_loads != 0; seed.has_turbo_loads = true; + host_volume_set(ls.volume); if (ls.bios_path[0]) { std::filesystem::path resolved = resolve_bios_path(ls.bios_path, argv[0]); @@ -10406,7 +10460,7 @@ soft_return_lobby: ls.fullscreen = g_fullscreen ? 1 : 0; ls.enable_audio = 1; ls.audio_freq = 44100; - ls.volume = 100; + ls.volume = host_volume_get(); ls.window_width = g_video_win_w; ls.renderer = g_video_renderer; ls.supersampling = g_video_scale; @@ -10499,6 +10553,7 @@ soft_return_lobby: #endif if (rui_rc == 0) { + host_volume_set(ls.volume); if (rui_out_disc[0]) { resolved_disc = normalize_disc_path_for_launch(rui_out_disc); disc_path_str = resolved_disc.string(); diff --git a/runtime/src/netplay_snap_ring.c b/runtime/src/netplay_snap_ring.c index f6ed9c98..a69fc39f 100644 --- a/runtime/src/netplay_snap_ring.c +++ b/runtime/src/netplay_snap_ring.c @@ -1,8 +1,29 @@ #include "netplay_snap_ring.h" #include "boot_state.h" +#include #include #include +#if defined(_WIN32) +# include +#else +# include +#endif + +static double snap_mono_ms(void) +{ +#if defined(_WIN32) + static LARGE_INTEGER freq; + LARGE_INTEGER c; + if (!freq.QuadPart) QueryPerformanceFrequency(&freq); + QueryPerformanceCounter(&c); + return (double)c.QuadPart * 1000.0 / (double)freq.QuadPart; +#else + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1.0e6; +#endif +} typedef struct NetplaySnapSlot { int valid; @@ -160,11 +181,39 @@ int netplay_snap_ring_save(NetplaySnapRing* r, uint32_t tick, uint32_t entry_pc) { uint8_t* data = NULL; size_t len = 0; + double t0, dt; + static uint32_t s_tele_n; + static double s_tele_ms_sum; + static uint64_t s_tele_bytes_sum; + static uint64_t s_tele_dirty_sum; + static uint32_t s_tele_incr_n; if (!r || !cpu) return 0; /* Raw (no zlib): compress2 on RAM+VRAM+SPU dominated live FPS. Load - * accepts uncompressed v4 sections. */ + * accepts uncompressed v4 sections. §96: dirty-scanline VRAM mirror. */ + t0 = snap_mono_ms(); if (!boot_state_save_buffer_raw(cpu, bios_checksum, entry_pc, &data, &len)) return 0; + dt = snap_mono_ms() - t0; + s_tele_n++; + s_tele_ms_sum += dt; + s_tele_bytes_sum += (uint64_t)len; + s_tele_dirty_sum += (uint64_t)boot_state_last_vram_dirty_rows(); + if (boot_state_last_vram_incremental()) + s_tele_incr_n++; + /* Heartbeat every 64 saves — visible during FMV dense / media windows. */ + if ((s_tele_n & 63u) == 0u) { + fprintf(stderr, + "psxrecomp: rb snap tele n=%u last=%.2fms avg=%.2fms " + "bytes=%zu avg_bytes=%.0f dirty_rows=%u incr=%d " + "(avg_dirty=%.1f incr%%=%.0f)\n", + (unsigned)s_tele_n, dt, s_tele_ms_sum / (double)s_tele_n, + len, (double)s_tele_bytes_sum / (double)s_tele_n, + (unsigned)boot_state_last_vram_dirty_rows(), + boot_state_last_vram_incremental(), + (double)s_tele_dirty_sum / (double)s_tele_n, + 100.0 * (double)s_tele_incr_n / (double)s_tele_n); + fflush(stderr); + } if (!netplay_snap_ring_store(r, tick, data, len)) return 0; /* store frees data on hard failure */ return 1; diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 150b8d6c..186d924e 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -24,6 +24,7 @@ uint64_t psx_cycle_count = 0; uint32_t g_psx_cyc_batch = 0; uint32_t g_psx_cyc_batch_limit = 0; int g_psx_cyc_bb_defer = 0; +uint32_t *g_psx_cyc_local_acc = NULL; static int s_cycle_replay_active = 0; static uint64_t s_cycle_replay_live = 0; @@ -331,7 +332,9 @@ void psx_advance_cycles_slow(uint32_t cycles) { } uint64_t psx_get_cycle_count(void) { - return psx_cycle_count + (uint64_t)g_psx_cyc_batch; + uint64_t n = psx_cycle_count + (uint64_t)g_psx_cyc_batch; + if (g_psx_cyc_local_acc) n += (uint64_t)(*g_psx_cyc_local_acc); + return n; } /* ===== Idle-loop cycle skip (wait-loop elision, 2026-07-06) ================== @@ -554,6 +557,8 @@ void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { * would try to replay a bogus gap. Re-anchor devices at the restored cycle and * force a fresh deadline on the next charge. */ void psx_cycles_resync_after_restore(CPUState *cpu) { + if (g_psx_cyc_local_acc) *g_psx_cyc_local_acc = 0; + g_psx_cyc_local_acc = NULL; g_psx_cyc_batch = 0; g_psx_cyc_batch_limit = 0; g_psx_cyc_bb_defer = 0; @@ -593,6 +598,8 @@ void psx_cycles_resync_after_restore(CPUState *cpu) { } void psx_cycles_reset_for_boot(void) { + if (g_psx_cyc_local_acc) *g_psx_cyc_local_acc = 0; + g_psx_cyc_local_acc = NULL; g_psx_cyc_batch = 0; g_psx_cyc_batch_limit = 0; g_psx_cyc_bb_defer = 0; diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index a2b9a85d..54298f53 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -858,6 +858,7 @@ static void np_commit_load_sync(void); static void np_begin_load_apply(int slot); static void np_starv_reset(void); static void np_maybe_stage_target_save(void); +static void np_note_save_complete(void); static int np_file_crc(const uint8_t *data, size_t size, uint32_t *crc_out) { @@ -1015,6 +1016,14 @@ static void np_apply_ready_state(void) return; } + /* §97: FMV media keyframe — install into RB pin/ring (no disk slot). */ + if (op == RNET_STATE_OP_RB_KF) { + (void)psx_netplay_rb_media_kf_on_ready(data, size); + rnet_session_state_finish(g_np.session, 0); + g_np.xfer = NP_XFER_NONE; + return; + } + if (op == RNET_STATE_OP_SAVE) { if (g_np.local_slot != 0) { if (!savestate_write_slot((int)slot, data, size)) { @@ -1047,6 +1056,7 @@ static void np_apply_ready_state(void) } rnet_session_state_finish(g_np.session, 0); g_np.xfer = NP_XFER_NONE; + np_note_save_complete(); return; } @@ -1153,6 +1163,16 @@ static void np_guest_handle_probe(void) return; } + /* §97: rollback FMV media keyframe — CRC of raw snap at episode load. */ + if (op == RNET_STATE_OP_RB_KF) { + match = psx_netplay_rb_media_kf_probe_match(size, crc); + (void)rnet_session_state_probe_reply(g_np.session, match); + printf("psxrecomp: netplay MEDIA-KF probe — %s (size=%u crc=%08x)\n", + match ? "match" : "miss", (unsigned)size, (unsigned)crc); + fflush(stdout); + return; + } + { uint32_t local_sz = 0, local_crc = 0; char reason[192]; @@ -1177,6 +1197,7 @@ static void np_guest_handle_probe(void) "skip transfer\n", (unsigned)slot); fflush(stdout); + np_note_save_complete(); } else { /* Host will chunk the authoritative .pst — stay parked. */ g_np.xfer = NP_XFER_SAVE_SEND; @@ -1276,6 +1297,7 @@ static void np_host_drive_xfer(void) g_np.xfer_slot); fflush(stdout); g_np.xfer = NP_XFER_NONE; + np_note_save_complete(); return; } if (!savestate_read_slot(g_np.xfer_slot, &buf, &n) || !buf || @@ -1333,6 +1355,7 @@ static void np_host_drive_xfer(void) case NP_XFER_LOAD_SEND: /* apply_ready runs first and clears take_ready (LOAD → LOAD_APPLYING). */ if (rnet_session_state_take_ready(g_np.session, NULL, NULL, NULL, NULL)) { + int was_save = (g_np.xfer == NP_XFER_SAVE_SEND); if (g_np.xfer == NP_XFER_MC_SEND) g_np.mc_sync_done = 1; rnet_session_state_finish(g_np.session, 0); @@ -1340,6 +1363,8 @@ static void np_host_drive_xfer(void) np_begin_load_apply(g_np.xfer_slot); } else { g_np.xfer = NP_XFER_NONE; + if (was_save) + np_note_save_complete(); } } return; @@ -1431,6 +1456,9 @@ static void np_commit_load_sync(void) g_np.needs_advance = 0; g_np.latched_for_tick = 0; /* staged_valid left set by prime — tip must match delay-prefix hold. */ + /* §95: load is a hard resync — drop leftover FMV DESYNC invent-hold. */ + if (g_np.rollback) + psx_netplay_rb_clear_fmv_desync_hold("netplay load sync"); } static void np_enter_load_ready(int slot) @@ -3421,7 +3449,7 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) #endif fprintf(stderr, "psxrecomp: rb binary path=%s size=%lld " - "(peers must match bit-identical)\n", + "(same game version + digests; per-peer PGO OK)\n", exe, sz); fflush(stderr); } @@ -3943,6 +3971,16 @@ static void np_maybe_stage_target_save(void) fflush(stdout); } +/* §94: SAVE freeze opens a tip hole; invent-hold from FMV MAX-unmatched then + * deadlocks admit (stall looked like fmv_settle). Clear invent-hold so gap1 + * invent can refill before the 20s watchdog. */ +static void np_note_save_complete(void) +{ + if (!g_np.rollback) + return; + psx_netplay_rb_clear_fmv_desync_hold("netplay save complete"); +} + static void np_pump_session(void) { #if defined(PSX_HAS_LOBBY_CLIENT) @@ -4008,6 +4046,66 @@ static int np_try_admit_gameplay(void) return 0; } +/* + * §95: rollback LOAD barrier admit — tip + hold-last invent, no INPUT_CONFIRM. + * Delay-sync try_admit wait_confirm hung the slower peer after the faster one + * applied and froze (guest LOADED, host stuck load_applying+wait_confirm). + * Load will hard_resync at mutual ready; pads here only need guest cycles for + * savestate_poll / one resume tick. + */ +static int np_try_admit_load_barrier_rb(void) +{ + rnet_u32 sim = rnet_session_sim_tick(g_np.session); + rnet_u32 wire; + RNetInputSample sample; + RNetRbFrame row; + PsxNetPad pad; + int slot; + + np_sched_set_admit_stall("load_barrier_rb"); + if (!rnet_session_prepare_local_tip(g_np.session, sim)) { + np_sched_set_admit_stall("load_barrier_prepare_tip"); + force_session_pads_connected(g_np.slot_count); + return 0; + } + wire = np_sched_wire_for_sim(sim); + + for (slot = 0; slot < g_np.slot_count; ++slot) { + if (slot == g_np.local_slot) { + if (rnet_session_peek_input(g_np.session, slot, wire, &sample)) { + decode_pad(&sample, &pad); + netplay_ih_pad_to_frame(&pad, sim, 0, &row); + (void)netplay_ih_put(&g_np.ih, slot, &row); + } else if (g_np.staged_valid) { + netplay_ih_pad_to_frame(&g_np.staged, sim, 0, &row); + (void)netplay_ih_put(&g_np.ih, slot, &row); + } else { + memset(&pad, 0, sizeof(pad)); + pad.buttons = 0xFFFFu; + pad.lx = pad.ly = pad.rx = pad.ry = 0x80u; + pad.analog = 0; + pad.connected = 1; + netplay_ih_pad_to_frame(&pad, sim, 0, &row); + (void)netplay_ih_put(&g_np.ih, slot, &row); + } + continue; + } + if (rnet_session_peek_remote_input(g_np.session, slot, wire, &sample)) { + decode_pad(&sample, &pad); + netplay_ih_pad_to_frame(&pad, sim, 0, &row); + (void)netplay_ih_put(&g_np.ih, slot, &row); + } else { + /* Never stall — peer may already be frozen in LOAD_READY. */ + (void)netplay_ih_invent_hold_last(&g_np.ih, slot, sim, &row); + } + } + + np_publish_hist_sio(sim); + g_np.needs_advance = 1; + np_sched_set_admit_stall(""); + return 1; +} + int psx_netplay_poll_admit(void) { rnet_u32 sim; @@ -4036,31 +4134,42 @@ int psx_netplay_poll_admit(void) return 0; /* Staged load must run guest cycles — bypass starvation latch. ICE xfer - * often leaves lead=D-1 and would otherwise block try_admit forever. */ + * often leaves lead=D-1 and would otherwise block try_admit forever. + * §95: rollback must not use delay-sync confirm here. */ if (g_np.xfer == NP_XFER_LOAD_APPLYING && savestate_pending()) { if (g_np.needs_advance) return 1; + if (g_np.rollback) + return np_try_admit_load_barrier_rb(); return np_try_admit_gameplay(); } - /* Both peers: after mutual ready + sync, stay in LOAD_READY until try_admit - * succeeds (fresh tip exchange + INPUT_CONFIRM). Dropping the barrier early - * on the host let it spin on confirm with FPS/present already "live". */ + /* Both peers: after mutual ready + sync, stay in LOAD_READY until admit + * succeeds. Dropping the barrier early on the host let it spin on confirm + * with FPS/present already "live". §95: rollback exits via tip invent. */ if (g_np.xfer == NP_XFER_LOAD_READY) { if (g_np.load_sync_done && g_np.load_ready_replied && !g_np.needs_advance) { - sim = rnet_session_sim_tick(g_np.session); - if (rnet_session_try_admit(g_np.session, sim)) { + int admitted; + if (g_np.rollback) + admitted = np_try_admit_load_barrier_rb(); + else { + sim = rnet_session_sim_tick(g_np.session); + admitted = rnet_session_try_admit(g_np.session, sim); + if (admitted) + g_np.needs_advance = 1; + else + force_session_pads_connected(g_np.slot_count); + } + if (admitted) { g_np.xfer = NP_XFER_NONE; g_np.load_applied_local = 0; g_np.load_ready_replied = 0; g_np.load_sync_done = 0; - g_np.needs_advance = 1; printf("psxrecomp: netplay load slot=%d — peer ready, resuming lockstep\n", g_np.xfer_slot); fflush(stdout); return 1; } - force_session_pads_connected(g_np.slot_count); } return 0; } diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index d8f5d059..53f37a71 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -34,7 +34,21 @@ int psx_netplay_rb_rewind_suppressed(void) { return 0; } int psx_netplay_rb_fmv_defer_rewind(void) { return 0; } int psx_netplay_rb_fmv_media_active(void) { return 0; } int psx_netplay_rb_lockstep_no_invent(void) { return 0; } +int psx_netplay_rb_fmv_desync_hold(void) { return 0; } +void psx_netplay_rb_clear_fmv_desync_hold(const char *why) { (void)why; } int psx_netplay_rb_fmv_episode_unsafe(uint32_t tick) { (void)tick; return 0; } +int psx_netplay_rb_media_kf_probe_match(uint32_t size, uint32_t crc) +{ + (void)size; + (void)crc; + return 0; +} +int psx_netplay_rb_media_kf_on_ready(const void *data, size_t size) +{ + (void)data; + (void)size; + return 0; +} void psx_netplay_rb_poll_replay_stall(void) {} int psx_netplay_rb_take_promote_sweep(void) { return 0; } int psx_netplay_rb_fmv_unlock_grace_active(void) { return 0; } @@ -102,6 +116,7 @@ uint32_t psx_netplay_rb_rtt_estimate_ms(void) { return 0; } #include "cpu_state.h" #include "crc32.h" #include "gpu.h" +#include "gpu_vram_dirty.h" #include "interrupts.h" #include "mdec.h" #include "psx_cycles.h" @@ -175,6 +190,29 @@ static uint32_t snap_interval(void) } return iv; } + +static int rb_media_kf_enabled(void); /* §97 — defined near episode wire clear */ + +/* §96/§98: FMV media snap interval. Default 4; MEDIA_KF uses 2 (near-tip + * loads without every-tick 0.7 ms snap tax). Override: PSX_NET_FMV_SNAP_INTERVAL. */ +static uint32_t fmv_media_snap_interval(void) +{ + static int latched; + static uint32_t iv = 4u; + if (!latched) { + const char *e = getenv("PSX_NET_FMV_SNAP_INTERVAL"); + if (e && e[0]) { + unsigned v = 0; + if (sscanf(e, "%u", &v) == 1 && v >= 1u && v <= 16u) + iv = v; + } else if (rb_media_kf_enabled()) { + iv = 2u; + } + latched = 1; + } + return iv; +} + /* §58 dense tip snaps: live saves every tick inside a sliding window so a * near-tip mispredict loads at (or within a tick of) the mismatch instead of * up to snap_interval-1 ticks below it (§57 soak: depth_avg 14–41 with @@ -348,6 +386,14 @@ static size_t g_pin_size; static uint32_t g_pin_tick; static int g_pin_valid; +/* §97: FMV media keyframe episode — host forces identical snap at load_tick + * before Replay (probe CRC, transfer on miss). Default ON; set + * PSX_NET_FMV_MEDIA_KF=0 to restore §93 refuse / invent-off during media. */ +static int g_media_kf_episode; +static int g_media_kf_ready; /* pin holds authoritative KF for load */ +static int g_media_kf_host_armed; /* host started probe/send for this episode */ +static int g_media_kf_sending; /* host transfer in flight */ + /* §67 resim audit digest cache (fin state per replayed tick) — declared here * so pin_baseline_from_cpu can compare pin digest vs audit fin (§78 PIN-SKEW * diag). See log_resim_tick_audit for the cache contract. */ @@ -430,6 +476,11 @@ static uint32_t g_last_begin_mismatch = 0xffffffffu; #define RB_FMV_LOCKSTEP_MIN 180u #define RB_FMV_LOCKSTEP_MAX 300u #define RB_FMV_LOCKSTEP_CONFIRM 16u +/* §94: MAX-unmatched DESYNC invent-hold must not last forever. Soft-forked + * digests never rematch CONFIRM ticks — invent stays off, then a SAVE/hitch + * tip hole deadlocks admit (soak: stall=fmv_settle → lobby). Expire invent + * hold after this many sim ticks; episode media-range refuse is separate. */ +#define RB_FMV_DESYNC_INVENT_EXPIRE 600u /* After cores match (or MAX): keep invent off this many more ticks so admit * waits for wire. Prevents hold-last sticky Up (pub=ffef vs wire=ffff) from * inventing at unlock+1 → FIRST CORE DIVERGE / tip episode. Matches dense @@ -545,6 +596,7 @@ static uint32_t g_fmv_media_hi; /* §93: FMV lockstep hit MAX with streak < CONFIRM — cores never agreed. * Keep invent/begin off until cores rematch CONFIRM ticks or session reset. */ static int g_fmv_unmatched_desync; +static uint32_t g_fmv_unmatched_desync_arm_sim; /* §93: consecutive resim-core aborts on the same diverge sim. */ static uint32_t g_resim_diverge_tick; static uint32_t g_resim_diverge_streak; @@ -614,6 +666,7 @@ static void clear_tip_hold_rereplay_pending(void); static int flush_tip_hold_deferred_rereplay(void); static void arm_rewind_cooldown_ticks(uint32_t sim, uint32_t ticks, const char *why); static void clear_rewind_cooldown(const char *why); +static void rb_clear_fmv_desync_hold(const char *why); static void tip_hold_try_finalize(void); static void schedule_episode_rereplay(uint32_t prefer_plus_one); static void ownership_on_post_match(uint32_t tip); @@ -830,13 +883,13 @@ static void rb_fmv_update_lockstep_gate(uint32_t sim) if (g_fmv_lockstep_released) { if (g_fmv_unmatched_desync && g_fmv_core_match_streak >= RB_FMV_LOCKSTEP_CONFIRM) { - fprintf(stderr, - "psxrecomp: rb FMV DESYNC cleared — cores rematched " - "streak=%u sim=%u\n", - (unsigned)g_fmv_core_match_streak, (unsigned)sim); - fflush(stderr); - g_fmv_unmatched_desync = 0; - clear_rewind_cooldown("FMV cores rematched after MAX unmatched"); + { + char why[96]; + snprintf(why, sizeof(why), + "cores rematched streak=%u sim=%u", + (unsigned)g_fmv_core_match_streak, (unsigned)sim); + rb_clear_fmv_desync_hold(why); + } } return; } @@ -868,15 +921,18 @@ static void rb_fmv_update_lockstep_gate(uint32_t sim) if (dense > g_fmv_lockstep_until) g_fmv_lockstep_until = dense; g_fmv_unmatched_desync = 1; + g_fmv_unmatched_desync_arm_sim = sim; arm_rewind_cooldown_ticks(sim, RB_FORK_STORM_COOLDOWN_TICKS, "FMV lockstep MAX unmatched " "(cores never confirmed)"); fprintf(stderr, "psxrecomp: rb DESYNC — FMV lockstep MAX unmatched " "sim=%u streak=%u cap=%u — invent/begin held until " - "cores rematch (confirm=%u)\n", + "cores rematch (confirm=%u) or invent-hold expires " + "(+%u ticks) / netplay save\n", (unsigned)sim, (unsigned)g_fmv_core_match_streak, - (unsigned)cap, (unsigned)RB_FMV_LOCKSTEP_CONFIRM); + (unsigned)cap, (unsigned)RB_FMV_LOCKSTEP_CONFIRM, + (unsigned)RB_FMV_DESYNC_INVENT_EXPIRE); fflush(stderr); } fprintf(stderr, @@ -1031,34 +1087,57 @@ static int rb_fmv_tick_unsafe_for_episode(uint32_t tick) return 0; } -/* Defer rewind during movie + short settle. */ +/* §94: drop MAX-unmatched invent-hold (rematch, save complete, or expire). */ +static void rb_clear_fmv_desync_hold(const char *why) +{ + if (!g_fmv_unmatched_desync) + return; + g_fmv_unmatched_desync = 0; + g_fmv_unmatched_desync_arm_sim = 0; + fprintf(stderr, + "psxrecomp: rb FMV DESYNC invent-hold cleared (%s)\n", + why ? why : "?"); + fflush(stderr); + clear_rewind_cooldown(why ? why : "FMV DESYNC invent-hold cleared"); +} + +/* Expire invent-hold when soft-fork digests never rematch CONFIRM ticks. */ +static void rb_fmv_desync_invent_maybe_expire(uint32_t sim) +{ + if (!g_fmv_unmatched_desync || g_fmv_unmatched_desync_arm_sim == 0u) + return; + if (sim < g_fmv_unmatched_desync_arm_sim + RB_FMV_DESYNC_INVENT_EXPIRE) + return; + rb_clear_fmv_desync_hold("invent-hold expired (no rematch)"); +} + +/* Defer rewind during movie + short settle. + * §97: with MEDIA_KF, allow rewind/episodes into live media (keyframe + * heals stream forks); settle + DESYNC hold still defer. */ static int rb_in_fmv_defer_rewind_window(void) { RNetSession *s = sess(); uint32_t sim = s ? rnet_session_sim_tick(s) : 0u; rb_fmv_tick_settle(); + rb_fmv_desync_invent_maybe_expire(sim); if (rb_fmv_media_active()) - return 1; + return rb_media_kf_enabled() ? 0 : 1; if (g_fmv_unmatched_desync) return 1; return sim < g_fmv_settle_until; } /* Admit no-invent gate (§26): media + short settle only. - * Pre-§26 also blocked invent through g_fmv_lockstep_until (MIN 180 + - * UNLOCK_GRACE 64 ≈ 4s of title-menu delay-sync). On TURN/CGNAT that - * re-serialized presentation to ~packet rate (~30 fps) even with - * PSX_RB_GAP1_INVENT — invent count was 0 from FMV-on through invent_at. - * Digest-gated lockstep (g_fmv_lockstep_until) still runs for dense snaps - * / RELEASE logs; it no longer stalls admit. - * §93: also hold invent after MAX-unmatched DESYNC until cores rematch. */ + * §97: MEDIA_KF allows invent during live media (episodes open with host + * keyframe). Settle + DESYNC invent-hold unchanged. */ static int rb_in_fmv_lockstep_window(void) { RNetSession *s = sess(); uint32_t sim = s ? rnet_session_sim_tick(s) : 0u; rb_fmv_tick_settle(); + rb_fmv_desync_invent_maybe_expire(sim); if (rb_fmv_media_active()) - return 1; + return rb_media_kf_enabled() ? 0 : 1; if (g_fmv_unmatched_desync) return 1; return sim < g_fmv_settle_until; @@ -1378,6 +1457,143 @@ static void clear_episode_wire_state(void) g_seal_wait_logged = 0; g_last_begin_rexmit_ms = 0; g_begin_rexmit_logged = 0; + g_media_kf_episode = 0; + g_media_kf_ready = 0; + g_media_kf_host_armed = 0; + g_media_kf_sending = 0; +} + +/* §97: default ON — invent/episodes into FMV media with host keyframe. */ +static int rb_media_kf_enabled(void) +{ + static int latched = -1; + if (latched < 0) { + const char *e = getenv("PSX_NET_FMV_MEDIA_KF"); + latched = (e && e[0] == '0') ? 0 : 1; + } + return latched; +} + +static void clear_baseline_pin(void); + +static int rb_install_media_kf_bytes(uint32_t tick, const uint8_t *data, size_t size) +{ + uint8_t *ring_copy; + if (!data || !size) + return 0; + clear_baseline_pin(); + g_pin_data = (uint8_t *)malloc(size); + if (!g_pin_data) + return 0; + memcpy(g_pin_data, data, size); + g_pin_size = size; + g_pin_tick = tick; + g_pin_valid = 1; + /* Ring store takes ownership — keep a separate copy from the pin. */ + if (g_snaps) { + ring_copy = (uint8_t *)malloc(size); + if (ring_copy) { + memcpy(ring_copy, data, size); + if (!netplay_snap_ring_store(g_snaps, tick, ring_copy, size)) + ; /* store frees ring_copy on hard failure */ + } + } + g_media_kf_ready = 1; + return 1; +} + +static int rb_media_kf_seal_local(uint32_t tick) +{ + size_t sz = 0; + const uint8_t *p; + if (!g_snaps || !netplay_snap_ring_has(g_snaps, tick)) + return 0; + p = netplay_snap_ring_peek(g_snaps, tick, &sz); + if (!p || !sz) + return 0; + return rb_install_media_kf_bytes(tick, p, sz); +} + +static void rb_media_kf_host_drive(void) +{ + RNetSession *s = sess(); + uint32_t load; + size_t sz = 0; + const uint8_t *p; + int match = 0; + uint32_t crc; + int local_slot; + + if (!g_media_kf_episode || !g_rb || !s || !rnet_rb_is_active(g_rb)) + return; + if (g_media_kf_ready) + return; + local_slot = g_b.local_slot ? *g_b.local_slot : 0; + if (local_slot != 0) + return; /* host-only transfer */ + if (rnet_session_state_busy(s) && !g_media_kf_sending) + return; /* SAVE/LOAD in flight — wait */ + + load = rnet_rb_get_load_tick(g_rb); + p = g_snaps ? netplay_snap_ring_peek(g_snaps, load, &sz) : NULL; + if (!p || !sz) { + static uint32_t s_miss_log; + if (s_miss_log != load) { + fprintf(stderr, + "psxrecomp: rb MEDIA-KF host — no snap at load=%u\n", + (unsigned)load); + fflush(stderr); + s_miss_log = load; + } + return; + } + + if (!g_media_kf_host_armed) { + crc = rnet_checksum(p, sz); + if (rnet_session_state_probe(s, RNET_STATE_OP_RB_KF, 0, (rnet_u32)sz, + crc) != 0) + return; + g_media_kf_host_armed = 1; + fprintf(stderr, + "psxrecomp: rb MEDIA-KF probe load=%u bytes=%zu crc=%08x\n", + (unsigned)load, sz, (unsigned)crc); + fflush(stderr); + return; + } + + if (g_media_kf_sending) { + /* Wait for take_ready on host side (sender also gets ready). */ + return; + } + + if (rnet_session_state_probe_take_reply(s, &match)) { + rnet_session_state_probe_finish(s); + if (match) { + if (rb_media_kf_seal_local(load)) { + fprintf(stderr, + "psxrecomp: rb MEDIA-KF hash match load=%u — local " + "pin sealed\n", + (unsigned)load); + fflush(stderr); + } + return; + } + /* Miss: send host snap bytes. */ + if (rnet_session_state_begin(s, RNET_STATE_OP_RB_KF, 0, p, sz) != 0) { + fprintf(stderr, + "psxrecomp: rb MEDIA-KF begin FAILED load=%u\n", + (unsigned)load); + fflush(stderr); + g_media_kf_host_armed = 0; + return; + } + g_media_kf_sending = 1; + (void)rb_media_kf_seal_local(load); + fprintf(stderr, + "psxrecomp: rb MEDIA-KF transfer start load=%u bytes=%zu\n", + (unsigned)load, sz); + fflush(stderr); + } } static void clear_baseline_pin(void) @@ -5134,10 +5350,16 @@ void psx_netplay_rb_start(void) if (!g_bound || !s) return; + /* §96: dirty-VRAM tracking only while RB netplay is live. */ + gpu_vram_dirty_set_tracking(1); + boot_state_vram_mirror_reset(); + g_snaps = netplay_snap_ring_create(NETPLAY_SNAP_RING_DEFAULT_DEPTH); tip_dense_reset(); if (!g_snaps) { fprintf(stderr, "psxrecomp: rb snap ring create FAILED — rewind disabled\n"); + gpu_vram_dirty_set_tracking(0); + boot_state_vram_mirror_reset(); return; } delay = g_b.input_delay ? *g_b.input_delay : 2; @@ -5245,6 +5467,8 @@ void psx_netplay_rb_shutdown(void) netplay_snap_ring_destroy(g_snaps); g_snaps = NULL; } + gpu_vram_dirty_set_tracking(0); + boot_state_vram_mirror_reset(); clear_baseline_pin(); g_pending_save_valid = 0; g_pending_load_valid = 0; @@ -5266,6 +5490,7 @@ void psx_netplay_rb_shutdown(void) g_fmv_media_lo = 0; g_fmv_media_hi = 0; g_fmv_unmatched_desync = 0; + g_fmv_unmatched_desync_arm_sim = 0; g_resim_diverge_tick = 0; g_resim_diverge_streak = 0; g_bl_mismatch_streak = 0; @@ -5311,15 +5536,13 @@ void psx_netplay_rb_shutdown(void) g_rb_rtt_ema_ms = 0; } -/* Dense tip snaps: media/settle, plus the digest lockstep/+tip window even - * after invent is allowed again (§26) — first Cross/Start after FMV still - * wants a near-tip load. */ +/* Dense tip snaps: settle + digest lockstep/+tip (§26) — first Cross/Start + * after FMV still wants a near-tip load. Media uses fmv_media_snap_interval + * instead (§96); episodes into media remain refused. */ static int rb_fmv_dense_snap_window(void) { RNetSession *s = sess(); uint32_t sim = s ? rnet_session_sim_tick(s) : 0u; - if (rb_fmv_media_active()) - return 1; if (sim < g_fmv_settle_until) return 1; if (g_fmv_media_end_sim && @@ -5341,9 +5564,9 @@ void psx_netplay_rb_request_snap(uint32_t tick) return; /* Resim must keep a snap at every replayed tick (post-verify + next * correction). Live path rate-limits — full boot_state snaps dominate FPS. - * Exception: media + post-FMV lockstep/+tip keep dense snaps so the first - * invent-miss loads near the mismatch (954→944 was a ~10-tick hitch; - * with tip snaps + shared frontier → load≈953). */ + * Exception: post-FMV settle/lockstep/+tip keep every-tick snaps so the + * first invent-miss loads near the mismatch. FMV media (§96) uses a + * coarser interval — episodes into media are refused anyway. */ if (!(g_rb && rnet_rb_is_resimulating(g_rb))) { /* §75: sealed Replay snaps are authoritative — Live invent must not * poison them for tip-extend rereplay. */ @@ -5354,6 +5577,12 @@ void psx_netplay_rb_request_snap(uint32_t tick) * first_bad-1 still in the ring after a 16–30 tick REMOTE-HELD walk. */ if (g_rb && rnet_rb_is_tip_holding(g_rb) && tip_dense_window() > 0u) { tip_dense_push(tick); + } else if (rb_fmv_media_active()) { + iv = fmv_media_snap_interval(); + if (iv > 1u && (tick % iv) != 0u) + return; + if (tick >= g_fmv_dense_through) + g_fmv_dense_through = tick; } else if (!rb_fmv_dense_snap_window()) { iv = snap_interval(); if (iv > 1u && (tick % iv) != 0u) { @@ -5482,11 +5711,15 @@ static int try_apply_pending_load(CPUState *cpu_in) int loaded = 0; int loaded_from_pin = 0; uint32_t loaded_tick = g_pending_load_tick; + /* §97: wait for host MEDIA-KF pin before applying into FMV media. */ + if (g_media_kf_episode && !g_media_kf_ready) + return 0; /* Prefer pin when it matches the load tick: live realign and §76 * tip-hold flush (sealed tip at tip-hold entry — ring may still be - * invent-poisoned). */ + * invent-poisoned). §97 MEDIA-KF always prefers the sealed pin. */ if (g_pin_valid && g_pin_tick == loaded_tick && g_pin_data && - g_pin_size && (live_realign || g_tip_extend_rereplay)) { + g_pin_size && + (live_realign || g_tip_extend_rereplay || g_media_kf_episode)) { loaded = boot_state_load_buffer(g_pin_data, g_pin_size, *g_b.bios_checksum, *g_b.entry_pc, cpu_in); @@ -5664,21 +5897,22 @@ static int try_apply_pending_load(CPUState *cpu_in) (unsigned)loaded_tick, (unsigned)pc); fflush(stderr); /* §93: Live media detector can be cold while the load snap is mid- - * FMV (soak: begin@184 load=176 → flush media=1). Expand the media - * range and abort before Replay poisons both peers. */ + * FMV. Expand the media range. §97 MEDIA-KF episodes continue into + * Replay with the host-sealed keyframe (no abort). */ if (rb_fmv_media_active()) { if (!g_was_in_fmv) { g_was_in_fmv = 1; fprintf(stderr, "psxrecomp: rb FMV rewind-defer ON " - "(depth24/mdec; no invent)\n"); + "(depth24/mdec; media_kf=%d)\n", + g_media_kf_episode); fflush(stderr); } if (g_fmv_media_lo == 0u || loaded_tick < g_fmv_media_lo) g_fmv_media_lo = loaded_tick; if (loaded_tick >= g_fmv_media_hi) g_fmv_media_hi = loaded_tick; - if (!g_ownership_skip_snap) { + if (!g_media_kf_episode && !g_ownership_skip_snap) { char why[96]; snprintf(why, sizeof(why), "episode load into FMV media (load=%u)", @@ -5748,6 +5982,9 @@ void psx_netplay_rb_flush_resume(void) { extern int g_psx_cyc_bb_defer; extern uint32_t g_psx_cyc_batch; + extern uint32_t *g_psx_cyc_local_acc; + if (g_psx_cyc_local_acc) *g_psx_cyc_local_acc = 0; + g_psx_cyc_local_acc = NULL; g_psx_cyc_bb_defer = 0; g_psx_cyc_batch = 0; } @@ -5850,17 +6087,81 @@ int psx_netplay_rb_fmv_media_active(void) int psx_netplay_rb_lockstep_no_invent(void) { - /* Media + settle only (§26). Post-FMV title menus invent+resim. - * §93: also hold after MAX-unmatched DESYNC until cores rematch. */ + /* Media + settle (§26). Post-FMV title menus invent+resim. + * §93/§94: also hold after MAX-unmatched DESYNC until rematch, + * invent-hold expire, or netplay save complete. */ return rb_in_fmv_lockstep_window(); } +int psx_netplay_rb_fmv_desync_hold(void) +{ + RNetSession *s = sess(); + uint32_t sim = s ? rnet_session_sim_tick(s) : 0u; + rb_fmv_desync_invent_maybe_expire(sim); + return g_fmv_unmatched_desync ? 1 : 0; +} + +void psx_netplay_rb_clear_fmv_desync_hold(const char *why) +{ + rb_clear_fmv_desync_hold(why); +} + int psx_netplay_rb_fmv_episode_unsafe(uint32_t tick) { (void)rb_in_fmv_defer_rewind_window(); + /* §97: MEDIA_KF allows episodes into media-range (begin arms keyframe). + * DESYNC invent-hold still blocks. */ + if (rb_media_kf_enabled() && !g_fmv_unmatched_desync) + return 0; return rb_fmv_tick_unsafe_for_episode(tick); } +int psx_netplay_rb_media_kf_probe_match(uint32_t size, uint32_t crc) +{ + uint32_t load; + size_t sz = 0; + const uint8_t *p; + if (!g_media_kf_episode || !g_rb || !g_snaps) + return 0; + load = rnet_rb_get_load_tick(g_rb); + p = netplay_snap_ring_peek(g_snaps, load, &sz); + if (!p || sz != (size_t)size) + return 0; + if (rnet_checksum(p, sz) != crc) + return 0; + /* Seal local pin so apply can proceed without waiting for a no-op xfer. */ + (void)rb_media_kf_seal_local(load); + return 1; +} + +int psx_netplay_rb_media_kf_on_ready(const void *data, size_t size) +{ + uint32_t load; + if (!g_media_kf_episode || !g_rb) + return 0; + load = rnet_rb_get_load_tick(g_rb); + if (!g_media_kf_ready) { + if (data && size > 0) { + if (!rb_install_media_kf_bytes(load, (const uint8_t *)data, size)) { + fprintf(stderr, + "psxrecomp: rb MEDIA-KF install FAILED load=%u " + "bytes=%zu\n", + (unsigned)load, size); + fflush(stderr); + return 1; + } + fprintf(stderr, + "psxrecomp: rb MEDIA-KF installed load=%u bytes=%zu\n", + (unsigned)load, size); + fflush(stderr); + } else { + (void)rb_media_kf_seal_local(load); + } + } + g_media_kf_sending = 0; + return 1; +} + void psx_netplay_rb_poll_replay_stall(void) { uint64_t now; @@ -6264,9 +6565,8 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) } return 0; } - /* §93: refuse when the *mismatch* sits in the last media bout — Live tip - * may already be past FMV while hc-fork still targets an in-media tick. */ - if (rb_fmv_tick_unsafe_for_episode(mismatch_tick)) { + /* §93 refuse / §97 MEDIA_KF: mismatch in prior media bout. */ + if (rb_fmv_tick_unsafe_for_episode(mismatch_tick) && !rb_media_kf_enabled()) { static uint32_t s_media_mm_refuse_sim; if (s_media_mm_refuse_sim != sim) { fprintf(stderr, @@ -6386,17 +6686,30 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) s_refuse_last_mismatch = 0xffffffffu; } - /* §93: chosen load may sit inside a prior FMV bout even when Live tip and - * mismatch are past settle (soak: load=176 / 736 into media). */ - if (rb_fmv_tick_unsafe_for_episode(load)) { - fprintf(stderr, - "psxrecomp: rb begin REFUSED mismatch=%u load=%u — FMV " - "media-range load (lo=%u hi=%u settle_until=%u)\n", - (unsigned)mismatch_tick, (unsigned)load, - (unsigned)g_fmv_media_lo, (unsigned)g_fmv_media_hi, - (unsigned)g_fmv_settle_until); - fflush(stderr); - return 0; + /* §93 refuse / §97 MEDIA_KF path for loads inside a prior FMV bout. */ + { + int media_load = rb_fmv_tick_unsafe_for_episode(load) || + rb_fmv_tick_unsafe_for_episode(mismatch_tick); + if (media_load && !rb_media_kf_enabled()) { + fprintf(stderr, + "psxrecomp: rb begin REFUSED mismatch=%u load=%u — FMV " + "media-range load (lo=%u hi=%u settle_until=%u)\n", + (unsigned)mismatch_tick, (unsigned)load, + (unsigned)g_fmv_media_lo, (unsigned)g_fmv_media_hi, + (unsigned)g_fmv_settle_until); + fflush(stderr); + return 0; + } + if (media_load && rb_media_kf_enabled()) { + if (!g_snaps || !netplay_snap_ring_has(g_snaps, load)) { + fprintf(stderr, + "psxrecomp: rb begin REFUSED mismatch=%u load=%u — " + "MEDIA-KF needs snap at load\n", + (unsigned)mismatch_tick, (unsigned)load); + fflush(stderr); + return 0; + } + } } target = rnet_rb_suggest_target(g_rb, mismatch_tick, sim); @@ -6435,7 +6748,7 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) /* Prefer MotK agreed tip when library watermark not yet set. Depth * ceiling comes from the session's configured light_tip_max_depth * (== RB_MOTK_TIP_RUNWAY), not the library default — see - * psx_netplay_rb_start(). §93: never light-tip into/near FMV media. */ + * psx_netplay_rb_start(). §93/§97: never light-tip into/near FMV media. */ if (g_agreed_valid && !rb_fmv_tick_unsafe_for_episode(load) && !rb_fmv_tick_unsafe_for_episode(target) && @@ -6447,6 +6760,15 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) rnet_rb_begin_episode(g_rb, &corr); psx_netplay_timesync_on_episode_boundary(); clear_episode_wire_state(); + /* §97: arm MEDIA_KF after wire clear (clear resets the flag). */ + if (rb_media_kf_enabled() && + (rb_fmv_tick_unsafe_for_episode(load) || + rb_fmv_tick_unsafe_for_episode(mismatch_tick))) { + g_media_kf_episode = 1; + g_media_kf_ready = 0; + g_media_kf_host_armed = 0; + g_media_kf_sending = 0; + } g_episode_snap_applied = 0; g_pending_resume_valid = 0; g_needs_advance = 0; @@ -6463,6 +6785,8 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) rnet_u8 wire_flags = (rnet_u8)( (rnet_rb_get_corr_flags(g_rb) & RNET_RB_CORR_LIGHT_TIP) ? RNET_RB_SYNC_FLAG_LIGHT_TIP : 0u); + if (g_media_kf_episode) + wire_flags = (rnet_u8)(wire_flags | RNET_RB_SYNC_FLAG_MEDIA_KF); (void)rnet_session_send_rb_sync(s, corr.epoch_id, corr.mismatch_tick, corr.load_tick, corr.target_tick, (rnet_u8)(slot < 0 ? 0 : slot), RNET_RB_SYNC_OP_BEGIN, wire_flags); @@ -6470,10 +6794,10 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) export_local_seals(); fprintf(stderr, "psxrecomp: rb begin epoch=%u mismatch=%u load=%u target=%u slot=%d " - "light=%u (snaps=%u %u..%u local_slot=%d)\n", + "light=%u media_kf=%d (snaps=%u %u..%u local_slot=%d)\n", (unsigned)corr.epoch_id, (unsigned)mismatch_tick, (unsigned)load, (unsigned)target, - slot, (unsigned)rnet_rb_recommend_light_tip(g_rb), (unsigned)snap_n, - (unsigned)snap_lo, (unsigned)snap_hi, + slot, (unsigned)rnet_rb_recommend_light_tip(g_rb), g_media_kf_episode, + (unsigned)snap_n, (unsigned)snap_lo, (unsigned)snap_hi, g_b.local_slot ? *g_b.local_slot : -1); fflush(stderr); @@ -6917,35 +7241,65 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin g_follow_nack_sends = 1; return; } - /* §93: refuse follow when load/mismatch sits in the last FMV media bout. */ - if (rb_fmv_tick_unsafe_for_episode(load) || - rb_fmv_tick_unsafe_for_episode(mismatch)) { - fprintf(stderr, - "psxrecomp: rb follow REFUSED epoch=%u load=%u mismatch=%u — " - "FMV media-range (lo=%u hi=%u settle_until=%u)\n", - (unsigned)epoch, (unsigned)load, (unsigned)mismatch, - (unsigned)g_fmv_media_lo, (unsigned)g_fmv_media_hi, - (unsigned)g_fmv_settle_until); - fflush(stderr); - g_follow_nack_pending = 1; - g_follow_nack_epoch = epoch; - g_follow_nack_mismatch = mismatch; - g_follow_nack_load = load; - g_follow_nack_target = target; - g_follow_nack_slot = slot; - g_follow_nack_sends = 0; - send_follow_nack(epoch, mismatch, load, target, slot, 1); - g_follow_nack_sends = 1; - return; + /* §93 refuse / §97 MEDIA_KF follow into prior FMV bout. */ + { + int media_ep = (wire_flags & RNET_RB_SYNC_FLAG_MEDIA_KF) || + rb_fmv_tick_unsafe_for_episode(load) || + rb_fmv_tick_unsafe_for_episode(mismatch); + if (media_ep && !rb_media_kf_enabled() && + !(wire_flags & RNET_RB_SYNC_FLAG_MEDIA_KF)) { + fprintf(stderr, + "psxrecomp: rb follow REFUSED epoch=%u load=%u mismatch=%u — " + "FMV media-range (lo=%u hi=%u settle_until=%u)\n", + (unsigned)epoch, (unsigned)load, (unsigned)mismatch, + (unsigned)g_fmv_media_lo, (unsigned)g_fmv_media_hi, + (unsigned)g_fmv_settle_until); + fflush(stderr); + g_follow_nack_pending = 1; + g_follow_nack_epoch = epoch; + g_follow_nack_mismatch = mismatch; + g_follow_nack_load = load; + g_follow_nack_target = target; + g_follow_nack_slot = slot; + g_follow_nack_sends = 0; + send_follow_nack(epoch, mismatch, load, target, slot, 1); + g_follow_nack_sends = 1; + return; + } + if (media_ep && rb_media_kf_enabled()) { + /* Follower may lack the snap — host will transfer the keyframe. */ + ; + } else if (media_ep) { + fprintf(stderr, + "psxrecomp: rb follow REFUSED epoch=%u — MEDIA-KF disabled " + "but peer requested media episode\n", + (unsigned)epoch); + fflush(stderr); + g_follow_nack_pending = 1; + g_follow_nack_epoch = epoch; + g_follow_nack_mismatch = mismatch; + g_follow_nack_load = load; + g_follow_nack_target = target; + g_follow_nack_slot = slot; + g_follow_nack_sends = 0; + send_follow_nack(epoch, mismatch, load, target, slot, 1); + g_follow_nack_sends = 1; + return; + } } snap_n = g_snaps ? netplay_snap_ring_count(g_snaps) : 0u; /* §60: ownership-chain continue uses skip-snap at the verified tip — the * initiator does not require a ring snap there; neither may the follower, - * or we NACK a legitimate chain BEGIN and storm. */ + * or we NACK a legitimate chain BEGIN and storm. + * §97 MEDIA_KF: missing snap is OK — host transfers the keyframe. */ if (!g_snaps || !netplay_snap_ring_has(g_snaps, load)) { int skip_ok = (g_ownership_skip_snap && g_agreed_valid && load == g_agreed_through); - if (!skip_ok) { + int media_kf_ok = rb_media_kf_enabled() && + ((wire_flags & RNET_RB_SYNC_FLAG_MEDIA_KF) || + rb_fmv_tick_unsafe_for_episode(load) || + rb_fmv_tick_unsafe_for_episode(mismatch)); + if (!skip_ok && !media_kf_ok) { fprintf(stderr, "psxrecomp: rb follow REFUSED epoch=%u load=%u — snap missing " "(ring count=%u newest=%u)\n", @@ -7041,6 +7395,15 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin rnet_rb_begin_episode(g_rb, &corr); psx_netplay_timesync_on_episode_boundary(); clear_episode_wire_state(); + if (rb_media_kf_enabled() && + ((wire_flags & RNET_RB_SYNC_FLAG_MEDIA_KF) || + rb_fmv_tick_unsafe_for_episode(load) || + rb_fmv_tick_unsafe_for_episode(mismatch))) { + g_media_kf_episode = 1; + g_media_kf_ready = 0; + g_media_kf_host_armed = 0; + g_media_kf_sending = 0; + } g_episode_snap_applied = 0; g_pending_resume_valid = 0; g_needs_advance = 0; @@ -7057,9 +7420,10 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin export_local_seals(); fprintf(stderr, "psxrecomp: rb follow epoch=%u mismatch=%u load=%u target=%u " - "(snaps=%u local_slot=%d)\n", + "media_kf=%d (snaps=%u local_slot=%d)\n", (unsigned)epoch, (unsigned)mismatch, (unsigned)load, (unsigned)target, - (unsigned)snap_n, g_b.local_slot ? *g_b.local_slot : -1); + g_media_kf_episode, (unsigned)snap_n, + g_b.local_slot ? *g_b.local_slot : -1); fflush(stderr); /* Initiator's export_local_seals() burst can have beaten this follower's * own delayed SYNC through the relay — drain any stash for this epoch @@ -7086,6 +7450,9 @@ void psx_netplay_rb_pump(void) if (!g_rb || !s) return; + /* §97: host probe/transfer media keyframe while awaiting baseline. */ + rb_media_kf_host_drive(); + /* §40: drain RESOLVED before SYNC so follow BEGIN in the same poll * sees an updated frontier (max(agreed, resolved_through)). */ { diff --git a/runtime/src/psx_netplay_sched.c b/runtime/src/psx_netplay_sched.c index 87de4207..364c40c3 100644 --- a/runtime/src/psx_netplay_sched.c +++ b/runtime/src/psx_netplay_sched.c @@ -591,6 +591,12 @@ static void np_timesync_note_late(uint32_t age) g_ts_note_late_suppressed_rb++; return; } + /* §98: FMV media invent/mispredict noise must not pile timesync debt — + * each 6 ms/tick shave showed up as admit=5–11 ms/f in FMV soaks. */ + if (psx_netplay_rb_fmv_media_active()) { + g_ts_note_late_suppressed_rb++; + return; + } now = sched_mono_ms(); if (g_ts_off_until_ms != 0u && (int32_t)(now - g_ts_off_until_ms) < 0) { g_ts_note_late_suppressed_off++; @@ -715,6 +721,14 @@ static int np_timesync_throttle(uint32_t wire) np_timesync_check_enabled(); if (!g_ts_enabled) return 0; + /* §98: drain media-era debt without stalling admit (FMV guest is already + * the ceiling; do not stack timesync shaves on top). */ + if (psx_netplay_rb_fmv_media_active()) { + g_ts_debt_ms = 0u; + s_stall_until = 0u; + s_logged = 0; + return 0; + } now = sched_mono_ms(); if (wire != s_last_wire) { if (s_last_wire != 0u && wire == s_last_wire + 1u) { @@ -1052,6 +1066,10 @@ static void np_adapt_delay_on_pcap_enter(uint32_t now) /* Host / sim-authority only — guests receive DELAY_SYNC. */ if (sched_local_slot() != 0) return; + /* §98: FMV media invent storms must not ratchet D (soak: D=6→7 while + * admit climbed to 5–11 ms/f). Keep D stable through the movie. */ + if (psx_netplay_rb_fmv_media_active()) + return; if (g_pcap_window_t0_ms == 0u || (uint32_t)(now - g_pcap_window_t0_ms) > RB_ADAPT_WINDOW_MS) { @@ -1195,6 +1213,11 @@ static void np_auto_delay_tick(uint32_t now) g_ad_late_sum_ms = 0; g_ad_late_max_ms = 0; + /* §98: freeze D through FMV media — discard invent-storm samples so the + * first post-media eval does not ratchet from movie miss_rate. */ + if (psx_netplay_rb_fmv_media_active()) + return; + d = sched_delay(); if (ticks < 120u) { s_agree_streak = 0u; /* <~2 s of live samples — episode/FMV window */ @@ -1513,8 +1536,10 @@ int np_sched_on_remote_miss(int slot, uint32_t sim, uint32_t wire, * Start). Invent idle opened tip episodes that hung. Tip-ahead with a * missing consumption wire is a ring hole — log before stalling. */ if (psx_netplay_rb_lockstep_no_invent()) { + /* §94: DESYNC invent-hold is not settle — mis-tag hid the real gate. */ const char *why = psx_netplay_rb_fmv_media_active() ? "fmv_media" - : "fmv_settle"; + : (psx_netplay_rb_fmv_desync_hold() ? "fmv_desync_hold" + : "fmv_settle"); if (st->highest_remote_wire > wire) np_diag_wire_hole(slot, sim, wire, st, why); else @@ -1646,7 +1671,7 @@ int np_sched_on_remote_miss(int slot, uint32_t sim, uint32_t wire, * next tip is due by cadence. Expiry still invents * (np_gap1_note_expire_invent), so WAN behavior degrades to * §29 with a bounded 6ms tax at worst. */ - if (case_a) { + if (case_a && !psx_netplay_rb_fmv_media_active()) { uint32_t rtt_raw = 0u; int lan; int tip_due; @@ -1660,6 +1685,7 @@ int np_sched_on_remote_miss(int slot, uint32_t sim, uint32_t wire, gap1_cap = RB_GAP1_LAN_GRACE_CAP_MS; } } + /* §98: during FMV media invent immediately (no LAN micro-grace). */ } if (gap1_cap != 0u) { if (np_invent_grace_stall_ex(slot, wire, gap1_cap, 0)) { diff --git a/runtime/src/savestate.c b/runtime/src/savestate.c index 0f00f63a..a63a135d 100644 --- a/runtime/src/savestate.c +++ b/runtime/src/savestate.c @@ -340,6 +340,9 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { int ok = boot_state_save(&snap, s_bios_checksum, s_entry_pc, path); fprintf(stderr, "savestate: %s slot %d @ pc=0x%08X -> %s\n", ok ? "SAVED" : "SAVE FAILED", slot, (unsigned)resume_pc, path); + psx_frontend_on_savestate_notify(0, slot, ok); + } else { + psx_frontend_on_savestate_notify(0, slot, 0); } } @@ -362,6 +365,7 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { "savestate: LOAD FAILED blob (%zu bytes, entry=%08X)\n", blob_len, (unsigned)s_entry_pc); s_load_failed = 1; + psx_frontend_on_savestate_notify(1, slot, 0); } } else if (savestate_slot_path(slot, path, sizeof(path))) { loaded = boot_state_load(path, s_bios_checksum, s_entry_pc, cpu); @@ -370,10 +374,12 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { "savestate: LOAD FAILED slot %d %s\n", slot, path); s_load_failed = 1; + psx_frontend_on_savestate_notify(1, slot, 0); } } else { fprintf(stderr, "savestate: LOAD FAILED slot %d (no path)\n", slot); s_load_failed = 1; + psx_frontend_on_savestate_notify(1, slot, 0); } if (loaded) { t_after_boot = savestate_mono_ms(); @@ -400,6 +406,7 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { t_after_frontend - t_after_boot, t_after_frontend - t_load0, path[0] ? "" : " [blob]"); + psx_frontend_on_savestate_notify(1, slot, 1); /* Unwind to the scheduler and re-dispatch the restored PC. Never * returns; abandons the suspended CPS frames on the current stack. */ psx_scheduler_resume_at(cpu->pc); diff --git a/runtime/src/traps.c b/runtime/src/traps.c index ae753fd1..c826318b 100644 --- a/runtime/src/traps.c +++ b/runtime/src/traps.c @@ -816,6 +816,9 @@ void psx_scheduler_run(CPUState* cpu) { extern int g_psx_cyc_bb_defer; extern uint32_t g_psx_cyc_batch; + extern uint32_t *g_psx_cyc_local_acc; + if (g_psx_cyc_local_acc) *g_psx_cyc_local_acc = 0; + g_psx_cyc_local_acc = NULL; g_psx_cyc_bb_defer = 0; g_psx_cyc_batch = 0; } diff --git a/runtime/tests/test_psx_cyc_batch.c b/runtime/tests/test_psx_cyc_batch.c index d704c4e8..2c583409 100644 --- a/runtime/tests/test_psx_cyc_batch.c +++ b/runtime/tests/test_psx_cyc_batch.c @@ -8,6 +8,7 @@ uint64_t psx_next_service_cycle = 0; uint32_t g_psx_cyc_batch = 0; uint32_t g_psx_cyc_batch_limit = 0; int g_psx_cyc_bb_defer = 0; +uint32_t *g_psx_cyc_local_acc = 0; int psx_in_device_service = 0; int g_event_step_conservative = 0; int g_ls_replay_active = 0; @@ -33,6 +34,7 @@ static void reset_clock(uint64_t deadline) { g_psx_cyc_batch = 0; g_psx_cyc_batch_limit = 0; g_psx_cyc_bb_defer = 0; + g_psx_cyc_local_acc = 0; service_count = 0; } @@ -73,5 +75,22 @@ int main(void) { psx_cyc_charge(1u); assert(psx_cycle_count == 1u); assert(service_count == 1); + + /* Emitter-level local accumulator: charges stay off the published clock + * until local_publish / batch_flush; guest total at the barrier matches. */ + reset_clock(1000u); + { + uint32_t acc = 0; + psx_cyc_local_begin(&acc); + for (int i = 0; i < 100; i++) psx_cyc_charge(5u); + assert(psx_cycle_count == 0u); + assert(g_psx_cyc_batch == 0u); + assert(acc == 500u); + psx_cyc_bb_defer_flush(); + assert(psx_cycle_count == 500u); + assert(acc == 0u); + psx_cyc_local_end(); + assert(g_psx_cyc_local_acc == 0); + } return 0; }