From 9769e9fbc2261942edb9bd49168a3ff85ba3745e Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Sun, 2 Aug 2026 00:08:39 -0400 Subject: [PATCH] rollback patches --- docs/ROLLBACK_MOTK_HOOKUP.md | 864 ++++++++++++++++- lib/recomp-net | 2 +- runtime/include/gpu_gl_renderer.h | 6 + runtime/include/netplay_hash_confirm.h | 4 + runtime/include/psx_netplay.h | 11 +- runtime/include/psx_netplay_rb.h | 11 +- runtime/src/gpu_gl_renderer.c | 162 ++++ runtime/src/main.cpp | 528 +++++++++- runtime/src/netplay_hash_confirm.c | 8 + runtime/src/psx_netplay.c | 1011 +++++++++++++++++-- runtime/src/psx_netplay_rb.c | 1066 +++++++++++++++++++-- runtime/tests/test_netplay_hash_confirm.c | 6 + 12 files changed, 3469 insertions(+), 210 deletions(-) diff --git a/docs/ROLLBACK_MOTK_HOOKUP.md b/docs/ROLLBACK_MOTK_HOOKUP.md index 6c54e486..22c3e051 100644 --- a/docs/ROLLBACK_MOTK_HOOKUP.md +++ b/docs/ROLLBACK_MOTK_HOOKUP.md @@ -7,11 +7,13 @@ Today MotK / psxrecomp lobby netplay defaults to **rollback**: `stage_local → poll_admit (invent within P) → guest frame → finish_frame`. Missing remotes are invented (**hold-last**, MotK digital) only while `wire_need ≤ highest_remote + P`; outside that window admit stalls (BattleShip -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`. +phase_lock). Gap=1 uses a **short** invent grace before hold-last (§23); +gap≥2 uses the full §21 budget. 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`. --- @@ -28,7 +30,8 @@ rewind (FMV soft-promote release-only only). Host **Disable Rollback** (or Env gate: `PSX_NET_MODE=delay|rollback`. Lobby default publishes `match_caps.rollback=true`; **Disable Rollback** publishes false. Auto D/P from -RTT (BattleShip tiers / phase_lock); optional Manual Input Delay / Prediction. +RTT (`P = 4 + D`, see §22); optional Manual Input Delay / Prediction. +Adaptive mid-match delay bumps are always on (no lobby disable). --- @@ -74,6 +77,11 @@ RTT (BattleShip tiers / phase_lock); optional Manual Input Delay / Prediction. - [x] `load_tick` tip slack (one `snap_interval` behind newest) so lagging peer still has the snap; follow refuse → SYNC `initiator=0` NACK; initiator aborts SealInputs (plus 4s seal timeout) +- [x] SEAL_ROWS: stash if episode not open yet (same TURN/UDP reordering race + as BASELINE below — the initiator's `export_local_seals()` burst can + beat a delayed SYNC BEGIN through the relay); unstashed on + `begin_follower` open, keyed by epoch (2026-08-01 scheduler asymmetry + review gap-close). - [x] BASELINE: stash if episode not open yet; burst rexmit on TURN; initiator ready-timeout → Replay (Verify still waits for peer POST) - [x] Resume PC: reject low/vector junk (`0xB0` etc.); prefer function entries; @@ -1405,3 +1413,847 @@ ghost `pub=held wire=ffff` release episodes; no `agreed ADVANCE tip→tip+N` immediately after tip-hold enter/commit from stale HC; tip-hold→commit should often be non-adjacent when a release extends the tip. + +--- + +## 18. POST-diverge tooling + post-realign cooldown (2026-08-01) + +**Post-§17 soak:** coalesce worked (14 tip-extends, 15 coalesce-ahead, +6/23 tip-holds extended by up to 24 ticks). One new failure mode: at tip +9203 both peers POSTed different cores (`8a460717` vs `138700d6`), +aborted, realigned to 9200 — then `RB_ABORT_COOLDOWN_TICKS=24` made every +subsequent real dpad edge `promote-no-resim reason=cooldown`, so the +fork never self-corrected (live digs still mismatched at 9216/9248 until +disconnect). Also: POST only compared tip digests, so there was no +visibility into *which earlier tick* in the sealed span actually forked. + +**Fixes landed:** + +- [x] **POST-DIVERGE diag dump** (before `abort_episode` clears seals): + for `load..tip`, print sealed buttons + hist overlay per seat, then + local/peer FRAME_COMMIT cores from the HC ring, highlighting + `FIRST_MISMATCH` when the fork is earlier than the POST tip. + Grep: `rb POST-DIVERGE`. Both peers print the same shape — diff the + two logs at the abort to see seal/FC asymmetry. +- [x] **`netplay_hc_peer_digest`** getter (symmetric with `local_digest`) + for the FC scan; unit-tested in `test_netplay_hash_confirm`. +- [x] **Skip rewind cooldown after a successful POST realign** + (`RB_POST_REALIGN_COOLDOWN_TICKS=0` + `clear_rewind_cooldown`). + Storm cooldown (`streak >= 2`) and "no realign tip" baseline + cooldown are unchanged. Log: `rb rewind cooldown cleared (...post...)`. + +**Re-soak checks:** on the next `post core/aux diverge`, expect a +`POST-DIVERGE diag` / `seals` / `fc FIRST_MISMATCH` block immediately +before `episode ABORT`; after realign, expect `rewind cooldown cleared` +(not `cooldown until sim=…`); subsequent real edges should open episodes +again instead of `promote-no-resim reason=cooldown`. If +`FIRST_MISMATCH` is earlier than POST tip, that tick is the next +investigation target (sealed-pad asymmetry vs. sim nondeterminism). + +--- + +## 19. Tip-extend invent-idle seal + POST storm cooldown (2026-08-01) + +**Post-§18 soak:** tip-hold coalesce/tip-extend worked through a press→ +release→press chain (epoch 21, tip 1416→1425), then POST diverged at +1425 (`310e567b` vs `dd8432b2`). Diag: + +- Host arm audit: `s1=ff7f` (LEFT — correct wire-promoted resign) +- Host POST dump: `t=1425 s1=ffffP/h=ff7f!` (seal invent-idle vs hist LEFT) +- Peer dump: `s1=ffffP/h=ffffP` (FOLLOW local seat never had the press in hist) +- Prior `ready timeout` left `streak=1`; POST → `streak=2` → **storm + cooldown** (`until sim=1474`) → promote-no-resim while Live stayed + forked at pin 1392 (`baseline core mismatch` storm) + +**Root cause:** + +1. Tip-hold invent-cap stalls Live at the tip, so FOLLOW's local hist + lacks `tip+1..edge` even though the pad was already sampled into the + delay ring at `sim+delay`. Seal `get_input_row` fell through to + `invent_idle` → exported `ffffP` for the correction seat. +2. Initiator had correctly resigned from wire peek (`ff7f`), then + `apply_peer_seal_rows` accepted the FOLLOW invent and overwrote it. +3. §18's `POST_REALIGN_COOLDOWN=0` was skipped by the storm branch + (`streak >= 2`); tip-hold commit did not clear the abort streak. + +**Fixes landed:** + +- [x] **`host_get_input_row` / tip-extend prefresh:** promote from + `rnet_session_peek(_remote)_input` at `wire_tick_from_sim` before + hist/invent; tip-extend (initiator + FOLLOW) prefreshes the + correction + local seats over the new tip range. +- [x] **`rnet_rb_apply_peer_seal_rows`:** reject predicted peer rows that + would clobber a non-predicted sealed row (still credit the mask). +- [x] **POST realign never storm-cools** when a realign tip exists + (clears streak + `RB_POST_REALIGN_COOLDOWN_TICKS`); tip-hold + finalize also clears `g_bl_mismatch_streak`. + +**Re-soak checks:** tip-extend rereplay arm/fin should keep matching +auth pads (`s1=ff7f` not `ffffP`); POST dump should not show +`ffffP/h=ff7f!`; on POST diverge expect `rewind cooldown cleared +(...post...)` even after a prior ready-timeout; no immediate baseline +mismatch storm at the pin from promote-no-resim. + +--- + +## 20. Skip presentation during resim ticks (2026-08-01) + +**Motivation:** with §17-19 landed, soaks were spending a large fraction +of wall-clock time in `Replay` (the `[FPS] ... replay=NN% (n=…)` line). +Every resimulated vblank still ran the *full* GL/VK/SW present path +(`gl_renderer_present_vram` / `gl_renderer_sync_cpu` FBO readback, +`vk_renderer_present_*`, or the software `SDL_UpdateTexture` + +`SDL_RenderPresent` path) even though none of that pixel work is visible +— it gets fully overwritten by the next resimulated (or the final Live) +frame. Host audio pump was already skipped during resim (see the +existing `!psx_netplay_is_resimulating()` guard around `sdl_audio_update` +in `sdl_vblank_present_body`); presentation had no equivalent guard. + +**Fix:** added a `psx_netplay_is_resimulating()` early return in +`sdl_vblank_present_body()` (`runtime/src/main.cpp`) immediately before +the `---- Display from our VRAM ----` block, mirroring the existing +audio-pump skip. This is presentation-only: + +- `psx_netplay_finish_frame()` (the digest/hash-confirm capture) and the + `ep.do_epilogue` / `ep.override` / `ep.skip_pace` epilogue flags used + by `sdl_vblank_present()` for netplay admit/pace are all latched + *before* this point in the function, so admit/pace timing is + unaffected. +- `gpu_depth24_present_hold_tick()` and the widescreen-engage check + (which must tick every vblank per their own comments) run *before* + the new guard, so their bookkeeping is preserved. +- Host-only presentation state (frame-blend `prev_buf`, disabled-frame- + presented latch, present ring) simply doesn't advance on resim ticks — + the same category of skip already used by the blank-display, + catchup-budget, and turbo-loads early returns elsewhere in this + function. Live's next real present after the episode settles still + draws the final, correct frame. +- Digest content is unaffected: `host_state_digest()` never reads GPU/ + VRAM/framebuffer state (core+aux hashing is CPU/RAM/IRQ/timers/CD/SPU + register state, not pixels), so skipping the pixel work cannot change + what gets hashed or sent over the wire. + +Debug and Release builds both compile clean after the change. + +**Re-soak checks:** `[FPS]` line's `replay=NN%` should be unaffected +(same resim ratio, since this doesn't change *when* resim happens, only +whether it presents), but wall-clock game FPS during heavy-resim spans +(chain-episoding, tip-hold storms) should improve since the GL readback/ +present cost is no longer paid on non-terminal resim vblanks. No new +divergence should appear — this change touches host presentation only, +never digested/guest state. + +--- + +## 21. Invent-grace ceiling scales with `input_delay` (2026-08-01) + +**Motivation:** a soak sweep across `PSX_NET_DELAY` values (`D=4` twice +via the launcher's "default" and an explicit `d=4`, plus `D=6`) showed +raising `D` did not reduce prediction — it made it *worse*. Normalized +per 1000 sim ticks: + +| | D=4 | D=6 | +|---|---|---| +| `rb invent grace` events | 127.7 | 232.0 | +| `tip-hold coalesce-ahead` | 18.9 | 73.0 | +| `rb episode commit` | 23.9 | 28.4 | +| `rb wire rewind-request` | 24.4 | 23.4 | + +Invent (predict) frequency roughly doubled and coalesce-ahead nearly +quadrupled from D=4→D=6, while the actual correction/rewind rate barely +moved — the opposite of what a bigger delay buffer should buy. + +**Root cause:** `np_try_admit_rollback()` computes `wire = sim + D` +(`rnet_wire_tick_from_sim`) but the decision to invent vs. stall never +used `D` at all. Any gap `wire > highest_remote_wire + 1` immediately +falls into `np_invent_grace_stall()`, whose patience budget was +`1.5x measured RTT`, hard-capped at **60ms regardless of `D`** (40ms in +the pre-RTT startup fallback). So choosing `D=6` (100ms of nominal +buffer) vs `D=4` (66.8ms) never changed how long the sim was willing to +wait for a late remote sample before predicting — the configured delay +only changed *which* wire tick was being requested, not the patience +for it. With this soak's RTTs already well under the 60ms ceiling +(17-53ms), the ceiling — not the delay — was the binding constraint the +whole time, so the extra `D` bought nothing but longer predicted runs to +later reconcile (more invents, more coalescing to absorb them, same +rewind rate). + +**Fix landed:** `np_invent_grace_stall()`'s ceiling now scales with +`g_np.input_delay`: `rtt_ceiling = clamp(delay_ms/2, 60, 150)` and +`fallback_ceiling = clamp(delay_ms/3, 40, 100)`, where +`delay_ms = input_delay_ticks * tick_ms` (tick_ms from the live +tick-period EMA, nominal 17ms fallback). `D<=4` reproduces the old +60/40ms ceilings unchanged (floors preserve historical behavior); larger +`D` now actually grants more real wall-clock patience before falling +back to prediction, in proportion to the extra buffer the user asked +for. The RTT-based *scaling* of the budget itself (`1.5x RTT`) is +unchanged — only the ceiling moves. The `rb invent grace budget` log +line now also prints `delay_ms=` so a soak can confirm the ceiling in +effect. + +**Re-soak checks:** re-run the same `D=4` vs `D=6` comparison; expect +`rb invent grace budget=...delay_ms=...` to show a materially higher +ceiling at `D=6` than `D=4`, and expect invent-events/1k-ticks to no +longer scale up with `D` (ideally trend down, since more genuinely-late +packets should now clear within the wait instead of triggering a +predict). `D<=4` behavior should be bit-for-bit unchanged from prior +soaks (floors match the old hard caps). + +--- + +## 22. Delay-buffer admit + adaptive delay (2026-08-01) + +**Motivation:** Soaks with manual `D=4` / `P=2` still saw frequent small +resims. Expectation mismatch: raising `D` alone does not widen the +sim-vs-remote phase gap that gates invent — both peers shift `wire=sim+D` +together, so the gap stays ~0–1. The product goal is delay-sync-like +operation at the delay edge (buffer filled, invent rare), with the +prediction runway as insurance for spikes, freeze+refill when `P` is +exhausted, and adaptive `D` growth when freezes persist. + +**Preserved (do not regress):** +- Gap gate: invent freely at `gap ≤ 1` (stalling every miss serialized + both sims to ~40–50 fps — §12). +- Timesync micro-throttle remains the primary phase-lock tool. + +**Lobby / auto D/P (recomp-ui):** +| Measured RTT | Input Delay | Prediction (`P = 4 + D`) | +| ------------ | ----------: | -----------------------: | +| 0–20 ms | 2 | 6 | +| 20–50 ms | 2 | 6 | +| 50–80 ms | 3 | 7 | +| 80–120 ms | 4 | 8 | +| 120–160 ms | 5 | 9 | +| then steps up | … | `4+D` | + +Manual defaults: **D=2**, **P=6**. Prediction tooltip: keep +`prediction = 4 + delay`. No UI to disable adaptive delay — with +`P=4+D` freezes should be rare; when they happen, growing `D` is the +intended response. Soak-only: `PSX_RB_ADAPT_DELAY=0`. + +**Runtime changes:** +1. **Timesync:** skip `note_late` during active episode / tip-hold (resim + cost ≠ transit latency). Pegged-streak off-guard raised 12→18. + `psx_netplay_timesync_on_episode_boundary()` clears pegged streak on + begin / tip-hold / abort (debt kept for post-episode pacing). +2. **Admit telemetry:** `rb admit stats invent_gap1/gap2/gap3+ + pcap_stalls/pcap_enters` every ~5s; invent path buckets by gap. +3. **P-cap freeze:** `wire > highest_remote + P` logs + `rb pcap FREEZE enter/exit` and counts freeze enters in a 5s window. +4. **Adaptive delay (always on):** host (`local_slot==0`) after ≥3 freeze + *enters* in 5s, with 10s cooldown, calls + `rnet_session_request_delay_change(D+1)` (max 16). `P` stays at the + lobby-committed value. Guests apply via DELAY_SYNC. +5. **recomp-net DELAY_SYNC:** deferred apply queue + `(pending_delay, effective_tick)`; commit in `rnet_session_advance` / + `set_sim_tick` when `sim_tick >= effective_tick`. New + `rnet_session_request_delay_change`. Retransmit pending packets every + 50ms while queued. MotK mirrors `g_np.input_delay` from + `rnet_session_committed_delay` on admit / after live advance. + +**Re-soak checklist:** +- [ ] LAN with auto or manual **D=2 / P=6**: expect fewer invents than the + old D=4/P=2 soak; gap1 invents dominate over gap2/gap3+. +- [ ] Log lines: `rb admit stats …`, rare/absent `rb pcap FREEZE enter`. +- [ ] Induced latency spike: freeze enter → after 3 enters, + `rb adaptive delay bump` + both peers `rb delay committed`. +- [ ] Timesync does not trip `OFF 10s` solely from tip-hold/resim load. +- [ ] Fight scene: fps / episode density vs prior soak; no serialization + regression (still inventing at gap≤1). + +--- + +## 23. Short invent grace at gap=1 (2026-08-01) + +**Soak evidence (same session pair, TURN):** +| Setting | ep/1k ticks | host invent_gap1/1k | pcap freezes | +| ------- | ----------: | ------------------: | -----------: | +| D=2 P=6 | ~23 | ~260 | 0–1 | +| D=4 P=8 | ~25 | ~297 | 0 | + +Raising D/P did nothing — invents were ~99% `gap1` (free path). Adaptive +delay never armed (P=8 runway unused). Hitch source remains +gap1 invent → edge mispredict → tip-hold/coalesce → resim. + +**Fix:** ahead-only **short** invent grace when +`wire == highest_remote + 1`: +- Cap = `max(8, min(16, RTT_ms))` (auto), or `PSX_RB_GAP1_GRACE_MS` + (0 = old invent-free gap1). +- Must stay << one peer guest frame so we do **not** revive the + 2026-07-31 serialization (full stall on every miss → 40–50 fps). +- Gap1 grace expiries do **not** feed the invent-grace adaptive-off + streak (would trip constantly under normal phase stagger). +- Gap≥2 keeps the full §21 budget unchanged. + +**Timesync:** mispredict debt add ~0.75 tick (was 0.5); admit shave +≤4 ms/tick (was 3). + +**Telemetry:** `rb admit stats` now includes `gap1_grace=` (times the +short stall returned before invent). + +**Re-soak checks:** compare to D=4/P=8 baseline (~25 ep/1k): +- Expect `gap1_grace` rising and `invent_gap1` / ep/1k falling. +- Steady FPS must not collapse to ~40–50 with zero episodes (that + would mean the short cap is too large / both peers serializing). +- `PSX_RB_GAP1_GRACE_MS=0` restores prior gap1 invent-free behavior. + +--- + +## 24. Admit-hitch cleanup after gap1 grace (2026-08-01) + +**§23 soak:** ep/1k ~25→14 and invent_gap1/1k ~297→188, but feel was still +bad. FPS forensics: **17/53** host windows were admit-dominated hitch +(`admit` 12–17 ms, `replay=0%`) — not resim. Causes: + +1. Gap1 grace spun the admit loop (~10k stall returns / ~2k ticks) and + often **expired into invent anyway** (stall tax without preventing + predict). Cap was up to 16 ms on measured RTT. +2. TipHold **coalesce-ahead** reset the quiet timer on every edge + (1825 resets) so Live stayed parked in tip-hold with high admit cost. + +**Fixes:** +- Gap1 auto cap → `max(4, min(10, RTT/2))`; count `gap1_grace` once per + wire tick; after 10 consecutive expire→invent, **OFF 1s** + (`rb gap1 invent grace OFF 1s`). +- TipHold: **350 ms absolute wall cap** from enter (`rb tip-hold WALL CAP`); + pure TipHold coalesce extends `tip_hold_until` but **does not** reset + quiet_t0 (rereplay still does); coalesce-ahead log rate-limited. + +**Re-soak:** expect fewer admit-dominated `replay=0%` dips, lower +`gap1_grace` counts (per-wire), occasional `gap1 invent grace OFF`, +`tip-hold WALL CAP` when menus mash, ep/1k staying ≤§23 or better. + +--- + +## 25. TipHold WALL vs quiet + gap1 shrink (2026-08-01) + +**§24 soak:** admit-spin fixed (`gap1_grace/1k` 4550→278, coalesce 1825→32) +but feel regressed: **27/27** tip-hold commits were `WALL CAP 350ms` +(quiet never won — quiet need ~400ms > WALL 350ms), and gap1 invent-free +OFF climbed invent_gap1/1k 188→360 and ep/1k 14→19. Hitching returned as +replay-dominated. + +**Fixes:** +- TipHold: quiet max **180ms** (can finish); **THRASH CAP 150ms** only when + `coalesce_n ≥ 3`; **SAFETY CAP 500ms** last resort. Logs: + `tip-hold THRASH CAP` / `tip-hold SAFETY CAP`. +- Gap1: on expire→invent streak, **SHRINK to 2ms for 1s** (not invent-free). + Log: `rb gap1 invent grace SHRINK 2ms for 1000ms`. + +**Re-soak:** expect mostly quiet tip-hold commits (not WALL), occasional +THRASH on mash, invent_gap1 not jumping when SHRINK fires, ep/1k ≤§23. + +--- + +## 26. FMV lockstep was re-serializing WAN (2026-08-01) + +**Clue:** `PSX_RB_GAP1_INVENT=1` still stayed ~30 fps on CGNAT/TURN. Early +menu with invent free-ran at **~60 fps / admit≈0**; the cliff lined up with +`FMV rewind-defer ON` and invent count **0** until `invent_at` (~4s of +post-FMV MIN+UNLOCK delay-sync). Tip-hold/replay windows later recovered +toward ~50 fps — so prediction *can* drive presentation; admit was still +gated on confirmed tip. + +**Root causes:** +1. `lockstep_no_invent` = media **and** `g_fmv_lockstep_until` (MIN 180 + + UNLOCK_GRACE 64). Correct for depth24 Start/skip; fatal for WAN title + menus (network paces every frame). +2. Default admit refused invent while `remote_lead > 0` until TIP_STALE + (~150ms+) — delay-sync unless ENV forced gap1 invent. +3. RUNWAY_EMPTY invent grace floored at 60ms ceiling → ~25ms tax/tick. + +**Fixes:** +- No-invent gate = **media + settle (24 ticks) only**. Digest lockstep + still drives dense snaps / RELEASE logs, not admit. +- Gap1 short-grace→invent is **default ON** (`PSX_RB_GAP1_INVENT=0` for + old cushion-wait). +- Invent grace OFF after 15 expiries for **5s**; RUNWAY ceiling floor 20 / + cap 80 (was 60/150). + +**Re-soak (no ENV needed):** FMV itself may still track packet rate (no +invent during media — intentional). Post-FMV menus should invent within +~settle and climb toward 60 with rollbacks, not flat ~30. Look for +`gap1 invent ON`, `invent grace OFF 5s`, settle log +`no invent through settle; then invent+resim §26`. + +--- + +## 27. Presentation continuity without resim storms (2026-08-01) + +**Problem:** §26 unlocked invent/resim, but the eye still saw stalls — +tip-hold parked Live at tip (`invent_slack=0`), gap1/RUNWAY grace taxed +every miss, and deep `RUNWAY_EMPTY` invents (`pred_depth` 4–5) chained +episodes. Telemetry showed high `replay%` while `present` starved. + +**Policy pivot:** keep the display clock moving with **shallow** prediction; +resim real edges; do not reintroduce delay-sync after invent commits. + +**Fixes:** +- TipHold seal slack → library default **2** (was FORCE0); quiet **80ms**, + thrash **100ms @ coalesce≥2**, safety **250ms**. +- Gap1 invent grace default **0** (`PSX_RB_GAP1_GRACE_MS` override kept). +- Invent depth cap: `pred_depth ≥ 2` waits until tip stale (1× invent RTT, + floor 40ms); RUNWAY invent grace hard-capped at **8ms**. +- FPS line: `present_gap_p95=… ms max=… ms` (primary soak metric). + +**Pass (WAN/TURN, post-settle):** `present_gap_p95 ≤ 33ms`, invent mostly +`GAP1` with `pred_depth ≤ 2`, ep/1k moderate (not zero invents, not +constant deep resim). FMV media may still be packet-paced (no invent). + +--- + +## 28. Gap=1 phase-vs-starvation split — LAN resim storms (2026-08-01) + +**Problem:** §27's `gap1_grace=0` default fixed WAN 30fps delay-sync but +regressed LAN. A 2-peer soak at `D=2 P=6` (one peer via TURN relay, one +direct host-host) showed **~310 gap1 invents / ~2400 frames**, a first +core diverge at sim=962, then a tip-hold commit storm (`ep/1k≈15`, +`present_gap_p95` spiking to 3.4s once). The invent geometry was +perfectly stable the whole soak: + +``` +wire = sim+2, remote_tip = sim+1, remote_lead = 1, pred_depth = 1 +``` + +That is **not starvation** — the confirmed remote tip is still one row +ahead of sim, i.e. there is real work already in flight. It is a +one-tick **phase offset**: this peer is consistently sampling wire a +tick before the matching remote row lands. §27 treated every gap=1 miss +identically (`invent now`), converting each phase tick into a mispredict +→ rewind-request → tip-hold episode. + +**Fix — Case A / Case B split at gap=1** (`np_gap1_grace_cap_ms`, +`np_tip_track_advance`/`np_tip_age_ms`, gap1 branch of +`np_try_admit_rollback` in `psx_netplay.c`): + +- Track the remote tip's own arrival cadence every admit tick (not just + on miss): `g_tip_arrival_ema_ms` = EMA of ms between + `highest_remote_wire` advances, `np_tip_age_ms()` = ms since the last + advance. +- **Case A (healthy/advancing tip):** `remote_lead ≥ 1` and the tip + advanced within the last `1.5×` its own cadence. The newest row is + probably already in flight — wait `clamp(period − tip_age, 4, 10)` ms + before inventing, instead of 0. Logged as `reason=GAP1_PHASE`. +- **Case B (stale/starved tip):** `remote_lead ≤ 0`, or the tip hasn't + advanced in over 1.5× its cadence (or cadence unknown). Invent + immediately — same as §27's default — so genuine WAN gaps stay + responsive and this never turns back into cushion-wait. +- `PSX_RB_GAP1_GRACE_MS` still forces the old flat cap (bypasses the + Case A/B split entirely) for A/B testing or a manual operator override. + +**Telemetry:** `rb admit stats` gained `gap1_case_a=`, `gap1_case_b=`, +`tip_ema=` (the learned arrival cadence in ms). Expect on LAN: +`gap1_case_a` rising, `gap1_case_b`/`invent_gap1_legacy` mostly flat, +`tip_ema` settling near the true tick period (~16–17ms at 60fps), +`present_gap_p95` and ep/1k dropping back toward the pre-§27 LAN +baseline (~60fps, near-zero episodes) without WAN regressing to 30fps. + +**Re-soak:** LAN pass = few-to-no `GAP1_LEGACY`/`RUNWAY_EMPTY` invents +once `tip_ema` converges, `gap1_case_a` dominant, ep/1k near 0, fps back +near 60. WAN pass = unchanged from §27 (Case B fires immediately on a +genuinely stale/negative-lead tip, so high-RTT links don't gain a hidden +per-tick stall). + +--- + +## 29. §28 Case A wait was the hitch — scheduler needs pacing, not waiting +(2026-08-01) + +**Problem:** the very next LAN soak after §28 showed *worse* hitching. +`gap1_case_a`/`gap1_grace` climbed to **1000/1000** (every gap=1 miss took +the Case A wait), but only ~12% of those waits actually found the row — +88% expired into invent anyway (`invent_gap1_legacy` 883, `GAP1_PHASE` +314 logged). `tip_ema` (the learned tip-advance cadence) drifted from +15ms to **28-41ms** over the match — 2-3x the true ~16ms tick period — +because the wait itself delays the next admit, which delays the next +tip-advance observation, which makes the next miss look "fresher" than +it is (self-reinforcing feedback loop). Net effect: `admit` cost rose +from ~6ms/f to ~8ms/f baseline, first core diverge moved earlier +(sim 279 vs 962), ep/1k rose (16→27), and `present_gap` spiked to +**~4 seconds** during storm windows with fps dropping to single digits. + +**Root cause, reframed as a scheduler question, not an invent-policy +question:** both soaks showed the exact same fixed point regardless of +gap1 policy: + +``` +wire = sim+D, remote_tip = sim+(D-1), remote_lead = D-1, pred_depth = 1 +``` + +This is **rock-stable**, not jittery — the signature of a fixed ~1-tick +network transit/scheduling cost consuming one of the two delay frames, +combined with a self-reinforcing rate effect: because gap=1 has always +resolved via immediate invent (never a wait), the peer that structurally +gets there first never has to slow down, so it keeps racing exactly one +tick ahead of the other's publish rate indefinitely. A **per-miss wait** +cannot fix either half of this: it cannot change a fixed transit delay, +and by delaying every admit uniformly it does not reintroduce any +asymmetry between the peers — so both soaks converged to the same +useless (or actively harmful) place. + +**Fix:** the only mechanism in this codebase that is actually +asymmetric-safe here is mispredict-driven `np_timesync_note_late` +pacing (`psx_netplay.c`) — soak evidence already established that real +mispredicts land almost entirely on one peer (the one racing ahead), +so pacing *that* peer down a few ms/tick closes phase without both +sides fighting each other or serializing. It was just too weak to +matter: MotK's hold-last invent means most gap=1 misses never produce a +mispredict (the invented value already matches), so genuine correction +signals are rare, and the old debt sizing (add ~0.75 tick/mispredict, +cap 2 ticks, shave <=4ms/tick) drained faster than it could accumulate +enough to move a persistent D-1 phase. + +- **Reverted** §28's per-miss Case A wait. Gap=1 invents immediately + again (§27 behavior) — zero added admit latency. The Case A/B + classification is kept as **pure diagnostics** (`GAP1_PHASE` / + `GAP1_LEGACY` reason, `gap1_case_a`/`gap1_case_b` counters) computed + from the same tip-health signal, but nothing waits on it. + `PSX_RB_GAP1_GRACE_MS` still forces the old flat-cap wait for A/B + testing. +- **Strengthened timesync pacing** (`np_timesync_note_late`, + `np_timesync_throttle`): per-mispredict debt add raised from ~0.75 + tick to ~1.25 ticks, cap raised from 2 ticks to 3 ticks, admit-side + shave raised from <=4ms/tick to <=6ms/tick — so a small cluster of + real mispredicts (e.g. one fight exchange) can actually walk a stuck + D-1 phase back toward D within a handful of ticks instead of dozens. + Adaptive-off guards (18 consecutive cap-hits → OFF 10s) are unchanged. + +**What to watch in the next soak:** `gap1_case_a`/`gap1_case_b` should +still climb (that's expected — LAN transit still eats ~1 tick most of +the time) but `admit` ms/f and `present_gap_p95` should drop back near +pre-§27/§28 baselines since no wait is added; `tip_ema` should hold near +the true tick period instead of drifting; and `timesync pacing` log +lines / `rb timesync OFF` should show whether the stronger debt actually +narrows `remote_lead` back toward D over a match, or whether the +transit-delay theory needs D raised instead (next lever if pacing alone +doesn't move it). + +--- + +## 30. Phase-control soak instrumentation — no algorithm change (2026-08-01) + +**Context:** Prior soak showed host (`slot=0`) opening ~91 rewind-requests +against remote slot 1 while guest opened only ~29 against slot 0, with +timesync pacing 72 vs 17. Hypothesis: the peer that starts ahead spends +more time in `rb_active`/`tip_holding`, so `np_timesync_note_late()` +suppresses the very debt that would slow it — a control-loop leak — +rather than pure gameplay/transport asymmetry. + +**Change:** telemetry only (`psx_netplay.c`). No pacing constants, grace, +or invent-policy edits. + +**~1 Hz line** (each peer, stderr): + +``` +psxrecomp: rb phase ctrl slot=N sim=… lead=… lead_avg=… lead_min=… +lead_max=… debt_ms=… debt_added=… mispredict=… note_late=… +suppressed_rb=… suppressed_off=… D=… +``` + +| Field | Meaning | +|-------|---------| +| `lead` / `lead_avg/min/max` | `remote_lead` now + window over the last ~1s of admits | +| `debt_ms` | current timesync pacing debt (drains ≤6 ms/tick) | +| `debt_added` | cumulative ms of debt ever added (never resets) | +| `mispredict` | cumulative `pads_differ` resolves (true mispredicts) | +| `note_late` | cumulative `note_late` calls that actually added debt | +| `suppressed_rb` | cumulative `note_late` early-outs while `rb_active` or `tip_holding` | +| `suppressed_off` | cumulative early-outs while timesync adaptive-OFF window | + +Same counters also appear on the 5s `rb admit stats` line. + +**How to read host vs guest:** + +1. **Supports control-loop instability:** one peer consistently has higher + `lead`/`lead_avg`, higher `mispredict`, *and* higher `suppressed_rb` + (especially `suppressed_rb` rising with `mispredict` while + `note_late` lags). That peer is losing correction signal during its + own resim/tip-hold windows. +2. **Points elsewhere (gameplay/transport):** `suppressed_rb` rare or + balanced, but `mispredict` still skewed — edges/content or path RTT + dominate; do not change the suppress gate yet. +3. **Secondary:** `suppressed_off` climbing on one side means adaptive-OFF + is also silencing pacing (different failure mode). + +**Do not** tweak pacing constants from this soak until the hypothesis is +confirmed or rejected from these four signals. + +## 31. Protocol correctness track — distributed episode contract (2026-08-01) + +Work split into two tracks (protocol correctness first, scheduler feel +second) so remaining rollback storms can be attributed to scheduler policy +rather than peers disagreeing about control flow. This section is the +protocol track. All six items landed together; the RB_SYNC wire format +gained an op code (the old `initiator` byte) and a flags byte (the old pad +byte, so packet size is unchanged). Constants live in +`recomp_net/session.h` (`RNET_RB_SYNC_OP_*`, `RNET_RB_SYNC_FLAG_*`, +`RNET_RB_ABORT_CLASS_*`). + +**1. Episode identity — slot-partitioned epoch ids.** Wire epoch = +`(counter << 3) | initiator_slot` (`RB_EPOCH_SLOT_BITS`). Concurrent dual +initiation can never collide on an id, and any epoch's initiator slot is +derivable from the id alone (needed by the tie-break). `g_epoch` is now +the counter, not the wire id; followers only raise their counter for log +monotonicity. + +**2. Dual-initiation tie-break.** Both peers opening episodes +concurrently used to deadlock (each dropped the other's SYNC as "already +active") or cross-wire baselines. Now, on receiving a competing BEGIN +while initiating: the lower initiator slot wins. The loser yields quietly +— `abort_episode` with class REALIGN, cooldown cleared (contention is not +failure) — and falls through to follow the winner's episode. Yield only +happens in SealInputs/AwaitingBaseline before the snap load mutated Live; +deeper phases drop the SYNC and rely on abort propagation to clean up. + +**3. Abort propagation.** Local aborts used to tear down silently; the +peer kept sealing/verifying until its own skewed timeout. Every +wire-visible abort now sends `RB_SYNC op=ABORT` (×3 burst, epoch-deduped +on receive) with the cooldown class in the mismatch field and the sender's +realign tick in the load field. The receiver mirrors teardown, realigns +via the shared `pick_realign_tip()` policy (pin > episode load > agreed), +and arms the same cooldown class. + +**4. Shared cooldown class.** `abort_episode_realign` classifies BEFORE +aborting (REALIGN / ABORT / STORM / NO_SNAP → 0/24/48/90 ticks via +`abort_class_cooldown_ticks`) and stages the class into the wire notice, +so both peers re-arm on the same schedule instead of deriving different +cooldowns from different local evidence. The receiver also syncs its +`g_bl_mismatch_streak` from the class (REALIGN clears; STORM forces ≥2). +An ABORT for an episode we never joined still arms STORM/NO_SNAP classes +so we don't immediately initiate into a peer that just declared a hard +failure. + +**5. Shared frontier.** Three parts: (a) follow-NACKs carry the +follower's confirmed frontier (`follower_frontier_hint()`: agreed +watermark, else mutually hash-confirmed tick) in the target field; the +initiator demotes to `min(load-1, peer_frontier)` walked down to a real +snap, so the reopened episode's load is provably followable instead of +NACK-cycling. (b) `finalize_tip_hold` bursts RESOLVED (enter_tip_hold and +commit already did) so a committed tip is never a local secret. (c) The +follow REFUSE "past frontier" check treats the library's peer-advertised +`rnet_rb_resolved_through` as a frontier floor — peers only advertise +committed ticks, so a load at/below it is followable even when our own +agreed/hc watermark lags from a lost packet. + +**6. Shared rereplay + light-tip decision.** BEGIN/tip-extend SYNCs carry +initiator-authoritative flags: `FLAG_LIGHT_TIP` (follower adopts verbatim; +`rnet_rb_begin_episode` no longer auto-classifies when `from_peer_notify`, +and `rnet_rb_recommend_light_tip` reports `corr.flags` only — this was the +asymmetric baseline-burst seed) and `FLAG_REREPLAY` (tip-extend: follower +mirrors the initiator's rereplay decision, OR'd with its own +"Live invented past the prior tip" check, which can only add correction, +never skip one). + +Verified: MotK `psx-runtime` builds; all 11 recomp-net tests pass +(rb_wire_test round-trips the op/flags byte and the ABORT shape; +rollback_episode_test covers follower flag adoption). Standalone +recomp-net repo synced with the same changes. + +--- + +## 32. Scheduler-feel track kickoff — lead regulation (2026-08-01) + +Protocol correctness (§31) landed clean: the rb-diag1/rb-diag2 re-soak had +no NACK cycles, no REFUSED, no SPAN CAP, tie-breaks resolved deterministically, +and ABORT propagated even when only one side locally detected the divergence. +Remaining asymmetry is scheduler *feel*, not protocol disagreement. Fable's +review flagged four items in priority order; this section covers the first +(highest-leverage) one: **lead regulation**. + +**Observed asymmetry (rb-diag soak):** host began ~3× as many rewind +episodes as guest and owned almost all corrections against the guest's pad; +pacing debt accumulated mostly on host (1428 ms vs 456 ms cumulative). The +begin/mispredict ratio tracks almost 1:1 (host mispredict=124/begin=107, +guest mispredict=50/begin=33) — `rnet_input_contract_stick_replace_decide` +converts nearly every genuine mispredict into a rewind, so the real question +is *why one peer mispredicts 2.5× more than the other*, not the contract's +promote/rewind split. + +**Hypothesis:** an over-prediction feedback loop. The peer that falls behind +wall-clock (more Replay/TipHold work from owning more corrections) still has +to keep pace with the wire tick rate, so it ends up predicting the remote pad +further into the future before the real row lands; deeper predictions are +mechanically more likely to be wrong; each wrong deep prediction becomes +another correction it initiates, which costs more Replay/TipHold time — the +same peer digs itself deeper. `np_timesync_note_late` already exists to +brake exactly this (pacing debt shaved off at admit), but until now every +mispredict added the *same* flat debt regardless of how deep the guess was, +so a peer running way ahead and a peer running barely ahead got identical +correction pressure. + +**Change 1 — proportional debt (`psx_netplay.c`):** `np_timesync_note_late` +now takes the mispredict's *age* (`sim - t`: how many ticks the wrong +predicted row rode before the real one arrived and was caught). A row +resolved right at the normal input-delay boundary (`age ≈ D`) is expected +steady-state noise and gets the same flat debt as before — zero behavior +change in the common case. A row that rode notably longer than `D` ticks +means we were running unusually far ahead of the confirmed remote tip when +we guessed it; extra debt scales `+25%` of the base add per tick past `D`, +still capped by the existing 3-tick ceiling. This only activates additional +braking specifically during the runaway-ahead pattern; it cannot make the +aligned/steady-state case worse since `extra_age` is 0 there. + +**Change 2 — telemetry (`psx_netplay.c` phase-ctrl line):** added +`mispredict_age_avg=` / `mispredict_age_max=` (match-lifetime, like the +other cumulative counters on that line) so the next soak can directly +confirm or reject the causal hypothesis by comparing host vs guest average +prediction age — instead of the begin/mispredict-count archaeology used to +produce the numbers above. If the hypothesis holds, host's +`mispredict_age_avg` should sit measurably above guest's, and both peers' +ratio should compress with change 1 applied vs a soak with it reverted. + +**Deliberately not done yet:** did not touch the tie-break weighting, the +resim/tip-hold `note_late` suppression gate (already balanced — 17/17 +`suppressed_rb` in the §31 re-soak, so that specific §30 hypothesis is +closed), or the admit-side throttle shave rate. Per this project's own +established practice (§29: a guessed pacing fix regressed and had to be +reverted after a soak), only one bounded, reversible, provably-no-worse- +in-steady-state change plus matching telemetry landed per cut — the next +soak's `mispredict_age_avg`/`lead_avg`/`debt_added` decide whether to push +further (e.g. widen the +25%/tick coefficient) or whether the asymmetry is +gameplay-driven (who's the aggressor) rather than scheduler-driven. + +**Still open (scheduler-feel track, unchanged priority from fable's +review):** +- Presentation cadence during Replay/TipHold — **landed in §33** (hold-last + present on wall-clock cadence during `is_resimulating`). +- POST core/aux diverge (~0.8/1k ticks) — heal path (§31 ABORT propagation) + already handles it gracefully; root cause is a separate emulation + determinism gap (audit/cpu-split dumps already exist for that chase). +- Gap1/grace — left as `invent-immediate` (§29's finding stands); revisit + only after lead regulation is confirmed closing the gap, so grace changes + aren't evaluated against a still-skewed baseline. + +--- + +## 33. Hold-last present during resim (2026-08-01) + +**Problem:** `sdl_vblank_present_body` early-returned on +`psx_netplay_is_resimulating()` (AwaitingBaseline | Replay | Verify) and +skipped every Swap. Admit still advanced uncapped, so `replay%` windows +produced `present_gap_p95` of 80–150ms — the metric tracked replay load +because presents only resumed on Live ticks. Visually: frozen front buffer +for the whole resim span, then a jump. + +**Fix (host presentation only — digests/wire unchanged):** + +1. **GL sticky hold** (`gpu_gl_renderer.c`): before each Live `SwapWindow`, + `hold_capture_drawable()` copies the drawn backbuffer into a hold + texture. When interpolation owns Swap, `hold_capture_native_fbo()` + blits the display band from the VRAM/wide FBO instead. Mid-resim must + not re-read `s_hr_tex` (it mutates during Replay). First NATIVE + hold-present letterboxes then re-captures as DRAWABLE (soak-proven path). +2. **`gl_renderer_present_hold_last()`**: redraws the hold texture + Swap + (swap interval already 0 under netplay — no vsync tax on resim throughput). +3. **`main.cpp` resim branch**: on a wall-clock period clamped to + `[8, 33] ms` (≈ `g_frame_period_ms`), call hold-last + `netplay_note_present()`. + Suspend GL interpolation for the episode (`set_interpolation_suspended(1)`) + so the interp thread cannot fight hold-last `SwapWindow` (A/B: GL+interp + buggy, GL without interp clean). Live restores FMV/game suspend policy. + SW path re-presents with the last Live `src`/`dst` rects (not `NULL,NULL` + on the full 640×512 texture — that produced the upper-left postage stamp). + VK left as no-op hold (status quo skip) for this cut. +4. Epilogue still skips the frame pacer during resim — catch-up stays fast; + only the present starvation is fixed. + +**Pass:** post-settle high-`replay%` windows should show `present_gap_p95` +near the frame period (~16–33ms) instead of tracking episode wall time. +Quiet Live tail unchanged. Re-soak GL+interp and SW; TipHold admit-stall +gaps (§32) are a separate follow-up. + +--- + +## 34. Double menu inputs — tip-hold quiet + scrub + debounce (2026-08-01) + +**Post-§33 soak (`rb-diag1/2`, LAN, software GPU, menu nav):** protocol +healthy enough (tip-extend live, quiet present gaps ~18ms) but **dpad taps +felt doubled** again. Audit arms showed fragmented digital pulses on the +wire (e.g. `ffbf` @1044, idle, `ffbf` @1048–50, idle, `ffbf` @1055, +tip-extend @1059) and tip-hold committing immediately between them. +`scrub-ahead` logged **0** times; releases during tip-hold hit +`begin_refused=1` then quiet-commit dropped the edge. + +**Root causes:** + +1. **TipHold quiet armed whenever `sim >= invent_cap`**, including through + 1-tick idle holes between controller bounce pulses (`QUIET_MAX=80ms`). +2. **`np_scrub_ahead_predicted` no-op'd under TipHold** (`sim <= release_tick` + while coalesce promoted release at `tip+N`). +3. **`begin_refused` while tip-holding** (tip-extend fail / different seat) + did not block quiet finalize — commit raced the unreabsorbed release. +4. **1–2 tick idle holes** published as real press/release/press edges; + MotK menus edge-trigger → multiple navigations. + +**Fixes:** + +- [x] Digital release debounce (2 sim ticks) in `psx_netplay_stage_local` + while rollback is on — bridges micro-bounce without lagging real taps. +- [x] Scrub-ahead end bound = `max(sim, tip+runway)` during TipHold. +- [x] TipHold quiet only while pads idle **and** no pending wire delta + `tip+1..runway` / `block_quiet`; held or pending resets the timer. + Quiet window **100–150ms** (was min 80 / max 80). +- [x] Thrash CAP suppressed while a wire edge is still pending (SAFETY + still wins). +- [x] `psx_netplay_rb_tip_hold_block_quiet` from coalesce/begin_refused; + different-seat tip-extend fail → tip-hold commit then fresh begin. + +**Re-soak checks:** one physical dpad tap → one cursor move; look for +`scrub-ahead release` during tip-hold coalesce; far fewer +`begin_refused=1` → immediate tip-hold commit pairs; tip-hold→commit +dwell should often exceed ~100ms on calm edges. + +--- + +## 35. TipHold SAFETY-while-held + hold-last on admit stall (2026-08-01) + +**Post-§34 soak:** doubles improved (`begin_refused=0`, chatter pulses +down) but **21/30 tip-holds hit SAFETY CAP 250ms**, driving +`present_gap_p95≈240ms` during menu play. Quiet almost never won: +`wire_pending_delta` treated future presses (tip already idle) as +pending, and SAFETY committed **while digital still held** → Live +key-repeat after the stall. TipHold invent-cap stalls admit with no +guest vblank, so §33 hold-last never ran. + +**Fixes:** + +- [x] SAFETY deferred while any pad held (log `SAFETY deferred`); commit + only on quiet/thrash when idle, or **ABSOLUTE 2000ms** stuck-pad + escape. `sim > tip_hold_until` also refuses commit while held. +- [x] Pending quiet-block = **unabsorbed release** only (tip held, wire + delta ahead). Future press while tip idle no longer parks quiet. + Stale `block_quiet` clears once idle + no pending release. +- [x] `netplay_hold_last_present_tick()` shared by resim present path and + TipHold admit-spin (`netplay_barrier_admit`) so SAFETY/held waits + keep Swap alive. Skip frame pacer while tip-holding. + +**Re-soak:** expect mostly quiet tip-hold commits (not SAFETY); +`SAFETY deferred` while holding then quiet after release; +`present_gap_p95` during menu not glued to 250ms; ABSOLUTE rare. + +--- + +## 36. TipHold held = live pad, not frozen sim peek (2026-08-02) + +**Post-§35 soak:** SAFETY deferred worked as coded — and every deferred +episode then hit **ABSOLUTE CAP 2000ms** (`held=1 pending=0`, 6/6 both +peers). Admit blew out (`admit≈988ms/f`, `1.0 fps`). Cause: invent-cap +freezes `sim`, `stage_local` latch freezes `staged`, and +`tip_hold_any_pad_held(sim)` peeked that one immutable delay-ring row — +a real controller release could never clear `held`, so quiet never armed +and ABSOLUTE was guaranteed. + +**Fixes:** + +- [x] `psx_netplay_stage_local` always refreshes a live physical snapshot + (even when latched). Admit spin captures while + `psx_netplay_rb_tip_holding()` so live keeps updating. +- [x] `psx_netplay_live_pad_buttons()` + `tip_hold_any_pad_held(tip)`: + local uses live buttons; remote uses latest arrived row in + `tip..tip+runway` (not parked `sim`). +- [x] Invent-cap stall in `try_admit` still keys off wire/staged (do not + invent-walk while the published tip pad is held — that was the + key-repeat class). Only wall-clock finalize uses live. + +**Re-soak:** `SAFETY deferred` then quiet/commit soon after release +(not ~2s ABSOLUTE); ABSOLUTE only for truly stuck pads; menu fps not +collapsing to ~1 on held tip-holds. diff --git a/lib/recomp-net b/lib/recomp-net index 8b31aad3..8fd500bc 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 8b31aad3a782e1294352e5618757b340974186fc +Subproject commit 8fd500bc2db22f095ac480490e63cefa72ca7379 diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index a12b653f..9304c669 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -49,6 +49,12 @@ void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linea /* Clear to black + swap (display-disabled frame). */ void gl_renderer_present_blank(void); +/* §33: re-present the last Live frame captured before Swap (or from a VRAM + * snapshot when interpolation owned the last present). Used during rollback + * resim so the window keeps a wall-clock present cadence without reading + * mid-resim VRAM. Returns 1 if a Swap happened, 0 if no hold is available. */ +int gl_renderer_present_hold_last(void); + /* Sync the authoritative FBO down to CPU VRAM if the GPU side is ahead (else * a no-op). Screenshots and the debug server call this before reading CPU * VRAM. Do NOT use before 24-bit (FMV) scanout — a full readback can clobber diff --git a/runtime/include/netplay_hash_confirm.h b/runtime/include/netplay_hash_confirm.h index fdd5b69b..3205565a 100644 --- a/runtime/include/netplay_hash_confirm.h +++ b/runtime/include/netplay_hash_confirm.h @@ -50,6 +50,10 @@ uint8_t netplay_hc_confirm_through(const NetplayHashConfirm* hc, uint32_t tick) uint8_t netplay_hc_local_digest(const NetplayHashConfirm* hc, uint32_t tick, uint32_t* digest_out); +/* Peek peer FRAME_COMMIT digest for tick; returns 0 if missing. */ +uint8_t netplay_hc_peer_digest(const NetplayHashConfirm* hc, uint32_t tick, + uint32_t* digest_out); + /* 1 if the next tick after resolved_through has both digests but they differ. * Fills tick/local/peer when non-NULL. Used to log first live core fork. */ uint8_t netplay_hc_peek_mismatch(const NetplayHashConfirm* hc, uint32_t* tick_out, diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index 2234f3b5..cd166198 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -116,12 +116,17 @@ int psx_netplay_in_load_barrier(void); * Clears. Caller should soft-exit to lobby — do not keep waiting on the barrier. */ int psx_netplay_consume_load_apply_failed(void); -/* Stage local pad for the current sim tick. Ignored once that tick is latched. */ +/* Stage local pad for the current sim tick. Ignored once that tick is latched. + * Always refreshes the live physical snapshot (see live_pad_buttons). */ void psx_netplay_stage_local(const PsxNetPad *pad); /* 1 while linking or before this sim tick's local pad is latched. */ int psx_netplay_needs_local_sample(void); +/* Latest physical local buttons (0xFFFF idle). TipHold SAFETY/quiet must use + * this — staged/delay-ring peeks stay frozen while sim is invent-capped. */ +int psx_netplay_live_pad_buttons(uint16_t *out); + /* 1 if INPUT_CONFIRM hash disagreement stalled the session. */ int psx_netplay_input_desync(uint32_t *tick, uint32_t *local_hash, uint32_t *remote_hash); @@ -154,6 +159,10 @@ int psx_netplay_remote_lead(void); /* Session input delay frames (default 2 when inactive). */ int psx_netplay_input_delay(void); +/* Clear timesync pegged-streak at episode/tip-hold boundaries so resim cost + * cannot look like WAN transit and trip adaptive-off (see §22). */ +void psx_netplay_timesync_on_episode_boundary(void); + /* * Extra headroom for post-starvation / behind-peer catch-up * (min(16, max(0, remote_lead - D, recovery_burst))). diff --git a/runtime/include/psx_netplay_rb.h b/runtime/include/psx_netplay_rb.h index 6ba73f79..7c73edd1 100644 --- a/runtime/include/psx_netplay_rb.h +++ b/runtime/include/psx_netplay_rb.h @@ -71,14 +71,15 @@ 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. */ +/* 1 during depth24 / recent MDEC / short post-FMV settle — MotK FMV. + * Post-FMV digest lockstep no longer blocks invent (§26). */ int psx_netplay_rb_fmv_defer_rewind(void); /* 1 while depth24 or recent MDEC (not settle). */ int psx_netplay_rb_fmv_media_active(void); -/* 1 during FMV media + post-FMV lockstep (+ unlock grace) — admit waits for - * remote wire (title Start / movie skip). Ticks the FMV→settle tracker. */ +/* 1 during FMV media + short settle (§26) — admit waits for remote wire. + * Post-FMV digest lockstep no longer blocks invent. Ticks FMV→settle. */ int psx_netplay_rb_lockstep_no_invent(void); /* Mid-guest resim pump: abort if Replay has made no finish_frame progress. @@ -107,6 +108,10 @@ uint32_t psx_netplay_rb_tip_hold_invent_slack(void); * release edges when Live is stalled at invent-cap. */ uint32_t psx_netplay_rb_tip_runway(void); +/* §34: while TipHold cannot tip-extend/begin an already-seen wire edge, + * block wall-clock quiet finalize (release must not be dropped by commit). */ +void psx_netplay_rb_tip_hold_block_quiet(int block); + /* 1 if a peer FRAME_COMMIT should be dropped (TipHold invent still on the * wire after tip-extend hc_prime). Call from FRAME_COMMIT drain. */ int psx_netplay_rb_ignore_peer_frame_commit(uint32_t tick, uint32_t hash); diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 7e8ef584..91a545e3 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -619,6 +619,20 @@ static int s_last_dx, s_last_dy, s_last_dw, s_last_dh; * (and FPS) keep advancing — especially on a 2nd+ load of the same slot. */ static int s_force_present_remaining = 0; +/* §33 rollback resim hold-last: sticky copy of the last Live present so + * mid-resim ticks can Swap a frozen frame without reading mutating VRAM. + * HOLD_DRAWABLE = full backbuffer copy taken just before SwapWindow. + * HOLD_NATIVE = GPU blit of a display band (used when interp owns Swap). */ +#define HOLD_NONE 0 +#define HOLD_DRAWABLE 1 +#define HOLD_NATIVE 2 +static int s_hold_kind = HOLD_NONE; +static GLuint s_hold_tex = 0; +static GLuint s_hold_fbo = 0; +static int s_hold_tw = 0, s_hold_th = 0; /* texture size in texels */ +static int s_hold_force_4_3 = 0; +static int s_hold_linear = 0; + /* Post-load freeze probe (main.cpp): accumulate skip/swap/dirty marks. */ static uint64_t s_probe_skip = 0; static uint64_t s_probe_swap = 0; @@ -652,6 +666,83 @@ static void present_force_consumed(void) { s_force_present_remaining--; } +static void hold_ensure_tex(int w, int h) { + if (w < 1) w = 1; + if (h < 1) h = 1; + if (!s_hold_tex) { + glGenTextures(1, &s_hold_tex); + if (!s_hold_fbo) + p_glGenFramebuffers(1, &s_hold_fbo); + } + if (s_hold_tw == w && s_hold_th == h) + return; + glBindTexture(GL_TEXTURE_2D, s_hold_tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, + NULL); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hold_fbo); + p_glFramebufferTexture2D(PSXGL_FRAMEBUFFER, PSXGL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, s_hold_tex, 0); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); + s_hold_tw = w; + s_hold_th = h; +} + +/* Snapshot the just-drawn default backbuffer (letterbox + content) before Swap. + * Hold-last can then redraw this exact image without touching guest VRAM. */ +static void hold_capture_drawable(void) { + int ww = 0, wh = 0; + if (!s_ctx || !s_win) + return; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + if (ww < 1 || wh < 1) + return; + hold_ensure_tex(ww, wh); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + glBindTexture(GL_TEXTURE_2D, s_hold_tex); + glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, ww, wh); + glBindTexture(GL_TEXTURE_2D, 0); + s_hold_kind = HOLD_DRAWABLE; + s_hold_force_4_3 = 0; + s_hold_linear = 0; +} + +/* Snapshot a native display band from an FBO when the main thread will not + * Swap (interpolation owns the cadence). Scale-aware blit into hold tex. */ +static void hold_capture_native_fbo(GLuint src_fbo, int dx, int dy, int dw, int dh, + int force_4_3, int linear) { + int S = s_scale > 0 ? s_scale : 1; + if (!s_ctx || !src_fbo || dw < 1 || dh < 1) + return; + hold_ensure_tex(dw * S, dh * S); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, src_fbo); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_hold_fbo); + glDisable(GL_SCISSOR_TEST); + p_glBlitFramebuffer(dx * S, dy * S, (dx + dw) * S, (dy + dh) * S, + 0, 0, dw * S, dh * S, + GL_COLOR_BUFFER_BIT, GL_NEAREST); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); + s_hold_kind = HOLD_NATIVE; + s_hold_force_4_3 = force_4_3 ? 1 : 0; + s_hold_linear = linear ? 1 : 0; +} + +static void hold_invalidate(void) { + s_hold_kind = HOLD_NONE; +} + +/* Defined after present_target_quad / letterbox helpers. */ +static void letterbox_rect_aspect(int ww, int wh, int num, int den, + int *x, int *y, int *w, int *h); +static void letterbox_rect(int ww, int wh, int *x, int *y, int *w, int *h); +static void present_target_quad(GLuint tex, int tex_w, int tex_h, + int x, int y, int w, int h, int linear, + int lx, int ly, int lw, int lh); + static void coh_record(int kind, int x0, int y0, int x1, int y1) { GlCohEvent *e = &s_coh_ring[s_coh_seq % GL_COH_RING_CAP]; e->frame = (uint32_t)s_frame_count; @@ -2592,6 +2683,11 @@ void gl_renderer_shutdown(void) { s_present_h = 0; s_depth24_skip_up = 0; rect_clear(&s_d24_skip_fb); + hold_invalidate(); + s_hold_tex = 0; + s_hold_fbo = 0; + s_hold_tw = 0; + s_hold_th = 0; } /* CPU-readout present (24-bit FMV frames and the PSX_GL_FORCE_CPU_PRESENT @@ -2659,6 +2755,7 @@ void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linea p_glBindVertexArray(s_present_vao); glDrawArrays(GL_TRIANGLES, 0, 3); p_glBindVertexArray(0); p_glUseProgram(0); pres_record(GL_PRES_CPU, 0, 0, src_w, src_h, lx, ly, lw, lh); + hold_capture_drawable(); latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); @@ -2674,6 +2771,7 @@ void gl_renderer_present_blank(void) { glDisable(GL_SCISSOR_TEST); glViewport(0, 0, ww, wh); glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); pres_record(GL_PRES_BLANK, 0, 0, 0, 0, 0, 0, ww, wh); + hold_capture_drawable(); latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); @@ -2692,6 +2790,7 @@ void gl_renderer_invalidate_present(void) { for (int i = 0; i < PRES_ROWS; i++) s_present_dirty[i] = ~0ull; s_last_present_path = -1; s_force_present_remaining = 8; + hold_invalidate(); interp_reset_history(); } @@ -3635,6 +3734,63 @@ static void present_target_quad(GLuint tex, int tex_w, int tex_h, p_glUseProgram(0); } +int gl_renderer_present_hold_last(void) { + int ww = 0, wh = 0; + int lx, ly, lw, lh; + if (!s_ctx || !s_win || s_hold_kind == HOLD_NONE || !s_hold_tex) + return 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + if (ww < 1 || wh < 1) + return 0; + glDisable(GL_SCISSOR_TEST); + glViewport(0, 0, ww, wh); + glClearColor(0.f, 0.f, 0.f, 1.f); + glClear(GL_COLOR_BUFFER_BIT); + if (s_hold_kind == HOLD_DRAWABLE) { + /* Captured image already includes letterbox bars. Stretch 1:1, or + * letterbox to preserve aspect if the drawable resized mid-resim. */ + lx = 0; + ly = 0; + lw = ww; + lh = wh; + if (s_hold_tw > 0 && s_hold_th > 0 && + ((int64_t)s_hold_tw * wh != (int64_t)s_hold_th * ww)) { + if ((int64_t)ww * s_hold_th > (int64_t)wh * s_hold_tw) { + lw = (int)(((int64_t)wh * s_hold_tw) / s_hold_th); + if (lw < 1) lw = 1; + lx = (ww - lw) / 2; + lh = wh; + ly = 0; + } else { + lh = (int)(((int64_t)ww * s_hold_th) / s_hold_tw); + if (lh < 1) lh = 1; + ly = (wh - lh) / 2; + lw = ww; + lx = 0; + } + } + present_target_quad(s_hold_tex, s_hold_tw, s_hold_th, + 0, 0, s_hold_tw, s_hold_th, 0, lx, ly, lw, lh); + } else { + if (s_hold_force_4_3) + letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); + else + letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); + present_target_quad(s_hold_tex, s_hold_tw, s_hold_th, + 0, 0, s_hold_tw, s_hold_th, s_hold_linear, + lx, ly, lw, lh); + /* Upgrade to drawable after letterboxing once. Live+interp leaves + * HOLD_NATIVE (no Swap on main); drawable path is the soak-proven + * full-window image (GL without interp). */ + hold_capture_drawable(); + } + latency_ring_mark(LAT_SWAP_BEGIN); + SDL_GL_SwapWindow(s_win); + latency_ring_mark(LAT_SWAP_END); + s_probe_swap++; + return 1; +} + void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, int force_4_3) { if (!s_ctx || !s_raster_ok) return; @@ -3671,6 +3827,9 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, int interp_pair = interp_capture(s_hr_fbo, disp_x, disp_y, w, h, linear, force_4_3, GL_PRES_VRAM); if (interp_pair) { + /* Interp owns Swap — still snapshot the band so resim hold-last has + * a frozen frame if an episode opens before the next main-thread Swap. */ + hold_capture_native_fbo(s_hr_fbo, disp_x, disp_y, w, h, force_4_3, linear); gl_perf_present_exit(0); present_dirty_rect(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1, 0); present_force_consumed(); @@ -3681,6 +3840,7 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, present_target_quad(s_hr_tex, VRAM_W, VRAM_H, disp_x, disp_y, w, h, linear, lx, ly, lw, lh); pres_record(GL_PRES_VRAM, disp_x, disp_y, w, h, lx, ly, lw, lh); + hold_capture_drawable(); latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); @@ -3768,6 +3928,7 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) int interp_pair = interp_capture(fbo, 0, disp_y, g_wide_w, disp_h, linear, 0, GL_PRES_WIDE); if (interp_pair) { + hold_capture_native_fbo(fbo, 0, disp_y, g_wide_w, disp_h, 0, linear); gl_perf_present_exit(1); present_dirty_rect(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1, 0); present_force_consumed(); @@ -3779,6 +3940,7 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) present_target_quad(tex, g_wide_w, VRAM_H, 0, disp_y, g_wide_w, disp_h, linear, lx, ly, lw, lh); pres_record(GL_PRES_WIDE, disp_x, disp_y, g_wide_w, disp_h, lx, ly, lw, lh); + hold_capture_drawable(); latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index d9ba1afa..45c3f22d 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -361,6 +361,12 @@ static Smooth60State g_smooth_60_state; * survive soft-return and poison FMV/FPS after session_reboot). */ static bool s_disabled_frame_presented = false; static bool s_force_present_after_load = false; +/* §33 SW hold-last: sdl_texture is 640x512; Live only uploads the active + * display rect. Resim must reuse that src/dst — RenderCopy(NULL,NULL) sticks + * the image in the upper-left corner (user-confirmed). */ +static int s_sw_hold_valid = 0; +static SDL_Rect s_sw_hold_src; +static SDL_Rect s_sw_hold_dst; /* After LOADED: optional freeze probe (PSX_POST_LOAD_PROBE=1). Off by default. */ static int s_post_load_probe_enabled = -1; /* -1 = unread env */ static int s_post_load_probe_left = 0; @@ -447,6 +453,7 @@ static void smooth_60_reset(void) { static void present_session_reset(void) { s_disabled_frame_presented = false; s_force_present_after_load = false; + s_sw_hold_valid = 0; s_fps_last_time = 0; s_fps_last_frame = 0; s_fps_base_title.clear(); @@ -3448,7 +3455,10 @@ static uint64_t s_np_admit_ticks = 0; static uint64_t s_np_guest_ticks = 0; static uint64_t s_np_last_admit_end = 0; static uint64_t s_np_timing_frames = 0; - +/* §27: wall-clock gaps between successful presents (player-visible starvation). */ +static uint64_t s_present_last_ms = 0; +static uint32_t s_present_gaps_ms[128]; +static unsigned s_present_gaps_n = 0; static int netplay_timing_on(void) { if (s_np_timing_enabled < 0) { const char *e = std::getenv("PSX_NETPLAY_TIMING"); @@ -3457,6 +3467,37 @@ static int netplay_timing_on(void) { return s_np_timing_enabled; } +static void netplay_note_present(void); +static void netplay_hold_last_present_tick(void); + +static void netplay_note_present(void) { + uint64_t now; + uint32_t gap; + if (!psx_netplay_active() || !netplay_timing_on()) + return; + now = SDL_GetTicks64(); + if (s_present_last_ms != 0ull && now >= s_present_last_ms) { + gap = (uint32_t)(now - s_present_last_ms); + if (s_present_gaps_n < (unsigned)(sizeof(s_present_gaps_ms) / sizeof(s_present_gaps_ms[0]))) + s_present_gaps_ms[s_present_gaps_n++] = gap; + } + s_present_last_ms = now ? now : 1ull; +} + +static void netplay_present_gap_stats(uint32_t *p95_out, uint32_t *max_out) { + unsigned n = s_present_gaps_n; + unsigned idx; + if (p95_out) *p95_out = 0; + if (max_out) *max_out = 0; + if (n == 0) + return; + std::sort(s_present_gaps_ms, s_present_gaps_ms + n); + idx = (n > 1u) ? (unsigned)((95u * (n - 1u)) / 100u) : 0u; + if (p95_out) *p95_out = s_present_gaps_ms[idx]; + if (max_out) *max_out = s_present_gaps_ms[n - 1u]; + s_present_gaps_n = 0; +} + /* Stage local pad + poll admit until published. Parks the guest fiber when * called from the vblank callback (or before scheduler entry). Latches one * sample per sim tick; stalls on INPUT_CONFIRM desync. */ @@ -3547,7 +3588,10 @@ static void netplay_barrier_admit(int override) { #endif continue; } - if (psx_netplay_needs_local_sample()) { + /* §36: TipHold parks sim, so needs_local_sample stays 0 after latch. + * Still capture every spin so live_pad_buttons can see a real release + * (SAFETY deferred must not ride ABSOLUTE 2000ms on a frozen peek). */ + if (psx_netplay_needs_local_sample() || psx_netplay_rb_tip_holding()) { PsxNetPad local{}; if (override >= 0 && !g_headless) { capture_override_pad(override, &local); @@ -3603,6 +3647,11 @@ static void netplay_barrier_admit(int override) { SDL_GameControllerUpdate(); } if (psx_return_to_lobby_requested()) return; + /* §35: TipHold invent-cap stall freezes guest (no vblank present). + * Keep Swap alive on the last Live frame so SAFETY/held waits do not + * open a ~250ms present gap. */ + if (psx_netplay_rb_tip_holding()) + netplay_hold_last_present_tick(); /* Wake on peer UDP (or 1ms timeout). SDL_Delay(1) under dual FMV load * often stretches multi-ms and cut MotK netplay intro ~59→~36; Delay(0) * busy-spins and can starve the peer (tick-0 hang). */ @@ -3683,6 +3732,41 @@ static constexpr double PSX_FRAME_PERIOD_MS = 1000.0 / 59.94; * the sim at the wrong speed. */ static double g_frame_period_ms = PSX_FRAME_PERIOD_MS; +/* §33/§35: re-present last Live frame on a wall-clock cadence while guest + * sim is frozen (resim) or TipHold invent-cap stall (admit spin). */ +static void netplay_hold_last_present_tick(void) { + static uint64_t s_hold_last_ms; + uint64_t now = SDL_GetTicks64(); + uint32_t period = (uint32_t)(g_frame_period_ms + 0.5); + int did = 0; + if (period < 8u) + period = 8u; + if (period > 33u) + period = 33u; +#ifndef PSX_SDL_NO_RENDER + if (g_gl_active) + gl_renderer_set_interpolation_suspended(1); +#endif + if (s_hold_last_ms != 0ull && now >= s_hold_last_ms && + (uint32_t)(now - s_hold_last_ms) < period) + return; +#ifndef PSX_SDL_NO_RENDER + if (g_gl_active) { + did = gl_renderer_present_hold_last(); + } else if (!g_vk_active && sdl_renderer && sdl_texture && s_sw_hold_valid) { + SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255); + SDL_RenderClear(sdl_renderer); + SDL_RenderCopy(sdl_renderer, sdl_texture, &s_sw_hold_src, &s_sw_hold_dst); + SDL_RenderPresent(sdl_renderer); + did = 1; + } +#endif + if (did) { + netplay_note_present(); + s_hold_last_ms = now ? now : 1ull; + } +} + /* ── Host-stack-usage profile (RECURSION_BUG.md §17) ────────────────────────── * The decisive instrument for the long-run freeze. The guest call graph mirrors * onto the 1MB guest fiber stack (traps.c), and the native-stack guard trips at @@ -3974,11 +4058,17 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { const uint64_t replay_ticks = psx_netplay_rb_take_replay_ticks(); const double replay_pct = 100.0 * (double)replay_ticks / (double)s_np_timing_frames; + uint32_t gap_p95 = 0, gap_max = 0; + /* §27: presentation starvation — eye cares about gaps more + * than replay%. Pass: post-settle present_gap_p95 ≤ 33ms. */ + netplay_present_gap_stats(&gap_p95, &gap_max); std::fprintf(stderr, "[FPS] game: %.1f fps (%.2fx) | frames: %llu | " - "guest=%.2f ms/f admit=%.2f ms/f replay=%.0f%% (n=%llu)\n", + "guest=%.2f ms/f admit=%.2f ms/f replay=%.0f%% " + "present_gap_p95=%u ms max=%u ms (n=%llu)\n", fps, speed, (unsigned long long)s_frame_count, guest_ms, admit_ms, replay_pct, + (unsigned)gap_p95, (unsigned)gap_max, (unsigned long long)s_np_timing_frames); s_np_admit_ticks = 0; s_np_guest_ticks = 0; @@ -4399,6 +4489,16 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { } } + /* Rollback resim / TipHold stall (§33/§35): presentation is host-only. + * Resim skips Swap; TipHold invent-cap stalls admit (no guest vblank) — + * both starved present_gap. Hold-last on wall-clock cadence. TipHold + * also presents from the admit spin (no vblank while stalled). + * Suspend GL interp so it cannot fight hold-last SwapWindow. */ + if (psx_netplay_is_resimulating()) { + netplay_hold_last_present_tick(); + return ep; + } + /* ---- Display from our VRAM ---- */ probe_reached = 1; uint32_t w = 0, h = 0; @@ -4507,12 +4607,15 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * SW stayed smooth). Falls through to the CPU readout path only if * the wide surface for this buffer doesn't exist yet. */ if (gl_renderer_present_wide_fbo((int)di.display_x, (int)di.display_y, - (int)h, g_video_aa ? 1 : 0)) + (int)h, g_video_aa ? 1 : 0)) { + netplay_note_present(); return ep; + } } else { gl_renderer_present_vram((int)di.display_x, (int)di.display_y, (int)present_w, (int)h, g_video_aa ? 1 : 0, (fmv_frame || nw_pin) ? 1 : 0); + netplay_note_present(); return ep; } } @@ -4545,6 +4648,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { (int)present_w, (int)h, g_video_aa ? 1 : 0, (fmv_frame || nw_pin) ? 1 : 0); } + netplay_note_present(); return ep; } #endif @@ -4673,6 +4777,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { gl_renderer_present(sdl_pixel_buf, src_w, src_h, (g_video_aa && !depth24_frame) ? 1 : 0, pin_43 ? 1 : 0, 0 /* full width */); + netplay_note_present(); } else { if ((!sdl_renderer || !sdl_texture) && ensure_sw_sdl_present() != 0) return ep; @@ -4729,6 +4834,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255); SDL_RenderClear(sdl_renderer); SDL_RenderCopy(sdl_renderer, sdl_texture, &src, &dst); + /* §33: remember active rect for resim hold-last (not full 640x512). */ + s_sw_hold_src = src; + s_sw_hold_dst = dst; + s_sw_hold_valid = 1; /* Vsync self-heal. The renderer is created with PRESENTVSYNC for * tear-free output, but the wall-clock pacer above already holds @@ -4745,6 +4854,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { SDL_RenderPresent(sdl_renderer); const Uint64 t1 = SDL_GetPerformanceCounter(); latency_ring_mark(LAT_SWAP_END); + netplay_note_present(); const Uint64 freq = SDL_GetPerformanceFrequency(); const Uint64 present_ms = (t1 >= t0 && freq) ? ((t1 - t0) * 1000u) / freq : 0; if (!g_present_vsync_disabled && present_ms > 250) { @@ -4775,7 +4885,7 @@ static void sdl_vblank_present(void) { psx_netplay_rb_flush_resume(); if (ep.skip_pace || psx_return_to_lobby_requested()) return; - if (psx_netplay_is_resimulating()) + if (psx_netplay_is_resimulating() || psx_netplay_rb_tip_holding()) return; if (!gpu_display_is_depth24() && psx_netplay_catchup_budget() > 0) { psx_netplay_catchup_consume_frame(); @@ -4992,6 +5102,9 @@ namespace { int g_lnch_lan_my_slot = -1; std::string g_lnch_lan_player_id; /* local process identity for LAN seats */ + static int ae_np_lan_occupied(const AeLanLobbyState& state); + static int ae_np_lan_endpoint_port(const std::string& endpoint); + #ifdef _WIN32 using AeLanSock = SOCKET; static constexpr AeLanSock kAeLanSockInvalid = INVALID_SOCKET; @@ -4999,6 +5112,8 @@ namespace { using AeLanSock = int; static constexpr AeLanSock kAeLanSockInvalid = -1; #endif + static void ae_np_lan_sock_close(AeLanSock* s); + static bool ae_np_lan_set_nonblock(AeLanSock s); AeLanSock g_lnch_lan_udp = kAeLanSockInvalid; sockaddr_in g_lnch_lan_peers[kAeLanMaxSlots]{}; bool g_lnch_lan_peer_ok[kAeLanMaxSlots]{}; @@ -5006,10 +5121,262 @@ namespace { /* LAN list latency from last Refresh probe; -1 unknown. */ int g_lnch_lan_latency_ms = -1; + /* Cross-machine LAN lobby discovery (UDP broadcast browse/beacon). + * The on-disk netplay_lan_lobby.txt registry only works for same-cwd / + * same-machine; remote peers discover hosts via MOTK1 BROWSE→BEACON. */ + static constexpr int kAeLanDiscoverMax = 16; + static constexpr int kAeLanBrowsePortLo = 7777; + static constexpr int kAeLanBrowsePortHi = 7808; /* inclusive */ + static constexpr uint32_t kAeLanDiscoverStaleMs = 8000u; + struct AeLanDiscovered { + char endpoint[64]; + char name[64]; + char game[64]; + int player_count = 0; + int max_slots = 2; + int has_password = 0; + int latency_ms = -1; + int input_delay = 2; + int input_prediction = 4; + int rollback = 1; + uint32_t session_id = 1; + uint32_t last_seen_ms = 0; + }; + static AeLanDiscovered g_lnch_lan_discovered[kAeLanDiscoverMax]; + static int g_lnch_lan_discovered_n = 0; + static uint32_t g_lnch_lan_beacon_announce_ms = 0; + std::filesystem::path ae_np_lan_file() { return std::filesystem::current_path() / "netplay_lan_lobby.txt"; } + static void ae_np_lan_sock_set_broadcast(AeLanSock s) { + if (s == kAeLanSockInvalid) return; +#ifdef _WIN32 + BOOL on = TRUE; + setsockopt(s, SOL_SOCKET, SO_BROADCAST, (const char*)&on, sizeof(on)); +#else + int on = 1; + setsockopt(s, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on)); +#endif + } + + static void ae_np_lan_prune_discovered(void) { + const uint32_t now = SDL_GetTicks(); + int w = 0; + for (int i = 0; i < g_lnch_lan_discovered_n; ++i) { + if ((uint32_t)(now - g_lnch_lan_discovered[i].last_seen_ms) > + kAeLanDiscoverStaleMs) + continue; + if (w != i) + g_lnch_lan_discovered[w] = g_lnch_lan_discovered[i]; + ++w; + } + g_lnch_lan_discovered_n = w; + } + + static int ae_np_lan_discovered_find(const char* endpoint) { + if (!endpoint || !endpoint[0]) return -1; + for (int i = 0; i < g_lnch_lan_discovered_n; ++i) { + if (std::strcmp(g_lnch_lan_discovered[i].endpoint, endpoint) == 0) + return i; + } + return -1; + } + + static void ae_np_lan_discovered_upsert(const AeLanDiscovered& in) { + if (!in.endpoint[0]) return; + const int idx = ae_np_lan_discovered_find(in.endpoint); + if (idx >= 0) { + AeLanDiscovered& e = g_lnch_lan_discovered[idx]; + e = in; + return; + } + if (g_lnch_lan_discovered_n >= kAeLanDiscoverMax) { + /* Drop oldest. */ + int oldest = 0; + for (int i = 1; i < g_lnch_lan_discovered_n; ++i) { + if (g_lnch_lan_discovered[i].last_seen_ms < + g_lnch_lan_discovered[oldest].last_seen_ms) + oldest = i; + } + g_lnch_lan_discovered[oldest] = in; + return; + } + g_lnch_lan_discovered[g_lnch_lan_discovered_n++] = in; + } + + static int ae_np_lan_format_beacon(char* buf, size_t cap, + const AeLanLobbyState& st) { + if (!buf || cap < 32) return -1; + const int players = ae_np_lan_occupied(st); + const int max_slots = st.max_slots >= 2 ? st.max_slots : 2; + const int has_pw = st.password.empty() ? 0 : 1; + const uint32_t sid = st.session_id ? st.session_id : 1u; + int delay = g_lnch_lobby_input_delay; + int pred = g_lnch_lobby_input_prediction; + if (delay < 2) delay = 2; + if (delay > 20) delay = 20; + if (pred < 2) pred = 2; + if (pred > 16) pred = 16; + return std::snprintf( + buf, cap, + "MOTK1 BEACON\n%s\n%s\n%s\n%d\n%d\n%d\n%u\n%d\n%d\n%d\n", + st.name.empty() ? "LAN Lobby" : st.name.c_str(), + st.game.empty() ? "PSX" : st.game.c_str(), + st.endpoint.c_str(), + players, max_slots, has_pw, (unsigned)sid, + delay, pred, g_lnch_rollback ? 1 : 0); + } + + /* Parse BEACON; prefer reply source IP + advertised port for joinability. */ + static int ae_np_lan_parse_beacon(char* buf, const sockaddr_in* from, + int rtt_ms) { + if (!buf || std::strncmp(buf, "MOTK1 BEACON\n", 13) != 0) return -1; + char* p = buf + 13; + char* lines[10] = {}; + for (int i = 0; i < 10; ++i) { + lines[i] = p; + char* nl = std::strchr(p, '\n'); + if (!nl) { + if (i < 6) return -1; + break; + } + *nl = '\0'; + p = nl + 1; + } + if (!lines[0] || !lines[1] || !lines[2]) return -1; + + AeLanDiscovered d{}; + std::snprintf(d.name, sizeof(d.name), "%s", lines[0]); + std::snprintf(d.game, sizeof(d.game), "%s", lines[1]); + const int adv_port = ae_np_lan_endpoint_port(lines[2]); + int port = adv_port > 0 ? adv_port : 7777; + if (from) { + char ip[64]; + if (inet_ntop(AF_INET, &from->sin_addr, ip, sizeof(ip))) + std::snprintf(d.endpoint, sizeof(d.endpoint), "%s:%d", ip, port); + else + std::snprintf(d.endpoint, sizeof(d.endpoint), "%s", lines[2]); + } else { + std::snprintf(d.endpoint, sizeof(d.endpoint), "%s", lines[2]); + } + d.player_count = lines[3] ? std::atoi(lines[3]) : 0; + d.max_slots = lines[4] ? std::atoi(lines[4]) : 2; + if (d.max_slots < 2) d.max_slots = 2; + if (d.max_slots > kAeLanMaxSlots) d.max_slots = kAeLanMaxSlots; + d.has_password = lines[5] ? (std::atoi(lines[5]) != 0) : 0; + d.session_id = lines[6] + ? (uint32_t)std::strtoul(lines[6], nullptr, 10) : 1u; + if (!d.session_id) d.session_id = 1u; + d.input_delay = lines[7] ? std::atoi(lines[7]) : 2; + d.input_prediction = lines[8] ? std::atoi(lines[8]) : 4; + d.rollback = lines[9] ? (std::atoi(lines[9]) != 0) : 1; + if (d.input_delay < 2) d.input_delay = 2; + if (d.input_delay > 20) d.input_delay = 20; + if (d.input_prediction < 2) d.input_prediction = 2; + if (d.input_prediction > 16) d.input_prediction = 16; + d.latency_ms = rtt_ms; + d.last_seen_ms = SDL_GetTicks(); + ae_np_lan_discovered_upsert(d); + return 0; + } + + static void ae_np_lan_broadcast_msg(AeLanSock s, int port, const char* msg) { + if (s == kAeLanSockInvalid || !msg || port <= 0 || port > 65535) return; + sockaddr_in to{}; + to.sin_family = AF_INET; + to.sin_port = htons((uint16_t)port); + to.sin_addr.s_addr = htonl(INADDR_BROADCAST); + const int n = (int)std::strlen(msg); +#ifdef _WIN32 + sendto(s, msg, n, 0, (const sockaddr*)&to, sizeof(to)); +#else + sendto(s, msg, (size_t)n, 0, (const sockaddr*)&to, sizeof(to)); +#endif + } + + /* Sync browse: broadcast BROWSE across the common lobby port range and + * collect BEACON replies for wait_ms. */ + static void ae_np_lan_browse(uint32_t wait_ms) { +#ifdef _WIN32 + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); +#endif + AeLanSock s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (s == kAeLanSockInvalid) return; + if (!ae_np_lan_set_nonblock(s)) { + ae_np_lan_sock_close(&s); + return; + } + ae_np_lan_sock_set_broadcast(s); + sockaddr_in bind_addr{}; + bind_addr.sin_family = AF_INET; + bind_addr.sin_addr.s_addr = htonl(INADDR_ANY); + bind_addr.sin_port = htons(0); + if (bind(s, (sockaddr*)&bind_addr, sizeof(bind_addr)) != 0) { + ae_np_lan_sock_close(&s); + return; + } + + const char browse[] = "MOTK1 BROWSE\n"; + const uint32_t t0 = SDL_GetTicks(); + for (int port = kAeLanBrowsePortLo; port <= kAeLanBrowsePortHi; ++port) + ae_np_lan_broadcast_msg(s, port, browse); + + const uint32_t deadline = t0 + (wait_ms ? wait_ms : 250u); + while ((int32_t)(deadline - SDL_GetTicks()) > 0) { + char buf[1024]; + sockaddr_in from{}; +#ifdef _WIN32 + int fromlen = (int)sizeof(from); + const int n = recvfrom(s, buf, (int)sizeof(buf) - 1, 0, + (sockaddr*)&from, &fromlen); +#else + socklen_t fromlen = sizeof(from); + const int n = (int)recvfrom(s, buf, sizeof(buf) - 1, 0, + (sockaddr*)&from, &fromlen); +#endif + if (n > 0) { + buf[n] = '\0'; + const int rtt = (int)(SDL_GetTicks() - t0); + (void)ae_np_lan_parse_beacon(buf, &from, rtt); + } else { + SDL_Delay(5); + } + } + ae_np_lan_sock_close(&s); + ae_np_lan_prune_discovered(); + } + + static void ae_np_lan_apply_discovered_caps(const char* endpoint) { + const int idx = ae_np_lan_discovered_find(endpoint); + if (idx < 0) return; + const AeLanDiscovered& d = g_lnch_lan_discovered[idx]; + g_lnch_lobby_input_delay = d.input_delay; + g_lnch_lobby_input_prediction = d.input_prediction; + g_lnch_rollback = d.rollback ? 1 : 0; + } + + static int ae_np_lan_fill_lobby_from_discovered( + int discovered_index, RecompLauncherCNetplayLobby* out) { + if (!out || discovered_index < 0 || + discovered_index >= g_lnch_lan_discovered_n) + return 0; + const AeLanDiscovered& d = g_lnch_lan_discovered[discovered_index]; + std::snprintf(out->lobby_id, sizeof(out->lobby_id), "lan:%s", d.endpoint); + std::snprintf(out->name, sizeof(out->name), "LAN - %s", + d.name[0] ? d.name : "Lobby"); + std::snprintf(out->game_name, sizeof(out->game_name), "%s", + d.game[0] ? d.game : "PSX"); + out->game_version[0] = '\0'; + out->player_count = d.player_count; + out->max_slots = d.max_slots; + out->has_password = d.has_password; + out->latency_ms = d.latency_ms; + return 1; + } + static void ae_np_lan_sock_close(AeLanSock* s) { if (!s || *s == kAeLanSockInvalid) return; #ifdef _WIN32 @@ -5727,9 +6094,36 @@ namespace { static void ae_np_lan_rescan(void) { if (g_lnch_hosting_lan) { g_lnch_lan_latency_ms = 0; + /* Still browse so the host list can show other LAN rooms. */ + ae_np_lan_browse(200u); + AeLanLobbyState self{}; + if (ae_np_read_lan_file_state(&self) && !self.started) { + AeLanDiscovered d{}; + std::snprintf(d.endpoint, sizeof(d.endpoint), "%s", + self.endpoint.c_str()); + std::snprintf(d.name, sizeof(d.name), "%s", + self.name.empty() ? "LAN Lobby" : self.name.c_str()); + std::snprintf(d.game, sizeof(d.game), "%s", + self.game.empty() ? "PSX" : self.game.c_str()); + d.player_count = ae_np_lan_occupied(self); + d.max_slots = self.max_slots >= 2 ? self.max_slots : 2; + d.has_password = self.password.empty() ? 0 : 1; + d.latency_ms = 0; + d.input_delay = g_lnch_lobby_input_delay; + d.input_prediction = g_lnch_lobby_input_prediction; + d.rollback = g_lnch_rollback ? 1 : 0; + d.session_id = self.session_id ? self.session_id : 1u; + d.last_seen_ms = SDL_GetTicks(); + ae_np_lan_discovered_upsert(d); + } return; } if (g_lnch_joined_lan) return; + + /* Cross-machine discovery: broadcast BROWSE, collect BEACONs. */ + ae_np_lan_browse(300u); + + /* Legacy same-machine file registry (shared cwd). */ AeLanLobbyState st; if (!ae_np_read_lan_file_state(&st)) { g_lnch_lan_latency_ms = -1; @@ -5740,6 +6134,24 @@ namespace { if (g_lnch_lan_latency_ms < 0) { std::error_code ec; std::filesystem::remove(ae_np_lan_file(), ec); + } else if (ae_np_lan_discovered_find(st.endpoint.c_str()) < 0) { + /* Promote file row into the discovered table so list_get is uniform. */ + AeLanDiscovered d{}; + std::snprintf(d.endpoint, sizeof(d.endpoint), "%s", st.endpoint.c_str()); + std::snprintf(d.name, sizeof(d.name), "%s", + st.name.empty() ? "LAN Lobby" : st.name.c_str()); + std::snprintf(d.game, sizeof(d.game), "%s", + st.game.empty() ? "PSX" : st.game.c_str()); + d.player_count = ae_np_lan_occupied(st); + d.max_slots = st.max_slots >= 2 ? st.max_slots : 2; + d.has_password = st.password.empty() ? 0 : 1; + d.latency_ms = g_lnch_lan_latency_ms; + d.input_delay = g_lnch_lobby_input_delay; + d.input_prediction = g_lnch_lobby_input_prediction; + d.rollback = g_lnch_rollback ? 1 : 0; + d.session_id = st.session_id ? st.session_id : 1u; + d.last_seen_ms = SDL_GetTicks(); + ae_np_lan_discovered_upsert(d); } } @@ -5753,6 +6165,15 @@ namespace { return true; } + /* Extra LAN rows after the online server list. Prefer discovered table; + * fall back to a single file-backed row when hosting / same-machine. */ + static int ae_np_lan_list_extra_count(void) { + ae_np_lan_prune_discovered(); + if (g_lnch_lan_discovered_n > 0) + return g_lnch_lan_discovered_n; + return ae_np_lan_list_visible() ? 1 : 0; + } + PsxLobbyMatchCaps ae_netplay_caps_from_settings(const RecompLauncherCSettings* s) { PsxLobbyMatchCaps caps{}; caps.valid = 1; @@ -5933,8 +6354,22 @@ namespace { if (g_lnch_hosting_lan) { AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) return; + const int lobby_port = ae_np_lan_endpoint_port(st.endpoint); if (g_lnch_lan_udp == kAeLanSockInvalid) - (void)ae_np_lan_udp_ensure(true, ae_np_lan_endpoint_port(st.endpoint)); + (void)ae_np_lan_udp_ensure(true, lobby_port); + /* Periodic broadcast BEACON so Refresh can find us without a + * unicast target (and for guests that listen between browses). */ + if (g_lnch_lan_udp != kAeLanSockInvalid && !st.started) { + const uint32_t now = SDL_GetTicks(); + if (g_lnch_lan_beacon_announce_ms == 0u || + now - g_lnch_lan_beacon_announce_ms >= 2000u) { + g_lnch_lan_beacon_announce_ms = now; + ae_np_lan_sock_set_broadcast(g_lnch_lan_udp); + char beacon[768]; + if (ae_np_lan_format_beacon(beacon, sizeof(beacon), st) > 0) + ae_np_lan_broadcast_msg(g_lnch_lan_udp, lobby_port, beacon); + } + } } else if (g_lnch_remote_lan) { if (g_lnch_lan_udp == kAeLanSockInvalid) (void)ae_np_lan_udp_ensure(false, 0); @@ -5982,6 +6417,17 @@ namespace { continue; } + /* LAN lobby discovery: answer browse with a BEACON (unicast). */ + if (std::strncmp(buf, "MOTK1 BROWSE", 12) == 0 && g_lnch_hosting_lan) { + AeLanLobbyState st; + if (ae_np_read_lan_state(&st) && !st.started) { + char beacon[768]; + if (ae_np_lan_format_beacon(beacon, sizeof(beacon), st) > 0) + ae_np_lan_udp_sendto(from, beacon); + } + continue; + } + if (std::strncmp(buf, "MOTK3 JOIN\n", 11) == 0 && g_lnch_hosting_lan) { char* p = buf + 11; char* nl = std::strchr(p, '\n'); @@ -6183,14 +6629,48 @@ namespace { } if (std::strncmp(buf, "MOTK1 START\n", 12) == 0) { g_lnch_remote_lan_state.started = true; - const char* sid = buf + 12; - if (*sid) { - const unsigned v = (unsigned)std::strtoul(sid, nullptr, 10); + /* MOTK1 START\n\n[\n\n\n] + * Trailing caps lines are host-authoritative (LAN had no + * match_caps channel; guests used local UI defaults). */ + char* p = buf + 12; + char* nl = std::strchr(p, '\n'); + if (nl) *nl = '\0'; + if (*p) { + const unsigned v = (unsigned)std::strtoul(p, nullptr, 10); if (v) { g_lnch_lan_session_id = (uint32_t)v; g_lnch_remote_lan_state.session_id = (uint32_t)v; } } + if (nl) { + p = nl + 1; + char* lines[3] = {}; + for (int i = 0; i < 3; ++i) { + if (!p || !*p) break; + lines[i] = p; + char* n2 = std::strchr(p, '\n'); + if (n2) { + *n2 = '\0'; + p = n2 + 1; + } else { + p = nullptr; + } + } + if (lines[0] && lines[0][0]) { + int d = std::atoi(lines[0]); + if (d < 2) d = 2; + if (d > 20) d = 20; + g_lnch_lobby_input_delay = d; + } + if (lines[1] && lines[1][0]) { + int pred = std::atoi(lines[1]); + if (pred < 2) pred = 2; + if (pred > 16) pred = 16; + g_lnch_lobby_input_prediction = pred; + } + if (lines[2] && lines[2][0]) + g_lnch_rollback = (std::atoi(lines[2]) != 0) ? 1 : 0; + } continue; } if (std::strncmp(buf, "MOTK1 KICK\n", 11) == 0 || @@ -6231,14 +6711,20 @@ namespace { } int ae_np_list_count(void*) { - return psx_lobby_list_count() + (ae_np_lan_list_visible() ? 1 : 0); + return psx_lobby_list_count() + ae_np_lan_list_extra_count(); } int ae_np_list_get(void*, int index, RecompLauncherCNetplayLobby* out) { if (!out) return 0; const int remote_count = psx_lobby_list_count(); - if (index >= remote_count) - return ae_np_lan_list_visible() ? ae_np_read_lan_lobby(out) : 0; + if (index >= remote_count) { + const int lan_i = index - remote_count; + ae_np_lan_prune_discovered(); + if (g_lnch_lan_discovered_n > 0) + return ae_np_lan_fill_lobby_from_discovered(lan_i, out); + return (lan_i == 0 && ae_np_lan_list_visible()) + ? ae_np_read_lan_lobby(out) : 0; + } PsxLobbyRow row{}; if (!psx_lobby_list_get(index, &row)) return 0; std::snprintf(out->lobby_id, sizeof(out->lobby_id), "%s", row.lobby_id); @@ -6564,6 +7050,10 @@ namespace { if (psx_lobby_in_lobby()) (void)psx_lobby_leave(); + /* Adopt host D/P/rollback from the last BEACON (if any) before + * seating — START still re-asserts host-authoritative caps. */ + ae_np_lan_apply_discovered_caps(endpoint); + /* Must be a live LAN/Direct IP host (UDP PONG). Online-only hosts * never answer — refuse so we don't open a fake local room. */ if (!ae_np_lan_probe_host_ms(endpoint, 750u)) @@ -6847,9 +7337,17 @@ namespace { g_lnch_lan_session_id = state.session_id; if (!ae_np_write_lan_state(state)) return -1; ae_np_lan_send_update_to_peers(state); - char start_msg[64]; - std::snprintf(start_msg, sizeof(start_msg), "MOTK1 START\n%u\n", - (unsigned)state.session_id); + char start_msg[96]; + int delay = g_lnch_lobby_input_delay; + int pred = g_lnch_lobby_input_prediction; + if (delay < 2) delay = 2; + if (delay > 20) delay = 20; + if (pred < 2) pred = 2; + if (pred > 16) pred = 16; + std::snprintf(start_msg, sizeof(start_msg), + "MOTK1 START\n%u\n%d\n%d\n%d\n", + (unsigned)state.session_id, delay, pred, + g_lnch_rollback ? 1 : 0); for (int i = 0; i < kAeLanMaxSlots; ++i) { if (g_lnch_lan_peer_ok[i]) ae_np_lan_udp_sendto(g_lnch_lan_peers[i], start_msg); diff --git a/runtime/src/netplay_hash_confirm.c b/runtime/src/netplay_hash_confirm.c index ea84c8f0..63d05df1 100644 --- a/runtime/src/netplay_hash_confirm.c +++ b/runtime/src/netplay_hash_confirm.c @@ -92,6 +92,14 @@ uint8_t netplay_hc_local_digest(const NetplayHashConfirm* hc, uint32_t tick, return 1u; } +uint8_t netplay_hc_peer_digest(const NetplayHashConfirm* hc, uint32_t tick, + uint32_t* digest_out) { + uint32_t d = 0; + if (!hc || !peer_at(hc, tick, &d)) return 0u; + if (digest_out) *digest_out = d; + return 1u; +} + uint8_t netplay_hc_peek_mismatch(const NetplayHashConfirm* hc, uint32_t* tick_out, uint32_t* local_out, uint32_t* peer_out) { uint32_t next; diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index cae6f848..92156c01 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -200,6 +200,12 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) void psx_netplay_shutdown(void) {} void psx_netplay_stage_local(const PsxNetPad *pad) { (void)pad; } int psx_netplay_needs_local_sample(void) { return 0; } +int psx_netplay_live_pad_buttons(uint16_t *out) +{ + if (out) + *out = 0xFFFFu; + return 0; +} int psx_netplay_input_desync(uint32_t *tick, uint32_t *local_hash, uint32_t *remote_hash) { (void)tick; @@ -223,6 +229,7 @@ int psx_netplay_poll_admit(void) { return 1; } void psx_netplay_finish_frame(void) {} int psx_netplay_remote_lead(void) { return 0; } int psx_netplay_input_delay(void) { return 2; } +void psx_netplay_timesync_on_episode_boundary(void) {} int psx_netplay_catchup_budget(void) { return 0; } void psx_netplay_catchup_consume_frame(void) {} void psx_netplay_wait_recv(int timeout_ms) { (void)timeout_ms; } @@ -272,6 +279,9 @@ typedef struct { RNetSession *session; PsxNetPad staged; int staged_valid; + /* Physical pad refreshed every stage_local call (even when latched). */ + PsxNetPad live; + int live_valid; int active; int slot_count; int local_slot; @@ -1085,6 +1095,7 @@ static void np_begin_load_apply(int slot) g_np.needs_advance = 0; g_np.latched_for_tick = 0; g_np.staged_valid = 0; + g_np.live_valid = 0; g_np.xfer_slot = slot; } @@ -1117,6 +1128,7 @@ static void np_enter_load_ready(int slot) g_np.needs_advance = 0; g_np.latched_for_tick = 0; g_np.staged_valid = 0; + g_np.live_valid = 0; g_np.xfer = NP_XFER_LOAD_READY; g_np.xfer_slot = slot; } @@ -1368,21 +1380,33 @@ static int np_digital_new_press(const RNetInputContractFrame *pub, } /* After promoting a digital release at `release_tick`, rewrite predicted hist - * rows release_tick+1..sim to the released pad. Hold-last invent ahead of the + * rows release_tick+1..end to the released pad. Hold-last invent ahead of the * release tip left `pub` still held → ghost second-release episodes (soak: - * RIGHT release@1607 then another `pub=ffdf wire=ffff` @1634). */ + * RIGHT release@1607 then another `pub=ffdf wire=ffff` @1634). + * + * §34: TipHold stalls sim at tip while coalesce promotes release at tip+N — + * end must reach tip+runway, not only sim (sim <= release_tick was a no-op). */ static void np_scrub_ahead_predicted(int slot, rnet_u32 release_tick, const RNetRbFrame *released) { rnet_u32 sim; + rnet_u32 end; rnet_u32 t; unsigned n = 0; if (!released || !g_np.session) return; sim = rnet_session_sim_tick(g_np.session); - if (sim <= release_tick) + end = sim; + if (psx_netplay_rb_tip_holding()) { + uint32_t tip = psx_netplay_rb_episode_target(); + uint32_t runway = psx_netplay_rb_tip_runway(); + uint32_t tip_end = tip + runway; + if (tip_end > end) + end = tip_end; + } + if (end <= release_tick) return; - for (t = release_tick + 1u; t <= sim; ++t) { + for (t = release_tick + 1u; t <= end; ++t) { RNetRbFrame row; RNetRbFrame scrub; if (!netplay_ih_get(&g_np.ih, slot, t, &row)) @@ -1402,9 +1426,9 @@ static void np_scrub_ahead_predicted(int slot, rnet_u32 release_tick, } if (n) { fprintf(stderr, - "psxrecomp: rb scrub-ahead release slot=%d from=%u sim=%u n=%u " - "btn=%04x\n", - slot, (unsigned)release_tick, (unsigned)sim, n, + "psxrecomp: rb scrub-ahead release slot=%d from=%u end=%u " + "sim=%u n=%u btn=%04x\n", + slot, (unsigned)release_tick, (unsigned)end, (unsigned)sim, n, (unsigned)released->buttons); fflush(stderr); } @@ -1490,20 +1514,42 @@ static void np_tip_hold_coalesce_ahead(void) np_scrub_ahead_predicted(slot, edge, &edge_frame); } } - fprintf(stderr, - "psxrecomp: rb tip-hold coalesce-ahead tip=%u edge=%u slot=%d " - "tip_btn=%04x\n", - (unsigned)tip, (unsigned)edge, slot, (unsigned)tip_row.buttons); - fflush(stderr); + { + static uint32_t s_last_log_tip, s_last_log_edge; + static uint32_t s_coalesce_log_suppressed; + if (tip != s_last_log_tip || edge != s_last_log_edge) { + if (s_coalesce_log_suppressed) { + fprintf(stderr, + "psxrecomp: rb tip-hold coalesce-ahead " + "(suppressed %u repeats)\n", + (unsigned)s_coalesce_log_suppressed); + s_coalesce_log_suppressed = 0; + } + fprintf(stderr, + "psxrecomp: rb tip-hold coalesce-ahead tip=%u edge=%u " + "slot=%d tip_btn=%04x\n", + (unsigned)tip, (unsigned)edge, slot, + (unsigned)tip_row.buttons); + fflush(stderr); + s_last_log_tip = tip; + s_last_log_edge = edge; + } else { + s_coalesce_log_suppressed++; + } + } if (psx_netplay_rb_tip_extend(edge, slot)) return; /* tip-extend refused (span cap / different seat) — open via begin. */ if (psx_netplay_rb_begin_rewind(edge, slot)) return; + /* Still tip-holding with an unreabsorbed wire edge: block quiet + * finalize so we do not commit and drop the release (§34 soak). */ + psx_netplay_rb_tip_hold_block_quiet(1); } } -static void np_timesync_note_late(void); +static void np_timesync_note_late(uint32_t age); +static void np_timesync_note_mispredict(uint32_t age); /* 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 @@ -1547,8 +1593,9 @@ static void np_rollback_reconcile_wire(void) cooldown = psx_netplay_rb_rewind_suppressed(); fmv_defer = psx_netplay_rb_fmv_defer_rewind(); { - /* Media + post-FMV lockstep (title Start): promote-only; admit waits - * for wire so presses never open 0x8006CDA0 / VLC tip episodes. */ + /* Media + short settle (§26): promote-only; admit waits for wire so + * FMV Start/skip never opens depth24 tip episodes. Post-FMV menus + * invent+resim (digest lockstep no longer gates admit). */ int fmv_lock = psx_netplay_rb_lockstep_no_invent(); no_resim = promote_sweep || cooldown || fmv_lock; if (promote_sweep) @@ -1598,9 +1645,16 @@ static void np_rollback_reconcile_wire(void) * works), drowning the one signal that actually identifies the * ahead peer. 2026-08-01 soak: gated correctly, the peer that * initiates corrections is unambiguous (28 vs 12 rewind-requests - * split across the two sides in one session). */ + * split across the two sides in one session). + * §32 lead regulation: also pass how many ticks this predicted + * row rode before we caught it wrong (sim - t). A row resolved + * right at the normal D-tick delay boundary is expected; one + * that rode far longer means we were running unusually far + * ahead of the confirmed remote tip when we guessed it — the + * direct "how much lead did this mispredict cost us" signal + * that scales the pacing debt below. */ if (pads_differ) - np_timesync_note_late(); + np_timesync_note_mispredict((sim >= t) ? (sim - t) : 0u); /* After commit/realign: flush invent poison without another episode. */ if (no_resim) { (void)netplay_ih_promote(&g_np.ih, slot, &wire_frame); @@ -1675,6 +1729,8 @@ static void np_rollback_reconcile_wire(void) n_episode_open++; } else { n_begin_refused++; + if (psx_netplay_rb_tip_holding()) + psx_netplay_rb_tip_hold_block_quiet(1); } g_np.pending_rewind = 0; } @@ -1740,6 +1796,32 @@ static void np_rollback_reconcile_wire(void) } } +/* After episode commit: refuse invent until remote tip rebuilds most of D. */ +static int g_cushion_rebuild; + +/* Invent-grace RTT: POST-handshake EMA is asymmetric (often 0 on the peer + * that receives POST first / light-tip skips). Always keep a D-scaled synth + * floor so both peers share a comparable patience baseline; trusted raw can + * only raise the estimate, never drop below the floor. */ +static uint32_t np_invent_rtt_ms(uint32_t *raw_out) +{ + uint32_t raw = psx_netplay_rb_rtt_estimate_ms(); + uint32_t tick_ms = 17u; + uint32_t delay_ticks; + uint32_t synth; + if (raw_out) + *raw_out = raw; + delay_ticks = (uint32_t)(g_np.input_delay > 0 ? g_np.input_delay : 4); + synth = (delay_ticks * tick_ms) / 4u; /* ~1/4 of the delay runway in ms */ + if (synth < 8u) + synth = 8u; + if (synth > 24u) + synth = 24u; + if (raw >= 4u && raw > synth) + return raw; + return synth; +} + /* Stall-before-invent grace. Soaks showed every menu episode was a real * press/release edge whose wire row landed only 1–2 ticks after the seal * point (LAN, admit skew — not link latency): invent-on-first-miss then @@ -1766,19 +1848,19 @@ static void np_rollback_reconcile_wire(void) * for 2s so we don't add a per-tick stall on top of real lag. * Host-side pacing only — the invented value is unchanged (hold-last), so * guest determinism is unaffected. 0 disables. */ -/* Budget policy (2026-08-01, see docs/ROLLBACK_MOTK_HOOKUP.md section 12): - * this used to scale purely off the local sim's own tick-to-tick cadence - * (s_tick_ema_ms), which is the wrong signal — tick period gets *longer* - * precisely when the engine is already struggling (an active resim burst, - * a heavy scene), none of which means the network needs more patience. That - * let a slowdown balloon the very stall reacting to it: a soak on a genuine - * sub-ms-RTT direct-LAN connection measured this landing at 70-90ms per - * occurrence. Now sized primarily from psx_netplay_rb_rtt_estimate_ms() — a - * live measurement of actual POST-handshake round-trip latency — with the - * old tick-cadence term kept only as a *fallback* for the brief window - * before the first episode has round-tripped this session (no RTT sample - * yet), and tightened since it's no longer the primary mechanism. */ -static int np_invent_grace_stall(int slot, rnet_u32 wire) +/* Budget policy (2026-08-01, see docs/ROLLBACK_MOTK_HOOKUP.md section 12; + * 2026-08-01 rb-diag follow-up): size from POST-handshake RTT when trusted + * (>=4ms). When untrusted (guest often stuck at 0–1), use D-scaled synth + * via np_invent_rtt_ms() so invent patience stays symmetric. */ +/* budget_cap: hard ceiling on this stall (0 = disabled / invent now; + * UINT32_MAX = full §21 budget for gap>=2). Gap=1 uses a short cap so the + * ahead peer waits for an in-flight tip without re-serializing both sims + * on a full peer guest frame (the 2026-07-31 40–50 fps regression). + * count_expire: only gap>=2 expiries feed the adaptive-off streak — gap=1 + * fires constantly under phase stagger and must not disable the deep-gap + * governor. */ +static int np_invent_grace_stall_ex(int slot, rnet_u32 wire, uint32_t budget_cap, + int count_expire) { static int s_grace_ms = -1; static rnet_u32 s_wire[PSX_MAX_PLAYERS]; @@ -1791,6 +1873,8 @@ static int np_invent_grace_stall(int slot, rnet_u32 wire) uint32_t now; uint32_t budget; uint32_t rtt; + uint32_t rtt_raw; + uint32_t delay_ms = 0; if (s_grace_ms < 0) { const char *e = getenv("PSX_RB_INVENT_GRACE_MS"); @@ -1800,12 +1884,12 @@ static int np_invent_grace_stall(int slot, rnet_u32 wire) fprintf(stderr, "psxrecomp: rb invent grace floor=%d ms (minimum stall before " "hold-last invent; PSX_RB_INVENT_GRACE_MS — actual per-stall " - "budget scales up from measured RTT, see 'rb invent grace " - "budget' lines)\n", + "budget scales up from measured/synth RTT, see 'rb invent " + "grace budget' lines; gap=1 uses a short cap, see §23)\n", s_grace_ms); fflush(stderr); } - if (s_grace_ms == 0 || slot < 0 || slot >= PSX_MAX_PLAYERS) + if (s_grace_ms == 0 || budget_cap == 0u || slot < 0 || slot >= PSX_MAX_PLAYERS) return 0; now = np_mono_ms(); if (s_off_until != 0u && (int32_t)(now - s_off_until) < 0) @@ -1816,9 +1900,8 @@ static int np_invent_grace_stall(int slot, rnet_u32 wire) * on without hitting the expiry path) — the link is keeping up. */ if (s_wire[slot] != 0u && !s_expired[slot]) s_expire_streak = 0; - /* Consecutive tracked ticks give the live tick period — fallback - * signal only now (see policy note above); real budget sizing - * prefers psx_netplay_rb_rtt_estimate_ms() below. */ + /* Consecutive tracked ticks give the live tick period — diagnostics + * only; invent budget prefers np_invent_rtt_ms(). */ if (s_wire[slot] != 0u && wire == s_wire[slot] + 1u) { uint32_t dt = now - s_t0[slot]; if (dt >= 1u && dt <= 250u) @@ -1833,44 +1916,56 @@ static int np_invent_grace_stall(int slot, rnet_u32 wire) return 1; } budget = (uint32_t)s_grace_ms; - rtt = psx_netplay_rb_rtt_estimate_ms(); - if (rtt) { - /* 1.5x measured RTT: slack for jitter without inheriting the old - * formula's habit of ballooning under local slowdown, since network - * transit time does not degrade just because guest/replay cost did. */ - uint32_t scaled = rtt + rtt / 2u; - if (scaled > budget) - budget = scaled; - if (budget > 60u) /* hard ceiling regardless of measured RTT */ - budget = 60u; - } else if (s_tick_ema_ms) { - /* No RTT sample yet this session (before the first episode has - * round-tripped) — narrower and lower-ceiling than before, since - * this is a brief startup fallback, not the steady-state mechanism. */ - uint32_t scaled = (s_tick_ema_ms * 3u) / 2u; /* 1.5 ticks, was 2.5 */ - if (scaled > budget) - budget = scaled; - if (budget > 40u) /* was 100 */ - budget = 40u; + rtt = np_invent_rtt_ms(&rtt_raw); + /* Ceiling scales with the configured input_delay (2026-08-01, see + * docs/ROLLBACK_MOTK_HOOKUP.md §21). Half the nominal delay window + * (floored so D<=4 keeps usable patience, capped so a very large D + * can't hang admit indefinitely). */ + { + uint32_t tick_ms = s_tick_ema_ms ? s_tick_ema_ms : 17u; /* ~59.94Hz nominal */ + uint32_t delay_ticks = (uint32_t)(g_np.input_delay > 0 ? g_np.input_delay : 0); + uint32_t rtt_ceiling; + delay_ms = delay_ticks * tick_ms; + rtt_ceiling = delay_ms / 2u; + /* §26: floor was 60ms — with D=4 synth RTT that forced a 25ms+ + * invent tax every RUNWAY_EMPTY miss and capped WAN at ~30fps even + * after gap1 invent. Cap patience closer to one frame. */ + if (rtt_ceiling < 20u) rtt_ceiling = 20u; + if (rtt_ceiling > 80u) rtt_ceiling = 80u; + + /* 1.5x invent RTT (trusted POST sample or D-scaled synth). */ + { + uint32_t scaled = rtt + rtt / 2u; + if (scaled > budget) + budget = scaled; + if (budget > rtt_ceiling) + budget = rtt_ceiling; + } } + if (budget_cap != 0xffffffffu && budget > budget_cap) + budget = budget_cap; if (!s_budget_logged[slot]) { s_budget_logged[slot] = 1; fprintf(stderr, "psxrecomp: rb invent grace budget=%u ms (floor=%d rtt=%u " - "tick_ema=%u) slot=%d wire=%u\n", - (unsigned)budget, s_grace_ms, (unsigned)rtt, - (unsigned)s_tick_ema_ms, slot, (unsigned)wire); + "rtt_raw=%u tick_ema=%u delay_ms=%u cap=%u) slot=%d wire=%u\n", + (unsigned)budget, s_grace_ms, (unsigned)rtt, (unsigned)rtt_raw, + (unsigned)s_tick_ema_ms, (unsigned)delay_ms, + (unsigned)budget_cap, slot, (unsigned)wire); fflush(stderr); } if ((uint32_t)(now - s_t0[slot]) < budget) return 1; if (!s_expired[slot]) { s_expired[slot] = 1; - if (++s_expire_streak >= 45u) { + if (count_expire && ++s_expire_streak >= 15u) { s_expire_streak = 0; - s_off_until = now + 2000u; + /* §26: WAN soaks needed invent-free sooner than 45×25ms (~2s of + * admit tax before OFF). Hold OFF longer so FMV/menu cutovers + * don't immediately re-arm a 25ms per-tick stall. */ + s_off_until = now + 5000u; fprintf(stderr, - "psxrecomp: rb invent grace OFF 2s (remote input " + "psxrecomp: rb invent grace OFF 5s (remote input " "consistently later than %u ms)\n", (unsigned)budget); fflush(stderr); @@ -1879,6 +1974,167 @@ static int np_invent_grace_stall(int slot, rnet_u32 wire) return 0; } +/* Gap=1 invent grace. §27 made this a flat 0 (invent on first miss) to kill + * WAN delay-sync — but the 2026-08-01 LAN soak showed the *default* path + * (no PSX_RB_GAP1_GRACE_MS override) was inventing on a rock-stable + * remote_lead=1 hundreds of frames in a row: not starvation, a one-tick + * phase offset between two sims that are otherwise keeping up. §28 splits + * gap=1 into two cases instead of one flat policy: + * Case A (healthy/advancing tip): remote_lead>=1 and the tip has + * advanced recently relative to its own arrival cadence — the newest + * row is in flight, not missing. Wait up to the time remaining until + * the next expected tip advance (a few ms) instead of inventing. + * Case B (stale/starved tip): remote_lead<=0 or the tip hasn't advanced + * in over ~1.5x its cadence — genuinely nothing is coming soon. + * Invent immediately, same as §27's default, so WAN gaps stay + * responsive and this never turns back into cushion-wait. + * PSX_RB_GAP1_GRACE_MS still forces the old flat-cap behavior for A/B + * testing or an operator override; when unset, §28's adaptive split runs + * instead of a fixed cap. */ +static uint32_t g_gap1_shrink_until_ms; +static uint32_t g_gap1_expire_invent_streak; +#define RB_GAP1_SHRINK_CAP_MS 6u +#define RB_GAP1_SHRINK_HOLD_MS 1000u +/* §27: deep invent (pred_depth≥2) only after tip looks stale. */ +#define RB_INVENT_DEPTH_STALE_FLOOR_MS 40u +#define RB_INVENT_RUNWAY_GRACE_CAP_MS 8u +/* §29: §28's Case A wait window (RB_GAP1_CASE_A_FLOOR_MS/CEIL_MS) was + * removed — see the gap=1 branch of np_try_admit_rollback for why. The + * A/B classification itself is kept (diagnostics only, zero latency): + * tip considered "advancing" if its last advance was within this + * multiple of its own arrival-period EMA (fixed-point x2, i.e. 1.5x). */ +#define RB_TIP_FRESH_MULT_X2 3u + +/* §28: tip arrival cadence — tracks how often the confirmed remote tip + * (highest_remote_wire) actually advances, independent of whether admit + * hit a miss. This lets a gap=1 miss be judged against "is a new row + * about due" instead of a flat timeout: rb-diag soaks showed remote_lead + * sitting at a stable 1 for hundreds of frames while the tip kept + * advancing on a steady ~1-tick cadence — that is phase offset, not + * starvation. */ +static uint32_t g_tip_last_highest; +static uint32_t g_tip_last_advance_ms; +static uint32_t g_tip_arrival_ema_ms; +static uint8_t g_tip_have_advance; + +static void np_tip_track_advance(rnet_u32 highest_remote_wire) +{ + uint32_t now = np_mono_ms(); + + if (!g_tip_have_advance) { + g_tip_last_highest = highest_remote_wire; + g_tip_last_advance_ms = now; + g_tip_have_advance = 1; + return; + } + if (highest_remote_wire == g_tip_last_highest) + return; + { + uint32_t dt = now - g_tip_last_advance_ms; + if (dt >= 1u && dt <= 250u) + g_tip_arrival_ema_ms = g_tip_arrival_ema_ms + ? (3u * g_tip_arrival_ema_ms + dt) / 4u + : dt; + } + g_tip_last_highest = highest_remote_wire; + g_tip_last_advance_ms = now; +} + +/* ms since the remote tip last advanced; UINT32_MAX if never observed yet + * (treated as stale — no evidence the pipeline is healthy). */ +static uint32_t np_tip_age_ms(void) +{ + if (!g_tip_have_advance) + return 0xffffffffu; + return np_mono_ms() - g_tip_last_advance_ms; +} + +/* has_override: set to 1 if PSX_RB_GAP1_GRACE_MS forces a flat cap (the + * return value is that cap, already SHRINK-adjusted). Set to 0 when + * unset — caller should run the §28 adaptive Case A/B split instead; the + * return value is 0 and must be ignored. */ +static uint32_t np_gap1_grace_cap_ms(int *has_override) +{ + static int s_cap = -2; /* -2 unset */ + uint32_t rtt_raw; + uint32_t cap; + uint32_t now; + + if (s_cap == -2) { + const char *e = getenv("PSX_RB_GAP1_GRACE_MS"); + if (e && e[0]) { + s_cap = atoi(e); + if (s_cap < 0) s_cap = 0; + if (s_cap > 40) s_cap = 40; + fprintf(stderr, + "psxrecomp: rb gap1 invent grace cap=%d ms " + "(PSX_RB_GAP1_GRACE_MS override; §28 adaptive split " + "disabled)\n", + s_cap); + fflush(stderr); + } else { + s_cap = -1; /* §28: no flat override, adaptive split decides */ + fprintf(stderr, + "psxrecomp: rb gap1 invent grace: adaptive §28 split " + "(healthy/advancing tip waits a few ms; stale tip " + "invents now; PSX_RB_GAP1_GRACE_MS forces a flat cap)\n"); + fflush(stderr); + } + } + if (has_override) + *has_override = (s_cap >= 0); + if (s_cap < 0) + return 0u; + now = np_mono_ms(); + if (s_cap == 0) + return 0u; + cap = (uint32_t)s_cap; + /* Only shrink when we have a trusted link sample — otherwise shrink + * recreates the guest invent/host wait split. */ + rtt_raw = psx_netplay_rb_rtt_estimate_ms(); + if (rtt_raw < 4u) { + g_gap1_shrink_until_ms = 0u; + } else if (g_gap1_shrink_until_ms != 0u && + (int32_t)(now - g_gap1_shrink_until_ms) < 0) { + if (cap > RB_GAP1_SHRINK_CAP_MS) + cap = RB_GAP1_SHRINK_CAP_MS; + } else { + g_gap1_shrink_until_ms = 0u; + } + return cap; +} + +/* Call after inventing at gap=1 when grace already expired for this wire. */ +static void np_gap1_note_expire_invent(void) +{ + uint32_t now = np_mono_ms(); + /* No SHRINK while POST-RTT is untrusted — both peers must keep the same + * invent patience (see rb-diag1/2 guest rtt=0–1 vs host 15–48). */ + if (psx_netplay_rb_rtt_estimate_ms() < 4u) { + g_gap1_expire_invent_streak = 0u; + return; + } + if (++g_gap1_expire_invent_streak < 10u) + return; + g_gap1_expire_invent_streak = 0u; + g_gap1_shrink_until_ms = now + RB_GAP1_SHRINK_HOLD_MS; + fprintf(stderr, + "psxrecomp: rb gap1 invent grace SHRINK %ums for %ums " + "(stall expired into invent repeatedly; trusted RTT only)\n", + (unsigned)RB_GAP1_SHRINK_CAP_MS, (unsigned)RB_GAP1_SHRINK_HOLD_MS); + fflush(stderr); +} + +static void np_gap1_note_grace_helped(void) +{ + g_gap1_expire_invent_streak = 0u; +} + +static int np_invent_grace_stall(int slot, rnet_u32 wire) +{ + return np_invent_grace_stall_ex(slot, wire, 0xffffffffu, 1); +} + /* Mispredict-driven timesync pacing. The first cut used a GGPO-style * advantage metric (wire_need - highest received remote row) with an * absolute +0.5 tick threshold — soak showed BOTH peers measure ~+0.6 @@ -1908,6 +2164,30 @@ static uint32_t g_ts_tick_ema_ms; static uint32_t g_ts_debt_ms; static uint32_t g_ts_pegged_streak; /* consecutive mispredicts landing at/above cap */ static uint32_t g_ts_off_until_ms; +/* §30 soak: prove/disprove "suppressed pacing ↔ chronically ahead peer". + * No algorithm change — counters only. Each peer logs its own view: + * mispredicts = remote pads_differ resolves; note_late_applied = debt + * actually added; note_late_suppressed_rb = note_late early-out while + * rb_active/tip_holding (the hypothesized control-loop leak). */ +static uint32_t g_ts_mispredict_count; +/* §32: cumulative/max prediction "age" (ticks ridden before a wrong guess + * was caught) across mispredicts, and how much of that fed extra debt vs + * the flat baseline — lets a soak confirm whether the peer with more + * mispredicts is also the one running further ahead per-edge (the + * over-prediction feedback loop fable's review flagged) rather than just + * eating more edges at the same depth. */ +static uint64_t g_ts_mispredict_age_sum; +static uint32_t g_ts_mispredict_age_max; +static uint32_t g_ts_note_late_applied; +static uint32_t g_ts_note_late_suppressed_rb; +static uint32_t g_ts_note_late_suppressed_off; +static uint32_t g_ts_debt_added_ms; /* cumulative debt added (pre-cap clamp) */ +/* remote_lead samples taken every live admit (window reset on phase-ctrl log). */ +static int64_t g_ts_lead_sum; +static uint32_t g_ts_lead_n; +static int g_ts_lead_min; +static int g_ts_lead_max; +static int g_ts_lead_have; static void np_timesync_check_enabled(void) { @@ -1917,6 +2197,15 @@ static void np_timesync_check_enabled(void) } } +void psx_netplay_timesync_on_episode_boundary(void) +{ + /* Resim/tip-hold can leave debt elevated; clear only the pegged-streak + * off-guard so the next live mispredicts can re-arm pacing. Keep debt — + * the ahead peer may still need a few ms/tick shaves after TipHold. */ + g_ts_pegged_streak = 0u; + g_cushion_rebuild = 1; +} + /* Reconcile saw a real remote row that contradicted what we invented for * this tick — i.e. we are (locally) the mispredicting/ahead peer for this * edge. Add pacing debt. @@ -1930,23 +2219,61 @@ static void np_timesync_check_enabled(void) * is transit latency, not phase skew" signal is debt landing AT THE CAP * repeatedly with no room to have drained in between — track a streak of * cap-hits instead of wall-clock nonzero time. */ -static void np_timesync_note_late(void) +static void np_timesync_note_late(uint32_t age) { uint32_t now; uint32_t add; uint32_t cap; + uint32_t expected_age; + uint32_t extra_age; np_timesync_check_enabled(); if (!g_ts_enabled) return; - now = np_mono_ms(); - if (g_ts_off_until_ms != 0u && (int32_t)(now - g_ts_off_until_ms) < 0) + /* Replay/tip-hold cost is not phase skew — do not feed pegged-streak + * adaptive-off (fight/resim load used to look like "WAN transit"). + * §30: count these suppressions — hypothesis is the ahead peer spends + * more time here and loses the correction signal that would slow it. */ + if (psx_netplay_rb_active() || psx_netplay_rb_tip_holding()) { + g_ts_note_late_suppressed_rb++; return; + } + now = np_mono_ms(); + if (g_ts_off_until_ms != 0u && (int32_t)(now - g_ts_off_until_ms) < 0) { + g_ts_note_late_suppressed_off++; + return; + } g_ts_off_until_ms = 0u; - add = g_ts_tick_ema_ms ? g_ts_tick_ema_ms / 2u : 8u; - cap = g_ts_tick_ema_ms ? g_ts_tick_ema_ms * 2u : 34u; + /* ~1.25 ticks per mispredict (was 0.75) — §29 soak: remote_lead sat at + * a rock-stable D-1 for the entire match (LAN, hold-last suppresses + * most gap1 misses from ever becoming a mispredict, so real edges are + * the only signal this scheduler gets that one side is racing ahead). + * With mispredicts this sparse, 0.75 ticks/edge could not close a + * persistent 1-tick offset inside a match; push the per-edge closure + * harder and let more debt accumulate (cap 3 ticks, was 2) so a + * cluster of edges (a fight exchange) actually walks the phase back + * to D instead of just taking the storm's mispredicts as its due. */ + add = g_ts_tick_ema_ms ? (g_ts_tick_ema_ms * 5u) / 4u : 20u; + cap = g_ts_tick_ema_ms ? g_ts_tick_ema_ms * 3u : 50u; + /* §32 lead regulation: a mispredict resolved right at the normal + * input-delay boundary (age ≈ D) is expected steady-state noise — no + * bonus, behavior unchanged from before this cut. A mispredict that + * rode notably longer than D ticks before we caught it means we were + * running unusually far ahead of the confirmed remote tip when we + * guessed it; scale extra debt by how far past D it went (+25% of the + * base add per extra tick of age, still bounded by the existing cap + * below) so the peer that is actually accumulating lead brakes harder, + * proportional to how far ahead it got, instead of every edge costing + * the same flat debt regardless of how deep the guess was. */ + expected_age = g_np.input_delay > 0 ? (uint32_t)g_np.input_delay : 2u; + extra_age = (age > expected_age) ? (age - expected_age) : 0u; + if (extra_age) + add += (add * extra_age) / 4u; + /* 18 consecutive cap-hits (was 12): fight scenes can peg briefly while + * pacing is still working; require a longer streak before declaring + * transit latency. */ if (g_ts_debt_ms >= cap) { - if (++g_ts_pegged_streak >= 12u) { + if (++g_ts_pegged_streak >= 18u) { g_ts_pegged_streak = 0u; g_ts_debt_ms = 0u; g_ts_off_until_ms = now + 10000u; @@ -1962,9 +2289,82 @@ static void np_timesync_note_late(void) g_ts_debt_ms += add; if (g_ts_debt_ms > cap) g_ts_debt_ms = cap; + g_ts_note_late_applied++; + g_ts_debt_added_ms += add; } -/* Admit-side: shave pacing debt off at <=3 ms per tick. */ +static void np_timesync_note_mispredict(uint32_t age) +{ + g_ts_mispredict_count++; + /* Raw signal (age-at-catch), independent of whether debt was actually + * applied below — a suppressed edge (resim/tip-hold/off-guard) still + * tells us how far ahead this peer was running when it guessed wrong. */ + g_ts_mispredict_age_sum += age; + if (age > g_ts_mispredict_age_max) + g_ts_mispredict_age_max = age; + np_timesync_note_late(age); +} + +static void np_timesync_sample_lead(int remote_lead) +{ + if (!g_ts_lead_have) { + g_ts_lead_min = remote_lead; + g_ts_lead_max = remote_lead; + g_ts_lead_have = 1; + } else { + if (remote_lead < g_ts_lead_min) + g_ts_lead_min = remote_lead; + if (remote_lead > g_ts_lead_max) + g_ts_lead_max = remote_lead; + } + g_ts_lead_sum += remote_lead; + g_ts_lead_n++; +} + +/* §30: ~1 Hz phase-control soak line. Compare host vs guest: + * higher lead + higher mispredicts + higher suppressed_rb on one peer + * ⇒ control-loop instability; balanced/rare suppress ⇒ gameplay/transport. */ +static void np_phase_ctrl_maybe_log(uint32_t now, rnet_u32 sim, int remote_lead) +{ + static uint32_t s_last; + int lead_avg; + + if (s_last != 0u && (uint32_t)(now - s_last) < 1000u) + return; + s_last = now ? now : 1u; + lead_avg = g_ts_lead_n ? (int)(g_ts_lead_sum / (int64_t)g_ts_lead_n) + : remote_lead; + fprintf(stderr, + "psxrecomp: rb phase ctrl slot=%d sim=%u lead=%d lead_avg=%d " + "lead_min=%d lead_max=%d debt_ms=%u debt_added=%u " + "mispredict=%u mispredict_age_avg=%u mispredict_age_max=%u " + "note_late=%u suppressed_rb=%u suppressed_off=%u " + "D=%d\n", + g_np.local_slot, (unsigned)sim, remote_lead, lead_avg, + g_ts_lead_have ? g_ts_lead_min : remote_lead, + g_ts_lead_have ? g_ts_lead_max : remote_lead, + (unsigned)g_ts_debt_ms, (unsigned)g_ts_debt_added_ms, + (unsigned)g_ts_mispredict_count, + (unsigned)(g_ts_mispredict_count + ? (g_ts_mispredict_age_sum / g_ts_mispredict_count) + : 0u), + (unsigned)g_ts_mispredict_age_max, + (unsigned)g_ts_note_late_applied, + (unsigned)g_ts_note_late_suppressed_rb, + (unsigned)g_ts_note_late_suppressed_off, + g_np.input_delay); + fflush(stderr); + /* Windowed lead stats reset each second; cumulative counters keep rising. */ + g_ts_lead_sum = 0; + g_ts_lead_n = 0; + g_ts_lead_have = 0; +} + +/* Admit-side: shave pacing debt off at <=6 ms per tick (§29: was 4; §23: + * was 3). Sparse mispredicts (hold-last suppresses most gap1 misses from + * ever mispredicting) meant debt drained faster than it could accumulate + * enough to matter — a bigger per-tick shave lets a handful of edges pull + * a stuck D-1 phase back to D within a few ticks instead of dozens. */ static int np_timesync_throttle(uint32_t wire) { static uint32_t s_last_wire; @@ -1988,7 +2388,7 @@ static int np_timesync_throttle(uint32_t wire) s_last_wire = wire; s_last_wire_ms = now; if (g_ts_debt_ms > 0u) { - uint32_t slice = g_ts_debt_ms > 3u ? 3u : g_ts_debt_ms; + uint32_t slice = g_ts_debt_ms > 6u ? 6u : g_ts_debt_ms; g_ts_debt_ms -= slice; if (g_ts_debt_ms == 0u) s_logged = 0; @@ -1996,7 +2396,7 @@ static int np_timesync_throttle(uint32_t wire) if (!s_logged) { fprintf(stderr, "psxrecomp: rb timesync pacing (debt=%u ms tick=%u ms — " - "shaving <=3 ms/tick)\n", + "shaving <=6 ms/tick)\n", (unsigned)(g_ts_debt_ms + slice), (unsigned)g_ts_tick_ema_ms); fflush(stderr); @@ -2010,6 +2410,236 @@ static int np_timesync_throttle(uint32_t wire) return 0; } +/* Admit telemetry (§22/§23): invents by gap size + P-cap freeze streak. */ +static uint32_t g_admit_invent_gap1; +static uint32_t g_admit_invent_gap2; +static uint32_t g_admit_invent_gap3p; +static uint32_t g_admit_gap1_grace; /* times gap=1 short grace returned stall */ +static uint32_t g_admit_pcap_stalls; +static uint32_t g_admit_pcap_enters; +static int g_pcap_frozen; +static uint32_t g_pcap_freeze_enters_window; +static uint32_t g_pcap_window_t0_ms; +static uint32_t g_adapt_last_bump_ms; +static uint32_t g_admit_stats_last_log_ms; +/* Soak-only: PSX_RB_ADAPT_DELAY=0 disables mid-match D bumps. */ +static int g_adapt_delay_enabled = -1; + +#define RB_ADAPT_FREEZE_ENTERS_THRESH 3u +#define RB_ADAPT_WINDOW_MS 5000u +#define RB_ADAPT_COOLDOWN_MS 10000u +#define RB_ADAPT_DELAY_MAX 16 + +static void np_sync_input_delay_from_session(void) +{ + rnet_u8 d; + if (!g_np.session) + return; + d = rnet_session_committed_delay(g_np.session); + if (d >= 2u && (int)d != g_np.input_delay) { + fprintf(stderr, + "psxrecomp: rb delay committed %d → %u (session)\n", + g_np.input_delay, (unsigned)d); + fflush(stderr); + g_np.input_delay = (int)d; + } else if (d >= 2u) { + g_np.input_delay = (int)d; + } +} + +static void np_admit_note_invent_gap(rnet_u32 wire, rnet_u32 highest_remote) +{ + rnet_u32 gap; + if (wire <= highest_remote) + gap = 0u; + else + gap = wire - highest_remote; + if (gap <= 1u) + g_admit_invent_gap1++; + else if (gap == 2u) + g_admit_invent_gap2++; + else + g_admit_invent_gap3p++; +} + +/* Why we spent prediction budget (one line per invent; throttle bursts). */ +static uint32_t g_admit_invent_runway_empty; +static uint32_t g_admit_invent_tip_stale; +static uint32_t g_admit_invent_gap1_legacy; +static uint32_t g_admit_cushion_wait; /* lead>0 stalls that refused invent */ +/* §28: gap1 Case A/B split telemetry — Case A grants (or attempts) a short + * adaptive wait because the tip looked healthy/advancing; Case B invents + * immediately because the tip looked stale or remote_lead was <=0. */ +static uint32_t g_admit_gap1_case_a; +static uint32_t g_admit_gap1_case_b; + +static void np_admit_log_invent(rnet_u32 sim, rnet_u32 wire, + rnet_u32 highest_remote, int remote_lead, + const char *reason) +{ + static uint32_t s_last_ms; + static uint32_t s_burst; + uint32_t now = np_mono_ms(); + uint32_t pred_depth = + (wire > highest_remote) ? (wire - highest_remote) : 0u; + int D = g_np.input_delay > 0 ? g_np.input_delay : 0; + + if (s_last_ms != 0u && (uint32_t)(now - s_last_ms) < 50u) { + s_burst++; + if ((s_burst & 15u) != 0u) + return; + } else { + s_burst = 0u; + } + s_last_ms = now ? now : 1u; + fprintf(stderr, + "psxrecomp: rb invent sim=%u wire=%u remote_tip=%u D=%d " + "pred_depth=%u remote_lead=%d reason=%s%s\n", + (unsigned)sim, (unsigned)wire, (unsigned)highest_remote, D, + (unsigned)pred_depth, remote_lead, reason, + s_burst ? " (burst)" : ""); + fflush(stderr); +} + +static void np_admit_log_runway(uint32_t now, rnet_u32 sim, rnet_u32 wire, + rnet_u32 highest_remote, int remote_lead) +{ + static uint32_t s_last; + uint32_t pred_depth; + int runway_rem; + uint32_t rtt_raw = 0; + uint32_t rtt = np_invent_rtt_ms(&rtt_raw); + int D = g_np.input_delay > 0 ? g_np.input_delay : 0; + + if (s_last != 0u && (uint32_t)(now - s_last) < 1000u) + return; + s_last = now ? now : 1u; + pred_depth = (wire > highest_remote) ? (wire - highest_remote) : 0u; + /* How many delay frames of remote tip remain vs live sim. */ + runway_rem = remote_lead; /* highest_remote - sim; healthy ≈ D */ + fprintf(stderr, + "psxrecomp: rb runway sim=%u wire=%u remote_tip=%u D=%d P=%d " + "pred_depth=%u remote_lead=%d runway_rem=%d cushion=%d " + "rtt=%u rtt_raw=%u\n", + (unsigned)sim, (unsigned)wire, (unsigned)highest_remote, D, + g_np.input_prediction, (unsigned)pred_depth, remote_lead, + runway_rem, g_cushion_rebuild, (unsigned)rtt, (unsigned)rtt_raw); + fflush(stderr); + (void)D; +} + +static void np_admit_maybe_log_stats(uint32_t now) +{ + if (g_admit_stats_last_log_ms != 0u && + (uint32_t)(now - g_admit_stats_last_log_ms) < 5000u) + return; + g_admit_stats_last_log_ms = now ? now : 1u; + fprintf(stderr, + "psxrecomp: rb admit stats invent_gap1=%u gap2=%u gap3+=%u " + "gap1_grace=%u gap1_case_a=%u gap1_case_b=%u tip_ema=%u " + "invent_runway_empty=%u invent_tip_stale=%u " + "invent_gap1_legacy=%u cushion_wait=%u " + "pcap_stalls=%u pcap_enters=%u freeze=%d D=%d P=%d cushion=%d " + "mispredict=%u note_late=%u suppressed_rb=%u suppressed_off=%u " + "debt_ms=%u debt_added=%u\n", + (unsigned)g_admit_invent_gap1, (unsigned)g_admit_invent_gap2, + (unsigned)g_admit_invent_gap3p, (unsigned)g_admit_gap1_grace, + (unsigned)g_admit_gap1_case_a, (unsigned)g_admit_gap1_case_b, + (unsigned)g_tip_arrival_ema_ms, + (unsigned)g_admit_invent_runway_empty, + (unsigned)g_admit_invent_tip_stale, + (unsigned)g_admit_invent_gap1_legacy, + (unsigned)g_admit_cushion_wait, + (unsigned)g_admit_pcap_stalls, (unsigned)g_admit_pcap_enters, + g_pcap_frozen, g_np.input_delay, g_np.input_prediction, + g_cushion_rebuild, + (unsigned)g_ts_mispredict_count, + (unsigned)g_ts_note_late_applied, + (unsigned)g_ts_note_late_suppressed_rb, + (unsigned)g_ts_note_late_suppressed_off, + (unsigned)g_ts_debt_ms, + (unsigned)g_ts_debt_added_ms); + fflush(stderr); +} + +static void np_adapt_delay_on_pcap_enter(uint32_t now) +{ + const char *e; + + if (g_adapt_delay_enabled < 0) { + e = getenv("PSX_RB_ADAPT_DELAY"); + g_adapt_delay_enabled = (e && e[0]) ? (atoi(e) != 0) : 1; + } + if (!g_adapt_delay_enabled || !g_np.session) + return; + /* Host / sim-authority only — guests receive DELAY_SYNC. */ + if (g_np.local_slot != 0) + return; + + if (g_pcap_window_t0_ms == 0u || + (uint32_t)(now - g_pcap_window_t0_ms) > RB_ADAPT_WINDOW_MS) { + g_pcap_window_t0_ms = now ? now : 1u; + g_pcap_freeze_enters_window = 0u; + } + g_pcap_freeze_enters_window++; + + if (g_pcap_freeze_enters_window < RB_ADAPT_FREEZE_ENTERS_THRESH) + return; + if (g_adapt_last_bump_ms != 0u && + (uint32_t)(now - g_adapt_last_bump_ms) < RB_ADAPT_COOLDOWN_MS) + return; + if (g_np.input_delay >= RB_ADAPT_DELAY_MAX) + return; + + { + int old_d = g_np.input_delay; + int new_d = old_d + 1; + if (new_d > RB_ADAPT_DELAY_MAX) + new_d = RB_ADAPT_DELAY_MAX; + if (rnet_session_request_delay_change(g_np.session, (rnet_u8)new_d)) { + g_adapt_last_bump_ms = now ? now : 1u; + g_pcap_freeze_enters_window = 0u; + g_pcap_window_t0_ms = now ? now : 1u; + fprintf(stderr, + "psxrecomp: rb adaptive delay bump %d → %d " + "(pcap freezes in window; P stays %d)\n", + old_d, new_d, g_np.input_prediction); + fflush(stderr); + } + } +} + +static void np_pcap_freeze_enter(rnet_u32 wire, rnet_u32 highest_remote, int pred) +{ + uint32_t now = np_mono_ms(); + g_admit_pcap_stalls++; + if (!g_pcap_frozen) { + g_pcap_frozen = 1; + g_admit_pcap_enters++; + fprintf(stderr, + "psxrecomp: rb pcap FREEZE enter wire=%u remote=%u P=%d " + "gap=%u D=%d\n", + (unsigned)wire, (unsigned)highest_remote, pred, + (unsigned)(wire > highest_remote ? wire - highest_remote : 0u), + g_np.input_delay); + fflush(stderr); + np_adapt_delay_on_pcap_enter(now); + } + np_admit_maybe_log_stats(now); +} + +static void np_pcap_freeze_exit(void) +{ + if (!g_pcap_frozen) + return; + g_pcap_frozen = 0; + fprintf(stderr, + "psxrecomp: rb pcap FREEZE exit (remote caught up / invent ok) " + "D=%d enters=%u\n", + g_np.input_delay, (unsigned)g_admit_pcap_enters); + fflush(stderr); +} + /* Rollback admit: tip + invent remotes within P of remote tip; stall outside. * BattleShip phase_lock: invent only when wire_need <= highest_remote + P. */ static int np_try_admit_rollback(void) @@ -2023,10 +2653,14 @@ static int np_try_admit_rollback(void) rnet_u32 wire; int slot; int pred; + int any_invent = 0; /* Tick FMV→settle tracker every admit (even when remotes are present). */ (void)psx_netplay_rb_lockstep_no_invent(); + /* Mid-session DELAY_SYNC may have committed on the last advance. */ + np_sync_input_delay_from_session(); + if (!rnet_session_prepare_local_tip(g_np.session, sim)) return 0; @@ -2039,6 +2673,9 @@ static int np_try_admit_rollback(void) memset(&st, 0, sizeof(st)); rnet_session_get_stats(g_np.session, &st); + /* §28: feed tip-arrival cadence every admit tick (not just on miss) so + * the gap1 Case A/B split has a real cadence to judge freshness by. */ + np_tip_track_advance(st.highest_remote_wire); /* TipHold past invent-cap: never invent (that caused tip-extend rereplay * cliffs). Advance only when every remote wire row is present *and* all @@ -2088,6 +2725,25 @@ static int np_try_admit_rollback(void) np_timesync_throttle(wire)) return 0; + np_timesync_sample_lead(st.remote_lead); + np_admit_log_runway(np_mono_ms(), sim, wire, st.highest_remote_wire, + st.remote_lead); + np_phase_ctrl_maybe_log(np_mono_ms(), sim, st.remote_lead); + + /* Rebuild D cushion after episode: do not invent until remote tip is + * nearly back at sim+D (remote_lead >= D-1). Both peers wait for real + * inputs instead of racing the frontier with hold-last. */ + if (g_cushion_rebuild && !psx_netplay_rb_active()) { + int need = (int)delay_u8 > 0 ? (int)delay_u8 - 1 : 0; + if (st.remote_lead >= need) { + g_cushion_rebuild = 0; + fprintf(stderr, + "psxrecomp: rb cushion rebuilt remote_lead=%d D=%u\n", + st.remote_lead, (unsigned)delay_u8); + fflush(stderr); + } + } + for (slot = 0; slot < g_np.slot_count; ++slot) { if (slot == g_np.local_slot) { if (rnet_session_peek_input(g_np.session, slot, wire, &sample)) { @@ -2113,33 +2769,174 @@ static int np_try_admit_rollback(void) decode_pad(&sample, &pad); netplay_ih_pad_to_frame(&pad, sim, 0, &row); (void)netplay_ih_put(&g_np.ih, slot, &row); + /* Remote arrived — gap1 grace (if any) did its job. */ + np_gap1_note_grace_helped(); } else { + const char *invent_reason = NULL; + static int s_gap1_legacy = -1; /* PSX_RB_GAP1_INVENT=1 → old path */ + static rnet_u32 s_miss_wire; + static uint32_t s_miss_t0; + uint32_t now_miss; + uint32_t tip_stale_ms; + rnet_u32 gap; + /* 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) + /* Stall when invent would run more than P ahead of remote tip + * (freeze + refill; adaptive delay may bump D on sustained + * freeze enters — see §22). */ + if (wire > st.highest_remote_wire + (rnet_u32)pred) { + np_pcap_freeze_enter(wire, st.highest_remote_wire, pred); return 0; - /* Brief stall before inventing — but ONLY when genuinely >=2 - * ticks ahead of the remote's sealed tip. A 1-tick gap is the - * normal phase offset of two pipelined sims: stalling on it - * serialized the peers (each admit waited out the other's - * guest frame; throughput fell to 1/(g1+g2) ~ 40-50 fps with - * zero episodes). Invent freely at gap<=1 — hold-last only - * mispredicts on real edges and episodes are cheap now that - * the agreed watermark tracks live. */ - if (wire > st.highest_remote_wire + 1u && - np_invent_grace_stall(slot, wire)) + } + /* Cushion rebuild: wait for real remote rows (no invent). */ + if (g_cushion_rebuild && !psx_netplay_rb_active()) return 0; + + if (s_gap1_legacy < 0) { + const char *e = getenv("PSX_RB_GAP1_INVENT"); + /* §26: default ON — short gap1 grace then invent (rollback). + * Cushion-wait-until-TIP_STALE while remote_lead>0 was the + * WAN 30fps delay-sync path; ENV=0 restores that. */ + if (e && e[0]) + s_gap1_legacy = (atoi(e) != 0) ? 1 : 0; + else + s_gap1_legacy = 1; + fprintf(stderr, + "psxrecomp: rb gap1 invent %s " + "(short grace then invent while remote_lead healthy%s)\n", + s_gap1_legacy ? "ON" : "OFF", + s_gap1_legacy ? "" : "; PSX_RB_GAP1_INVENT=0"); + fflush(stderr); + } + + gap = (wire > st.highest_remote_wire) + ? (wire - st.highest_remote_wire) + : 0u; + now_miss = np_mono_ms(); + if (s_miss_wire != wire) { + s_miss_wire = wire; + s_miss_t0 = now_miss; + } + + /* Default: invent is last resort. While remote_lead > 0 the + * confirmed tip is still ahead of sim — keep consuming wait, + * not prediction. Ideal LAN: lead≈D, pred_depth=0, never invent. + * Safety: if tip stalls too long, invent as TIP_STALE. */ + if (!s_gap1_legacy && st.remote_lead > 0) { + tip_stale_ms = np_invent_rtt_ms(NULL) * 4u; + if (tip_stale_ms < 150u) + tip_stale_ms = 150u; + if (tip_stale_ms > 400u) + tip_stale_ms = 400u; + if ((uint32_t)(now_miss - s_miss_t0) < tip_stale_ms) { + static rnet_u32 s_cw_wire; + if (s_cw_wire != wire) { + s_cw_wire = wire; + g_admit_cushion_wait++; + } + /* gap=1: still count as grace-wait for telemetry. */ + if (gap == 1u) { + static rnet_u32 s_gap1_counted_wire; + if (s_gap1_counted_wire != wire) { + s_gap1_counted_wire = wire; + g_admit_gap1_grace++; + np_admit_maybe_log_stats(now_miss); + } + } + return 0; + } + invent_reason = "TIP_STALE"; + g_admit_invent_tip_stale++; + } else if (s_gap1_legacy && gap == 1u) { + /* §29: §28's per-miss Case A wait was reverted — soak data + * showed remote_lead sitting at a rock-stable D-1 the whole + * match (not drifting, not recovering), the signature of a + * fixed ~1-tick network transit delay, not a wait-it-out + * jitter blip. Waiting 4-10ms on every such miss only added + * admit tax (88% still expired into invent) and inflated + * `tip_ema` in a feedback loop (soak: 15ms -> 30-40ms), + * making later misses look "fresher" than they were. A + * fixed transit delay cannot be waited out per-tick; it can + * only be closed by pacing the peer that is structurally + * racing ahead (see np_timesync_note_late — that mechanism + * IS asymmetric-safe, per soak evidence one side owns + * essentially all mispredicts) or absorbed with more delay + * (D). So gap=1 invents immediately again (back to §27), + * classified for diagnostics only (GAP1_PHASE = tip was + * healthy/advancing when we inverted; GAP1_LEGACY = it was + * not) at zero added latency. PSX_RB_GAP1_GRACE_MS still + * forces the old flat-cap wait for A/B testing. */ + int has_override = 0; + uint32_t gap1_cap = np_gap1_grace_cap_ms(&has_override); + int case_a = 0; + + if (!has_override) { + uint32_t tip_age = np_tip_age_ms(); + uint32_t period = + g_tip_arrival_ema_ms ? g_tip_arrival_ema_ms : 17u; + + case_a = (st.remote_lead >= 1) && + (tip_age != 0xffffffffu) && + (tip_age * 2u < period * RB_TIP_FRESH_MULT_X2); + if (case_a) + g_admit_gap1_case_a++; + else + g_admit_gap1_case_b++; + gap1_cap = 0u; /* no wait — classification only */ + } + if (gap1_cap != 0u) { + if (np_invent_grace_stall_ex(slot, wire, gap1_cap, 0)) { + static rnet_u32 s_gap1_counted_wire; + if (s_gap1_counted_wire != wire) { + s_gap1_counted_wire = wire; + g_admit_gap1_grace++; + np_admit_maybe_log_stats(np_mono_ms()); + } + return 0; + } + np_gap1_note_expire_invent(); + } + invent_reason = case_a ? "GAP1_PHASE" : "GAP1_LEGACY"; + g_admit_invent_gap1_legacy++; + } else { + /* §27 shallow invent: pred_depth≥2 only after tip looks stale + * (1× invent RTT, floor 40ms). Avoids burning deep into P + * every tick (constant deep resim). Then short grace. */ + uint32_t pred_depth = gap; + tip_stale_ms = np_invent_rtt_ms(NULL); + if (tip_stale_ms < RB_INVENT_DEPTH_STALE_FLOOR_MS) + tip_stale_ms = RB_INVENT_DEPTH_STALE_FLOOR_MS; + if (pred_depth >= 2u && + (uint32_t)(now_miss - s_miss_t0) < tip_stale_ms) { + return 0; + } + if (np_invent_grace_stall_ex(slot, wire, + RB_INVENT_RUNWAY_GRACE_CAP_MS, 1)) + return 0; + invent_reason = "RUNWAY_EMPTY"; + g_admit_invent_runway_empty++; + } + /* 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. */ + np_admit_note_invent_gap(wire, st.highest_remote_wire); + np_admit_log_invent(sim, wire, st.highest_remote_wire, + st.remote_lead, invent_reason); + any_invent = 1; (void)netplay_ih_invent_hold_last(&g_np.ih, slot, sim, &row); } } + /* Remote caught up or we invented inside P — leave freeze if armed. */ + np_pcap_freeze_exit(); + if (any_invent) + np_admit_maybe_log_stats(np_mono_ms()); + np_publish_hist_sio(sim); g_np.needs_advance = 1; return 1; @@ -2188,12 +2985,46 @@ uint32_t psx_netplay_sim_tick(void) return rnet_session_sim_tick(g_np.session); } +/* §34 MotK digital: bridge 1–2 tick idle holes so controller bounce does not + * publish press/idle/press edges (menu edge-triggers → double navigations). + * Longer gaps stay real releases; tip-hold idle-dwell covers the rest. */ +#define NP_DIGITAL_RELEASE_DEBOUNCE_TICKS 2u +static uint16_t g_dig_sticky_buttons = 0xFFFFu; +static uint32_t g_dig_sticky_idle_n; + +static void np_digital_debounce_staged(void) +{ + uint16_t b; + if (!g_np.rollback || !g_np.staged_valid) + return; + b = g_np.staged.buttons; + if (b == 0xFFFFu && g_dig_sticky_buttons != 0xFFFFu) { + if (g_dig_sticky_idle_n < NP_DIGITAL_RELEASE_DEBOUNCE_TICKS) { + g_np.staged.buttons = g_dig_sticky_buttons; + g_dig_sticky_idle_n++; + } else { + g_dig_sticky_buttons = 0xFFFFu; + g_dig_sticky_idle_n = 0u; + } + } else if (b != 0xFFFFu) { + g_dig_sticky_buttons = b; + g_dig_sticky_idle_n = 0u; + } +} + void psx_netplay_stage_local(const PsxNetPad *pad) { if (!pad) { g_np.staged_valid = 0; + g_np.live_valid = 0; return; } + /* Always refresh live physical snapshot first. TipHold invent-cap parks + * sim (and therefore the latched staged sample); SAFETY/quiet must still + * observe a real release (§36). */ + g_np.live = *pad; + psx_netplay_normalize_pad(&g_np.live); + g_np.live_valid = 1; /* Once running, freeze the first sample for the current sim tick so * re-admits / barrier retries cannot change the INPUT_CONFIRM hash. */ if (psx_netplay_active() && rnet_session_is_running(g_np.session)) { @@ -2202,6 +3033,7 @@ void psx_netplay_stage_local(const PsxNetPad *pad) return; g_np.staged = *pad; psx_netplay_normalize_pad(&g_np.staged); + np_digital_debounce_staged(); g_np.staged_valid = 1; g_np.latched_for_tick = 1; g_np.latched_sim_tick = t; @@ -2223,6 +3055,18 @@ int psx_netplay_needs_local_sample(void) } } +int psx_netplay_live_pad_buttons(uint16_t *out) +{ + if (!out) + return 0; + if (!g_np.live_valid) { + *out = 0xFFFFu; + return 0; + } + *out = g_np.live.buttons; + return 1; +} + int psx_netplay_input_desync(uint32_t *tick, uint32_t *local_hash, uint32_t *remote_hash) { if (!psx_netplay_active()) return 0; @@ -2686,6 +3530,7 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) else sio_set_multitap(0); g_np.staged_valid = 0; + g_np.live_valid = 0; g_np.needs_advance = 0; g_np.latched_for_tick = 0; g_np.latched_sim_tick = 0; @@ -3489,6 +4334,8 @@ void psx_netplay_finish_frame(void) } } rnet_session_advance(g_np.session); + /* DELAY_SYNC may commit on this advance — keep g_np.input_delay aligned. */ + np_sync_input_delay_from_session(); g_np.needs_advance = 0; g_np.latched_for_tick = 0; g_np.frames_finished++; diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index 2c5a2366..a3602b52 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -43,6 +43,7 @@ int psx_netplay_rb_tip_holding(void) { return 0; } uint32_t psx_netplay_rb_episode_target(void) { return 0; } uint32_t psx_netplay_rb_tip_hold_invent_slack(void) { return 0; } uint32_t psx_netplay_rb_tip_runway(void) { return 0; } +void psx_netplay_rb_tip_hold_block_quiet(int block) { (void)block; } int psx_netplay_rb_ignore_peer_frame_commit(uint32_t tick, uint32_t hash) { (void)tick; @@ -80,6 +81,7 @@ uint32_t psx_netplay_rb_sticky_bb_pc(void) { return 0; } #include "netplay_rb_post.h" #include "netplay_snap_ring.h" #include "netplay_state_digest.h" +#include "psx_netplay.h" #include "boot_state.h" #include "cdrom.h" #include "cpu_state.h" @@ -103,7 +105,13 @@ static PsxNetplayRbBindings g_b; static int g_bound; static RNetRbSession *g_rb; static NetplaySnapRing *g_snaps; +/* Episode counter (NOT the wire epoch). Wire epoch ids are partitioned by + * initiator slot — epoch = (counter << RB_EPOCH_SLOT_BITS) | initiator_slot — + * so concurrent dual-initiation can never collide on an id and any epoch's + * initiator is derivable for the tie-break. See rb_epoch_alloc(). */ static uint32_t g_epoch; +#define RB_EPOCH_SLOT_BITS 3u +#define RB_EPOCH_SLOT_MASK 7u static uint32_t g_episode_count; /* Ticks armed into Replay, accumulated for the host's periodic [FPS] line * (psx_netplay_rb_take_replay_ticks resets on read). Lets a soak confirm @@ -195,6 +203,25 @@ static uint32_t g_stash_bl_dig_a; static uint32_t g_stash_bl_dig_b; static uint32_t g_stash_bl_dig_c; static int g_stash_bl_logged; +/* SEAL_ROWS that arrived before the local episode opened (same TURN/UDP + * reordering race as the baseline stash above — the initiator calls + * export_local_seals() immediately after sending SYNC BEGIN, and a burst of + * chunk packets can beat a delayed/reordered SYNC through NAT relay). Small + * ring since export_local_seals() only ever emits a handful of chunks per + * episode open; entries not consumed by the matching begin_follower/tip + * extend are just dropped on the next episode (epoch mismatch). */ +#define RB_STASH_SEAL_ROWS_MAX 8 +static struct { + int valid; + uint32_t epoch; + uint32_t mismatch; + uint32_t target; + uint8_t slot; + uint32_t row_begin; + RNetRbFrame rows[24]; + uint16_t row_count; +} g_stash_seal_rows[RB_STASH_SEAL_ROWS_MAX]; +static int g_stash_seal_rows_logged; static uint64_t g_baseline_handshake_ms; /* when local baseline first sent */ static uint64_t g_last_baseline_burst_ms; static int g_ready_timeout_logged; @@ -244,6 +271,11 @@ static uint32_t g_last_begin_mismatch = 0xffffffffu; #define RB_ABORT_COOLDOWN_TICKS 24u /* Repeated abort streak — longer promote-only (char-select storm). */ #define RB_STORM_COOLDOWN_TICKS 48u +/* After a genuine POST diverge where Live successfully realigned, do not + * promote-only-block the next edges — cooldown was preventing the fork from + * self-correcting (2026-08-01 §17 soak: tip=9203 abort → promote-no-resim + * until 9228 while live digs stayed forked). Storm streak still cools. */ +#define RB_POST_REALIGN_COOLDOWN_TICKS 0u /* Cap initiator begin/follow-open resim span. Deep catch-up after abort is * chunked (commit → next episode) instead of one 120-tick Replay. */ #define RB_MAX_RESIM_SPAN 24u @@ -266,12 +298,22 @@ static uint32_t g_last_begin_mismatch = 0xffffffffu; * at invent_at+1 (896) — bump to 64. */ #define RB_FMV_UNLOCK_GRACE 64u /* MotK TipHold: quiet window so press→release→D-pad coalesce in one episode. - * invent slack 0 (UINT32_MAX sentinel) so Live never *invents* past tip - * (tip-extend rereplay was the menu FPS cliff). Runway is wall-clock frames - * at 60 Hz (not admit-pump spins — those finalized in ms and killed coalesce; - * 2026-08-01 soak: 0 tip-extends, 351 fresh light episodes). */ + * §27: seal slack uses library default (2) so Live may invent tip+1..tip+2 + * during tip-hold — FORCE0 parked presentation at the tip (mini delay-sync). + * Runway is wall-clock frames at 60 Hz. */ #define RB_MOTK_TIP_RUNWAY 24u -#define RB_MOTK_TIP_SEAL_SLACK_FORCE0 0xffffffffu +/* TipHold deadlines (§27): brief coalesce, not a multi-hundred-ms park. + * Quiet 80ms; thrash sooner when coalesce is busy; safety above quiet. */ +/* §34: quiet must outlast micro-chatter idle holes (~2–5 ticks) without + * approaching SAFETY; 80ms was committing between bounce pulses. */ +#define RB_MOTK_TIP_HOLD_QUIET_MAX_MS 150u +#define RB_MOTK_TIP_HOLD_QUIET_MIN_MS 100u +#define RB_MOTK_TIP_HOLD_THRASH_COUNT 2u +#define RB_MOTK_TIP_HOLD_THRASH_WALL_MS 100u +#define RB_MOTK_TIP_HOLD_SAFETY_WALL_MS 250u +/* §35: SAFETY must not commit while digital still held (MotK key-repeat). + * Absolute wall is the stuck-pad escape only. */ +#define RB_MOTK_TIP_HOLD_ABSOLUTE_WALL_MS 2000u /* Baseline mismatch with no agreed tip — don't reopen every few ticks. */ #define RB_BASELINE_MISMATCH_COOLDOWN_TICKS 90u @@ -286,8 +328,15 @@ static uint32_t g_fmv_dense_through; static uint32_t g_bl_mismatch_streak; static uint32_t g_tip_hold_until; /* TipHold invent-cap quiet: wall-clock ms when we first stalled at tip+slack - * (0 = not armed). Replaces the old pump-frame counter. */ + * with pads idle and no pending wire edge (0 = not armed). */ static uint64_t g_tip_hold_quiet_t0_ms; +/* §34: coalesce/begin saw a wire edge TipHold could not absorb yet — do not + * quiet-commit (was dropping releases → ghost re-press episodes). */ +static int g_tip_hold_block_quiet; +/* Wall-clock enter time for TipHold deadlines (0 = inactive). */ +static uint64_t g_tip_hold_enter_ms; +/* TipHold coalesce/tip-extend count this episode (thrash WALL trigger). */ +static uint32_t g_tip_hold_coalesce_n; /* Mid-span tip-extend rereplay: reload snap without replacing episode pin. */ static int g_tip_extend_rereplay; /* Inclusive tip of last tip-extend hc_prime; drop stale TipHold invent @@ -353,7 +402,8 @@ static void rb_fmv_update_lockstep_gate(uint32_t sim) g_promote_sweep = 1; fprintf(stderr, "psxrecomp: rb FMV lockstep RELEASE sim=%u streak=%u " - "cap=%u invent_at=%u (grace=%u; promote_sweep%s)\n", + "cap=%u dense_until=%u (unlock_grace=%u; promote_sweep%s; " + "invent already on after settle §26)\n", (unsigned)sim, (unsigned)g_fmv_core_match_streak, (unsigned)cap, (unsigned)g_fmv_lockstep_until, (unsigned)RB_FMV_UNLOCK_GRACE, @@ -435,8 +485,10 @@ static void rb_fmv_tick_settle(void) if (g_b.hc) netplay_hc_prime_after(g_b.hc, sim); fprintf(stderr, - "psxrecomp: rb FMV settle until sim=%u lockstep min=%u " - "max=%u confirm=%u (no invent; hc primed)\n", + "psxrecomp: rb FMV settle until sim=%u invent_hold=%u " + "dense_lockstep min=%u max=%u confirm=%u " + "(no invent through settle; then invent+resim §26; hc primed)\n", + (unsigned)g_fmv_settle_until, (unsigned)g_fmv_settle_until, (unsigned)g_fmv_lockstep_until, (unsigned)(sim + RB_FMV_LOCKSTEP_MAX), @@ -457,7 +509,13 @@ static int rb_in_fmv_defer_rewind_window(void) return sim < g_fmv_settle_until; } -/* Media + post-FMV lockstep: admit waits for remote wire (title Start safe). */ +/* Admit no-invent gate (§26): media + short settle only. + * Pre-§26 also blocked invent through g_fmv_lockstep_until (MIN 180 + + * UNLOCK_GRACE 64 ≈ 4s of title-menu delay-sync). On TURN/CGNAT that + * re-serialized presentation to ~packet rate (~30 fps) even with + * PSX_RB_GAP1_INVENT — invent count was 0 from FMV-on through invent_at. + * Digest-gated lockstep (g_fmv_lockstep_until) still runs for dense snaps + * / RELEASE logs; it no longer stalls admit. */ static int rb_in_fmv_lockstep_window(void) { RNetSession *s = sess(); @@ -465,7 +523,7 @@ static int rb_in_fmv_lockstep_window(void) rb_fmv_tick_settle(); if (rb_fmv_media_active()) return 1; - return sim < g_fmv_lockstep_until; + return sim < g_fmv_settle_until; } static void arm_rewind_cooldown_ticks(uint32_t sim, uint32_t ticks, const char *why) @@ -479,6 +537,126 @@ static void arm_rewind_cooldown_ticks(uint32_t sim, uint32_t ticks, const char * fflush(stderr); } +static void clear_rewind_cooldown(const char *why) +{ + g_rewind_cooldown_until = 0; + g_promote_sweep = 0; + fprintf(stderr, "psxrecomp: rb rewind cooldown cleared (%s)\n", + why ? why : "?"); + fflush(stderr); +} + +/* Dump sealed pads + hist + FRAME_COMMIT digests for the failed episode span + * BEFORE abort_episode clears the seal table. Cross-peer: both sides print + * the same shape — compare tip_btn / FC first_mismatch to find the fork. */ +static void dump_post_diverge_diag(const char *why) +{ + uint32_t load; + uint32_t tip; + uint32_t t; + uint32_t first_fc_mis = 0; + uint32_t first_fc_loc = 0; + uint32_t first_fc_peer = 0; + int n; + int slot; + int sealed_ok; + + if (!g_rb || !rnet_rb_is_active(g_rb)) + return; + load = rnet_rb_get_load_tick(g_rb); + tip = rnet_rb_get_target_tick(g_rb); + sealed_ok = rnet_rb_inputs_sealed(g_rb) ? 1 : 0; + n = g_b.slot_count ? *g_b.slot_count : 0; + if (n < 1) + n = 2; + if (n > (int)RNET_RB_MAX_SLOTS) + n = (int)RNET_RB_MAX_SLOTS; + + fprintf(stderr, + "psxrecomp: rb POST-DIVERGE diag epoch=%u load=%u tip=%u " + "seal_base=%u span=%u sealed=%d why=%s\n", + (unsigned)rnet_rb_get_epoch_id(g_rb), (unsigned)load, (unsigned)tip, + (unsigned)rnet_rb_get_seal_base_tick(g_rb), + (unsigned)rnet_rb_get_seal_span(g_rb), sealed_ok, + why ? why : "?"); + + if (sealed_ok && tip >= load) { + fprintf(stderr, "psxrecomp: rb POST-DIVERGE seals (btn; P=predicted):\n"); + for (t = load; t <= tip; ++t) { + fprintf(stderr, " t=%u", (unsigned)t); + for (slot = 0; slot < n; ++slot) { + RNetRbFrame seal; + RNetRbFrame hist; + int have_s = rnet_rb_get_sealed_frame(g_rb, slot, t, &seal) && + seal.is_valid; + int have_h = g_b.ih && + netplay_ih_get(g_b.ih, slot, t, &hist) && + hist.is_valid; + if (have_s) + fprintf(stderr, " s%d=%04x%s", slot, (unsigned)seal.buttons, + seal.is_predicted ? "P" : ""); + else + fprintf(stderr, " s%d=----", slot); + if (have_h) { + fprintf(stderr, "/h=%04x%s", (unsigned)hist.buttons, + hist.is_predicted ? "P" : ""); + if (have_s && (seal.buttons != hist.buttons || + seal.stick_x != hist.stick_x || + seal.stick_y != hist.stick_y)) + fprintf(stderr, "!"); + } else { + fprintf(stderr, "/h=----"); + } + } + fprintf(stderr, "\n"); + } + } else { + fprintf(stderr, + "psxrecomp: rb POST-DIVERGE seals: (none — inputs not sealed " + "or empty span)\n"); + } + + /* FRAME_COMMIT local vs peer across the span — first mismatch is the + * earliest visible sim fork (often earlier than POST tip). */ + if (g_b.hc && tip >= load) { + fprintf(stderr, "psxrecomp: rb POST-DIVERGE fc (local/peer core):\n"); + for (t = load; t <= tip; ++t) { + uint32_t ld = 0, pd = 0; + int have_l = netplay_hc_local_digest(g_b.hc, t, &ld); + int have_p = netplay_hc_peer_digest(g_b.hc, t, &pd); + fprintf(stderr, " t=%u L=%08x%s P=%08x%s", (unsigned)t, + have_l ? (unsigned)ld : 0u, have_l ? "" : "?", + have_p ? (unsigned)pd : 0u, have_p ? "" : "?"); + if (have_l && have_p && ld != pd) { + fprintf(stderr, " MISMATCH"); + if (!first_fc_mis) { + first_fc_mis = t; + first_fc_loc = ld; + first_fc_peer = pd; + } + } else if (have_l && have_p) { + fprintf(stderr, " ok"); + } + fprintf(stderr, "\n"); + } + if (first_fc_mis) { + fprintf(stderr, + "psxrecomp: rb POST-DIVERGE fc FIRST_MISMATCH t=%u " + "local=%08x peer=%08x (POST tip=%u — fork was %u ticks " + "earlier)\n", + (unsigned)first_fc_mis, (unsigned)first_fc_loc, + (unsigned)first_fc_peer, (unsigned)tip, + (unsigned)(tip - first_fc_mis)); + } else { + fprintf(stderr, + "psxrecomp: rb POST-DIVERGE fc FIRST_MISMATCH: none in " + "ring for load..tip (fork may be outside HC window or " + "only in POST fold)\n"); + } + } + fflush(stderr); +} + #if defined(PSX_HAS_GAME_DISPATCH) extern int psx_game_is_function_entry(uint32_t addr); #endif @@ -693,10 +871,54 @@ static void schedule_live_realign(uint32_t tick, const char *why) fflush(stderr); } +/* ---- Abort propagation (shared cooldown class) ---- + * Local aborts used to tear down silently: the peer kept sealing/verifying + * until its own (skewed) timeout, then armed a cooldown computed from + * different local evidence — the two peers re-armed on different schedules + * and re-initiated into each other (abort storms). Now every wire-visible + * abort sends OP_ABORT carrying the RNET_RB_ABORT_CLASS_* cooldown class so + * the peer mirrors teardown + cooldown from the same class. */ +static uint32_t g_abort_wire_class = RNET_RB_ABORT_CLASS_ABORT; +static uint32_t g_abort_wire_realign_tick; +static int g_abort_from_peer; /* applying a peer ABORT — no echo */ +static uint32_t g_peer_abort_last_epoch = 0xffffffffu; /* rexmit dedupe */ + +#define RB_ABORT_NOTICE_BURST 3 + +static void send_abort_notice(uint32_t epoch, uint32_t cls, uint32_t realign_tick) +{ + RNetSession *s = sess(); + int i; + if (!s || g_abort_from_peer) + return; + for (i = 0; i < RB_ABORT_NOTICE_BURST; ++i) + (void)rnet_session_send_rb_sync(s, epoch, cls, realign_tick, 0u, 0u, + RNET_RB_SYNC_OP_ABORT, 0u); +} + +static uint32_t abort_class_cooldown_ticks(uint32_t cls) +{ + switch (cls) { + case RNET_RB_ABORT_CLASS_REALIGN: return RB_POST_REALIGN_COOLDOWN_TICKS; + case RNET_RB_ABORT_CLASS_STORM: return RB_STORM_COOLDOWN_TICKS; + case RNET_RB_ABORT_CLASS_NO_SNAP: return RB_BASELINE_MISMATCH_COOLDOWN_TICKS; + case RNET_RB_ABORT_CLASS_ABORT: + default: return RB_ABORT_COOLDOWN_TICKS; + } +} + static void abort_episode(const char *why) { fprintf(stderr, "psxrecomp: rb episode ABORT — %s\n", why ? why : "?"); fflush(stderr); + /* Propagate before the session reset clears the epoch. Class defaults to + * ABORT; abort_episode_realign pre-stages a more specific class. */ + if (g_rb && rnet_rb_is_active(g_rb)) + send_abort_notice(rnet_rb_get_epoch_id(g_rb), g_abort_wire_class, + g_abort_wire_realign_tick); + g_abort_wire_class = RNET_RB_ABORT_CLASS_ABORT; + g_abort_wire_realign_tick = 0u; + psx_netplay_timesync_on_episode_boundary(); /* Preserve a queued Live realign load; clear only episode baseline loads. */ if (!g_live_realign_pending) g_pending_load_valid = 0; @@ -706,6 +928,9 @@ static void abort_episode(const char *why) g_episode_baseline_matched = 0; g_tip_hold_until = 0; g_tip_hold_quiet_t0_ms = 0ull; + g_tip_hold_block_quiet = 0; + g_tip_hold_enter_ms = 0ull; + g_tip_hold_coalesce_n = 0u; g_tip_extend_rereplay = 0; clear_tip_extend_prime(); psx_scheduler_top_level_resume_clear(); @@ -760,6 +985,28 @@ int psx_netplay_rb_recover_null_pc(struct CPUState *cpu_in, uint32_t *out_pc) return 0; } +/* Realign tip both peers already agreed on (pin > episode load > agreed). + * Shared with the peer-ABORT handler so both sides pick the same policy. */ +static int pick_realign_tip(uint32_t *out_tick) +{ + /* Prefer the frozen pre-resim baseline — ring load_tick was often overwritten. */ + if (g_episode_baseline_matched && g_pin_valid) { + *out_tick = g_pin_tick; + return 1; + } + if (g_episode_baseline_matched && g_snaps && + netplay_snap_ring_has(g_snaps, g_episode_load_tick)) { + *out_tick = g_episode_load_tick; + return 1; + } + if (g_agreed_valid && g_snaps && + netplay_snap_ring_has(g_snaps, g_agreed_through)) { + *out_tick = g_agreed_through; + return 1; + } + return 0; +} + /* After a failed episode: rewind Live to a tip both peers already agreed on. */ static void abort_episode_realign(const char *why) { @@ -770,50 +1017,58 @@ static void abort_episode_realign(const char *why) uint32_t live_sim = s ? rnet_session_sim_tick(s) : 0u; uint32_t tick = 0; int have = 0; + uint32_t cls; uint32_t cool_n; char cool_why[160]; int hard_fail; + int post_fail = why && strstr(why, "post ") != NULL; - /* Prefer the frozen pre-resim baseline — ring load_tick was often overwritten. */ - if (g_episode_baseline_matched && g_pin_valid) { - tick = g_pin_tick; - have = 1; - } else if (g_episode_baseline_matched && g_snaps && - netplay_snap_ring_has(g_snaps, g_episode_load_tick)) { - tick = g_episode_load_tick; - have = 1; - } else if (g_agreed_valid && g_snaps && - netplay_snap_ring_has(g_snaps, g_agreed_through)) { - tick = g_agreed_through; - have = 1; + have = pick_realign_tip(&tick); + /* Sealed table dies in abort_episode — dump first on POST diverge. */ + if (post_fail) + dump_post_diverge_diag(why); + + /* Classify BEFORE abort_episode so the wire ABORT carries the shared + * cooldown class and both peers re-arm on the same schedule. */ + if (post_fail && have) { + /* POST diverge with a realign tip is a heal path, not a storm seed — + * prior ready-timeout streak must not force STORM cooldown (soak §18: + * streak=2 → promote-no-resim while Live stayed forked at the pin). */ + g_bl_mismatch_streak = 0; + cls = RNET_RB_ABORT_CLASS_REALIGN; + } else { + hard_fail = !have || + (why && (strstr(why, "baseline") != NULL || + strstr(why, "resim core") != NULL || + post_fail || + strstr(why, "desync") != NULL)); + if (hard_fail) + g_bl_mismatch_streak++; + else + g_bl_mismatch_streak = 0; + + if (!have) + cls = RNET_RB_ABORT_CLASS_NO_SNAP; + else if (g_bl_mismatch_streak >= 2u) + cls = RNET_RB_ABORT_CLASS_STORM; + else + cls = RNET_RB_ABORT_CLASS_ABORT; } + cool_n = abort_class_cooldown_ticks(cls); + + g_abort_wire_class = cls; + g_abort_wire_realign_tick = have ? tick : 0u; abort_episode(why); if (have) schedule_live_realign(tick, why); - hard_fail = !have || - (why && (strstr(why, "baseline") != NULL || - strstr(why, "resim core") != NULL || - strstr(why, "post ") != NULL || - strstr(why, "desync") != NULL)); - if (hard_fail) - g_bl_mismatch_streak++; - else - g_bl_mismatch_streak = 0; - - if (!have) - cool_n = RB_BASELINE_MISMATCH_COOLDOWN_TICKS; - else if (g_bl_mismatch_streak >= 2u) - cool_n = RB_STORM_COOLDOWN_TICKS; - else if (hard_fail) - cool_n = RB_ABORT_COOLDOWN_TICKS; - else - cool_n = RB_ABORT_COOLDOWN_TICKS; - - snprintf(cool_why, sizeof(cool_why), "%s (live=%u streak=%u)", + snprintf(cool_why, sizeof(cool_why), "%s (live=%u streak=%u class=%u)", why ? why : "realign", (unsigned)live_sim, - (unsigned)g_bl_mismatch_streak); - arm_rewind_cooldown_ticks(live_sim, cool_n, cool_why); + (unsigned)g_bl_mismatch_streak, (unsigned)cls); + if (cool_n == 0u) + clear_rewind_cooldown(cool_why); + else + arm_rewind_cooldown_ticks(live_sim, cool_n, cool_why); } static int host_save_state(void *ctx, uint32_t tick) @@ -870,11 +1125,72 @@ static uint8_t host_hash_confirm_through(void *ctx, uint32_t tick) return netplay_hc_confirm_through(g_b.hc, tick); } +/* Session delay-ring → hist (authoritative). Tip-hold invent-cap stalls Live + * so hist often lacks tip+N local rows even though the pad was sampled into + * the wire ring at sim+delay earlier — seal must read that ring, not invent. */ +static int host_promote_from_session(int slot, uint32_t tick, RNetRbFrame *out) +{ + RNetSession *s = sess(); + RNetInputSample sample; + PsxNetPad pad; + RNetRbFrame frame; + int delay; + rnet_u8 delay_u8; + rnet_u32 wire; + int local; + + if (!s || !g_b.ih || slot < 0) + return 0; + delay = g_b.input_delay ? *g_b.input_delay : 0; + if (delay < 0) + delay = 0; + if (delay > 255) + delay = 255; + delay_u8 = (rnet_u8)delay; + wire = rnet_wire_tick_from_sim(tick, delay_u8); + local = (g_b.local_slot && slot == *g_b.local_slot) ? 1 : 0; + if (local) { + if (!rnet_session_peek_input(s, slot, wire, &sample)) + return 0; + } else if (!rnet_session_peek_remote_input(s, slot, wire, &sample)) { + return 0; + } + if (!sample.valid || sample.size < PSX_NETPLAY_PAD_BYTES) + return 0; + memset(&pad, 0, sizeof(pad)); + pad.buttons = (uint16_t)sample.bytes[0] | ((uint16_t)sample.bytes[1] << 8); + pad.lx = sample.bytes[2]; + pad.ly = sample.bytes[3]; + pad.rx = sample.bytes[4]; + pad.ry = sample.bytes[5]; + pad.analog = sample.bytes[6] ? 1u : 0u; + pad.connected = 1; + netplay_ih_pad_to_frame(&pad, tick, 0, &frame); + if (!netplay_ih_promote(g_b.ih, slot, &frame)) + return 0; + if (out) + *out = frame; + return 1; +} + +static void host_prefresh_seal_range(int slot, uint32_t lo, uint32_t hi) +{ + uint32_t t; + if (slot < 0 || hi < lo) + return; + for (t = lo; t <= hi; ++t) + (void)host_promote_from_session(slot, t, NULL); +} + static uint8_t host_get_input_row(void *ctx, int32_t slot, uint32_t tick, RNetRbFrame *out) { (void)ctx; if (!g_b.ih || !out) return 0; + /* Prefer delay-ring (auth) over hist — invent-idle may have poisoned hist + * while Live was invent-cap stalled during tip-hold. */ + if (host_promote_from_session((int)slot, tick, out)) + return 1u; if (netplay_ih_get(g_b.ih, (int)slot, tick, out)) return 1u; /* Seal path requires is_valid=1 rows or the peer never completes SealInputs @@ -1225,19 +1541,41 @@ static int choose_load_tick(uint32_t mismatch, uint32_t *out_load) return 0; } -/* MotK convention: SYNC with initiator=0 = follower cannot open episode - * (missing load snap). Initiator aborts SealInputs instead of hanging. */ +/* Follower's confirmed frontier for the NACK wire hint: the deepest tick this + * peer can prove agreement through (agreed watermark, else mutually + * hash-confirmed). 0 = unknown. The initiator demotes to this instead of + * guessing load-1, so both watermarks converge on a mutually provable tick. */ +static uint32_t follower_frontier_hint(void) +{ + uint32_t oldest = g_snaps ? netplay_snap_ring_oldest_tick(g_snaps) : 0u; + if (g_agreed_valid && g_agreed_through >= oldest) + return g_agreed_through; + if (g_b.hc) { + uint32_t rt = netplay_hc_resolved_through(g_b.hc); + if (rt >= oldest && netplay_hc_confirm_through(g_b.hc, rt)) + return rt; + } + return 0u; +} + +/* MotK convention: SYNC op=NACK = follower cannot open episode (missing load + * snap / past frontier). Initiator aborts SealInputs instead of hanging. + * target field carries this follower's frontier (recomputed every send). */ static void send_follow_nack(uint32_t epoch, uint32_t mismatch, uint32_t load, uint32_t target, int slot, int log_line) { RNetSession *s = sess(); + uint32_t frontier = follower_frontier_hint(); + (void)target; /* episode target is not useful to the initiator; send frontier */ if (!s) return; - (void)rnet_session_send_rb_sync(s, epoch, mismatch, load, target, - (rnet_u8)(slot < 0 ? 0 : slot), 0u); + (void)rnet_session_send_rb_sync(s, epoch, mismatch, load, frontier, + (rnet_u8)(slot < 0 ? 0 : slot), + RNET_RB_SYNC_OP_NACK, 0u); if (log_line) { - fprintf(stderr, "psxrecomp: rb follow NACK epoch=%u load=%u (snap missing)\n", - (unsigned)epoch, (unsigned)load); + fprintf(stderr, + "psxrecomp: rb follow NACK epoch=%u load=%u frontier=%u\n", + (unsigned)epoch, (unsigned)load, (unsigned)frontier); fflush(stderr); } } @@ -1425,6 +1763,80 @@ static void stash_or_accept_baseline(uint32_t epoch, uint32_t load, uint32_t dig } } +/* Apply any SEAL_ROWS chunks stashed while the episode wasn't open yet. + * Mirrors apply_stashed_baseline(); call at every point an episode becomes + * active for a given epoch (begin_follower open, tip-extend absorb). */ +static void apply_stashed_seal_rows(void) +{ + int i; + if (!g_rb || !rnet_rb_is_active(g_rb)) + return; + for (i = 0; i < RB_STASH_SEAL_ROWS_MAX; ++i) { + if (!g_stash_seal_rows[i].valid) + continue; + if (g_stash_seal_rows[i].epoch != rnet_rb_get_epoch_id(g_rb)) { + /* Stale — belongs to an epoch we've since moved past. */ + g_stash_seal_rows[i].valid = 0; + continue; + } + g_stash_seal_rows[i].valid = 0; + (void)rnet_rb_apply_peer_seal_rows( + g_rb, g_stash_seal_rows[i].epoch, g_stash_seal_rows[i].mismatch, + g_stash_seal_rows[i].target, (int32_t)g_stash_seal_rows[i].slot, + g_stash_seal_rows[i].row_begin, g_stash_seal_rows[i].rows, + g_stash_seal_rows[i].row_count); + fprintf(stderr, + "psxrecomp: rb seal_rows unstash epoch=%u slot=%u row_begin=%u " + "n=%u\n", + (unsigned)g_stash_seal_rows[i].epoch, + (unsigned)g_stash_seal_rows[i].slot, + (unsigned)g_stash_seal_rows[i].row_begin, + (unsigned)g_stash_seal_rows[i].row_count); + fflush(stderr); + } + if (rnet_rb_get_phase(g_rb) == nRNetRbPhaseSealInputs && + rnet_rb_all_peer_seal_rows_complete(g_rb)) + enter_awaiting_baseline(); +} + +/* Stash a SEAL_ROWS chunk that arrived for an episode not open yet (or for a + * different epoch than the active one). Overwrites the oldest/invalid slot + * once full — losing a stale stash entry just means the sender's normal + * chunk retransmit cadence re-delivers it, same as a plain drop today. */ +static void stash_seal_rows_chunk(uint32_t epoch, uint32_t mismatch, uint32_t target, + uint8_t slot, uint32_t row_begin, + const RNetRbFrame *rows, uint16_t row_count) +{ + int i; + int victim = 0; + if (row_count > 24) + row_count = 24; + for (i = 0; i < RB_STASH_SEAL_ROWS_MAX; ++i) { + if (!g_stash_seal_rows[i].valid) { + victim = i; + break; + } + victim = i; /* full: keep evicting forward, favors newest chunks */ + } + g_stash_seal_rows[victim].valid = 1; + g_stash_seal_rows[victim].epoch = epoch; + g_stash_seal_rows[victim].mismatch = mismatch; + g_stash_seal_rows[victim].target = target; + g_stash_seal_rows[victim].slot = slot; + g_stash_seal_rows[victim].row_begin = row_begin; + memcpy(g_stash_seal_rows[victim].rows, rows, row_count * sizeof(RNetRbFrame)); + g_stash_seal_rows[victim].row_count = row_count; + if (!g_stash_seal_rows_logged) { + fprintf(stderr, + "psxrecomp: rb seal_rows stashed epoch=%u slot=%u row_begin=%u " + "n=%u (episode not active yet)\n", + (unsigned)epoch, (unsigned)slot, (unsigned)row_begin, + (unsigned)row_count); + fflush(stderr); + g_stash_seal_rows_logged = 1; + } +} + static void send_baseline_packet(int ready, int rexmit_log) { RNetSession *s = sess(); @@ -1868,6 +2280,148 @@ static void clear_tip_extend_prime(void) g_tip_extend_prime_tick = 0; } +/* Peek pad buttons from the delay ring at sim-keyed `tick` (0xFFFF if missing). */ +static uint16_t tip_hold_peek_buttons(int slot, uint32_t tick) +{ + RNetSession *s = sess(); + RNetInputSample sample; + int delay; + rnet_u8 delay_u8; + rnet_u32 wire; + int local; + if (!s || slot < 0) + return 0xFFFFu; + delay = g_b.input_delay ? *g_b.input_delay : 0; + if (delay < 0) + delay = 0; + if (delay > 255) + delay = 255; + delay_u8 = (rnet_u8)delay; + wire = rnet_wire_tick_from_sim(tick, delay_u8); + local = (g_b.local_slot && slot == *g_b.local_slot) ? 1 : 0; + if (local) { + if (!rnet_session_peek_input(s, slot, wire, &sample)) + return 0xFFFFu; + } else if (!rnet_session_peek_remote_input(s, slot, wire, &sample)) { + return 0xFFFFu; + } + if (!sample.valid || sample.size < 2u) + return 0xFFFFu; + return (uint16_t)sample.bytes[0] | ((uint16_t)sample.bytes[1] << 8); +} + +/* 1 if any seat currently shows a held digital button. + * Local: live physical pad (sim/staged stay frozen under invent-cap). + * Remote: latest arrived row in tip..tip+runway (not the parked sim tick). */ +static int tip_hold_any_pad_held(uint32_t tip) +{ + int slots = g_b.slot_count ? *g_b.slot_count : 0; + int local = g_b.local_slot ? *g_b.local_slot : -1; + int slot; + uint32_t runway = g_rb ? rnet_rb_get_tip_runway(g_rb) : 0u; + if (slots < 1) + slots = 2; + for (slot = 0; slot < slots; ++slot) { + if (slot == local) { + uint16_t live = 0xFFFFu; + if (psx_netplay_live_pad_buttons(&live) && live != 0xFFFFu) + return 1; + continue; + } + { + uint16_t latest = tip_hold_peek_buttons(slot, tip); + uint32_t t; + if (g_b.ih) { + RNetRbFrame tip_row; + if (netplay_ih_get(g_b.ih, slot, tip, &tip_row) && tip_row.is_valid) + latest = tip_row.buttons; + } + for (t = tip + 1u; runway > 0u && t <= tip + runway; ++t) { + uint16_t b = tip_hold_peek_buttons(slot, t); + RNetSession *s = sess(); + RNetInputSample sample; + int delay = g_b.input_delay ? *g_b.input_delay : 0; + rnet_u8 delay_u8; + rnet_u32 wire; + if (delay < 0) + delay = 0; + if (delay > 255) + delay = 255; + delay_u8 = (rnet_u8)delay; + wire = rnet_wire_tick_from_sim(t, delay_u8); + if (!s || !rnet_session_peek_remote_input(s, slot, wire, &sample)) + break; + if (g_b.ih) { + RNetRbFrame row; + if (netplay_ih_get(g_b.ih, slot, t, &row) && row.is_valid) + b = row.buttons; + } + latest = b; + } + if (latest != 0xFFFFu) + return 1; + } + } + return 0; +} + +/* 1 if wire tip+1..tip+runway has an unabsorbed RELEASE vs tip for a remote + * seat. §35: do NOT treat a future press (tip already idle) as pending — + * that over-blocked quiet for the full SAFETY window (soak: 21/30 SAFETY). + * Future presses tip-extend or open a fresh episode after quiet commit. */ +static int tip_hold_wire_pending_release(uint32_t tip) +{ + uint32_t runway; + int slots; + int slot; + int local; + if (!g_rb || tip == 0u) + return 0; + runway = rnet_rb_get_tip_runway(g_rb); + if (runway == 0u) + return 0; + slots = g_b.slot_count ? *g_b.slot_count : 0; + local = g_b.local_slot ? *g_b.local_slot : -1; + if (slots < 1) + slots = 2; + for (slot = 0; slot < slots; ++slot) { + uint16_t tip_btn; + uint32_t t; + if (slot == local) + continue; + tip_btn = tip_hold_peek_buttons(slot, tip); + if (g_b.ih) { + RNetRbFrame tip_row; + if (netplay_ih_get(g_b.ih, slot, tip, &tip_row) && tip_row.is_valid) + tip_btn = tip_row.buttons; + } + /* Tip already idle — nothing to coalesce-release. */ + if (tip_btn == 0xFFFFu) + continue; + for (t = tip + 1u; t <= tip + runway; ++t) { + uint16_t b; + RNetSession *s = sess(); + RNetInputSample sample; + int delay = g_b.input_delay ? *g_b.input_delay : 0; + rnet_u8 delay_u8; + rnet_u32 wire; + if (delay < 0) + delay = 0; + if (delay > 255) + delay = 255; + delay_u8 = (rnet_u8)delay; + wire = rnet_wire_tick_from_sim(t, delay_u8); + if (!s || !rnet_session_peek_remote_input(s, slot, wire, &sample)) + break; + b = tip_hold_peek_buttons(slot, t); + /* Any delta while tip is held (usually release) needs coalesce. */ + if (b != tip_btn) + return 1; + } + } + return 0; +} + static void enter_tip_hold(uint32_t target) { RNetSession *s = sess(); @@ -1883,6 +2437,8 @@ static void enter_tip_hold(uint32_t target) int had_local_post = g_local_post_sent; if (!g_rb || !rnet_rb_enter_tip_hold(g_rb)) return; + /* Episode boundary: don't let resim cost trip timesync adaptive-off. */ + psx_netplay_timesync_on_episode_boundary(); g_agreed_through = target; g_agreed_span_lo = g_episode_load_tick; g_agreed_valid = 1; @@ -1911,8 +2467,24 @@ static void enter_tip_hold(uint32_t target) * sit past target when tip-extend rereplay returns to TipHold. */ base = (sim > target) ? sim : target; g_tip_hold_until = base + runway; - g_tip_hold_quiet_t0_ms = 0ull; /* arm on first stalled poll */ + g_tip_hold_quiet_t0_ms = 0ull; /* arm on first stalled idle poll */ + g_tip_hold_block_quiet = 0; + g_tip_hold_enter_ms = rb_mono_ms(); + if (g_tip_hold_enter_ms == 0ull) + g_tip_hold_enter_ms = 1ull; + g_tip_hold_coalesce_n = 0u; g_needs_advance = 0; + /* If we never sampled RTT in the peer-POST path (common for the peer + * that receives POST first / light-tip skips), take one here from + * POST-send → tip-hold when the wait was meaningful. */ + if (had_local_post && g_verify_wait_ms != 0ull && g_rb_rtt_ema_ms < 4u) { + uint64_t now = rb_mono_ms(); + if (now >= g_verify_wait_ms) { + uint64_t sample = now - g_verify_wait_ms; + if (sample >= 4ull && sample <= 2000ull) + g_rb_rtt_ema_ms = (uint32_t)sample; + } + } clear_post_handshake(); clear_tip_extend_prime(); fprintf(stderr, "psxrecomp: rb tip-hold through=%u until=%u invent_slack=%u\n", @@ -1923,7 +2495,9 @@ static void enter_tip_hold(uint32_t target) static void finalize_tip_hold(void) { + RNetSession *s = sess(); uint32_t target; + int i; if (!g_rb || !rnet_rb_is_tip_holding(g_rb)) return; target = rnet_rb_get_target_tick(g_rb); @@ -1934,11 +2508,21 @@ static void finalize_tip_hold(void) if (g_b.hc) netplay_hc_prime_after(g_b.hc, target); rnet_rb_set_peer_convergence(g_rb, target); + /* Shared frontier: advertise the committed tip. Without this the peer's + * library watermark lagged our agreed_through and its next follow REFUSE + * / light-tip classification disagreed with ours (burst on loss). */ + if (s) { + for (i = 0; i < RB_ABORT_NOTICE_BURST; ++i) + (void)rnet_session_send_rb_resolved(s, target); + } 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_hold_quiet_t0_ms = 0ull; + g_tip_hold_block_quiet = 0; + g_tip_hold_enter_ms = 0ull; + g_tip_hold_coalesce_n = 0u; g_tip_extend_rereplay = 0; clear_tip_extend_prime(); g_needs_advance = 0; @@ -1947,6 +2531,11 @@ static void finalize_tip_hold(void) g_episode_baseline_matched = 0; clear_episode_wire_state(); clear_baseline_pin(); + /* Matched tip-hold is a clean commit — clear abort streak so a later + * POST realign is not storm-cooled by a stale ready-timeout. */ + g_bl_mismatch_streak = 0; + /* Live invent must rebuild the delay cushion (see psx_netplay.c). */ + psx_netplay_timesync_on_episode_boundary(); } static void poll_tip_hold_finalize(void) @@ -1959,6 +2548,8 @@ static void poll_tip_hold_finalize(void) uint32_t runway; uint64_t now; uint64_t need_ms; + int held; + int pending; if (!g_rb || !rnet_rb_is_tip_holding(g_rb) || !s) return; sim = rnet_session_sim_tick(s); @@ -1966,26 +2557,76 @@ static void poll_tip_hold_finalize(void) slack = rnet_rb_get_tip_seal_slack(g_rb); invent_cap = tip + slack; runway = rnet_rb_get_tip_runway(g_rb); + now = rb_mono_ms(); + held = tip_hold_any_pad_held(tip); + pending = tip_hold_wire_pending_release(tip) || g_tip_hold_block_quiet; + /* Drop stale block_quiet once pads are idle and tip has no release ahead — + * otherwise a failed tip-extend parked quiet for the whole SAFETY window. */ + if (g_tip_hold_block_quiet && !held && !tip_hold_wire_pending_release(tip)) + g_tip_hold_block_quiet = 0; + pending = tip_hold_wire_pending_release(tip) || g_tip_hold_block_quiet; + /* §25/§35: thrash WALL only when coalesce is busy *and* idle (no held / + * pending release). SAFETY never commits while digital held. */ + if (g_tip_hold_enter_ms != 0ull) { + uint64_t elapsed = now - g_tip_hold_enter_ms; + if (!held && !pending && + g_tip_hold_coalesce_n >= RB_MOTK_TIP_HOLD_THRASH_COUNT && + elapsed >= (uint64_t)RB_MOTK_TIP_HOLD_THRASH_WALL_MS) { + fprintf(stderr, + "psxrecomp: rb tip-hold THRASH CAP %u ms " + "(coalesce_n=%u) — commit through=%u\n", + (unsigned)RB_MOTK_TIP_HOLD_THRASH_WALL_MS, + (unsigned)g_tip_hold_coalesce_n, (unsigned)tip); + fflush(stderr); + finalize_tip_hold(); + return; + } + if (elapsed >= (uint64_t)RB_MOTK_TIP_HOLD_SAFETY_WALL_MS) { + if (held && + elapsed < (uint64_t)RB_MOTK_TIP_HOLD_ABSOLUTE_WALL_MS) { + static uint32_t s_held_defer_tip; + if (s_held_defer_tip != tip) { + fprintf(stderr, + "psxrecomp: rb tip-hold SAFETY deferred " + "(digital held) through=%u elapsed=%u ms\n", + (unsigned)tip, (unsigned)elapsed); + fflush(stderr); + s_held_defer_tip = tip; + } + } else { + fprintf(stderr, + "psxrecomp: rb tip-hold %s CAP %u ms — commit " + "through=%u held=%d pending=%d\n", + (elapsed >= (uint64_t)RB_MOTK_TIP_HOLD_ABSOLUTE_WALL_MS) + ? "ABSOLUTE" + : "SAFETY", + (elapsed >= (uint64_t)RB_MOTK_TIP_HOLD_ABSOLUTE_WALL_MS) + ? (unsigned)RB_MOTK_TIP_HOLD_ABSOLUTE_WALL_MS + : (unsigned)RB_MOTK_TIP_HOLD_SAFETY_WALL_MS, + (unsigned)tip, held, pending); + fflush(stderr); + finalize_tip_hold(); + return; + } + } + } /* Confirmed Live walked past the coalesce window (wire present during - * tip-hold — see np_try_admit_rollback). */ - if (g_tip_hold_until > 0u && sim > g_tip_hold_until) { + * tip-hold — see np_try_admit_rollback). Do not walk past while held — + * same key-repeat class as SAFETY-while-held. */ + if (g_tip_hold_until > 0u && sim > g_tip_hold_until && !held) { finalize_tip_hold(); return; } - /* Invent-cap stall (MotK slack=0 ⇒ sim stuck at tip waiting for wire): - * wait runway frames of *wall clock* at 60 Hz. The old pump-frame counter - * hit "24" in a few ms of admit spinning and committed before the paired - * release edge could tip-extend (soak: 0 tip-extends). Tip-extend resets - * quiet_t0 so an active coalesce keeps the episode open. */ - if (sim >= invent_cap) { - now = rb_mono_ms(); + /* §34/§35: quiet only while invent-capped, pads idle, and no pending + * release. Held button or unreabsorbed release resets the timer. */ + if (sim >= invent_cap && !held && !pending) { if (g_tip_hold_quiet_t0_ms == 0ull) g_tip_hold_quiet_t0_ms = now; need_ms = (uint64_t)runway * 1000ull / 60ull; - if (need_ms < 80ull) - need_ms = 80ull; - if (need_ms > 500ull) - need_ms = 500ull; + if (need_ms < (uint64_t)RB_MOTK_TIP_HOLD_QUIET_MIN_MS) + need_ms = (uint64_t)RB_MOTK_TIP_HOLD_QUIET_MIN_MS; + if (need_ms > (uint64_t)RB_MOTK_TIP_HOLD_QUIET_MAX_MS) + need_ms = (uint64_t)RB_MOTK_TIP_HOLD_QUIET_MAX_MS; if (runway > 0u && now >= g_tip_hold_quiet_t0_ms + need_ms) finalize_tip_hold(); } else { @@ -2107,7 +2748,9 @@ void psx_netplay_rb_start(void) cfg.local_slot = (uint32_t)(g_b.local_slot ? *g_b.local_slot : 0); cfg.delay = (uint32_t)delay; cfg.tip_runway = RB_MOTK_TIP_RUNWAY; - cfg.tip_seal_slack = RB_MOTK_TIP_SEAL_SLACK_FORCE0; + /* §27: 0 → RNET_RB_TIP_SEAL_SLACK_DEFAULT (2). Live invents past tip + * during TipHold so presentation keeps moving; tip-extend repairs edges. */ + cfg.tip_seal_slack = 0u; /* Keep the light-tip depth ceiling matched to tip_runway. A coalesced * TipHold episode's eventual depth (load..target after tip-extend) can * approach tip_runway ticks — with the library default of 16 that @@ -2141,7 +2784,11 @@ void psx_netplay_rb_start(void) g_snaps = NULL; return; } - g_epoch = 1; + g_epoch = 0; /* counter — wire ids are (counter << slot_bits) | slot */ + g_peer_abort_last_epoch = 0xffffffffu; + g_abort_wire_class = RNET_RB_ABORT_CLASS_ABORT; + g_abort_wire_realign_tick = 0u; + g_abort_from_peer = 0; g_episode_count = 0; g_pending_save_valid = 0; g_pending_load_valid = 0; @@ -2155,6 +2802,9 @@ void psx_netplay_rb_start(void) g_episode_load_tick = 0; g_tip_hold_until = 0; g_tip_hold_quiet_t0_ms = 0ull; + g_tip_hold_block_quiet = 0; + g_tip_hold_enter_ms = 0ull; + g_tip_hold_coalesce_n = 0u; g_tip_extend_rereplay = 0; g_tip_extend_prime_tick = 0; clear_baseline_pin(); @@ -2193,23 +2843,33 @@ void psx_netplay_rb_shutdown(void) g_bl_mismatch_streak = 0; g_tip_hold_until = 0; g_tip_hold_quiet_t0_ms = 0ull; + g_tip_hold_block_quiet = 0; + g_tip_hold_enter_ms = 0ull; + g_tip_hold_coalesce_n = 0u; g_tip_extend_rereplay = 0; g_tip_extend_prime_tick = 0; g_last_good_bb_pc = 0; g_agreed_valid = 0; g_agreed_through = 0; g_agreed_span_lo = 0; - g_epoch = 1; + g_epoch = 0; + g_peer_abort_last_epoch = 0xffffffffu; + g_abort_wire_class = RNET_RB_ABORT_CLASS_ABORT; + g_abort_wire_realign_tick = 0u; + g_abort_from_peer = 0; g_rb_rtt_ema_ms = 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). */ +/* Dense tip snaps: media/settle, plus the digest lockstep/+tip window even + * after invent is allowed again (§26) — first Cross/Start after FMV still + * wants a near-tip load. */ 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()) + if (rb_fmv_media_active()) + return 1; + if (sim < g_fmv_settle_until) return 1; if (g_fmv_media_end_sim && sim < g_fmv_lockstep_until + 32u && @@ -2530,7 +3190,7 @@ int psx_netplay_rb_fmv_media_active(void) int psx_netplay_rb_lockstep_no_invent(void) { - /* Media + post-FMV lockstep: wait for remote wire (title Start / skip). */ + /* Media + settle only (§26). Post-FMV title menus invent+resim. */ return rb_in_fmv_lockstep_window(); } @@ -2621,6 +3281,17 @@ int psx_netplay_rb_tip_extend(uint32_t mismatch_tick, int slot) /* TipHold/Verify leave invalidates the prior POST pair. */ if (phase_at_entry == nRNetRbPhaseTipHold || phase_at_entry == nRNetRbPhaseVerify) clear_post_handshake(); + /* Prefill hist from the delay ring before resign/extend so local seat + * (FOLLOW) and correction seat (initiator wire peek) seal auth pads — + * not invent-idle — while Live is invent-cap stalled at the prior tip. */ + { + uint32_t pref_lo = mismatch_tick; + uint32_t pref_hi = (new_target > mismatch_tick) ? new_target : mismatch_tick; + int local = g_b.local_slot ? *g_b.local_slot : -1; + host_prefresh_seal_range(slot, pref_lo, pref_hi); + if (local >= 0 && local != slot) + host_prefresh_seal_range(local, pref_lo, pref_hi); + } (void)rnet_rb_resign_slot_range(g_rb, slot, mismatch_tick, mismatch_tick); export_local_seals(); if (new_target > old_target) { @@ -2628,9 +3299,15 @@ int psx_netplay_rb_tip_extend(uint32_t mismatch_tick, int slot) 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)) { + /* Shared rereplay decision: the follower mirrors this flag instead + * of re-deriving from its own (possibly different) live tip. */ + rnet_u8 wire_flags = (rnet_u8)(need_rereplay ? RNET_RB_SYNC_FLAG_REREPLAY : 0u); + if (rnet_rb_recommend_light_tip(g_rb)) + wire_flags |= RNET_RB_SYNC_FLAG_LIGHT_TIP; (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); + load, new_target, (rnet_u8)(slot < 0 ? 0 : slot), + RNET_RB_SYNC_OP_BEGIN, wire_flags); } export_local_seals(); fprintf(stderr, @@ -2660,7 +3337,10 @@ int psx_netplay_rb_tip_extend(uint32_t mismatch_tick, int slot) if (cap > g_tip_hold_until) g_tip_hold_until = cap; } - g_tip_hold_quiet_t0_ms = 0ull; /* restart coalesce window */ + /* Rereplay needs a fresh quiet window after the resim settles. */ + g_tip_hold_quiet_t0_ms = 0ull; + if (phase_at_entry == nRNetRbPhaseTipHold) + g_tip_hold_coalesce_n++; fprintf(stderr, "psxrecomp: rb tip-extend rereplay epoch=%u mismatch=%u " "prior_tip=%u slot=%d until=%u\n", @@ -2672,8 +3352,12 @@ int psx_netplay_rb_tip_extend(uint32_t mismatch_tick, int slot) uint32_t cap = new_target + runway; if (cap > g_tip_hold_until) g_tip_hold_until = cap; - g_tip_hold_quiet_t0_ms = 0ull; /* restart coalesce window */ + /* §24: do NOT reset quiet_t0 on pure TipHold coalesce — extending + * tip_hold_until still absorbs the edge into this episode, but the + * wall-clock quiet / thrash deadline can still commit. */ + g_tip_hold_coalesce_n++; } + g_tip_hold_block_quiet = 0; return 1; } @@ -2698,8 +3382,25 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) if (rnet_rb_is_active(g_rb)) { if (psx_netplay_rb_tip_extend(mismatch_tick, slot)) return 1; - if (rnet_rb_is_active(g_rb)) + if (rnet_rb_is_tip_holding(g_rb)) { + int32_t corr = rnet_rb_get_corrected_slot(g_rb); + if (corr >= 0 && slot >= 0 && corr != (int32_t)slot) { + /* Different seat cannot tip-extend — commit and reopen so the + * release/press is not begin_refused then quiet-dropped (§34). */ + fprintf(stderr, + "psxrecomp: rb tip-hold yield different seat " + "corr=%d edge_slot=%d mismatch=%u — tip-hold commit\n", + (int)corr, slot, (unsigned)mismatch_tick); + fflush(stderr); + finalize_tip_hold(); + } else { + g_tip_hold_block_quiet = 1; + g_tip_hold_quiet_t0_ms = 0ull; + return 0; + } + } else if (rnet_rb_is_active(g_rb)) { return 0; + } fprintf(stderr, "psxrecomp: rb tip-hold yielded — begin fresh episode " "mismatch=%u slot=%d\n", @@ -2796,7 +3497,12 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) } memset(&corr, 0, sizeof(corr)); - corr.epoch_id = g_epoch++; + /* Slot-partitioned epoch id: (counter << bits) | initiator_slot. Dual + * initiation can never collide, and the tie-break can derive any epoch's + * initiator from the id alone. */ + g_epoch++; + corr.epoch_id = (g_epoch << RB_EPOCH_SLOT_BITS) | + ((uint32_t)(g_b.local_slot ? *g_b.local_slot : 0) & RB_EPOCH_SLOT_MASK); corr.mismatch_tick = mismatch_tick; corr.load_tick = load; corr.target_tick = target; @@ -2814,6 +3520,7 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) g_last_begin_mismatch = mismatch_tick; rnet_rb_begin_episode(g_rb, &corr); + psx_netplay_timesync_on_episode_boundary(); clear_episode_wire_state(); g_episode_snap_applied = 0; g_pending_resume_valid = 0; @@ -2823,8 +3530,16 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) rnet_rb_seal_inputs(g_rb, corr.load_tick, corr.target_tick, corr.slot); g_seal_wait_ms = rb_mono_ms(); g_follow_nack_pending = 0; - (void)rnet_session_send_rb_sync(s, corr.epoch_id, corr.mismatch_tick, corr.load_tick, - corr.target_tick, (rnet_u8)(slot < 0 ? 0 : slot), 1u); + { + /* Initiator-authoritative episode attributes: light-tip class rides + * the wire so the follower classifies identically (burst symmetry). */ + rnet_u8 wire_flags = (rnet_u8)( + (rnet_rb_get_corr_flags(g_rb) & RNET_RB_CORR_LIGHT_TIP) + ? RNET_RB_SYNC_FLAG_LIGHT_TIP : 0u); + (void)rnet_session_send_rb_sync(s, corr.epoch_id, corr.mismatch_tick, corr.load_tick, + corr.target_tick, (rnet_u8)(slot < 0 ? 0 : slot), + RNET_RB_SYNC_OP_BEGIN, wire_flags); + } export_local_seals(); fprintf(stderr, "psxrecomp: rb begin epoch=%u mismatch=%u load=%u target=%u slot=%d " @@ -2843,7 +3558,7 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) } static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uint32_t target, - int slot) + int slot, uint8_t wire_flags) { RNetRbCorrection corr; uint32_t snap_n; @@ -2857,11 +3572,13 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin RNetRbPhase phase_at_entry = rnet_rb_get_phase(g_rb); RNetSession *fs = sess(); uint32_t sim_now = fs ? rnet_session_sim_tick(fs) : 0u; - /* TipHold: rereplay only if Live invented past prior tip (mirror - * initiator). Verify always rereplays. */ - int need_rereplay = 0; + /* Shared rereplay decision: the initiator's flag is authoritative; + * OR in the local invent check (our Live inventing past the prior tip + * forks history regardless of what the initiator saw). Verify always + * rereplays. */ + int need_rereplay = (wire_flags & RNET_RB_SYNC_FLAG_REREPLAY) ? 1 : 0; if (phase_at_entry == nRNetRbPhaseTipHold) - need_rereplay = (sim_now > old_t) ? 1 : 0; + need_rereplay |= (sim_now > old_t) ? 1 : 0; else if (phase_at_entry == nRNetRbPhaseVerify) need_rereplay = 1; if (!rnet_rb_can_extend_target(g_rb, target)) { @@ -2879,6 +3596,13 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin return; } clear_post_handshake(); + { + int local = g_b.local_slot ? *g_b.local_slot : -1; + host_prefresh_seal_range(slot, old_t + 1u, target); + host_prefresh_seal_range(slot, mismatch, mismatch); + if (local >= 0 && local != slot) + host_prefresh_seal_range(local, old_t + 1u, target); + } 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); @@ -2898,7 +3622,13 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin uint32_t cap = target + runway; if (cap > g_tip_hold_until) g_tip_hold_until = cap; - g_tip_hold_quiet_t0_ms = 0ull; /* restart coalesce window */ + /* §24: only rereplay restarts quiet; pure TipHold coalesce + * keeps the quiet/thrash deadline running. */ + if (need_rereplay) + g_tip_hold_quiet_t0_ms = 0ull; + if (phase_at_entry == nRNetRbPhaseTipHold) + g_tip_hold_coalesce_n++; + g_tip_hold_block_quiet = 0; } } return; @@ -2914,6 +3644,43 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin fflush(stderr); finalize_tip_hold(); } + /* Dual-initiation tie-break: both peers opened concurrent episodes and + * each received the other's BEGIN while initiating. Deterministic winner: + * lower initiator slot (derived from the partitioned epoch id). The loser + * yields quietly — no cooldown, this is contention, not failure — and + * falls through to follow the winner. Only yield in the early handshake + * phases before our snap load mutated Live; deeper phases drop the SYNC + * and let the abort/NACK contract clean up. */ + if (g_rb && rnet_rb_is_active(g_rb) && !rnet_rb_is_from_peer_notify(g_rb) && + epoch != rnet_rb_get_epoch_id(g_rb)) { + uint32_t our_epoch = rnet_rb_get_epoch_id(g_rb); + uint32_t our_slot = our_epoch & RB_EPOCH_SLOT_MASK; + uint32_t their_slot = epoch & RB_EPOCH_SLOT_MASK; + RNetRbPhase ph = rnet_rb_get_phase(g_rb); + int early = (ph == nRNetRbPhaseSealInputs || + ph == nRNetRbPhaseAwaitingBaseline) && + !g_episode_snap_applied; + if (their_slot < our_slot && early) { + fprintf(stderr, + "psxrecomp: rb tie-break YIELD our epoch=%u to peer " + "epoch=%u (slot %u beats %u)\n", + (unsigned)our_epoch, (unsigned)epoch, + (unsigned)their_slot, (unsigned)our_slot); + fflush(stderr); + g_abort_wire_class = RNET_RB_ABORT_CLASS_REALIGN; + abort_episode("tie-break yield (dual initiation)"); + clear_rewind_cooldown("tie-break yield"); + /* fall through: follow the winner's episode below */ + } else if (their_slot > our_slot) { + fprintf(stderr, + "psxrecomp: rb tie-break WIN our epoch=%u over peer " + "epoch=%u (slot %u beats %u) — drop\n", + (unsigned)our_epoch, (unsigned)epoch, + (unsigned)our_slot, (unsigned)their_slot); + fflush(stderr); + return; + } + } if (!g_rb || rnet_rb_is_active(g_rb)) return; (void)rb_in_fmv_defer_rewind_window(); @@ -2980,6 +3747,12 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin * should have raised agreed; mirror choose_load fall-through). */ if (have_f && g_snaps && frontier < oldest) have_f = 0; + /* Shared frontier floor: the peer only advertises RESOLVED for ticks + * it committed (tip-hold/commit), so a load at-or-below the library's + * peer-advertised watermark is provably followable even when our own + * agreed/hc frontier lags (lost RESOLVED was a NACK-cycle seed). */ + if (have_f && frontier < rnet_rb_resolved_through(g_rb)) + frontier = rnet_rb_resolved_through(g_rb); /* Mirror choose_load hard cap: once tip-hold/commit set agreed_through, * refuse loads above it even if local hash_confirm matched TipHold Live * (heal raises agreed when that tip aged out of the ring). */ @@ -3009,13 +3782,16 @@ 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_ex(load, target, g_agreed_through, - rnet_rb_get_light_tip_max_depth(g_rb))) + /* Light-tip is initiator-authoritative (wire flag) — never re-derived + * from this peer's watermarks, so both sides classify identically. */ + if (wire_flags & RNET_RB_SYNC_FLAG_LIGHT_TIP) corr.flags = RNET_RB_CORR_LIGHT_TIP; - if (epoch >= g_epoch) - g_epoch = epoch + 1u; + /* Keep our epoch counter ahead of any seen counter (ids are + * slot-partitioned; this only keeps counters monotonic for logging). */ + if ((epoch >> RB_EPOCH_SLOT_BITS) > g_epoch) + g_epoch = epoch >> RB_EPOCH_SLOT_BITS; rnet_rb_begin_episode(g_rb, &corr); + psx_netplay_timesync_on_episode_boundary(); clear_episode_wire_state(); g_episode_snap_applied = 0; g_pending_resume_valid = 0; @@ -3031,17 +3807,23 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin (unsigned)epoch, (unsigned)mismatch, (unsigned)load, (unsigned)target, (unsigned)snap_n, g_b.local_slot ? *g_b.local_slot : -1); fflush(stderr); - if (rnet_rb_all_peer_seal_rows_complete(g_rb)) - enter_awaiting_baseline(); - else - apply_stashed_baseline(); + /* Initiator's export_local_seals() burst can have beaten this follower's + * own delayed SYNC through the relay — drain any stash for this epoch + * before checking completeness. */ + apply_stashed_seal_rows(); + if (rnet_rb_get_phase(g_rb) == nRNetRbPhaseSealInputs) { + if (rnet_rb_all_peer_seal_rows_complete(g_rb)) + enter_awaiting_baseline(); + else + apply_stashed_baseline(); + } } void psx_netplay_rb_pump(void) { RNetSession *s = sess(); rnet_u32 epoch, mismatch, load, target, row_begin; - rnet_u8 cslot, initiator, slot; + rnet_u8 cslot, op, wire_flags, slot; rnet_u32 dig_m, dig_a, dig_b, dig_c, in_dig; rnet_u8 match; RNetRbFrame rows[24]; @@ -3050,15 +3832,78 @@ void psx_netplay_rb_pump(void) if (!g_rb || !s) return; - while (rnet_session_take_rb_sync(s, &epoch, &mismatch, &load, &target, &cslot, &initiator)) { - if (!initiator) { - /* MotK: initiator=0 is follow-NACK (missing load snap), not an echo. */ + while (rnet_session_take_rb_sync(s, &epoch, &mismatch, &load, &target, &cslot, &op, + &wire_flags)) { + if (op == RNET_RB_SYNC_OP_ABORT) { + /* Peer tore its episode down. Mirror teardown + the shared + * cooldown class (mismatch field) so both peers re-arm on the + * same schedule instead of drifting to skewed local timeouts. */ + uint32_t cls = mismatch; + if (epoch == g_peer_abort_last_epoch) + continue; /* rexmit burst dedupe */ + g_peer_abort_last_epoch = epoch; + if (rnet_rb_is_active(g_rb) && epoch == rnet_rb_get_epoch_id(g_rb)) { + char why[96]; + uint32_t live_sim = rnet_session_sim_tick(s); + uint32_t tick = 0; + int have; + int snap_was_applied = g_episode_snap_applied; + snprintf(why, sizeof(why), "peer abort epoch=%u class=%u", + (unsigned)epoch, (unsigned)cls); + have = pick_realign_tip(&tick); + g_abort_from_peer = 1; + abort_episode(why); + g_abort_from_peer = 0; + if (snap_was_applied && have) + schedule_live_realign(tick, why); + /* Shared cooldown class → same re-arm schedule as the peer. */ + if (cls == RNET_RB_ABORT_CLASS_REALIGN) { + g_bl_mismatch_streak = 0; + } else { + g_bl_mismatch_streak++; + if (cls == RNET_RB_ABORT_CLASS_STORM && + g_bl_mismatch_streak < 2u) + g_bl_mismatch_streak = 2u; + } + { + uint32_t cool_n = abort_class_cooldown_ticks(cls); + if (cool_n == 0u) + clear_rewind_cooldown(why); + else + arm_rewind_cooldown_ticks(live_sim, cool_n, why); + } + } else { + /* Not (or no longer) in that episode — still honor hard + * cooldown classes so we don't instantly re-initiate into a + * peer that just declared a storm/no-snap failure. */ + if (cls == RNET_RB_ABORT_CLASS_STORM || + cls == RNET_RB_ABORT_CLASS_NO_SNAP) { + char why[96]; + snprintf(why, sizeof(why), + "peer abort (idle) epoch=%u class=%u", + (unsigned)epoch, (unsigned)cls); + arm_rewind_cooldown_ticks(rnet_session_sim_tick(s), + abort_class_cooldown_ticks(cls), why); + } + } + continue; + } + if (op == RNET_RB_SYNC_OP_NACK) { + /* MotK: op=NACK is follow-NACK (missing load snap / past frontier), + * not an echo. target field carries the follower's frontier. */ if (rnet_rb_is_active(g_rb) && epoch == rnet_rb_get_epoch_id(g_rb) && !rnet_rb_is_from_peer_notify(g_rb)) { char why[96]; RNetSession *ns = sess(); uint32_t live_sim = ns ? rnet_session_sim_tick(ns) : 0u; + uint32_t peer_frontier = target; uint32_t demote = (load > 0u) ? load - 1u : 0u; + /* Shared frontier: demote to the follower's advertised + * frontier when it is deeper than load-1 — a tick the + * follower can actually prove, so the reopened episode's + * load is followable instead of NACK-cycling. */ + if (peer_frontier > 0u && peer_frontier < demote) + demote = peer_frontier; /* Capture before abort_episode clears it — decides whether Live * must rewind (snap already applied) or can stay put. */ int snap_was_applied = g_episode_snap_applied; @@ -3120,15 +3965,20 @@ void psx_netplay_rb_pump(void) } continue; } - begin_follower(epoch, mismatch, load, target, (int)cslot); + begin_follower(epoch, mismatch, load, target, (int)cslot, wire_flags); } while (rnet_session_take_rb_seal_rows(s, &epoch, &mismatch, &target, &slot, &row_begin, rows, &row_count)) { - if (!rnet_rb_is_active(g_rb)) - continue; - if (epoch != rnet_rb_get_epoch_id(g_rb)) + /* Same TURN/UDP reordering race as BASELINE: the initiator's + * export_local_seals() burst can beat a delayed SYNC BEGIN through + * the relay. Stash instead of dropping so the follower doesn't have + * to wait out a full chunk retransmit before completing seal rows + * (2026-08-01 gap review). */ + if (!rnet_rb_is_active(g_rb) || epoch != rnet_rb_get_epoch_id(g_rb)) { + stash_seal_rows_chunk(epoch, mismatch, target, slot, row_begin, rows, row_count); continue; + } (void)rnet_rb_apply_peer_seal_rows(g_rb, epoch, mismatch, target, (int32_t)slot, row_begin, rows, row_count); if (rnet_rb_get_phase(g_rb) == nRNetRbPhaseSealInputs && @@ -3330,6 +4180,18 @@ uint32_t psx_netplay_rb_tip_runway(void) return rnet_rb_get_tip_runway(g_rb); } +void psx_netplay_rb_tip_hold_block_quiet(int block) +{ + if (!g_rb || !rnet_rb_is_tip_holding(g_rb)) + return; + if (block) { + g_tip_hold_block_quiet = 1; + g_tip_hold_quiet_t0_ms = 0ull; + } else { + g_tip_hold_block_quiet = 0; + } +} + int psx_netplay_rb_ignore_peer_frame_commit(uint32_t tick, uint32_t hash) { uint32_t ld = 0; diff --git a/runtime/tests/test_netplay_hash_confirm.c b/runtime/tests/test_netplay_hash_confirm.c index 338e77b1..82d6bdbb 100644 --- a/runtime/tests/test_netplay_hash_confirm.c +++ b/runtime/tests/test_netplay_hash_confirm.c @@ -31,6 +31,12 @@ int main(void) { CHECK(netplay_hc_confirm_through(&hc, 0), "tick 0 matched"); CHECK(netplay_hc_resolved_through(&hc) == 0u, "resolved at 0"); CHECK(!netplay_hc_confirm_through(&hc, 1), "tick 1 not yet"); + { + uint32_t pd = 0; + CHECK(netplay_hc_peer_digest(&hc, 0, &pd) && pd == 0x1111u, + "peer_digest peeks FRAME_COMMIT hash"); + CHECK(!netplay_hc_peer_digest(&hc, 1, &pd), "peer_digest missing tick"); + } netplay_hc_note_peer(&hc, 1, 0x2222u); CHECK(netplay_hc_confirm_through(&hc, 1), "tick 1 matched");