From fe403e6ce181a47cf76d5db252948cb0eea6214f Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 31 Jul 2026 17:14:35 -0400 Subject: [PATCH] rollback patches --- docs/ROLLBACK_MOTK_HOOKUP.md | 164 +++- lib/recomp-net | 2 +- runtime/include/dirty_ram_interp.h | 11 +- runtime/include/gpu.h | 5 + runtime/include/interrupts.h | 9 + runtime/include/netplay_hash_confirm.h | 7 + runtime/include/netplay_input_hist.h | 4 +- runtime/include/overlay_loader.h | 16 + runtime/include/psx_interpreter.h | 4 + runtime/include/psx_netplay_rb.h | 37 +- runtime/include/psx_scheduler.h | 10 +- runtime/include/psx_selfcheck.h | 96 ++ runtime/runtime.cmake | 1 + runtime/src/cdrom.c | 15 +- runtime/src/dirty_ram_interp.c | 7 + runtime/src/gpu.c | 16 + runtime/src/interrupts.c | 106 +- runtime/src/main.cpp | 96 +- runtime/src/mdec.c | 357 ++++++- runtime/src/memory.c | 28 + runtime/src/netplay_hash_confirm.c | 41 + runtime/src/netplay_state_digest.c | 53 +- runtime/src/overlay_loader.c | 55 +- runtime/src/psx_cycles.c | 14 +- runtime/src/psx_interpreter.c | 6 + runtime/src/psx_netplay.c | 133 ++- runtime/src/psx_netplay_rb.c | 1063 +++++++++++++++++---- runtime/src/psx_selfcheck.c | 989 +++++++++++++++++++ runtime/src/traps.c | 36 +- runtime/tests/test_netplay_hash_confirm | Bin 16720 -> 20864 bytes runtime/tests/test_netplay_hash_confirm.c | 25 + 31 files changed, 3048 insertions(+), 358 deletions(-) create mode 100644 runtime/include/psx_selfcheck.h create mode 100644 runtime/src/psx_selfcheck.c diff --git a/docs/ROLLBACK_MOTK_HOOKUP.md b/docs/ROLLBACK_MOTK_HOOKUP.md index 355406a5..299d9b23 100644 --- a/docs/ROLLBACK_MOTK_HOOKUP.md +++ b/docs/ROLLBACK_MOTK_HOOKUP.md @@ -5,11 +5,13 @@ Status: **hooked (experimental)** · branch `feat/rollback-netplay` · depends o Today MotK / psxrecomp lobby netplay defaults to **rollback**: `stage_local → poll_admit (invent within P) → guest frame → finish_frame`. -Missing remotes are invented (**idle**, not hold-last — MotK digital) only while +Missing remotes are invented (**hold-last**, MotK digital) only while `wire_need ≤ highest_remote + P`; outside that window admit stalls (BattleShip -phase_lock). Late wire goes through the input contract; rewinds open an -`RNetRbSession` episode. Host **Disable Rollback** (or `PSX_NET_MODE=delay`) -forces classic delay-sync `try_admit`. +phase_lock). Idle invent re-mismatched every held D-pad tick after commit +(char-select episode storm). Seal gap-fill stays idle. Late wire goes through +the input contract; rewinds open an `RNetRbSession` episode. Menu releases +rewind (FMV soft-promote release-only only). Host **Disable Rollback** (or +`PSX_NET_MODE=delay`) forces classic delay-sync `try_admit`. --- @@ -52,9 +54,10 @@ RTT (BattleShip tiers / phase_lock); optional Manual Input Delay / Prediction. resume is pending or while still in `AwaitingBaseline` - [x] **Baseline gate:** send/enter Replay only after `g_episode_snap_applied` (peer BASELINE can arrive during SealInputs — must not skip restore) -- [x] Live snap rate-limit: every `PSX_NET_SNAP_INTERVAL` ticks (default **8**); - resim still snaps every tick. Ring uses **raw** `boot_state` (no zlib) — - `compress2` on RAM+VRAM+SPU was the live FPS tax; disk `.pst` still zlib +- [x] Live snap rate-limit: every `PSX_NET_SNAP_INTERVAL` ticks (default **16**, + max 32); resim still snaps every tick. Ring uses **raw** `boot_state` + (no zlib) — `compress2` on RAM+VRAM+SPU was the live FPS tax; disk `.pst` + still zlib. (Skipping VRAM on live snaps is unsafe for rewind.) - [x] Snap PC must be `psx_is_dispatchable` — pick IRQ/BB-edge resume PC (not `cpu->pc` at present, often 0); defer save / abort episode instead of `resume_at(0)` → `trap_crash` @@ -89,24 +92,86 @@ RTT (BattleShip tiers / phase_lock); optional Manual Input Delay / Prediction. FIFO on RB restore - [x] Baseline/POST/hash_confirm agree on **core** digest only (`cd=` audit); CD-only forks were aborting good title/menu Start corrections -- [x] Storm calm: 12-tick rewind cooldown + promote-sweep after **commit**; - MotK digital release-only → promote (no episode) **only while cores still - match**; else open episode. Live/seal invent is **idle** (not hold-last). - Freeze `cdrom_advance` during Replay (CD IRQ timing was forking POST cores) +- [x] Storm calm: **abort/storm** cooldown + promote-sweep only (not after + clean commit). Post-commit promote-only made char-select D-pad feel + rejected (hist ok, sim not rolled). Live invent is **hold-last** (idle + invent → hold mismatch every tick = episode storm). Seal gap-fill idle. + Menu release soft-promote removed (forked sticky Up with hold-last); + FMV settle still soft-promotes releases. Press/release → episode; held + matching invent → no episode. + **Char-select “hang” with matched POST:** digital button edges always + rewind in `rnet_input_contract` (no `hash_confirm` promote for buttons). + A tap used to be two episodes (press + release). Fixed in recomp-net: + **tip-extend** (`rnet_rb_extend_target` / MotK `psx_netplay_rb_tip_extend`) + + **TipHold** (`rnet_rb_enter_tip_hold` after POST match: Live continues, + `tip_runway` quiet window, late release tip-extends without a second + baseline) coalesce press+release into one episode. Tip-extend from + TipHold/Verify clears the POST handshake; follower FOLLOW mirrors + initiator rereplay; TipHold ignores stale rexmit POSTs. + Post-TipHold `choose_load_tick` must not pick Live tip snaps above + `agreed_through` unless `hash_confirm` confirms them (else follower + NACK past frontier). + + `tip_seal_slack` (default 2) sets initial seal headroom; + `tip_runway` (default 12) caps TipHold + suggest slack; + **light tip** + (`RNET_RB_CORR_LIGHT_TIP`) skips ready-ACK RTT when load is at the + shared frontier and depth ≤ 8. Do **not** soft-promote releases. + **Resim depth (main-menu "back to title" hang):** `choose_load_tick` + always applied interval rounding + one-interval tip slack, so a mismatch + one tick after a commit (798) re-loaded 768 and every menu tap replayed + ~30 ticks twice. Fixed with a **shared-frontier fast path**: ticks the + peer provably holds need no slack — (a) `hash_confirm` resolved_through + (both simmed, core digests matched → same interval snaps) and (b) the + last committed replay span (both peers resim-save a snap at every + replayed tick). Release-after-press now loads at commit target, ~2-tick + replay. Peer eviction still covered by follower NACK (abort, not hang). + Do **not** freeze `cdrom_advance` during Replay (FMV skip resim hung). + Promote wire into hist **before** `begin_rewind` seal. + FMV media + **digest-gated post-FMV lockstep**: no invent + refuse + begin/follow for at least MIN=90 ticks (title Start was + invent→`0x8006CDA0` episode hang); hold until hash_confirm matched + CONFIRM=16 contiguous ticks or MAX=180 (cross-game cutover). Dense + tip snaps during media/lockstep/+32 after invent unlock so the first + invent-miss loads near the mismatch (was 954→944). Diag `FIRST CORE + DIVERGE` at first post-lockstep press is invent≠wire (e.g. `ffff`→ + `ffbf` Cross), not residual FMV non-determinism — live digs matched + through lockstep. + Symmetric ready: follower ACK → initiator GO → both Replay. + **`flush_resume` releases `gpu_vblank_flush_present` reentrancy guard** + before longjmp (stuck `s_flushing_present` blocked all later presents / + `finish_frame` — MotK `0x8006CDA0` Replay hang). Mid-guest pump runs + `poll_replay_stall` (5s abort). + **Menu wait-loop resim (CD54↔CDA0):** do **not** arm deferred present on + flush_resume / do **not** re-finish `load_tick` (snap is already + post-present). Phantom fin@load before the latched VBlank IRQ put peers + on opposite ping-pong edges (`v0=1` vs countdown, ~5-cycle skew). First + arm is `load+1`; `hc` primed after load. Netplay flushes deferred present + **after** IRQ delivery when both are due (post-RFE digest). + **Returning-leaf flush_resume (`0x8006A9F8` → PC=0):** prefer IRQ/sticky/ + `$ra` over bare function-entry snap PCs; `flush_resume` clears deferred + present + bb_defer nest; sentinel same-thread path must not publish + `pc=0` during top-level RB resume; scheduler recovers null-pc via `$ra`. - [x] **Abort cooldown from live sim** (before realign rewinds the clock). Old path armed `until` from rewound tip → uncapped catch-up burned a - 12-tick window in ms → char-select episode storms (`STALE COOLDOWN`). + short window in ms → char-select episode storms (`STALE COOLDOWN`). Failed episode: 60 ticks from live (120 on streak≥2; 90 if no tip). - Reconcile promotes wire for the whole window (`promote-no-resim`) + Reconcile promotes wire for the whole abort window (`promote-no-resim`). + Clean commit does **not** arm cooldown. - [x] Netplay forces **software GPU** — GL/VK `glReadPixels` VRAM readback was forking peer snaps (core matched, pin zlib ~220KB apart) and mid-resim cores; baseline/POST also agree on `av=` (GPU+VRAM) via dig_b / POST input_digest - [x] SW GPU **before window** + late `force_sw` builds `SDL_Renderer` after GL teardown (first match was black: cleared `g_gl_active` with null present) -- [x] No rollback episodes during depth24 / recent MDEC (FMV-defer); CD-frozen - Replay was scrambling movies — promote late wire until 15-bit gameplay -- [x] FMV lockstep: stall admit (no invent) during movie + ~90-tick settle after - cutover; baseline-mismatch without agreed tip uses 60-tick cooldown +- [x] `flush_resume` releases present-flush reentrancy guard (no latched-VBlank + re-arm — that phantom-finished load and forked menu wait resim). + Symmetric ready GO. Live snaps stay on during media. Replay arms + `load+1` with present-after-IRQ on netplay BB edges. +- [x] Netplay depth24: present 1/4 vblanks; skip live FRAME_COMMIT + (full-RAM CRC) + prime `hash_confirm` when media ends +- [x] MDEC SSE2 IDCT + 24bpp YCbCr row encode (Beetle-matching, bit-identical + to scalar; self-check at `mdec_init`) — raises offline/netplay FMV on + slow peers; remaining FMV FPS is mostly host CPU ceiling - [x] MDEC snap `last_color_age` is guest-cycle relative (was host `s_frame_count` → false baseline aux trips with matched FIFO/SPU) - [x] Diag: `rb wire promote-no-resim` / `rewind-request` — late wire into hist @@ -137,6 +202,8 @@ RTT (BattleShip tiers / phase_lock); optional Manual Input Delay / Prediction. - [x] **Core digest folds GTE** (was snap-only) — MFC2 can fork GPRs with matched RAM/clk/SIO; `rb cpu-split` adds `gte=` + `rb gpr-dump` on fin/abort/post so peers can diff which regs forked +- [x] **Core digest = fold of part digests** (one RAM CRC pass, not dual + stream); `core=` values change vs older binaries — peers must match - [x] Log `rb binary path=… size=…` at rollback start — **peers must run the same bit-identical binary**. Diags showed host `build-release/` vs guest `motk-0.1.0-linux-x64/` with pin zlib ~1.34M vs ~1.13M, matched baselines, @@ -159,6 +226,16 @@ RTT (BattleShip tiers / phase_lock); optional Manual Input Delay / Prediction. peers digested different instr points. Fix: under netplay, queue the GPU vblank callback and flush in `psx_check_interrupts` (BB edge); clear deferred pending on snap resync. Offline still presents immediately. +- [x] **Menu wait resim phase (CD54↔CDA0):** after present-guard fix, ep1 + matched fin@load then forked fin@load+1 (`r2=1` vs countdown, cyc±5). + Cause: flush_resume armed deferred present for latched I_STAT → phantom + finish_frame before latched VBlank IRQ. Fix: arm `load+1` only; no + latched re-arm; present-after-IRQ when delivery is due. +- [x] **Returning-leaf resume crash:** after load+1 fix, Start-class episode + resumed at `0x8006A9F8` (`jr $ra` leaf) with IEc=0 → top-level + `execution completed, PC=0` (cps: `final_ra=0x8006CCA0`). Fix: resume + PC pick prefers IRQ/sticky/`$ra`; clear present+bb_defer on flush; + no sentinel `pc=0` while `top_level_resume`; scheduler null-pc recover. - [ ] Memory budget / thinner snap: optional strip CDROM/MDEC if MotK match path allows (further RAM headroom) - [x] Standalone ring bookkeeping test: `runtime/tests/test_netplay_snap_ring.c` @@ -187,7 +264,8 @@ RTT (BattleShip tiers / phase_lock); optional Manual Input Delay / Prediction. - [x] Per-slot input history ring (`netplay_input_hist`) - [x] Local path: `is_predicted = 0` -- [x] Remote invent = **hold-last** (neutral if no prior); stall when ahead of remote tip by > P +- [x] Remote invent = **hold-last** (neutral if no prior); stall when ahead of + remote tip by > P. Seal `get_input_row` gap-fill remains **idle**. - [x] Late wire → `rnet_input_contract_stick_replace_decide` + `hash_confirm_promote` - [x] Rewind → `psx_netplay_rb_begin_rewind` (episode) - [x] `get_input_row` vtable → `netplay_ih_get` @@ -243,7 +321,43 @@ RTT (BattleShip tiers / phase_lock); optional Manual Input Delay / Prediction. - [x] Netplay clears mods (`mod_runtime_clear_for_netplay` / launcher hook) - [x] Same BIOS stem + disc identity on both peers (existing verify) -- [ ] Audit non-deterministic host clocks in sim path — soak-driven +- [x] Audit non-deterministic host clocks in sim path — **selfcheck-driven** + (was soak-driven). `PSX_RB_SELFCHECK=1` (offline, single process, + `runtime/src/psx_selfcheck.c`): every INTERVAL boundaries snap the + machine at a savestate BB edge, record SPAN ticks of applied pad rows + + full digest partitions, then rewind and resim the window **twice** from + the same snap. Replay#1 vs replay#2 is the rollback invariant (both + peers resim in an episode) → PASS/FAIL with per-part DIVERGE lines; + live-vs-resim is reported as `restore-drift` (diagnostic; VBlank phase + re-applied from snap). Verdict = warm #2vs#3 after ambient-prime. + Fixes via selfcheck: VBlank-phase restore; overlay freeze; discard + dirty/interp load-delay on restore; pin host frame; RECORD=resim + host-skip profile; idle_skip off; turbo/FMV-skip gated; top-level + resume; IRQ escape + check-cycle/poll-throttle clear; sticky + `text_diverged` / kernel-bless / overlay validation memos cleared on + RAM restore; **span-end load** via `psx_selfcheck_flush_load` after + present-body RAII (BB fast-poll tails forked #2/#3 load sites); + snap PC uses RB `resume_pc_ok` (reject 0xB0). MotK attract soak + `/tmp/selfcheck_spanend2.log`: **316+ PASS / 0 FAIL** through the + classic win#70/#73/#118/#138/#139/#277/#279/#301 set. Re-verified after + hold-last invent (`/tmp/selfcheck_verify.log`): win#70/#73/#118/#139 + PASS, **157+ PASS / 0 FAIL** and climbing. Post-FMV cutover soak + (`/tmp/selfcheck_fmvcut.log`, INTERVAL=120 SPAN=48): **52 PASS / 0 + FAIL** through early attract/menu — confirms 954-class peer fork is + invent≠wire, not offline resim non-determinism. Tune: + `PSX_RB_SELFCHECK_INTERVAL/SPAN/FAULT/PRIME`. + **Mash:** `PSX_RB_SELFCHECK_MASH=1` (+ optional `MASH_SEED` / + `MASH_RATE`) synthesizes fighter-style face/D-pad/shoulder spam on + live boundaries so headless soaks stress invent edges / wait-loop + resim without a controller. Rows are recorded and replayed. + **Stuck hash_confirm / doomed tip loads:** a transient FIRST CORE + (e.g. sim 201) left `resolved_through` stuck; once that tick aged + out of the 128-slot dig ring, `choose_load_tick` fell through to + tip-slack (912) while confirmed snaps were gone — baseline then + aborted on cpu-only MotK wait-loop PC forks (`CD54`↔`CDA0`) despite + matched ram/clk. Fix: `netplay_hc_heal_stale_gap`, refuse loads past + the shared frontier, canonicalize wait-loop snap PC to `CDA0`. + `rb live dig local` is a breadcrumb (not peer match). - [x] FMV / depth24: defer rewind (promote wire only); follow NACKs; RB snap load uses light frontend hook (no FMV cutover/present thrash) - [x] Multitap / N-slot: history + seal tables sized to `slot_count` @@ -270,8 +384,8 @@ RTT (BattleShip tiers / phase_lock); optional Manual Input Delay / Prediction. 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** — strip CDROM/MDEC + movie digest stability - (raw ring snaps + interval 8 already landed for FPS) +7. ~~Thinner snap / FMV policy~~ — interval **16**, settle invent ok, digest + fold (skip-VRAM on live snaps unsafe for rewind; optional later) 8. **Dual-instance soak** — prove Done-when items --- @@ -293,10 +407,12 @@ RTT (BattleShip tiers / phase_lock); optional Manual Input Delay / Prediction. - [ ] Two MotK instances with `PSX_NET_MODE=rollback` complete a match without admit-stall on one-frame remote loss -- [ ] Forced remote stick correction triggers one episode and both digests match - post-commit +- [x] Forced remote digital correction opens episode(s); POST digests match + (char-select L/R soak: 4 commits, live dig ok after). Tap = press+release + = two episodes by input-contract (button deltas always rewind). - [ ] `hash_confirm` invent path shows promotes in diag without opening episodes - when master hashes still agree + when master hashes still agree (stick/analog path; **not** MotK digital + buttons — contract always rewinds button deltas) - [x] Delay-sync path (`PSX_NET_MODE=delay`) unchanged for lobby rematch / save xfer Reference: `lib/recomp-net/docs/rollback.md`, diff --git a/lib/recomp-net b/lib/recomp-net index 2623cf2a..23eec611 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 2623cf2ac442aeae09e70a0fb2a3b83342f98941 +Subproject commit 23eec611eb151709b9a6a60d913027eb7f5b4346 diff --git a/runtime/include/dirty_ram_interp.h b/runtime/include/dirty_ram_interp.h index 6f1cc6cc..d716dcd4 100644 --- a/runtime/include/dirty_ram_interp.h +++ b/runtime/include/dirty_ram_interp.h @@ -46,6 +46,12 @@ int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr); * interpreter or an exception is delivered, so no register write is dropped. */ void dirty_ram_ld_delay_flush(CPUState* cpu); +/* Drop a deferred load-delay writeback WITHOUT applying it. Required on + * savestate/RB restore: the snap already has architectural GPRs, and a + * host-static pending load from the pre-load timeline must not stomp them + * on the next interp step (selfcheck MotK v0=countdown vs v0=1 forks). */ +void dirty_ram_ld_delay_discard(void); + /* Overlay-cache windows — the address ranges eligible for capture, offline * recompilation, and per-entry-validated native execution (Rule 18 code that * does not exist in any compile-time image): @@ -102,6 +108,7 @@ int dirty_ram_is_dirty(uint32_t phys); int psx_kernel_bless_dispatchable(uint32_t phys); void psx_kernel_bless_note_range(uint32_t phys, uint32_t len); void psx_kernel_bless_stats(uint64_t out[6]); +void psx_kernel_bless_resync_after_restore(void); /* Capture/candidate window membership. Kernel window and overlay region are * unconditional; main-EXE text [KERNEL_WINDOW_END, FLOOR) is included only @@ -119,8 +126,10 @@ uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); uint32_t dirty_ram_get_bitmap_word_count(void); void dirty_ram_set_bitmap_words(const uint32_t* words, uint32_t count); /* After bulk RAM restore (savestate): bump overlay page gens + lazy-miss epoch - * so native overlays re-hash against restored bytes. */ + * so native overlays re-hash against restored bytes; also drop sticky + * text_diverged/modified bitmaps (host-only — restored RAM may match ref). */ void overlay_watch_invalidate_after_ram_restore(void); +void dirty_ram_text_guard_resync_after_restore(void); void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len); void dirty_ram_register_text_image(uint32_t phys_lo, const uint8_t *bytes, uint32_t len); diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index 35eb077d..183a91ca 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -118,6 +118,11 @@ typedef void (*gpu_vblank_cb)(void); void gpu_set_vblank_callback(gpu_vblank_cb cb); void gpu_vblank_flush_present(void); void gpu_vblank_clear_deferred_present(void); +/* Queue one deferred present (netplay RB resume when I_STAT already has + * VBlank — snap resync cleared the pending callback). */ +void gpu_vblank_arm_deferred_present(void); +/* Clear flush_present reentrancy guard before longjmp (flush_resume). */ +void gpu_vblank_release_present_flush_guard(void); /* Present-time screen-colour model (see color_lut.h ScreenKind: 0=raw, * 1=crt, 2=composite, 3=trinitron). Config/launcher-driven; the PSX_SCREEN diff --git a/runtime/include/interrupts.h b/runtime/include/interrupts.h index d33f1e18..9a07fa19 100644 --- a/runtime/include/interrupts.h +++ b/runtime/include/interrupts.h @@ -80,6 +80,12 @@ void psx_check_interrupts_dispatch_entry(struct CPUState* cpu, uint32_t resume_p void interrupts_advance_cycles(uint32_t cycles); void interrupts_service_scheduled_events(void); uint32_t interrupts_cycles_to_vblank(void); +/* VBlank phase within the current frame (0 .. VBLANK_CYCLES-1). Self-check + * snaps save/restore this so resim keeps the snap's phase instead of the + * warm-load rebase to 0 (which is correct for UI savestates, wrong for + * bit-identical resim of a mid-frame tip). */ +uint32_t interrupts_get_cycles_since_vblank(void); +void interrupts_set_cycles_since_vblank(uint32_t v); /* Cycle-budgeted precise event slicing: minimum guest-CPU-cycle distance to the * next DELIVERABLE hardware interrupt (source raises its I_STAT bit AND that bit @@ -94,6 +100,9 @@ int psx_get_in_exception(void); * Used by the post-savestate freeze probe (vblank-time "where was the game"). */ uint32_t psx_last_irq_check_pc(void); uint32_t psx_compiled_irq_resume_pc(void); +uint64_t psx_last_irq_check_cycle(void); +uint64_t psx_interrupt_total_checks(void); +uint32_t psx_interrupt_fast_maintenance(void); /* Snapshot internal counters for the freeze_check diagnostic. Any out_* * pointer may be NULL. All counters are monotonically non-decreasing diff --git a/runtime/include/netplay_hash_confirm.h b/runtime/include/netplay_hash_confirm.h index 2c27534c..fdd5b69b 100644 --- a/runtime/include/netplay_hash_confirm.h +++ b/runtime/include/netplay_hash_confirm.h @@ -55,6 +55,13 @@ uint8_t netplay_hc_local_digest(const NetplayHashConfirm* hc, uint32_t tick, uint8_t netplay_hc_peek_mismatch(const NetplayHashConfirm* hc, uint32_t* tick_out, uint32_t* local_out, uint32_t* peer_out); +/* Heal a stuck watermark when the next tick after resolved_through has aged + * out of the ring (slot reused) and is no longer a live mismatch. Scans the + * ring for the highest tick where local==peer with no newer mismatch present, + * then advances resolved_through there (ring contents kept). + * Returns 1 if the watermark moved. Call from FRAME_COMMIT ingress. */ +uint8_t netplay_hc_heal_stale_gap(NetplayHashConfirm* hc); + #ifdef __cplusplus } #endif diff --git a/runtime/include/netplay_input_hist.h b/runtime/include/netplay_input_hist.h index 9d549135..188af9fb 100644 --- a/runtime/include/netplay_input_hist.h +++ b/runtime/include/netplay_input_hist.h @@ -49,8 +49,8 @@ int netplay_ih_get(const NetplayInputHist *h, int slot, uint32_t tick, RNetRbFr int netplay_ih_invent_hold_last(NetplayInputHist *h, int slot, uint32_t tick, RNetRbFrame *out); -/* MotK digital: invent neutral only (never hold-last). Sticky D-pad invent - * after FMV settle forked RAM while soft-promote release-only skipped resim. */ +/* Neutral invent (buttons 0xFFFF). Seal gap-fill only — live MotK admit uses + * hold-last so a held D-pad does not re-episode every tick. */ int netplay_ih_invent_idle(NetplayInputHist *h, int slot, uint32_t tick, RNetRbFrame *out); diff --git a/runtime/include/overlay_loader.h b/runtime/include/overlay_loader.h index 99f8eeb2..122f3dc7 100644 --- a/runtime/include/overlay_loader.h +++ b/runtime/include/overlay_loader.h @@ -43,6 +43,22 @@ void overlay_loader_check_cache(uint32_t load_addr, uint32_t size, * 0 if not found (fall through to interpreter). */ int overlay_loader_dispatch(CPUState *cpu, uint32_t addr); +/* Freeze new DLL discovery/load (lazy try_load_region, live publish commit, + * rescan). Already-registered natives keep running. Required for rollback + * resim / selfcheck: overlay registration is host-only and not in the snap, + * so a load mid-pass#1 that is visible at the start of pass#2 forks BB-edge + * IRQ cadence (matched clocks, mismatched GPRs). */ +void overlay_loader_set_load_freeze(int freeze); +int overlay_loader_load_frozen(void); + +/* Drop the negative lazy-lookup memo (host-only). Call on snap restore so + * resim#1 and resim#2 take the same dispatch path. */ +void overlay_loader_clear_lazy_miss(void); + +/* After RAM restore: bust overlay candidate gen fast-path + static-match + * cache so native vs interp is re-decided against restored bytes. */ +void overlay_loader_resync_validation_after_restore(void); + /* Step 2.8: re-scan the cache dir for DLLs compiled after init and clear the * checked-regions memo so the next dispatch reconsiders the cache. Idempotent * (loaded DLLs stay loaded); emu thread only. */ diff --git a/runtime/include/psx_interpreter.h b/runtime/include/psx_interpreter.h index 8b871da0..c4073b87 100644 --- a/runtime/include/psx_interpreter.h +++ b/runtime/include/psx_interpreter.h @@ -22,6 +22,10 @@ extern "C" { /* Initialize interpreter state. Call once after CPUState is allocated. */ void interp_init(CPUState* cpu); +/* Drop host-static pending load-delay (not in boot_state). See + * dirty_ram_ld_delay_discard — same restore contract for the debug interp. */ +void interp_ld_delay_discard(void); + /* Execute exactly `count` instructions. Returns number actually executed * (may be less if a breakpoint fires). */ uint32_t interp_step(CPUState* cpu, uint32_t count); diff --git a/runtime/include/psx_netplay_rb.h b/runtime/include/psx_netplay_rb.h index dcee40cc..12b9e93a 100644 --- a/runtime/include/psx_netplay_rb.h +++ b/runtime/include/psx_netplay_rb.h @@ -46,27 +46,47 @@ void psx_netplay_rb_poll(struct CPUState *cpu, uint32_t resume_pc); * success. */ void psx_netplay_rb_flush_resume(void); +/* 1 after flush_resume longjmp until finish_frame / abort — top-level + * dispatch has no native call chain under the resume PC. */ +int psx_netplay_rb_top_level_resume_active(void); + +/* After outermost psx_dispatch returns pc==0 following an RB resume: retry + * at $ra / sticky BB if safe. Returns 1 and arms RESUME_CURRENT via out_pc. */ +int psx_netplay_rb_recover_null_pc(struct CPUState *cpu, uint32_t *out_pc); + /* After a live tick completes: request snap at tick for next safe poll. */ void psx_netplay_rb_request_snap(uint32_t tick); /* Initiator: start episode from invent/contract rewind. * Returns 1 if an episode opened, 0 if refused (no snap, already active, - * post-commit/realign cooldown, …). On refuse, reconcile should promote wire. */ + * abort/storm cooldown, FMV lockstep, …). On refuse, reconcile should promote + * wire (or tip-extend if active). Clean commit does not arm cooldown. */ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot); -/* 1 while begin_rewind is suppressed (cooldown after commit / realign). */ +/* Active episode: grow target / resign sealed rows for a late wire edge + * (press+release coalesce). Host must promote wire into hist first. + * Returns 1 if the correction was absorbed into the current episode. */ +int psx_netplay_rb_tip_extend(uint32_t mismatch_tick, int slot); + +/* 1 while begin_rewind is suppressed (cooldown after abort / realign). */ int psx_netplay_rb_rewind_suppressed(void); -/* 1 during depth24 / recent MDEC / post-FMV settle — MotK FMV. Reconcile - * promotes wire; no episodes (CD-frozen Replay corrupts the movie). */ +/* 1 during depth24 / recent MDEC / post-FMV settle — MotK FMV. */ int psx_netplay_rb_fmv_defer_rewind(void); -/* 1 in the same FMV/settle window: admit must stall for remote wire instead - * of inventing (promote-only during FMV left peers desynced at cutover). */ +/* 1 while depth24 or recent MDEC (not settle). */ +int psx_netplay_rb_fmv_media_active(void); + +/* 1 during FMV media + post-FMV lockstep (~90 ticks) — admit waits for remote + * wire (title Start / movie skip). Ticks the FMV→settle tracker. */ int psx_netplay_rb_lockstep_no_invent(void); -/* 1 once after commit/abort/realign — reconcile should promote all late wire - * without opening another episode (clears invent poison). */ +/* 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); + +/* 1 once after abort/realign cooldown arm — reconcile should promote all late + * wire without opening another episode (clears invent poison). */ int psx_netplay_rb_take_promote_sweep(void); /* Drain peer RB_* + drive Seal/Baseline/Replay/Verify. Call from pump. */ @@ -75,6 +95,7 @@ void psx_netplay_rb_pump(void); /* 1 while episode is active (seal/baseline/replay/verify). */ int psx_netplay_rb_active(void); int psx_netplay_rb_is_resimulating(void); +int psx_netplay_rb_tip_holding(void); /* FRAME_COMMIT mismatch during Replay — abort before a false POST commit. * Returns 1 if an episode was aborted. */ diff --git a/runtime/include/psx_scheduler.h b/runtime/include/psx_scheduler.h index 0b5c7f8c..7127ae30 100644 --- a/runtime/include/psx_scheduler.h +++ b/runtime/include/psx_scheduler.h @@ -99,9 +99,17 @@ void psx_clear_return_to_lobby(void); /* Save-state restore: unwind to psx_scheduler_run and re-dispatch resume_pc * (the restored CPUState's PC). Call on the scheduler fiber at a block-leader - * boundary, in_exception == 0. Never returns (longjmp). */ + * boundary, in_exception == 0. Never returns (longjmp). Arms top-level + * resume (see below) for the first post-resume frame. */ void psx_scheduler_resume_at(uint32_t resume_pc); +/* After resume_at, dispatch is top-level — there is no abandoned mid-block + * native chain under the resume PC. Sentinel same-thread RFE must not publish + * pc=0 / "continue live chain" (that is GUEST_EXIT). Active until the first + * finish_frame / explicit clear. Shared by savestate, selfcheck, and RB. */ +int psx_scheduler_top_level_resume_active(void); +void psx_scheduler_top_level_resume_clear(void); + /* HLE-tier standing subsystem replacement (CLAUDE.md §0 amendments * 2026-06-29 + 2026-07-02). 1 = deterministic TCB scheduler (default, both * BIOS backends — the LLE host-fiber bridge it replaces is non-deterministic); diff --git a/runtime/include/psx_selfcheck.h b/runtime/include/psx_selfcheck.h new file mode 100644 index 00000000..467233bf --- /dev/null +++ b/runtime/include/psx_selfcheck.h @@ -0,0 +1,96 @@ +#ifndef PSX_SELFCHECK_H +#define PSX_SELFCHECK_H + +/* + * Solo rollback resim self-check (PSX_RB_SELFCHECK=1, offline only). + * + * Periodically during normal offline play: snapshot the machine at a safe + * BB edge, keep playing N ticks while recording the per-boundary pad rows + * the sampler applied to SIO plus the full netplay digest partitions, then + * rewind to the snapshot and resim the same rows, comparing digests at every + * boundary. Any live-vs-resim fork is exactly the class of determinism bug + * that otherwise only surfaces as a doomed netplay episode in a two-peer + * soak (SIO pace, GTE fold, present-edge PC, v0 restore, ...). + * + * Uses the same machinery as netplay rollback: boot_state raw buffer snaps, + * netplay_state_digest partitions, RB restore recipe (cycle/IRQ resync, SPU + * CD FIFO reset, NO cdrom_accelerate, light frontend hook). No recomp-net + * dependency; fully offline, single process. + * + * Env: + * PSX_RB_SELFCHECK=1 enable + * PSX_RB_SELFCHECK_INTERVAL=N boundaries between windows (default 600) + * PSX_RB_SELFCHECK_SPAN=N window length in boundaries (default 32) + * PSX_RB_SELFCHECK_FAULT=1 corrupt one recorded digest per window to + * prove the comparator/report path + * PSX_RB_SELFCHECK_PRIME=N throwaway resims before counted passes + * (default 1, max 4) — clears live-RECORD ambient + * PSX_RB_SELFCHECK_MASH=1 synthetic fighter-style button spam on live + * boundaries (IDLE/SAVE_WAIT/RECORD). Recorded + * rows replay during resim. Headless-safe. + * PSX_RB_SELFCHECK_MASH_SEED=N xorshift seed (default 0xC0FFEE) + * PSX_RB_SELFCHECK_MASH_RATE=N % chance to start a new press/release each + * boundary when not mid-hold (default 75) + * + * Verdict = warm resim#2 vs #3 (peer episode path). Cold #1 vs #2 is dig only. + */ + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct CPUState; + +/* Startup bind (after savestate_configure; needs HLE scheduler for the + * rewind longjmp). Reads env; no-op unless PSX_RB_SELFCHECK=1. */ +void psx_selfcheck_init(struct CPUState *cpu, uint32_t bios_checksum, + uint32_t entry_pc); + +int psx_selfcheck_enabled(void); + +/* 1 while a window is open (record/replay/load). Offline GPU present stays + * immediate — full BB-edge present-defer skews clk/tim between warm peers. */ +int psx_selfcheck_defer_present(void); + +/* 1 while the resim replay is running: skip wall pacer / host audio pump / + * host CD boost exactly like netplay resim. */ +int psx_selfcheck_resim_active(void); + +/* 1 during replay: main.cpp must skip live pad sampling entirely (the + * self-check republishes the recorded rows itself). */ +int psx_selfcheck_replay_input(void); + +/* 1 during record or replay: main.cpp must skip the mid-frame low-latency + * input re-sample so pads stay boundary-latched (netplay tick semantics). */ +int psx_selfcheck_input_locked(void); + +/* Offline pad sampler notes what it applied to SIO this boundary (called + * from apply_pad_slot_to_sio / apply_input_override_to_sio). */ +void psx_selfcheck_note_pad(int slot, uint16_t buttons, uint8_t lx, uint8_t ly, + uint8_t rx, uint8_t ry, uint8_t analog); + +/* 1 when mash owns this boundary's P1 pad word (active-low). Latched once + * per offline vblank so mid-frame resampling sees a stable word. */ +int psx_selfcheck_mash_override(uint16_t *out_buttons); + +/* Once per offline vblank boundary, after pad sampling (main.cpp present + * body). defer_window!=0 postpones opening a new window (e.g. multitap + * arming still pending). */ +void psx_selfcheck_finish_frame(int defer_window); + +/* Safe BB-edge poll (beside savestate_poll in interrupts.c): performs the + * deferred snapshot save. Loads only as a fallback when span-end flush did + * not run (e.g. headless paths that skip present). Near-free when idle. */ +void psx_selfcheck_poll(struct CPUState *cpu, uint32_t resume_pc); + +/* After present-body C++ RAII: if a window span just ended, rewind+resume + * immediately at that VBlank boundary (no post-span BB tail). Longjmps. */ +void psx_selfcheck_flush_load(void); + +#ifdef __cplusplus +} +#endif + +#endif /* PSX_SELFCHECK_H */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index bcda3d15..991c8834 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -209,6 +209,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/netplay_hash_confirm.c ${PSXRECOMP_ROOT}/runtime/src/netplay_input_hist.c ${PSXRECOMP_ROOT}/runtime/src/psx_netplay_rb.c + ${PSXRECOMP_ROOT}/runtime/src/psx_selfcheck.c ${PSXRECOMP_ROOT}/runtime/src/bios_hle.c ${PSXRECOMP_ROOT}/runtime/src/bios_hle_plan.c ${PSXRECOMP_ROOT}/runtime/src/savestate.c diff --git a/runtime/src/cdrom.c b/runtime/src/cdrom.c index 848298eb..c89b8742 100644 --- a/runtime/src/cdrom.c +++ b/runtime/src/cdrom.c @@ -17,7 +17,6 @@ #include "event_ring.h" #include "audio_trace.h" #include "psx_cycles.h" -#include "psx_netplay.h" #include #include #include @@ -2473,18 +2472,14 @@ uint32_t cdrom_cycles_to_irq(uint32_t i_mask) { } void cdrom_advance(uint32_t cycles) { - /* Rollback Replay: freeze CD FSM aging. Uncapped resim + host CD timing - * forks peer POST cores even with identical seals/baseline; core digest - * excludes CD, but CD IRQs still steer guest execution. Both peers freeze - * identically from the restored snap. */ - if (psx_netplay_is_resimulating()) { - refresh_cdrom_irq_line(); - return; - } /* Age the response presentation latency. Once it elapses, the following * refresh_cdrom_irq_line() presents the held response to INTC — at this * cdrom_advance boundary, not synchronously inside the guest store that - * armed it. */ + * armed it. + * + * Note: do NOT freeze CD during rollback Replay. MotK FMV skip resim + * resumes into VLC/sector waits; frozen IRQs → no finish_frame hang. + * Menu CD asymmetry is contained by no-invent during media + core POST. */ if (cdrom_irq_present_delay > 0) { cdrom_irq_present_delay -= (int)cycles; if (cdrom_irq_present_delay < 0) cdrom_irq_present_delay = 0; diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 6f60d349..b047d616 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -315,6 +315,13 @@ void dirty_ram_ld_delay_flush(CPUState *cpu) { cpu->gpr[0] = 0; } +void dirty_ram_ld_delay_discard(void) { + s_ld_pend_armed = 0; + s_ld_pend_age = 0; + s_ld_pend_rt = 0; + s_ld_pend_val = 0; +} + uint32_t g_insn_gate_lo = 0; /* extra always-log phys range [lo,hi) */ uint32_t g_insn_gate_hi = 0; /* 0 = extra range disabled */ uint32_t g_insn_freeze_addr = 0; /* candidate phys entry to watch */ diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index a9ba7fda..922ec0bd 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -2477,6 +2477,18 @@ static int s_flushing_present; void gpu_vblank_clear_deferred_present(void) { s_present_pending = 0; + /* longjmp from flush_resume abandons the flush_present stack frame — + * must drop the reentrancy guard or every later flush no-ops forever. */ + s_flushing_present = 0; +} + +void gpu_vblank_arm_deferred_present(void) { + if (s_present_pending < 8) + s_present_pending++; +} + +void gpu_vblank_release_present_flush_guard(void) { + s_flushing_present = 0; } void gpu_vblank_flush_present(void) { @@ -2522,6 +2534,10 @@ void gpu_vblank_tick(void) { return; { extern int psx_netplay_active(void); + /* Offline selfcheck keeps immediate present: BB-edge defer + + * post-IRQ flush reintroduces clk/tim/csv phase skew between warm + * resim peers (selfcheck soak: many FAILs with d_cyc≠0). Netplay + * defers — both peers share the same present contract from boot. */ if (psx_netplay_active()) { /* Cap: a stuck defer must not queue unbounded presents. */ if (s_present_pending < 8) diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index 4ca61db7..5f1f6b14 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -346,6 +346,14 @@ uint32_t interrupts_cycles_to_vblank(void) { return VBLANK_CYCLES - cycles_since_vblank; } +uint32_t interrupts_get_cycles_since_vblank(void) { + return cycles_since_vblank; +} + +void interrupts_set_cycles_since_vblank(uint32_t v) { + cycles_since_vblank = v; +} + void interrupts_advance_cycles(uint32_t cycles) { cycles_since_vblank += cycles; interrupts_service_scheduled_events(); @@ -451,9 +459,15 @@ extern int g_psx_dispatch_depth; static uint32_t s_compiled_interrupt_resume_pc = 0; static uint32_t s_last_interrupt_check_pc = 0; static uint64_t s_last_interrupt_check_cycle = UINT64_MAX; +/* Poll-throttle counter for fast IRQ paths — host-only; must not survive + * rewind or load#N can hit the 16K poll edge at a different guest point. */ +static uint32_t s_fast_maintenance = 0; uint32_t psx_last_irq_check_pc(void) { return s_last_interrupt_check_pc; } uint32_t psx_compiled_irq_resume_pc(void) { return s_compiled_interrupt_resume_pc; } +uint64_t psx_last_irq_check_cycle(void) { return s_last_interrupt_check_cycle; } +uint64_t psx_interrupt_total_checks(void) { return total_checks; } +uint32_t psx_interrupt_fast_maintenance(void) { return s_fast_maintenance; } /* Deferred cooperative thread switch from nested exception delivery. * @@ -633,6 +647,35 @@ void interrupts_resync_after_restore(void) { s_defer_switch_pending = 0; s_defer_switch_target = 0; s_defer_switch_from = 0; + /* Host-only IRQ/escape ambient — not in the snap. Stale RFE/SENTINEL + * escape flags from the pre-load timeline can bias the first same-thread + * restore decision after rewind. Also zero resume-PC latches: the poll + * that triggered this load left the PRE-LOAD timeline's BB PC in + * s_compiled_interrupt_resume_pc; if sticky I_STAT delivers before the + * first post-resume BB edge rewrites it, EPC/saved_gpr fork warm + * resim#2 vs #3 (selfcheck MotK attract win#70/#73, matched clocks). */ + g_exception_real_epc = 0; + g_exc_escape_reason = PSX_EXC_ESCAPE_NONE; + g_rfe_escape_pending = 0; + g_pending_exception_longjmp = 0; + s_compiled_interrupt_resume_pc = 0; + s_last_interrupt_check_pc = 0; + /* De-dupe key for dispatch_entry: a stale absolute cycle from the pre-load + * timeline can equal the restored tip and skip the first post-resume IRQ + * check at that PC (warm selfcheck MotK #2≠#3 at matched clocks). */ + s_last_interrupt_check_cycle = UINT64_MAX; + s_fast_maintenance = 0; + total_checks = 0; + { + extern uint32_t g_dirty_safe_resume_pc; + g_dirty_safe_resume_pc = 0; + } + /* Load longjmp from mid-path abandons PSX_CHECK_INTERRUPTS_RETURN's + * g_ls_suppress_record-- — clamp so suppress cannot accumulate. */ + { + extern int g_ls_suppress_record; + g_ls_suppress_record = 0; + } /* Drop mid-quantum present armed before the rewind — do not finish_frame * against the restored tip with a stale pending count. */ gpu_vblank_clear_deferred_present(); @@ -805,11 +848,23 @@ int psx_interrupt_delivery_needed(const CPUState* cpu) { void psx_check_interrupts(CPUState* cpu) { psx_cyc_batch_flush(); - /* Netplay: run deferred sdl_vblank_present here (BB edge), not from - * mid-block fire_vblank_edge → gpu_vblank_tick. Offline presents immediately. */ - gpu_vblank_flush_present(); + /* Netplay: deferred sdl_vblank_present at BB edge (not mid-block + * fire_vblank_edge). When an IRQ is also deliverable, flush AFTER + * delivery so finish_frame digests post-RFE GPRs at the interrupted + * EPC — MotK menu CD54↔CDA0 was presenting pre-IRQ on opposite edges. + * Selfcheck stays on immediate present (see gpu.c). */ extern int g_ls_suppress_record; -#define PSX_CHECK_INTERRUPTS_RETURN() do { if (g_ls_suppress_record > 0) g_ls_suppress_record--; return; } while (0) + extern int psx_netplay_active(void); + int np_present_after_irq = 0; + if (psx_netplay_active() && psx_interrupt_delivery_needed(cpu)) + np_present_after_irq = 1; + else + gpu_vblank_flush_present(); +#define PSX_CHECK_INTERRUPTS_RETURN() do { \ + if (np_present_after_irq) gpu_vblank_flush_present(); \ + if (g_ls_suppress_record > 0) g_ls_suppress_record--; \ + return; \ + } while (0) #ifdef PSX_COSIM extern uint32_t g_dirty_safe_resume_pc; #define COSIM_IRQ_TAKE_PC() (g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc : s_compiled_interrupt_resume_pc) @@ -823,7 +878,6 @@ void psx_check_interrupts(CPUState* cpu) { * mid-path bookkeeping / irq_deliver_eval that used to run every BB. * Guest-visible timing unchanged (same non-delivery). LTO can collapse * this into the VLC call sites. */ - static uint32_t s_fast_maintenance; if (!in_exception && !s_defer_switch_pending && (i_stat & i_mask) != 0) { uint32_t sr = cpu->cop0[COP0_SR]; if (!(sr & 0x01u) || !(sr & (1u << 10))) { @@ -831,12 +885,14 @@ void psx_check_interrupts(CPUState* cpu) { if ((++s_fast_maintenance & 0x3FFFu) == 0) { extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); extern void psx_netplay_poll_snap(CPUState* cpu, uint32_t resume_pc); + extern void psx_selfcheck_poll(CPUState* cpu, uint32_t resume_pc); savestate_poll(cpu, s_compiled_interrupt_resume_pc); /* MotK FMV/VLC live here — must flush pending RB snaps too. */ psx_netplay_poll_snap(cpu, s_compiled_interrupt_resume_pc); + psx_selfcheck_poll(cpu, s_compiled_interrupt_resume_pc); debug_server_poll(); } - return; + PSX_CHECK_INTERRUPTS_RETURN(); } } @@ -881,15 +937,18 @@ void psx_check_interrupts(CPUState* cpu) { if ((++s_fast_maintenance & 0x3FFFu) == 0) { extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); extern void psx_netplay_poll_snap(CPUState* cpu, uint32_t resume_pc); + extern void psx_selfcheck_poll(CPUState* cpu, uint32_t resume_pc); savestate_poll(cpu, s_compiled_interrupt_resume_pc); psx_netplay_poll_snap(cpu, s_compiled_interrupt_resume_pc); + psx_selfcheck_poll(cpu, s_compiled_interrupt_resume_pc); debug_server_poll(); } - return; + PSX_CHECK_INTERRUPTS_RETURN(); } /* Idle skip advanced time and a device raised I_STAT — deliver below. */ } - if (in_exception && !(cpu->cop0[COP0_SR] & 0x01u)) return; + if (in_exception && !(cpu->cop0[COP0_SR] & 0x01u)) + PSX_CHECK_INTERRUPTS_RETURN(); /* Mid path: unmasked IRQ already pending, ordinary compiled edge. * MotK VLC / FMV spend most BB edges here (sticky CD/VBlank bits). @@ -905,10 +964,12 @@ void psx_check_interrupts(CPUState* cpu) { if ((total_checks & 0x3FFFu) == 0) { extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); extern void psx_netplay_poll_snap(CPUState* cpu, uint32_t resume_pc); + extern void psx_selfcheck_poll(CPUState* cpu, uint32_t resume_pc); savestate_poll(cpu, check_pc); /* Sticky CD/VBlank mid-path is MotK's FMV hot edge — without this * the RB snap ring never fills (pending save never polled). */ psx_netplay_poll_snap(cpu, check_pc); + psx_selfcheck_poll(cpu, check_pc); debug_server_poll(); } goto irq_deliver_eval; @@ -954,8 +1015,10 @@ void psx_check_interrupts(CPUState* cpu) { if (!in_exception) { extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); extern void psx_netplay_poll_snap(CPUState* cpu, uint32_t resume_pc); + extern void psx_selfcheck_poll(CPUState* cpu, uint32_t resume_pc); savestate_poll(cpu, s_last_interrupt_check_pc); psx_netplay_poll_snap(cpu, s_last_interrupt_check_pc); + psx_selfcheck_poll(cpu, s_last_interrupt_check_pc); } /* Deferred cooperative thread switch: honor at the next real thread-save @@ -1514,10 +1577,11 @@ irq_deliver_eval: } { extern int psx_netplay_active(void); + extern int psx_selfcheck_enabled(void); if (s_str_mode_env >= 0) s_str_mode = s_str_mode_env; - else if (psx_netplay_active()) - s_str_mode = 3; /* netplay auto: TCB-stable always restore */ + else if (psx_netplay_active() || psx_selfcheck_enabled()) + s_str_mode = 3; /* netplay/selfcheck: TCB-stable always restore */ else s_str_mode = 1; } @@ -1557,13 +1621,29 @@ irq_deliver_eval: * live chain, and one hop later lands on an un-re-enterable mid-function * PC — the top-level "execution completed, PC=0" abnormal exit (Tomba 2 * splash, frame 1385). A GENUINE in-handler switch (PCB[0] moved) skips - * this and is honored/deferred by the scheduler block below. */ + * this and is honored/deferred by the scheduler block below. + * + * Exception: after netplay RB flush_resume, dispatch is top-level — there + * is no live native chain. Publishing pc=0 there is GUEST_EXIT. Prefer the + * compiled IRQ resume PC (or keep the post-RFE guest PC). */ if (g_exception_real_epc == (uint32_t)PSX_EXC_SENTINEL_PC && entry_tcb != 0u && entry_tcb == exit_tcb && (g_exc_escape_reason == PSX_EXC_ESCAPE_RFE_RETURN || g_exc_escape_reason == PSX_EXC_ESCAPE_SYSCALL_RETURN)) { same_thread_resume = 1; - cpu->pc = 0; /* continue the interrupted live chain */ + { + extern int psx_scheduler_top_level_resume_active(void); + if (psx_scheduler_top_level_resume_active()) { + uint32_t resume = s_compiled_interrupt_resume_pc; + if (resume == 0u) + resume = s_last_interrupt_check_pc; + if (resume != 0u) + cpu->pc = resume; + /* else keep post-RFE cpu->pc — never publish 0 */ + } else { + cpu->pc = 0; /* continue the interrupted live chain */ + } + } } int do_restore = (g_exc_escape_reason == PSX_EXC_ESCAPE_LEGACY_SENTINEL || same_thread_resume); @@ -1748,6 +1828,8 @@ irq_deliver_eval: #undef COSIM_IRQ_NOTE #undef COSIM_IRQ_TAKE_PC #endif + if (np_present_after_irq) + gpu_vblank_flush_present(); #undef PSX_CHECK_INTERRUPTS_RETURN } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c67ef141..3a71be07 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -42,6 +42,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #endif #include "psx_netplay.h" #include "psx_netplay_rb.h" +#include "psx_selfcheck.h" #include "psx_lobby_client.h" #include "spu.h" #include "audio_trace.h" @@ -1252,7 +1253,9 @@ static uint64_t g_legacy_underruns = 0; int g_audio_unmute_resync = 0; /* Actual device rate the host opened at (bridge mode may differ from 44100; * the T3 tap ring runs at this rate and its WAV dump must say so). */ -extern "C" int g_audio_host_rate = 44100; +extern "C" { +int g_audio_host_rate = 44100; +} static void sdl_drc_callback(void* /*user*/, Uint8* stream, int len) { if (!s_drc_ready) { std::memset(stream, 0, (size_t)len); return; } @@ -1994,7 +1997,8 @@ static void sdl_audio_pump(bool discard_output = false) { * across the first ~50 ms of samples (sdl_audio_pump above). */ /* Bridge/legacy output health, surfaced through the audio_stats TCP command * (debug_server.c) — no stderr probe; rule 3. */ -extern "C" int psx_audio_out_stats(double *fill_ms, uint64_t *underruns, +extern "C" int psx_audio_out_stats(double *fill_ms, double *target_ms, + uint64_t *underruns, uint64_t *overflow_drops, double *correction, int *legacy, int *host_rate) { @@ -2005,6 +2009,7 @@ extern "C" int psx_audio_out_stats(double *fill_ms, uint64_t *underruns, ? (double)psx_sdl_audio_queued_size(sdl_audio_device) / (44100.0 * 4.0) * 1000.0 : 0.0; + *target_ms = 0.0; /* push queue — no DRC fill target */ *underruns = g_legacy_underruns; *overflow_drops = 0; *correction = 0.0; @@ -2013,6 +2018,7 @@ extern "C" int psx_audio_out_stats(double *fill_ms, uint64_t *underruns, rab_stats st; rab_get_stats(&s_drc, &st); *fill_ms = st.last_fill_ms; + *target_ms = s_drc.cfg.target_ms; *underruns = st.underrun_events; *overflow_drops = st.overflow_drops; *correction = st.last_correction; @@ -2252,11 +2258,12 @@ static void runtime_perf_diag_tick() { RuntimePerfSnapshot current = runtime_perf_snapshot(now); AudioTraceStats audio; audio_trace_get_stats(&audio); - double fill_ms = 0.0, correction = 0.0; + double fill_ms = 0.0, target_ms = 0.0, correction = 0.0; uint64_t underruns = 0, overflows = 0; int legacy = 0, host_rate = 0; - psx_audio_out_stats(&fill_ms, &underruns, &overflows, &correction, + psx_audio_out_stats(&fill_ms, &target_ms, &underruns, &overflows, &correction, &legacy, &host_rate); + (void)target_ms; uint64_t up[6] = {0}; gl_renderer_runtime_diag(up); uint64_t overlay_load_us = 0, overlay_load_max_us = 0, overlay_load_last_us = 0; @@ -3196,6 +3203,8 @@ static void apply_input_override_to_sio(int override_word) { if (!eff_analog) { st[0] = st[1] = st[2] = st[3] = 0x80; } sio_set_pad_sticks(0, st[0], st[1], st[2], st[3]); sio_request_pad_type(0, eff_analog); + psx_selfcheck_note_pad(0, w, st[0], st[1], st[2], st[3], + (uint8_t)(eff_analog ? 1 : 0)); } /* Capture one SIO slot's host pad into a netplay/local blob. Returns 1 if a @@ -3333,6 +3342,9 @@ static void apply_pad_slot_to_sio(int s, const PsxNetPad& pad) { sio_set_pad_state_slot(s, pad.buttons); sio_set_pad_sticks(s, pad.lx, pad.ly, pad.rx, pad.ry); sio_request_pad_type(s, pad.analog ? 1 : 0); + /* Solo resim self-check records exactly what was applied this boundary. */ + psx_selfcheck_note_pad(s, pad.buttons, pad.lx, pad.ly, pad.rx, pad.ry, + pad.analog); } /* Local human pad for delay-sync: sample the host PlayerInput selected for @@ -3585,6 +3597,12 @@ static void netplay_barrier_admit(int override) { } static void sample_pad_into_sio(int override) { + /* Selfcheck fighter mash owns P1 when enabled (headless-safe). */ + if (override < 0) { + uint16_t mash = 0xFFFFu; + if (psx_selfcheck_mash_override(&mash)) + override = (int)mash; + } if (override >= 0) { apply_input_override_to_sio(override); return; @@ -3607,6 +3625,11 @@ static void sample_pad_into_sio(int override) { } static void sample_headless_pad_into_sio(int override) { + if (override < 0) { + uint16_t mash = 0xFFFFu; + if (psx_selfcheck_mash_override(&mash)) + override = (int)mash; + } if (override >= 0) { apply_input_override_to_sio(override); return; @@ -3860,6 +3883,10 @@ struct NetplayVblankEpilogue { /* Called from gpu_vblank_tick() at each simulated vblank. */ static NetplayVblankEpilogue sdl_vblank_present_body(void) { NetplayVblankEpilogue ep{}; + /* Guest quantum for this vblank is complete. Drop top-level-resume armed + * by any resume_at (savestate / selfcheck / RB) during that quantum — the + * next tick has a live native chain under its dispatch again. */ + psx_scheduler_top_level_resume_clear(); int probe_turbo = 0; int probe_reached = 0; struct PostLoadProbeScope { @@ -4093,10 +4120,19 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { "tap pads forced digital)\n", sio_get_multitap_port() + 1); } - if (g_headless) - sample_headless_pad_into_sio(override); - else - sample_pad_into_sio(override); + /* Solo resim self-check replay republishes the recorded rows itself — + * live sampling must not touch SIO during the replay window. */ + if (!psx_selfcheck_replay_input()) { + if (g_headless) + sample_headless_pad_into_sio(override); + else + sample_pad_into_sio(override); + } + /* Offline vblank boundary: record/replay/compare (PSX_RB_SELFCHECK). + * Defer opening a window while multitap arming is still pending — + * the arm writes SIO once, host-latched, and would not replay. */ + psx_selfcheck_finish_frame( + (g_offline_pad_count >= 3 && !sio_get_multitap()) ? 1 : 0); } /* Latency ring: open this present cycle's slot, stamping when input was @@ -4105,7 +4141,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { /* Post-savestate CD delay boost (ReadTOC/seek clamp window). * Skip during rollback resim — boost is host-local and not in the CD snap. */ - if (!psx_netplay_is_resimulating()) + if (!psx_netplay_is_resimulating() && !psx_selfcheck_resim_active()) cdrom_savestate_boost_vblank(); /* Turbo-active test shared by the pacing/present gate below. */ @@ -4115,9 +4151,12 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { int load_run_value = 0; static int load_run = 0; static int release_run = 0; - if (g_turbo_loads_enabled && !psx_netplay_active()) { + if (g_turbo_loads_enabled && !psx_netplay_active() && + !psx_selfcheck_resim_active()) { /* Require a short sustained predicate on entry, then retain turbo over - * a short false gap after it has engaged. Netplay keeps wall pacing. */ + * a short false gap after it has engaged. Netplay / selfcheck keep + * wall-pacing off via their own gates; turbo engage hysteresis must + * not leak host ambient across resim passes. */ if (logical_load_active) { if (load_run < (1 << 20)) load_run++; if (load_run >= TURBO_LOADS_ENGAGE_FRAMES || release_run > 0) { @@ -4164,7 +4203,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { int fmv_skip_active = 0; /* Never inject START / poke movie totals under netplay — host-side skip * forks peers (and stomps SIO after sealed publish during resim). */ - if (g_auto_skip_fmv && !psx_netplay_active()) { + if (g_auto_skip_fmv && !psx_netplay_active() && + !psx_selfcheck_resim_active()) { uint32_t mc = mdec_get_decode_count(); int mdec_decoding = (mc != s_fmv_skip_last_mdec); s_fmv_skip_last_mdec = mc; @@ -4198,7 +4238,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * retains the hard mute because it deliberately fast-forwards a movie. * Rollback resim: skip host audio pump (uncapped frames) so SPU/CD audio * paths cannot fork peers during the episode window. */ - if (!psx_netplay_is_resimulating()) { + if (!psx_netplay_is_resimulating() && !psx_selfcheck_resim_active()) { sdl_audio_update(fmv_skip_active, turbo_loads_active && g_turbo_audio_sink_enabled); } @@ -4269,22 +4309,24 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { } } - /* Netplay FMV: skip present every other depth24 vblank to cut GPU cost - * (admit + wall pace still run in the epilogue every tick). Do NOT skip - * pace here — that let MotK movies run ~90fps once decode was fast enough. */ + /* Netplay FMV: present 1 of every 4 depth24 vblanks. 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. */ if (psx_netplay_active() && gpu_display_is_depth24()) { - if (s_netplay_depth24_present_skip) { - s_netplay_depth24_present_skip = 0; + if (s_netplay_depth24_present_skip > 0) { + s_netplay_depth24_present_skip--; return ep; } - s_netplay_depth24_present_skip = 1; + s_netplay_depth24_present_skip = 3; /* skip next three presents */ } else { s_netplay_depth24_present_skip = 0; } /* Offline wall-clock pacing before present. Netplay paces in the epilogue - * AFTER present so Swap overlaps the peer's guest quantum. */ - if (!psx_netplay_active()) { + * AFTER present so Swap overlaps the peer's guest quantum. Self-check + * replay runs uncapped like netplay resim. */ + if (!psx_netplay_active() && !psx_selfcheck_resim_active()) { uint64_t perf_start = runtime_perf_section_begin(); frame_pacer_wait(&s_frame_pacer, g_frame_period_ms); runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks); @@ -4293,8 +4335,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { /* Low-latency input: the early sample above is now ~one pacer-wait old. * Refresh the device state and re-sample right before present so the next * CPU frame reads near-fresh input (the dominant input->photon cost on a - * vsync-light box). Re-stamp the ring's input mark to measure from here. */ - if (g_low_latency_input) { + * vsync-light box). Re-stamp the ring's input mark to measure from here. + * Self-check record keeps pads boundary-latched (netplay tick + * semantics) — the mid-frame re-sample would fork the resim. */ + if (g_low_latency_input && !psx_selfcheck_input_locked()) { SDL_GameControllerUpdate(); /* refresh pad state after the wait */ SDL_PumpEvents(); /* refresh keyboard state */ sample_pad_into_sio(override); @@ -4674,6 +4718,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { static void sdl_vblank_present(void) { NetplayVblankEpilogue ep = sdl_vblank_present_body(); + /* Selfcheck span-end rewind: after present-body C++ RAII, before any + * further guest progress. Longjmps on success — keeps every resim load + * on the same VBlank boundary (BB fast-poll tails forked #2 vs #3). */ + psx_selfcheck_flush_load(); if (!ep.do_epilogue) return; if (!psx_return_to_lobby_requested()) @@ -8933,6 +8981,8 @@ session_reboot: debug_server_set_cpu(&cpu); /* Master digests / FRAME_COMMIT for netplay hash_confirm watermark. */ psx_netplay_bind_cpu(&cpu); + /* Solo rollback resim self-check (PSX_RB_SELFCHECK=1, offline only). */ + psx_selfcheck_init(&cpu, memory_get_bios_checksum(), game_entry_pc); /* Execute. */ std::fprintf(stdout, "psxrecomp runtime: executing from PC=0x%08X\n", cpu.pc); diff --git a/runtime/src/mdec.c b/runtime/src/mdec.c index f56da4e5..c753b8ff 100644 --- a/runtime/src/mdec.c +++ b/runtime/src/mdec.c @@ -6,6 +6,17 @@ #include #include +/* x86-64 always has SSE2; MSVC x64 does not define __SSE2__. */ +#if defined(__SSE2__) || defined(_M_X64) || defined(_M_AMD64) || \ + (defined(_M_IX86_FP) && _M_IX86_FP >= 2) +#include +#define MDEC_HAVE_SSE2 1 +#endif +#if !defined(MDEC_HAVE_SSE2) && (defined(__ARM_NEON) || defined(__ARM_NEON__)) +#include +#define MDEC_HAVE_NEON 1 +#endif + extern uint64_t s_frame_count; /* FMV-activity detector: host display-frame stamp of the newest colour @@ -236,73 +247,172 @@ static int mask9_clamp_s8(int v) { return v; } -/* Faithful R3000A MDEC IDCT (Beetle IDCT/IDCT_1D_Multi, mdec.cpp:243-291). - * Two separable 1-D passes over the >>3 scale matrix: pass 1 keeps int16 and - * transposes (out[x*8+col]); pass 2 clamps to int8 via Mask9ClampS8. Rounding - * is (sum + 0x4000) >> 15 (vs the old (sum+0xFFF)/0x2000 + per-tap /8, whose - * bias differed from hardware → the washed/wrong-contrast FMV). The block buffer - * holds int8-range samples on return. */ -static void idct_block(int16_t *block) { - /* DC-only fast path: bit-identical to the loops below when AC is zero - * (common in dark/flat FMV macroblocks). Pass1 only fills tmp[*][0]; - * pass2 only multiplies by scale[*][0]. */ +/* Faithful R3000A MDEC IDCT (Beetle IDCT/IDCT_1D_Multi). Two separable 1-D + * passes over the >>3 scale matrix: pass 1 keeps int16 and transposes + * (out[x*8+col]); pass 2 clamps via Mask9ClampS8. Rounding (sum+0x4000)>>15. + * SSE2 path matches Beetle's madd_epi16 reduce (bit-identical to scalar). */ + +static void idct_block_dc_only(int16_t *block) +{ + int16_t tmp0[8]; + int dc = block[0]; + int x, col; + for (x = 0; x < 8; x++) { + int sum = dc * (int)mdec.scale[x * 8]; + tmp0[x] = (int16_t)((sum + 0x4000) >> 15); + } + for (col = 0; col < 8; col++) { + for (x = 0; x < 8; x++) { + int sum = (int)tmp0[col] * (int)mdec.scale[x * 8]; + block[col * 8 + x] = + (int16_t)mask9_clamp_s8((sum + 0x4000) >> 15); + } + } +} + +static void idct_block_scalar(int16_t *block) +{ int ac = 0; - for (int i = 1; i < 64; i++) ac |= block[i]; + int i, col, x, u; + int16_t tmp[64]; + + for (i = 1; i < 64; i++) + ac |= block[i]; if (!ac) { - int16_t tmp0[8]; - int dc = block[0]; - for (int x = 0; x < 8; x++) { - int sum = dc * (int)mdec.scale[x * 8]; - tmp0[x] = (int16_t)((sum + 0x4000) >> 15); - } - for (int col = 0; col < 8; col++) { - for (int x = 0; x < 8; x++) { - int sum = (int)tmp0[col] * (int)mdec.scale[x * 8]; - block[col * 8 + x] = - (int16_t)mask9_clamp_s8((sum + 0x4000) >> 15); - } - } + idct_block_dc_only(block); return; } - int16_t tmp[64]; - /* pass 1 — int16 out, transposed. Skip all-zero source columns - * (sparse AC after zig-zag); zero column ⇒ zero tmp[*][col]. */ - for (int col = 0; col < 8; col++) { + for (col = 0; col < 8; col++) { const int16_t *src = block + col * 8; - int col_or = (int)src[0] | (int)src[1] | (int)src[2] | (int)src[3] - | (int)src[4] | (int)src[5] | (int)src[6] | (int)src[7]; + int col_or = (int)src[0] | (int)src[1] | (int)src[2] | (int)src[3] | + (int)src[4] | (int)src[5] | (int)src[6] | (int)src[7]; if (!col_or) { - for (int x = 0; x < 8; x++) tmp[x * 8 + col] = 0; + for (x = 0; x < 8; x++) + tmp[x * 8 + col] = 0; continue; } - for (int x = 0; x < 8; x++) { + for (x = 0; x < 8; x++) { int sum = 0; const int16_t *sc = mdec.scale + x * 8; - for (int u = 0; u < 8; u++) + for (u = 0; u < 8; u++) sum += (int)src[u] * (int)sc[u]; tmp[x * 8 + col] = (int16_t)((sum + 0x4000) >> 15); } } - /* pass 2 — int8 out (Mask9ClampS8), no transpose. Same zero-row skip. */ - for (int col = 0; col < 8; col++) { + for (col = 0; col < 8; col++) { const int16_t *src = tmp + col * 8; - int col_or = (int)src[0] | (int)src[1] | (int)src[2] | (int)src[3] - | (int)src[4] | (int)src[5] | (int)src[6] | (int)src[7]; + int col_or = (int)src[0] | (int)src[1] | (int)src[2] | (int)src[3] | + (int)src[4] | (int)src[5] | (int)src[6] | (int)src[7]; if (!col_or) { - for (int x = 0; x < 8; x++) block[col * 8 + x] = 0; + for (x = 0; x < 8; x++) + block[col * 8 + x] = 0; continue; } - for (int x = 0; x < 8; x++) { + for (x = 0; x < 8; x++) { int sum = 0; const int16_t *sc = mdec.scale + x * 8; - for (int u = 0; u < 8; u++) + for (u = 0; u < 8; u++) sum += (int)src[u] * (int)sc[u]; - block[col * 8 + x] = (int16_t)mask9_clamp_s8((sum + 0x4000) >> 15); + block[col * 8 + x] = + (int16_t)mask9_clamp_s8((sum + 0x4000) >> 15); } } } +#if defined(MDEC_HAVE_SSE2) +/* Horizontal sum of 4×i32 after madd_epi16 — same reduce as Beetle mdec.c. */ +static int idct_sse2_dot8(const int16_t *src8, const int16_t *scale8) +{ + __m128i c = _mm_loadu_si128((const __m128i *)src8); + __m128i m = _mm_loadu_si128((const __m128i *)scale8); + __m128i sum = _mm_madd_epi16(m, c); + int32_t tmp[4] __attribute__((aligned(16))); + sum = _mm_add_epi32(sum, _mm_shuffle_epi32(sum, _MM_SHUFFLE(0, 1, 2, 3))); + sum = _mm_add_epi32(sum, _mm_shuffle_epi32(sum, _MM_SHUFFLE(0, 0, 0, 1))); + _mm_store_si128((__m128i *)tmp, sum); + return tmp[0]; +} + +static void idct_block_sse2(int16_t *block) +{ + int ac = 0; + int i, col, x; + int16_t tmp[64] __attribute__((aligned(16))); + + for (i = 1; i < 64; i++) + ac |= block[i]; + if (!ac) { + idct_block_dc_only(block); + return; + } + + for (col = 0; col < 8; col++) { + const int16_t *src = block + col * 8; + int col_or = (int)src[0] | (int)src[1] | (int)src[2] | (int)src[3] | + (int)src[4] | (int)src[5] | (int)src[6] | (int)src[7]; + if (!col_or) { + for (x = 0; x < 8; x++) + tmp[x * 8 + col] = 0; + continue; + } + for (x = 0; x < 8; x++) { + int sum = idct_sse2_dot8(src, mdec.scale + x * 8); + tmp[x * 8 + col] = (int16_t)((sum + 0x4000) >> 15); + } + } + for (col = 0; col < 8; col++) { + const int16_t *src = tmp + col * 8; + int col_or = (int)src[0] | (int)src[1] | (int)src[2] | (int)src[3] | + (int)src[4] | (int)src[5] | (int)src[6] | (int)src[7]; + if (!col_or) { + for (x = 0; x < 8; x++) + block[col * 8 + x] = 0; + continue; + } + for (x = 0; x < 8; x++) { + int sum = idct_sse2_dot8(src, mdec.scale + x * 8); + block[col * 8 + x] = + (int16_t)mask9_clamp_s8((sum + 0x4000) >> 15); + } + } +} + +static void idct_simd_selfcheck(void) +{ + uint32_t seed = 0xC0FFEEu; + int n; + for (n = 0; n < 64; n++) + mdec.scale[n] = (int16_t)(((n % 9) - 4) * 256); + /* Deterministic patterns + PRNG — abort if SSE2 ever drifts from scalar. */ + for (n = 0; n < 512; n++) { + int16_t a[64], b[64]; + int i; + for (i = 0; i < 64; i++) { + seed = seed * 1664525u + 1013904223u; + a[i] = (int16_t)((int)(seed >> 16) - 32768); + if (n < 8) + a[i] = (i == 0) ? (int16_t)(n * 100) : 0; /* DC-only */ + b[i] = a[i]; + } + idct_block_scalar(a); + idct_block_sse2(b); + if (memcmp(a, b, sizeof(a)) != 0) + abort(); + } +} + +static void idct_block(int16_t *block) +{ + idct_block_sse2(block); +} +#else +static void idct_block(int16_t *block) +{ + idct_block_scalar(block); +} +#endif + static int decode_rle_block(int16_t *block, const uint8_t *quant, uint32_t *pos, uint32_t end) { memset(block, 0, 64 * sizeof(int16_t)); @@ -399,6 +509,96 @@ static void append_luma_block(const int16_t *yblk) { mdec.output_size += 64u; } +#if defined(MDEC_HAVE_SSE2) +/* Beetle mdec.c EncodeRow24 — 8 luma + 4 chroma → 24 RGB bytes, bit-exact. */ +#define MDEC_MUL32(a, b) \ + _mm_or_si128( \ + _mm_and_si128(_mm_mul_epu32((a), (b)), _mm_set1_epi64x(0xFFFFFFFFull)),\ + _mm_slli_epi64( \ + _mm_mul_epu32(_mm_srli_epi64((a), 32), _mm_srli_epi64((b), 32)), \ + 32)) +#define MDEC_M9(v) \ + _mm_min_epi16(_mm_max_epi16(_mm_srai_epi16(_mm_slli_epi16((v), 7), 7), \ + _mm_set1_epi16(-128)), \ + _mm_set1_epi16(127)) + +static void mdec_encode_row24_sse2(const int16_t *by, const int16_t *cb4, + const int16_t *cr4, uint8_t rgb_xor, + uint8_t *out) +{ + int16_t cbd[8], crd[8]; + int8_t by8[8]; + int l, i; + __m128i R, G, B; + __m128i flip = _mm_set1_epi16((short)(0x80 ^ rgb_xor)); + uint8_t r8[8], g8[8], b8[8]; + __m128i y16, CB, CR, cb_lo, cb_hi, cr_lo, cr_hi, y_lo, y_hi; + const __m128i k359 = _mm_set1_epi32(359); + const __m128i k454 = _mm_set1_epi32(454); + const __m128i km88 = _mm_set1_epi32(-88); + const __m128i km183 = _mm_set1_epi32(-183); + const __m128i bias = _mm_set1_epi32(0x80); + __m128i r_lo, r_hi, b_lo, b_hi, a0, a1, c0, c1, g_lo, g_hi; + + for (l = 0; l < 8; l++) + by8[l] = (int8_t)by[l]; + for (l = 0; l < 4; l++) { + cbd[2 * l] = cbd[2 * l + 1] = (int16_t)cb4[l]; + crd[2 * l] = crd[2 * l + 1] = (int16_t)cr4[l]; + } + + y16 = _mm_srai_epi16( + _mm_slli_epi16( + _mm_unpacklo_epi8(_mm_loadl_epi64((const __m128i *)by8), + _mm_setzero_si128()), + 8), + 8); + CB = _mm_loadu_si128((const __m128i *)cbd); + CR = _mm_loadu_si128((const __m128i *)crd); + cb_lo = _mm_srai_epi32(_mm_unpacklo_epi16(CB, CB), 16); + cb_hi = _mm_srai_epi32(_mm_unpackhi_epi16(CB, CB), 16); + cr_lo = _mm_srai_epi32(_mm_unpacklo_epi16(CR, CR), 16); + cr_hi = _mm_srai_epi32(_mm_unpackhi_epi16(CR, CR), 16); + y_lo = _mm_srai_epi32(_mm_unpacklo_epi16(y16, y16), 16); + y_hi = _mm_srai_epi32(_mm_unpackhi_epi16(y16, y16), 16); + + r_lo = _mm_add_epi32( + y_lo, _mm_srai_epi32(_mm_add_epi32(MDEC_MUL32(k359, cr_lo), bias), 8)); + r_hi = _mm_add_epi32( + y_hi, _mm_srai_epi32(_mm_add_epi32(MDEC_MUL32(k359, cr_hi), bias), 8)); + b_lo = _mm_add_epi32( + y_lo, _mm_srai_epi32(_mm_add_epi32(MDEC_MUL32(k454, cb_lo), bias), 8)); + b_hi = _mm_add_epi32( + y_hi, _mm_srai_epi32(_mm_add_epi32(MDEC_MUL32(k454, cb_hi), bias), 8)); + a0 = _mm_andnot_si128(_mm_set1_epi32(0x1F), MDEC_MUL32(km88, cb_lo)); + a1 = _mm_andnot_si128(_mm_set1_epi32(0x1F), MDEC_MUL32(km88, cb_hi)); + c0 = _mm_andnot_si128(_mm_set1_epi32(0x07), MDEC_MUL32(km183, cr_lo)); + c1 = _mm_andnot_si128(_mm_set1_epi32(0x07), MDEC_MUL32(km183, cr_hi)); + g_lo = _mm_add_epi32( + y_lo, + _mm_srai_epi32(_mm_add_epi32(_mm_add_epi32(a0, c0), bias), 8)); + g_hi = _mm_add_epi32( + y_hi, + _mm_srai_epi32(_mm_add_epi32(_mm_add_epi32(a1, c1), bias), 8)); + R = MDEC_M9(_mm_packs_epi32(r_lo, r_hi)); + G = MDEC_M9(_mm_packs_epi32(g_lo, g_hi)); + B = MDEC_M9(_mm_packs_epi32(b_lo, b_hi)); + R = _mm_xor_si128(_mm_and_si128(R, _mm_set1_epi16(0xFF)), flip); + G = _mm_xor_si128(_mm_and_si128(G, _mm_set1_epi16(0xFF)), flip); + B = _mm_xor_si128(_mm_and_si128(B, _mm_set1_epi16(0xFF)), flip); + _mm_storel_epi64((__m128i *)r8, _mm_packus_epi16(R, R)); + _mm_storel_epi64((__m128i *)g8, _mm_packus_epi16(G, G)); + _mm_storel_epi64((__m128i *)b8, _mm_packus_epi16(B, B)); + for (i = 0; i < 8; i++) { + out[i * 3 + 0] = r8[i]; + out[i * 3 + 1] = g8[i]; + out[i * 3 + 2] = b8[i]; + } +} +#undef MDEC_MUL32 +#undef MDEC_M9 +#endif /* MDEC_HAVE_SSE2 */ + static void append_color_macroblock(const int16_t *crblk, const int16_t *cbblk, const int16_t yblk[4][64]) { /* 16×16: 512 bytes @15bpp, 768 @24bpp — reserve once per MB. */ @@ -406,6 +606,27 @@ static void append_color_macroblock(const int16_t *crblk, const int16_t *cbblk, uint8_t *out = output_reserve(need); if (!out) return; uint8_t *p = out; +#if defined(MDEC_HAVE_SSE2) + /* MotK FMV is 24bpp — row SIMD (Beetle EncodeRow24). 16bpp stays scalar. */ + if (mdec.output_depth != 3) { + uint8_t rgb_xor = mdec.output_signed ? 0x80u : 0x00u; + int py; + for (py = 0; py < 16; py++) { + int y_left = (py >= 8 ? 2 : 0); + int y_right = y_left + 1; + int ly = py & 7; + int crow = (py >> 1) * 8; + mdec_encode_row24_sse2(&yblk[y_left][ly * 8], &cbblk[crow], + &crblk[crow], rgb_xor, p); + p += 24; + mdec_encode_row24_sse2(&yblk[y_right][ly * 8], &cbblk[crow + 4], + &crblk[crow + 4], rgb_xor, p); + p += 24; + } + mdec.output_size += need; + return; + } +#endif for (int py = 0; py < 16; py++) { for (int px = 0; px < 16; px++) { int y_index = (py >= 8 ? 2 : 0) + (px >= 8 ? 1 : 0); @@ -568,6 +789,60 @@ void mdec_init(void) { } mdec.output_depth = 3; mdec.current_block = 4; +#if defined(MDEC_HAVE_SSE2) + idct_simd_selfcheck(); + memset(mdec.scale, 0, sizeof(mdec.scale)); + /* YCbCr row vs scalar emit — MotK FMV path (depth 2 = 24bpp). */ + { + uint32_t seed = 0xBADC0DEEu; + int n, i; + mdec.output_depth = 2; + mdec.output_signed = 0; + for (n = 0; n < 256; n++) { + int16_t by[8], cb4[4], cr4[4]; + uint8_t sse[24], ref[24]; + uint8_t *rp = ref; + for (i = 0; i < 8; i++) { + seed = seed * 1664525u + 1013904223u; + by[i] = (int16_t)((int)(seed % 255u) - 128); + } + for (i = 0; i < 4; i++) { + seed = seed * 1664525u + 1013904223u; + cb4[i] = (int16_t)((int)(seed % 255u) - 128); + seed = seed * 1664525u + 1013904223u; + cr4[i] = (int16_t)((int)(seed % 255u) - 128); + } + mdec_encode_row24_sse2(by, cb4, cr4, 0, sse); + for (i = 0; i < 8; i++) + rp = emit_rgb_pixel(rp, by[i], cr4[i >> 1], cb4[i >> 1]); + if (memcmp(sse, ref, 24) != 0) + abort(); + } + mdec.output_signed = 1; + for (n = 0; n < 64; n++) { + int16_t by[8], cb4[4], cr4[4]; + uint8_t sse[24], ref[24]; + uint8_t *rp = ref; + for (i = 0; i < 8; i++) { + seed = seed * 1664525u + 1013904223u; + by[i] = (int16_t)((int)(seed % 255u) - 128); + } + for (i = 0; i < 4; i++) { + seed = seed * 1664525u + 1013904223u; + cb4[i] = (int16_t)((int)(seed % 255u) - 128); + seed = seed * 1664525u + 1013904223u; + cr4[i] = (int16_t)((int)(seed % 255u) - 128); + } + mdec_encode_row24_sse2(by, cb4, cr4, 0x80u, sse); + for (i = 0; i < 8; i++) + rp = emit_rgb_pixel(rp, by[i], cr4[i >> 1], cb4[i >> 1]); + if (memcmp(sse, ref, 24) != 0) + abort(); + } + mdec.output_signed = 0; + mdec.output_depth = 3; + } +#endif } uint32_t mdec_read(uint32_t addr) { diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 41849963..6de674d0 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -267,6 +267,15 @@ void psx_kernel_bless_stats(uint64_t out[6]) { out[5] = kbless_invalidations; } +void psx_kernel_bless_resync_after_restore(void) { + /* kbless_state is host-only. CLEAN/MISMATCH are sticky across guest + * stores only via kbless_note_write; savestate RAM memcpy never hits + * that path. Leaving MISMATCH blocks native forever against restored + * bytes that may match ROM again; leaving CLEAN skips re-verify against + * restored patches. Both fork RB/selfcheck peers. */ + memset(kbless_state, KBLESS_UNKNOWN, sizeof(kbless_state)); +} + static inline void dirty_ram_mark_kernel_write(uint32_t phys) { if (phys >= DIRTY_RAM_KERNEL_TRACK_BYTES) return; dirty_ram_mark_page(phys); @@ -638,11 +647,30 @@ uint32_t overlay_watch_pagegen_sum(uint32_t phys, uint32_t len) { * bumps overlay_page_gen. Without this, ENTRY_VALID overlays keep the gen-gated * fast path and run native code against restored bytes they were not validated * for — hang / freeze after the restored frame presents. */ +void dirty_ram_text_guard_resync_after_restore(void) { + /* text_diverged_bitmap is sticky: once a page's entry bytes fail the + * reference compare, native stays blocked forever. That is correct for + * forward sim, but after a savestate/RB rewind the restored RAM may + * match the reference again — leaving the pre-load sticky bit forces + * dirty-interp where the peer (or the prior resim) still runs native, + * forking MotK selfcheck warm #2vs#3 at matched clocks (win#118 class: + * cold≡0, warm FAIL; post-span irq_resume also drifts). Drop both + * host-only text-guard bitmaps; live writes re-arm modified, and the + * next native_ok compare re-decides diverge against restored bytes. */ + memset(text_modified_bitmap, 0, sizeof(text_modified_bitmap)); + memset(text_diverged_bitmap, 0, sizeof(text_diverged_bitmap)); + g_text_diverged_pages = 0; +} + void overlay_watch_invalidate_after_ram_restore(void) { for (uint32_t pg = 0; pg < DIRTY_RAM_PAGE_COUNT; pg++) overlay_page_gen[pg]++; extern void overlay_loader_note_code_write(void); + extern void overlay_loader_resync_validation_after_restore(void); overlay_loader_note_code_write(); + dirty_ram_text_guard_resync_after_restore(); + psx_kernel_bless_resync_after_restore(); + overlay_loader_resync_validation_after_restore(); } static inline void overlay_watch_note_write(uint32_t phys, uint32_t size) { diff --git a/runtime/src/netplay_hash_confirm.c b/runtime/src/netplay_hash_confirm.c index 944c0941..ea84c8f0 100644 --- a/runtime/src/netplay_hash_confirm.c +++ b/runtime/src/netplay_hash_confirm.c @@ -112,3 +112,44 @@ uint8_t netplay_hc_peek_mismatch(const NetplayHashConfirm* hc, uint32_t* tick_ou if (peer_out) *peer_out = pd; return 1u; } + +uint8_t netplay_hc_heal_stale_gap(NetplayHashConfirm* hc) { + uint32_t next; + uint32_t best = 0u; + uint8_t have_best = 0u; + uint32_t i; + uint32_t ld = 0, pd = 0; + if (!hc || !hc->resolved_valid) + return 0u; + if (hc->resolved_through == 0xffffffffu) + return 0u; + next = hc->resolved_through + 1u; + /* Still have both digests for next: let try_advance / peek_mismatch own it. */ + if (local_at(hc, next, &ld) && peer_at(hc, next, &pd)) + return 0u; + /* Incomplete (one side only) — wait; do not jump over a pending commit. */ + if (local_at(hc, next, NULL) || peer_at(hc, next, NULL)) + return 0u; + /* next aged out of the ring. Refuse heal if any ring slot above the + * watermark still records a local≠peer pair (live fork still visible). */ + for (i = 0; i < NETPLAY_HC_RING; i++) { + uint32_t t; + if (!hc->local_valid[i] || !hc->peer_valid[i]) + continue; + if (hc->local_tick[i] != hc->peer_tick[i]) + continue; + t = hc->local_tick[i]; + if (t <= hc->resolved_through) + continue; + if (hc->local_digest[i] != hc->peer_digest[i]) + return 0u; /* known fork still in ring */ + if (!have_best || t > best) { + best = t; + have_best = 1u; + } + } + if (!have_best || best <= hc->resolved_through) + return 0u; + hc->resolved_through = best; + return 1u; +} diff --git a/runtime/src/netplay_state_digest.c b/runtime/src/netplay_state_digest.c index 554ceeb8..566497f8 100644 --- a/runtime/src/netplay_state_digest.c +++ b/runtime/src/netplay_state_digest.c @@ -105,21 +105,23 @@ void netplay_core_digest_parts(const CPUState* cpu, NetplayCoreParts* out) uint32_t crc_tim = 0xFFFFFFFFu; uint32_t crc_ram = 0xFFFFFFFFu; uint32_t crc_drt = 0xFFFFFFFFu; - uint32_t crc = 0xFFFFFFFFu; + uint32_t fold = 0xFFFFFFFFu; uint16_t counter[3], target[3]; uint32_t mode[3], frac[3]; int32_t irq_line[3]; uint32_t wc; uint32_t i; uint8_t* ram; + uint32_t cpu_h, clk_h, tim_h, ram_h, drt_h; if (out) memset(out, 0, sizeof(*out)); if (!cpu) return; - /* GTE is in BS_SEC_CPU but was omitted here — MFC2 can fork GPRs with - * matched RAM/clk (menu resim). Keep snap + digest in lockstep. */ + /* Single pass per partition, then fold the five digests into core. + * Old path streamed every byte into BOTH part CRCs and a combined CRC — + * 2× full RDRAM (~4 MiB CRC/frame) and dominated rollback FPS. */ crc_cpu = crc32_update(crc_cpu, (const uint8_t*)cpu->gpr, sizeof(cpu->gpr)); crc_cpu = crc32_update(crc_cpu, (const uint8_t*)&cpu->pc, sizeof(cpu->pc)); crc_cpu = crc32_update(crc_cpu, (const uint8_t*)&cpu->hi, sizeof(cpu->hi)); @@ -129,24 +131,12 @@ void netplay_core_digest_parts(const CPUState* cpu, NetplayCoreParts* out) crc_cpu = crc32_update(crc_cpu, (const uint8_t*)&cpu->cop0[14], sizeof(uint32_t)); crc_cpu = crc32_update(crc_cpu, (const uint8_t*)cpu->gte_data, sizeof(cpu->gte_data)); crc_cpu = crc32_update(crc_cpu, (const uint8_t*)cpu->gte_ctrl, sizeof(cpu->gte_ctrl)); - crc = crc32_update(crc, (const uint8_t*)cpu->gpr, sizeof(cpu->gpr)); - crc = crc32_update(crc, (const uint8_t*)&cpu->pc, sizeof(cpu->pc)); - crc = crc32_update(crc, (const uint8_t*)&cpu->hi, sizeof(cpu->hi)); - crc = crc32_update(crc, (const uint8_t*)&cpu->lo, sizeof(cpu->lo)); - crc = crc32_update(crc, (const uint8_t*)&cpu->cop0[12], sizeof(uint32_t)); - crc = crc32_update(crc, (const uint8_t*)&cpu->cop0[13], sizeof(uint32_t)); - crc = crc32_update(crc, (const uint8_t*)&cpu->cop0[14], sizeof(uint32_t)); - crc = crc32_update(crc, (const uint8_t*)cpu->gte_data, sizeof(cpu->gte_data)); - crc = crc32_update(crc, (const uint8_t*)cpu->gte_ctrl, sizeof(cpu->gte_ctrl)); { uint64_t cyc = psx_cycle_count; crc_clk = crc32_update(crc_clk, (const uint8_t*)&cyc, sizeof(cyc)); crc_clk = crc32_update(crc_clk, (const uint8_t*)&i_stat, sizeof(i_stat)); crc_clk = crc32_update(crc_clk, (const uint8_t*)&i_mask, sizeof(i_mask)); - crc = crc32_update(crc, (const uint8_t*)&cyc, sizeof(cyc)); - crc = crc32_update(crc, (const uint8_t*)&i_stat, sizeof(i_stat)); - crc = crc32_update(crc, (const uint8_t*)&i_mask, sizeof(i_mask)); } timers_get_snapshot(counter, mode, target, irq_line, frac); @@ -155,32 +145,35 @@ void netplay_core_digest_parts(const CPUState* cpu, NetplayCoreParts* out) crc_tim = crc32_update(crc_tim, (const uint8_t*)target, sizeof(target)); crc_tim = crc32_update(crc_tim, (const uint8_t*)irq_line, sizeof(irq_line)); crc_tim = crc32_update(crc_tim, (const uint8_t*)frac, sizeof(frac)); - crc = crc32_update(crc, (const uint8_t*)counter, sizeof(counter)); - crc = crc32_update(crc, (const uint8_t*)mode, sizeof(mode)); - crc = crc32_update(crc, (const uint8_t*)target, sizeof(target)); - crc = crc32_update(crc, (const uint8_t*)irq_line, sizeof(irq_line)); - crc = crc32_update(crc, (const uint8_t*)frac, sizeof(frac)); ram = memory_get_ram_ptr(); - if (ram) { + if (ram) crc_ram = crc32_update(crc_ram, ram, NP_RAM_SIZE); - crc = crc32_update(crc, ram, NP_RAM_SIZE); - } wc = dirty_ram_get_bitmap_word_count(); for (i = 0; i < wc; i++) { uint32_t w = dirty_ram_get_bitmap_word(i); crc_drt = crc32_update(crc_drt, (const uint8_t*)&w, sizeof(w)); - crc = crc32_update(crc, (const uint8_t*)&w, sizeof(w)); } + cpu_h = crc_cpu ^ 0xFFFFFFFFu; + clk_h = crc_clk ^ 0xFFFFFFFFu; + tim_h = crc_tim ^ 0xFFFFFFFFu; + ram_h = crc_ram ^ 0xFFFFFFFFu; + drt_h = crc_drt ^ 0xFFFFFFFFu; + fold = crc32_update(fold, (const uint8_t*)&cpu_h, sizeof(cpu_h)); + fold = crc32_update(fold, (const uint8_t*)&clk_h, sizeof(clk_h)); + fold = crc32_update(fold, (const uint8_t*)&tim_h, sizeof(tim_h)); + fold = crc32_update(fold, (const uint8_t*)&ram_h, sizeof(ram_h)); + fold = crc32_update(fold, (const uint8_t*)&drt_h, sizeof(drt_h)); + if (out) { - out->cpu = crc_cpu ^ 0xFFFFFFFFu; - out->clock_irq = crc_clk ^ 0xFFFFFFFFu; - out->timers = crc_tim ^ 0xFFFFFFFFu; - out->ram = crc_ram ^ 0xFFFFFFFFu; - out->dirty = crc_drt ^ 0xFFFFFFFFu; - out->core = crc ^ 0xFFFFFFFFu; + out->cpu = cpu_h; + out->clock_irq = clk_h; + out->timers = tim_h; + out->ram = ram_h; + out->dirty = drt_h; + out->core = fold ^ 0xFFFFFFFFu; } } diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index 32908fca..edd5af69 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -431,6 +431,31 @@ static int cand_selected_for_diff(const Candidate *c) { return 0; } +/* ---- Load freeze (rollback resim / selfcheck) -------------------------- + * Overlay DLL registration is host-only and not part of boot_state. A lazy + * load mid-resim-pass#1 that is visible at the start of pass#2 changes + * native-vs-interp BB boundaries and forks MotK wait-loop GPRs at matched + * guest clocks. */ +static int s_load_freeze = 0; + +void overlay_loader_set_load_freeze(int freeze) +{ + s_load_freeze = freeze ? 1 : 0; +} +int overlay_loader_load_frozen(void) { return s_load_freeze; } + +static int overlay_loads_allowed(void) +{ + if (s_load_freeze) + return 0; + { + extern int psx_netplay_is_resimulating(void); + if (psx_netplay_is_resimulating()) + return 0; + } + return 1; +} + /* ---- Counters (surfaced via overlay_loader_status) --------------------- */ static int s_ndlls = 0; /* DLLs LoadLibrary'd */ static uint64_t s_load_total_us = 0; @@ -2797,12 +2822,33 @@ static void mark_checked(uint32_t region_start) { if (s_nchecked < MAX_CHECKED) s_checked[s_nchecked++] = region_start; } +void overlay_loader_clear_lazy_miss(void) +{ + lazy_miss_invalidate_loader(); + s_nchecked = 0; +} + +void overlay_loader_resync_validation_after_restore(void) +{ + /* Host-only validation memos. Page gens were already bumped by + * overlay_watch_invalidate_after_ram_restore; force every candidate off + * the gen fast-path (including ENTRY_INVALID + gen==val_gen skips, and + * nranges==0 bodies whose gensum never moves). Static-match cache is the + * same class for AOT game/BIOS overlays. */ + int i; + for (i = 0; i < s_cand_n; i++) + s_cand[i].val_gen ^= 0x80000000u; +#ifdef PSX_HAS_OVERLAY_DISPATCH + memset(s_static_match_cache, 0, sizeof(s_static_match_cache)); +#endif +} + /* Re-scan the cache dir for DLLs compiled after init (step 2.8 autocompile) * and clear the checked-regions memo so the next dispatch into a window * region reconsiders the cache. Already-loaded DLLs stay loaded; * dll_already_loaded() makes the re-walk idempotent. */ void overlay_loader_rescan(void) { - if (!s_active) return; + if (!s_active || !overlay_loads_allowed()) return; scan_cache_dir(); load_bios_resident_shards(); s_nchecked = 0; @@ -3042,6 +3088,10 @@ OverlayPreparedImage *overlay_loader_prepare_published(const char *dll_path) { int overlay_loader_commit_published(OverlayPreparedImage *image) { if (!image) return 0; + if (!overlay_loads_allowed()) { + overlay_loader_discard_prepared(image); + return 0; + } OverlayLibraryHandle handle = image->handle; image->handle = NULL; int loaded = 0; @@ -3209,6 +3259,9 @@ static int lazy_load_selected(int li) { static int try_load_region(uint32_t phys) { extern uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); + if (!overlay_loads_allowed()) + return 0; + uint32_t page_sz = 4096u; /* Walk back over the contiguous dirty run to recover region_start — cache diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index ffefeaeb..150b8d6c 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -410,9 +410,11 @@ static void idle_snapshot_regs(const CPUState *cpu) { static int idle_skip_on(void) { /* Cycle-skip is a host enhancement; under netplay it forks peers (detector - * streak / skip quanta are not part of the shared snap). */ + * streak / skip quanta are not part of the shared snap). Same for the + * solo resim self-check — keep the window on the faithful cycle path. */ extern int psx_netplay_active(void); - if (psx_netplay_active()) + extern int psx_selfcheck_enabled(void); + if (psx_netplay_active() || psx_selfcheck_enabled()) return 0; if (g_idle_skip_enabled < 0) { /* No game config reached this process (for example a BIOS-only @@ -580,6 +582,14 @@ void psx_cycles_resync_after_restore(CPUState *cpu) { cpu->ld_which_t = 0x20u; /* no pending load dest */ cpu->ld_absorb = 0; } + /* Dirty-RAM interpreter load-delay writebacks live in host statics, not + * BS_SEC_CPU. Discard (do not flush): snap GPRs are already architectural. + * A stale pending v0 write from the pre-load timeline was forking MotK + * resim peers (countdown vs BIOS v0=1) at matched guest clocks. */ + { + extern void dirty_ram_ld_delay_discard(void); + dirty_ram_ld_delay_discard(); + } } void psx_cycles_reset_for_boot(void) { diff --git a/runtime/src/psx_interpreter.c b/runtime/src/psx_interpreter.c index 72fcf266..2f0a8b52 100644 --- a/runtime/src/psx_interpreter.c +++ b/runtime/src/psx_interpreter.c @@ -71,6 +71,7 @@ static uint32_t s_load_val; /* ---- Init ---- */ void interp_init(CPUState* cpu) { + (void)cpu; s_hit_bp = 0; s_trace_on = 0; s_halted = 0; @@ -83,6 +84,11 @@ void interp_init(CPUState* cpu) { memset(s_trace_ring, 0, sizeof(s_trace_ring)); } +void interp_ld_delay_discard(void) { + s_load_reg = 0; + s_load_val = 0; +} + /* ---- Breakpoints ---- */ int interp_break_add(uint32_t pc) { diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 51e69954..75bc41fc 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -33,6 +33,7 @@ #include "netplay_state_digest.h" #include "psx_netplay_rb.h" #include "cpu_state.h" +#include "gpu.h" #include "interrupts.h" #include "psx_scheduler.h" #if defined(PSX_HAS_LOBBY_CLIENT) @@ -426,6 +427,19 @@ static void np_drain_peer_frame_commits(void) if (!g_np.session) return; while (rnet_session_take_rb_frame_commit(g_np.session, &through, &hash)) netplay_hc_note_peer(&g_np.hc, through, hash); + /* FIRST CORE can stick the watermark forever once that tick ages out of + * the 128-slot ring — heal so choose_load_tick sees a real frontier. */ + if (netplay_hc_heal_stale_gap(&g_np.hc)) { + static uint32_t s_heal_log; + uint32_t rt = netplay_hc_resolved_through(&g_np.hc); + if (s_heal_log != rt) { + fprintf(stderr, + "psxrecomp: rb hash_confirm heal stale gap → resolved=%u\n", + (unsigned)rt); + fflush(stderr); + s_heal_log = rt; + } + } np_check_core_diverge(); } @@ -438,6 +452,12 @@ static void np_emit_frame_commit(uint32_t tick) uint32_t aux = 0u; int crumb; if (!g_np.cpu || !g_np.session) return; + /* Live depth24: skip full-RAM FRAME_COMMIT (rewind already deferred). + * Resim still commits every tick. Leaving FMV primes hash_confirm so the + * watermark is not stuck on missing movie slots. */ + if (gpu_display_is_depth24() && + !(g_np.rollback && psx_netplay_rb_is_resimulating())) + return; /* Present-edge: clear PC so parked-0 vs live-BB does not fork FRAME_COMMIT * while GPRs/RAM/clk match (was aborting good Replay on dig_cpu alone). */ psx_netplay_rb_cpu_for_present_digest(&dig_cpu, g_np.cpu); @@ -452,10 +472,12 @@ static void np_emit_frame_commit(uint32_t tick) np_part_ring_put(tick, &parts, av, cd, aux); netplay_hc_note_local(&g_np.hc, tick, parts.core); (void)rnet_session_send_rb_frame_commit(g_np.session, tick, parts.core); - /* Breadcrumbs so both peers' logs line up by sim tick. */ + (void)netplay_hc_heal_stale_gap(&g_np.hc); + /* Breadcrumbs so both peers' logs line up by sim tick. Tag is local-only + * (not peer agreement — that is hash_confirm resolved_through). */ if (crumb && s_live_dig_last_tick != tick) { s_live_dig_last_tick = tick; - np_log_live_digest(tick, &parts, av, cd, aux, "ok"); + np_log_live_digest(tick, &parts, av, cd, aux, "local"); } np_check_core_diverge(); } @@ -1301,8 +1323,7 @@ static void np_rb_bind_and_start(void) } /* MotK digital (active-low): wire only releases buttons vs invent; sticks equal. - * Menu press/release thrash opened an episode every edge — soft-promote releases - * only while cores still match (see reconcile). */ + * Used for FMV soft-promote releases only (menu releases must rewind). */ static int np_digital_release_only(const RNetInputContractFrame *pub, const RNetInputContractFrame *wire) { @@ -1319,6 +1340,19 @@ static int np_digital_release_only(const RNetInputContractFrame *pub, return newly_pressed == 0 && newly_released != 0; } +/* Wire has at least one newly pressed button vs published invent (active-low). + * FMV skip uses this — promote-without-resim left host mid-movie while peer + * already cut over. */ +static int np_digital_new_press(const RNetInputContractFrame *pub, + const RNetInputContractFrame *wire) +{ + uint16_t newly_pressed; + if (!pub || !wire) + return 0; + newly_pressed = (uint16_t)((uint16_t)(~wire->buttons) & pub->buttons); + return newly_pressed != 0; +} + /* Late authoritative wire vs published predicted rows → promote or queue rewind. * Diag: promote-no-resim means hist took the late pad but guest sim did not * rewind — looks like "remote input rejected" when cadence already drifted. */ @@ -1360,14 +1394,18 @@ static void np_rollback_reconcile_wire(void) promote_sweep = psx_netplay_rb_take_promote_sweep(); cooldown = psx_netplay_rb_rewind_suppressed(); fmv_defer = psx_netplay_rb_fmv_defer_rewind(); - /* FMV: never open episodes — same promote path as cooldown. */ - no_resim = promote_sweep || cooldown || fmv_defer; - if (fmv_defer) - no_resim_why = "fmv"; - else if (promote_sweep) - no_resim_why = "sweep"; - else if (cooldown) - no_resim_why = "cooldown"; + { + /* Media + post-FMV lockstep (title Start): promote-only; admit waits + * for wire so presses never open 0x8006CDA0 / VLC tip episodes. */ + int fmv_lock = psx_netplay_rb_lockstep_no_invent(); + no_resim = promote_sweep || cooldown || fmv_lock; + if (promote_sweep) + no_resim_why = "sweep"; + else if (cooldown) + no_resim_why = "cooldown"; + else if (fmv_lock) + no_resim_why = "fmv-lockstep"; + } sim = rnet_session_sim_tick(g_np.session); for (slot = 0; slot < g_np.slot_count; ++slot) { if (slot == g_np.local_slot) continue; @@ -1401,7 +1439,7 @@ static void np_rollback_reconcile_wire(void) pads_differ = (pub_c.buttons != wire_c.buttons) || (pub_c.stick_x != wire_c.stick_x) || (pub_c.stick_y != wire_c.stick_y); - /* After commit/realign/FMV: flush invent poison without another episode. */ + /* After commit/realign: flush invent poison without another episode. */ if (no_resim) { (void)netplay_ih_promote(&g_np.ih, slot, &wire_frame); if (pads_differ) { @@ -1420,11 +1458,11 @@ static void np_rollback_reconcile_wire(void) d = rnet_input_contract_stick_replace_decide( &pub_c, &wire_c, completed, ¶ms, &gates); if (rnet_input_contract_decision_is_rewind(d)) { - /* Soft-promote releases only when peers still agree. After a - * sticky invent forked RAM, promote-without-resim left cores - * desynced until the next press aborted on baseline mismatch. */ - if (np_digital_release_only(&pub_c, &wire_c) && - !netplay_hc_peek_mismatch(&g_np.hc, NULL, NULL, NULL)) { + /* FMV/settle only: soft-promote releases (skip must rewind on + * press). Menu soft-promote + hold-last invent forked RAM + * (sticky Up skipped resim). Live invent is hold-last again — + * menu releases open a real episode. */ + if (fmv_defer && np_digital_release_only(&pub_c, &wire_c)) { (void)netplay_ih_promote(&g_np.ih, slot, &wire_frame); if (n_soft_release == 0) { first_no_resim_t = t; @@ -1435,6 +1473,21 @@ static void np_rollback_reconcile_wire(void) n_soft_release++; continue; } + /* FMV: non-press pad noise → promote only (avoid CD thrash). */ + if (fmv_defer && !np_digital_new_press(&pub_c, &wire_c)) { + (void)netplay_ih_promote(&g_np.ih, slot, &wire_frame); + if (pads_differ) { + if (n_no_resim == 0) { + first_no_resim_t = t; + first_no_resim_slot = slot; + first_pub_btn = pub_c.buttons; + first_wire_btn = wire_c.buttons; + no_resim_why = "fmv-nopress"; + } + n_no_resim++; + } + continue; + } if (!g_np.pending_rewind) { g_np.pending_rewind = 1; g_np.pending_rewind_tick = t; @@ -1447,13 +1500,15 @@ static void np_rollback_reconcile_wire(void) first_pub_btn = pub_c.buttons; first_wire_btn = wire_c.buttons; } - /* Only park the guest when an episode actually opens. */ + /* Promote BEFORE begin — seal_inputs reads hist for the + * local seat and publish_sio falls back to hist before + * peer SEAL_ROWS land. Skipping promote left invent-idle + * pads sealed/published across the skip press tick. */ + (void)netplay_ih_promote(&g_np.ih, slot, &wire_frame); if (psx_netplay_rb_begin_rewind(t, slot)) { g_np.needs_advance = 0; n_episode_open++; } else { - /* No snap / cooldown / already active — promote wire. */ - (void)netplay_ih_promote(&g_np.ih, slot, &wire_frame); n_begin_refused++; } g_np.pending_rewind = 0; @@ -1526,10 +1581,9 @@ static int np_try_admit_rollback(void) rnet_u32 wire; int slot; int pred; - int lockstep; /* Tick FMV→settle tracker every admit (even when remotes are present). */ - lockstep = psx_netplay_rb_lockstep_no_invent(); + (void)psx_netplay_rb_lockstep_no_invent(); if (!rnet_session_prepare_local_tip(g_np.session, sim)) return 0; @@ -1570,16 +1624,18 @@ static int np_try_admit_rollback(void) netplay_ih_pad_to_frame(&pad, sim, 0, &row); (void)netplay_ih_put(&g_np.ih, slot, &row); } else { - /* FMV + post-FMV settle: never invent — wait for wire (lockstep). - * Promote-only during movies left peers core-desynced at cutover. */ - if (lockstep) + /* FMV media + post-FMV lockstep: wait for remote wire (skip / + * title Start). Invent idle opened tip episodes that hung. */ + if (psx_netplay_rb_lockstep_no_invent()) return 0; /* Stall when invent would run more than P ahead of remote tip. */ if (wire > st.highest_remote_wire + (rnet_u32)pred) return 0; - /* MotK digital: invent idle, not hold-last. Hold-last sticky Up - * after settle forked menu RAM (soft-promote then skipped resim). */ - (void)netplay_ih_invent_idle(&g_np.ih, slot, sim, &row); + /* MotK digital: hold-last. Idle invent re-mismatched every held + * D-pad tick after commit → episode storm / char-select freeze. + * Menu release soft-promote is off (see reconcile) so sticky Up + * cannot skip a needed resim. Seal gap-fill stays idle. */ + (void)netplay_ih_invent_hold_last(&g_np.ih, slot, sim, &row); } } @@ -2678,6 +2734,8 @@ void psx_netplay_pump(void) drain_lobby_signals(); rnet_session_pump(g_np.session); np_drain_peer_frame_commits(); + /* Stall abort only — full rb_pump stays off mid-guest (asymmetric). */ + psx_netplay_rb_poll_replay_stall(); return; } np_pump_session(); @@ -2759,8 +2817,12 @@ int psx_netplay_poll_admit(void) * while a post-abort realign load is queued (episode already inactive). */ if (g_np.rollback && g_np.xfer == NP_XFER_NONE && (psx_netplay_rb_active() || psx_netplay_rb_load_pending())) { - g_np.needs_advance = 0; - return psx_netplay_rb_try_admit(); + if (psx_netplay_rb_tip_holding() && !psx_netplay_rb_load_pending()) { + /* TipHold: Live invent continues; episode stays open for tip-extend. */ + } else { + g_np.needs_advance = 0; + return psx_netplay_rb_try_admit(); + } } /* Already published this tick and waiting for finish_frame — do not @@ -2887,6 +2949,11 @@ void psx_netplay_finish_frame(void) if (g_np.rollback && psx_netplay_rb_is_resimulating()) { rnet_u32 done = rnet_session_sim_tick(g_np.session); + /* Present-edge only: mid-guest pump skips reconcile. Coalesce late + * wire into the active episode (tip-extend) before POST/Verify. */ + rnet_session_pump(g_np.session); + np_rollback_reconcile_wire(); + psx_netplay_rb_pump(); psx_netplay_rb_finish_frame(); /* Exchange cores during Replay so mid-resim forks abort before POST. */ np_emit_frame_commit(done); @@ -2903,6 +2970,10 @@ void psx_netplay_finish_frame(void) np_emit_frame_commit(done); if (g_np.rollback) { uint32_t resume_hint = 0; + /* TipHold Live: reconcile late wire so tip-extend can schedule rereplay. */ + if (psx_netplay_rb_tip_holding()) + np_rollback_reconcile_wire(); + psx_netplay_rb_pump(); psx_netplay_rb_request_snap(done); /* Flush at vblank: MotK IRQ fast/mid paths used to skip poll_snap, so * deferred BB-edge saves never ran and the ring stayed empty. Prefer diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index 9f74dd8b..038869bf 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -10,6 +10,13 @@ void psx_netplay_rb_poll(struct CPUState *cpu, uint32_t resume_pc) (void)resume_pc; } void psx_netplay_rb_flush_resume(void) {} +int psx_netplay_rb_top_level_resume_active(void) { return 0; } +int psx_netplay_rb_recover_null_pc(struct CPUState *cpu, uint32_t *out_pc) +{ + (void)cpu; + (void)out_pc; + return 0; +} void psx_netplay_rb_request_snap(uint32_t tick) { (void)tick; } int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) { @@ -17,13 +24,22 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) (void)slot; return 0; } +int psx_netplay_rb_tip_extend(uint32_t mismatch_tick, int slot) +{ + (void)mismatch_tick; + (void)slot; + return 0; +} 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; } +void psx_netplay_rb_poll_replay_stall(void) {} int psx_netplay_rb_take_promote_sweep(void) { return 0; } void psx_netplay_rb_pump(void) {} int psx_netplay_rb_active(void) { return 0; } int psx_netplay_rb_is_resimulating(void) { return 0; } +int psx_netplay_rb_tip_holding(void) { return 0; } int psx_netplay_rb_abort_resim_core_mismatch(uint32_t tick, uint32_t local_core, uint32_t peer_core) { @@ -90,17 +106,17 @@ static int g_episode_snap_applied; /* Live-path snap every N ticks (raw boot_state ~3.5MB, no zlib). Resim still * snaps every tick so episode baselines stay dense. - * Override: PSX_NET_SNAP_INTERVAL (default 8 — was 4; zlib removal + wider - * interval are the main FPS wins). */ + * Override: PSX_NET_SNAP_INTERVAL (default 16 — was 8; live memcpy was a + * major FPS tax stacked on per-frame RAM digests). */ static uint32_t snap_interval(void) { static int latched; - static uint32_t iv = 8u; + static uint32_t iv = 16u; if (!latched) { const char *e = getenv("PSX_NET_SNAP_INTERVAL"); if (e && e[0]) { unsigned v = 0; - if (sscanf(e, "%u", &v) == 1 && v >= 1u && v <= 16u) + if (sscanf(e, "%u", &v) == 1 && v >= 1u && v <= 32u) iv = v; } latched = 1; @@ -155,6 +171,10 @@ static int g_wait_peer_bl_logged; /* Last committed target — Live realign after POST/baseline abort. */ static int g_agreed_valid; static uint32_t g_agreed_through; +/* Load tick of the last committed episode. Both peers replayed + * [span_lo+1 .. agreed_through] and resim saves a snap at every replayed tick, + * so any ring snap inside that span is guaranteed to exist on the peer too. */ +static uint32_t g_agreed_span_lo; /* 1: pending load is a Live realign (not an episode baseline). */ static int g_live_realign_pending; static uint32_t g_episode_load_tick; /* captured for post-diverge realign */ @@ -165,7 +185,8 @@ static uint8_t *g_pin_data; static size_t g_pin_size; static uint32_t g_pin_tick; static int g_pin_valid; -/* After commit/realign: refuse new episodes for N sim ticks (promote wire). */ +/* After abort/realign only: refuse new episodes for N sim ticks (promote wire). + * Successful commit does NOT arm this — late wire must open another episode. */ static uint32_t g_rewind_cooldown_until; static int g_promote_sweep; static uint32_t g_last_begin_mismatch = 0xffffffffu; @@ -179,24 +200,43 @@ static uint32_t g_last_begin_mismatch = 0xffffffffu; #define RB_BASELINE_BURST_MS 40u /* Initiator waits for follower ready-ACK; do NOT solo-enter Replay. */ #define RB_READY_TIMEOUT_MS 4000u -/* Clean commit: short promote-only window (invent edges thrash without this). */ -#define RB_REWIND_COOLDOWN_TICKS 12u /* Failed episode (resim/baseline/post): calm from *live* sim before realign. */ #define RB_ABORT_COOLDOWN_TICKS 60u /* Repeated abort streak — longer promote-only (char-select storm). */ #define RB_STORM_COOLDOWN_TICKS 120u /* Keep deferring a few frames after MDEC goes quiet (cutover / credits). */ #define RB_FMV_MDEC_HYSTERESIS 8u -/* After leaving FMV: stay lockstep (no invent / no rewind) so cutover heals. */ -#define RB_FMV_SETTLE_TICKS 90u +/* After leaving FMV: defer rewind briefly so cutover heals. */ +#define RB_FMV_SETTLE_TICKS 24u +/* Post-FMV no-invent lockstep (title Start / skip safe). Floor keeps invent + * off for at least MIN even when digests already agree; if cores have not + * matched for CONFIRM contiguous ticks by then, hold until they do or MAX + * (cross-game cutover — MotK was healthy at ~90, slower titles need gate). */ +#define RB_FMV_LOCKSTEP_MIN 90u +#define RB_FMV_LOCKSTEP_MAX 180u +#define RB_FMV_LOCKSTEP_CONFIRM 16u /* Baseline mismatch with no agreed tip — don't reopen every few ticks. */ #define RB_BASELINE_MISMATCH_COOLDOWN_TICKS 90u static int g_was_in_fmv; static uint32_t g_fmv_settle_until; +static uint32_t g_fmv_lockstep_until; +static uint32_t g_fmv_media_end_sim; +static uint32_t g_fmv_core_match_streak; +static int g_fmv_lockstep_released; /* sticky: never re-lock after invent on */ +/* Inclusive last tick both peers dense-snapped (media/lockstep/+tip). */ +static uint32_t g_fmv_dense_through; static uint32_t g_bl_mismatch_streak; +static uint32_t g_tip_hold_until; +/* Mid-span tip-extend rereplay: reload snap without replacing episode pin. */ +static int g_tip_extend_rereplay; static RNetSession *sess(void); /* fwd — used by FMV lockstep window */ +static void commit_episode(void); /* fwd — legacy immediate commit */ +static void enter_tip_hold(uint32_t target); +static void enter_verify_at_tip(uint32_t done); +static void finalize_tip_hold(void); +static void poll_tip_hold_finalize(void); static int rb_fmv_media_active(void) { @@ -207,8 +247,68 @@ static int rb_fmv_media_active(void) return 0; } -/* Updates settle window; returns 1 during FMV or post-FMV settle. */ -static int rb_in_fmv_lockstep_window(void) +/* After media: grow/shrink lockstep_until from hash_confirm agreement. */ +static void rb_fmv_update_lockstep_gate(uint32_t sim) +{ + int matched = 0; + uint32_t cap; + uint32_t prev_until; + if (!g_fmv_media_end_sim || rb_fmv_media_active() || g_fmv_lockstep_released) + return; + + if (g_b.hc && !netplay_hc_peek_mismatch(g_b.hc, NULL, NULL, NULL)) { + uint32_t need = (sim > 0u) ? (sim - 1u) : 0u; + if (netplay_hc_confirm_through(g_b.hc, need)) + matched = 1; + } + if (matched) + g_fmv_core_match_streak++; + else + g_fmv_core_match_streak = 0; + + if (sim < g_fmv_media_end_sim + RB_FMV_LOCKSTEP_MIN) + return; /* accumulate streak; invent stays off through MIN */ + + prev_until = g_fmv_lockstep_until; + cap = g_fmv_media_end_sim + RB_FMV_LOCKSTEP_MAX; + if (sim >= cap || g_fmv_core_match_streak >= RB_FMV_LOCKSTEP_CONFIRM) { + if (g_fmv_lockstep_until > sim) + g_fmv_lockstep_until = sim; + g_fmv_lockstep_released = 1; + fprintf(stderr, + "psxrecomp: rb FMV lockstep RELEASE sim=%u streak=%u " + "cap=%u (invent on%s)\n", + (unsigned)sim, (unsigned)g_fmv_core_match_streak, + (unsigned)cap, + (sim >= cap) ? "; MAX" : "; cores matched"); + fflush(stderr); + (void)prev_until; + return; + } + /* Past MIN, cores not confirmed — hold invent until match or MAX. */ + { + uint32_t until = sim + 1u; + if (until > cap) + until = cap; + if (until > g_fmv_lockstep_until) + g_fmv_lockstep_until = until; + } + if (g_fmv_lockstep_until > prev_until) { + static uint32_t s_ext_log; + if (s_ext_log != sim) { + fprintf(stderr, + "psxrecomp: rb FMV lockstep EXTEND until=%u sim=%u " + "streak=%u (waiting core confirm)\n", + (unsigned)g_fmv_lockstep_until, (unsigned)sim, + (unsigned)g_fmv_core_match_streak); + fflush(stderr); + s_ext_log = sim; + } + } +} + +/* Track settle window after FMV ends. */ +static void rb_fmv_tick_settle(void) { RNetSession *s = sess(); uint32_t sim = s ? rnet_session_sim_tick(s) : 0u; @@ -217,22 +317,55 @@ static int rb_in_fmv_lockstep_window(void) if (media) { if (!g_was_in_fmv) { g_was_in_fmv = 1; - fprintf(stderr, "psxrecomp: rb FMV lockstep ON (depth24/mdec)\n"); + fprintf(stderr, + "psxrecomp: rb FMV rewind-defer ON (depth24/mdec; no invent)\n"); fflush(stderr); } - return 1; + return; } if (g_was_in_fmv) { g_was_in_fmv = 0; + g_fmv_media_end_sim = sim; + g_fmv_core_match_streak = 0; + g_fmv_lockstep_released = 0; g_fmv_settle_until = sim + RB_FMV_SETTLE_TICKS; + g_fmv_lockstep_until = sim + RB_FMV_LOCKSTEP_MIN; + /* Live FRAME_COMMITs were skipped during depth24 — waive the gap so + * hash_confirm can advance again once digests resume. */ + if (g_b.hc) + netplay_hc_prime_after(g_b.hc, sim); fprintf(stderr, - "psxrecomp: rb FMV settle until sim=%u (no invent/rewind)\n", - (unsigned)g_fmv_settle_until); + "psxrecomp: rb FMV settle until sim=%u lockstep min=%u " + "max=%u confirm=%u (no invent; hc primed)\n", + (unsigned)g_fmv_settle_until, + (unsigned)g_fmv_lockstep_until, + (unsigned)(sim + RB_FMV_LOCKSTEP_MAX), + (unsigned)RB_FMV_LOCKSTEP_CONFIRM); fflush(stderr); } - if (sim < g_fmv_settle_until) + rb_fmv_update_lockstep_gate(sim); +} + +/* Defer rewind during movie + short settle. */ +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(); + if (rb_fmv_media_active()) return 1; - return 0; + return sim < g_fmv_settle_until; +} + +/* Media + post-FMV lockstep: admit waits for remote wire (title Start safe). */ +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(); + if (rb_fmv_media_active()) + return 1; + return sim < g_fmv_lockstep_until; } static void arm_rewind_cooldown_ticks(uint32_t sim, uint32_t ticks, const char *why) @@ -246,11 +379,6 @@ static void arm_rewind_cooldown_ticks(uint32_t sim, uint32_t ticks, const char * fflush(stderr); } -static void arm_rewind_cooldown(uint32_t sim, const char *why) -{ - arm_rewind_cooldown_ticks(sim, RB_REWIND_COOLDOWN_TICKS, why); -} - #if defined(PSX_HAS_GAME_DISPATCH) extern int psx_game_is_function_entry(uint32_t addr); #endif @@ -286,38 +414,37 @@ static int rb_resume_pc_ok(uint32_t pc) return 0; } -static int rb_resume_pc_preferred(uint32_t pc) +/* MotK title/menu wait ping-pong. Opposite edges with matched clocks fork + * dig_cpu (v0 countdown vs 1) while FRAME_COMMIT clears PC and still matches. + * Always store the post-present edge so both peers pin the same resume. */ +#define RB_MOTK_WAIT_A 0x8006CD54u +#define RB_MOTK_WAIT_B 0x8006CDA0u + +static uint32_t rb_canonicalize_resume_pc(uint32_t pc) { - if (!rb_resume_pc_ok(pc)) - return 0; -#if defined(PSX_HAS_GAME_DISPATCH) - /* Prefer real function entries when the game table is linked. */ - if ((pc & 0xfff00000u) != 0xbfc00000u && !psx_game_is_function_entry(pc)) - return 0; -#endif - return 1; + if (pc == RB_MOTK_WAIT_A || pc == RB_MOTK_WAIT_B) + return RB_MOTK_WAIT_B; + return pc; } /* Prefer BB-edge / IRQ check PCs — finish_frame often sees cpu->pc==0 while the - * guest is parked in the vblank present path. */ + * guest is parked in the vblank present path. IRQ/sticky/$ra first: bare + * function-entry snaps picked short jr-$ra leaves (0x8006A9F8) whose top-level + * flush_resume re-dispatch published pc=0 (cps exit: final_ra=0x8006CCA0). */ static uint32_t pick_snap_resume_pc(const CPUState *c, uint32_t hint) { - const uint32_t cands[5] = { - hint, + const uint32_t cands[6] = { + psx_last_irq_check_pc(), g_last_good_bb_pc, psx_compiled_irq_resume_pc(), - psx_last_irq_check_pc(), + c ? c->gpr[31] : 0u, + hint, c ? c->pc : 0u, }; int i; - /* Pass 1: function-entry / preferred. Pass 2: any ok RAM/BIOS PC. */ - for (i = 0; i < 5; ++i) { - if (rb_resume_pc_preferred(cands[i])) - return cands[i]; - } - for (i = 0; i < 5; ++i) { + for (i = 0; i < 6; ++i) { if (rb_resume_pc_ok(cands[i])) - return cands[i]; + return rb_canonicalize_resume_pc(cands[i]); } return 0; } @@ -471,6 +598,9 @@ static void abort_episode(const char *why) g_episode_snap_applied = 0; g_needs_advance = 0; g_episode_baseline_matched = 0; + g_tip_hold_until = 0; + g_tip_extend_rereplay = 0; + psx_scheduler_top_level_resume_clear(); clear_episode_wire_state(); if (g_rb && rnet_rb_is_active(g_rb)) { rnet_rb_set_phase(g_rb, nRNetRbPhaseAbort); @@ -478,6 +608,50 @@ static void abort_episode(const char *why) } } +int psx_netplay_rb_top_level_resume_active(void) +{ + /* Shared with savestate/selfcheck — armed inside psx_scheduler_resume_at. */ + return psx_scheduler_top_level_resume_active(); +} + +/* Top-level flush_resume has no native chain under the resume PC. A leaf that + * returns (or sentinel "continue native") publishes pc=0 → GUEST_EXIT. Recover + * at $ra / sticky BB when that is a safe re-entry. */ +int psx_netplay_rb_recover_null_pc(struct CPUState *cpu_in, uint32_t *out_pc) +{ + uint32_t cand; + int i; + if (!psx_scheduler_top_level_resume_active() || !cpu_in || !out_pc) + return 0; + { + const uint32_t cands[4] = { + cpu_in->gpr[31], + g_last_good_bb_pc, + psx_last_irq_check_pc(), + psx_compiled_irq_resume_pc(), + }; + for (i = 0; i < 4; ++i) { + cand = cands[i]; + if (!rb_resume_pc_ok(cand)) + continue; + fprintf(stderr, + "psxrecomp: rb recover null-pc → 0x%08x (ra=0x%08x sticky=0x%08x)\n", + (unsigned)cand, (unsigned)cpu_in->gpr[31], + (unsigned)g_last_good_bb_pc); + fflush(stderr); + *out_pc = cand; + note_good_bb_pc(cand); + return 1; + } + } + fprintf(stderr, + "psxrecomp: rb recover null-pc FAILED ra=0x%08x sticky=0x%08x\n", + (unsigned)cpu_in->gpr[31], (unsigned)g_last_good_bb_pc); + fflush(stderr); + psx_scheduler_top_level_resume_clear(); + return 0; +} + /* After a failed episode: rewind Live to a tip both peers already agreed on. */ static void abort_episode_realign(const char *why) { @@ -619,19 +793,90 @@ static uint64_t rb_mono_ms(void) /* Pick a load snap ≤ mismatch that the lagging peer is likely to have. * Live snaps only land on snap_interval ticks; never invent a phantom tick. - * Tip slack: avoid newest local snap (peer often still one interval behind). */ + * + * Shared-frontier fast path: ticks the peer PROVABLY holds need no tip slack. + * - hc resolved_through: both peers simmed those ticks and core digests + * matched, so both saved the same interval snaps. + * - last committed span (g_agreed_span_lo, g_agreed_through]: both peers + * replayed it and resim saves a snap at EVERY replayed tick (dense). + * Without this, mismatch one tick after a commit re-loaded a full interval + + * slack deep (798 → 768) and each menu tap replayed ~30 ticks twice — the + * main-menu "resim back to title" hang. Peer eviction is still covered by the + * follower NACK path (abort, not hang). + * + * Fallback (no proven frontier below mismatch): interval ticks with one- + * interval tip slack — ONLY when there is no shared frontier yet. Once + * hash_confirm / a commit exists, never load an unconfirmed tip (those snaps + * can fork dig_cpu while PC-cleared FRAME_COMMITs still matched). */ static int choose_load_tick(uint32_t mismatch, uint32_t *out_load) { uint32_t t; uint32_t iv; uint32_t newest; uint32_t cap; + uint32_t shared = 0; + int have_shared = 0; if (!out_load || !g_snaps || netplay_snap_ring_count(g_snaps) == 0) return 0; iv = snap_interval(); if (iv < 1u) iv = 1u; newest = netplay_snap_ring_newest_tick(g_snaps); + + if (g_b.hc) + (void)netplay_hc_heal_stale_gap(g_b.hc); + + if (g_agreed_valid) { + shared = g_agreed_through; + have_shared = 1; + } + if (g_b.hc) { + uint32_t rt = netplay_hc_resolved_through(g_b.hc); + /* Only raise the walk ceiling on confirmed ticks — resolved_through + * alone can sit on TipHold Live tip snaps the peer never agreed. */ + if (netplay_hc_confirm_through(g_b.hc, rt) && rt >= shared) { + shared = rt; + have_shared = 1; + } + } + /* Snap at T = state after tick T; replaying the mismatch needs load < mismatch. */ + if (have_shared && mismatch > 0u) { + uint32_t hi = shared; + uint32_t lo; + uint32_t oldest = netplay_snap_ring_oldest_tick(g_snaps); + if (hi > mismatch - 1u) + hi = mismatch - 1u; + /* Bound the walk to one ring depth of ticks. */ + lo = (hi > 255u) ? hi - 255u : 0u; + if (oldest > lo) + lo = oldest; + for (t = hi;; --t) { + int mutual = 0; + /* TipHold Live saves tip snaps past the POST tip; those are + * local-only until hash_confirm. Never treat bare interval + * alignment above agreed_through as peer-safe (follower NACK + * past frontier=tip-hold commit). */ + if (g_b.hc && netplay_hc_confirm_through(g_b.hc, t)) + mutual = 1; + else if (g_agreed_valid && t <= g_agreed_through && + (t > g_agreed_span_lo || (iv <= 1u) || ((t % iv) == 0u))) + mutual = 1; + else if (!g_agreed_valid && ((iv <= 1u) || ((t % iv) == 0u))) + mutual = 1; + else if (g_fmv_dense_through > 0u && t <= g_fmv_dense_through && + t >= g_fmv_media_end_sim) + mutual = 1; + if (mutual && netplay_snap_ring_has(g_snaps, t)) { + *out_load = t; + return 1; + } + if (t <= lo) + break; + } + /* Shared frontier exists but every confirmed snap aged out of the + * tip-dense ring — refuse rather than load an unconfirmed tip. */ + return 0; + } cap = mismatch; if (newest >= iv && cap > newest - iv) cap = newest - iv; @@ -650,7 +895,7 @@ static int choose_load_tick(uint32_t mismatch, uint32_t *out_load) else cap--; } - /* Last resort: any snap ≤ mismatch (including tip). */ + /* Last resort (boot / no frontier yet): any snap ≤ mismatch. */ if (netplay_snap_ring_has(g_snaps, mismatch)) { *out_load = mismatch; return 1; @@ -907,19 +1152,21 @@ static void maybe_send_baseline(void) g_local_baseline_sent = 1; g_baseline_handshake_ms = rb_mono_ms(); g_last_baseline_burst_ms = 0; /* allow immediate burst */ - send_baseline_burst(0, RB_BASELINE_BURST, 0); { + int burst = rnet_rb_recommend_light_tip(g_rb) ? 1 : RB_BASELINE_BURST; NetplaySioParts sp; + send_baseline_burst(0, burst, 0); netplay_sio_digest_parts(&sp); fprintf(stderr, "psxrecomp: rb baseline sent load=%u core=%08x av=%08x ext=%08x cd=%08x " - "(burst=%d) parts cpu=%08x clk=%08x tim=%08x ram=%08x dirty=%08x " + "(burst=%d light=%u) parts cpu=%08x clk=%08x tim=%08x ram=%08x dirty=%08x " "spu=%08x mdec=%08x aux=%08x spad=%08x dma=%08x sio=%08x " "sioP=%08x/%08x/%08x/%08x/%08x\n", (unsigned)rnet_rb_get_load_tick(g_rb), (unsigned)g_local_baseline_digest, (unsigned)g_local_baseline_av, (unsigned)g_local_baseline_aux, - (unsigned)netplay_cdrom_digest(), RB_BASELINE_BURST, + (unsigned)netplay_cdrom_digest(), burst, + (unsigned)rnet_rb_recommend_light_tip(g_rb), (unsigned)parts.cpu, (unsigned)parts.clock_irq, (unsigned)parts.timers, (unsigned)parts.ram, (unsigned)parts.dirty, (unsigned)netplay_spu_digest(), @@ -1039,6 +1286,86 @@ static void arm_replay_tick(uint32_t sim) log_resim_tick_audit(sim, "arm"); } +static void arm_rereplay_after_load(uint32_t reload_tick) +{ + RNetSession *s = sess(); + uint32_t target; + uint32_t first; + + if (!g_rb || !s) + return; + target = rnet_rb_get_target_tick(g_rb); + first = reload_tick + 1u; + if (g_b.hc) + netplay_hc_prime_after(g_b.hc, reload_tick); + g_episode_snap_applied = 1; + g_pending_load_valid = 0; + if (first > target) { + rnet_session_set_sim_tick(s, reload_tick); + g_needs_advance = 0; + enter_verify_at_tip(reload_tick); + return; + } + rnet_session_set_sim_tick(s, first); + arm_replay_tick(first); +} + +/* Enter Verify/POST at the restored tip (empty resim span, or finish_frame + * reached target). */ +static void enter_verify_at_tip(uint32_t done) +{ + RNetSession *s = sess(); + CPUState *c = cpu(); + if (!g_rb || !s) + return; + if (c) { + CPUState dig_cpu; + log_cpu_digest_split(c, "post"); + psx_netplay_rb_cpu_for_present_digest(&dig_cpu, c); + { + uint32_t core = netplay_core_digest(&dig_cpu); + uint32_t aux = netplay_aux_digest(); + uint32_t fold = 0xFFFFFFFFu; + fold = crc32_update(fold, (const uint8_t *)&core, sizeof(core)); + fold = crc32_update(fold, (const uint8_t *)&aux, sizeof(aux)); + g_post_digest = fold ^ 0xFFFFFFFFu; + fprintf(stderr, + "psxrecomp: rb post parts core=%08x aux=%08x (wire dig_m=%08x)\n", + (unsigned)core, (unsigned)aux, (unsigned)g_post_digest); + } + } else { + g_post_digest = 0u; + } + g_post_av = netplay_av_digest(); + rnet_rb_set_phase(g_rb, nRNetRbPhaseVerify); + g_verify_wait_ms = rb_mono_ms(); + (void)rnet_session_send_rb_post(s, rnet_rb_get_epoch_id(g_rb), done, g_post_digest, + g_post_av, 1u); + g_local_post_sent = 1; + g_post_rexmit_logged = 0; + fprintf(stderr, + "psxrecomp: rb post sent dig_m=%08x av=%08x cd=%08x (peer_ok=%d)\n", + (unsigned)g_post_digest, (unsigned)g_post_av, + (unsigned)netplay_cdrom_digest(), g_peer_post_ok); + fflush(stderr); + if (g_peer_post_ok) { + if (g_peer_post_digest == g_post_digest && g_peer_post_av == g_post_av) + enter_tip_hold(rnet_rb_get_target_tick(g_rb)); + else { + char why[128]; + if (g_peer_post_digest != g_post_digest) + snprintf(why, sizeof(why), + "post core/aux diverge local=%08x peer=%08x", + (unsigned)g_post_digest, (unsigned)g_peer_post_digest); + else + snprintf(why, sizeof(why), "post av diverge local=%08x peer=%08x", + (unsigned)g_post_av, (unsigned)g_peer_post_av); + rnet_rb_on_post_diverge(g_rb); + abort_episode_realign(why); + } + } +} + static void maybe_enter_replay(void) { RNetSession *s = sess(); @@ -1084,49 +1411,184 @@ static void maybe_enter_replay(void) g_episode_baseline_matched = 1; g_episode_load_tick = rnet_rb_get_load_tick(g_rb); follower = rnet_rb_is_from_peer_notify(g_rb) ? 1 : 0; - /* Follower: both baselines in hand → ready-ACK (digest_a), then go. - * Initiator: MUST wait for ready so both flush_resume together. Solo - * Replay after timeout left the follower wedged on a deferred apply. */ - if (follower) { - send_baseline_burst(1, RB_BASELINE_BURST, 0); - } else if (!g_peer_baseline_ready) { - uint64_t now = rb_mono_ms(); - uint64_t t0 = g_baseline_handshake_ms ? g_baseline_handshake_ms : now; - send_baseline_burst(0, RB_BASELINE_BURST, 1); - if (now >= t0 && (now - t0) >= (uint64_t)RB_READY_TIMEOUT_MS) { - if (!g_ready_timeout_logged) { - fprintf(stderr, - "psxrecomp: rb ready timeout — abort (no peer ready-ACK)\n"); - fflush(stderr); - g_ready_timeout_logged = 1; + /* Light tip: digests already match and load is tip-aligned — skip the + * ready-ACK RTT (both peers enter Replay on digest match). Full episodes + * keep symmetric ready (follower ACK → initiator GO). */ + if (!rnet_rb_recommend_light_tip(g_rb)) { + if (follower) { + send_baseline_burst(1, RB_BASELINE_BURST, 0); + if (!g_peer_baseline_ready) { + uint64_t now = rb_mono_ms(); + uint64_t t0 = g_baseline_handshake_ms ? g_baseline_handshake_ms : now; + if (now >= t0 && (now - t0) >= (uint64_t)RB_READY_TIMEOUT_MS) { + if (!g_ready_timeout_logged) { + fprintf(stderr, + "psxrecomp: rb ready timeout — abort (no initiator GO)\n"); + fflush(stderr); + g_ready_timeout_logged = 1; + } + abort_episode_realign("ready timeout (initiator never sent GO)"); + } + return; } - abort_episode_realign("ready timeout (peer never ACK'd baseline)"); + } else if (!g_peer_baseline_ready) { + uint64_t now = rb_mono_ms(); + uint64_t t0 = g_baseline_handshake_ms ? g_baseline_handshake_ms : now; + send_baseline_burst(0, RB_BASELINE_BURST, 1); + if (now >= t0 && (now - t0) >= (uint64_t)RB_READY_TIMEOUT_MS) { + if (!g_ready_timeout_logged) { + fprintf(stderr, + "psxrecomp: rb ready timeout — abort (no peer ready-ACK)\n"); + fflush(stderr); + g_ready_timeout_logged = 1; + } + abort_episode_realign("ready timeout (peer never ACK'd baseline)"); + } + return; + } else { + /* Initiator saw follower ACK — emit GO so follower can enter too. */ + send_baseline_burst(1, RB_BASELINE_BURST, 0); } - return; + } else { + g_peer_baseline_ready = 1; /* treat as ready — digests already matched */ + fprintf(stderr, "psxrecomp: rb light-tip skip ready-ACK load=%u target=%u\n", + (unsigned)rnet_rb_get_load_tick(g_rb), + (unsigned)rnet_rb_get_target_tick(g_rb)); + fflush(stderr); } load = rnet_rb_get_load_tick(g_rb); - /* Drop live invent FRAME_COMMITs so mid-resim diverge checks only see - * digests emitted during this Replay (was aborting on stale sim=mismatch). */ - if (g_b.hc) { - if (load == 0u) - netplay_hc_reset(g_b.hc); - else - netplay_hc_prime_after(g_b.hc, load - 1u); - } - rnet_session_set_sim_tick(s, load); + /* Snap at load_tick is post-present state (already finished that tip). + * Re-arming load + flush_resume deferred-present used to phantom + * finish_frame before the latched VBlank IRQ ran — MotK menu wait + * (0x8006CD54↔0x8006CDA0) then presented on opposite ping-pong edges + * (v0=slt 1 vs v0=countdown) with a ~5-cycle skew. Skip re-finishing + * load; first resim quantum is load+1 after the latched IRQ + one + * real VBlank period. */ + if (g_b.hc) + netplay_hc_prime_after(g_b.hc, load); rnet_rb_set_phase(g_rb, nRNetRbPhaseReplay); g_local_post_sent = 0; g_peer_post_ok = 0; g_post_rexmit_logged = 0; - /* Arm load_tick BEFORE flush_resume: after longjmp the guest runs to the - * next vblank, and finish_frame must see needs_advance. */ - arm_replay_tick(load); + { + uint32_t target = rnet_rb_get_target_tick(g_rb); + uint32_t first = load + 1u; + if (first > target) { + /* Seal span empty — restored tip is already the target. */ + rnet_session_set_sim_tick(s, load); + g_needs_advance = 0; + fprintf(stderr, + "psxrecomp: rb replay empty span load=target=%u " + "(resume_pending=%d peer_ready=%d follower=%d)\n", + (unsigned)load, g_pending_resume_valid, g_peer_baseline_ready, + follower); + fflush(stderr); + enter_verify_at_tip(load); + } else { + rnet_session_set_sim_tick(s, first); + /* Arm before flush_resume: after longjmp the guest runs to the + * next vblank, and finish_frame must see needs_advance. */ + arm_replay_tick(first); + fprintf(stderr, + "psxrecomp: rb replay %u..%u (first_arm=%u resume_pending=%d " + "peer_ready=%d follower=%d hc_primed_after=%u)\n", + (unsigned)load, (unsigned)target, (unsigned)first, + g_pending_resume_valid, g_peer_baseline_ready, follower, + (unsigned)load); + fflush(stderr); + } + } +} + +static void clear_post_handshake(void) +{ + g_local_post_sent = 0; + g_peer_post_ok = 0; + g_peer_post_digest = 0; + g_peer_post_av = 0; + g_peer_post_match = 0; + g_post_rexmit_logged = 0; + g_verify_wait_ms = 0; +} + +static void enter_tip_hold(uint32_t target) +{ + RNetSession *s = sess(); + uint32_t runway; + uint32_t sim; + uint32_t base; + if (!g_rb || !rnet_rb_enter_tip_hold(g_rb)) + return; + g_agreed_through = target; + g_agreed_span_lo = g_episode_load_tick; + g_agreed_valid = 1; + if (s) + (void)rnet_session_send_rb_resolved(s, target); + runway = rnet_rb_get_tip_runway(g_rb); + sim = s ? rnet_session_sim_tick(s) : target; + /* Quiet window from the later of sealed tip and live tip — Live may already + * sit past target when tip-extend rereplay returns to TipHold. */ + base = (sim > target) ? sim : target; + g_tip_hold_until = base + runway; + g_needs_advance = 0; + clear_post_handshake(); + fprintf(stderr, "psxrecomp: rb tip-hold through=%u until=%u\n", (unsigned)target, + (unsigned)g_tip_hold_until); + fflush(stderr); +} + +static void finalize_tip_hold(void) +{ + uint32_t target; + if (!g_rb || !rnet_rb_is_tip_holding(g_rb)) + return; + target = rnet_rb_get_target_tick(g_rb); + g_episode_count++; + fprintf(stderr, "psxrecomp: rb episode commit through=%u (tip-hold)\n", (unsigned)target); + fflush(stderr); + rnet_rb_session_reset(g_rb); + g_tip_hold_until = 0; + g_tip_extend_rereplay = 0; + g_needs_advance = 0; + g_episode_snap_applied = 0; + g_pending_resume_valid = 0; + g_episode_baseline_matched = 0; + clear_episode_wire_state(); + clear_baseline_pin(); +} + +static void poll_tip_hold_finalize(void) +{ + RNetSession *s = sess(); + uint32_t sim; + if (!g_rb || !rnet_rb_is_tip_holding(g_rb) || !s) + return; + sim = rnet_session_sim_tick(s); + if (g_tip_hold_until > 0u && sim > g_tip_hold_until) + finalize_tip_hold(); +} + +static void schedule_episode_rereplay(uint32_t mismatch_tick) +{ + uint32_t load = rnet_rb_get_load_tick(g_rb); + uint32_t reload = load; + uint32_t prefer = (mismatch_tick > 0u) ? mismatch_tick - 1u : 0u; + + if (prefer >= load && g_snaps && netplay_snap_ring_has(g_snaps, prefer)) + reload = prefer; + else if (g_pin_valid && g_pin_tick >= load) + reload = g_pin_tick; + g_pending_load_tick = reload; + g_pending_load_valid = 1; + g_tip_extend_rereplay = 1; + g_pending_resume_valid = 0; + g_episode_snap_applied = 0; + clear_post_handshake(); + rnet_rb_set_phase(g_rb, nRNetRbPhaseReplay); fprintf(stderr, - "psxrecomp: rb replay %u..%u (resume_pending=%d peer_ready=%d follower=%d " - "hc_primed_after=%u)\n", - (unsigned)load, (unsigned)rnet_rb_get_target_tick(g_rb), g_pending_resume_valid, - g_peer_baseline_ready, follower, - (unsigned)(load == 0u ? 0u : load - 1u)); + "psxrecomp: rb tip-extend rereplay load=%u mismatch=%u target=%u\n", + (unsigned)reload, (unsigned)mismatch_tick, + (unsigned)rnet_rb_get_target_tick(g_rb)); fflush(stderr); } @@ -1146,18 +1608,22 @@ static void commit_episode(void) } g_episode_count++; g_agreed_through = target; + g_agreed_span_lo = g_episode_load_tick; g_agreed_valid = 1; g_episode_baseline_matched = 0; fprintf(stderr, "psxrecomp: rb episode commit through=%u (count=%u)\n", (unsigned)target, (unsigned)g_episode_count); fflush(stderr); rnet_rb_session_reset(g_rb); + g_tip_extend_rereplay = 0; g_needs_advance = 0; g_episode_snap_applied = 0; g_pending_resume_valid = 0; clear_episode_wire_state(); g_bl_mismatch_streak = 0; - arm_rewind_cooldown(sim_after, "commit"); + /* No post-commit cooldown: promote-only after commit made char-select + * D-pad feel rejected (hist updated, live sim not rolled). Abort/storm + * cooldown still arms from live sim on failed episodes. */ } void psx_netplay_rb_bind(const PsxNetplayRbBindings *b) @@ -1194,6 +1660,7 @@ void psx_netplay_rb_start(void) memset(&cfg, 0, sizeof(cfg)); cfg.local_slot = (uint32_t)(g_b.local_slot ? *g_b.local_slot : 0); cfg.delay = (uint32_t)delay; + cfg.tip_runway = RNET_RB_TIP_RUNWAY_DEFAULT; { int slots = g_b.slot_count ? *g_b.slot_count : 2; if (slots < 2) slots = 2; @@ -1227,9 +1694,12 @@ void psx_netplay_rb_start(void) g_episode_snap_applied = 0; g_agreed_valid = 0; g_agreed_through = 0; + g_agreed_span_lo = 0; g_live_realign_pending = 0; g_episode_baseline_matched = 0; g_episode_load_tick = 0; + g_tip_hold_until = 0; + g_tip_extend_rereplay = 0; clear_baseline_pin(); clear_episode_wire_state(); fprintf(stderr, "psxrecomp: rb snap ring ready depth=%u snap_interval=%u\n", @@ -1258,7 +1728,29 @@ void psx_netplay_rb_shutdown(void) g_last_begin_mismatch = 0xffffffffu; g_was_in_fmv = 0; g_fmv_settle_until = 0; + g_fmv_lockstep_until = 0; + g_fmv_media_end_sim = 0; + g_fmv_core_match_streak = 0; + g_fmv_lockstep_released = 0; + g_fmv_dense_through = 0; g_bl_mismatch_streak = 0; + g_tip_hold_until = 0; + g_tip_extend_rereplay = 0; +} + +/* Dense tip snaps: media/lockstep, plus a short window after invent unlock + * (first Cross/Start after FMV often lands in the next ~0.5s). */ +static int rb_dense_snap_window(void) +{ + RNetSession *s = sess(); + uint32_t sim = s ? rnet_session_sim_tick(s) : 0u; + if (rb_in_fmv_lockstep_window()) + return 1; + if (g_fmv_media_end_sim && + sim < g_fmv_lockstep_until + 32u && + sim < g_fmv_media_end_sim + RB_FMV_LOCKSTEP_MAX + 32u) + return 1; + return 0; } void psx_netplay_rb_request_snap(uint32_t tick) @@ -1272,11 +1764,18 @@ void psx_netplay_rb_request_snap(uint32_t tick) if (g_rb && rnet_rb_is_active(g_rb) && tick == rnet_rb_get_load_tick(g_rb)) return; /* Resim must keep a snap at every replayed tick (post-verify + next - * correction). Live path rate-limits — full boot_state snaps dominate FPS. */ + * 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). */ if (!(g_rb && rnet_rb_is_resimulating(g_rb))) { - iv = snap_interval(); - if (iv > 1u && (tick % iv) != 0u) - return; + if (!rb_dense_snap_window()) { + iv = snap_interval(); + if (iv > 1u && (tick % iv) != 0u) + return; + } else if (tick >= g_fmv_dense_through) { + g_fmv_dense_through = tick; + } } g_pending_save_tick = tick; g_pending_save_valid = 1; @@ -1329,13 +1828,16 @@ static int try_apply_pending_load(CPUState *cpu_in) } { - uint32_t snap_pc = cpu_in->pc; + uint32_t snap_pc = rb_canonicalize_resume_pc(cpu_in->pc); uint32_t pc; g_pending_load_valid = 0; g_live_realign_pending = 0; - /* Never trust a weak snap PC (0xB0 passed the old dispatchable check). - * Re-pick from snap PC + live IRQ/BB-edge hints with rb_resume_pc_ok. */ - pc = pick_snap_resume_pc(cpu_in, snap_pc); + /* Prefer the snap's stored PC when already safe — host sticky / + * IRQ hints can re-introduce MotK wait-loop edge forks. */ + if (rb_resume_pc_ok(snap_pc)) + pc = snap_pc; + else + pc = pick_snap_resume_pc(cpu_in, snap_pc); if (!pc) { char why[96]; snprintf(why, sizeof(why), @@ -1349,10 +1851,10 @@ static int try_apply_pending_load(CPUState *cpu_in) } return 0; } - if (pc != snap_pc) { + if (pc != cpu_in->pc) { fprintf(stderr, "psxrecomp: rb snap pc rewrite tick=%u snap=0x%08x -> 0x%08x\n", - (unsigned)loaded_tick, (unsigned)snap_pc, (unsigned)pc); + (unsigned)loaded_tick, (unsigned)cpu_in->pc, (unsigned)pc); fflush(stderr); } cpu_in->pc = pc; @@ -1363,6 +1865,10 @@ static int try_apply_pending_load(CPUState *cpu_in) * forks peers. Normalize SPU CD FIFO so host audio drain asymmetry * left outside the digest cannot steer XA overflow differently. */ spu_cd_audio_reset(); + { + extern void overlay_loader_clear_lazy_miss(void); + overlay_loader_clear_lazy_miss(); + } psx_frontend_on_rb_snap_loaded(); g_pending_resume_pc = pc; g_pending_resume_valid = 1; @@ -1376,6 +1882,17 @@ static int try_apply_pending_load(CPUState *cpu_in) fflush(stderr); return 1; } + if (g_tip_extend_rereplay) { + g_episode_snap_applied = 1; + g_tip_extend_rereplay = 0; + fprintf(stderr, + "psxrecomp: rb tip-extend snap applied tick=%u pc=0x%08x " + "(episode pin kept load=%u)\n", + (unsigned)loaded_tick, (unsigned)pc, + (unsigned)g_episode_load_tick); + fflush(stderr); + return 1; + } g_episode_snap_applied = 1; g_episode_load_tick = loaded_tick; pin_baseline_from_ring(loaded_tick); @@ -1417,9 +1934,35 @@ void psx_netplay_rb_flush_resume(void) } g_pending_resume_valid = 0; g_replay_progress_ms = rb_mono_ms(); - fprintf(stderr, "psxrecomp: rb flush_resume pc=0x%08x\n", (unsigned)pc); - fflush(stderr); - /* Unwind like disk savestate load so mid-block CPS is abandoned. */ + { + CPUState *c = cpu(); + CDROMDebugState cds; + memset(&cds, 0, sizeof(cds)); + cdrom_debug_snapshot(&cds); + fprintf(stderr, + "psxrecomp: rb flush_resume pc=0x%08x depth24=%d mdec_recent=%d " + "media=%d cd_reading=%d read_delay=%d i_stat=%08x sr=%08x\n", + (unsigned)pc, gpu_display_is_depth24(), + mdec_recently_active(RB_FMV_MDEC_HYSTERESIS), rb_fmv_media_active(), + cds.reading, cds.read_delay, (unsigned)cds.i_stat, + c ? (unsigned)c->cop0[12] : 0u); + fflush(stderr); + /* flush_resume is called from admit inside sdl_vblank_present, which + * runs under gpu_vblank_flush_present with s_flushing_present=1. + * longjmp abandons that frame — drop guard AND any leftover present + * queue (mid-while decrement) so the first post-resume BB edge does + * not phantom-finish_frame. Do not re-arm for latched I_STAT.VBlank. */ + gpu_vblank_clear_deferred_present(); + /* longjmp also skips generated bb_defer cleanup — zero the nest. */ + { + extern int g_psx_cyc_bb_defer; + extern uint32_t g_psx_cyc_batch; + g_psx_cyc_bb_defer = 0; + g_psx_cyc_batch = 0; + } + } + /* Unwind like disk savestate load so mid-block CPS is abandoned. + * resume_at arms shared top-level-resume for sentinel / null-pc recover. */ psx_scheduler_resume_at(pc); /* unreachable on success */ fprintf(stderr, "psxrecomp: rb flush_resume RETURNED (resume failed)\n"); @@ -1503,14 +2046,36 @@ int psx_netplay_rb_rewind_suppressed(void) int psx_netplay_rb_fmv_defer_rewind(void) { - return rb_in_fmv_lockstep_window(); + return rb_in_fmv_defer_rewind_window(); +} + +int psx_netplay_rb_fmv_media_active(void) +{ + return rb_fmv_media_active(); } int psx_netplay_rb_lockstep_no_invent(void) { + /* Media + post-FMV lockstep: wait for remote wire (title Start / skip). */ return rb_in_fmv_lockstep_window(); } +void psx_netplay_rb_poll_replay_stall(void) +{ + uint64_t now; + if (!g_rb || !rnet_rb_is_active(g_rb)) + return; + if (rnet_rb_get_phase(g_rb) != nRNetRbPhaseReplay) + return; + if (g_replay_progress_ms == 0ull) + return; + now = rb_mono_ms(); + if (now >= g_replay_progress_ms && + (now - g_replay_progress_ms) >= (uint64_t)RB_REPLAY_STALL_MS) { + abort_episode("replay stall (no finish_frame after resume)"); + } +} + int psx_netplay_rb_take_promote_sweep(void) { int v = g_promote_sweep; @@ -1518,6 +2083,87 @@ int psx_netplay_rb_take_promote_sweep(void) return v; } +int psx_netplay_rb_tip_extend(uint32_t mismatch_tick, int slot) +{ + RNetSession *s = sess(); + RNetRbPhase phase; + RNetRbPhase phase_at_entry; + uint32_t old_target; + uint32_t new_target; + uint32_t sim; + uint32_t load; + int32_t corr_slot; + int need_rereplay; + if (!g_rb || !s || !rnet_rb_is_active(g_rb)) + return 0; + phase = rnet_rb_get_phase(g_rb); + phase_at_entry = phase; + if (phase != nRNetRbPhaseSealInputs && phase != nRNetRbPhaseAwaitingBaseline && + phase != nRNetRbPhaseReplay && phase != nRNetRbPhaseVerify && + phase != nRNetRbPhaseTipHold) + return 0; + load = rnet_rb_get_load_tick(g_rb); + if (mismatch_tick < load) + return 0; + corr_slot = rnet_rb_get_corrected_slot(g_rb); + if (corr_slot >= 0 && slot >= 0 && corr_slot != slot) + return 0; /* different seat — open a new episode after commit */ + old_target = rnet_rb_get_target_tick(g_rb); + sim = rnet_session_sim_tick(s); + new_target = old_target; + if (mismatch_tick > new_target) + new_target = mismatch_tick; + if (sim > new_target) + new_target = sim; + need_rereplay = (phase_at_entry == nRNetRbPhaseTipHold || + phase_at_entry == nRNetRbPhaseVerify || sim > mismatch_tick) + ? 1 + : 0; + /* Any TipHold/Verify leave invalidates the prior POST pair — stale peer + * digests caused follower abort (local=new POST vs peer=pre-extend POST). */ + if (phase_at_entry == nRNetRbPhaseTipHold || phase_at_entry == nRNetRbPhaseVerify || + need_rereplay) + clear_post_handshake(); + (void)rnet_rb_resign_slot_range(g_rb, slot, mismatch_tick, mismatch_tick); + export_local_seals(); + if (new_target > old_target) { + if (!rnet_rb_extend_target(g_rb, new_target)) + return 0; + (void)rnet_rb_resign_slot_range(g_rb, slot, old_target + 1u, new_target); + if (!rnet_rb_is_from_peer_notify(g_rb)) { + (void)rnet_session_send_rb_sync( + s, rnet_rb_get_epoch_id(g_rb), rnet_rb_get_mismatch_tick(g_rb), + load, new_target, (rnet_u8)(slot < 0 ? 0 : slot), 1u); + } + export_local_seals(); + fprintf(stderr, + "psxrecomp: rb tip-extend epoch=%u %u→%u mismatch=%u slot=%d " + "(light=%u)\n", + (unsigned)rnet_rb_get_epoch_id(g_rb), (unsigned)old_target, + (unsigned)new_target, (unsigned)mismatch_tick, slot, + (unsigned)rnet_rb_recommend_light_tip(g_rb)); + fflush(stderr); + } else if (need_rereplay) { + (void)rnet_rb_extend_target(g_rb, old_target); + } + if (need_rereplay) { + schedule_episode_rereplay(mismatch_tick); + { + uint32_t runway = rnet_rb_get_tip_runway(g_rb); + uint32_t cap = new_target + runway; + if (cap > g_tip_hold_until) + g_tip_hold_until = cap; + } + fprintf(stderr, + "psxrecomp: rb tip-extend rereplay epoch=%u mismatch=%u slot=%d " + "until=%u\n", + (unsigned)rnet_rb_get_epoch_id(g_rb), (unsigned)mismatch_tick, slot, + (unsigned)g_tip_hold_until); + fflush(stderr); + } + return 1; +} + int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) { RNetRbCorrection corr; @@ -1531,27 +2177,34 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) static uint32_t s_refuse_last_mismatch = 0xffffffffu; static uint32_t s_refuse_suppressed; - if (!g_rb || !s || rnet_rb_is_active(g_rb)) + if (!g_rb || !s) return 0; + /* Prefer tip-extend over refusing while an episode is already open. */ + if (rnet_rb_is_active(g_rb)) + return psx_netplay_rb_tip_extend(mismatch_tick, slot); sim = rnet_session_sim_tick(s); - /* FMV + settle: CD-frozen Replay scrambles movies; cutover must stay lockstep. */ + /* Tick settle tracker. Media/lockstep episodes resume into MotK wait/VLC + * tips and hang — admit waits for wire instead. */ + (void)rb_in_fmv_defer_rewind_window(); if (rb_in_fmv_lockstep_window()) { - static uint32_t s_fmv_log_sim; - if (s_fmv_log_sim != sim) { + static uint32_t s_fmv_refuse_sim; + if (s_fmv_refuse_sim != sim) { fprintf(stderr, - "psxrecomp: rb begin FMV-defer mismatch=%u sim=%u " - "(lockstep — promote only)\n", - (unsigned)mismatch_tick, (unsigned)sim); + "psxrecomp: rb begin REFUSED mismatch=%u — FMV lockstep " + "(no tip episode; admit waits for wire)\n", + (unsigned)mismatch_tick); fflush(stderr); - s_fmv_log_sim = sim; + s_fmv_refuse_sim = sim; } return 0; } - /* Coalesce: refuse reopen after commit/abort; reconcile promotes wire. + + /* Coalesce: refuse reopen after abort/storm only; reconcile promotes wire. * until is armed from live sim at failure (not rewound tip) so catch-up - * cannot burn a short window before the next d-pad edge. */ + * cannot burn the window before the next d-pad edge. Clean commit does + * not arm this. */ if (sim < g_rewind_cooldown_until) { static uint32_t s_cd_log_sim; if (s_cd_log_sim != sim) { @@ -1581,10 +2234,13 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) s_refuse_suppressed = 0; } fprintf(stderr, - "psxrecomp: rb begin REFUSED mismatch=%u slot=%d — no snap " - "(ring count=%u oldest=%u newest=%u)\n", + "psxrecomp: rb begin REFUSED mismatch=%u slot=%d — no " + "confirmed snap (ring count=%u oldest=%u newest=%u " + "hc=%u agreed=%u)\n", (unsigned)mismatch_tick, slot, (unsigned)snap_n, - (unsigned)snap_lo, (unsigned)snap_hi); + (unsigned)snap_lo, (unsigned)snap_hi, + (unsigned)(g_b.hc ? netplay_hc_resolved_through(g_b.hc) : 0u), + (unsigned)(g_agreed_valid ? g_agreed_through : 0u)); s_refuse_last_mismatch = mismatch_tick; } else { s_refuse_suppressed++; @@ -1599,7 +2255,7 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) s_refuse_last_mismatch = 0xffffffffu; } - target = sim > mismatch_tick ? sim : mismatch_tick; + target = rnet_rb_suggest_target(g_rb, mismatch_tick, sim); if (target < load) target = load; @@ -1611,6 +2267,10 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) corr.slot = slot; corr.initiator = 1u; corr.from_peer_notify = 0u; + /* Prefer MotK agreed tip when library watermark not yet set. */ + if (g_agreed_valid && + rnet_rb_is_light_tip_candidate(load, target, g_agreed_through)) + corr.flags = RNET_RB_CORR_LIGHT_TIP; g_last_begin_mismatch = mismatch_tick; rnet_rb_begin_episode(g_rb, &corr); @@ -1628,9 +2288,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 " - "(snaps=%u %u..%u local_slot=%d)\n", + "light=%u (snaps=%u %u..%u local_slot=%d)\n", (unsigned)corr.epoch_id, (unsigned)mismatch_tick, (unsigned)load, (unsigned)target, - slot, (unsigned)snap_n, (unsigned)snap_lo, (unsigned)snap_hi, + slot, (unsigned)rnet_rb_recommend_light_tip(g_rb), (unsigned)snap_n, + (unsigned)snap_lo, (unsigned)snap_hi, g_b.local_slot ? *g_b.local_slot : -1); fflush(stderr); @@ -1646,12 +2307,49 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin { RNetRbCorrection corr; uint32_t snap_n; + /* Tip-extend SYNC: same epoch, higher target — absorb, don't re-begin. + * Must mirror initiator tip_extend: clear POST + rereplay from prior tip + * when TipHold/Verify (follower used to arm tip-only and abort on stale + * pre-extend peer POST). */ + if (g_rb && rnet_rb_is_active(g_rb) && epoch == rnet_rb_get_epoch_id(g_rb) && + load == rnet_rb_get_load_tick(g_rb) && target > rnet_rb_get_target_tick(g_rb)) { + uint32_t old_t = rnet_rb_get_target_tick(g_rb); + RNetRbPhase phase_at_entry = rnet_rb_get_phase(g_rb); + int need_rereplay = (phase_at_entry == nRNetRbPhaseTipHold || + phase_at_entry == nRNetRbPhaseVerify) + ? 1 + : 0; + clear_post_handshake(); + if (rnet_rb_extend_target(g_rb, target)) { + (void)rnet_rb_resign_slot_range(g_rb, slot, old_t + 1u, target); + (void)rnet_rb_resign_slot_range(g_rb, slot, mismatch, mismatch); + export_local_seals(); + fprintf(stderr, + "psxrecomp: rb tip-extend FOLLOW epoch=%u %u→%u slot=%d " + "rereplay=%d\n", + (unsigned)epoch, (unsigned)old_t, (unsigned)target, slot, + need_rereplay); + fflush(stderr); + if (need_rereplay) { + /* Reload at prior tip (old_t); mismatch field is episode-original. */ + schedule_episode_rereplay(old_t + 1u); + { + uint32_t runway = rnet_rb_get_tip_runway(g_rb); + uint32_t cap = target + runway; + if (cap > g_tip_hold_until) + g_tip_hold_until = cap; + } + } + } + return; + } if (!g_rb || rnet_rb_is_active(g_rb)) return; + (void)rb_in_fmv_defer_rewind_window(); + /* Same policy as begin: do not follow into media/lockstep tip episodes. */ if (rb_in_fmv_lockstep_window()) { fprintf(stderr, - "psxrecomp: rb follow FMV-defer epoch=%u load=%u — NACK " - "(lockstep)\n", + "psxrecomp: rb follow REFUSED epoch=%u load=%u — FMV lockstep\n", (unsigned)epoch, (unsigned)load); fflush(stderr); g_follow_nack_pending = 1; @@ -1685,6 +2383,43 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin g_follow_nack_sends = 1; return; } + /* NACK loads past our shared frontier — initiator tip-slack beyond + * hash_confirm is a doomed baseline (cpu-only tip snap fork). */ + if (g_b.hc) + (void)netplay_hc_heal_stale_gap(g_b.hc); + { + uint32_t frontier = 0u; + int have_f = 0; + if (g_agreed_valid) { + frontier = g_agreed_through; + have_f = 1; + } + if (g_b.hc) { + uint32_t rt = netplay_hc_resolved_through(g_b.hc); + if (netplay_hc_confirm_through(g_b.hc, rt) && rt >= frontier) { + frontier = rt; + have_f = 1; + } + } + if (have_f && load > frontier && + !(g_b.hc && netplay_hc_confirm_through(g_b.hc, load))) { + fprintf(stderr, + "psxrecomp: rb follow REFUSED epoch=%u load=%u — past " + "frontier=%u (unconfirmed tip)\n", + (unsigned)epoch, (unsigned)load, (unsigned)frontier); + 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; + } + } memset(&corr, 0, sizeof(corr)); corr.epoch_id = epoch; corr.mismatch_tick = mismatch; @@ -1693,6 +2428,9 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin corr.slot = slot; corr.initiator = 0u; corr.from_peer_notify = 1u; + if (g_agreed_valid && + rnet_rb_is_light_tip_candidate(load, target, g_agreed_through)) + corr.flags = RNET_RB_CORR_LIGHT_TIP; if (epoch >= g_epoch) g_epoch = epoch + 1u; rnet_rb_begin_episode(g_rb, &corr); @@ -1764,13 +2502,18 @@ void psx_netplay_rb_pump(void) while (rnet_session_take_rb_post(s, &epoch, &target, &dig_m, &in_dig, &match)) { if (!rnet_rb_is_active(g_rb) || epoch != rnet_rb_get_epoch_id(g_rb)) continue; + /* TipHold cleared the POST handshake — ignore late/rexmit POSTs from + * the prior Verify so tip-extend cannot compare against a stale peer + * digest. Replay/Seal/Baseline also must not latch peer_ok early. */ + if (rnet_rb_get_phase(g_rb) != nRNetRbPhaseVerify) + continue; g_peer_post_ok = 1; g_peer_post_digest = dig_m; g_peer_post_av = in_dig; g_peer_post_match = match; - if (rnet_rb_get_phase(g_rb) == nRNetRbPhaseVerify && g_local_post_sent) { + if (g_local_post_sent) { if (dig_m == g_post_digest && in_dig == g_post_av) - commit_episode(); + enter_tip_hold(rnet_rb_get_target_tick(g_rb)); else { char why[128]; if (dig_m != g_post_digest) @@ -1823,18 +2566,15 @@ void psx_netplay_rb_pump(void) /* Keep BASELINE on the wire through Replay until the peer is clearly in. */ if (rnet_rb_is_active(g_rb) && rnet_rb_get_phase(g_rb) == nRNetRbPhaseReplay) { - uint64_t now = rb_mono_ms(); if (g_local_baseline_sent && !g_local_post_sent) { if (rnet_rb_is_from_peer_notify(g_rb)) send_baseline_burst(1, RB_BASELINE_BURST, 1); else if (!g_peer_baseline_ready) send_baseline_burst(0, RB_BASELINE_BURST, 1); } - /* Guest crash/hang after flush_resume: no finish_frame progress. */ - if (g_replay_progress_ms != 0ull && now >= g_replay_progress_ms && - (now - g_replay_progress_ms) >= (uint64_t)RB_REPLAY_STALL_MS) { - abort_episode("replay stall (no finish_frame after resume)"); - } + /* Guest crash/hang after flush_resume: no finish_frame progress. + * Also polled from mid-guest truncated pump (poll_replay_stall). */ + psx_netplay_rb_poll_replay_stall(); } /* Retransmit POST while Verify waits for peer — do not race into live. */ @@ -1857,6 +2597,8 @@ void psx_netplay_rb_pump(void) abort_episode_realign("verify timeout (peer POST missing)"); } } + + poll_tip_hold_finalize(); } int psx_netplay_rb_active(void) @@ -1869,6 +2611,11 @@ int psx_netplay_rb_is_resimulating(void) return g_rb && rnet_rb_is_resimulating(g_rb); } +int psx_netplay_rb_tip_holding(void) +{ + return g_rb && rnet_rb_is_tip_holding(g_rb); +} + int psx_netplay_rb_abort_resim_core_mismatch(uint32_t tick, uint32_t local_core, uint32_t peer_core) { @@ -1969,6 +2716,17 @@ int psx_netplay_rb_try_admit(void) return 0; /* wait for POST / commit */ } + if (rnet_rb_get_phase(g_rb) == nRNetRbPhaseTipHold) + return 0; /* Live runs; do not stall admit forever */ + + if (rnet_rb_get_phase(g_rb) == nRNetRbPhaseReplay && g_pending_load_valid && + !g_episode_snap_applied) { + CPUState *c = cpu(); + if (c && try_apply_pending_load(c)) + arm_rereplay_after_load(g_pending_load_tick); + return g_needs_advance ? 1 : 0; + } + if (rnet_rb_get_phase(g_rb) != nRNetRbPhaseReplay) return 0; @@ -2002,11 +2760,14 @@ void psx_netplay_rb_finish_frame(void) RNetSession *s = sess(); CPUState *c = cpu(); rnet_u32 done; + uint32_t target_before; if (!g_rb || !s || !g_needs_advance) return; if (rnet_rb_get_phase(g_rb) != nRNetRbPhaseReplay) return; + /* First present after flush_resume — native chain is live again. */ + psx_scheduler_top_level_resume_clear(); done = rnet_session_sim_tick(s); g_replay_progress_ms = rb_mono_ms(); fprintf(stderr, "psxrecomp: rb finish_frame sim=%u target=%u\n", (unsigned)done, @@ -2019,54 +2780,16 @@ void psx_netplay_rb_finish_frame(void) rnet_session_advance(s); g_needs_advance = 0; - if (done >= rnet_rb_get_target_tick(g_rb)) { - /* Present-edge: drop PC (not sticky — sticky is host-local). */ - if (c) { - CPUState dig_cpu; - log_cpu_digest_split(c, "post"); - psx_netplay_rb_cpu_for_present_digest(&dig_cpu, c); - { - uint32_t core = netplay_core_digest(&dig_cpu); - uint32_t aux = netplay_aux_digest(); - /* Wire dig_m folds aux so SPU/MDEC reconverge-fakes can't commit. */ - uint32_t fold = 0xFFFFFFFFu; - fold = crc32_update(fold, (const uint8_t *)&core, sizeof(core)); - fold = crc32_update(fold, (const uint8_t *)&aux, sizeof(aux)); - g_post_digest = fold ^ 0xFFFFFFFFu; - fprintf(stderr, - "psxrecomp: rb post parts core=%08x aux=%08x (wire dig_m=%08x)\n", - (unsigned)core, (unsigned)aux, (unsigned)g_post_digest); - } - } else { - g_post_digest = 0u; - } - g_post_av = netplay_av_digest(); - rnet_rb_set_phase(g_rb, nRNetRbPhaseVerify); - g_verify_wait_ms = rb_mono_ms(); - (void)rnet_session_send_rb_post(s, rnet_rb_get_epoch_id(g_rb), done, g_post_digest, - g_post_av, 1u); - g_local_post_sent = 1; - g_post_rexmit_logged = 0; - fprintf(stderr, - "psxrecomp: rb post sent dig_m=%08x av=%08x cd=%08x (peer_ok=%d)\n", - (unsigned)g_post_digest, (unsigned)g_post_av, - (unsigned)netplay_cdrom_digest(), g_peer_post_ok); - fflush(stderr); - if (g_peer_post_ok) { - if (g_peer_post_digest == g_post_digest && g_peer_post_av == g_post_av) - commit_episode(); - else { - char why[128]; - if (g_peer_post_digest != g_post_digest) - snprintf(why, sizeof(why), - "post core/aux diverge local=%08x peer=%08x", - (unsigned)g_post_digest, (unsigned)g_peer_post_digest); - else - snprintf(why, sizeof(why), "post av diverge local=%08x peer=%08x", - (unsigned)g_post_av, (unsigned)g_peer_post_av); - rnet_rb_on_post_diverge(g_rb); - abort_episode_realign(why); - } + /* Host may have tip-extended (via reconcile before this call) — if the + * tip grew past done, keep Replay instead of POST. */ + target_before = rnet_rb_get_target_tick(g_rb); + if (done >= target_before) + enter_verify_at_tip(done); + else if (rnet_rb_get_phase(g_rb) == nRNetRbPhaseReplay) { + uint32_t next = done + 1u; + if (next <= rnet_rb_get_target_tick(g_rb)) { + rnet_session_set_sim_tick(s, next); + arm_replay_tick(next); } } } diff --git a/runtime/src/psx_selfcheck.c b/runtime/src/psx_selfcheck.c new file mode 100644 index 00000000..cb06ae98 --- /dev/null +++ b/runtime/src/psx_selfcheck.c @@ -0,0 +1,989 @@ +/* + * Solo rollback resim self-check — see psx_selfcheck.h. + * + * Window life cycle (boundary = one offline vblank finish_frame): + * + * IDLE ── window due ──▶ SAVE_WAIT (poll saves raw snap at next BB edge) + * SAVE_WAIT ─ saved ───▶ RECORD (per-boundary rows + digests, span N) + * RECORD ── span done ─▶ LOAD_WAIT (poll loads snap, longjmp resume) + * LOAD_WAIT ─ loaded ──▶ PRIME (optional throwaway span; primes ambient) + * PRIME ── span done ──▶ LOAD_WAIT (rewind again for counted resims) + * LOAD_WAIT ─ loaded ──▶ REPLAY #1 (republish rows, digest per boundary) + * REPLAY#1 ─ span done ▶ LOAD_WAIT (rewind to the SAME snap again) + * … REPLAY #2, #3 … ──▶ IDLE (verdict = #2 vs #3; cold dig = #1 vs #2) + * + * The snapshot is taken a few instructions into the tick after the boundary + * that armed it (first BB-edge poll), so comparisons start at the first + * boundary after the save completed — record and replay both cover exactly + * the guest work from the snap point to each boundary. + * + * Verdict = warm #2 vs #3 (both peers in an episode are post-rewind resims). + * Cold #1 vs #2 digs live-RECORD host ambient; ambient-prime (default on) + * collapses most of that. Live-vs-resim is reported as restore-drift. + */ + +#include "psx_selfcheck.h" + +#include +#include +#include + +#include "boot_state.h" +#include "cpu_state.h" +#include "dirty_ram_interp.h" +#include "gpu.h" +#include "interrupts.h" +#include "netplay_state_digest.h" +#include "overlay_loader.h" +#include "psx_cycles.h" +#include "psx_netplay.h" +#include "psx_scheduler.h" +#include "savestate.h" +#include "sio.h" +#include "spu.h" + +extern int fntrace_is_game_started(void); +extern uint64_t psx_cycle_count; +extern uint64_t s_frame_count; + +#define SC_MAX_SLOTS 8 +#define SC_SPAN_MAX 120 +#define SC_SPAN_DEF 32u +#define SC_INTERVAL_DEF 600u +#define SC_WARMUP_BOUNDARIES 900u /* ~15s after enable before first window */ +#define SC_ARM_TIMEOUT 10u /* boundaries to find a BB-edge save/load */ + +enum { + SC_OFF = 0, + SC_IDLE, + SC_SAVE_WAIT, + SC_RECORD, + SC_LOAD_WAIT, + SC_REPLAY, +}; + +typedef struct { + uint16_t buttons; + uint8_t lx, ly, rx, ry; + uint8_t analog; + uint8_t present; +} ScRow; + +typedef struct { + ScRow rows[SC_MAX_SLOTS]; + NetplayCoreParts core; + uint32_t av, aux, cd, spad, dma; + NetplaySioParts sio; +} ScBoundary; + +static int s_phase = SC_OFF; +static CPUState *s_cpu; +static uint32_t s_bios_checksum; +static uint32_t s_entry_pc; + +static uint32_t s_interval = SC_INTERVAL_DEF; +static uint32_t s_span = SC_SPAN_DEF; +static int s_fault; +static int s_mash; +static uint32_t s_mash_rate; /* 0..100 */ +static uint32_t s_mash_rng; +static uint16_t s_mash_word = 0xFFFFu; +static uint32_t s_mash_hold_left; +static uint32_t s_mash_latched_boundary = 0xffffffffu; +static uint32_t s_mash_edges; /* diagnostic: press/release transitions */ + +static uint32_t s_boundary; /* offline vblank boundaries seen */ +static uint32_t s_next_window; +static uint32_t s_arm_deadline; +static uint32_t s_snap_boundary; /* boundary counter when snap completed */ + +static uint8_t *s_blob; +static size_t s_blob_len; +static uint32_t s_snap_csv; /* cycles_since_vblank at snap — restore for resim */ +static uint64_t s_snap_host_frame; /* s_frame_count at snap — pin for resim */ + +static ScBoundary s_rec[SC_SPAN_MAX]; /* live pass rows + digests */ +static ScBoundary s_r1[SC_SPAN_MAX]; /* replay #1 digests (rows unused) */ +static ScRow s_staged[SC_MAX_SLOTS]; /* sampler notes for this boundary */ +static uint32_t s_r1_gpr[SC_SPAN_MAX][32]; /* replay#1 GPRs for diverge dump */ +static uint64_t s_r1_cyc[SC_SPAN_MAX]; +static uint32_t s_r1_csv[SC_SPAN_MAX]; + +static int s_pass; /* 1..3 = replay #N (verdict = #2 vs #3) */ +static uint32_t s_prime; /* throwaway spans before counted resims */ +static uint32_t s_prime_left; /* remaining throwaways for this window */ +static int s_warming; /* 1 = throwaway span to prime host ambient */ +static uint32_t s_replay_idx; +static uint32_t s_mismatches; /* warm resim-vs-resim (#2 vs #3) */ +static uint32_t s_first_bad_idx; +static uint32_t s_cold_mismatches; /* #1 vs #2 — host-ambient warm-up dig */ +static uint32_t s_cold_first_idx; +static uint32_t s_drift; /* live-vs-resim forks (diagnostic) */ +static uint32_t s_drift_first_idx; +static char s_drift_parts[128]; +static uint32_t s_window_no; +static uint32_t s_windows_pass; +static uint32_t s_windows_fail; +/* Span just ended in finish_frame — load from flush_load after present RAII + * (not from a later fast-poll BB: #2 vs #3 were loading at different PCs). */ +static int s_load_after_present; +/* Raw guest PC at each boundary (digests clear PC). */ +static uint32_t s_rec_pc[SC_SPAN_MAX]; +static uint32_t s_r1_pc[SC_SPAN_MAX]; + +/* Post-load identity (after restore recipe, before longjmp). */ +static ScBoundary s_post_load_r1; +static int s_post_load_r1_valid; +static uint32_t s_post_load_r1_gpr[32]; +static uint32_t s_post_load_r1_pc; +static uint64_t s_post_load_r1_cyc; +static uint64_t s_post_load_r1_host_frame; + +static void sc_free_blob(void) +{ + free(s_blob); + s_blob = NULL; + s_blob_len = 0; +} + +static void sc_schedule_next(void) +{ + s_next_window = s_boundary + s_interval; +} + +static void sc_freeze_overlays(int on) +{ + overlay_loader_set_load_freeze(on); +} + +static void sc_cancel(const char *why) +{ + fprintf(stderr, "psxrecomp: selfcheck win#%u aborted (%s)\n", + (unsigned)s_window_no, why); + fflush(stderr); + sc_free_blob(); + sc_freeze_overlays(0); + s_load_after_present = 0; + s_warming = 0; + s_phase = SC_IDLE; + sc_schedule_next(); +} + +static void sc_capture_digests(ScBoundary *b) +{ + /* Present-edge digest: clear PC (host parks cpu->pc=0 at present while a + * resim boundary may hold a live BB PC — the RB lesson). */ + CPUState dig_cpu = *s_cpu; + dig_cpu.pc = 0; + netplay_core_digest_parts(&dig_cpu, &b->core); + b->av = netplay_av_digest(); + b->aux = netplay_aux_digest(); + b->cd = netplay_cdrom_digest(); + b->spad = netplay_spad_digest(); + b->dma = netplay_dma_digest(); + netplay_sio_digest_parts(&b->sio); +} + +static uint32_t sc_raw_pc(void) +{ + return s_cpu ? s_cpu->pc : 0u; +} + +/* Arm span-end load: prefer flush_load after present-body RAII so every + * rewind happens at the same VBlank boundary (no post-span BB walk). */ +static void sc_arm_span_end_load(void) +{ + s_phase = SC_LOAD_WAIT; + s_load_after_present = 1; + s_arm_deadline = s_boundary + SC_ARM_TIMEOUT; +} + +static void sc_apply_rows(const ScBoundary *b) +{ + int i; + for (i = 0; i < SC_MAX_SLOTS; ++i) { + const ScRow *r = &b->rows[i]; + if (!r->present) + continue; + if (sio_pad_on_multitap(i)) + sio_set_pad_config_capable(i, 0); + sio_set_pad_state_slot(i, r->buttons); + sio_set_pad_sticks(i, r->lx, r->ly, r->rx, r->ry); + sio_request_pad_type(i, r->analog ? 1 : 0); + } +} + +/* Append the name of each differing partition to buf. Returns count. */ +static int sc_diff_parts(const ScBoundary *a, const ScBoundary *b, + char *buf, size_t cap) +{ + int n = 0; + size_t off = 0; +#define SC_PART(name, expr_a, expr_b) \ + do { \ + if ((expr_a) != (expr_b)) { \ + int w = snprintf(buf + off, cap > off ? cap - off : 0, \ + "%s%s", n ? "," : "", name); \ + if (w > 0) off += (size_t)w; \ + n++; \ + } \ + } while (0) + SC_PART("cpu", a->core.cpu, b->core.cpu); + SC_PART("clk", a->core.clock_irq, b->core.clock_irq); + SC_PART("tim", a->core.timers, b->core.timers); + SC_PART("ram", a->core.ram, b->core.ram); + SC_PART("dirty",a->core.dirty, b->core.dirty); + SC_PART("av", a->av, b->av); + SC_PART("aux", a->aux, b->aux); + SC_PART("cd", a->cd, b->cd); + SC_PART("spad", a->spad, b->spad); + SC_PART("dma", a->dma, b->dma); + SC_PART("sioR", a->sio.regs, b->sio.regs); + SC_PART("sioP", a->sio.pads, b->sio.pads); + SC_PART("sioM", a->sio.mc, b->sio.mc); + SC_PART("sioT", a->sio.pace, b->sio.pace); +#undef SC_PART + return n; +} + +static uint32_t sc_env_u32(const char *name, uint32_t def, uint32_t lo, + uint32_t hi) +{ + const char *e = getenv(name); + if (!e || !*e) + return def; + { + unsigned long v = strtoul(e, NULL, 10); + if (v < lo) v = lo; + if (v > hi) v = hi; + return (uint32_t)v; + } +} + +/* PSX digital pad bits (active-low). Face + D-pad + shoulders — fighter mash + * set. START is rare (menu advance); SELECT/L3/R3 stay released. */ +#define SC_PAD_START (1u << 3) +#define SC_PAD_UP (1u << 4) +#define SC_PAD_RIGHT (1u << 5) +#define SC_PAD_DOWN (1u << 6) +#define SC_PAD_LEFT (1u << 7) +#define SC_PAD_L2 (1u << 8) +#define SC_PAD_R2 (1u << 9) +#define SC_PAD_L1 (1u << 10) +#define SC_PAD_R1 (1u << 11) +#define SC_PAD_TRIANGLE (1u << 12) +#define SC_PAD_CIRCLE (1u << 13) +#define SC_PAD_CROSS (1u << 14) +#define SC_PAD_SQUARE (1u << 15) + +static const uint16_t sc_mash_bits[] = { + SC_PAD_UP, SC_PAD_DOWN, SC_PAD_LEFT, SC_PAD_RIGHT, + SC_PAD_TRIANGLE, SC_PAD_CIRCLE, SC_PAD_CROSS, SC_PAD_SQUARE, + SC_PAD_L1, SC_PAD_R1, SC_PAD_L2, SC_PAD_R2, +}; +#define SC_MASH_NBITS ((int)(sizeof(sc_mash_bits) / sizeof(sc_mash_bits[0]))) + +static uint32_t sc_mash_rng_next(void) +{ + /* xorshift32 */ + uint32_t x = s_mash_rng; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + s_mash_rng = x ? x : 0xA5A5A5A5u; + return s_mash_rng; +} + +static uint16_t sc_mash_advance(void) +{ + uint16_t prev = s_mash_word; + if (s_mash_hold_left > 0u) { + s_mash_hold_left--; + return s_mash_word; + } + /* Decide whether to open a new chord or sit idle. */ + if ((sc_mash_rng_next() % 100u) < s_mash_rate) { + uint16_t pressed = 0u; + int n = 1 + (int)(sc_mash_rng_next() % 3u); /* 1..3 simultaneous */ + int i; + for (i = 0; i < n; ++i) + pressed |= sc_mash_bits[sc_mash_rng_next() % (uint32_t)SC_MASH_NBITS]; + /* ~8% include START (menu / confirm spam). */ + if ((sc_mash_rng_next() % 100u) < 8u) + pressed |= SC_PAD_START; + s_mash_word = (uint16_t)(0xFFFFu & ~pressed); + s_mash_hold_left = 1u + (sc_mash_rng_next() % 3u); /* hold 1..3 */ + } else { + s_mash_word = 0xFFFFu; /* full release */ + s_mash_hold_left = sc_mash_rng_next() % 3u; /* idle 0..2 */ + } + if (s_mash_word != prev) + s_mash_edges++; + return s_mash_word; +} + +int psx_selfcheck_mash_override(uint16_t *out_buttons) +{ + if (!s_mash || s_phase == SC_OFF || s_phase == SC_REPLAY || + s_phase == SC_LOAD_WAIT) + return 0; + if (!out_buttons) + return 0; + /* One stable word per offline boundary (finish_frame bumps s_boundary + * after sampling). Mid-frame low-latency resample must not see a new + * RNG roll within the same tick. */ + if (s_mash_latched_boundary != s_boundary) { + s_mash_latched_boundary = s_boundary; + (void)sc_mash_advance(); + } + *out_buttons = s_mash_word; + return 1; +} + +void psx_selfcheck_init(struct CPUState *cpu, uint32_t bios_checksum, + uint32_t entry_pc) +{ + const char *e = getenv("PSX_RB_SELFCHECK"); + if (!e || !*e || *e == '0') + return; + if (!psx_hle_scheduler_enabled()) { + fprintf(stderr, "psxrecomp: selfcheck disabled — requires the HLE " + "scheduler (rewind longjmp)\n"); + return; + } + s_cpu = cpu; + s_bios_checksum = bios_checksum; + s_entry_pc = entry_pc; + s_interval = sc_env_u32("PSX_RB_SELFCHECK_INTERVAL", SC_INTERVAL_DEF, + 120u, 1000000u); + s_span = sc_env_u32("PSX_RB_SELFCHECK_SPAN", SC_SPAN_DEF, 4u, SC_SPAN_MAX); + s_fault = sc_env_u32("PSX_RB_SELFCHECK_FAULT", 0u, 0u, 1u) != 0u; + /* Default 1: throwaway resim(s) of the snap clear live-RECORD host + * ambient so counted #1 matches #2/#3. 0 disables; 2+ for stubborn windows. */ + s_prime = sc_env_u32("PSX_RB_SELFCHECK_PRIME", 1u, 0u, 4u); + s_mash = sc_env_u32("PSX_RB_SELFCHECK_MASH", 0u, 0u, 1u) != 0u; + s_mash_rate = sc_env_u32("PSX_RB_SELFCHECK_MASH_RATE", 75u, 1u, 100u); + s_mash_rng = sc_env_u32("PSX_RB_SELFCHECK_MASH_SEED", 0xC0FFEEu, 1u, + 0xffffffffu); + s_mash_word = 0xFFFFu; + s_mash_hold_left = 0u; + s_mash_latched_boundary = 0xffffffffu; + s_mash_edges = 0u; + s_phase = SC_IDLE; + s_next_window = SC_WARMUP_BOUNDARIES; + /* Match netplay: idle-skip advances guest cycles without lockstep device + * aging — host-trained streaks fork cold resim#1 from warm #2/#3. */ + { + extern int g_idle_skip_enabled; + if (g_idle_skip_enabled != 0) { + g_idle_skip_enabled = 0; + fprintf(stderr, "psxrecomp: selfcheck — idle_skip forced off\n"); + } + } + fprintf(stderr, + "psxrecomp: selfcheck enabled interval=%u span=%u fault=%d " + "prime=%u mash=%d rate=%u seed=%u\n", + (unsigned)s_interval, (unsigned)s_span, s_fault, + (unsigned)s_prime, s_mash, (unsigned)s_mash_rate, + (unsigned)s_mash_rng); + fflush(stderr); +} + +int psx_selfcheck_enabled(void) { return s_phase != SC_OFF; } +int psx_selfcheck_defer_present(void) +{ + /* API retained for netplay-parity probes; offline GPU present stays + * immediate (BB-edge defer skews clk/tim between warm peers). */ + return s_phase == SC_SAVE_WAIT || s_phase == SC_RECORD || + s_phase == SC_LOAD_WAIT || s_phase == SC_REPLAY; +} +int psx_selfcheck_resim_active(void) +{ + /* RECORD must share the resim host-skip profile (no CD boost / audio pump / + * wall pacer). Otherwise the ambient left by the live record pass forks + * resim#1 from resim#2 while #2≡#3 (warm) stays bit-identical. */ + return s_phase == SC_RECORD || s_phase == SC_REPLAY || + s_phase == SC_LOAD_WAIT; +} +int psx_selfcheck_replay_input(void) { return s_phase == SC_REPLAY || + s_phase == SC_LOAD_WAIT; } +int psx_selfcheck_input_locked(void) +{ + return s_phase == SC_RECORD || s_phase == SC_LOAD_WAIT || + s_phase == SC_REPLAY; +} + +static void sc_dump_gpr_diff(const uint32_t *a, const uint32_t *b, + const char *tag) +{ + int i; + int n = 0; + char buf[512]; + size_t off = 0; + buf[0] = '\0'; + for (i = 0; i < 32; ++i) { + if (a[i] == b[i]) + continue; + int w = snprintf(buf + off, sizeof(buf) > off ? sizeof(buf) - off : 0, + "%sr%d=%08x/%08x", n ? " " : "", i, + (unsigned)a[i], (unsigned)b[i]); + if (w > 0) off += (size_t)w; + n++; + if (n >= 12) break; /* keep the line readable */ + } + fprintf(stderr, "psxrecomp: selfcheck %s gpr_diff n=%d %s%s\n", + tag, n, n ? buf : "(none)", (n >= 12) ? " ..." : ""); + fflush(stderr); +} + +void psx_selfcheck_note_pad(int slot, uint16_t buttons, uint8_t lx, uint8_t ly, + uint8_t rx, uint8_t ry, uint8_t analog) +{ + ScRow *r; + if (s_phase != SC_RECORD && s_phase != SC_SAVE_WAIT) + return; + if (slot < 0 || slot >= SC_MAX_SLOTS) + return; + r = &s_staged[slot]; + r->buttons = buttons; + r->lx = lx; r->ly = ly; r->rx = rx; r->ry = ry; + r->analog = analog; + r->present = 1; +} + +static void sc_report(void) +{ + char drift[160]; + if (s_drift) + snprintf(drift, sizeof(drift), + "restore-drift %u/%u (first idx=%u parts=%s)", + (unsigned)s_drift, (unsigned)s_span, + (unsigned)s_drift_first_idx, s_drift_parts); + else + snprintf(drift, sizeof(drift), "restore-drift 0/%u", + (unsigned)s_span); + if (s_mismatches == 0) { + s_windows_pass++; + fprintf(stderr, + "psxrecomp: selfcheck win#%u PASS warm-resim(#2vs#3) %u/%u; " + "cold(#1vs#2) mismatched=%u/%u; %s (pass=%u fail=%u)", + (unsigned)s_window_no, (unsigned)s_span, (unsigned)s_span, + (unsigned)s_cold_mismatches, (unsigned)s_span, drift, + (unsigned)s_windows_pass, (unsigned)s_windows_fail); + } else { + s_windows_fail++; + fprintf(stderr, + "psxrecomp: selfcheck win#%u FAIL warm-resim(#2vs#3) " + "mismatched=%u/%u first_idx=%u; cold(#1vs#2) " + "mismatched=%u/%u first_idx=%u; %s (pass=%u fail=%u)", + (unsigned)s_window_no, (unsigned)s_mismatches, + (unsigned)s_span, (unsigned)s_first_bad_idx, + (unsigned)s_cold_mismatches, (unsigned)s_span, + (unsigned)s_cold_first_idx, drift, + (unsigned)s_windows_pass, (unsigned)s_windows_fail); + } + if (s_mash) + fprintf(stderr, " mash_edges=%u", (unsigned)s_mash_edges); + fprintf(stderr, "\n"); + fflush(stderr); + sc_freeze_overlays(0); +} + +void psx_selfcheck_finish_frame(int defer_window) +{ + if (s_phase == SC_OFF) + return; + if (psx_netplay_active()) { + /* Netplay owns the resim machinery from here on. */ + fprintf(stderr, "psxrecomp: selfcheck off (netplay active)\n"); + fflush(stderr); + sc_free_blob(); + sc_freeze_overlays(0); + s_phase = SC_OFF; + return; + } + /* First boundary after resume_at — native chain under the snap PC is live + * again for subsequent ticks (same point netplay clears). */ + if (s_phase == SC_REPLAY) + psx_scheduler_top_level_resume_clear(); + + s_boundary++; + + switch (s_phase) { + case SC_IDLE: + if (s_boundary < s_next_window) + return; + if (defer_window || !fntrace_is_game_started() || + gpu_display_is_depth24() || savestate_pending()) + return; /* retry next boundary */ + s_window_no++; + memset(s_staged, 0, sizeof(s_staged)); + /* Freeze overlay discovery for the whole window (record+both + * resims) so host-only DLL registration cannot fork BB edges. */ + sc_freeze_overlays(1); + s_phase = SC_SAVE_WAIT; + s_arm_deadline = s_boundary + SC_ARM_TIMEOUT; + return; + + case SC_SAVE_WAIT: + memset(s_staged, 0, sizeof(s_staged)); /* rows latch per boundary */ + if (s_boundary > s_arm_deadline) + sc_cancel("no BB-edge save opportunity"); + return; + + case SC_RECORD: { + uint32_t idx = s_boundary - s_snap_boundary - 1u; + if (savestate_pending()) { + sc_cancel("user savestate during record"); + return; + } + if (idx < s_span) { + ScBoundary *b = &s_rec[idx]; + memcpy(b->rows, s_staged, sizeof(b->rows)); + sc_capture_digests(b); + s_rec_pc[idx] = sc_raw_pc(); + } + memset(s_staged, 0, sizeof(s_staged)); + if (idx + 1u >= s_span) { + /* Optional throwaway span(s): prime host ambient left by live + * RECORD so counted resim#1 matches #2/#3. */ + s_pass = 0; + s_prime_left = s_prime; + s_warming = s_prime_left > 0u ? 1 : 0; + sc_arm_span_end_load(); + } + return; + } + + case SC_LOAD_WAIT: + /* Live boundaries past the window while waiting for a BB edge — + * discarded by the restore. */ + if (s_boundary > s_arm_deadline) + sc_cancel("no BB-edge load opportunity"); + return; + + case SC_REPLAY: { + ScBoundary cur; + if (savestate_pending()) { + sc_cancel("user savestate during replay"); + return; + } + if (s_replay_idx >= s_span) { /* defensive */ + s_phase = SC_IDLE; + sc_schedule_next(); + return; + } + /* Order matches record: rows applied (sampler there, us here), then + * digest at the same boundary point. */ + sc_apply_rows(&s_rec[s_replay_idx]); + memset(&cur, 0, sizeof(cur)); + sc_capture_digests(&cur); + if (s_warming) { + /* Throwaway — no compare. Advance; more primes or counted #1. */ + s_replay_idx++; + if (s_replay_idx >= s_span) { + if (s_prime_left > 0u) + s_prime_left--; + if (s_prime_left > 0u) { + fprintf(stderr, + "psxrecomp: selfcheck win#%u ambient-prime " + "pass done (%u left)\n", + (unsigned)s_window_no, (unsigned)s_prime_left); + fflush(stderr); + s_warming = 1; + } else { + fprintf(stderr, + "psxrecomp: selfcheck win#%u ambient-prime done " + "— starting counted resims\n", + (unsigned)s_window_no); + fflush(stderr); + s_warming = 0; + s_pass = 0; + } + sc_arm_span_end_load(); + } + return; + } + if (s_pass == 1) { + /* Replay #1: seed cold reference; live-vs-resim = drift dig. */ + char parts[128]; + memcpy(cur.rows, s_rec[s_replay_idx].rows, sizeof(cur.rows)); + s_r1[s_replay_idx] = cur; + memcpy(s_r1_gpr[s_replay_idx], s_cpu->gpr, sizeof(s_r1_gpr[0])); + s_r1_cyc[s_replay_idx] = psx_cycle_count; + s_r1_csv[s_replay_idx] = interrupts_get_cycles_since_vblank(); + s_r1_pc[s_replay_idx] = sc_raw_pc(); + if (sc_diff_parts(&s_rec[s_replay_idx], &cur, parts, + sizeof(parts))) { + if (s_drift == 0) { + s_drift_first_idx = s_replay_idx; + snprintf(s_drift_parts, sizeof(s_drift_parts), "%s", + parts); + } + s_drift++; + } + } else if (s_pass == 2) { + /* Replay #2: cold dig (#1 vs #2), then become the warm reference. */ + const ScBoundary *ref = &s_r1[s_replay_idx]; + char parts[128]; + int n = sc_diff_parts(ref, &cur, parts, sizeof(parts)); + if (n) { + if (s_cold_mismatches == 0) + s_cold_first_idx = s_replay_idx; + s_cold_mismatches++; + if (s_cold_mismatches == 1) { + char tag[48]; + fprintf(stderr, + "psxrecomp: selfcheck win#%u COLD idx=%u " + "parts=%s raw_pc=%08x/%08x\n", + (unsigned)s_window_no, (unsigned)s_replay_idx, + parts, (unsigned)s_r1_pc[s_replay_idx], + (unsigned)sc_raw_pc()); + snprintf(tag, sizeof(tag), "win#%u cold idx=%u", + (unsigned)s_window_no, (unsigned)s_replay_idx); + sc_dump_gpr_diff(s_r1_gpr[s_replay_idx], s_cpu->gpr, tag); + interrupts_log_last_vblank_irqctx(tag); + fflush(stderr); + } + } + s_r1[s_replay_idx] = cur; + memcpy(s_r1_gpr[s_replay_idx], s_cpu->gpr, sizeof(s_r1_gpr[0])); + s_r1_cyc[s_replay_idx] = psx_cycle_count; + s_r1_csv[s_replay_idx] = interrupts_get_cycles_since_vblank(); + s_r1_pc[s_replay_idx] = sc_raw_pc(); + } else { + /* Replay #3: warm resim-vs-resim — the rollback invariant after + * host ambient has seen one resim of this snap. */ + const ScBoundary *ref = &s_r1[s_replay_idx]; + char parts[128]; + int n = sc_diff_parts(ref, &cur, parts, sizeof(parts)); + if (n) { + int first = (s_mismatches == 0); + if (first) + s_first_bad_idx = s_replay_idx; + s_mismatches++; + fprintf(stderr, + "psxrecomp: selfcheck win#%u DIVERGE idx=%u parts=%s " + "core=%08x/%08x ram=%08x/%08x cpu=%08x/%08x " + "clk=%08x/%08x av=%08x/%08x cd=%08x/%08x " + "sioT=%08x/%08x cyc=%llu/%llu d_cyc=%lld " + "csv=%u/%u host_f=%llu raw_pc=%08x/%08x\n", + (unsigned)s_window_no, (unsigned)s_replay_idx, parts, + (unsigned)ref->core.core, (unsigned)cur.core.core, + (unsigned)ref->core.ram, (unsigned)cur.core.ram, + (unsigned)ref->core.cpu, (unsigned)cur.core.cpu, + (unsigned)ref->core.clock_irq, + (unsigned)cur.core.clock_irq, + (unsigned)ref->av, (unsigned)cur.av, + (unsigned)ref->cd, (unsigned)cur.cd, + (unsigned)ref->sio.pace, (unsigned)cur.sio.pace, + (unsigned long long)s_r1_cyc[s_replay_idx], + (unsigned long long)psx_cycle_count, + (long long)psx_cycle_count - + (long long)s_r1_cyc[s_replay_idx], + (unsigned)s_r1_csv[s_replay_idx], + (unsigned)interrupts_get_cycles_since_vblank(), + (unsigned long long)s_frame_count, + (unsigned)s_r1_pc[s_replay_idx], + (unsigned)sc_raw_pc()); + fflush(stderr); + if (first) { + char tag[48]; + snprintf(tag, sizeof(tag), "win#%u idx=%u", + (unsigned)s_window_no, + (unsigned)s_replay_idx); + sc_dump_gpr_diff(s_r1_gpr[s_replay_idx], s_cpu->gpr, tag); + interrupts_log_last_vblank_irqctx(tag); + } + } + } + s_replay_idx++; + if (s_replay_idx >= s_span) { + if (s_pass == 1) { + if (s_fault) { + uint32_t fi = s_span / 2u; + s_r1[fi].core.ram ^= 1u; + fprintf(stderr, + "psxrecomp: selfcheck win#%u fault injected " + "idx=%u\n", + (unsigned)s_window_no, (unsigned)fi); + fflush(stderr); + } + sc_arm_span_end_load(); + } else if (s_pass == 2) { + /* Warm reference captured — one more rewind for #3. */ + sc_arm_span_end_load(); + } else { + sc_report(); + sc_free_blob(); + s_load_after_present = 0; + s_phase = SC_IDLE; + sc_schedule_next(); + } + } + return; + } + + default: + return; + } +} + +/* SC_LOAD_WAIT rewind. Mirrors the RB restore recipe — cycle + IRQ resync, + * SPU CD FIFO normalize, NO cdrom_accelerate, light frontend hook. + * Longjmps via psx_scheduler_resume_at — never returns on success. */ +static void sc_do_load(struct CPUState *cpu, const char *via) +{ + if (!cpu) { + sc_cancel("load with null cpu"); + return; + } + if (!s_blob || !s_blob_len) { + sc_cancel("no snap blob"); + return; + } + s_load_after_present = 0; + /* Host-only ambient from the PREVIOUS pass — sample before load. */ + { + extern uint32_t g_dirty_safe_resume_pc; + extern uint32_t g_dirty_ram_code_gen; + extern int g_idle_skip_enabled; + extern int g_ls_suppress_record; + extern int g_ls_mode; + extern uint64_t g_guest_store_count; + extern uint64_t g_irqctx_seq; + extern uint32_t dirty_ram_text_diverged_pages(void); + uint64_t kb[6]; + const int next_pass = s_warming ? 0 : (s_pass == 0 ? 1 : s_pass + 1); + psx_kernel_bless_stats(kb); + fprintf(stderr, + "psxrecomp: selfcheck win#%u load ambient pre " + "pass=%d via=%s dirty_resume=%08x irq_resume=%08x " + "irq_chk_pc=%08x irq_chk_cyc=%llu tot_chk=%llu " + "fast_m=%u code_gen=%u stores=%llu irqctx=%llu " + "ls_sup=%d ls_mode=%d idle_skip=%d ovl=%d " + "txt_div=%u kb_clean=%llu kb_mism=%llu\n", + (unsigned)s_window_no, next_pass, via ? via : "?", + (unsigned)g_dirty_safe_resume_pc, + (unsigned)psx_compiled_irq_resume_pc(), + (unsigned)psx_last_irq_check_pc(), + (unsigned long long)psx_last_irq_check_cycle(), + (unsigned long long)psx_interrupt_total_checks(), + (unsigned)psx_interrupt_fast_maintenance(), + (unsigned)g_dirty_ram_code_gen, + (unsigned long long)g_guest_store_count, + (unsigned long long)g_irqctx_seq, + g_ls_suppress_record, g_ls_mode, g_idle_skip_enabled, + overlay_loader_registered_count(), + (unsigned)dirty_ram_text_diverged_pages(), + (unsigned long long)kb[1], (unsigned long long)kb[2]); + fflush(stderr); + } + if (!boot_state_load_buffer(s_blob, s_blob_len, s_bios_checksum, + s_entry_pc, cpu)) { + sc_cancel("snap load failed"); + return; + } + psx_cycles_resync_after_restore(cpu); + interrupts_resync_after_restore(); + /* Keep the snap's VBlank phase (resync zeroed it for UI savestate safety). */ + interrupts_set_cycles_since_vblank(s_snap_csv); + /* Pin host frame counter so present/GPU hysteresis that keys on + * s_frame_count cannot fork resim#1 vs resim#2 (+span otherwise). */ + s_frame_count = s_snap_host_frame; + /* Host-only overlay negative-lookup memo — not in the snap; a fill during + * resim#1 made #2≡#3 while #1 forked (cold ambient). */ + overlay_loader_clear_lazy_miss(); + spu_cd_audio_reset(); + psx_frontend_on_rb_snap_loaded(); + /* Mirror RB flush_resume: longjmp abandons any flush_present frame. */ + gpu_vblank_release_present_flush_guard(); + gpu_vblank_clear_deferred_present(); + s_replay_idx = 0; + s_cpu = cpu; + if (s_warming) { + /* Throwaway prime span — do not advance s_pass / post_load refs. */ + s_phase = SC_REPLAY; + fprintf(stderr, + "psxrecomp: selfcheck win#%u ambient-prime begin pc=0x%08x " + "span=%u via=%s\n", + (unsigned)s_window_no, (unsigned)cpu->pc, (unsigned)s_span, + via ? via : "?"); + fflush(stderr); + psx_scheduler_resume_at(cpu->pc); + return; /* unreachable */ + } + if (s_pass == 0) { /* entering counted replay #1 */ + s_mismatches = 0; + s_first_bad_idx = 0; + s_cold_mismatches = 0; + s_cold_first_idx = 0; + s_drift = 0; + s_drift_first_idx = 0; + s_drift_parts[0] = '\0'; + s_post_load_r1_valid = 0; + } + s_pass = (s_pass == 0) ? 1 : (s_pass + 1); + s_phase = SC_REPLAY; + { + ScBoundary post; + memset(&post, 0, sizeof(post)); + sc_capture_digests(&post); + if (s_pass == 1) { + s_post_load_r1 = post; + s_post_load_r1_valid = 1; + memcpy(s_post_load_r1_gpr, cpu->gpr, sizeof(s_post_load_r1_gpr)); + s_post_load_r1_pc = cpu->pc; + s_post_load_r1_cyc = psx_cycle_count; + s_post_load_r1_host_frame = s_frame_count; + fprintf(stderr, + "psxrecomp: selfcheck win#%u replay#1 begin pc=0x%08x " + "span=%u post_load core=%08x ram=%08x cpu=%08x " + "clk=%08x cyc=%llu host_f=%llu ovl=%d freeze=%d\n", + (unsigned)s_window_no, (unsigned)cpu->pc, + (unsigned)s_span, (unsigned)post.core.core, + (unsigned)post.core.ram, (unsigned)post.core.cpu, + (unsigned)post.core.clock_irq, + (unsigned long long)psx_cycle_count, + (unsigned long long)s_frame_count, + overlay_loader_registered_count(), + overlay_loader_load_frozen()); + } else if (s_post_load_r1_valid) { + char parts[128]; + int n = sc_diff_parts(&s_post_load_r1, &post, parts, + sizeof(parts)); + fprintf(stderr, + "psxrecomp: selfcheck win#%u replay#%d begin pc=0x%08x " + "span=%u post_load %s parts=%s core=%08x/%08x " + "ram=%08x/%08x cpu=%08x/%08x clk=%08x/%08x " + "cyc=%llu/%llu host_f=%llu/%llu ovl=%d freeze=%d\n", + (unsigned)s_window_no, s_pass, (unsigned)cpu->pc, + (unsigned)s_span, n ? "MISMATCH" : "MATCH", + n ? parts : "-", + (unsigned)s_post_load_r1.core.core, + (unsigned)post.core.core, + (unsigned)s_post_load_r1.core.ram, + (unsigned)post.core.ram, + (unsigned)s_post_load_r1.core.cpu, + (unsigned)post.core.cpu, + (unsigned)s_post_load_r1.core.clock_irq, + (unsigned)post.core.clock_irq, + (unsigned long long)s_post_load_r1_cyc, + (unsigned long long)psx_cycle_count, + (unsigned long long)s_post_load_r1_host_frame, + (unsigned long long)s_frame_count, + overlay_loader_registered_count(), + overlay_loader_load_frozen()); + if (n) { + char tag[48]; + snprintf(tag, sizeof(tag), "win#%u post_load", + (unsigned)s_window_no); + sc_dump_gpr_diff(s_post_load_r1_gpr, cpu->gpr, tag); + } else if (memcmp(s_post_load_r1_gpr, cpu->gpr, + sizeof(s_post_load_r1_gpr)) != 0) { + /* Digests clear PC; catch GPR-only forks digests miss. */ + char tag[48]; + snprintf(tag, sizeof(tag), "win#%u post_load_gpr", + (unsigned)s_window_no); + sc_dump_gpr_diff(s_post_load_r1_gpr, cpu->gpr, tag); + } + if (cpu->pc != s_post_load_r1_pc) { + fprintf(stderr, + "psxrecomp: selfcheck win#%u post_load PC " + "0x%08x vs 0x%08x\n", + (unsigned)s_window_no, (unsigned)s_post_load_r1_pc, + (unsigned)cpu->pc); + } + } + fflush(stderr); + } + psx_scheduler_resume_at(cpu->pc); +} + +void psx_selfcheck_flush_load(void) +{ + if (s_phase != SC_LOAD_WAIT || !s_load_after_present) + return; + if (psx_netplay_active()) + return; + /* Prefer span-end present: same VBlank boundary for every rewind. */ + sc_do_load(s_cpu, "present"); +} + +/* Same gate as rb_resume_pc_ok — psx_is_dispatchable still accepts 0xB0. */ +static int sc_resume_pc_ok(uint32_t pc) +{ + uint32_t phys; + if (!psx_is_dispatchable(pc)) + return 0; + if ((pc & 3u) != 0u) + return 0; + if (pc == 0x80000080u || pc == 0xbfc00180u || pc == 0x80000000u) + return 0; + if ((pc & 0xfff00000u) == 0xbfc00000u) + return 1; + phys = pc & 0x1fffffffu; + if (phys >= 0x1000u && phys < 0x200000u) + return 1; + return 0; +} + +static uint32_t sc_pick_snap_pc(struct CPUState *cpu, uint32_t hint) +{ + const uint32_t cands[5] = { + hint, + psx_last_irq_check_pc(), + psx_compiled_irq_resume_pc(), + cpu ? cpu->gpr[31] : 0u, + cpu ? cpu->pc : 0u, + }; + int i; + for (i = 0; i < 5; ++i) { + if (sc_resume_pc_ok(cands[i])) + return cands[i]; + } + return 0; +} + +void psx_selfcheck_poll(struct CPUState *cpu, uint32_t resume_pc) +{ + if (s_phase != SC_SAVE_WAIT && s_phase != SC_LOAD_WAIT) + return; + if (!cpu || psx_netplay_active()) + return; + + if (s_phase == SC_SAVE_WAIT) { + CPUState snap; + uint32_t snap_pc = sc_pick_snap_pc(cpu, resume_pc); + if (!snap_pc) + return; /* retry — reject 0xB0 / vector junk (win#138 class) */ + snap = *cpu; + snap.pc = snap_pc; + sc_free_blob(); + if (!boot_state_save_buffer_raw(&snap, s_bios_checksum, s_entry_pc, + &s_blob, &s_blob_len)) { + sc_cancel("snap save failed"); + return; + } + s_snap_boundary = s_boundary; + s_snap_csv = interrupts_get_cycles_since_vblank(); + s_snap_host_frame = s_frame_count; + s_phase = SC_RECORD; + fprintf(stderr, + "psxrecomp: selfcheck win#%u snap boundary=%u pc=0x%08x " + "bytes=%zu span=%u csv=%u host_f=%llu%s\n", + (unsigned)s_window_no, (unsigned)s_boundary, + (unsigned)snap_pc, s_blob_len, (unsigned)s_span, + (unsigned)s_snap_csv, (unsigned long long)s_snap_host_frame, + (snap_pc != resume_pc) ? " (picked)" : ""); + fflush(stderr); + return; + } + + /* LOAD_WAIT: span-end flush owns the load. BB-poll is fallback only + * (e.g. if present was skipped). Never load from fast-poll while a + * present flush is armed — that was the #2/#3 load-site skew. */ + if (s_load_after_present) + return; + sc_do_load(cpu, "poll"); +} diff --git a/runtime/src/traps.c b/runtime/src/traps.c index 77402276..ae753fd1 100644 --- a/runtime/src/traps.c +++ b/runtime/src/traps.c @@ -695,6 +695,19 @@ static int psx_request_thread_switch(CPUState* cpu, uint32_t target_tcb) * on the old stack (safe: the restored state is complete in CPUState/RAM/devices * and every block leader is re-enterable). MUST be called on the scheduler fiber * (HLE mode, default) at a block-leader boundary with in_exception == 0. */ +/* Set by resume_at; cleared on first post-resume finish_frame (or abort). */ +static int g_sched_top_level_resume; + +int psx_scheduler_top_level_resume_active(void) +{ + return g_sched_top_level_resume; +} + +void psx_scheduler_top_level_resume_clear(void) +{ + g_sched_top_level_resume = 0; +} + void psx_scheduler_resume_at(uint32_t resume_pc) { if (!psx_is_dispatchable(resume_pc)) { @@ -707,6 +720,9 @@ void psx_scheduler_resume_at(uint32_t resume_pc) g_sched_escape.target_tcb = 0; g_sched_escape.resume_pc = resume_pc; g_sched_escape.reason = PSX_RUN_RESUME_CURRENT; + /* longjmp abandons every mid-block CPS frame — sentinel RFE must not + * treat the new top-level dispatch as a continuable live chain. */ + g_sched_top_level_resume = 1; longjmp(g_scheduler_jmpbuf, 1); /* unwind to psx_scheduler_run; never returns */ } @@ -796,6 +812,13 @@ void psx_scheduler_run(CPUState* cpu) * nested-unit gate so IRQ checks are not wedged-off after the escape * (backstop for the Ape memcard native<->interp fix). */ { extern int g_call_unit_depth; g_call_unit_depth = 0; } + /* flush_resume / savestate longjmp skips generated bb_defer cleanup. */ + { + extern int g_psx_cyc_bb_defer; + extern uint32_t g_psx_cyc_batch; + g_psx_cyc_bb_defer = 0; + g_psx_cyc_batch = 0; + } } switch (g_sched_escape.reason) { @@ -843,7 +866,8 @@ void psx_scheduler_run(CPUState* cpu) * dispatch saw cpu->pc==0. If a thread yielded to us and then ran to * completion, hand control back to its yielder (one-level safety net) * instead of tearing down the whole run. Otherwise it's the legacy - * top-level abnormal exit. */ + * top-level abnormal exit — unless an RB top-level resume can recover + * at $ra / sticky BB (returning-leaf snap after flush_resume). */ if (psx_is_valid_tcb(cpu, g_sched_return_tcb) && g_sched_return_tcb != psx_current_tcb_ptr(cpu)) { uint32_t yielder = g_sched_return_tcb; @@ -854,6 +878,16 @@ void psx_scheduler_run(CPUState* cpu) psx_current_tcb_ptr(cpu), yielder, 0); continue; } + { + extern int psx_netplay_rb_recover_null_pc(CPUState *c, uint32_t *out_pc); + uint32_t recover_pc = 0; + if (psx_netplay_rb_recover_null_pc(cpu, &recover_pc) && recover_pc) { + g_sched_escape.target_tcb = 0; + g_sched_escape.resume_pc = recover_pc; + g_sched_escape.reason = PSX_RUN_RESUME_CURRENT; + continue; + } + } g_sched_escape.reason = PSX_RUN_GUEST_EXIT; g_in_scheduler_run = 0; return; diff --git a/runtime/tests/test_netplay_hash_confirm b/runtime/tests/test_netplay_hash_confirm index 638d91191b073fceedad0575e2512092d6eb92e3..07e60f433a13ba57291b93bb1b2e1583416b6824 100755 GIT binary patch literal 20864 zcmeHPdvsjId7qWEST;72v5g;qeX%jI!$C;0Yz($Qeyn^Aeqmdta6&ID?Mm8uwX5vj zH8zQl?FdtEmL;Zn^faMuXb&fj3pv2y6+t-yQdY$Lj-l${=S*{ z?%utY+MJ%A{?YE7BYiX9d%pQ*c4qeO-4$5VT;}r$F6H7{L9Om;g=x}+zSX({&?FX% zD*T@%W{Pvru8=rIZ&CnMRm}2YSgG-ZCbPAV0Gp<9k(bEHwp9a&opZ!1&nP7PU(nj|-%~=xS6yHT+)tc4IS6KBm4Nk4TW}#DG&>*bUo7Y;Qa3b6hN!sDW=Cv!L@mP3s zur(TnjgGE(Ok1@S>xhg(PkH$nHGUo`Mqxa+`_?NwjW^{2mwp#sr??~?apCpS zEb+%&c(y@=0T&(}DirMx>Cj7@q*c1`Jm-l}?ZWFNTJleG;i(U$Qi?z+0;LF)B2bDz zDFUSk{C|tU$5k^wH+u#v&5Zv!?A6V^eRf&?h}rYA$`@oP@(W)AIzRVi{I8nTgc`Lw ziFR@zk23dpYSUKmS22dTs@EKaCK~cpla4`;(I|u z@ZT)?7j7gl{|hrcWIp%7b>?%!<)-hD`RW(;WGFa(tx`~#A84y9_JUKG>38f2W~T8|H1bmp;D4?SojK$mMAdie3k84SN0VqTv`OE|4)C@>hq-6a zz@|22W-8{+lWGIQb$w=b@6f0DyqP|nn}|KVZaxb->>ke5Rts@wpM9m7opRGmBKt!G z(nmpjikNcyzR2e@{&u+1)8|Y3Z$;(QOt8;W?ALkNZw9-|=3iF{fl!kESxTV7BQQ_u zKj&Jl|M$3>kot!%mhQYoz~#`L}hXWb^mPYOGT2!RJ8x{MN9`@Cv`8)MI$6v?#-vD=K zUy7nNU8y^YD=K&QKR-j(k1f^K{|Oao{XOE#`XBMI{~YYD(U=JRdNlf#z&4M-_oe>r zOSS$uDpLPnDE1pY>{|=^op>Kdn&}XJRY^_sNG+5`CN0%Q#-SpOELZG<7Z&&VoPv?! zk^Plx0C}`6XfyaPr#7f-W;RPzGy7elnc2sQ+12qi(j_^hcPJZu(~J9k;xp>^^H*z^ zeu|28=?2CA0}uP-#4cUx87@oB$Vi_WX>CYN$m}Iw8fhTp7+;`Ntn;WyNQ)cT;wrZI zF7ajk^F8boHH+v5rbS+|K;|heQpLQ)`C$X~gP*EId{j$NVhpI=JJuroSN{^;Z(r_Icp2voV zh%f6O=V8CpWw@x@ui+NO6}KBvdQI=%GIS=H}`*Lqbqlo!I|#ii2e zGIsj2McU~Pi7)Ga{et2?eo~kp&$834xQ80ug|X5Fnb40ZojW}`?}yHee-|vvRN2NZ z>{aZ4Rzp_o(fqnPle;vBjQ%|;TEPSq+=3J!m2y-yjYH!npK;KM>V$)gPgk7|`ikK5g1O$8U&fH^!MISz z2C*sQ02C31Tw`_oI3ym`61^^oBEqu;i4i7a9s$*h<^{^fkeW?F_r=H-*+X~_2HegeXaFVImbz4J^aaHeNq z*R8UNBWkso-Z^ac>>L*MG&2(z?yba%=`0xEf#fOQekIJ@>C|Z@P=5s4DU^bUdok+I~Kvz!KH)H}Q(1)9Frb`FTKq{AMIg?p?B0YY;ZkX9` z?ndV_Ed%Mv`_RQ4dUEK_GWw3sM=TVAcfjB_Gf$f_(=Er8flqPjk_M!F)e}kqCPo$8 ztk{2b;DkL!M)H&a?~VyN+)9Vcp7(ZVCX+|U($#VVoY0(YDsz^p-T1hKsS8-!p63mW zVy4LA9A$B8f#-+BGqTZI^zWGG;CnbQh$x7|fgu9qYSj~R0I;BqJsdbq*i*9zpV>_# z)H9s7Bd#V6yMfbYwq-E0_B5f>uTW(F4xPbA!U_}rD_e%nI zwb@BA-S>s)`w?M}h2X+?4*&af>PtRCyZ1xX&V$*+CJaXuTI23pb zlWKsfhXW@FD07qq{e{HQk@^9Bf`D*VM7~i>pok2i;{%zsSas*06{Gd~wM%vk z^Ia}V{h76c=AP%&O12LRKpT@Q_R~t@1ObyFdA+6;7E&!Pr)>qqdF8!z_s+M3U6~1- zzB-UgP6A8^au@^OsWEyD=?M(_dUg)(s=8}A78c1a$3lgCNg#)*2mi&X^Oez@`b3h` z(VGOB_b3U3b8qY1)RKZyDMg?Zfl>rY5hz8V6oFC%N)aeU;6IOmPd;%fM7XQl-eJVz zw$T=kwMPpJY3rq zOl(zB^&XeI!{Mz4i-@9@u`L))5-M?)v%%1|V5}`{#A8Mj`VHA7_&GAZPN476uw%yP zM(@Hg`Ot7z*l3G}gNY=?2T%UuIbxvf&TveH0;+={gE}la!@;P5XN^w@w!+o(Q+6lHsHgjcf}W?eWA`BN8+4WORp^8#3s5>Qo{;FFAjlShi-3 zv0>S!O}M^3kWQSc@51q8D)&PN;qm3O=!TD%5y#3gN_x8%& z;<_12zP#Yl*@Pz9ui$S!`gS?K&=$+itG=sj#e{RpFPi`plKyp|8&2i(Re%otdZH@% z`vB*FUgM@m3iM}yuLFIxn_jk3GF$}Q8~D2g^o}BWt3y8n{QaPBEuyzO^otOOLC{l0 z^lpc~6!c{nlcPoSR~`CSK>r=+u_C(a0QvWI&;uAl+zc1`AC=-VKKFsX1@wTMUe@I3 ze+Klgf&R53`ZkCD2IxmYzX;HYZ~0UoF_8T;!2bd{PqwM6RLVSU;w3+cPQM?aLj7TWi75Rm zMW7UcQUpp7C`F(Yf&Y^c;NSJ}@A&xhzghQoJolP53n;X8rSjBV1@W+$Ec|;xJeDX6 z?OCbdSvy(ycZ0OgqQdk~zR1U^-H*o)sL+0f$}!rYpwOO#N>0;hUq$5!1>_Eis?&6v z_F3+t#YAqs!s>0lxJ@(hexLVEcnnFF1KKd-`S;rFcc13N*UYl8eS8lk`vat8#aFVj zeL!~%+e6vrIO7N++xKWXo+^}W_FJp}UkCjAaj)S;IuDvP+@fK-hRn~u|6H|V#Wlt} z>@epWb&G21YU+&o+WJMci|Q9L=g5%aQz1T-PnfznDBeCXN$~H-4Nb(8A|vw80=`U) z7eDm)cZtRy@!;t!NBle=(3ItR{P!W7Yd5GikKY!FpCUNF6;+6xsE{6>UyM&n{>jeo zYZ-q4m67`UGs!ydXTz#rE5 ztF&EtHv#+`z*8K&`g5B9sOAqSGol;|wxR28a53<+-3VP@4E*`@Yk^NZRx8;b^U7var=pzRf6bVI2NMLSyuDLWorXgVBJa! zZ#`If^SWhgSFZpXA0Jl0)KqijMq#a5vwr!qHP-s(=1qak*5+l)*90u#xYK6QbtJt% zAsV*m{?bK}cePe1(t!>@wdeH?P5j=5|Kbe|^tK3xm)`8)vVcqRuqDk1D;x^iK_Mdz zV{pjzatTMiLBBr2L6AcAVu>OY#u?hwYayVm_#F{eC>h5<#6tA`h}G-C6^g_xT*rpo zzR`8B#n-@aFIGh5du7Y+Ovqb#SFhyFv=wZ}bvkUfCX?C^`BDrQAKh)+Zj&+`G}sWs zTQ2nIx+HYsI7a20F-CmdMe(~giep0=Dnw0kN0%LJ1+)_ic5;pUsOgBMYFbkfyb>i6 z5;b^dM>x?fB$jem)P#1#pg}=9p(wZtPe$UgqJ{-pA{-5pfUb2%ZPExYYV7cK{HN&! zT3n_;O}JA}sLl{%xdlh%G!r$Bd)_Y z8u$r8oaOo4z>v=uY?t@ztfL8)!mVoIT*2pyA_6}O8BW%6>_77{ycKjhQ*fKl6%3oSyw`t?7Ex44H_P+6hGCy(Wc%L! zZ`1P2H6Ndo7~bZRr~5EZ)iD&6km7IWXH^1 zAiCvwAHtA+4&p8>&p7(7$}P|5V1|4?W`3{#^xPrIbNqQf#c;htP#QRXtnd3CdEO^8 zB>&vSYySXw0`JwAiqzn$t{e! zSDwx{Zh1ZjGBiB$-u}N1I(D(nJAwG#g5SU8wa0CS??RT&RI~V!L+-G?{|2Xe&qrpMZ*8xudIS`1B#%P~~vwt_sLD~u+^&wjERmg9Ccj1!LS^ZvTFNpX1jz2i4U`(MinHDr13`aylA z;)b3Sm-G!y5{POY_>0jTY5k)62lib_sQk1CIA(eTJWcT* D;9o%p delta 4419 zcmZ8keNa@_6~FJn!m@%a;0i1V3*k$VT@0dVL>AGwSRt#ZV{EjhwKk@jwgx}a=&TC) zKthAuI%XWF)oK6eI7udUGlM~E(WFUhod(;QU}7>GlXgH!V^XpB5x2i{?|r+EeKYT! zd(Ziu-#Pc(kN4i5-)IB-wC*y!d(MGrp9V%#nYawe?6R*x#8cBn4_q$sj7p2@8`9*L z0MkH)v=7vdm+N)ee?m(Bf|j19pdkef)$-%oy7Z$(5*d};sh6aOlsYQ;a;T#y!;A0N zOA5LRWH(l}OEqif7xm2aEX5g>{Eq%TBd_QX_>*z&Nz=DJJMX)_?H99$yZeqLj7_nv z{@fpYHLg|OisH`v`J z(B9nWPR*AS>+7oRfX3Hl*+*i|SgXU=X_@HZC+)dbVW?j2;aBXrnw#IY=S_o(ZL3=8 zYpd;Zqv`ATS8Ix|v-TD`VaMbTm_@jWUAjE%Z z0%NEi@z>-?4;JH5CITe5Re*1?uS9^66p_!zDST0SsUEWOr_wV6?_otK`o5|v#?DYp zDbVXtLu7EP5L^vzwju!qai~5n{Zv0*J9v*1>97}#o#yU_>T108sl+T1lT!U z1^5XXBEUfjZ%XF>$S7>c5Q&Nj*&+=oBuq7_7Caen7d5e=A$aCX_|ODiGpRISGc6`s z=QIZgp;JY;kJ4jQFOx8F_ui!<94uGdFQFm0-l_VhPe@n$T4ducZ4dr;58p<&=Xec&^ zU*ZE+ek`+ed3!V+N`iq`?T8$S6O(Glm?8`j!MMcV?BgOc+o50$vHm zQg}-OPvFgB&iC^FDBs>vmGA3lh|<$40`E(S{FhmUIZZg3j3TAs-Fapv1w!Jxb$&4` zQ{NlUw`AuwoI^adXL6H4K_ zf>i};HGTWn!Ja~=R1yN1WmXw@mWw}u8!u=q&4vipUUqV(U z>_;FAV8;tPYTp#KpN854d!yN|FBAaczrt96tp?yn;{e}}0PaEmYuJB0&b~2X&qWEY z!M<&r{ihN8JlN|{$`8ibFGlQ-X#vJY0eEU0Kn{U2+y#OL)P9E9zFGLo3c$Vs_C;p9 z?u`UE4f`{&KReF8Jz~F-3BW%AppisM9G9u_n={J_N3PQsVmM6mhU1$VeJ|P$+_@vc z`PDsEZciNI1GP2jL-|nR%9er+)N<$ie65zxogdKZf_?Kn8af4WlY6{VH7}p$U8jeN z_Y_Ig7BDjC-CPq0BY4n}!lI+Is z%UtMb#23_v#rgrH1$sMkvYHqkE3aRe8Cb2nKAs}k#Ii=SRp}Ge$i;?*zEiQqj{K0q zZ&H7P;s62uh|*tHi$oj@(7z`253?;Qu-I=foP&Xe9UI_piYQqPPwX$?hoKh*)8hDy zMVVS&uyN5kEqM0fy13x|#;dx%GnHRm>b4nEGL+AqO|x@daHY#7p6nNxxX5+jLq ze4v7V)I8hh4OVdHBavRHg0DeOt?qsgKOF51dH6@@d1XGXO78SWdX<%YjlWK-sEXK diff --git a/runtime/tests/test_netplay_hash_confirm.c b/runtime/tests/test_netplay_hash_confirm.c index 231112de..338e77b1 100644 --- a/runtime/tests/test_netplay_hash_confirm.c +++ b/runtime/tests/test_netplay_hash_confirm.c @@ -67,6 +67,31 @@ int main(void) { netplay_hc_note_peer(&hc, 820, 0xAAAAu); CHECK(netplay_hc_resolved_through(&hc) == 820u, "prime then match load tick"); + /* Stale FIRST-CORE gap: mismatch tick ages out of the ring; later tip + * digests match — heal must advance past the stuck watermark. */ + netplay_hc_reset(&hc); + netplay_hc_note_local(&hc, 0, 0x1u); + netplay_hc_note_peer(&hc, 0, 0x1u); + CHECK(netplay_hc_resolved_through(&hc) == 0u, "heal setup resolved 0"); + netplay_hc_note_local(&hc, 1, 0xAAAAu); + netplay_hc_note_peer(&hc, 1, 0xBBBBu); /* FIRST CORE */ + CHECK(netplay_hc_resolved_through(&hc) == 0u, "mismatch sticks at 0"); + CHECK(netplay_hc_peek_mismatch(&hc, NULL, NULL, NULL), "mismatch visible"); + /* Overwrite slot for tick 1 (1 % 128 == 1) with a far tip match. */ + netplay_hc_note_local(&hc, 1u + NETPLAY_HC_RING, 0xCCCCu); + netplay_hc_note_peer(&hc, 1u + NETPLAY_HC_RING, 0xCCCCu); + netplay_hc_note_local(&hc, 50u + NETPLAY_HC_RING, 0xDDDDu); + netplay_hc_note_peer(&hc, 50u + NETPLAY_HC_RING, 0xDDDDu); + CHECK(!netplay_hc_peek_mismatch(&hc, NULL, NULL, NULL), + "aged mismatch no longer peekable"); + CHECK(netplay_hc_heal_stale_gap(&hc), "heal advances over stale gap"); + CHECK(netplay_hc_resolved_through(&hc) == 50u + NETPLAY_HC_RING, + "heal to highest matched tip"); + /* Do not heal over a still-visible fork. */ + netplay_hc_note_local(&hc, 60u + NETPLAY_HC_RING, 0x1111u); + netplay_hc_note_peer(&hc, 60u + NETPLAY_HC_RING, 0x2222u); + CHECK(!netplay_hc_heal_stale_gap(&hc), "heal refuses live fork in ring"); + if (failures) { printf("%d failure(s)\n", failures); return 1;