From d4ea4ade1fa3de9525b6d6932d9e7e3996236217 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 14 Aug 2026 02:25:59 -0400 Subject: [PATCH] fix crash path from bad splash screen bug, and optimize cross platform pacing + rollback --- docs/ROLLBACK_MOTK_HOOKUP.md | 143 ++++ runtime/include/dirty_ram_interp.h | 5 + runtime/include/interrupts.h | 3 + runtime/include/psx_netplay_rb.h | 25 +- runtime/src/dirty_ram_interp.c | 45 +- runtime/src/gpu.c | 23 +- runtime/src/interrupts.c | 47 +- runtime/src/psx_cycles.c | 6 + runtime/src/psx_netplay.c | 19 +- runtime/src/psx_netplay_rb.c | 741 ++++++++++++++++-- runtime/src/psx_netplay_sched.c | 7 +- runtime/src/traps.c | 14 + .../tests/test_psx_cycle_event_boundaries.c | 1 + 13 files changed, 959 insertions(+), 120 deletions(-) diff --git a/docs/ROLLBACK_MOTK_HOOKUP.md b/docs/ROLLBACK_MOTK_HOOKUP.md index 765e516e..670a8028 100644 --- a/docs/ROLLBACK_MOTK_HOOKUP.md +++ b/docs/ROLLBACK_MOTK_HOOKUP.md @@ -4863,6 +4863,9 @@ window. `media_kf=0` because load was past the media/settle unsafe range. 5. **On heal Live/tip-hold** — clear DESYNC invent-hold and RELEASE lockstep. 6. **Pad-mispredict** begins in the post-FMV window still SPAN (optionally with MEDIA_KF via `rb_want_heal_kf`); only silent-fork recovery is apply-only. +7. **POST match → Live** — `ownership_on_post_match` treats heal like + peer-ahead light: no ownership SPAN hop (session 28: matched POST @826 + then chain target=850 hung guest wait-FOLLOW → disconnect). **Re-soak watch (Win↔Linux 2P through title FMV):** @@ -4873,3 +4876,143 @@ window. `media_kf=0` because load was past the media/settle unsafe range. - No `ABORT — resim core diverge` on that heal episode; no `DESYNC — FMV lockstep MAX unmatched` from the same cutover. - Linux↔Linux control still clean; 3P WIRE_HOLE is a separate bug class. + +## 110. Post-FMV heal sticky + invent hold (session 3 loading hitch) (2026-08-13) + +**Soak (TM4 Win↔CachyOS session 3):** §109 heal POST matched @2494 → Live → +`FMV lockstep RELEASE`. Loading-screen hitch kept HC forked at 2495; absurd +lead + invent catch-up; second `hc-fork` with `heal=0` tip-SPAN → +`resim core diverge @2495` → storm DESYNC. + +**Cause:** `request_post_fmv_heal_kf` gated on pre-RELEASE lockstep only. +RELEASE dropped eligibility; invent free-ran through the soft fork. + +**What landed (§110):** + +1. **Heal sticky** (`RB_FMV_HEAL_STICKY_TICKS=300`) armed on heal Live/tip-hold + — `rb_post_fmv_heal_eligible` stays true through sticky (and DESYNC / + lockstep). Re-heal hc-fork keeps `media_kf=1 heal=1`. +2. **Invent hold** during sticky via `lockstep_no_invent` — no GAP1 invent + catch-up until sticky expires or cores rematch CONFIRM. +3. Early sticky clear when post-RELEASE HC streak hits CONFIRM. + +**Re-soak watch:** after heal expect `heal sticky until sim=…`; on hitch +re-fork expect `heal KF requested … sticky_until=` then apply-only again — +not `begin … media_kf=0 heal=0` / resim storm. Digs rematch → +`heal sticky cleared (cores rematched…)`. + +## 111. Post-FMV tip+1 ±1-cycle Win↔Linux (dirty entry poll) (2026-08-13) + +**Soak (MotK Win↔CachyOS after §110b verify heal):** tip **870** matched +(`core=1d304d69`, MEDIA-KF CRC match, resume `pc=0x80076880`, +`i_stat=00000001`). Resim fin@871: Win `cyc=492226568` vs Cachy +`492226569` (±1); cpu/clk/tim/ram fork; hc-fork `no pad mispredict`. + +**Cause:** post-FMV wait lives in a dirty-interp hole (not in static emit). +Dirty entry used a host-only `s_interp_entry_poll %% 64` stride to pump IRQs. +Peers that drifted through FMV entered the wait with opposite phases while +VBlank was already latched — one delivered immediately, the other after a +few wait-loop instructions (`exit_pc=0x800768C8`, `v0=1`). Same class as the +old CD54 hold gated on host `s_present_pending`. + +**What landed (§111):** + +1. **Deliverable → poll every dirty entry** (guest-deterministic). Keep the + %%64 throttle only when no IRQ is deliverable. +2. **`dirty_ram_irq_ambient_resync_after_restore`** — reset entry-poll stride + + 4096-insn pump gap from `psx_cycles_resync_after_restore`. +3. **Second MotK wait pair** `0x800768C8` (hold A) ↔ `0x80076880` (canonical + B) for IRQ hold / present gate / snap resume canonicalize. + +**Re-soak watch:** through title FMV → settle → tip+1 must keep matched +`audit fin` cyc (no ±1) and no `FIRST CORE DIVERGE` / heal-verify abort at +the first post-settle frame. Linux↔Linux control still clean. + +## 112. Post-snap LEGACY_SENTINEL tip+1 (loading screen) (2026-08-13) + +**Soak (MotK Win↔CachyOS after §111):** matched through settle@1354 and live +dig@1472; soft fork **sim=1480**. Tip **1479** matched (`core=f9e21305`, +resume `pc=0x800756e0`, `i_stat=00000001`). Heal verify fin@1480 cyc Δ4; +Win abort irqctx `same_thr=0 reason=3 exit_pc=0 epc=80000048` at arm cycle +(`PSX_EXC_ESCAPE_LEGACY_SENTINEL`). + +**Cause:** `interrupts_resync_after_restore` zeros compiled/dirty resume +latches. Sticky VBlank then delivered on the first post-`flush_resume` +check with `take_pc=0` → sentinel EPC / cross-thread restore, while a peer +that latched the BB PC first took the real same-thread path. + +**What landed (§112):** + +1. **`psx_irq_arm_compiled_resume_pc`** — publish resume PC (+ last-check edge). +2. **`psx_scheduler_resume_at` + RESUME_CURRENT dispatch** arm before + `psx_dispatch`. +3. **Delivery fallback** — if latches still 0 under top-level resume, use + `cpu->pc` instead of the sentinel. + +**Re-soak watch:** loading-screen tip+1 — no Win `irqctx … reason=3 +epc=80000048` on heal abort; fin cyc match; no `FIRST CORE DIVERGE` at the +first post-settle loading frame. + + +## 113. Win↔UNIX post-FMV invent + ahead-tip pacing (2026-08-13) + +**Soak:** after §111/§112 accuracy work, Win↔Cachy still showed chronic +cadence skew through loading: Win `phase ctrl lead≈+7..8` / `debt_ms=0`, +Cachy `GAP1_LEGACY` / `RUNWAY_EMPTY` invents. Soft fork @1480 sat **before** +`dense_lockstep min=1510` while invent had already unlocked at settle@1354. + +**What landed (§113):** + +1. **Invent hold through lockstep MIN** — `rb_in_fmv_lockstep_window` now + gates invent until `g_fmv_lockstep_until` (media_end+MIN), matching the + gate comment that already said invent stays off through MIN. Stall tag + `fmv_lockstep` after settle; settle log `invent_hold=` reports MIN. +2. **Ahead-of-tip timesync** — `np_timesync_note_ahead_skew`: when + `remote_lead < 0` for ~8 admits outside media/lockstep, add ~½-tick debt + so the inventing seat paces before GAP1 (BattleShip cross-OS pattern). +3. **Gap1 LAN grace cap 12→20 ms** — more room for a mid-flight tip row on + LAN Win↔UNIX before inventing. + +**Re-soak watch:** `RBE_CROSS_OS_PACING_DIAG=1` — through FMV→loading, +`stall=fmv_lockstep` (not invent) until MIN; `debt_ms` rises on the +ahead-of-tip seat; fewer `GAP1_LEGACY` / `RUNWAY_EMPTY` before tip+1. +Accuracy (§112 arm) still required for matched fin cyc at tip+1. + + +## 114. Platform tip+1 apply-only heal + KF stream (2026-08-13) + +**Problem:** matched-baseline tip+1 after post-FMV loading is Win↔Linux +platform nondeterminism (cpu/clk/tim/ram diverge with matched dirty). Verify +span past load always aborted; empty tip KF rematched tip and stormed. + +**What landed (§114):** + +1. Apply-only MEDIA_KF heal (`target=load`) — no tip+1 resim. +2. Heal Live → invent off + `g_post_fmv_platform_nondet` + initiator KF stream + every 16 sim ticks (dense snaps). +3. Heal loop CAP → same keep-live stream instead of hard DESYNC abort. + +**Gap:** stream always rewound to HC's stuck tip+1 fork (`resolved+1`), so +`choose_load` re-pinned keep forever → loading-screen livelock (no DESYNC). + +## 115. Platform KF stream escape / host-tip ride (2026-08-13) + +**Soak (MotK Win↔CachyOS after §114):** `FIRST CORE DIVERGE sim=1112`, heal +pin `1111` / `pc=0x800768e8`, then hundreds of `§114 KF stream begin +fork=1112` + `heal KF CAP →1111` with `invent off until rematch`. No DESYNC; +loading never advanced. HC `peek_mismatch` stays at tip+1 while +`resolved_through=keep`. + +**What landed (§115):** + +1. **Host-tip ride** — when HC fork is the known platform tip+1 and + `sim > fork`, stream `begin_rewind(sim)` so MEDIA-KF pins near host tip. +2. **Accept on advance** — heal Live/tip-hold with `tip > keep` primes HC + past the soft fork, clears invent-hold/stream, sets + `g_platform_accepted_fork` (hc-fork / begin refuse re-heal). +3. **Stream CAP** (`RB_FMV_PLATFORM_KF_STREAM_LIMIT=6`) — same stale fork + without escape → force accept. + +**Re-soak watch:** after first post-FMV tip+1 diverge, logs +`§115 KF stream ride host tip` and/or `§115 platform fork accepted`; sim +advances past keep; no endless `snap applied tick=`; loading completes. diff --git a/runtime/include/dirty_ram_interp.h b/runtime/include/dirty_ram_interp.h index c95a0f3e..857c042b 100644 --- a/runtime/include/dirty_ram_interp.h +++ b/runtime/include/dirty_ram_interp.h @@ -52,6 +52,11 @@ void dirty_ram_ld_delay_flush(CPUState* cpu); * on the next interp step (selfcheck MotK v0=countdown vs v0=1 forks). */ void dirty_ram_ld_delay_discard(void); +/* Re-anchor host-only dirty IRQ pump ambient (entry-poll stride + 4096-insn + * gap) after snap restore. Not in boot_state — peers that drifted through FMV + * otherwise entered the post-FMV dirty wait on opposite poll phases. */ +void dirty_ram_irq_ambient_resync_after_restore(void); + /* Overlay-cache windows — the address ranges eligible for capture, offline * recompilation, and per-entry-validated native execution (Rule 18 code that * does not exist in any compile-time image): diff --git a/runtime/include/interrupts.h b/runtime/include/interrupts.h index 4167ab0f..7b7d2507 100644 --- a/runtime/include/interrupts.h +++ b/runtime/include/interrupts.h @@ -102,6 +102,9 @@ uint32_t psx_compiled_irq_resume_pc(void); /* Soft-return / netplay BYE: drop sticky resume latches so rematch dig0 snaps * cannot inherit a prior-match game PC (see pick_snap_resume_pc). */ void psx_irq_clear_resume_latches(void); +/* Arm compiled IRQ resume before top-level flush_resume / savestate dispatch + * so a latched I_STAT cannot take LEGACY_SENTINEL with take_pc=0. */ +void psx_irq_arm_compiled_resume_pc(uint32_t pc); uint64_t psx_last_irq_check_cycle(void); uint64_t psx_interrupt_total_checks(void); uint32_t psx_interrupt_fast_maintenance(void); diff --git a/runtime/include/psx_netplay_rb.h b/runtime/include/psx_netplay_rb.h index 5a979f37..dd4d6e00 100644 --- a/runtime/include/psx_netplay_rb.h +++ b/runtime/include/psx_netplay_rb.h @@ -84,11 +84,15 @@ 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 + short settle (§26) — admit waits for remote wire. - * Post-FMV digest lockstep no longer blocks invent. Ticks FMV→settle. - * Also 1 while §93 MAX-unmatched DESYNC invent-hold is armed (expires). */ +/* 1 during FMV media + post-FMV lockstep MIN (§26/§113) — admit waits for + * remote wire through loading tip+1 (not settle-only). Ticks FMV→settle→MIN. + * Also 1 while §93 MAX-unmatched DESYNC invent-hold is armed (expires). + * §111: post-FMV heal sticky invent-hold only while remote_lead > 0. */ int psx_netplay_rb_lockstep_no_invent(void); +/* 1 while sim is still inside the short post-FMV settle tail (before MIN). */ +int psx_netplay_rb_fmv_settle_active(void); + /* §93/§94: 1 while MAX-unmatched DESYNC invent-hold is armed (not media/settle). * Stall tag should say fmv_desync_hold, not fmv_settle. */ int psx_netplay_rb_fmv_desync_hold(void); @@ -98,9 +102,22 @@ int psx_netplay_rb_fmv_desync_hold(void); void psx_netplay_rb_clear_fmv_desync_hold(const char *why); /* §109: next begin_rewind is an apply-only MEDIA_KF heal (target=load). - * Call from hc-fork / resim-diverge escalate before begin_rewind. */ + * Call from hc-fork / resim-diverge escalate before begin_rewind. + * §114: tip+1 verify-span abandoned (platform nondet); heal Live arms + * invent-off + KF stream instead of lockstep RELEASE. */ void psx_netplay_rb_request_post_fmv_heal_kf(void); +/* §110: 1 while post-FMV lockstep, DESYNC hold, or heal sticky — hc-fork + * may open apply-only KF even when invent is held. */ +int psx_netplay_rb_post_fmv_heal_eligible(void); + +/* §111: 1 while heal sticky window is live (invent hold may be tip-lead only). */ +int psx_netplay_rb_post_fmv_heal_sticky(void); + +/* §115: 1 if this tip+1 fork was accepted (HC primed past it) — hc-fork / + * begin must not re-open apply-only heal for it. */ +int psx_netplay_rb_platform_fork_accepted(uint32_t fork_tick); + /* §93: 1 if tick sits in the last FMV media bout / settle tail (or DESYNC * hold). Begin/follow/hc-fork must not open episodes that load there. * §97 MEDIA_KF (default on): returns 0 for media-range — episodes allowed. */ diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index d8eab838..fc3c5ad0 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -54,6 +54,11 @@ uint64_t g_dirty_ram_native_handoffs = 0; uint64_t g_dirty_pump_max_gap_insns = 0; uint64_t g_dirty_pump_count = 0; static uint64_t s_last_dirty_irq_pump_insns = 0; +/* Host-only stride for the no-pending IRQ entry throttle. NOT in the snap — + * peers that drifted apart through FMV entered the post-FMV dirty wait with + * different phases and forked tip+1 (Win↔Linux ±1 cyc / swapped cores). Reset + * on restore; when an IRQ is already deliverable, poll every entry instead. */ +static uint32_t s_interp_entry_poll = 0; /* EPC de-overload signal (Tomba 2 frame-1997 fix). Set to the committed guest PC * immediately around a dirty-pump psx_check_interrupts() call, 0 otherwise. When @@ -343,6 +348,13 @@ void dirty_ram_ld_delay_discard(void) { s_ld_pend_val = 0; } +void dirty_ram_irq_ambient_resync_after_restore(void) { + /* Re-anchor host-only IRQ pump ambient at the restored timeline so both + * peers take the first post-load dirty entry poll from the same phase. */ + s_last_dirty_irq_pump_insns = g_dirty_ram_insns_run; + s_interp_entry_poll = 0; +} + uint32_t g_insn_gate_lo = 0; /* extra always-log phys range [lo,hi) */ uint32_t g_insn_gate_hi = 0; /* 0 = extra range disabled */ uint32_t g_insn_freeze_addr = 0; /* candidate phys entry to watch */ @@ -2767,20 +2779,39 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ * per-invocation `(insns_executed & 0xFFF)` gate below can fire — so a * pending, IEc+IM2-enabled interrupt is never taken and the loop spins for * seconds while the CD IRQ that would set its wait-flag is never serviced. - * Poll on a GLOBAL invocation counter so short-block loops still yield to - * interrupts (this is the interpreter analogue of a block-leader poll in - * static code). psx_check_interrupts runs the handler and returns with + * When an IRQ is already deliverable, poll EVERY entry (guest-deterministic; + * MotK post-FMV overlay wait @0x80076880 with latched I_STAT.VBlank forked + * Win↔Linux on a host-only %%64 stride). Otherwise throttle on the global + * invocation counter. psx_check_interrupts runs the handler and returns with * registers restored, so continuing the loop afterward is safe. */ { - static uint32_t s_interp_entry_poll = 0; static int s_entry_poll_enabled = -1; /* DIAGNOSTIC toggle (590c236 x kind-30 escape regression hunt) */ if (s_entry_poll_enabled < 0) { const char* e = getenv("PSX_DIRTY_ENTRY_POLL"); s_entry_poll_enabled = (e && e[0] == '0') ? 0 : 1; } - if (s_entry_poll_enabled && (++s_interp_entry_poll & 0x3Fu) == 0) { - cpu->pc = pc; - psx_check_interrupts(cpu); + if (s_entry_poll_enabled) { + extern uint32_t i_stat; + uint32_t sr = cpu->cop0[12]; + int deliverable = + ((i_stat & i_mask) != 0u) && + !psx_get_in_exception() && + (sr & 0x1u) != 0u && + (sr & (1u << 10)) != 0u; + if (deliverable || (++s_interp_entry_poll & 0x3Fu) == 0) { + cpu->pc = pc; + s_last_dirty_irq_pump_insns = g_dirty_ram_insns_run; + psx_check_interrupts(cpu); + if (cpu->pc != 0u && !dirty_ram_same_pc(cpu->pc, pc)) { + /* Handler resumed elsewhere — surface to dispatch. */ + g_dirty_ram_blocks_run++; + if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; + g_dirty_interp_chain_target = cpu->pc; + OV_FPLOG_RET1(); + } + if (cpu->pc == 0u) + cpu->pc = pc; + } } } for (int i = 0; i < MAX_INSNS_PER_DISPATCH; i++) { diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index e67fb57b..99f926f6 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -2595,11 +2595,10 @@ void gpu_vblank_flush_present(void) { if (psx_netplay_active() && sio_hold_present_for_card()) return; } - /* MotK menu wait (0x8006CD54↔0x8006CDA0): present ONLY at an explicit - * CDA0 edge. Never present on a CD54 edge (sticky CDA0 must not allow - * that — soak ep3 non-det fin@946). Non-wait edges (FMV / cutover) must - * present even if sticky still names the wait loop — treating sticky as - * in_wait blocked deferred present for the whole FMV1→FMV2 gap. */ + /* MotK menu wait (0x8006CD54↔0x8006CDA0) and post-FMV overlay wait + * (0x800768C8↔0x80076880): present ONLY at an explicit B edge. Never + * present on an A edge (sticky B must not allow that). Non-wait edges + * (FMV / cutover) must present even if sticky still names the wait loop. */ { extern int psx_netplay_active(void); extern uint32_t psx_compiled_irq_resume_pc(void); @@ -2608,18 +2607,20 @@ void gpu_vblank_flush_present(void) { if (psx_netplay_active()) { const uint32_t wait_a = 0x8006CD54u; const uint32_t wait_b = 0x8006CDA0u; + const uint32_t wait2_a = 0x800768C8u; + const uint32_t wait2_b = 0x80076880u; uint32_t pc = psx_compiled_irq_resume_pc(); uint32_t last = psx_last_irq_check_pc(); uint32_t sticky = psx_netplay_rb_sticky_bb_pc(); uint32_t edge = pc ? pc : last; - if (edge == wait_a) + if (edge == wait_a || edge == wait2_a) return; - if (edge == wait_b || edge != 0u) { - /* CDA0, or non-wait (FMV/cutover) — present; ignore sticky */ - } else if (sticky == wait_a) { - return; /* latch cleared, sticky CD54 — defer */ + if (edge == wait_b || edge == wait2_b || edge != 0u) { + /* B edge, or non-wait (FMV/cutover) — present; ignore sticky */ + } else if (sticky == wait_a || sticky == wait2_a) { + return; /* latch cleared, sticky A — defer */ } - /* sticky CDA0 or unrelated/0 — present */ + /* sticky B or unrelated/0 — present */ } } s_flushing_present = 1; diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index 3e14e369..5c87cbe6 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -553,6 +553,18 @@ void psx_irq_clear_resume_latches(void) s_last_interrupt_check_cycle = UINT64_MAX; } +/* Publish the known resume PC before the first post-load / flush_resume + * dispatch. interrupts_resync zeros the latches; a sticky I_STAT.VBlank can + * then deliver with take_pc=0 → LEGACY_SENTINEL / same_thr=0 and fork peers + * (MotK loading-screen tip+1 @1480, Win irqctx reason=3 epc=sentinel). */ +void psx_irq_arm_compiled_resume_pc(uint32_t pc) +{ + if (pc == 0u || (pc & 3u) != 0u) + return; + s_compiled_interrupt_resume_pc = pc; + s_last_interrupt_check_pc = pc; +} + /* Deferred cooperative thread switch from nested exception delivery. * * A genuine in-exception ChangeThread (kind-30, escape site below) must be @@ -1006,20 +1018,21 @@ void psx_check_interrupts(CPUState* cpu) { #define COSIM_IRQ_NOTE(kind_) cosim_irq_note(cpu, (kind_), COSIM_IRQ_TAKE_PC(), g_dirty_safe_resume_pc, s_compiled_interrupt_resume_pc, cpu->cop0[COP0_SR]) #endif - /* MotK wait CD54 + VBlank-only I_STAT: never deliver at the CD54 edge in - * netplay — hold until CDA0. Delivering at CD54 leaves v0=slt(1) while a - * peer that hit CDA0 first delivers with countdown in v0 (soak: non-det - * fin@946/@902 CD54 vs CDA0, cyc ±1 VB, on identical baseline+pads). - * UNCONDITIONAL on the edge PC — the earlier gate also required - * gpu_vblank_present_pending(), but s_present_pending is host-only state - * (not in the snap), so replay delivery timing forked across peers. - * Edge PC + I_STAT are guest-deterministic; the wait ping-pong reaches - * CDA0 a few instructions later, so no starvation. */ + /* MotK wait CD54 / post-FMV 768C8 + VBlank-only I_STAT: never deliver at + * the "A" edge in netplay — hold until the canonical B edge. Delivering at + * A leaves v0=slt(1) while a peer that hit B first delivers with countdown + * in v0 (soak: non-det fin@946/@902 CD54 vs CDA0; post-FMV tip+1 @871 + * 768C8 vs 76880, cyc ±1). UNCONDITIONAL on the edge PC — the earlier gate + * also required gpu_vblank_present_pending(), but s_present_pending is + * host-only state (not in the snap), so replay delivery timing forked + * across peers. Edge PC + I_STAT are guest-deterministic; the wait + * ping-pong reaches B a few instructions later, so no starvation. */ if (!in_exception && psx_netplay_active()) { const uint32_t wait_a = 0x8006CD54u; + const uint32_t wait2_a = 0x800768C8u; uint32_t edge = s_last_interrupt_check_pc; uint32_t pend = i_stat & i_mask; - if (edge == wait_a && pend != 0u && + if ((edge == wait_a || edge == wait2_a) && pend != 0u && (pend & ~(1u << IRQ_VBLANK)) == 0u) { PSX_CHECK_INTERRUPTS_RETURN(); } @@ -1454,6 +1467,20 @@ irq_deliver_eval: { uint32_t real_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc : s_compiled_interrupt_resume_pc; + /* Top-level flush_resume / savestate: resync cleared the latches and + * the first IRQ may fire before any BB edge republishes them. Prefer + * cpu->pc (already set to the resume target) over the sentinel so + * EPC/saved_gpr stay on the real same-thread path. */ + if (real_pc == 0u) { + extern int psx_scheduler_top_level_resume_active(void); + if (psx_scheduler_top_level_resume_active() && + cpu->pc != 0u && (cpu->pc & 3u) == 0u) { + uint32_t phys = cpu->pc & 0x1FFFFFFFu; + if (phys < 0x00200000u || + (phys >= 0x1FC00000u && phys < 0x1FC80000u)) + real_pc = cpu->pc; + } + } /* Accept the real resume PC from guest RAM (<2MB) OR the BIOS ROM * window. The old RAM-only guard rejected ROM-space block leaders * (e.g. OpenBIOS mcWaitForStatus spinning at 0xBFC076xx during a diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 186d924e..a00e6ef3 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -595,6 +595,12 @@ void psx_cycles_resync_after_restore(CPUState *cpu) { extern void dirty_ram_ld_delay_discard(void); dirty_ram_ld_delay_discard(); } + /* Entry-poll %%64 stride + 4096-insn pump gap are host-only; reset so + * both peers take the first post-load dirty wait IRQ on the same phase. */ + { + extern void dirty_ram_irq_ambient_resync_after_restore(void); + dirty_ram_irq_ambient_resync_after_restore(); + } } void psx_cycles_reset_for_boot(void) { diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 6af83a30..8335b7be 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -693,17 +693,22 @@ static void np_try_hc_fork_recovery(uint32_t fork_tick) return; if (g_np.local_slot != 0) return; /* initiator side only */ + /* §115: tip+1 soft fork already accepted — stay Live. */ + if (psx_netplay_rb_platform_fork_accepted(fork_tick)) + return; /* §64: do not bookkeep persist while an episode / tip-hold / load is * in flight. Previously fork_tick advances during tip-extend rereplay * started the persist clock, so tip-extend abandon → Live opened a * second hc-fork recovery with zero Live gap (soak: epoch 8 → 16, * "persisted 39 ticks"). */ - /* §109: allow hc-fork through DESYNC invent-hold when MEDIA_KF can heal - * (fmv_episode_unsafe is 0 with KF on). Keep blocking media+settle. */ + /* §109/§110: allow hc-fork through DESYNC invent-hold or heal sticky when + * MEDIA_KF can heal. Keep blocking live media+settle invent-hold. */ if (psx_netplay_rb_active() || psx_netplay_rb_tip_holding() || psx_netplay_rb_load_pending() || psx_netplay_rb_rewind_suppressed() || - (psx_netplay_rb_fmv_defer_rewind() && !psx_netplay_rb_fmv_desync_hold()) || - (psx_netplay_rb_lockstep_no_invent() && !psx_netplay_rb_fmv_desync_hold()) || + (psx_netplay_rb_fmv_defer_rewind() && !psx_netplay_rb_fmv_desync_hold() && + !psx_netplay_rb_post_fmv_heal_eligible()) || + (psx_netplay_rb_lockstep_no_invent() && !psx_netplay_rb_fmv_desync_hold() && + !psx_netplay_rb_post_fmv_heal_eligible()) || psx_netplay_rb_fmv_episode_unsafe(fork_tick)) return; sim = rnet_session_sim_tick(g_np.session); @@ -729,7 +734,7 @@ static void np_try_hc_fork_recovery(uint32_t fork_tick) (unsigned)fork_tick, (unsigned)(sim - s_fork_first_sim), fork_cap ? "; fork_cap backoff" : ""); fflush(stderr); - /* §109: silent fork → apply-only host KF (tip-snap SPAN re-diverges). */ + /* §109/§114: silent fork → apply-only MEDIA_KF heal (target=load). */ psx_netplay_rb_request_post_fmv_heal_kf(); (void)psx_netplay_rb_begin_rewind(fork_tick, remote); } @@ -4754,6 +4759,10 @@ int psx_netplay_catchup_budget(void) return 0; if (psx_start_bisect_no_catchup()) return 0; + /* §114: during post-FMV DESYNC / heal sticky, do not turbo catch-up — + * absurd lead after heal abort + catchup widens platform tip+1 skew. */ + if (psx_netplay_rb_fmv_desync_hold() || psx_netplay_rb_post_fmv_heal_sticky()) + return 0; cap = np_starv_env_int("PSX_NET_CATCHUP_CAP", PSX_CATCHUP_CAP_DEFAULT); if (cap <= 0 && g_starv.recovery_amount <= 0) return 0; diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index 60237a1c..57dd73e1 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -36,9 +36,11 @@ int psx_netplay_rb_rewind_suppressed(void) { return 0; } int psx_netplay_rb_fmv_defer_rewind(void) { return 0; } int psx_netplay_rb_fmv_media_active(void) { return 0; } int psx_netplay_rb_lockstep_no_invent(void) { return 0; } +int psx_netplay_rb_fmv_settle_active(void) { return 0; } int psx_netplay_rb_fmv_desync_hold(void) { return 0; } void psx_netplay_rb_clear_fmv_desync_hold(const char *why) { (void)why; } void psx_netplay_rb_request_post_fmv_heal_kf(void) {} +int psx_netplay_rb_post_fmv_heal_eligible(void) { return 0; } int psx_netplay_rb_fmv_episode_unsafe(uint32_t tick) { (void)tick; return 0; } int psx_netplay_rb_media_kf_probe_match(uint32_t size, uint32_t crc) { @@ -179,6 +181,11 @@ static uint32_t g_auth_snap_through; /* Set after apply-from-pump/admit; flushed via psx_netplay_rb_flush_resume. */ static int g_pending_resume_valid; static uint32_t g_pending_resume_pc; +/* §112: load==target (apply-only / post-FMV heal) — defer enter_verify_at_tip + * until flush_resume so longjmp arms top-level resume before Verify/Live. + * Entering Verify first blocked flush (Replay-only gate) then finalize cleared + * pending → leaf PC=0 / "execution completed" (Win↔Linux TM4 loading). */ +static int g_empty_span_verify_pending; /* 1 only after a successful ring load for this episode — blocks baseline/replay * until restore (peer BASELINE can arrive while we are still sealing). */ static int g_episode_snap_applied; @@ -204,6 +211,14 @@ static uint32_t snap_interval(void) } static int rb_media_kf_enabled(void); /* §97 — defined near episode wire clear */ +static void rb_arm_post_fmv_heal_sticky(uint32_t tip, uint32_t fork_tick); +static void rb_clear_post_fmv_heal_sticky(const char *why); +static void rb_post_fmv_platform_nondet_enter(uint32_t keep_tick, uint32_t fork_tick, + uint32_t live_sim, const char *why); +static void rb_clear_post_fmv_platform_nondet(const char *why); +static void rb_post_fmv_platform_accept(uint32_t through, uint32_t fork, + const char *why); +static int rb_post_fmv_heal_eligible(void); static int rb_fmv_media_active(void); /* §96 — used by ownership budget during FMV */ /* §96/§98: FMV media snap interval. Default 4; MEDIA_KF uses 2 (near-tip @@ -421,10 +436,28 @@ static int g_media_kf_episode; static int g_media_kf_ready; /* pin holds authoritative KF for load */ static int g_media_kf_host_armed; /* host started probe/send for this episode */ static int g_media_kf_sending; /* host transfer in flight */ -/* §109: this episode is a post-FMV / DESYNC apply-only heal (no resim span). */ +/* §109/§114: this episode is a post-FMV / DESYNC apply-only MEDIA_KF heal + * (target=load). Verify-span tip+1 was abandoned — platform nondet. */ static int g_post_fmv_heal_kf; -/* §109: hc-fork / resim-escalate asked for apply-only heal on next begin. */ +/* §109: hc-fork / resim-escalate asked for heal on next begin. */ static int g_request_post_fmv_heal; +/* §110: sim < until → re-heal eligible + invent hold after heal RELEASE. */ +static uint32_t g_post_fmv_heal_sticky_until; +/* §110: mismatch tick of last heal (diagnostics / sticky arm / loop cap). */ +static uint32_t g_post_fmv_heal_fork_tick; +/* §110b: consecutive heal begins at g_post_fmv_heal_loop_fork. */ +static uint32_t g_post_fmv_heal_loop_fork; +static uint32_t g_post_fmv_heal_loop_count; +/* §114: matched-baseline tip+1 nondeterminism — invent stays off until HC + * rematch; initiator streams apply-only KF; realign keeps the matched pin + * (not load-1 Live-walk). */ +static int g_post_fmv_platform_nondet; +static uint32_t g_post_fmv_platform_keep_tick; +static uint32_t g_platform_kf_next_sim; +/* §115: KF stream opens while still pinned at keep_tick (livelock counter). */ +static uint32_t g_platform_kf_stream_count; +/* §115: tip+1 fork accepted (HC primed past it) — refuse re-heal/stream. */ +static uint32_t g_platform_accepted_fork; /* §100: recent FMV media snap CRCs — prefer choose_load ticks we already * sealed, and let probe_match confirm without a ring edge-case miss. */ @@ -536,6 +569,25 @@ static uint32_t g_last_begin_mismatch = 0xffffffffu; * tip-snap +32 after invent unlock. Soak: grace=32 still saw Cross invent * at invent_at+1 (896) — bump to 64. */ #define RB_FMV_UNLOCK_GRACE 64u +/* §110/§114: after apply-only heal Live, keep re-heal + invent-hold (via + * §114 platform nondet DESYNC, not lockstep RELEASE) so loading hitch + * cannot tip-SPAN. */ +#define RB_FMV_HEAL_STICKY_TICKS 300u +/* §110b/§114: verify-span past load used to catch tip+1, but matched-baseline + * resim of tip+1 is Win↔Linux platform nondeterminism (+10 cyc @1036 soak) — + * heal always aborts. §114 heals are apply-only (target=load); this span is + * retained only as a diagnostic/legacy cap if a caller still raises target. */ +#define RB_FMV_HEAL_VERIFY_SPAN 4u +/* §110b: apply-only heal loops at the same fork tick before §114 platform + * nondet keep-live + KF stream (empty KF alone cannot fix tip+1 drift). */ +#define RB_FMV_HEAL_LOOP_LIMIT 3u +/* §114: while platform nondet DESYNC is armed, initiator re-opens apply-only + * MEDIA_KF this often (sim ticks) so both peers ride the host timeline + * through loading instead of Live-walking a forked tip+1. */ +#define RB_FMV_PLATFORM_KF_IV 16u +/* §115: same keep_tick re-stream limit — beyond this, prime HC past the + * tip+1 fork and clear invent-hold (Win↔Linux loading-screen livelock). */ +#define RB_FMV_PLATFORM_KF_STREAM_LIMIT 6u /* MotK TipHold: quiet window so press→release→D-pad coalesce in one episode. * §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). @@ -893,17 +945,23 @@ static void ownership_exit_to_live(uint32_t tip, uint32_t frontier, "psxrecomp: rb ownership Live tip=%u frontier=%u (%s) — no TipHold\n", (unsigned)tip, (unsigned)frontier, why ? why : "?"); fflush(stderr); - /* §109: apply-only heal converged — drop DESYNC invent-hold / heal flag. */ + /* §109/§114/§115: apply-only heal converged. First pin at the soft-fork + * keep → invent OFF + KF stream. Once a later host-tip KF advances past + * the keep/fork, accept the timeline (§115) instead of re-pinning forever. */ if (g_post_fmv_heal_kf) { - rb_clear_fmv_desync_hold("post-FMV heal KF Live"); + uint32_t fork = g_last_begin_mismatch; + if (fork == 0xffffffffu) + fork = g_post_fmv_heal_fork_tick; g_post_fmv_heal_kf = 0; - g_fmv_core_match_streak = RB_FMV_LOCKSTEP_CONFIRM; - if (!g_fmv_lockstep_released) { - g_fmv_lockstep_released = 1; - fprintf(stderr, - "psxrecomp: rb FMV lockstep RELEASE (post-FMV heal KF tip=%u)\n", - (unsigned)tip); - fflush(stderr); + g_fmv_core_match_streak = 0; /* need fresh CONFIRM after Live walk */ + if (g_post_fmv_platform_nondet && + g_post_fmv_platform_keep_tick > 0u && + tip > g_post_fmv_platform_keep_tick) { + rb_post_fmv_platform_accept(tip, fork, + "heal Live past platform keep"); + } else { + rb_post_fmv_platform_nondet_enter(tip, fork, tip, + "post-FMV heal KF Live"); } } if (g_stash_begin_valid && g_rb && @@ -979,6 +1037,33 @@ static void rb_fmv_update_lockstep_gate(uint32_t sim) rb_clear_fmv_desync_hold(why); } } + /* §110: drop heal sticky once cores rematch CONFIRM after RELEASE. + * §110b: tip rematch alone is not enough — apply-only / tip KF only + * proves tip; the silent fork is tip+1. Require a confirmed walk + * past the fork tick before dropping sticky (else heal storm). */ + if (g_post_fmv_heal_sticky_until > 0u && + g_fmv_core_match_streak >= RB_FMV_LOCKSTEP_CONFIRM) { + if (g_post_fmv_heal_fork_tick > 0u && + sim <= g_post_fmv_heal_fork_tick) { + static uint32_t s_sticky_hold_log_sim; + if (sim != s_sticky_hold_log_sim) { + s_sticky_hold_log_sim = sim; + fprintf(stderr, + "psxrecomp: rb post-FMV heal sticky hold " + "(tip rematch sim=%u fork=%u — need walk past " + "fork before clear)\n", + (unsigned)sim, + (unsigned)g_post_fmv_heal_fork_tick); + fflush(stderr); + } + } else { + char why[96]; + snprintf(why, sizeof(why), + "cores rematched streak=%u sim=%u", + (unsigned)g_fmv_core_match_streak, (unsigned)sim); + rb_clear_post_fmv_heal_sticky(why); + } + } return; } @@ -1034,7 +1119,7 @@ static void rb_fmv_update_lockstep_gate(uint32_t sim) : ((sim >= cap) ? "; MAX" : "; cores matched"), max_unmatched ? "DESYNC hold — invent off until rematch" - : "invent already on after settle §26"); + : "invent off through unlock_grace §113"); fflush(stderr); (void)prev_until; return; @@ -1133,10 +1218,11 @@ static void rb_fmv_tick_settle(void) fprintf(stderr, "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, + "(no invent through lockstep MIN — covers loading tip+1; " + "hc primed)\n", (unsigned)g_fmv_settle_until, (unsigned)g_fmv_lockstep_until, + (unsigned)g_fmv_lockstep_until, (unsigned)(sim + RB_FMV_LOCKSTEP_MAX), (unsigned)RB_FMV_LOCKSTEP_CONFIRM); fflush(stderr); @@ -1182,6 +1268,7 @@ static void rb_clear_fmv_desync_hold(const char *why) return; g_fmv_unmatched_desync = 0; g_fmv_unmatched_desync_arm_sim = 0; + rb_clear_post_fmv_platform_nondet(why ? why : "FMV DESYNC invent-hold cleared"); fprintf(stderr, "psxrecomp: rb FMV DESYNC invent-hold cleared (%s)\n", why ? why : "?"); @@ -1189,11 +1276,15 @@ static void rb_clear_fmv_desync_hold(const char *why) clear_rewind_cooldown(why ? why : "FMV DESYNC invent-hold cleared"); } -/* Expire invent-hold when soft-fork digests never rematch CONFIRM ticks. */ +/* Expire invent-hold when soft-fork digests never rematch CONFIRM ticks. + * §114: platform nondet stay invent-off until HC rematch (expire only + * widens the fork via invent). */ static void rb_fmv_desync_invent_maybe_expire(uint32_t sim) { if (!g_fmv_unmatched_desync || g_fmv_unmatched_desync_arm_sim == 0u) return; + if (g_post_fmv_platform_nondet) + return; if (sim < g_fmv_unmatched_desync_arm_sim + RB_FMV_DESYNC_INVENT_EXPIRE) return; rb_clear_fmv_desync_hold("invent-hold expired (no rematch)"); @@ -1215,11 +1306,41 @@ static int rb_in_fmv_defer_rewind_window(void) return sim < g_fmv_settle_until; } -/* Admit no-invent gate (§26): media + short settle only. +/* §111: sticky invent hold only when we would invent *ahead* of the peer + * (absurd catch-up through a soft fork). Tip-starved lead<=0 must invent or + * admit deadlocks (Win↔Linux loading-screen soak: follow NACK + lead=-1 → + * stall=fmv_settle → lobby). */ +static int rb_post_fmv_heal_sticky_invent_hold(uint32_t sim) +{ + RNetSession *s; + RNetSessionStats st; + if (g_post_fmv_heal_sticky_until == 0u || sim >= g_post_fmv_heal_sticky_until) + return 0; + s = sess(); + if (!s) + return 1; + memset(&st, 0, sizeof(st)); + rnet_session_get_stats(s, &st); + /* remote_lead > 0 → invent would catch up through the fork — hold. + * lead <= 0 → fill tip hole; allow invent. */ + return (st.remote_lead > 0) ? 1 : 0; +} + +static int rb_post_fmv_heal_sticky_active(uint32_t sim) +{ + return (g_post_fmv_heal_sticky_until > 0u && sim < g_post_fmv_heal_sticky_until) + ? 1 + : 0; +} + +/* Admit no-invent gate (§26/§113): media + post-FMV lockstep MIN. * §100: invent stays OFF during live media even with MEDIA_KF — GAP1 invent * was opening ~3.7 MB KF transfers mid-intro FMV. Wait for wire; real - * rewinds still open episodes (defer_rewind allows MEDIA_KF). Settle + - * DESYNC invent-hold unchanged. */ + * rewinds still open episodes (defer_rewind allows MEDIA_KF). + * §113: invent_hold used to end at settle (24 ticks) while dense_lockstep + * MIN started ~180 later — Win↔Linux invent was free in the loading tip+1 + * window (fork@1480 before min=1510). Hold invent through lockstep_until. + * §110/§111: heal sticky invent hold only while remote_lead > 0. */ static int rb_in_fmv_lockstep_window(void) { RNetSession *s = sess(); @@ -1230,7 +1351,9 @@ static int rb_in_fmv_lockstep_window(void) return 1; if (g_fmv_unmatched_desync) return 1; - return sim < g_fmv_settle_until; + if (rb_post_fmv_heal_sticky_invent_hold(sim)) + return 1; + return sim < g_fmv_lockstep_until; } static void rb_media_crc_note(uint32_t tick, uint32_t size, uint32_t crc) @@ -1467,11 +1590,17 @@ static int rb_resume_pc_ok(uint32_t pc) * Always store the post-present edge so both peers pin the same resume. */ #define RB_MOTK_WAIT_A 0x8006CD54u #define RB_MOTK_WAIT_B 0x8006CDA0u +/* Post-FMV overlay wait (dirty-interp hole @0x80076880..). Same class: IRQ at + * 0x800768C8 left v0=1 while the snap/resume edge is 0x80076880. */ +#define RB_MOTK_WAIT2_A 0x800768C8u +#define RB_MOTK_WAIT2_B 0x80076880u static uint32_t rb_canonicalize_resume_pc(uint32_t pc) { if (pc == RB_MOTK_WAIT_A || pc == RB_MOTK_WAIT_B) return RB_MOTK_WAIT_B; + if (pc == RB_MOTK_WAIT2_A || pc == RB_MOTK_WAIT2_B) + return RB_MOTK_WAIT2_B; return pc; } @@ -1660,6 +1789,145 @@ static int rb_post_fmv_lockstep_active(void) return sim <= cap ? 1 : 0; } +/* §110: heal KF allowed — lockstep window, DESYNC hold, or sticky after heal. */ +static int rb_post_fmv_heal_eligible(void) +{ + RNetSession *s; + uint32_t sim; + if (!rb_media_kf_enabled()) + return 0; + if (rb_fmv_media_active()) + return 0; + if (g_fmv_unmatched_desync) + return 1; + if (rb_post_fmv_lockstep_active()) + return 1; + s = sess(); + sim = s ? rnet_session_sim_tick(s) : 0u; + if (g_post_fmv_heal_sticky_until > 0u && sim < g_post_fmv_heal_sticky_until) + return 1; + return 0; +} + +static void rb_arm_post_fmv_heal_sticky(uint32_t tip, uint32_t fork_tick) +{ + uint32_t until = tip + RB_FMV_HEAL_STICKY_TICKS; + if (until < tip) + until = 0xffffffffu; + if (until > g_post_fmv_heal_sticky_until) + g_post_fmv_heal_sticky_until = until; + if (fork_tick > 0u) + g_post_fmv_heal_fork_tick = fork_tick; + fprintf(stderr, + "psxrecomp: rb post-FMV heal sticky until sim=%u " + "(fork=%u tip=%u — re-heal + invent hold)\n", + (unsigned)g_post_fmv_heal_sticky_until, + (unsigned)g_post_fmv_heal_fork_tick, (unsigned)tip); + fflush(stderr); +} + +static void rb_clear_post_fmv_heal_sticky(const char *why) +{ + if (g_post_fmv_heal_sticky_until == 0u && g_post_fmv_heal_fork_tick == 0u) + return; + fprintf(stderr, + "psxrecomp: rb post-FMV heal sticky cleared (%s)\n", + why ? why : "?"); + fflush(stderr); + g_post_fmv_heal_sticky_until = 0u; + g_post_fmv_heal_fork_tick = 0u; + g_post_fmv_heal_loop_fork = 0u; + g_post_fmv_heal_loop_count = 0u; +} + +/* §114: arm invent-off + KF stream after matched-baseline tip+1 nondet. */ +static void rb_post_fmv_platform_nondet_enter(uint32_t keep_tick, uint32_t fork_tick, + uint32_t live_sim, const char *why) +{ + uint32_t arm = live_sim ? live_sim : keep_tick; + g_post_fmv_platform_nondet = 1; + if (keep_tick > 0u) + g_post_fmv_platform_keep_tick = keep_tick; + g_fmv_unmatched_desync = 1; + g_fmv_unmatched_desync_arm_sim = arm ? arm : 1u; + if (keep_tick > 0u) + rb_arm_post_fmv_heal_sticky(keep_tick, fork_tick ? fork_tick : keep_tick); + else if (fork_tick > 0u) + rb_arm_post_fmv_heal_sticky(fork_tick, fork_tick); + if (keep_tick > 0u) { + uint32_t until = keep_tick + RB_FMV_LOCKSTEP_MAX; + if (until < keep_tick) + until = 0xffffffffu; + if (until > g_fmv_lockstep_until) + g_fmv_lockstep_until = until; + } + g_platform_kf_next_sim = arm + RB_FMV_PLATFORM_KF_IV; + g_post_fmv_heal_loop_count = 0u; + g_request_post_fmv_heal = 0; + g_post_fmv_heal_kf = 0; + /* Allow stream heals through cooldown. */ + clear_rewind_cooldown("§114 platform nondet"); + fprintf(stderr, + "psxrecomp: rb §114 platform nondet keep-live pin=%u fork=%u " + "live=%u — invent off until rematch; KF stream iv=%u (%s)\n", + (unsigned)g_post_fmv_platform_keep_tick, (unsigned)fork_tick, + (unsigned)live_sim, (unsigned)RB_FMV_PLATFORM_KF_IV, + why ? why : "?"); + fflush(stderr); +} + +static void rb_clear_post_fmv_platform_nondet(const char *why) +{ + if (!g_post_fmv_platform_nondet && g_post_fmv_platform_keep_tick == 0u && + g_platform_kf_next_sim == 0u) + return; + fprintf(stderr, + "psxrecomp: rb §114 platform nondet cleared (%s)\n", + why ? why : "?"); + fflush(stderr); + g_post_fmv_platform_nondet = 0; + g_post_fmv_platform_keep_tick = 0u; + g_platform_kf_next_sim = 0u; + g_platform_kf_stream_count = 0u; +} + +/* §115: accept a tip+1 platform fork — prime HC past it, drop invent-hold / + * KF stream, and refuse re-heal for that fork tick. */ +static void rb_post_fmv_platform_accept(uint32_t through, uint32_t fork, + const char *why) +{ + uint32_t prime = through; + if (fork > prime) + prime = fork; + if (g_b.hc && prime > 0u) + netplay_hc_prime_after(g_b.hc, prime); + if (fork > 0u) + g_platform_accepted_fork = fork; + else if (through > 0u) + g_platform_accepted_fork = through; + g_request_post_fmv_heal = 0; + g_post_fmv_heal_kf = 0; + g_platform_kf_stream_count = 0u; + rb_clear_post_fmv_platform_nondet(why ? why : "§115 platform accept"); + rb_clear_post_fmv_heal_sticky(why ? why : "§115 platform accept"); + if (g_fmv_unmatched_desync) + rb_clear_fmv_desync_hold(why ? why : "§115 platform accept"); + psx_netplay_hc_fork_recovery_clear(); + fprintf(stderr, + "psxrecomp: rb §115 platform fork accepted through=%u fork=%u " + "(%s) — invent unlocked; no re-heal for this tip+1\n", + (unsigned)through, (unsigned)fork, why ? why : "?"); + fflush(stderr); +} + +int psx_netplay_rb_platform_fork_accepted(uint32_t fork_tick) +{ + return (g_platform_accepted_fork != 0u && + fork_tick == g_platform_accepted_fork) + ? 1 + : 0; +} + /* §109/§97: arm host keyframe for media-range OR requested post-FMV heal. */ static int rb_want_heal_kf(uint32_t mismatch, uint32_t load) { @@ -2048,9 +2316,14 @@ static void abort_episode(const char *why) if (!g_live_realign_pending) g_pending_load_valid = 0; g_pending_resume_valid = 0; + g_empty_span_verify_pending = 0; g_episode_snap_applied = 0; g_needs_advance = 0; g_episode_baseline_matched = 0; + /* §110b: heal episode aborted mid-verify — clear episode heal mark so + * escalate's !g_post_fmv_heal_kf gate works. Keep g_request_post_fmv_heal + * (escalate sets it before abort then begin_rewind). */ + g_post_fmv_heal_kf = 0; g_tip_hold_until = 0; g_tip_hold_quiet_t0_ms = 0ull; g_tip_hold_block_quiet = 0; @@ -2238,6 +2511,31 @@ static void abort_episode_realign(const char *why) } { uint32_t failed_load = g_rb ? rnet_rb_get_load_tick(g_rb) : 0u; + /* §114: heal KF / platform mode already proved matched baseline — + * keep the pin, invent-off, KF stream. Do NOT fork_cap→load-1 + * Live-walk (soak: realign 1034 → lockstep streak=0 forever). */ + if (g_post_fmv_heal_kf || g_post_fmv_platform_nondet) { + fprintf(stderr, + "psxrecomp: rb §114 resim diverge during heal " + "sim=%u load=%u — keep matched pin (no load-1 " + "realign)\n", + (unsigned)(diverge_sim ? diverge_sim + : g_resim_diverge_tick), + (unsigned)failed_load); + fflush(stderr); + if (g_bl_fork_cap == failed_load) + g_bl_fork_cap = 0u; + abort_episode(why); + rb_post_fmv_platform_nondet_enter( + failed_load, + diverge_sim ? diverge_sim : g_resim_diverge_tick, live_sim, + why); + if (failed_load > 0u && g_snaps && + netplay_snap_ring_has(g_snaps, failed_load)) + schedule_live_realign(failed_load, + "§114 platform nondet keep pin"); + return; + } if (failed_load > 0u && (g_bl_fork_cap == 0u || failed_load < g_bl_fork_cap)) { g_bl_fork_cap = failed_load; @@ -2248,15 +2546,17 @@ static void abort_episode_realign(const char *why) fflush(stderr); } } - /* §109: first post-FMV tip-SPAN resim diverge → request apply-only - * heal KF instead of burning the load into fork_cap / storming. */ + /* §109/§114: first post-FMV tip-SPAN resim diverge → apply-only + * MEDIA_KF heal. Do NOT re-request when this fork already healed. */ if (g_resim_diverge_streak == 1u && !g_post_fmv_heal_kf && - rb_media_kf_enabled() && - (rb_post_fmv_lockstep_active() || g_fmv_unmatched_desync)) { + rb_post_fmv_heal_eligible() && + g_post_fmv_heal_fork_tick != g_resim_diverge_tick && + g_post_fmv_heal_loop_count == 0u) { uint32_t failed_load = g_rb ? rnet_rb_get_load_tick(g_rb) : 0u; fprintf(stderr, "psxrecomp: rb post-FMV heal escalate " - "(resim diverge sim=%u load=%u — next begin apply-only KF)\n", + "(resim diverge sim=%u load=%u — next begin apply-only " + "KF)\n", (unsigned)g_resim_diverge_tick, (unsigned)failed_load); fflush(stderr); /* Undo fork_cap raise so heal can reload the same pin. */ @@ -2281,10 +2581,11 @@ static void abort_episode_realign(const char *why) return; } if (g_resim_diverge_streak >= RB_RESIM_DIVERGE_STORM_LIMIT) { + uint32_t failed_load = g_rb ? rnet_rb_get_load_tick(g_rb) : 0u; fprintf(stderr, "psxrecomp: rb DESYNC — resim diverge storm sim=%u " "(%u× same tick; matched baseline nondeterminism) — " - "keep-live, cooldown %u ticks (no same-pin realign)\n", + "§114 keep-live KF stream, cooldown %u ticks\n", (unsigned)g_resim_diverge_tick, (unsigned)g_resim_diverge_streak, (unsigned)RB_FORK_STORM_COOLDOWN_TICKS); @@ -2292,6 +2593,15 @@ static void abort_episode_realign(const char *why) g_abort_wire_class = RNET_RB_ABORT_CLASS_STORM; g_abort_wire_realign_tick = 0u; abort_episode(why); + if (g_bl_fork_cap == failed_load) + g_bl_fork_cap = 0u; + rb_post_fmv_platform_nondet_enter( + failed_load, g_resim_diverge_tick, live_sim, + "resim diverge storm"); + if (failed_load > 0u && g_snaps && + netplay_snap_ring_has(g_snaps, failed_load)) + schedule_live_realign(failed_load, + "§114 resim storm keep pin"); arm_rewind_cooldown_ticks(live_sim, RB_FORK_STORM_COOLDOWN_TICKS, "DESYNC resim diverge storm"); return; @@ -3876,6 +4186,7 @@ static void enter_awaiting_baseline(void) g_pending_load_tick = load; g_pending_load_valid = 1; g_pending_resume_valid = 0; + g_empty_span_verify_pending = 0; g_baseline_rexmit_logged = 0; g_wait_peer_bl_logged = 0; fprintf(stderr, "psxrecomp: rb episode load_tick=%u (snap pending)\n", (unsigned)load); @@ -4333,7 +4644,17 @@ static void arm_rereplay_after_load(uint32_t reload_tick) if (first > target) { rnet_session_set_sim_tick(s, reload_tick); g_needs_advance = 0; - enter_verify_at_tip(reload_tick); + /* §112: if resume still pending, stay Replay until flush_resume. */ + if (g_pending_resume_valid) { + g_empty_span_verify_pending = 1; + fprintf(stderr, + "psxrecomp: rb rereplay empty span load=target=%u " + "(defer verify; resume_pending=1)\n", + (unsigned)reload_tick); + fflush(stderr); + } else { + enter_verify_at_tip(reload_tick); + } return; } /* Reset Live tip clock — TipHold invent may sit past the prior tip; arming @@ -4589,7 +4910,10 @@ static void maybe_enter_replay(void) uint32_t target = rnet_rb_get_target_tick(g_rb); uint32_t first = load + 1u; if (first > target) { - /* Seal span empty — restored tip is already the target. */ + /* Seal span empty — restored tip is already the target. + * §112: stay in Replay when resume is pending so flush_resume can + * longjmp (and enter Verify) before Live. Immediate enter_verify + * blocked flush then finalize cleared pending → PC=0 exit. */ rnet_session_set_sim_tick(s, load); g_needs_advance = 0; fprintf(stderr, @@ -4598,7 +4922,15 @@ static void maybe_enter_replay(void) (unsigned)load, g_pending_resume_valid, g_peer_baseline_ready, follower); fflush(stderr); - enter_verify_at_tip(load); + if (g_pending_resume_valid) { + g_empty_span_verify_pending = 1; + fprintf(stderr, + "psxrecomp: rb empty span defer verify " + "(flush_resume will POST)\n"); + fflush(stderr); + } else { + enter_verify_at_tip(load); + } } else { rnet_session_set_sim_tick(s, first); /* Arm before flush_resume: after longjmp the guest runs to the @@ -4812,7 +5144,9 @@ static void ownership_chain_next_span(uint32_t tip, uint32_t frontier) /* §51 Layer 3: continue from matched tip — restore pin on apply (§70). */ g_ownership_skip_snap = g_pin_valid && g_pin_tick == tip ? 1 : 0; g_episode_snap_applied = 0; - g_pending_resume_valid = 0; + /* §112: do not clear g_pending_resume_valid — empty-span heal may still + * owe flush_resume; next begin/apply re-arms or flush consumes it. */ + g_empty_span_verify_pending = 0; g_episode_baseline_matched = 0; clear_episode_wire_state(); /* Keep g_pin — do not clear_baseline_pin(). */ @@ -4888,6 +5222,19 @@ static void ownership_on_post_match(uint32_t tip) ownership_exit_to_live(tip, frontier, "peer-ahead light", 1); return; } + /* §109: apply-only post-FMV heal KF — POST already matched at load. + * Do NOT ownership-chain SPAN through the soft-fork window (soak session + * 28: heal POST tip=826 matched, then chain hop target=850 hung guest + * wait-FOLLOW → peer disconnect). */ + if (g_post_fmv_heal_kf) { + fprintf(stderr, + "psxrecomp: rb post-FMV heal KF tip=%u frontier=%u → Live " + "(no ownership chain)\n", + (unsigned)tip, (unsigned)frontier); + fflush(stderr); + ownership_exit_to_live(tip, frontier, "post-FMV heal KF", 1); + return; + } /* §48/§49/§73/§86/§87 B/§104: micro frontiers at/below min_ahead do not * chain. min_ahead = max(6, D-1) — delay cushion is not catch-up backlog, * but tip+D still chains once (D-1 headroom) instead of tip-hold every @@ -4951,18 +5298,23 @@ static void ownership_on_post_match(uint32_t tip) (unsigned)tip, (unsigned)frontier, (unsigned)min_ahead); fflush(stderr); ownership_clear_chain_budget(); - /* §109: heal KF reached tip with no confirmed ahead — still converged. */ + /* §109/§114: heal KF reached tip with no confirmed ahead — still + * converged; keep invent off + KF stream (do not RELEASE). §115: if the + * pin already advanced past the original keep, accept instead. */ if (g_post_fmv_heal_kf) { - rb_clear_fmv_desync_hold("post-FMV heal KF tip-hold"); + uint32_t fork = g_last_begin_mismatch; + if (fork == 0xffffffffu) + fork = g_post_fmv_heal_fork_tick; g_post_fmv_heal_kf = 0; - g_fmv_core_match_streak = RB_FMV_LOCKSTEP_CONFIRM; - if (!g_fmv_lockstep_released) { - g_fmv_lockstep_released = 1; - fprintf(stderr, - "psxrecomp: rb FMV lockstep RELEASE " - "(post-FMV heal KF tip-hold tip=%u)\n", - (unsigned)tip); - fflush(stderr); + g_fmv_core_match_streak = 0; + if (g_post_fmv_platform_nondet && + g_post_fmv_platform_keep_tick > 0u && + tip > g_post_fmv_platform_keep_tick) { + rb_post_fmv_platform_accept(tip, fork, + "heal tip-hold past platform keep"); + } else { + rb_post_fmv_platform_nondet_enter(tip, fork, tip, + "post-FMV heal KF tip-hold"); } } /* §61: discard any stashed BEGIN for this episode (rexmit) so Live does @@ -5362,7 +5714,10 @@ static void finalize_tip_hold(void) clear_tip_extend_prime(); g_needs_advance = 0; g_episode_snap_applied = 0; - g_pending_resume_valid = 0; + /* §112: keep pending_resume across tip-hold Live so apply-only / empty-span + * heal can still flush_resume (live_realign gate once episode inactive). + * Normal Replay already cleared it before POST. */ + g_empty_span_verify_pending = 0; g_episode_baseline_matched = 0; clear_episode_wire_state(); clear_baseline_pin(); @@ -5945,6 +6300,17 @@ void psx_netplay_rb_cold_reset(void) g_fmv_media_hi = 0; g_fmv_unmatched_desync = 0; g_fmv_unmatched_desync_arm_sim = 0; + g_post_fmv_heal_sticky_until = 0u; + g_post_fmv_heal_fork_tick = 0u; + g_post_fmv_heal_loop_fork = 0u; + g_post_fmv_heal_loop_count = 0u; + g_request_post_fmv_heal = 0; + g_post_fmv_heal_kf = 0; + g_post_fmv_platform_nondet = 0; + g_post_fmv_platform_keep_tick = 0u; + g_platform_kf_next_sim = 0u; + g_platform_kf_stream_count = 0u; + g_platform_accepted_fork = 0u; g_last_good_bb_pc = 0; } @@ -5969,6 +6335,7 @@ void psx_netplay_rb_shutdown(void) g_pending_load_valid = 0; g_auth_snap_through = 0; /* §75 */ g_pending_resume_valid = 0; + g_empty_span_verify_pending = 0; g_episode_snap_applied = 0; g_needs_advance = 0; g_live_realign_pending = 0; @@ -6045,6 +6412,9 @@ static int rb_fmv_dense_snap_window(void) uint32_t sim = s ? rnet_session_sim_tick(s) : 0u; if (sim < g_fmv_settle_until) return 1; + /* §114: platform nondet / DESYNC hold — every-tick snaps for KF stream. */ + if (g_post_fmv_platform_nondet || g_fmv_unmatched_desync) + return 1; if (g_fmv_media_end_sim && sim < g_fmv_lockstep_until + 32u && sim < g_fmv_media_end_sim + RB_FMV_LOCKSTEP_MAX + 32u) @@ -6462,12 +6832,27 @@ void psx_netplay_rb_flush_resume(void) fflush(stderr); if (!alt) { g_pending_resume_valid = 0; + g_empty_span_verify_pending = 0; abort_episode("flush_resume: no safe resume pc"); return; } pc = alt; g_pending_resume_pc = alt; } + /* §112: empty-span POST while still on the flush path — enter_verify may + * immediately ownership_exit_to_live/finalize (clears pending); we still + * longjmp below with the captured pc so top-level resume is armed. */ + if (g_empty_span_verify_pending && g_rb && + rnet_rb_get_phase(g_rb) == nRNetRbPhaseReplay) { + uint32_t tip = rnet_rb_get_target_tick(g_rb); + g_empty_span_verify_pending = 0; + fprintf(stderr, + "psxrecomp: rb flush_resume empty-span verify tip=%u " + "pc=0x%08x\n", + (unsigned)tip, (unsigned)pc); + fflush(stderr); + enter_verify_at_tip(tip); + } g_pending_resume_valid = 0; g_replay_progress_ms = rb_mono_ms(); pc = rb_canonicalize_resume_pc(pc); @@ -6638,12 +7023,20 @@ int psx_netplay_rb_fmv_media_active(void) int psx_netplay_rb_lockstep_no_invent(void) { - /* Media + settle (§26). Post-FMV title menus invent+resim. + /* Media + post-FMV lockstep MIN (§26/§113). Invent after MIN/RELEASE. * §93/§94: also hold after MAX-unmatched DESYNC until rematch, * invent-hold expire, or netplay save complete. */ return rb_in_fmv_lockstep_window(); } +int psx_netplay_rb_fmv_settle_active(void) +{ + RNetSession *s = sess(); + uint32_t sim = s ? rnet_session_sim_tick(s) : 0u; + rb_fmv_tick_settle(); + return (sim < g_fmv_settle_until) ? 1 : 0; +} + int psx_netplay_rb_fmv_desync_hold(void) { RNetSession *s = sess(); @@ -6657,16 +7050,35 @@ void psx_netplay_rb_clear_fmv_desync_hold(const char *why) rb_clear_fmv_desync_hold(why); } +int psx_netplay_rb_post_fmv_heal_eligible(void) +{ + return rb_post_fmv_heal_eligible(); +} + +int psx_netplay_rb_post_fmv_heal_sticky(void) +{ + RNetSession *s = sess(); + uint32_t sim = s ? rnet_session_sim_tick(s) : 0u; + return rb_post_fmv_heal_sticky_active(sim); +} + void psx_netplay_rb_request_post_fmv_heal_kf(void) { - if (!rb_media_kf_enabled()) + if (!rb_post_fmv_heal_eligible()) return; - if (!rb_post_fmv_lockstep_active() && !g_fmv_unmatched_desync) + /* §110b/§114: refuse further heal requests once this fork exhausted the + * loop budget — unless §114 platform stream is armed (re-heal allowed). */ + if (g_post_fmv_heal_loop_count > RB_FMV_HEAL_LOOP_LIMIT && + g_post_fmv_heal_loop_fork > 0u && !g_post_fmv_platform_nondet) return; g_request_post_fmv_heal = 1; fprintf(stderr, "psxrecomp: rb post-FMV heal KF requested " - "(hc-fork / silent core fork)\n"); + "(hc-fork / silent core fork; sticky_until=%u loop=%u " + "platform=%d)\n", + (unsigned)g_post_fmv_heal_sticky_until, + (unsigned)g_post_fmv_heal_loop_count, + g_post_fmv_platform_nondet); fflush(stderr); } @@ -6782,8 +7194,9 @@ int psx_netplay_rb_fmv_unlock_grace_active(void) { RNetSession *s = sess(); uint32_t sim = s ? rnet_session_sim_tick(s) : 0u; - /* RELEASE arms dense_until = sim+UNLOCK_GRACE; invent is already on - * after settle, so this window is reconcile-only (soft-promote). */ + /* RELEASE arms dense_until = sim+UNLOCK_GRACE. §113: invent stays off + * through lockstep_until; this flag still marks the post-RELEASE + * reconcile / soft-promote window. */ return g_fmv_lockstep_released && g_fmv_lockstep_until > 0u && sim < g_fmv_lockstep_until; } @@ -7106,6 +7519,21 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) if (!g_rb || !s) return 0; + /* §115: tip+1 platform fork already accepted — do not re-heal it. */ + if (g_platform_accepted_fork != 0u && + mismatch_tick == g_platform_accepted_fork) { + static uint32_t s_accept_refuse_log; + if (s_accept_refuse_log != mismatch_tick) { + fprintf(stderr, + "psxrecomp: rb begin REFUSED mismatch=%u — §115 platform " + "fork already accepted\n", + (unsigned)mismatch_tick); + fflush(stderr); + s_accept_refuse_log = mismatch_tick; + } + g_request_post_fmv_heal = 0; + return 0; + } /* Prefer tip-extend over refusing while an episode is already open. * Span-cap TipHold commit leaves the session inactive — fall through to * open a fresh episode for this mismatch (symmetric with peer). */ @@ -7154,10 +7582,11 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) * §103: ownership SPAN after MEDIA-KF must continue through re-armed * settle (session 142: tip=776 frontier=835 refused → tip-hold hang). */ (void)rb_in_fmv_defer_rewind_window(); - /* §109: MEDIA_KF heal may open through media/settle/DESYNC hold. */ + /* §109/§114: MEDIA_KF heal may open through media/settle/DESYNC hold. */ if (rb_in_fmv_lockstep_window() && !g_ownership_chain && !(rb_media_kf_enabled() && (g_media_kf_episode || g_request_post_fmv_heal || g_post_fmv_heal_kf || + g_post_fmv_platform_nondet || (g_fmv_unmatched_desync && g_request_post_fmv_heal)))) { static uint32_t s_fmv_refuse_sim; if (s_fmv_refuse_sim != sim) { @@ -7190,7 +7619,8 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) * until is armed from live sim at failure (not rewound tip) so catch-up * cannot burn the window before the next d-pad edge. Clean commit does * not arm this. */ - if (sim < g_rewind_cooldown_until && !g_ownership_chain) { + if (sim < g_rewind_cooldown_until && !g_ownership_chain && + !g_request_post_fmv_heal && !g_post_fmv_platform_nondet) { static uint32_t s_cd_log_sim; if (s_cd_log_sim != sim) { fprintf(stderr, @@ -7400,22 +7830,61 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) fflush(stderr); target = load + RB_MAX_RESIM_SPAN; } - /* §109: hc-fork / escalate requested apply-only heal — host KF at load, - * no resim span (Replay re-forks Win↔Linux at the same tick). Ordinary - * pad-mispredict begins in the post-FMV window still SPAN with MEDIA_KF. */ - if (g_request_post_fmv_heal && rb_media_kf_enabled() && - (rb_post_fmv_lockstep_active() || g_fmv_unmatched_desync) && - !rb_fmv_media_active()) { - if (target > load) { + /* §109/§110b: hc-fork requested MEDIA_KF verify heal — resim a short + * span past load so tip+1 is checked. Empty-span tip KF rematches tip + * and storms (Win↔Linux soak). Cap loops at the same fork → DESYNC. */ + if (g_request_post_fmv_heal && rb_post_fmv_heal_eligible()) { + if (mismatch_tick == g_post_fmv_heal_loop_fork && + g_post_fmv_heal_loop_fork > 0u) + g_post_fmv_heal_loop_count++; + else { + g_post_fmv_heal_loop_fork = mismatch_tick; + g_post_fmv_heal_loop_count = 1u; + } + if (g_post_fmv_heal_loop_count > RB_FMV_HEAL_LOOP_LIMIT) { + RNetSession *s = sess(); + uint32_t live = + s ? rnet_session_sim_tick(s) : (mismatch_tick > 0u ? mismatch_tick : load); fprintf(stderr, - "psxrecomp: rb begin post-FMV heal KF CAP target %u→%u " - "(apply-only; mismatch=%u load=%u)\n", - (unsigned)target, (unsigned)load, - (unsigned)mismatch_tick, (unsigned)load); + "psxrecomp: rb DESYNC — post-FMV heal loop storm " + "fork=%u (%u×) — tip+1 platform nondet; §114 keep-live " + "KF stream, cooldown %u ticks\n", + (unsigned)mismatch_tick, + (unsigned)g_post_fmv_heal_loop_count, + (unsigned)RB_FORK_STORM_COOLDOWN_TICKS); fflush(stderr); - target = load; + g_request_post_fmv_heal = 0; + g_post_fmv_heal_kf = 0; + rb_post_fmv_platform_nondet_enter(load, mismatch_tick, live, + "heal loop storm"); + arm_rewind_cooldown_ticks(live, RB_FORK_STORM_COOLDOWN_TICKS, + "DESYNC post-FMV heal loop storm"); + /* Stream heals bypass cooldown via g_request_post_fmv_heal. */ + return 0; + } + { + /* §114: apply-only. Verify-span resim of tip+1 fails matched- + * baseline nondeterminism (Win↔Linux soak: +10 cyc @1036). */ + if (target != load) { + fprintf(stderr, + "psxrecomp: rb begin post-FMV heal KF CAP target %u→%u " + "(apply-only; mismatch=%u load=%u loop=%u)\n", + (unsigned)target, (unsigned)load, + (unsigned)mismatch_tick, (unsigned)load, + (unsigned)g_post_fmv_heal_loop_count); + fflush(stderr); + target = load; + } else { + fprintf(stderr, + "psxrecomp: rb begin post-FMV heal KF apply-only " + "load=%u mismatch=%u loop=%u\n", + (unsigned)load, (unsigned)mismatch_tick, + (unsigned)g_post_fmv_heal_loop_count); + fflush(stderr); + } } g_post_fmv_heal_kf = 1; + g_post_fmv_heal_fork_tick = mismatch_tick; g_request_post_fmv_heal = 0; g_ownership_chain = 0; g_ownership_chain_frontier = 0; @@ -7453,8 +7922,7 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) clear_episode_wire_state(); /* §97/§109: arm MEDIA_KF after wire clear (clear resets media_kf_*). */ { - int post_heal = (rb_post_fmv_lockstep_active() || g_fmv_unmatched_desync) && - !rb_fmv_media_active() && + int post_heal = rb_post_fmv_heal_eligible() && !rb_fmv_tick_unsafe_for_episode(load) && !rb_fmv_tick_unsafe_for_episode(mismatch_tick); /* g_post_fmv_heal_kf may already be set when we CAP'd target=load. */ @@ -7472,6 +7940,7 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) } g_episode_snap_applied = 0; g_pending_resume_valid = 0; + g_empty_span_verify_pending = 0; g_needs_advance = 0; g_seal_export_logged = 0; /* §85: freeze catch-up at this begin's target (before mid-Replay wire). */ @@ -7925,23 +8394,30 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin return; } (void)rb_in_fmv_defer_rewind_window(); - /* Same policy as begin: do not follow into media/lockstep tip episodes. */ - if (rb_in_fmv_lockstep_window()) { - fprintf(stderr, - "psxrecomp: rb follow REFUSED epoch=%u load=%u — FMV lockstep%s\n", - (unsigned)epoch, (unsigned)load, - g_fmv_unmatched_desync ? " [DESYNC hold]" : ""); - fflush(stderr); - g_follow_nack_pending = 1; - g_follow_nack_epoch = epoch; - g_follow_nack_mismatch = mismatch; - g_follow_nack_load = load; - g_follow_nack_target = target; - g_follow_nack_slot = slot; - g_follow_nack_sends = 0; - send_follow_nack(epoch, mismatch, load, target, slot, 1); - g_follow_nack_sends = 1; - return; + /* Same policy as begin (§109/§111): refuse tip episodes through + * media/lockstep/sticky, but allow MEDIA_KF / post-FMV heal follows — + * NACKing heal while sticky invent-held tip-starves both peers. */ + { + int media_ep = (wire_flags & RNET_RB_SYNC_FLAG_MEDIA_KF) || + rb_want_heal_kf(mismatch, load); + if (rb_in_fmv_lockstep_window() && + !(rb_media_kf_enabled() && media_ep)) { + fprintf(stderr, + "psxrecomp: rb follow REFUSED epoch=%u load=%u — FMV lockstep%s\n", + (unsigned)epoch, (unsigned)load, + g_fmv_unmatched_desync ? " [DESYNC hold]" : ""); + fflush(stderr); + g_follow_nack_pending = 1; + g_follow_nack_epoch = epoch; + g_follow_nack_mismatch = mismatch; + g_follow_nack_load = load; + g_follow_nack_target = target; + g_follow_nack_slot = slot; + g_follow_nack_sends = 0; + send_follow_nack(epoch, mismatch, load, target, slot, 1); + g_follow_nack_sends = 1; + return; + } } /* §93 refuse / §97 MEDIA_KF follow into prior FMV bout. */ { @@ -8117,17 +8593,23 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin g_media_kf_ready = 0; g_media_kf_host_armed = 0; g_media_kf_sending = 0; - /* §109: apply-only heal when initiator CAP'd target to load. */ + /* §109/§114: heal when initiator MEDIA_KF + apply-only (target=load) + * or legacy short verify span. Follower arms heal → §114 on success. */ g_post_fmv_heal_kf = - ((wire_flags & RNET_RB_SYNC_FLAG_MEDIA_KF) && target == load && - (rb_post_fmv_lockstep_active() || g_fmv_unmatched_desync)) + ((wire_flags & RNET_RB_SYNC_FLAG_MEDIA_KF) && + target >= load && + target <= load + RB_FMV_HEAL_VERIFY_SPAN && + mismatch > load && rb_post_fmv_heal_eligible()) ? 1 : 0; + if (g_post_fmv_heal_kf) + g_post_fmv_heal_fork_tick = mismatch; } else { g_post_fmv_heal_kf = 0; } g_episode_snap_applied = 0; g_pending_resume_valid = 0; + g_empty_span_verify_pending = 0; g_needs_advance = 0; g_seal_export_logged = 0; g_follow_nack_pending = 0; @@ -8159,6 +8641,99 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin } } +/* §114/§115: initiator re-opens apply-only MEDIA_KF while platform nondet is + * armed and cores still disagree. Prefer host *current* tip when HC is stuck + * on the known tip+1 fork so the pin advances; cap same-keep streams. */ +static void rb_post_fmv_platform_kf_stream_poll(void) +{ + RNetSession *s = sess(); + uint32_t sim; + uint32_t fork = 0u; + uint32_t stream_mm; + int local; + int remote; + int n; + int stale_platform_fork; + + if (!g_post_fmv_platform_nondet && !g_fmv_unmatched_desync) + return; + if (!g_rb || !s || !rb_media_kf_enabled()) + return; + if (rnet_rb_is_active(g_rb)) + return; + if (rnet_session_state_busy(s)) + return; + local = g_b.local_slot ? *g_b.local_slot : 0; + if (local != 0) + return; /* initiator-only */ + sim = rnet_session_sim_tick(s); + if (g_platform_kf_next_sim != 0u && sim < g_platform_kf_next_sim) + return; + if (g_b.hc && !netplay_hc_peek_mismatch(g_b.hc, &fork, NULL, NULL)) { + /* Digests agree for now — wait for CONFIRM streak to clear hold. */ + g_platform_kf_next_sim = sim + RB_FMV_PLATFORM_KF_IV; + return; + } + if (fork == 0u) + fork = (sim > 0u) ? sim : 1u; + if (g_platform_accepted_fork != 0u && fork == g_platform_accepted_fork) { + g_platform_kf_next_sim = sim + RB_FMV_PLATFORM_KF_IV; + return; + } + + stale_platform_fork = + (g_post_fmv_heal_fork_tick != 0u && fork == g_post_fmv_heal_fork_tick) || + (g_post_fmv_platform_keep_tick != 0u && + fork == g_post_fmv_platform_keep_tick + 1u); + + /* §115: HC stays stuck at tip+1 forever (resolved=keep). Streaming that + * fork only re-applies keep. Ride host sim so choose_load pins near tip. */ + stream_mm = fork; + if (stale_platform_fork && sim > fork) { + stream_mm = sim; + fprintf(stderr, + "psxrecomp: rb §115 KF stream ride host tip fork=%u→%u " + "sim=%u (stale tip+1)\n", + (unsigned)fork, (unsigned)stream_mm, (unsigned)sim); + fflush(stderr); + } + + if (stale_platform_fork) { + g_platform_kf_stream_count++; + if (g_platform_kf_stream_count > RB_FMV_PLATFORM_KF_STREAM_LIMIT) { + uint32_t through = g_post_fmv_platform_keep_tick; + if (sim > through) + through = (sim > 0u) ? (sim - 1u) : fork; + fprintf(stderr, + "psxrecomp: rb §115 KF stream CAP fork=%u count=%u " + "sim=%u — accept platform tip+1\n", + (unsigned)fork, (unsigned)g_platform_kf_stream_count, + (unsigned)sim); + fflush(stderr); + rb_post_fmv_platform_accept(through, fork, + "KF stream limit at stale tip+1"); + return; + } + } else { + g_platform_kf_stream_count = 0u; + } + + n = g_b.slot_count ? *g_b.slot_count : 0; + remote = (local == 0) ? 1 : 0; + if (n < 2) + remote = local; + g_request_post_fmv_heal = 1; + g_post_fmv_heal_loop_count = 0u; + g_platform_kf_next_sim = sim + RB_FMV_PLATFORM_KF_IV; + fprintf(stderr, + "psxrecomp: rb §114 KF stream begin fork=%u stream_mm=%u sim=%u " + "(platform nondet; stream=%u)\n", + (unsigned)fork, (unsigned)stream_mm, (unsigned)sim, + (unsigned)g_platform_kf_stream_count); + fflush(stderr); + (void)psx_netplay_rb_begin_rewind(stream_mm, remote); +} + void psx_netplay_rb_pump(void) { RNetSession *s = sess(); @@ -8174,6 +8749,8 @@ void psx_netplay_rb_pump(void) /* §97: host probe/transfer media keyframe while awaiting baseline. */ rb_media_kf_host_drive(); + /* §114: stream apply-only KF while platform nondet invent-hold is armed. */ + rb_post_fmv_platform_kf_stream_poll(); /* §40: drain RESOLVED before SYNC so follow BEGIN in the same poll * sees an updated frontier (max(agreed, resolved_through)). */ diff --git a/runtime/src/psx_netplay_sched.c b/runtime/src/psx_netplay_sched.c index 1aeb77dd..4a77890a 100644 --- a/runtime/src/psx_netplay_sched.c +++ b/runtime/src/psx_netplay_sched.c @@ -95,7 +95,12 @@ static const char *np_gate_lockstep_stall_tag(void *ctx) return "fmv_media"; if (psx_netplay_rb_fmv_desync_hold()) return "fmv_desync_hold"; - return "fmv_settle"; + if (psx_netplay_rb_post_fmv_heal_sticky()) + return "heal_sticky"; + /* §113: invent hold runs through lockstep MIN (loading), not settle-only. */ + if (psx_netplay_rb_fmv_settle_active()) + return "fmv_settle"; + return "fmv_lockstep"; } static uint32_t np_gate_episode_count(void *ctx) diff --git a/runtime/src/traps.c b/runtime/src/traps.c index 01d6da67..ac6fa06b 100644 --- a/runtime/src/traps.c +++ b/runtime/src/traps.c @@ -732,6 +732,12 @@ void psx_scheduler_resume_at(uint32_t resume_pc) /* longjmp abandons every mid-block CPS frame — sentinel RFE must not * treat the new top-level dispatch as a continuable live chain. */ g_sched_top_level_resume = 1; + /* Publish before the longjmp lands in dispatch: sticky I_STAT can deliver + * on the first edge with take_pc=0 otherwise (LEGACY_SENTINEL fork). */ + { + extern void psx_irq_arm_compiled_resume_pc(uint32_t pc); + psx_irq_arm_compiled_resume_pc(resume_pc); + } longjmp(g_scheduler_jmpbuf, 1); /* unwind to psx_scheduler_run; never returns */ } @@ -849,10 +855,12 @@ void psx_scheduler_run(CPUState* cpu) } uint32_t run_pc; + int from_top_resume = 0; if (g_sched_escape.reason == PSX_RUN_RESUME_CURRENT && g_sched_escape.resume_pc != 0u) { /* Same-thread RFE: GPRs already committed by the RFE; just re-dispatch. */ run_pc = g_sched_escape.resume_pc; + from_top_resume = 1; } else { uint32_t cur = psx_current_tcb_ptr(cpu); if (psx_is_valid_tcb(cpu, cur)) { @@ -872,6 +880,12 @@ void psx_scheduler_run(CPUState* cpu) cpu->pc = run_pc; g_sched_escape.reason = PSX_RUN_CONTINUE; + /* Re-arm on the dispatch that consumes RESUME_CURRENT (covers + * recover_null_pc continues that skip resume_at). */ + if (from_top_resume) { + extern void psx_irq_arm_compiled_resume_pc(uint32_t pc); + psx_irq_arm_compiled_resume_pc(run_pc); + } psx_dispatch(cpu, run_pc); /* psx_dispatch returned with NO structured escape => the outermost diff --git a/runtime/tests/test_psx_cycle_event_boundaries.c b/runtime/tests/test_psx_cycle_event_boundaries.c index 1d5a6ad1..b6c2ac43 100644 --- a/runtime/tests/test_psx_cycle_event_boundaries.c +++ b/runtime/tests/test_psx_cycle_event_boundaries.c @@ -61,6 +61,7 @@ void starvation_ring_pc_sample(void) {} int psx_netplay_active(void) { return 0; } int psx_selfcheck_enabled(void) { return 0; } void dirty_ram_ld_delay_discard(void) {} +void dirty_ram_irq_ambient_resync_after_restore(void) {} int main(void) { /* NULL cpu: this test pins the scheduler boundary, not the CPU-state