From 4073e2311cf9b074aaa448dd09db044245b2c817 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Thu, 6 Aug 2026 20:34:50 -0400 Subject: [PATCH] rollback + fmv + connectivity --- docs/GAME_PROJECT_SETUP.md | 3 + docs/ROLLBACK_MOTK_HOOKUP.md | 216 ++++++++ docs/config_schema.md | 24 + recompiler/src/config_loader.cpp | 29 ++ recompiler/src/config_loader.h | 10 + runtime/include/cdrom.h | 7 +- runtime/include/dirty_ram_interp.h | 3 + runtime/include/disc_identity.h | 35 +- runtime/include/mdec.h | 2 + runtime/include/psx_lobby_client.h | 10 + runtime/include/psx_netplay.h | 19 +- runtime/include/psx_netplay_rb.h | 3 + runtime/src/cdrom.c | 348 +++++++++---- runtime/src/disc_identity.cpp | 107 +++- runtime/src/gpu.c | 17 +- runtime/src/main.cpp | 119 ++++- runtime/src/mdec.c | 8 + runtime/src/memory.c | 16 + runtime/src/psx_lobby_client.c | 98 +++- runtime/src/psx_netplay.c | 221 ++++++++- runtime/src/psx_netplay_rb.c | 761 +++++++++++++++++++++++++---- runtime/src/psx_netplay_sched.c | 116 +++-- 22 files changed, 1911 insertions(+), 261 deletions(-) diff --git a/docs/GAME_PROJECT_SETUP.md b/docs/GAME_PROJECT_SETUP.md index bf105208..6bec48d0 100644 --- a/docs/GAME_PROJECT_SETUP.md +++ b/docs/GAME_PROJECT_SETUP.md @@ -189,6 +189,9 @@ Use this before tagging a setup-host release that matches other titles - [ ] `psxrecomp/` and `recomp-ui/` are root-level submodules on pinned commits - [ ] CLI lives in the submodule (`psxrecomp/psxrecomp_cli.py`) — no sibling sdk - [ ] `game.toml` has disc identity (`[prepare_disc]` hashes/sizes) and boot EXE +- [ ] Multi-track titles that ship netplay: `[netplay] require_cue` + + `required_tracks` (and optional `required_disc_fp`) so Track-01-only + dumps cannot go online against full Redump cues - [ ] Seeds cover the boot path; `VERSION` matches the release you will tag - [ ] Disc images, `generated/`, and `SCPH1001.BIN` are gitignored - [ ] Setup-host CMake path builds with **no** game C and **no** BIOS backends diff --git a/docs/ROLLBACK_MOTK_HOOKUP.md b/docs/ROLLBACK_MOTK_HOOKUP.md index c336ab6e..9930aabd 100644 --- a/docs/ROLLBACK_MOTK_HOOKUP.md +++ b/docs/ROLLBACK_MOTK_HOOKUP.md @@ -4586,3 +4586,219 @@ hardcoded present-1/4 skip (~67 ms+ between Swaps), not snap cost. **Re-soak watch:** during FMV media, `admit=` near ~0–2 ms/f (not 5–11), `D` stable (no mid-movie 6→7), `present_gap_p95` roughly half of §97, `rb snap tele` cadence ~interval 2, sim FPS closer to guest ceiling. + +--- + +## 101. Zombie-load follow NACK (session-136 seal hang) (2026-08-06) + +**Symptoms (session 136 soak):** guest `begin epoch=33 load=3184` while host +already had `agreed tip=3200` → host `follow REFUSED … zombie load` ×100 with +**no NACK** → guest sat in `SealInputs` until `RB_SEAL_TIMEOUT_MS` (4s) → +`episode ABORT` → hc-fork + ownership cascade; host FPS cliff `0.8` / +`admit≈1241 ms`, present_gap max ~3–4 s. + +**Root cause:** §87 zombie-load / zombie-epoch refuse returned silently. Other +refuse paths (past frontier, missing snap, FMV) already armed `follow NACK`. + +**What landed:** + +1. **NACK on zombie load / zombie epoch** — `follow_refuse_nack` arms the + same follow-NACK (frontier = `follower_frontier_hint` / agreed tip) so the + initiator aborts Seal in ~1 RTT instead of 4s. +2. **Rate-limited refuse log** — one line per `(epoch, load)`, then + `+N similar` every 32; one NACK arm per unique refuse. +3. **NACK peer-ahead fast path** — when NACK `frontier >= load`, set + `g_peer_nack_floor`, `apply_peer_resolved(frontier)`, abort Seal, and + **keep-live** when the snap was never applied (no demote-to-`load-1` + rewind of good Live). +4. **Begin raise** — after `choose_load`, if `peer_resolved > load`, raise + to a mutual snap ≤ peer tip so we do not reopen a zombie load. + +**Re-soak watch:** no `seal timeout (peer missing snap / NACK lost)` after +zombie refuse; expect `follow NACK … frontier=` then +`NACK keep-live peer-ahead` / `begin raise load` within tens of ms, not a +multi-second present hitch. + +--- + +## 102. Peer-ahead NACK → one light tip (no ownership cascade) (2026-08-06) + +**Symptoms (session 140 after §101):** zombie refuse NACKed cleanly, but host +still opened `hc-fork` → `begin load=6640 target=6660` → ownership hops +32/40 → host **22 fps / replay 96%** / present_gap max ~531 ms. + +**Root cause:** keep-live left hc-fork recovery to open a deep SPAN; Replay +ownership then chained to drain confirmed backlog. + +**What landed:** + +1. **Drain RESOLVED before `choose_load`** — avoid opening a zombie load when + the peer tip is already queued on the socket. +2. **`psx_netplay_hc_fork_recovery_clear`** — peer-ahead NACK drops pending + hc-fork bookkeeping so it cannot open a SPAN recovery. +3. **Immediate light tip reopen** — raise agreed to peer tip, arm + `g_peer_ahead_force_load`, `begin` with target capped to `load+4`, force + light-tip class. +4. **No ownership chain** — `ownership_on_post_match` exits that episode to + Live (`peer-ahead light → Live`) instead of SPAN hops. + +**Re-soak watch:** after zombie NACK expect +`peer-ahead light reopen` / `begin … light=1` / `peer-ahead light → Live` +with **no** `ownership chain tip=` hops and no `hc-fork recovery begin` on +that edge. Hitch should be a short light replay (depth ≤4), not a 96% window. + +--- + +## 103. Past-frontier NACK deep demote → MEDIA-KF hang (2026-08-06) + +**Symptoms (session 142):** post-FMV host `begin epoch=16 load=816 light=1 +media_kf=0`; guest `follow REFUSED … past frontier=763` + NACK; host +`NACK realign … demote=763 live_was=827` (dropped 63 snaps) → +`FIRST CORE DIVERGE @764` → `begin … load=752 media_kf=1` (~3.7 MB transfer, +`stall=rb_baseline`) → after KF tip=776 ownership hop +`begin REFUSED … FMV lockstep` → tip-hold; host present_gap max ~3531 ms / +~15 fps, guest ~7.4 fps. + +**Root cause:** past-frontier NACK treated a stale follower frontier like a +shared rewind tip. Live demote across the FMV bout forked cores and forced a +deep MEDIA-KF; re-armed settle then blocked the ownership catch-up hop. + +**What landed:** + +1. **Deep keep-live** — when snap was never applied and + `live − demote > RB_NACK_DEEP_REALIGN_MAX` (8): no Live realign; soft-demote + agreed to `load-1` only; clamp `peer_resolved` to the follower frontier. +2. **Sticky peer gate** — `g_past_frontier_sticky` prevents GATE_MS force-open + from reopening above that frontier until the follower advertises RESOLVED. +3. **Ownership through settle** — `begin` allows `g_ownership_chain` (and + active MEDIA_KF) through FMV lockstep so post-KF SPAN catch-up is not + tip-held. + +**Re-soak watch:** after `follow REFUSED … past frontier` expect +`NACK keep-live deep frontier=…` (not `NACK realign … demote=` far below +live); no immediate `MEDIA-KF transfer` / `stall=rb_baseline` from that edge; +if a MEDIA-KF still completes, ownership should log `ownership chain tip=` +hops rather than `ownership chain FAILED … tip-hold`. + +--- + +## 104. Fight ownership tax + guest invent (session 144) (2026-08-06) + +**Symptoms (session 144 after §103):** no MEDIA-KF / deep demote / seal hang. +Median 60 fps. Remaining cost was fight-phase: many short ownership +`final`/`BUDGET` hops → replay windows 12–79% / present_gap max ~430 ms; +guest invent ~5× host (`GAP1_LEGACY` case_b + `RUNWAY_EMPTY`); auto-delay +`5→4` mid-fight; one ~3.1 s FMV present cliff (unchanged — not `present_div`). + +**What landed:** + +1. **Ownership floor tip+6** — `RB_OWNERSHIP_CHAIN_MIN_AHEAD` 4→6 so tip+4/+5 + delay cushion exits Live instead of SPAN. Suppress after budget + 24→48 ticks / 400→800 ms. +2. **Gap1 micro-grace for relay-LAN** — `RB_GAP1_LAN_RTT_MAX_MS` 12→48 so + `force_input_relay` RTT≈21–45 ms arms grace; cap 6→12 ms. Healthy-lead + case_b also waits (not invent-now on tip cadence blips). +3. **Runway grace** — `RB_INVENT_RUNWAY_GRACE_CAP_MS` 8→12. +4. **Auto-delay lower floor** — never shrink below D=5 mid-match + (`RB_AUTO_DELAY_LOWER_FLOOR`); tighter lower bar (miss<1‰ + lead≥3). + Force-TURN floor 6 unchanged. +5. **PIN-SKEW log rate-limit** — one line per tick (cosmetic; no control change). + +**Left alone:** FMV `PSX_NET_FMV_PRESENT_DIV` default 2 (3.1 s cliff is a +single media stall, not 1/N cadence); HC-silent promote stay fail-closed on +digest proof; §101–103 NACK paths untouched. + +**Re-soak watch:** fewer `ownership chain tip=` / `BUDGET` lines during +fight; guest `admit stats invent_gap1` closer to host; `gap1_grace` counts +rise; no `delay committed 5 → 4`; replay% spikes under button traffic +smaller (aim <30% windows, present_gap max <200 ms outside FMV). + +--- + +## 105. Invent≠delay raise + gap1 wait for tip invent (session 149) (2026-08-06) + +**Symptoms (session 149 after §104):** felt regressive. Ownership begins +dropped (good), but host invent exploded (`invent_gap1=2285`, all +`remote_lead=-1` / case_b), `gap1_grace=0` (healthy-lead arm dead), lobby +`D=4` then auto-delay `4→5→6→7` on invent-as-miss (`miss=993‰`, +`late_n≈1`, `lead_avg≈-2`). Ended in FMV DESYNC MAX unmatched. + +**Root cause:** tip invent counted as auto-delay arrival miss → raising D +added lag without stopping invent. §104 grace required `lead≥D-1`, which +never holds on the invent path (`lead=-1`). + +**What landed:** + +1. **Undo invent miss** — invent clears the auto-delay miss sample + (`np_auto_delay_undo_invent_miss`); scorecard miss-at-need still counts. +2. **Raise needs lateness** — auto-delay bump only when `late_n>0` (waited-out + arrival), not invent-only miss storms. +3. **Gap1 grace for tip invent** — on gap=1, if RTT≤48 ms or tip-due, always + wait `min(½RTT+base, 12ms)` (no healthy-lead gate). +4. **Session D floor 5** — rollback clamps lobby seed `<5` → `5` at + `rnet_session_create` and in `np_sched_sync_delay_from_session`. + +**Kept from §104:** ownership `min_ahead=6` / suppress 48/800; runway grace +12; PIN-SKEW rate-limit; auto-delay lower floor 5. + +**Re-soak watch:** start log `session delay floor … → 5` if lobby was 4; +`gap1_grace` > 0 in admit stats; no `auto delay 5 → 6/7` driven by +`miss≈1000‰` with `late_n=0/1` and `lead_avg<0`; invent counts lower; D +stable near 5–6 unless real lateness / pcap. + +--- + +## 106. No-ICE MotK → SFU dial / clear ice_p2p refuse (session 151) (2026-08-06) + +**Symptoms (session 151):** host (`dist/motk`, ICE build) started SFU +(`force_input_relay=1`, peer `192.168.66.3:8777`). Guest Desktop pack +failed start `-4` with `hosted lobby requires ICE, but ICE is not available +in this build` while peer was already `netplay…:8777` — SFU endpoint present, +`force_input_relay` not applied / ICE required anyway. + +**Root cause:** no-ICE guest + MotK room treated as hard ICE requirement even +when a usable SFU peer (or equal host/guest relay advertise) was available. +`ice_p2p` launch on no-ICE builds fell through to the same opaque `-4`. + +**What landed:** + +1. **`resolve_use_ice` no-ICE** — MotK + non-empty `peer_hostport` → LAN/SFU + dial (log note); MotK/`transport=1` with empty peer → clearer SFU/ICE error. +2. **Launch `ice_p2p` without ICE** — refuse with `last_error=ice_required` + (do not leave launch_pending for a doomed start). +3. **`ae_np_fill_launch`** — infer `force_input_relay=1` when + `host_endpoint==guest_endpoint` (SFU advertise) even if caps bit omitted. + +**Re-soak watch:** no-ICE guest on SFU MotK logs `server input relay` or +`no-ICE build — MotK room dialing peer … as LAN/SFU` and connects; ice_p2p +without ICE shows `ice_required` in lobby UI, not start `-4`; both sides +share SFU when server picked `transport=sfu`. + +--- + +## 107. Gap1 micro-grace on SFU/relay RTT (post-§105 soak) (2026-08-06) + +**Symptoms (post-§105 soak, ~18k ticks, SFU `force_input_relay=1`):** resim +health good (mispredict host 8 / guest 17, freeze=0, MEDIA-KF sealed, D +ends 7 and moves on real `late_n`). Remaining cost: host tip invent storms +in fight windows (`GAP1_LEGACY` ~550–600 at `remote_lead=-1` while guest +~0–5); **1277/1305 host invents had `rtt>48`**. One auto-delay window +`miss=926‰` with `lead_avg≈-0.9` at rtt~75 (tip-park tax). + +**Root cause:** §105 gap1 micro-grace only armed when `rtt_raw≤48` (LAN gate) +or tip-due. SFU/relay POST-RTT sat ~75–125 → `lan=false` → `gap1_cap=0` → +invent every gap=1 tick. §105 correctly stopped invent-as-miss from +ratcheting D; it did not stop the invents. + +**What landed:** + +1. **Always grace on gap=1** (non-FMV) — `min(½RTT+base, cap)` for tip invent. +2. **Split caps** — LAN / tip-due keep cap **12 ms**; relay (`rtt>48` and not + tip-due) cap **20 ms** so SFU gets a real wait without parking a full + frame on a due tip. +3. **FMV unchanged** — §98 invent-immediate during media. + +**Re-soak watch:** host invents/1k drop hard in fight windows (aim ≪70); +`gap1_grace` rises vs invent_gap1; guest invent stays low; D still only +raises on real lateness (`late_n>0`); no feel of extra stall on LAN +(`rtt≤48` path unchanged at cap 12). diff --git a/docs/config_schema.md b/docs/config_schema.md index 0d41756e..dab1773f 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -53,11 +53,35 @@ How the two configs relate: ```toml [program] # in bios.toml; describes the BIOS [game] # in game.toml; describes the game +[prepare_disc] # optional; data-track digests for prepare/verify +[netplay] # optional; TOC / cue policy for online [recompiler] [runtime] [audit] ``` +## Netplay disc mount (`[netplay]`) + +Optional. Online play needs the same CD geometry on every peer — data-track +CRC/SHA alone cannot distinguish a Track-01-only dump from a full Redump +multi-track cue. The runtime mounts the resolved path, fingerprints the TOC +(`disc_fp`), and gates Host/Join on `netplay_ok`. Peers also exchange +`disc_fp` through the lobby (`disc_mismatch` on join). + +| Field | Default | Description | +|---|---|---| +| `require_cue` | `false` | Require a `.cue` mount (reject bare `.bin` / cue→bin fallback) | +| `required_tracks` | `0` | Exact `iso_track_count` when > 0 (e.g. MotK Redump = `17`) | +| `required_leadout_lba` | unset | Exact lead-out LBA when set | +| `required_disc_fp` | `""` | Exact lowercase hex SHA-256 TOC fingerprint when non-empty | + +Offline Play may still launch with a TOC warning; first-run setup Finish and +online Create/Join require `netplay_ok` (and online also a clean verify + +non-empty `disc_fp`). Mirror `required_tracks` in the RetComM catalog as +`rom_identity.track_counts` so the hub library scan rejects Track-01-only dumps. +Wizard / RetComM / catalog submission accept Redump `.cue` + sibling `.bin` +tracks only — not `.iso`/`.chd` (cannot reliably expand to multi-track). + ## Program / game block - **bios.toml** has a `[program]` block describing the BIOS ROM. diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 400ab744..ff27ed64 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -1128,6 +1128,30 @@ GameConfig load_game_config(const fs::path& config_path_in) { for (char& c : disc_sha1) c = (char)std::tolower((unsigned char)c); } + // [netplay] — mount / TOC policy for online (see DiscIdentity::disc_fp). + bool netplay_require_cue = false; + int netplay_required_tracks = 0; + bool has_netplay_required_leadout = false; + uint32_t netplay_required_leadout_lba = 0; + std::string netplay_required_disc_fp; + if (cfg.contains("netplay")) { + const toml::value& np = toml::find(cfg, "netplay"); + if (np.contains("require_cue")) + netplay_require_cue = toml::find(np, "require_cue"); + if (np.contains("required_tracks")) + netplay_required_tracks = toml::find(np, "required_tracks"); + if (np.contains("required_leadout_lba")) { + netplay_required_leadout_lba = + (uint32_t)toml::find(np, "required_leadout_lba"); + has_netplay_required_leadout = true; + } + if (np.contains("required_disc_fp")) { + netplay_required_disc_fp = toml::find(np, "required_disc_fp"); + for (char& c : netplay_required_disc_fp) + c = (char)std::tolower((unsigned char)c); + } + } + // [recompiler] if (!cfg.contains("recompiler")) { throw std::runtime_error( @@ -1866,6 +1890,11 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*has_disc_crc*/ has_disc_crc, /*disc_crc*/ disc_crc, /*disc_sha1*/ disc_sha1, + /*netplay_require_cue*/ netplay_require_cue, + /*netplay_required_tracks*/ netplay_required_tracks, + /*has_netplay_required_leadout*/ has_netplay_required_leadout, + /*netplay_required_leadout_lba*/ netplay_required_leadout_lba, + /*netplay_required_disc_fp*/ netplay_required_disc_fp, /*seeds_path*/ seeds_path, /*bios_thunks_path*/ bios_thunks_path, /*bios_config_path*/ bios_config_path, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 869395db..4b1c43e8 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -603,6 +603,16 @@ struct GameConfig { uint32_t disc_crc = 0; std::string disc_sha1; + // [netplay] disc mount policy (portable across psxrecomp titles). + // Data-track CRC proves "right game"; these prove "same CD geometry" + // (GetTN track count / cue layout) so peers cannot join with Track-01-only + // dumps vs full Redump multi-track cues. 0 / empty = do not check that field. + bool netplay_require_cue = false; + int netplay_required_tracks = 0; + bool has_netplay_required_leadout = false; + uint32_t netplay_required_leadout_lba = 0; + std::string netplay_required_disc_fp; // lowercase hex SHA-256 + // [recompiler] block std::filesystem::path seeds_path; // absolute path to seeds (text or json) std::filesystem::path bios_thunks_path; // optional; empty if not set diff --git a/runtime/include/cdrom.h b/runtime/include/cdrom.h index 363b172d..cea4eb16 100644 --- a/runtime/include/cdrom.h +++ b/runtime/include/cdrom.h @@ -63,7 +63,8 @@ int cdrom_xa_stream_active(void); * entry opened a tip episode into a matched black wait. */ int cdrom_fmv_stream_pending(void); /* Re-arm host absolute CD deadlines from restored relative delays after - * boot_state / RB snap load. Does NOT clamp delays (unlike accelerate). */ + * boot_state / RB snap load (read stream + pending/present dues from snap). + * Does NOT clamp delays (unlike accelerate). */ void cdrom_resync_deadlines_after_restore(void); /* CD load-burst ring (always-on). One record per gap-separated run of @@ -155,9 +156,13 @@ typedef struct CDROMDebugState { uint8_t filter_channel; uint8_t muted; int read_delay; + /* Cycles until a held CD response is presented to INTC (0 = ready). */ + int irq_present_delay; int pending_pending; int pending_delay; int pending_phase; + /* Operating disc-speed divisor (1 during BIOS; game.toml post-entry). */ + int speed_divisor; uint32_t i_stat; int last_sector_lba; int last_sector_size; diff --git a/runtime/include/dirty_ram_interp.h b/runtime/include/dirty_ram_interp.h index d716dcd4..4e009197 100644 --- a/runtime/include/dirty_ram_interp.h +++ b/runtime/include/dirty_ram_interp.h @@ -125,6 +125,9 @@ uint32_t dirty_ram_get_bitmap(void); uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); uint32_t dirty_ram_get_bitmap_word_count(void); void dirty_ram_set_bitmap_words(const uint32_t* words, uint32_t count); +/* Rematch / session_reboot: wipe host dirty tracking so dig0 matches a cold + * process (memory_init clears RAM but used to leave these bitmaps sticky). */ +void dirty_ram_reset_for_boot(void); /* After bulk RAM restore (savestate): bump overlay page gens + lazy-miss epoch * so native overlays re-hash against restored bytes; also drop sticky * text_diverged/modified bitmaps (host-only — restored RAM may match ref). */ diff --git a/runtime/include/disc_identity.h b/runtime/include/disc_identity.h index 6fa92740..490b41ae 100644 --- a/runtime/include/disc_identity.h +++ b/runtime/include/disc_identity.h @@ -5,6 +5,11 @@ // PlayStation boot serial (from SYSTEM.CNF), derives the region from the // serial prefix, and optionally compares against an expected serial / CRC32. // +// Also mounts the resolved path via ISOReader to capture TOC geometry +// (track count, lead-out, per-track LBAs) and a stable disc_fp fingerprint +// used by netplay peer matching — data-track CRC alone cannot catch a +// Track-01-only mount vs a full Redump multi-track cue. +// // Used both by the runtime's launch-time disc check (runtime/src/main.cpp) // and by the shared launcher's "Disc verified" badge, so // the two never drift apart. @@ -36,9 +41,31 @@ struct DiscIdentity { bool expected_crc_given = false; bool crc_matches = false; + // Mount / TOC (ISOReader on resolve_disc_path().mount). + bool from_cue = false; // mount path is a .cue + bool cue_fallback = false; // resolver fell back from a broken cue to a bin + bool upgraded_to_cue = false; // caller picked bin; mounted owning cue + bool toc_opened = false; // ISOReader::Open succeeded on mount + int track_count = 0; // >= 1 when toc_opened + uint32_t leadout_lba = 0; // iso sector count (GetTD track 0) + std::string disc_fp; // lowercase hex SHA-256 of canonical TOC + + // Netplay gate (filled when NetplayDiscExpect is applied). + bool netplay_ok = true; + std::string netplay_detail; // why netplay_ok is false + std::string detail; // human-readable note (failure reason or extra info) }; +// Optional title policy from game.toml [netplay] (0 / empty = do not check). +struct NetplayDiscExpect { + bool require_cue = false; + int required_tracks = 0; // exact iso_track_count; 0 = skip + bool has_required_leadout = false; + uint32_t required_leadout_lba = 0; + std::string required_disc_fp; // exact fingerprint; empty = skip +}; + // Identify and (optionally) verify a disc image. // path : a .cue file or a raw .bin/.iso image // expected_serial : the game id, e.g. "SCUS-94236" ("" skips the serial check) @@ -47,9 +74,15 @@ struct DiscIdentity { // compute_crc : compute the full-file CRC32 (streamed; can be slow on big // images — callers typically only request it when there is an // expected CRC to compare against) +// netplay_expect : optional TOC / cue policy; nullptr skips netplay_ok checks +// (disc_fp / track_count are still computed when mount opens) DiscIdentity identify_disc(const std::filesystem::path& path, const std::string& expected_serial, uint32_t expected_crc, bool has_expected_crc, - bool compute_crc); + bool compute_crc, + const NetplayDiscExpect* netplay_expect = nullptr); + +// Apply / re-apply netplay mount policy onto an already-identified disc. +void apply_netplay_disc_expect(DiscIdentity& id, const NetplayDiscExpect& expect); } // namespace PSXRecompV4 diff --git a/runtime/include/mdec.h b/runtime/include/mdec.h index edc83fbb..dbe69663 100644 --- a/runtime/include/mdec.h +++ b/runtime/include/mdec.h @@ -24,6 +24,8 @@ int mdec_dma_read_ready(void); * widescreen present pins such frames to native 4:3 (FMVs are authored 4:3 * and get no GTE squash to compensate the stretch). */ int mdec_recently_active(uint32_t within_frames); +/* Guest cycles since last colour decode; UINT64_MAX if never / reset. */ +uint64_t mdec_color_age_cycles(void); typedef struct MDECDebugState { uint32_t command; diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 9d05a051..c8811c1a 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -103,6 +103,16 @@ void psx_lobby_pump(void); void psx_lobby_set_game_identity(const char *game_name, const char *game_version); const char *psx_lobby_game_version(void); +/* + * TOC fingerprint (lowercase hex SHA-256 from DiscIdentity::disc_fp). + * Sent on create/join so the lobby server can reject mismatched mounts + * (e.g. Track-01-only vs full multi-track cue). Empty disables the check + * on the server for that peer (legacy clients); modern clients always set it + * after a successful disc verify. + */ +void psx_lobby_set_disc_fp(const char *disc_fp); +const char *psx_lobby_disc_fp(void); + /* Default max_slots for create (clamped 2..8, default 2). */ void psx_lobby_set_max_slots(int max_slots); diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index e974f521..ae55a5ea 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -52,7 +52,7 @@ typedef struct PsxNetplayConfig { int input_delay; /* Rollback invent runway (phase_lock / P). Clamped 2..16. Unused in delay-sync. */ int input_prediction; - int force_input_relay; /* 1 = lobby UDP SFU star (online default) */ + int force_input_relay; /* 1 = lobby UDP SFU star (from launch) */ int force_turn; /* 1 = ICE relay-only (Force TURN for UDP) */ /* 0 = auto (MotK room → ICE, else LAN), 1 = force ICE, 2 = force LAN. * Env PSX_NET_TRANSPORT=lan|ice overrides. */ @@ -93,8 +93,10 @@ int psx_netplay_start(const PsxNetplayConfig *cfg); void psx_netplay_shutdown(void); /* - * Guest only: after savestate_configure + memcard_init, redirect .pst/.mcd - * writes to saves/netplay/ so host sync never touches personal saves. + * After savestate_configure + memcard_init: refresh RB integrity keys from + * savestate (host + guest — start() ran before configure), then guest-only + * redirect .pst/.mcd writes to saves/netplay/ so host sync never touches + * personal saves. */ void psx_netplay_bind_guest_saves(void); @@ -247,6 +249,17 @@ void psx_netplay_on_rb_snap_loaded(void); /* §64: restart hc-fork persist/retry clocks after tip-extend abandon so a * second recovery episode cannot open on the same Live tick. */ void psx_netplay_hc_fork_recovery_restart(void); +/* §102: drop pending hc-fork bookkeeping (peer-ahead NACK already scheduled + * a light tip reopen — do not open a deep SPAN recovery). */ +void psx_netplay_hc_fork_recovery_clear(void); + +/* §93 P1 bisect: PSX_RB_CD_BISECT=1 (optional =LO-HI, default 150-250) logs + * per-tick CD/MDEC/clk crumbs + CD cmd ISSUE/QUEUE/DONE timeline (cdrom.c; + * includes stat/resp FIFO + GetTN tracks / GetTD td_lba). + * Arm N more ticks after a media flush_resume. */ +void psx_netplay_cd_bisect_arm(uint32_t from_sim, uint32_t ticks); +/* 1 when CD bisect env is on and sim is in the fixed or post-media arm window. */ +int psx_netplay_cd_bisect_active(void); /* Netplay GPU lock: CPU-authoritative VRAM (software raster) so snaps/digests * are peer-identical. If OpenGL was requested, keep the GL context for diff --git a/runtime/include/psx_netplay_rb.h b/runtime/include/psx_netplay_rb.h index 9131bae3..b0df7650 100644 --- a/runtime/include/psx_netplay_rb.h +++ b/runtime/include/psx_netplay_rb.h @@ -101,6 +101,9 @@ int psx_netplay_rb_fmv_episode_unsafe(uint32_t tick); int psx_netplay_rb_media_kf_probe_match(uint32_t size, uint32_t crc); /* §97: apply RB_KF state blob (or host finish). Returns 1 if handled. */ int psx_netplay_rb_media_kf_on_ready(const void *data, size_t size); +/* §100: 1 while a MEDIA-KF episode is waiting on probe/transfer/pin (sim + * stalled — present should hold-last so the intro FMV does not freeze). */ +int psx_netplay_rb_media_kf_busy(void); /* Mid-guest resim pump: abort if Replay has made no finish_frame progress. * Full rb_pump stays admit/present-edge only (host-asymmetric). */ diff --git a/runtime/src/cdrom.c b/runtime/src/cdrom.c index decbc743..b783ecd5 100644 --- a/runtime/src/cdrom.c +++ b/runtime/src/cdrom.c @@ -16,7 +16,9 @@ #include "spu.h" #include "event_ring.h" #include "audio_trace.h" +#include "interrupts.h" #include "psx_cycles.h" +#include "psx_netplay.h" #include #include #include @@ -116,7 +118,9 @@ static uint32_t cdrom_intc_latched_generation; * while staying well under the second-response delays (10k-30k), so command * throughput is unaffected. */ #define CDROM_IRQ_PRESENT_DELAY 5000 -static int cdrom_irq_present_delay; +/* Absolute guest cycle when an armed CD response may be presented to INTC. + * 0 = no presentation hold. Relative remaining is derived for snaps/digests. */ +static uint64_t cdrom_irq_present_due; /* Parameter FIFO */ #define PARAM_FIFO_SIZE 16 @@ -285,9 +289,10 @@ int cdrom_fmv_stream_pending(void) { if (xa_stream_active) return 1; - /* mode 0x40 = XA-ADPCM enable; 0x08 = sector size / related FMV setup. - * Match warm-route consumer (mode & 0x48). */ - if (reading && (mode_reg & 0x48u)) + /* FMV/STR preamble: Form2 (0x20) often arms before XA-ADPCM (0x40) / + * filter (0x08). MotK intro SetMode(0xa2)+ReadN was invisible here, so + * invent ran through the first double-speed Form2 sector race. */ + if (reading && (mode_reg & 0x68u)) return 1; return 0; } @@ -298,6 +303,8 @@ void cdrom_resync_deadlines_after_restore(void) s_cd_timing_next_due = psx_cycle_count + (uint64_t)read_delay; else if (!reading) s_cd_timing_next_due = 0; + /* pending.due_cyc / cdrom_irq_present_due rebuilt in snap_parse from + * relative remaining (same guest clock as the restore). */ } /* Response-overwrite diagnostics consumed by debug_server.c. master's CD @@ -524,9 +531,11 @@ static int apply_speed(int delay) { static int apply_read_speed(int delay) { /* A route is an explicit DATA-read allowlist, never a blanket drive-speed - * change. XA filter/ADPCM modes are rejected before the first streaming - * sector can set xa_stream_active; established XA remains authentic too. */ - if (xa_stream_active || (mode_reg & 0x48u)) return delay; + * change. Keep FMV/STR authentic: XA-ADPCM (0x40), filter (0x08), and + * Form2/0x924 (0x20) — MotK sets mode 0xa2 (Form2+2×) before the XA bit, + * and disc_speed=4x was compressing that preamble into a one-sector race + * (soak §93 P1 sim 184: rd≈112896 vs delivered). */ + if (xa_stream_active || (mode_reg & 0x68u)) return delay; if (s_warm_route_active) return warm_route_period(); return apply_speed(delay); } @@ -611,15 +620,58 @@ static int seek_complete_delay_cycles(void) { return apply_speed(base); } -/* Pending command */ +/* Pending second-response command. due_cyc is an absolute guest deadline — + * the drive clock never freezes while irq_flag is set (ack only gates + * presentation). Relative remaining is derived for snaps/digests so wire + * format stays peer-stable across hosts. */ typedef struct { uint8_t cmd; int pending; - int delay; int phase; + uint64_t due_cyc; } PendingCmd; static PendingCmd pending; +static int cycles_until_due(uint64_t due_cyc) { + if (due_cyc == 0 || psx_cycle_count >= due_cyc) return 0; + uint64_t rem = due_cyc - psx_cycle_count; + return rem > 0x7FFFFFFFull ? 0x7FFFFFFF : (int)rem; +} + +static int pending_rem_cycles(void) { + if (!pending.pending) return 0; + return cycles_until_due(pending.due_cyc); +} + +static int irq_present_rem_cycles(void) { + return cycles_until_due(cdrom_irq_present_due); +} + +static void pending_arm(uint8_t cmd, int latency_cycles, int phase) { + if (latency_cycles < 0) latency_cycles = 0; + pending.cmd = cmd; + pending.pending = 1; + pending.phase = phase; + pending.due_cyc = psx_cycle_count + (uint64_t)latency_cycles; +} + +static void pending_set_remaining(int rem) { + if (!pending.pending) { + pending.due_cyc = 0; + return; + } + if (rem < 0) rem = 0; + pending.due_cyc = psx_cycle_count + (uint64_t)rem; +} + +static void irq_present_set_remaining(int rem) { + if (rem <= 0) { + cdrom_irq_present_due = 0; + return; + } + cdrom_irq_present_due = psx_cycle_count + (uint64_t)rem; +} + typedef struct { uint8_t cmd; uint8_t params[PARAM_FIFO_SIZE]; @@ -661,7 +713,7 @@ static void trace_cdrom(uint8_t kind, uint32_t addr, uint32_t val, uint8_t width e->sector_size = sector_size; e->pending_cmd = pending.cmd; e->pending_pending = (uint8_t)pending.pending; - e->pending_delay = pending.delay; + e->pending_delay = pending_rem_cycles(); e->reading = (uint8_t)reading; e->read_cmd = read_cmd; e->read_delay = read_delay; @@ -796,8 +848,9 @@ static void set_irq(int type) { cdrom_intc_request_latched = 0; /* Arm the presentation latency: this response must NOT be presented to INTC * synchronously inside the guest store that triggered it (see the - * CDROM_IRQ_PRESENT_DELAY note). It is presented at a later cdrom_advance. */ - cdrom_irq_present_delay = CDROM_IRQ_PRESENT_DELAY; + * CDROM_IRQ_PRESENT_DELAY note). Absolute due — not a slice-relative + * countdown (see pending.due_cyc). */ + cdrom_irq_present_due = psx_cycle_count + (uint64_t)CDROM_IRQ_PRESENT_DELAY; trace_cdrom('I', 0, (uint32_t)type, 0); /* DEQUEUE: CD response/data event fired (aux = CD irq type). */ event_ring_record_aux(EV_DEQ, (uint8_t)SRC_CD_IRQ, (uint32_t)type); @@ -819,7 +872,8 @@ static void present_cdrom_irq(void) { * keeps a freshly-issued response from being presented synchronously inside * the guest's command/ack write — and thus from being lost to that ISR's * own trailing INTC ack. */ - if (cdrom_irq_present_delay > 0) return; + if (cdrom_irq_present_due != 0 && psx_cycle_count < cdrom_irq_present_due) + return; if (cdrom_irq_mask_matches_reason(irq_enable, irq_flag) && !cdrom_intc_request_latched) { psx_irq_raise(2, irq_flag); /* IRQ_CDROM; detail = CD response/IRQ type */ @@ -1403,6 +1457,26 @@ static void start_read_stream(uint8_t cmd) { * content load that happens in OFF but not ON shows up as a missing * SRC_CD_READ enqueue here. */ event_ring_record_aux(EV_ENQ, (uint8_t)SRC_CD_READ, (uint32_t)read_delay); + { + static int s_bisect = -1; + if (s_bisect < 0) { + const char *e = getenv("PSX_RB_CD_BISECT"); + s_bisect = (e && e[0] && e[0] != '0') ? 1 : 0; + } + if (s_bisect) { + fprintf(stderr, + "psxrecomp: rb cd-bisect ReadN-arm cyc=%llu csv=%u " + "rd0=%d mode=%02x div=%d cmd=%02x lba=%d " + "seek=%02d:%02d:%02d irq_f=%02x present_d=%d\n", + (unsigned long long)psx_cycle_count, + (unsigned)interrupts_get_cycles_since_vblank(), + read_delay, (unsigned)mode_reg, g_disc_speed_divisor, + (unsigned)cmd, msf_to_lba(read_min, read_sec, read_sect), + read_min, read_sec, read_sect, + (unsigned)irq_flag, irq_present_rem_cycles()); + fflush(stderr); + } + } } static void stop_read_stream(void) { @@ -1561,6 +1635,82 @@ static int deliver_read_sector_without_irq(void) { return delivered; } +/* §93 P1: cmd timeline under PSX_RB_CD_BISECT (window via netplay). + * ISSUE/DONE include controller stat + response FIFO (GetTN last-track BCD, + * GetTD MSF) so peers can diff TOC replies after matched command issue. */ +static void cd_bisect_cmd_log(const char *kind, uint8_t cmd, + const uint8_t *params, int nparam) +{ + char pbuf[3 * PARAM_FIFO_SIZE + 4]; + char rbuf[3 * RESPONSE_FIFO_SIZE + 4]; + int i, o = 0; + int pend_rem; + int seek_lba; + int rn; + int tracks = 0; + int td_lba = -1; + if (!psx_netplay_cd_bisect_active()) + return; + if (nparam < 0) nparam = 0; + if (nparam > PARAM_FIFO_SIZE) nparam = PARAM_FIFO_SIZE; + pbuf[0] = '-'; + pbuf[1] = '\0'; + if (params && nparam > 0) { + for (i = 0; i < nparam && o + 3 < (int)sizeof(pbuf); i++) { + if (i) pbuf[o++] = ','; + o += sprintf(pbuf + o, "%02x", (unsigned)params[i]); + } + pbuf[o] = '\0'; + } + rn = response_count; + if (rn < 0) rn = 0; + if (rn > RESPONSE_FIFO_SIZE) rn = RESPONSE_FIFO_SIZE; + rbuf[0] = '-'; + rbuf[1] = '\0'; + o = 0; + if (rn > 0) { + for (i = 0; i < rn && o + 3 < (int)sizeof(rbuf); i++) { + if (i) rbuf[o++] = ','; + o += sprintf(rbuf + o, "%02x", (unsigned)response_fifo[i]); + } + rbuf[o] = '\0'; + } + pend_rem = pending_rem_cycles(); + seek_lba = msf_to_lba(seek_min, seek_sec, seek_sect); + /* TOC helpers: binary track count / resolved GetTD LBA (mount-side). */ + if (cmd == 0x13u) + tracks = iso_handle ? iso_track_count(iso_handle) : 0; + else if (cmd == 0x14u && params && nparam >= 1) { + int raw = (int)params[0]; + int track = bcd_to_bin(raw); + if (raw == 0xAA || track == 0) { + uint32_t sectors = iso_handle ? iso_sector_count(iso_handle) : 0u; + td_lba = sectors ? (int)sectors : 0; + } else { + td_lba = iso_handle ? (int)iso_track_start_lba(iso_handle, track) : 0; + } + } + fprintf(stderr, + "psxrecomp: rb cd-cmd %s sim=%u cyc=%llu csv=%u cmd=%02x " + "n=%d params=%s stat=%02x rn=%d resp=%s irq_f=%02x mode=%02x " + "reading=%d pend=%d/%d pend_cmd=%02x seek=%02d:%02d:%02d lba=%d " + "rMSF=%02d:%02d:%02d tracks=%d td_lba=%d resim=%d\n", + kind, + (unsigned)psx_netplay_sim_tick(), + (unsigned long long)psx_cycle_count, + (unsigned)interrupts_get_cycles_since_vblank(), + (unsigned)cmd, nparam, pbuf, + (unsigned)stat_reg, rn, rbuf, + (unsigned)irq_flag, (unsigned)mode_reg, reading ? 1 : 0, + pending.pending ? 1 : 0, pend_rem, + pending.pending ? (unsigned)pending.cmd : 0u, + seek_min, seek_sec, seek_sect, seek_lba, + read_min, read_sec, read_sect, + tracks, td_lba, + psx_netplay_is_resimulating() ? 1 : 0); + fflush(stderr); +} + static void try_execute_queued_command(void) { if (!queued_cmd.pending || irq_flag != 0) return; @@ -1595,6 +1745,7 @@ static void queue_or_exec_command(uint8_t cmd) { param_count = 0; trace_cdrom('Q', 0, cmd, 0); record_command_history('Q', cmd, queued_cmd.params, queued_cmd.param_count); + cd_bisect_cmd_log("QUEUE", cmd, queued_cmd.params, queued_cmd.param_count); } /* Pause (0x09) completion latency — Beetle PS_CDC::Command_Pause. @@ -1713,12 +1864,12 @@ static void exec_command(uint8_t cmd) { } response_push(stat_reg); set_irq(CDIRQ_ACK); - pending.cmd = 0x07; - pending.pending = 1; - pending.delay = apply_speed(30000); /* motor spin-up */ - s_cd_probe_motor_count++; - s_cd_probe_motor_cycles += (uint64_t)pending.delay; - pending.phase = 1; + { + int lat = apply_speed(30000); /* motor spin-up */ + pending_arm(0x07, lat, 1); + s_cd_probe_motor_count++; + s_cd_probe_motor_cycles += (uint64_t)lat; + } break; case 0x08: /* Stop — stop the motor. Two-phase like Pause: INT3 (ACK) now @@ -1738,12 +1889,12 @@ static void exec_command(uint8_t cmd) { stat_reg &= ~(CDSTAT_READ | CDSTAT_PLAY | CDSTAT_SEEK); response_push(stat_reg); set_irq(CDIRQ_ACK); - pending.cmd = 0x08; - pending.pending = 1; - pending.delay = apply_speed(30000); /* motor spin-down */ - s_cd_probe_stop_count++; - s_cd_probe_stop_cycles += (uint64_t)pending.delay; - pending.phase = 1; + { + int lat = apply_speed(30000); /* motor spin-down */ + pending_arm(0x08, lat, 1); + s_cd_probe_stop_count++; + s_cd_probe_stop_cycles += (uint64_t)lat; + } break; case 0x09: { /* Pause */ @@ -1755,12 +1906,9 @@ static void exec_command(uint8_t cmd) { stat_reg &= ~(CDSTAT_READ | CDSTAT_PLAY); response_push(stat_reg); set_irq(CDIRQ_ACK); - pending.cmd = 0x09; - pending.pending = 1; - pending.delay = complete_delay; + pending_arm(0x09, complete_delay, 1); s_cd_probe_pause_count++; s_cd_probe_pause_cycles += (uint64_t)complete_delay; - pending.phase = 1; break; } @@ -1772,8 +1920,6 @@ static void exec_command(uint8_t cmd) { stat_reg = has_disc() ? CDSTAT_MOTOR : CDSTAT_SHELL; response_push(stat_reg); set_irq(CDIRQ_ACK); - pending.cmd = 0x0A; - pending.pending = 1; /* The old 1136000-cycle (34 ms) value was borrowed from Beetle's * Command_RESET — a different command. Init(0x0A) on an already- * spinning drive completes far faster on real hardware, and OpenBIOS @@ -1786,8 +1932,7 @@ static void exec_command(uint8_t cmd) { * two, not a measured constant) sits inside OpenBIOS's window and * within real-hardware quick-init behavior; cross-check against * Beetle's exact PS_CDC::Command_Init figure when refining. */ - pending.delay = 131072; - pending.phase = 1; + pending_arm(0x0A, 131072, 1); break; case 0x0B: /* Mute */ @@ -1975,12 +2120,12 @@ static void exec_command(uint8_t cmd) { stat_reg |= CDSTAT_SEEK; response_push(stat_reg); set_irq(CDIRQ_ACK); - pending.cmd = cmd; /* 0x15 or 0x16 — completed in process_pending */ - pending.pending = 1; - pending.delay = seek_complete_delay_cycles(); - s_cd_probe_seek_count++; - s_cd_probe_seek_cycles += (uint64_t)pending.delay; - pending.phase = 1; + { + int lat = seek_complete_delay_cycles(); + pending_arm(cmd, lat, 1); /* 0x15/0x16 — completed in process_pending */ + s_cd_probe_seek_count++; + s_cd_probe_seek_cycles += (uint64_t)lat; + } break; case 0x1A: /* GetID */ @@ -1989,12 +2134,9 @@ static void exec_command(uint8_t cmd) { * or INT5 (ERROR) if no disc / lid open. */ response_push(stat_reg); set_irq(CDIRQ_ACK); - pending.cmd = 0x1A; - pending.pending = 1; /* Beetle PS_CDC::Command_ID: second response after 33868 cycles. * Unscaled — same authentic-latency class as Pause/Init/ReadTOC. */ - pending.delay = 33868; - pending.phase = 1; + pending_arm(0x1A, 33868, 1); break; case 0x1B: /* ReadS */ @@ -2011,13 +2153,10 @@ static void exec_command(uint8_t cmd) { case 0x1E: /* ReadTOC */ response_push(stat_reg); set_irq(CDIRQ_ACK); - pending.cmd = 0x1E; - pending.pending = 1; /* Beetle PS_CDC::Command_ReadTOC: ~30M cycles (a near-second TOC * re-scan; Beetle adds a seek term on top — we keep the dominant * constant). Unscaled — authentic-latency class. */ - pending.delay = 30000000; - pending.phase = 1; + pending_arm(0x1E, 30000000, 1); break; case 0x19: /* Test */ @@ -2054,24 +2193,27 @@ static void exec_command(uint8_t cmd) { * Delayed responses (pending) fire from process_pending(). */ fire_cdrom_irq(); record_command_history('C', cmd, cmd_params, cmd_param_count); + cd_bisect_cmd_log("ISSUE", cmd, cmd_params, cmd_param_count); } static void process_pending(uint32_t cycles) { + uint8_t done_cmd; + (void)cycles; if (!pending.pending) return; - if (cycles == 0) return; - /* Serialized responses: a delayed completion (e.g. SeekL / Init COMPLETE) - * cannot be presented while a prior response IRQ is still unacked. Freeze - * pending.delay while held so it does not accrue fake time debt and - * underflow once the guest finally acks (mirrors process_read_stream). */ + /* Absolute drive deadline: due_cyc advances with guest time regardless of + * slice quantum or irq_flag. Ack only gates *presentation* — do not push + * due_cyc forward while the response FIFO is busy (old relative freeze + * under/over-counted mid-slice acks and forked Pause→Seek→ReadN). */ + if (psx_cycle_count < pending.due_cyc) return; if (irq_flag != 0) return; - pending.delay -= (int)cycles; - if (pending.delay > 0) return; + done_cmd = pending.cmd; pending.pending = 0; + pending.due_cyc = 0; response_clear(); - switch (pending.cmd) { + switch (done_cmd) { case 0x06: /* ReadN data ready */ case 0x1B: /* ReadS data ready */ if (reading) { @@ -2150,6 +2292,7 @@ static void process_pending(uint32_t cycles) { default: break; } + cd_bisect_cmd_log("DONE", done_cmd, NULL, 0); } /* Read-stream hold accounting (always-on). Before 2026-07-10 the stream @@ -2273,7 +2416,7 @@ void cdrom_init(const char* cue_path) { cdrom_intc_request_latched = 0; cdrom_irq_generation = 0; cdrom_intc_latched_generation = 0; - cdrom_irq_present_delay = 0; + cdrom_irq_present_due = 0; param_count = 0; response_read = 0; response_count = 0; @@ -2300,9 +2443,17 @@ void cdrom_init(const char* cue_path) { cd_muted = 0; xa_reset_decode(); spu_cd_audio_reset(); - pending.pending = 0; + /* Rematch: pending.cmd/delay/phase and read MSF used to survive with + * pending.pending=0 / reading=0 and fork the CD digest at dig0. */ + memset(&pending, 0, sizeof(pending)); memset(&queued_cmd, 0, sizeof(queued_cmd)); + cdrom_clear_pending_dataready(); + read_min = read_sec = read_sect = 0; + reading = 0; + read_cmd = 0; + read_delay = 0; seek_min = seek_sec = seek_sect = 0; + s_setloc_lba = -1; setloc_seek_far = 0; s_warm_routes_count = 0; s_warm_route_configured = 0; @@ -2454,9 +2605,10 @@ void cdrom_write(uint32_t addr, uint32_t value) { param_count = 0; } /* A fully-acked INT presents any pended data-ready first - * (Beetle CheckAIP on IRQ-register clear); a queued command - * then waits behind that INT1's own ack cycle. */ + * (Beetle CheckAIP on IRQ-register clear); then a second-response + * that is already past due_cyc; a queued command waits behind. */ present_pending_dataready(); + process_pending(0); try_execute_queued_command(); } break; @@ -2475,14 +2627,23 @@ void cdrom_write(uint32_t addr, uint32_t value) { uint32_t cdrom_cycles_to_irq(uint32_t i_mask) { if (!(i_mask & (1u << 2))) return 0xFFFFFFFFu; /* IRQ_CDROM masked */ uint32_t best = 0xFFFFFFFFu; - /* Armed response awaiting presentation (will raise bit2 when delay hits 0). */ + /* Armed response awaiting presentation (raises bit2 at present_due). */ if (cdrom_irq_mask_matches_reason(irq_enable, irq_flag)) { - uint32_t d = cdrom_irq_present_delay > 0 ? (uint32_t)cdrom_irq_present_delay : 0u; + int rem = irq_present_rem_cycles(); + uint32_t d = rem > 0 ? (uint32_t)rem : 0u; if (d < best) best = d; } - /* Pending second response: fires set_irq in pending.delay cycles. */ - if (pending.pending && pending.delay > 0 && (uint32_t)pending.delay < best) - best = (uint32_t)pending.delay; + /* Pending second response: slice to due_cyc while the drive clock runs. + * Once due, presentation waits on irq_flag clear (CPU ack) — do not + * advertise a deliverable CD IRQ until the FIFO is free. */ + if (pending.pending) { + if (psx_cycle_count < pending.due_cyc) { + uint32_t d = (uint32_t)cycles_until_due(pending.due_cyc); + if (d < best) best = d; + } else if (irq_flag == 0 && 0u < best) { + best = 0u; + } + } /* Active sector read: next data-ready in read_delay cycles. */ if (reading && !warm_route_consumer_blocked() && read_delay > 0 && (uint32_t)read_delay < best) @@ -2491,21 +2652,13 @@ uint32_t cdrom_cycles_to_irq(uint32_t i_mask) { } void cdrom_advance(uint32_t cycles) { - /* Age the response presentation latency. Once it elapses, the following - * refresh_cdrom_irq_line() presents the held response to INTC — at this - * cdrom_advance boundary, not synchronously inside the guest store that - * armed it. - * - * Note: do NOT freeze CD during rollback Replay. MotK FMV skip resim + /* Presentation hold is absolute (cdrom_irq_present_due); refresh checks + * it. Note: do NOT freeze CD during rollback Replay. MotK FMV skip resim * resumes into VLC/sector waits; frozen IRQs → no finish_frame hang. * Menu CD asymmetry is contained by no-invent during media + core POST. */ - if (cdrom_irq_present_delay > 0) { - cdrom_irq_present_delay -= (int)cycles; - if (cdrom_irq_present_delay < 0) cdrom_irq_present_delay = 0; - } refresh_cdrom_irq_line(); - try_execute_queued_command(); process_pending(cycles); + try_execute_queued_command(); process_read_stream(cycles); process_cdda_stream(cycles); refresh_cdrom_irq_line(); @@ -2594,13 +2747,15 @@ void cdrom_debug_snapshot(CDROMDebugState* out) { out->filter_channel = filter_channel; out->muted = cd_muted; out->read_delay = read_delay; + out->irq_present_delay = irq_present_rem_cycles(); + out->speed_divisor = g_disc_speed_divisor; out->read_hold_cycles = s_read_hold_cycles; out->read_hold_events = s_read_hold_events; out->int1_pended = s_int1_pended; out->int1_lost = s_int1_lost; out->int1_pending_now = pending_dataready; out->pending_pending = pending.pending; - out->pending_delay = pending.delay; + out->pending_delay = pending_rem_cycles(); out->pending_phase = pending.phase; out->i_stat = i_stat; out->last_sector_lba = last_sector_lba; @@ -2694,13 +2849,16 @@ int cdrom_data_read_active(void) { static int s_savestate_cd_boost_vblanks = 0; static void cdrom_apply_savestate_delay_caps(int log_clamps) { - if (pending.pending && pending.delay > CDROM_SAVESTATE_DELAY_CAP) { - if (log_clamps) - fprintf(stderr, - "cdrom: savestate clamped pending cmd=0x%02X delay %d -> %d\n", - (unsigned)pending.cmd, pending.delay, - CDROM_SAVESTATE_DELAY_CAP); - pending.delay = CDROM_SAVESTATE_DELAY_CAP; + if (pending.pending && psx_cycle_count < pending.due_cyc) { + int rem = pending_rem_cycles(); + if (rem > CDROM_SAVESTATE_DELAY_CAP) { + if (log_clamps) + fprintf(stderr, + "cdrom: savestate clamped pending cmd=0x%02X delay %d -> %d\n", + (unsigned)pending.cmd, rem, + CDROM_SAVESTATE_DELAY_CAP); + pending_set_remaining(CDROM_SAVESTATE_DELAY_CAP); + } } if (reading && !xa_stream_active && read_delay > CDROM_SAVESTATE_DELAY_CAP) { @@ -2711,8 +2869,8 @@ static void cdrom_apply_savestate_delay_caps(int log_clamps) { read_delay = CDROM_SAVESTATE_DELAY_CAP; s_cd_timing_next_due = psx_cycle_count + (uint64_t)read_delay; } - if (cdrom_irq_present_delay > CDROM_SAVESTATE_DELAY_CAP) - cdrom_irq_present_delay = CDROM_SAVESTATE_DELAY_CAP; + if (irq_present_rem_cycles() > CDROM_SAVESTATE_DELAY_CAP) + irq_present_set_remaining(CDROM_SAVESTATE_DELAY_CAP); } void cdrom_accelerate_after_savestate(void) { @@ -2734,7 +2892,7 @@ void cdrom_savestate_boost_vblank(void) { int cdrom_savestate_cd_wait_active(void) { if (s_savestate_cd_boost_vblanks <= 0) return 0; - if (pending.pending && pending.delay > 0) return 1; + if (pending.pending && pending_rem_cycles() > 0) return 1; if (reading && !xa_stream_active) return 1; return 0; } @@ -2761,7 +2919,7 @@ static int cdrom_snap_emit(PstW *w) { #define WB(a) do { if (!pst_w_bytes(w, (a), sizeof(a))) return 0; } while (0) W8(index_reg); W8(stat_reg); W8(request_reg); W8(irq_enable); W8(irq_flag); WI(cdrom_intc_request_latched); WU(cdrom_irq_generation); - WU(cdrom_intc_latched_generation); WI(cdrom_irq_present_delay); + WU(cdrom_intc_latched_generation); WI(irq_present_rem_cycles()); WB(param_fifo); WI(param_count); WB(response_fifo); WI(response_read); WI(response_count); WB(sector_buffer); WI(sector_read_pos); WI(sector_available); WI(sector_size); @@ -2781,8 +2939,9 @@ static int cdrom_snap_emit(PstW *w) { return 0; W8(xa_stream_file); W8(xa_stream_channel); W8(xa_stream_coding); WI(xa_stream_active); WI(g_disc_speed_divisor); WI(g_game_divisor); WI(g_instant_max_per_frame); - /* PendingCmd / QueuedCmd — no host padding on the wire */ - W8(pending.cmd); WI(pending.pending); WI(pending.delay); WI(pending.phase); + /* PendingCmd / QueuedCmd — relative remaining on the wire (absolute due + * rebuilt on load from psx_cycle_count). */ + W8(pending.cmd); WI(pending.pending); WI(pending_rem_cycles()); WI(pending.phase); W8(queued_cmd.cmd); WB(queued_cmd.params); WI(queued_cmd.param_count); WI(queued_cmd.pending); W8(pending_dataready); W8(pending_dataready_stat); #undef W8 @@ -2798,6 +2957,8 @@ static int cdrom_snap_parse(PstR *r) { int32_t i; uint32_t u; uint64_t u64; + int present_rem = 0; + int pending_rem = 0; #define R8(f) do { if (!pst_r_u8(r, &b)) return 0; (f) = b; } while (0) #define RI(f) do { if (!pst_r_i32(r, &i)) return 0; (f) = (int)i; } while (0) #define RU(f) do { if (!pst_r_u32(r, &u)) return 0; (f) = u; } while (0) @@ -2805,7 +2966,7 @@ static int cdrom_snap_parse(PstR *r) { #define RB(a) do { if (!pst_r_bytes(r, (a), sizeof(a))) return 0; } while (0) R8(index_reg); R8(stat_reg); R8(request_reg); R8(irq_enable); R8(irq_flag); RI(cdrom_intc_request_latched); RU(cdrom_irq_generation); - RU(cdrom_intc_latched_generation); RI(cdrom_irq_present_delay); + RU(cdrom_intc_latched_generation); RI(present_rem); RB(param_fifo); RI(param_count); RB(response_fifo); RI(response_read); RI(response_count); RB(sector_buffer); RI(sector_read_pos); RI(sector_available); RI(sector_size); @@ -2823,7 +2984,7 @@ static int cdrom_snap_parse(PstR *r) { return 0; R8(xa_stream_file); R8(xa_stream_channel); R8(xa_stream_coding); RI(xa_stream_active); RI(g_disc_speed_divisor); RI(g_game_divisor); RI(g_instant_max_per_frame); - R8(pending.cmd); RI(pending.pending); RI(pending.delay); RI(pending.phase); + R8(pending.cmd); RI(pending.pending); RI(pending_rem); RI(pending.phase); R8(queued_cmd.cmd); RB(queued_cmd.params); RI(queued_cmd.param_count); RI(queued_cmd.pending); R8(pending_dataready); R8(pending_dataready_stat); #undef R8 @@ -2831,6 +2992,11 @@ static int cdrom_snap_parse(PstR *r) { #undef RU #undef R64 #undef RB + irq_present_set_remaining(present_rem); + if (pending.pending) + pending_set_remaining(pending_rem); + else + pending.due_cyc = 0; return 1; } diff --git a/runtime/src/disc_identity.cpp b/runtime/src/disc_identity.cpp index f5c78ac7..8c4d8cf0 100644 --- a/runtime/src/disc_identity.cpp +++ b/runtime/src/disc_identity.cpp @@ -8,9 +8,12 @@ #include "crc32.h" #include "disc_path.h" +#include "iso_reader.h" +#include "psx_sha256.h" #include #include +#include #include #include #include @@ -112,12 +115,103 @@ uint32_t crc32_stream(std::ifstream& f) { return crc ^ 0xFFFFFFFFu; } +std::string hex_sha256(const std::string& canonical) { + uint8_t dig[32]; + psx_sha256_compute(reinterpret_cast(canonical.data()), + canonical.size(), dig); + char hex[65]; + for (int i = 0; i < 32; i++) + std::snprintf(hex + i * 2, 3, "%02x", dig[i]); + hex[64] = '\0'; + return std::string(hex); +} + +void fill_toc_from_mount(DiscIdentity& v, const DiscPathResolution& resolved) { + v.from_cue = resolved.from_cue; + v.cue_fallback = resolved.cue_fallback; + v.upgraded_to_cue = resolved.upgraded_to_cue; + + PS1::ISOReader reader; + if (!reader.Open(resolved.mount.string())) { + v.toc_opened = false; + if (v.detail.empty()) + v.detail = "Could not mount the disc image for TOC inspection " + "(missing track FILE or unreadable cue)."; + return; + } + v.toc_opened = true; + v.track_count = reader.TrackCount(); + if (v.track_count < 1) v.track_count = 1; + v.leadout_lba = reader.GetSectorCount(); + + // Canonical TOC string — peers with the same mount geometry share disc_fp. + // Version prefix lets us evolve the format without silent collisions. + std::ostringstream toc; + toc << "psxrecomp-toc-v1\n"; + toc << "tracks=" << v.track_count << "\n"; + toc << "leadout=" << v.leadout_lba << "\n"; + for (int t = 1; t <= v.track_count; t++) { + toc << "t=" << t + << ",a=" << (reader.TrackIsAudio(t) ? 1 : 0) + << ",start=" << reader.TrackStartLBA(t) + << ",pregap=" << reader.TrackPregapLBA(t) << "\n"; + } + v.disc_fp = hex_sha256(toc.str()); +} + } // namespace +void apply_netplay_disc_expect(DiscIdentity& id, const NetplayDiscExpect& expect) { + id.netplay_ok = true; + id.netplay_detail.clear(); + + if (!id.toc_opened) { + id.netplay_ok = false; + id.netplay_detail = "Disc TOC could not be opened for netplay."; + return; + } + if (expect.require_cue) { + if (id.cue_fallback || !id.from_cue) { + id.netplay_ok = false; + id.netplay_detail = + "Netplay requires the full .cue (with all track files), " + "not a bare .bin / incomplete dump."; + return; + } + } + if (expect.required_tracks > 0 && id.track_count != expect.required_tracks) { + id.netplay_ok = false; + char buf[160]; + std::snprintf(buf, sizeof(buf), + "Disc has %d track(s); netplay requires %d " + "(use the supported multi-track .cue dump).", + id.track_count, expect.required_tracks); + id.netplay_detail = buf; + return; + } + if (expect.has_required_leadout && id.leadout_lba != expect.required_leadout_lba) { + id.netplay_ok = false; + char buf[160]; + std::snprintf(buf, sizeof(buf), + "Disc lead-out LBA %u != required %u.", + (unsigned)id.leadout_lba, + (unsigned)expect.required_leadout_lba); + id.netplay_detail = buf; + return; + } + if (!expect.required_disc_fp.empty() && id.disc_fp != expect.required_disc_fp) { + id.netplay_ok = false; + id.netplay_detail = "Disc TOC fingerprint does not match this build's " + "required dump."; + return; + } +} + DiscIdentity identify_disc(const fs::path& path, const std::string& expected_serial, uint32_t expected_crc, bool has_expected_crc, - bool compute_crc) { + bool compute_crc, + const NetplayDiscExpect* netplay_expect) { DiscIdentity v; v.expected_serial_given = !expected_serial.empty(); v.expected_crc_given = has_expected_crc; @@ -126,12 +220,18 @@ DiscIdentity identify_disc(const fs::path& path, // the data track no matter which member of the dump the user handed us — // the .cue and its .bin must produce the same verdict, serial and CRC32. const DiscPathResolution resolved = resolve_disc_path(path); + fill_toc_from_mount(v, resolved); + const fs::path data_path = resolved.data; std::ifstream f(data_path, std::ios::binary | std::ios::ate); if (!f.is_open()) { v.detail = resolved.note.empty() ? "Could not open the disc image or its CUE-referenced BIN file." : resolved.note; + if (netplay_expect) + apply_netplay_disc_expect(v, *netplay_expect); + else if (!v.toc_opened) + v.netplay_ok = false; return v; } v.opened = true; @@ -151,6 +251,8 @@ DiscIdentity identify_disc(const fs::path& path, v.detail = "No ISO9660 CD001 header was found at the standard PS1 disc locations."; // Region may still be inferable from the expected serial for display. v.region = region_from_serial(expected_serial); + if (netplay_expect) + apply_netplay_disc_expect(v, *netplay_expect); return v; } @@ -200,6 +302,9 @@ DiscIdentity identify_disc(const fs::path& path, if (has_expected_crc) v.crc_matches = (v.crc == expected_crc); } + if (netplay_expect) + apply_netplay_disc_expect(v, *netplay_expect); + return v; } diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index a2edf08b..50ebfc3f 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -2263,12 +2263,27 @@ static void gpu_reset_state(int clear_vram) { } gr_init(vram); - /* Reset GP0 state machine */ + /* Reset GP0 state machine (+ leftover cmd/xfer crumbs that survive in + * gpu_snapshot and fork av digests on rematch). */ gp0_state = GP0_IDLE; gp0_words_collected = 0; gp0_words_needed = 0; + memset(gp0_cmd_buf, 0, sizeof(gp0_cmd_buf)); + gp0_next_source_addr = 0xFFFFFFFFu; + gp0_cmd_source_addr = 0xFFFFFFFFu; + polyline_color = 0; + polyline_prev_x = polyline_prev_y = 0; + polyline_prev_c = 0; + polyline_semi_trans = 0; + polyline_has_prev = 0; + vram_write_x = vram_write_y = 0; + vram_write_w = vram_write_h = 0; + vram_write_col = vram_write_row = 0; vram_write_remaining = 0; vram_read_active = 0; + vram_read_x = vram_read_y = 0; + vram_read_w = vram_read_h = 0; + vram_read_col = vram_read_row = 0; /* Reset all state to power-on defaults */ texpage_x = 0; diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c9c4e7c4..16a058a7 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -3996,8 +3996,13 @@ static void netplay_barrier_admit(int override) { 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()) + * open a ~250ms present gap. + * §100: MEDIA-KF probe/xfer (~3.7 MB) also parks sim in admit — hold + * last FMV frame so intro does not freeze for the transfer window. */ + if (psx_netplay_rb_tip_holding() || + psx_netplay_rb_media_kf_busy() || + (psx_netplay_rb_fmv_media_active() && psx_netplay_rb_active() && + !psx_netplay_rb_is_resimulating())) 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) @@ -4925,6 +4930,14 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { uint32_t sim = psx_netplay_sim_tick(); int beyond_shown = sim != 0u && sim != 0xffffffffu && sim > s_netplay_present_sim_watermark; + /* §99: FMV media Replay is CD/MDEC-bound — skip dual-raster present + * entirely (hold-last). Live FMV present + catchup gate still apply + * once media ends; near-tip media loads are short enough that a frozen + * frame for a few resim ticks is preferable to 1–3s GL hitch. */ + if (psx_netplay_rb_fmv_media_active()) { + netplay_hold_last_present_tick(); + return ep; + } if (!beyond_shown && !netplay_replay_catchup_should_live_present(rem)) { netplay_hold_last_present_tick(); return ep; @@ -5349,6 +5362,12 @@ static void sdl_vblank_present(void) { latency_ring_mark(LAT_PACED); } +/* game.toml [netplay] + last TOC fingerprint — shared by launcher verify and + * the pre-psx_netplay_start gate (works with or without RECOMP_LAUNCHER). */ +static PSXRecompV4::NetplayDiscExpect g_netplay_disc_expect{}; +static std::string g_session_disc_fp; +static bool g_session_netplay_disc_ok = false; + #if defined(RECOMP_LAUNCHER) // Host verification/inspection callbacks for the shared recomp-ui launcher. // The launcher re-runs these on every disc/memory-card change so the "Disc @@ -5526,18 +5545,30 @@ namespace { int ae_disc_verify(const char* disc_path, RecompLauncherCDiscVerify* out) { if (!disc_path || !disc_path[0] || !out) return 0; + std::memset(out, 0, sizeof(*out)); PSXRecompV4::DiscIdentity id = PSXRecompV4::identify_disc( disc_path, g_lnch_expected_serial, g_lnch_expected_crc, - g_lnch_has_crc, /*compute_crc*/ g_lnch_has_crc); + g_lnch_has_crc, /*compute_crc*/ g_lnch_has_crc, + &g_netplay_disc_expect); const std::string& serial = !id.detected_serial.empty() ? id.detected_serial : g_lnch_expected_serial; std::snprintf(out->serial, sizeof(out->serial), "%s", serial.c_str()); std::snprintf(out->region, sizeof(out->region), "%s", id.region.c_str()); out->iso_ok = id.has_header ? 1 : 0; - // Verdict shown by the launcher. + out->track_count = id.track_count; + out->netplay_ok = id.netplay_ok ? 1 : 0; + std::snprintf(out->disc_fp, sizeof(out->disc_fp), "%s", id.disc_fp.c_str()); + std::snprintf(out->netplay_detail, sizeof(out->netplay_detail), "%s", + id.netplay_detail.c_str()); + g_session_disc_fp = id.disc_fp; + g_session_netplay_disc_ok = id.netplay_ok && !id.disc_fp.empty(); + psx_lobby_set_disc_fp(id.disc_fp.c_str()); + // Verdict shown by the launcher. TOC / [netplay] failures are warn so + // offline Play still works; online is gated by netplay_ok + disc_fp. if (!id.opened || !id.has_header) out->verdict = 3; // bad else if (id.expected_serial_given && !id.serial_matches) out->verdict = 3; // wrong disc else if (id.expected_crc_given && id.crc_computed && !id.crc_matches) out->verdict = 2; // warn + else if (!id.netplay_ok) out->verdict = 2; // TOC/cue else out->verdict = 1; // ok return 1; } @@ -5559,10 +5590,10 @@ namespace { RecompLauncherCNetplayLaunch g_lnch_pending_direct_launch{}; int g_lnch_lobby_input_delay = 2; int g_lnch_lobby_input_prediction = 4; - /* Online default: lobby UDP SFU star (server always opens relay on start). */ - int g_lnch_force_input_relay = 1; - /* Default on: CGNAT-safe relay-only ICE (BattleShip-style online path). */ - int g_lnch_force_turn = 1; + /* Offline default off — waiting-room ICE probes host/srflx so the lobby + * server can choose ice_p2p vs SFU. Force-relay / Force TURN still override. */ + int g_lnch_force_input_relay = 0; + int g_lnch_force_turn = 0; /* Lobby default on; host “Disable Rollback” clears this → delay_sync. */ int g_lnch_rollback = 1; int g_lnch_host_max_slots = 2; @@ -6843,6 +6874,7 @@ namespace { int ae_np_connect(void*) { psx_lobby_set_game_identity(g_lnch_netplay_game_name.c_str(), PSX_GAME_VERSION); + psx_lobby_set_disc_fp(g_session_disc_fp.c_str()); psx_lobby_set_max_slots(g_lnch_game_players); return psx_lobby_connect(ae_np_default_url(nullptr)); } @@ -8012,11 +8044,26 @@ namespace { if (seated > out->max_slots) seated = out->max_slots; out->player_count = seated; } - /* Online WS lobbies always use lobby UDP SFU; LAN/direct stay local. */ + /* SFU when launch rewrote caps.force_input_relay (relay_endpoint); + * ice_p2p / LAN leave it clear. Also detect equal host/guest + * endpoints (server SFU advertise) when the caps bit was omitted — + * session 151 Desktop guest dialed the relay hostname without + * force_input_relay and died in resolve_use_ice (-4). */ out->force_input_relay = - (g_lnch_hosting_lan || g_lnch_joined_lan) ? 0 : 1; - out->force_turn = - (caps && caps->valid && caps->force_turn) ? 1 : 0; + (g_lnch_hosting_lan || g_lnch_joined_lan) + ? 0 + : (caps->force_input_relay ? 1 : 0); + if (!out->force_input_relay && !g_lnch_hosting_lan && + !g_lnch_joined_lan && ji->host_endpoint[0] && + ji->guest_endpoint[0] && + std::strcmp(ji->host_endpoint, ji->guest_endpoint) == 0) { + out->force_input_relay = 1; + std::fprintf(stderr, + "psxrecomp: launch force_input_relay inferred " + "(host_endpoint==guest_endpoint=%s)\n", + ji->host_endpoint); + } + out->force_turn = caps->force_turn ? 1 : 0; out->rollback = (caps && caps->valid && caps->rollback) ? 1 : 0; return 1; @@ -8311,6 +8358,13 @@ std::string player_device[PSX_MAX_PLAYERS]; g_offline_pad_count = PSX_MAX_PLAYERS; game_has_disc_crc = gc.has_disc_crc; game_disc_crc = gc.disc_crc; + g_netplay_disc_expect.require_cue = gc.netplay_require_cue; + g_netplay_disc_expect.required_tracks = gc.netplay_required_tracks; + g_netplay_disc_expect.has_required_leadout = + gc.has_netplay_required_leadout; + g_netplay_disc_expect.required_leadout_lba = + gc.netplay_required_leadout_lba; + g_netplay_disc_expect.required_disc_fp = gc.netplay_required_disc_fp; if (!gc.discs.empty()) resolved_disc = gc.discs.front(); if (gc.runtime.has_memcard_dir) memcard_dir = gc.runtime.memcard_dir; if (gc.runtime.has_window_title) window_title = gc.runtime.window_title; @@ -10055,6 +10109,43 @@ session_reboot: * Must start after SDL so local pad capture has devices; before the guest * fiber so the first vblanks already pump HELLO/START. */ if (net_cfg.enabled) { + /* Refuse mismatched / incomplete mounts before opening transport. + * Launcher already gates create/join; this covers CLI --netplay and + * any path that skipped the UI verify. */ + { + const std::filesystem::path disc_check = resolved_disc; + if (!disc_check.empty()) { +#if defined(RECOMP_LAUNCHER) + const std::string& expect_serial = g_lnch_expected_serial; + const uint32_t expect_crc = g_lnch_expected_crc; + const bool has_crc = g_lnch_has_crc; +#else + const std::string expect_serial = game_id; + const uint32_t expect_crc = game_disc_crc; + const bool has_crc = game_has_disc_crc; +#endif + PSXRecompV4::DiscIdentity nid = PSXRecompV4::identify_disc( + disc_check, expect_serial, expect_crc, has_crc, + /*compute_crc*/ false, &g_netplay_disc_expect); + g_session_disc_fp = nid.disc_fp; + g_session_netplay_disc_ok = nid.netplay_ok && !nid.disc_fp.empty(); + psx_lobby_set_disc_fp(nid.disc_fp.c_str()); + if (!g_session_netplay_disc_ok) { + std::fprintf(stderr, + "psxrecomp: netplay refused — disc mount not valid for " + "online (%s)\n", + nid.netplay_detail.empty() + ? "TOC fingerprint missing or policy failed" + : nid.netplay_detail.c_str()); + return 1; + } + } else if (!g_session_netplay_disc_ok || g_session_disc_fp.empty()) { + std::fprintf(stderr, + "psxrecomp: netplay refused — no verified disc TOC " + "fingerprint (mount the supported .cue dump)\n"); + return 1; + } + } /* Transport role and gameplay slot are independent. An empty peer * means this process listens for the first peer, even when the lobby * owner was reordered into gameplay slot 1 (Player 2). */ @@ -10182,8 +10273,8 @@ session_reboot: } } } - /* Guest netplay: sandbox .pst/.mcd under saves/netplay/ after personal - * roots are known (must follow savestate_configure + memcard_init). */ + /* Netplay: refresh RB bios/entry after savestate_configure (start() ran + * earlier with zeros); guest also sandboxes .pst/.mcd under saves/netplay/. */ psx_netplay_bind_guest_saves(); /* Let memory subsystem see SR for cache-isolation checks. */ diff --git a/runtime/src/mdec.c b/runtime/src/mdec.c index c303e056..6f8fc1ff 100644 --- a/runtime/src/mdec.c +++ b/runtime/src/mdec.c @@ -782,6 +782,14 @@ int mdec_recently_active(uint32_t within_frames) { return (psx_cycle_count - mdec_last_color_decode_cycle) <= window; } +uint64_t mdec_color_age_cycles(void) { + if (mdec_last_color_decode_cycle == (uint64_t)0 - 1000u) + return (uint64_t)0 - 1u; + if (psx_cycle_count < mdec_last_color_decode_cycle) + return (uint64_t)0 - 1u; + return psx_cycle_count - mdec_last_color_decode_cycle; +} + void mdec_init(void) { memset(&mdec, 0, sizeof(mdec)); memset(mdec_trace, 0, sizeof(mdec_trace)); diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 6de674d0..9ceaefa8 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -609,6 +609,20 @@ void dirty_ram_set_bitmap_words(const uint32_t* words, uint32_t count) { static uint32_t overlay_watch_bitmap[DIRTY_RAM_BITMAP_WORDS]; static uint32_t overlay_page_gen[DIRTY_RAM_PAGE_COUNT]; +void dirty_ram_reset_for_boot(void) { + memset(dirty_ram_bitmap, 0, sizeof(dirty_ram_bitmap)); + memset(text_modified_bitmap, 0, sizeof(text_modified_bitmap)); + memset(text_diverged_bitmap, 0, sizeof(text_diverged_bitmap)); + g_text_diverged_pages = 0; + memset(overlay_watch_bitmap, 0, sizeof(overlay_watch_bitmap)); + memset(overlay_page_gen, 0, sizeof(overlay_page_gen)); + memset(g_dirty_ram_exec_page_bitmap, 0, sizeof(g_dirty_ram_exec_page_bitmap)); + memset(g_dirty_ram_exec_pc_bitmap, 0, sizeof(g_dirty_ram_exec_pc_bitmap)); + memset(g_dirty_ram_dispatch_pc_bitmap, 0, + sizeof(g_dirty_ram_dispatch_pc_bitmap)); + g_dirty_ram_code_gen++; +} + void overlay_watch_set_range(uint32_t phys, uint32_t len) { if (len == 0 || phys >= RAM_SIZE) return; uint32_t end = phys + len - 1u; @@ -874,6 +888,8 @@ void memory_init(const char* bios_path) { ram_size_reg = 0; i_stat = 0; i_mask = 0; + /* Host dirty/text/overlay bitmaps survive memset(ram) and fork dig0. */ + dirty_ram_reset_for_boot(); FILE* f = fopen(bios_path, "rb"); if (!f) { diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index fad4fef3..9e5c431b 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -25,6 +25,8 @@ int psx_lobby_list_count(void) { return 0; } int psx_lobby_list_get(int index, PsxLobbyRow *out) { (void)index; (void)out; return 0; } void psx_lobby_set_game_identity(const char *a, const char *b) { (void)a; (void)b; } const char *psx_lobby_game_version(void) { return PSX_GAME_VERSION; } +void psx_lobby_set_disc_fp(const char *disc_fp) { (void)disc_fp; } +const char *psx_lobby_disc_fp(void) { return ""; } void psx_lobby_set_max_slots(int max_slots) { (void)max_slots; } int psx_lobby_create(const char *a, const char *b, const char *c, const char *d, const char *e, const PsxLobbyMatchCaps *f) @@ -147,6 +149,7 @@ typedef struct { char my_bind[PSX_LOBBY_ENDPOINT_LEN]; char filter_game_name[PSX_LOBBY_NAME_LEN]; char filter_game_version[PSX_LOBBY_VERSION_LEN]; + char disc_fp[65]; /* lowercase hex SHA-256 TOC fingerprint; "" = unset */ PsxLobbyJoinInfo join; PsxLobbyMember members[PSX_LOBBY_MAX_MEMBERS]; int member_count; @@ -223,6 +226,9 @@ static RNetIceRttProbe *g_ice_rtt; static char g_ice_rtt_peer_id[PSX_LOBBY_ID_LEN]; static int g_ice_rtt_force_relay; static uint64_t g_ice_rtt_last_log_ms; +/* Last path_report sent to the lobby server (direct|relay|fail). */ +static char g_ice_path_reported[16]; +static uint64_t g_ice_path_report_ms; /* One-shot list latency: burst-ping LAN + public candidates after lobby_list. */ #define PSX_LOBBY_MAX_PROBE_PEND (PSX_LOBBY_MAX_LIST * (PSX_LOBBY_MAX_LAN_EPS + 1)) @@ -1840,12 +1846,17 @@ static void handle_server_json(const char *json) if (!g_lc.match_caps.valid) g_lc.match_caps.valid = 1; g_lc.match_caps.force_input_relay = 1; + } else if (g_lc.match_caps.valid) { + /* ice_p2p / non-SFU launch — do not keep a stale force_input_relay. */ + g_lc.match_caps.force_input_relay = 0; } fill_peer_bind_from_join(); parse_slots_array(json); /* Guest must know host:port (or relay). Host may leave peer empty for - * accept-first / host-as-relay. Server relay: every peer dials relay. */ + * accept-first / host-as-relay. Server relay: every peer dials relay. + * ice_p2p: MotK ICE signaling; LAN endpoints optional. */ { + char transport[24]; const int force_relay = using_server_input_relay(&g_lc.join); const int seats = g_lc.join.player_count >= 2 ? g_lc.join.player_count : g_lc.join.max_slots; @@ -1853,6 +1864,12 @@ static void handle_server_json(const char *json) (g_lc.is_host && seats >= 3 && !force_relay) ? 1 : 0; const int peer_bad = !g_lc.join.peer_hostport[0] || endpoint_port_is_zero(g_lc.join.peer_hostport); + int ice_p2p = 0; + transport[0] = '\0'; + json_get_str(json, "transport", transport, sizeof(transport)); + if (strcmp(transport, "ice_p2p") == 0 || + (!force_relay && seats == 2 && !relay_endpoint[0])) + ice_p2p = 1; if (force_relay) { if (peer_bad) { strncpy(g_lc.join.last_error, "missing_endpoints", @@ -1860,6 +1877,24 @@ static void handle_server_json(const char *json) g_lc.launch_pending = 0; return; } + } else if (ice_p2p) { +#if !defined(RNET_ENABLE_ICE) + /* Session 151: no-ICE Desktop cannot run ice_p2p — refuse + * launch with a clear error instead of start failed (-4). */ + fprintf(stderr, + "psx_lobby: launch transport=ice_p2p refused — this " + "build has no ICE (need SFU / Force Relay, or rebuild " + "with PSX_NET_ICE=ON)\n"); + fflush(stderr); + strncpy(g_lc.join.last_error, "ice_required", + sizeof(g_lc.join.last_error) - 1); + g_lc.launch_pending = 0; + return; +#else + fprintf(stderr, + "psx_lobby: launch transport=ice_p2p (no SFU)\n"); + fflush(stderr); +#endif } else if (!g_lc.join.host_endpoint[0] || (g_lc.is_host && !host_hub && !g_lc.join.guest_endpoint[0]) || (!g_lc.is_host && peer_bad)) { @@ -1928,7 +1963,8 @@ static void handle_server_json(const char *json) strcmp(code, "already_in_lobby") == 0 || strcmp(code, "lobby_limit") == 0 || strcmp(code, "version_mismatch") == 0 || - strcmp(code, "game_mismatch") == 0) { + strcmp(code, "game_mismatch") == 0 || + strcmp(code, "disc_mismatch") == 0) { g_lc.join.ok = 0; } return; @@ -2193,6 +2229,8 @@ static void lobby_ice_rtt_close(void) g_ice_rtt_peer_id[0] = '\0'; g_ice_rtt_force_relay = 0; g_ice_rtt_last_log_ms = 0; + g_ice_path_reported[0] = '\0'; + g_ice_path_report_ms = 0; /* Keep accept=1 through launch / match so match ICE can queue offers. * Waiting-room resume clears accept until the probe restarts. */ if (!g_lc.launch_pending && !g_lc.ice_rtt_suspended) @@ -2366,6 +2404,7 @@ static void lobby_ice_rtt_tick(void) rnet_ice_rtt_selected_path(g_ice_rtt, path, sizeof(path)); { uint64_t now = lobby_mono_ms(); + const char *report = NULL; if (now - g_ice_rtt_last_log_ms >= 3000ull) { g_ice_rtt_last_log_ms = now; fprintf(stderr, @@ -2373,6 +2412,27 @@ static void lobby_ice_rtt_tick(void) rnet_ice_state_name(st), path, ms); fflush(stderr); } + /* Server picks SFU vs ICE P2P from these reports (2 seated only). */ + if (strcmp(path, "host") == 0 || strcmp(path, "srflx") == 0 || + strcmp(path, "prflx") == 0) + report = "direct"; + else if (strcmp(path, "relay") == 0) + report = "relay"; + else if (strcmp(path, "failed") == 0) + report = "fail"; + if (report && g_lc.member_count == 2 && + (strcmp(report, g_ice_path_reported) != 0 || + now - g_ice_path_report_ms >= 10000ull)) { + char msg[96]; + snprintf(msg, sizeof(msg), + "{\"op\":\"path_report\",\"path\":\"%s\"}", report); + queue_send(msg); + strncpy(g_ice_path_reported, report, sizeof(g_ice_path_reported) - 1); + g_ice_path_reported[sizeof(g_ice_path_reported) - 1] = '\0'; + g_ice_path_report_ms = now ? now : 1ull; + fprintf(stderr, "psx_lobby: path_report %s\n", report); + fflush(stderr); + } } #endif /* RNET_ENABLE_ICE */ } @@ -2498,6 +2558,29 @@ const char *psx_lobby_game_version(void) return effective_game_version(NULL); } +void psx_lobby_set_disc_fp(const char *disc_fp) +{ + size_t i; + g_lc.disc_fp[0] = '\0'; + if (!disc_fp || !disc_fp[0]) return; + /* Accept only 64 lowercase/uppercase hex chars; normalize to lower. */ + for (i = 0; i < 64; i++) { + unsigned char c = (unsigned char)disc_fp[i]; + if (!isxdigit(c)) return; + g_lc.disc_fp[i] = (char)tolower(c); + } + if (disc_fp[64] != '\0') { + g_lc.disc_fp[0] = '\0'; + return; + } + g_lc.disc_fp[64] = '\0'; +} + +const char *psx_lobby_disc_fp(void) +{ + return g_lc.disc_fp; +} + void psx_lobby_request_list(void) { g_list_rtt_on_next_list = 1; @@ -2547,10 +2630,12 @@ int psx_lobby_create(const char *name, const char *game_name, const char *game_v } n = snprintf(msg, sizeof(msg), "{\"op\":\"create\",\"name\":\"%s\",\"game_name\":\"%s\",\"game_version\":\"%s\"," - "\"password\":\"%s\",\"max_slots\":%d,\"host_bind\":\"%s\",\"display_name\":\"%s\"%s}", + "\"password\":\"%s\",\"max_slots\":%d,\"host_bind\":\"%s\",\"display_name\":\"%s\"," + "\"disc_fp\":\"%s\"%s}", name && name[0] ? name : "Lobby", gn, gv, password ? password : "", g_lobby_max_slots, g_lc.my_bind, - g_lc.display_name[0] ? g_lc.display_name : "Host", caps_json); + g_lc.display_name[0] ? g_lc.display_name : "Host", + g_lc.disc_fp, caps_json); if (n < 0 || (size_t)n >= sizeof(msg)) return -1; queue_send(msg); flush_pending(); @@ -2572,10 +2657,11 @@ int psx_lobby_join(const char *lobby_id, const char *password, const char *guest g_lc.join.last_error[0] = '\0'; snprintf(msg, sizeof(msg), "{\"op\":\"join\",\"lobby_id\":\"%s\",\"password\":\"%s\",\"guest_bind\":\"%s\"," - "\"display_name\":\"%s\",\"game_name\":\"%s\",\"game_version\":\"%s\"}", + "\"display_name\":\"%s\",\"game_name\":\"%s\",\"game_version\":\"%s\"," + "\"disc_fp\":\"%s\"}", lobby_id, password ? password : "", g_lc.my_bind, g_lc.display_name[0] ? g_lc.display_name : "Guest", - gn, gv); + gn, gv, g_lc.disc_fp); queue_send(msg); flush_pending(); return 0; diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 54298f53..d32477c4 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -34,9 +34,11 @@ #include "psx_netplay_rb.h" #include "psx_netplay_sched.h" #include "cpu_state.h" +#include "cdrom.h" #include "gpu.h" #include "interrupts.h" #include "mdec.h" +#include "psx_cycles.h" #include "psx_scheduler.h" #if defined(PSX_HAS_LOBBY_CLIENT) #include "psx_lobby_client.h" @@ -415,6 +417,13 @@ void psx_netplay_pad_trace_dev(int card, int fallback, int sdl_start, } void psx_netplay_on_rb_snap_loaded(void) {} void psx_netplay_hc_fork_recovery_restart(void) {} +void psx_netplay_hc_fork_recovery_clear(void) {} +void psx_netplay_cd_bisect_arm(uint32_t from_sim, uint32_t ticks) +{ + (void)from_sim; + (void)ticks; +} +int psx_netplay_cd_bisect_active(void) { return 0; } int psx_netplay_needs_local_sample(void) { return 0; } int psx_netplay_live_pad_buttons(uint16_t *out) { @@ -712,6 +721,166 @@ void psx_netplay_hc_fork_recovery_restart(void) fflush(stderr); } +void psx_netplay_hc_fork_recovery_clear(void) +{ + s_fork_tick = 0xffffffffu; + s_fork_first_sim = 0u; + s_fork_last_attempt_sim = 0u; +} + +/* §93 P1: per-tick CD/MDEC/clk crumbs across BIOS→FMV (~160–192) and after + * media realign. Enable with PSX_RB_CD_BISECT=1 or =LO-HI (default 150-250). + * PSX_RB_CD_BISECT_ARM=N extends N ticks past each media flush_resume arm. */ +static int s_cd_bisect_enabled; +static int s_cd_bisect_inited; +static uint32_t s_cd_bisect_lo = 150u; +static uint32_t s_cd_bisect_hi = 250u; +static uint32_t s_cd_bisect_arm_until; /* exclusive sim; 0 = inactive */ +static uint32_t s_cd_bisect_arm_span = 48u; +static uint32_t s_cd_bisect_last_tick = 0xffffffffu; + +static void np_cd_bisect_init(void) +{ + const char *e; + const char *dash; + unsigned lo = 0, hi = 0; + if (s_cd_bisect_inited) + return; + s_cd_bisect_inited = 1; + e = getenv("PSX_RB_CD_BISECT"); + if (!e || !e[0] || e[0] == '0') + return; + s_cd_bisect_enabled = 1; + dash = strchr(e, '-'); + if (dash && dash > e && + sscanf(e, "%u-%u", &lo, &hi) == 2 && hi >= lo) { + s_cd_bisect_lo = lo; + s_cd_bisect_hi = hi; + } + e = getenv("PSX_RB_CD_BISECT_ARM"); + if (e && e[0]) { + unsigned n = (unsigned)atoi(e); + if (n > 0u && n < 4096u) + s_cd_bisect_arm_span = n; + } + fprintf(stderr, + "psxrecomp: rb CD bisect ON sim=%u..%u +arm=%u after media " + "flush_resume (per-tick crumbs + cd-cmd ISSUE/QUEUE/DONE; " + "diff peer lines by cyc, ignore resim=1)\n", + (unsigned)s_cd_bisect_lo, (unsigned)s_cd_bisect_hi, + (unsigned)s_cd_bisect_arm_span); + fflush(stderr); +} + +void psx_netplay_cd_bisect_arm(uint32_t from_sim, uint32_t ticks) +{ + uint32_t span; + np_cd_bisect_init(); + if (!s_cd_bisect_enabled) + return; + span = ticks ? ticks : s_cd_bisect_arm_span; + if (span == 0u) + span = 48u; + if (from_sim + span < from_sim) + s_cd_bisect_arm_until = 0xffffffffu; + else + s_cd_bisect_arm_until = from_sim + span; + fprintf(stderr, + "psxrecomp: rb CD bisect arm sim=%u..%u (media flush_resume)\n", + (unsigned)from_sim, (unsigned)(s_cd_bisect_arm_until - 1u)); + fflush(stderr); +} + +int psx_netplay_cd_bisect_active(void) +{ + uint32_t sim; + np_cd_bisect_init(); + if (!s_cd_bisect_enabled) + return 0; + sim = psx_netplay_sim_tick(); + if (sim >= s_cd_bisect_lo && sim <= s_cd_bisect_hi) + return 1; + if (s_cd_bisect_arm_until != 0u && sim < s_cd_bisect_arm_until) + return 1; + return 0; +} + +static void np_cd_bisect_tick(uint32_t tick) +{ + CDROMDebugState cds; + MDECDebugState mds; + CPUState dig_cpu; + uint32_t csv; + uint32_t cd_crc; + uint32_t clk_h; + uint64_t mdec_age; + int in_fixed; + int in_arm; + np_cd_bisect_init(); + if (!s_cd_bisect_enabled || !g_np.cpu) + return; + in_fixed = (tick >= s_cd_bisect_lo && tick <= s_cd_bisect_hi); + in_arm = (s_cd_bisect_arm_until != 0u && tick < s_cd_bisect_arm_until); + if (!in_fixed && !in_arm) + return; + if (s_cd_bisect_last_tick == tick) + return; + s_cd_bisect_last_tick = tick; + + memset(&cds, 0, sizeof(cds)); + memset(&mds, 0, sizeof(mds)); + cdrom_debug_snapshot(&cds); + mdec_debug_get_state(&mds); + psx_netplay_rb_cpu_for_present_digest(&dig_cpu, g_np.cpu); + csv = interrupts_get_cycles_since_vblank(); + cd_crc = netplay_cdrom_digest(); + { + NetplayCoreParts parts; + netplay_core_digest_parts(&dig_cpu, &parts); + clk_h = parts.clock_irq; + } + mdec_age = mdec_color_age_cycles(); + + fprintf(stderr, + "psxrecomp: rb cd-bisect sim=%u cyc=%llu csv=%u pc=%08x " + "i_stat=%08x clk=%08x cd=%08x " + "rd=%d reading=%d present_d=%d pend=%d/%d pend_cmd=%02x div=%d " + "lba=%d rMSF=%02d:%02d:%02d mode=%02x " + "irq_f=%02x irq_e=%02x sec_av=%d sec_pos=%d " + "mdec_age=%llu mdec_busy=%u in=%u out=%u/%u depth24=%d " + "xa=%d fmv_pend=%d\n", + (unsigned)tick, + (unsigned long long)psx_cycle_count, + (unsigned)csv, + (unsigned)dig_cpu.pc, + (unsigned)cds.i_stat, + (unsigned)clk_h, + (unsigned)cd_crc, + cds.read_delay, + cds.reading, + cds.irq_present_delay, + cds.pending_pending, + cds.pending_delay, + (unsigned)cds.pending_cmd, + cds.speed_divisor, + cds.last_sector_lba, + cds.read_min, cds.read_sec, cds.read_sect, + (unsigned)cds.mode_reg, + (unsigned)cds.irq_flag, + (unsigned)cds.irq_enable, + cds.sector_available, + cds.sector_read_pos, + (unsigned long long)mdec_age, + (unsigned)mds.busy, + (unsigned)mds.input_count, + (unsigned)mds.output_pos, + (unsigned)mds.output_size, + gpu_display_is_depth24(), + cdrom_xa_stream_active(), + cdrom_fmv_stream_pending()); + fflush(stderr); +} + static void np_check_core_diverge(void) { uint32_t tick = 0, local_d = 0, peer_d = 0; @@ -2310,9 +2479,9 @@ static void np_rollback_reconcile_wire(void) cooldown = psx_netplay_rb_rewind_suppressed(); fmv_defer = psx_netplay_rb_fmv_defer_rewind(); { - /* 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). */ + /* Media + short settle (§26/§100): promote-only; invent off during + * live FMV even with MEDIA_KF (avoids GAP1→3.7MB KF xfer). Post-FMV + * menus invent+resim. */ int fmv_lock = psx_netplay_rb_lockstep_no_invent(); no_resim = promote_sweep || cooldown || fmv_lock; if (promote_sweep) @@ -3114,13 +3283,24 @@ static int resolve_use_ice(const PsxNetplayConfig *cfg) return 0; #else { - int online_requested = cfg->transport == 1 || - (cfg->transport == 0 && in_motk_room); - if (online_requested) { + /* Session 151: Desktop guest (no ICE) got MotK launch with peer set + * to the SFU advertise host but force_input_relay unset → hard -4 + * while host SFU-started. Dial a usable peer as LAN/SFU instead. */ + if (in_motk_room && cfg->peer_hostport && cfg->peer_hostport[0]) { fprintf(stderr, - "psx_netplay: hosted lobby requires ICE, but ICE is not " - "available in this build (configure with PSX_NET_ICE=ON / " - "RNET_ENABLE_ICE=ON)\n"); + "psx_netplay: no-ICE build — MotK room dialing peer %s " + "as LAN/SFU (rebuild with PSX_NET_ICE=ON for ice_p2p)\n", + cfg->peer_hostport); + fflush(stderr); + return 0; + } + if (cfg->transport == 1 || in_motk_room) { + fprintf(stderr, + "psx_netplay: hosted lobby needs ICE or a SFU " + "relay_endpoint, but ICE is not in this build and peer " + "is empty (configure PSX_NET_ICE=ON / RNET_ENABLE_ICE=ON, " + "or Force Relay so launch includes relay_endpoint)\n"); + fflush(stderr); return -1; } } @@ -3156,6 +3336,15 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) /* Max delay 20 matches RNET_MAX_BUNDLE 21 (neutral prefix + tip). */ rcfg.input_delay = (rnet_u8)(cfg->input_delay < 0 ? 0 : (cfg->input_delay > 20 ? 20 : cfg->input_delay)); + /* §105: rollback floor D≥5 at session create (both peers). Lobby can + * still seed 2–4 for delay-sync probe; Play clamps here. */ + if (cfg->rollback && rcfg.input_delay > 0u && rcfg.input_delay < 5u) { + fprintf(stderr, + "psxrecomp: rb session delay floor %u → 5 (rollback min)\n", + (unsigned)rcfg.input_delay); + fflush(stderr); + rcfg.input_delay = 5u; + } rcfg.session_id = cfg->session_id ? cfg->session_id : 1u; /* Host resolves auto (-1) before start; accept 0..PSX_MAX_PLAYERS-1. */ @@ -3522,7 +3711,16 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) void psx_netplay_bind_guest_saves(void) { - if (!psx_netplay_active() || g_np.local_slot == 0 || g_np.guest_sandbox) + uint32_t bios = 0, entry = 0; + if (!psx_netplay_active()) + return; + /* psx_netplay_start runs before savestate_configure, so g_np still held + * bios=0/entry=0. Refresh for both seats: host RB snaps (MEDIA-KF) must + * carry the real integrity key or the guest rejects the transfer. */ + savestate_get_integrity(&bios, &entry); + g_np.bios_checksum = bios; + g_np.entry_pc = entry; + if (g_np.local_slot == 0 || g_np.guest_sandbox) return; np_enter_guest_sandbox(); } @@ -4329,6 +4527,8 @@ void psx_netplay_finish_frame(void) psx_netplay_rb_finish_frame(); /* Exchange cores during Replay so mid-resim forks abort before POST. */ np_emit_frame_commit(done); + /* Outside emit: Live FMV skips FRAME_COMMIT; bisect still needs crumbs. */ + np_cd_bisect_tick(done); np_drain_peer_frame_commits(); g_np.latched_for_tick = 0; g_np.needs_advance = 0; /* live latch must not outlive a resim vblank */ @@ -4340,6 +4540,7 @@ void psx_netplay_finish_frame(void) /* Digest the tick that just ran (sim_tick before advance). */ done = rnet_session_sim_tick(g_np.session); np_emit_frame_commit(done); + np_cd_bisect_tick(done); if (g_np.rollback) { uint32_t resume_hint = 0; /* TipHold Live: reconcile late wire so tip-extend can schedule rereplay. */ diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index 53f37a71..ce326df8 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -49,6 +49,7 @@ int psx_netplay_rb_media_kf_on_ready(const void *data, size_t size) (void)size; return 0; } +int psx_netplay_rb_media_kf_busy(void) { return 0; } void psx_netplay_rb_poll_replay_stall(void) {} int psx_netplay_rb_take_promote_sweep(void) { return 0; } int psx_netplay_rb_fmv_unlock_grace_active(void) { return 0; } @@ -192,6 +193,7 @@ static uint32_t snap_interval(void) } static int rb_media_kf_enabled(void); /* §97 — defined near episode wire clear */ +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 * loads without every-tick 0.7 ms snap tax). Override: PSX_NET_FMV_SNAP_INTERVAL. */ @@ -388,12 +390,28 @@ static int g_pin_valid; /* §97: FMV media keyframe episode — host forces identical snap at load_tick * before Replay (probe CRC, transfer on miss). Default ON; set - * PSX_NET_FMV_MEDIA_KF=0 to restore §93 refuse / invent-off during media. */ + * PSX_NET_FMV_MEDIA_KF=0 to restore §93 refuse / invent-off during media. + * §100: invent stays off during live media (lockstep); KF still heals real + * rewinds. Present hold-last while probe/xfer runs. */ 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 */ +/* §100: recent FMV media snap CRCs — prefer choose_load ticks we already + * sealed, and let probe_match confirm without a ring edge-case miss. */ +#define RB_MEDIA_CRC_RING 32u +typedef struct RbMediaCrc { + uint32_t tick; + uint32_t size; + uint32_t crc; +} RbMediaCrc; +static RbMediaCrc g_media_crc[RB_MEDIA_CRC_RING]; +static uint32_t g_media_crc_head; +static uint32_t g_media_crc_n; +/* Highest FMV media tick whose CRC was probe-matched (peer aligned). */ +static uint32_t g_media_crc_peer_match_through; + /* §67 resim audit digest cache (fin state per replayed tick) — declared here * so pin_baseline_from_cpu can compare pin digest vs audit fin (§78 PIN-SKEW * diag). See log_resim_tick_audit for the cache contract. */ @@ -424,12 +442,15 @@ static uint32_t g_last_begin_mismatch = 0xffffffffu; #define RB_READY_TIMEOUT_MS 4000u /* §49: WAN/TURN baseline GO can take multiple RTT; floor stays 4s. */ #define RB_READY_TIMEOUT_MAX_MS 12000u -/* §49/§73/§86: ownership chain min confirmed ticks ahead of tip. tip+1 (§48) - * opened SPAN every Verify on WAN. tip+8 tip-held every POST on LAN tip+4/+5 - * (§73). Default floor tip+4; §86 also raises to max(floor, D) so Force-TURN - * delay cushion is not treated as catch-up backlog. Env PSX_RB_CHAIN_MIN_AHEAD - * (when set) overrides both. */ -#define RB_OWNERSHIP_CHAIN_MIN_AHEAD 4u +/* §49/§73/§86/§104: ownership chain min confirmed ticks ahead of tip. tip+1 + * (§48) opened SPAN every Verify on WAN. tip+8 tip-held every POST on LAN + * tip+4/+5 (§73/§144). Default floor tip+6; §86 also raises to max(floor, D-1) + * so Force-TURN delay cushion is not treated as catch-up backlog. Env + * PSX_RB_CHAIN_MIN_AHEAD (when set) overrides both. */ +/* §104: floor tip+6 — session-144 fight soak chained tip+4/+5 every POST + * (ownership final / hop storms → 12–79% replay windows). tip+6 still leaves + * D≈5–7 cushion as Live exit, not SPAN. Env PSX_RB_CHAIN_MIN_AHEAD overrides. */ +#define RB_OWNERSHIP_CHAIN_MIN_AHEAD 6u /* §85: Replay must not chase Live forever. With Force-TURN D≈6–7 the * post-match frontier stays tip+5..+8 (> MIN_AHEAD), so §47 chaining + * mid-Replay tip-extends treadmilled ~800 ticks (audio mute / OBS cutout). @@ -438,11 +459,11 @@ static uint32_t g_last_begin_mismatch = 0xffffffffu; * at wire pace (§74). Env: PSX_RB_CHAIN_MAX_HOPS / PSX_RB_CHAIN_MAX_TICKS. */ #define RB_OWNERSHIP_CHAIN_MAX_HOPS 2u #define RB_OWNERSHIP_CHAIN_MAX_TICKS 48u -/* §86 C: after BUDGET→Live, suppress re-chain for one tip-runway / 400ms so - * residual coalesce/FOLLOW ping-pong is logged as SUPPRESS (diagnose) instead - * of immediately restarting hop1. Env: PSX_RB_CHAIN_SUPPRESS_TICKS / _MS. */ -#define RB_OWNERSHIP_CHAIN_SUPPRESS_TICKS 24u -#define RB_OWNERSHIP_CHAIN_SUPPRESS_MS 400u +/* §86 C / §104: after BUDGET→Live, suppress re-chain for ~one tip-runway / + * 800ms so fight button chatter does not immediately restart hop1. + * Env: PSX_RB_CHAIN_SUPPRESS_TICKS / _MS. */ +#define RB_OWNERSHIP_CHAIN_SUPPRESS_TICKS 48u +#define RB_OWNERSHIP_CHAIN_SUPPRESS_MS 800u /* Failed episode (resim/baseline/post): calm from *live* sim before realign. * Was 60/120 — after abort, choose_load stuck at agreed tip so the next begin * resimmed the whole cooldown (depth 63→128) = "pushing further back". */ @@ -675,6 +696,20 @@ static int seat_tick_confirmed(int slot, uint32_t tick); static void advertise_agreed_resolved(uint32_t through); static uint64_t rb_mono_ms(void); +/* §102: after peer-ahead follow-NACK, force one short light tip reopen and + * suppress ownership chaining (session-140: NACK keep-live → hc-fork SPAN + * 6640→6660 → hops 32/40 → 96% replay hitch). */ +#define RB_PEER_AHEAD_LIGHT_DEPTH 4u +static uint32_t g_peer_ahead_force_load; /* 0 = off; consumed in begin_rewind */ +static int g_peer_ahead_light_episode; /* 1 while that light tip is open */ + +/* §103: past-frontier NACK with demote depth > this → keep-live (no deep + * Live rewind). Session 142: demote 827→763 forked cores → MEDIA-KF 3.7MB. */ +#define RB_NACK_DEEP_REALIGN_MAX 8u +/* Sticky: peer_resolved gate must not expire until the follower advertises + * past the refused tip (force-open after GATE_MS reopened the NACK storm). */ +static int g_past_frontier_sticky; + /* §47: begin_rewind is an ownership SPAN chain (skip cooldown; force target). */ static int g_ownership_chain; static uint32_t g_ownership_chain_frontier; @@ -707,6 +742,10 @@ static uint32_t ownership_chain_max_hops(void) if (v >= 0 && v <= 64) return (uint32_t)v; } + /* §100: during live FMV media, at most one ownership hop after MEDIA-KF + * (logs showed hop1/hop2 stack + present hitch). */ + if (rb_fmv_media_active()) + return 1u; return RB_OWNERSHIP_CHAIN_MAX_HOPS; } @@ -718,13 +757,16 @@ static uint32_t ownership_chain_max_ticks(void) if (v >= 1 && v <= 512) return (uint32_t)v; } + if (rb_fmv_media_active()) + return 8u; return RB_OWNERSHIP_CHAIN_MAX_TICKS; } -/* §86/§87 B: floor tip+4; raise toward D but leave one tick of headroom - * (max(4, D-1)) so Force-TURN frontier≈tip+D still chains once instead of - * tip-hold invent-parking every POST (soak §86: min_ahead=D → tip+9 final - * tip-hold → dual-init hang). Env PSX_RB_CHAIN_MIN_AHEAD overrides. */ +/* §86/§87 B / §104: floor tip+6; raise toward D but leave one tick of + * headroom (max(6, D-1)) so Force-TURN frontier≈tip+D still chains once + * instead of tip-hold invent-parking every POST (soak §86: min_ahead=D → + * tip+9 final tip-hold → dual-init hang). Env PSX_RB_CHAIN_MIN_AHEAD + * overrides. */ static uint32_t ownership_chain_min_ahead(void) { uint32_t min_ahead = RB_OWNERSHIP_CHAIN_MIN_AHEAD; @@ -750,6 +792,9 @@ static uint32_t ownership_chain_suppress_ticks(void) if (v >= 0 && v <= 256) return (uint32_t)v; } + /* §100: longer post-BUDGET quiet during intro FMV. */ + if (rb_fmv_media_active()) + return 48u; return RB_OWNERSHIP_CHAIN_SUPPRESS_TICKS; } @@ -761,6 +806,8 @@ static uint32_t ownership_chain_suppress_ms(void) if (v >= 0 && v <= 5000) return (uint32_t)v; } + if (rb_fmv_media_active()) + return 800u; return RB_OWNERSHIP_CHAIN_SUPPRESS_MS; } @@ -1128,8 +1175,10 @@ static int rb_in_fmv_defer_rewind_window(void) } /* Admit no-invent gate (§26): media + short settle only. - * §97: MEDIA_KF allows invent during live media (episodes open with host - * keyframe). Settle + DESYNC invent-hold unchanged. */ + * §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. */ static int rb_in_fmv_lockstep_window(void) { RNetSession *s = sess(); @@ -1137,12 +1186,53 @@ static int rb_in_fmv_lockstep_window(void) rb_fmv_tick_settle(); rb_fmv_desync_invent_maybe_expire(sim); if (rb_fmv_media_active()) - return rb_media_kf_enabled() ? 0 : 1; + return 1; if (g_fmv_unmatched_desync) return 1; return sim < g_fmv_settle_until; } +static void rb_media_crc_note(uint32_t tick, uint32_t size, uint32_t crc) +{ + RbMediaCrc *e; + uint32_t i; + if (tick == 0u || size == 0u) + return; + for (i = 0; i < g_media_crc_n && i < RB_MEDIA_CRC_RING; ++i) { + uint32_t idx = (g_media_crc_head + RB_MEDIA_CRC_RING - 1u - i) % + RB_MEDIA_CRC_RING; + if (g_media_crc[idx].tick == tick) { + g_media_crc[idx].size = size; + g_media_crc[idx].crc = crc; + return; + } + } + e = &g_media_crc[g_media_crc_head % RB_MEDIA_CRC_RING]; + e->tick = tick; + e->size = size; + e->crc = crc; + g_media_crc_head = (g_media_crc_head + 1u) % RB_MEDIA_CRC_RING; + if (g_media_crc_n < RB_MEDIA_CRC_RING) + g_media_crc_n++; +} + +static int rb_media_crc_lookup(uint32_t tick, uint32_t *size_out, uint32_t *crc_out) +{ + uint32_t i; + for (i = 0; i < g_media_crc_n && i < RB_MEDIA_CRC_RING; ++i) { + uint32_t idx = (g_media_crc_head + RB_MEDIA_CRC_RING - 1u - i) % + RB_MEDIA_CRC_RING; + if (g_media_crc[idx].tick == tick) { + if (size_out) + *size_out = g_media_crc[idx].size; + if (crc_out) + *crc_out = g_media_crc[idx].crc; + return 1; + } + } + return 0; +} + static void arm_rewind_cooldown_ticks(uint32_t sim, uint32_t ticks, const char *why) { uint32_t until = sim + ticks; @@ -1475,6 +1565,7 @@ static int rb_media_kf_enabled(void) } static void clear_baseline_pin(void); +static void abort_episode(const char *why); static int rb_install_media_kf_bytes(uint32_t tick, const uint8_t *data, size_t size) { @@ -1547,6 +1638,31 @@ static void rb_media_kf_host_drive(void) } return; } + /* Refuse to probe/send a snap the peer cannot load (host integrity still + * 0/0 after start-before-configure left all ring snaps poisoned). */ + if (!g_b.bios_checksum || !g_b.entry_pc || *g_b.bios_checksum == 0u || + *g_b.entry_pc == 0u) { + fprintf(stderr, + "psxrecomp: rb MEDIA-KF host — integrity unset " + "(bios=%08x entry=%08x)\n", + (unsigned)(g_b.bios_checksum ? *g_b.bios_checksum : 0u), + (unsigned)(g_b.entry_pc ? *g_b.entry_pc : 0u)); + fflush(stderr); + abort_episode("MEDIA-KF integrity unset"); + return; + } + { + char reason[256]; + if (!boot_state_check_buffer(p, sz, *g_b.bios_checksum, *g_b.entry_pc, + reason, sizeof(reason))) { + fprintf(stderr, + "psxrecomp: rb MEDIA-KF host — snap reject load=%u — %s\n", + (unsigned)load, reason[0] ? reason : "unknown"); + fflush(stderr); + abort_episode("MEDIA-KF snap integrity reject"); + return; + } + } if (!g_media_kf_host_armed) { crc = rnet_checksum(p, sz); @@ -1570,6 +1686,8 @@ static void rb_media_kf_host_drive(void) rnet_session_state_probe_finish(s); if (match) { if (rb_media_kf_seal_local(load)) { + if (load > g_media_crc_peer_match_through) + g_media_crc_peer_match_through = load; fprintf(stderr, "psxrecomp: rb MEDIA-KF hash match load=%u — local " "pin sealed\n", @@ -1666,21 +1784,26 @@ static int pin_baseline_from_cpu(uint32_t tick) /* §75: pin is sealed POST state — Live must not re-poison this tick. */ if (tick > g_auth_snap_through) g_auth_snap_through = tick; - /* §78 diag: digest the pin with its canonical resume pc AT SAVE. If this - * already differs from the just-computed audit fin, the SNAP-STALE class - * is the pc substitution (snap.pc = pick_snap_resume_pc vs live pc), not - * load infidelity — names which layer owns the fork. */ + /* §78/§104 diag: digest the pin with its canonical resume pc AT SAVE. + * If this already differs from the just-computed audit fin, the + * SNAP-STALE class is the pc substitution (snap.pc = pick_snap_resume_pc + * vs live pc), not load infidelity. Rate-limit — session-144 logged 32× + * identical lines per fight (cosmetic; no control-plane effect). */ { uint32_t pin_core = netplay_core_digest(&snap); if (s_resim_audit_valid && s_resim_audit_sim == tick && pin_core != s_resim_audit_core) { - fprintf(stderr, - "psxrecomp: rb PIN-SKEW tick=%u pin_core=%08x " - "fin_core=%08x live_pc=0x%08x snap_pc=0x%08x — pc " - "substitution shifts digest at save\n", - (unsigned)tick, (unsigned)pin_core, - (unsigned)s_resim_audit_core, (unsigned)c->pc, - (unsigned)pc); + static uint32_t s_pin_skew_log_tick; + if (s_pin_skew_log_tick != tick) { + fprintf(stderr, + "psxrecomp: rb PIN-SKEW tick=%u pin_core=%08x " + "fin_core=%08x live_pc=0x%08x snap_pc=0x%08x — pc " + "substitution shifts digest at save\n", + (unsigned)tick, (unsigned)pin_core, + (unsigned)s_resim_audit_core, (unsigned)c->pc, + (unsigned)pc); + s_pin_skew_log_tick = tick; + } } fprintf(stderr, "psxrecomp: rb baseline pinned from CPU tick=%u bytes=%zu " @@ -1831,6 +1954,9 @@ static void abort_episode(const char *why) clear_episode_wire_state(); g_ownership_skip_snap = 0; ownership_clear_chain_budget(); + /* Keep g_peer_ahead_force_load — NACK handler sets it after abort then + * immediately begin_rewind. Clear a stale light-episode marker. */ + g_peer_ahead_light_episode = 0; if (g_rb && rnet_rb_is_active(g_rb)) { rnet_rb_set_phase(g_rb, nRNetRbPhaseAbort); rnet_rb_session_reset(g_rb); @@ -2484,15 +2610,22 @@ static void advertise_agreed_resolved(uint32_t through) } /* §41: 1 while choose_load/begin should clamp to g_peer_resolved_through. - * After GATE_MS of waiting, sticky-expire until peer RESOLVED advances. */ + * After GATE_MS of waiting, sticky-expire until peer RESOLVED advances. + * §103: past-frontier sticky never expires on the timer — only a peer + * RESOLVED raise clears it (session 142: force-open → NACK → deep demote). */ static int peer_resolved_gate_blocks(uint32_t want_through) { uint64_t now; if (g_peer_resolved_through == 0u) return 0; - if (want_through <= g_peer_resolved_through) + if (want_through <= g_peer_resolved_through) { + if (g_past_frontier_sticky) + g_past_frontier_sticky = 0; return 0; + } + if (g_past_frontier_sticky) + return 1; if (g_peer_resolved_gate_expired) return 0; now = rb_mono_ms(); @@ -2688,6 +2821,9 @@ static void apply_peer_resolved(uint32_t resolved) g_peer_resolved_through = resolved; g_peer_resolved_gate_expired = 0; g_peer_resolved_wait_t0_ms = 0ull; + /* §103: follower caught up past sticky past-frontier NACK. */ + if (g_past_frontier_sticky) + g_past_frontier_sticky = 0; } rnet_rb_set_peer_convergence(g_rb, resolved); /* Peer already POST-matched and tip-held this tip (or later). If we are @@ -3101,12 +3237,39 @@ static int choose_load_tick_inner(uint32_t mismatch, uint32_t *out_load) uint32_t hi = shared; uint32_t lo; uint32_t oldest = netplay_snap_ring_oldest_tick(g_snaps); + /* §99 MEDIA_KF: invent-through-media leaves HC/agreed on the last + * pre-media interval snap (e.g. 432) while the ring holds FMV snaps + * every fmv_media_snap_interval. Raise the walk to mismatch-1 so + * Start-during-FMV loads ~tip; probe/transfer makes the pin identical. */ + int media_kf_near = 0; + if (rb_media_kf_enabled() && + (rb_fmv_tick_unsafe_for_episode(mismatch) || + rb_fmv_tick_unsafe_for_episode(mismatch - 1u))) + media_kf_near = 1; if (hi > mismatch - 1u) hi = mismatch - 1u; + if (media_kf_near && mismatch > 1u) { + uint32_t media_hi = mismatch - 1u; + if (media_hi > hi) { + static uint32_t s_media_raise_log; + if (s_media_raise_log != media_hi) { + fprintf(stderr, + "psxrecomp: rb choose_load MEDIA-KF raise walk " + "%u→%u (mismatch=%u agreed=%u hc_iv=%u)\n", + (unsigned)hi, (unsigned)media_hi, + (unsigned)mismatch, + (unsigned)(g_agreed_valid ? g_agreed_through : 0u), + (unsigned)hc_iv); + fflush(stderr); + s_media_raise_log = media_hi; + } + hi = media_hi; + } + } /* Watermark entirely below the ring: walk cannot succeed. Fall through * to tip-slack (BOOTSTRAP/HEAL should have raised agreed; if not, * refuse would stick forever with invent≠wire). */ - if (shared < oldest) { + if (shared < oldest && !media_kf_near) { have_shared = 0; } else { /* Bound the walk to one ring depth of ticks. */ @@ -3144,6 +3307,17 @@ static int choose_load_tick_inner(uint32_t mismatch, uint32_t *out_load) if (!mutual && hc_floor > 0u && t <= hc_floor && netplay_hc_confirm_through(g_b.hc, t)) mutual = 1; + /* §99: media-interval (or dense) snaps are mutual under MEDIA_KF + * — CRC probe / transfer reconciles peer forks. */ + if (!mutual && media_kf_near) { + uint32_t miv = fmv_media_snap_interval(); + if (miv < 1u) + miv = 1u; + if ((miv <= 1u) || ((t % miv) == 0u) || + (g_fmv_dense_through > 0u && t <= g_fmv_dense_through && + (g_fmv_media_lo == 0u || t >= g_fmv_media_lo))) + mutual = 1; + } if (mutual && netplay_snap_ring_has(g_snaps, t)) { *out_load = t; return 1; @@ -3152,12 +3326,53 @@ static int choose_load_tick_inner(uint32_t mismatch, uint32_t *out_load) break; } /* Shared frontier in range but no snap left — refuse rather than - * load an unconfirmed tip above a live agreed watermark. */ - if (g_agreed_valid) + * load an unconfirmed tip above a live agreed watermark. MEDIA_KF + * may still tip-slack on media-interval snaps below. */ + if (g_agreed_valid && !media_kf_near) return 0; have_shared = 0; } } + /* §99/§100: tip-slack with FMV media interval when MEDIA_KF covers the + * bout. Prefer ticks whose CRC we already cached (peer more likely to + * hash-match and skip the 3.7 MB xfer). */ + if (rb_media_kf_enabled() && mismatch > 0u && + (rb_fmv_tick_unsafe_for_episode(mismatch) || + rb_fmv_tick_unsafe_for_episode(mismatch - 1u))) { + uint32_t miv = fmv_media_snap_interval(); + uint32_t mcap = mismatch - 1u; + uint32_t fallback = 0u; + int have_fallback = 0; + if (miv < 1u) + miv = 1u; + if (miv > 1u) + mcap -= (mcap % miv); + for (;;) { + if (netplay_snap_ring_has(g_snaps, mcap)) { + uint32_t csz = 0, ccrc = 0; + if (rb_media_crc_lookup(mcap, &csz, &ccrc) || + (g_media_crc_peer_match_through > 0u && + mcap <= g_media_crc_peer_match_through)) { + *out_load = mcap; + return 1; + } + if (!have_fallback) { + fallback = mcap; + have_fallback = 1; + } + } + if (mcap == 0u) + break; + if (miv > 1u) + mcap = (mcap < miv) ? 0u : (mcap - miv); + else + mcap--; + } + if (have_fallback) { + *out_load = fallback; + return 1; + } + } cap = mismatch; if (newest >= iv && cap > newest - iv) cap = newest - iv; @@ -3331,8 +3546,9 @@ static uint32_t remote_wire_hole_end_after(uint32_t demote, uint32_t live_sim) } /* 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). */ + * snap / past frontier / zombie load). 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) { @@ -3352,6 +3568,64 @@ static void send_follow_nack(uint32_t epoch, uint32_t mismatch, uint32_t load, } } +/* §101: refuse FOLLOW with NACK + rate-limited log. Zombie load/epoch used to + * return silently → initiator sat in SealInputs until RB_SEAL_TIMEOUT_MS (4s) + * while the follower spammed identical REFUSED lines (session-136 soak). */ +static void follow_refuse_nack(uint32_t epoch, uint32_t mismatch, uint32_t load, + uint32_t target, int slot, const char *detail) +{ + static uint32_t s_ep; + static uint32_t s_load; + static uint32_t s_suppressed; + int log_line = 1; + int arm_fresh; + + if (epoch == s_ep && load == s_load) { + s_suppressed++; + log_line = 0; + if ((s_suppressed % 32u) == 0u) { + fprintf(stderr, + "psxrecomp: rb follow REFUSED (+%u similar epoch=%u " + "load=%u)%s%s\n", + (unsigned)s_suppressed, (unsigned)epoch, (unsigned)load, + detail && detail[0] ? " — " : "", + detail && detail[0] ? detail : ""); + fflush(stderr); + } + } else { + if (s_suppressed > 0u) { + fprintf(stderr, + "psxrecomp: rb follow REFUSED (+%u similar epoch=%u " + "load=%u)\n", + (unsigned)s_suppressed, (unsigned)s_ep, (unsigned)s_load); + fflush(stderr); + s_suppressed = 0u; + } + s_ep = epoch; + s_load = load; + if (detail && detail[0]) { + fprintf(stderr, + "psxrecomp: rb follow REFUSED epoch=%u load=%u — %s\n", + (unsigned)epoch, (unsigned)load, detail); + fflush(stderr); + } + } + + arm_fresh = !g_follow_nack_pending || g_follow_nack_epoch != epoch || + g_follow_nack_load != load; + if (arm_fresh) { + 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, log_line); + g_follow_nack_sends = 1; + } +} + static void export_local_seals(void) { RNetSession *s = sess(); @@ -4470,9 +4744,21 @@ static void ownership_on_post_match(uint32_t tip) /* Contiguous frontier from the tick after the verified tip. */ frontier = psx_netplay_rb_confirmed_frontier(tip + 1u); - /* §48/§49/§73/§86/§87 B: micro frontiers at/below min_ahead do not chain. - * min_ahead = max(4, D-1) — delay cushion is not catch-up backlog, but - * tip+D still chains once (D-1 headroom) instead of tip-hold every POST. */ + /* §102: peer-ahead light tip — one Verify then Live (no SPAN hops). */ + if (g_peer_ahead_light_episode) { + fprintf(stderr, + "psxrecomp: rb peer-ahead light tip=%u frontier=%u → Live " + "(no ownership chain)\n", + (unsigned)tip, (unsigned)frontier); + fflush(stderr); + g_peer_ahead_light_episode = 0; + ownership_exit_to_live(tip, frontier, "peer-ahead light", 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 + * POST. */ min_ahead = ownership_chain_min_ahead(); if (frontier > tip + min_ahead) { uint32_t max_hops = ownership_chain_max_hops(); @@ -5423,6 +5709,7 @@ void psx_netplay_rb_start(void) g_peer_resolved_through = 0; g_peer_resolved_wait_t0_ms = 0ull; g_peer_resolved_gate_expired = 0; + g_past_frontier_sticky = 0; g_local_advertised_through = 0; g_peer_resolved_hb_last_ms = 0ull; g_live_realign_pending = 0; @@ -5491,6 +5778,10 @@ void psx_netplay_rb_shutdown(void) g_fmv_media_hi = 0; g_fmv_unmatched_desync = 0; g_fmv_unmatched_desync_arm_sim = 0; + g_media_crc_head = 0; + g_media_crc_n = 0; + g_media_crc_peer_match_through = 0; + memset(g_media_crc, 0, sizeof(g_media_crc)); g_resim_diverge_tick = 0; g_resim_diverge_streak = 0; g_bl_mismatch_streak = 0; @@ -5526,6 +5817,7 @@ void psx_netplay_rb_shutdown(void) g_peer_resolved_through = 0; g_peer_resolved_wait_t0_ms = 0ull; g_peer_resolved_gate_expired = 0; + g_past_frontier_sticky = 0; g_local_advertised_through = 0; g_peer_resolved_hb_last_ms = 0ull; g_epoch = 0; @@ -5912,7 +6204,10 @@ static int try_apply_pending_load(CPUState *cpu_in) g_fmv_media_lo = loaded_tick; if (loaded_tick >= g_fmv_media_hi) g_fmv_media_hi = loaded_tick; - if (!g_media_kf_episode && !g_ownership_skip_snap) { + /* Boot dig0 realign after rematch residue must not refuse + * load=0 as "FMV media" — BIOS has not reached a movie yet. */ + if (!g_media_kf_episode && !g_ownership_skip_snap && + loaded_tick != 0u) { char why[96]; snprintf(why, sizeof(why), "episode load into FMV media (load=%u)", @@ -5972,6 +6267,13 @@ void psx_netplay_rb_flush_resume(void) cdrom_fmv_stream_pending(), (unsigned)cds.i_stat, c ? (unsigned)c->cop0[12] : 0u); fflush(stderr); + /* §93 P1: densify CD/MDEC crumbs after media pin load (160→192 class). */ + if (rb_fmv_media_active() || cds.reading || + mdec_recently_active(RB_FMV_MDEC_HYSTERESIS)) { + RNetSession *s = sess(); + uint32_t sim = s ? (uint32_t)rnet_session_sim_tick(s) : 0u; + psx_netplay_cd_bisect_arm(sim, 0u); + } /* flush_resume is called from admit inside sdl_vblank_present, which * runs under gpu_vblank_flush_present with s_flushing_present=1. * longjmp abandons that frame — drop guard AND any leftover present @@ -6032,6 +6334,15 @@ void psx_netplay_rb_poll(struct CPUState *cpu_in, uint32_t resume_pc) if (g_rb && rnet_rb_is_resimulating(g_rb) && g_pending_save_tick > g_auth_snap_through) g_auth_snap_through = g_pending_save_tick; + /* §100: cache FMV media snap CRC for later MEDIA-KF probe. */ + if (rb_fmv_media_active()) { + size_t sz = 0; + const uint8_t *p = netplay_snap_ring_peek( + g_snaps, g_pending_save_tick, &sz); + if (p && sz) + rb_media_crc_note(g_pending_save_tick, (uint32_t)sz, + rnet_checksum(p, sz)); + } g_pending_save_valid = 0; s_snap_fail_logged = 0; s_snap_pc_reject_logged = 0; @@ -6121,19 +6432,30 @@ int psx_netplay_rb_media_kf_probe_match(uint32_t size, uint32_t crc) uint32_t load; size_t sz = 0; const uint8_t *p; + uint32_t got; if (!g_media_kf_episode || !g_rb || !g_snaps) return 0; load = rnet_rb_get_load_tick(g_rb); p = netplay_snap_ring_peek(g_snaps, load, &sz); if (!p || sz != (size_t)size) return 0; - if (rnet_checksum(p, sz) != crc) + got = rnet_checksum(p, sz); + if (got != crc) return 0; - /* Seal local pin so apply can proceed without waiting for a no-op xfer. */ - (void)rb_media_kf_seal_local(load); + /* §100: refresh CRC ring + peer-match watermark (skip xfer). */ + rb_media_crc_note(load, size, got); + if (!rb_media_kf_seal_local(load)) + return 0; + if (load > g_media_crc_peer_match_through) + g_media_crc_peer_match_through = load; return 1; } +int psx_netplay_rb_media_kf_busy(void) +{ + return (g_media_kf_episode && !g_media_kf_ready) ? 1 : 0; +} + int psx_netplay_rb_media_kf_on_ready(const void *data, size_t size) { uint32_t load; @@ -6142,6 +6464,22 @@ int psx_netplay_rb_media_kf_on_ready(const void *data, size_t size) load = rnet_rb_get_load_tick(g_rb); if (!g_media_kf_ready) { if (data && size > 0) { + if (g_b.bios_checksum && g_b.entry_pc) { + char reason[256]; + if (!boot_state_check_buffer((const uint8_t *)data, size, + *g_b.bios_checksum, *g_b.entry_pc, + reason, sizeof(reason))) { + fprintf(stderr, + "psxrecomp: rb MEDIA-KF install reject load=%u " + "bytes=%zu — %s\n", + (unsigned)load, size, + reason[0] ? reason : "unknown"); + fflush(stderr); + g_media_kf_sending = 0; + abort_episode("MEDIA-KF install integrity reject"); + return 1; + } + } if (!rb_install_media_kf_bytes(load, (const uint8_t *)data, size)) { fprintf(stderr, "psxrecomp: rb MEDIA-KF install FAILED load=%u " @@ -6548,11 +6886,21 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) } sim = rnet_session_sim_tick(s); + /* §102: drain peer RESOLVED before choose_load — a tip already in the + * UDP queue must raise peer_resolved so we do not open a zombie load. */ + { + rnet_u32 resolved = 0; + while (rnet_session_take_rb_resolved(s, &resolved)) + apply_peer_resolved(resolved); + } /* Tick settle tracker. Media/lockstep episodes resume into MotK wait/VLC - * tips and hang — admit waits for wire instead. */ + * tips and hang — admit waits for wire instead. + * §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(); - if (rb_in_fmv_lockstep_window()) { + if (rb_in_fmv_lockstep_window() && !g_ownership_chain && + !(g_media_kf_episode && rb_media_kf_enabled())) { static uint32_t s_fmv_refuse_sim; if (s_fmv_refuse_sim != sim) { fprintf(stderr, @@ -6626,6 +6974,51 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) } return 0; } + /* §101: never begin behind a peer tip we already know — that opens a + * zombie-load episode the follower must NACK. Prefer peer_resolved (or + * the newest mutual snap ≤ it) when choose_load landed lower. */ + if (!g_ownership_chain && g_peer_resolved_through > load && + mismatch_tick > 0u && g_peer_resolved_through < mismatch_tick && + g_snaps) { + uint32_t want = g_peer_resolved_through; + uint32_t t; + uint32_t raised = 0u; + if (want > mismatch_tick - 1u) + want = mismatch_tick - 1u; + for (t = want;; --t) { + if (netplay_snap_ring_has(g_snaps, t) && + (t <= g_peer_resolved_through) && + (g_peer_nack_floor == 0u || t > g_peer_nack_floor)) { + raised = t; + break; + } + if (t == 0u || t <= load) + break; + } + if (raised > load) { + fprintf(stderr, + "psxrecomp: rb begin raise load %u→%u " + "(peer_resolved=%u — avoid zombie load)\n", + (unsigned)load, (unsigned)raised, + (unsigned)g_peer_resolved_through); + fflush(stderr); + load = raised; + } + } + /* §102: peer-ahead NACK reopen — pin load at the follower's tip. */ + if (g_peer_ahead_force_load > 0u && + g_peer_ahead_force_load < mismatch_tick && + (g_peer_nack_floor == 0u || g_peer_ahead_force_load > g_peer_nack_floor) && + (netplay_snap_ring_has(g_snaps, g_peer_ahead_force_load) || + (g_agreed_valid && g_peer_ahead_force_load == g_agreed_through))) { + if (load != g_peer_ahead_force_load) { + fprintf(stderr, + "psxrecomp: rb begin peer-ahead force load %u→%u\n", + (unsigned)load, (unsigned)g_peer_ahead_force_load); + fflush(stderr); + load = g_peer_ahead_force_load; + } + } /* §51 Layer 3: chain continue must load exactly at the verified tip. */ if (g_ownership_chain && mismatch_tick > 0u) { uint32_t tip = mismatch_tick - 1u; @@ -6718,6 +7111,24 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) /* §47 ownership chain: aim at contiguous frontier, still SPAN-chunked. */ if (g_ownership_chain && g_ownership_chain_frontier > target) target = g_ownership_chain_frontier; + /* §102: peer-ahead light tip — short depth, no ownership SPAN chain. */ + if (g_peer_ahead_force_load > 0u) { + uint32_t max_t = load + RB_PEER_AHEAD_LIGHT_DEPTH; + if (target > max_t) { + fprintf(stderr, + "psxrecomp: rb begin peer-ahead light CAP target %u→%u " + "(load=%u depth=%u)\n", + (unsigned)target, (unsigned)max_t, (unsigned)load, + (unsigned)RB_PEER_AHEAD_LIGHT_DEPTH); + fflush(stderr); + target = max_t; + } + g_ownership_chain = 0; + g_ownership_chain_frontier = 0; + ownership_clear_chain_budget(); + g_peer_ahead_light_episode = 1; + g_peer_ahead_force_load = 0u; + } /* Chunk deep catch-up (post-abort agreed tip << live) so Replay never * walks the whole cooldown window in one episode. */ if (target > load + RB_MAX_RESIM_SPAN) { @@ -7191,26 +7602,26 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin } if (!g_rb) return; - /* §87: never reopen an epoch we already committed (tip-hold / ownership). + /* §87/§101: never reopen an epoch we already committed (tip-hold / + * ownership). NACK so the initiator aborts Seal instead of waiting 4s. * Soak: tie-break follow of zombie epoch=8 after tip-hold commit → * rb_baseline hang while peer already left → peer pcap_freeze. */ if (!rnet_rb_is_active(g_rb) && epoch == g_last_commit_epoch) { - fprintf(stderr, - "psxrecomp: rb follow REFUSED epoch=%u load=%u — already " - "committed (zombie epoch)\n", - (unsigned)epoch, (unsigned)load); - fflush(stderr); + char detail[96]; + snprintf(detail, sizeof(detail), "already committed (zombie epoch)"); + follow_refuse_nack(epoch, mismatch, load, target, slot, detail); return; } - /* §87: load strictly behind agreed tip rewinds into a dead timeline - * (soak: follow load=1808 after commit tip=1852). load==agreed is the - * ownership-chain skip-snap continue and stays legal. */ + /* §87/§101: load strictly behind agreed tip rewinds into a dead timeline + * (soak: follow load=1808 after commit tip=1852; session-136 load=3184 + * vs agreed tip=3200 → silent REFUSED ×100 + seal timeout). load==agreed + * is the ownership-chain skip-snap continue and stays legal. */ if (!rnet_rb_is_active(g_rb) && g_agreed_valid && load < g_agreed_through) { - fprintf(stderr, - "psxrecomp: rb follow REFUSED epoch=%u load=%u — behind " - "agreed tip=%u (zombie load)\n", - (unsigned)epoch, (unsigned)load, (unsigned)g_agreed_through); - fflush(stderr); + char detail[96]; + snprintf(detail, sizeof(detail), + "behind agreed tip=%u (zombie load)", + (unsigned)g_agreed_through); + follow_refuse_nack(epoch, mismatch, load, target, slot, detail); return; } if (rnet_rb_is_active(g_rb)) { @@ -7357,23 +7768,38 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin 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). */ + * (heal raises agreed when that tip aged out of the ring). + * §99 MEDIA_KF: invent-through-media leaves HC/agreed lagging the tip + * while choose_load raises to a near-tip media snap — same trust as + * missing-snap OK (host KF probe/transfer). Do not NACK. */ if (have_f && load > frontier) { - fprintf(stderr, - "psxrecomp: rb follow REFUSED epoch=%u load=%u — past " - "frontier=%u (unconfirmed tip)\n", - (unsigned)epoch, (unsigned)load, (unsigned)frontier); - fflush(stderr); - g_follow_nack_pending = 1; - g_follow_nack_epoch = epoch; - g_follow_nack_mismatch = mismatch; - g_follow_nack_load = load; - g_follow_nack_target = target; - g_follow_nack_slot = slot; - g_follow_nack_sends = 0; - send_follow_nack(epoch, mismatch, load, target, slot, 1); - g_follow_nack_sends = 1; - return; + int media_kf_ok = rb_media_kf_enabled() && + ((wire_flags & RNET_RB_SYNC_FLAG_MEDIA_KF) || + rb_fmv_tick_unsafe_for_episode(load) || + rb_fmv_tick_unsafe_for_episode(mismatch)); + if (media_kf_ok) { + fprintf(stderr, + "psxrecomp: rb follow MEDIA-KF allow load=%u past " + "frontier=%u (keyframe reconciles)\n", + (unsigned)load, (unsigned)frontier); + fflush(stderr); + } else { + fprintf(stderr, + "psxrecomp: rb follow REFUSED epoch=%u load=%u — past " + "frontier=%u (unconfirmed tip)\n", + (unsigned)epoch, (unsigned)load, (unsigned)frontier); + fflush(stderr); + g_follow_nack_pending = 1; + g_follow_nack_epoch = epoch; + g_follow_nack_mismatch = mismatch; + g_follow_nack_load = load; + g_follow_nack_target = target; + g_follow_nack_slot = slot; + g_follow_nack_sends = 0; + send_follow_nack(epoch, mismatch, load, target, slot, 1); + g_follow_nack_sends = 1; + return; + } } } memset(&corr, 0, sizeof(corr)); @@ -7560,8 +7986,9 @@ void psx_netplay_rb_pump(void) 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. */ + /* MotK: op=NACK is follow-NACK (missing load snap / past frontier / + * zombie load), 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]; @@ -7569,24 +7996,6 @@ void psx_netplay_rb_pump(void) 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; - /* §62: frontier AHEAD of the refused load = the peer's snap - * ring evicted that tick (not "peer behind"). Remember the - * floor so choose_load never reopens at/below it. */ - if (peer_frontier >= load && load > g_peer_nack_floor) { - g_peer_nack_floor = load; - fprintf(stderr, - "psxrecomp: rb peer NACK floor=%u " - "(peer frontier=%u ahead — snap evicted)\n", - (unsigned)g_peer_nack_floor, - (unsigned)peer_frontier); - fflush(stderr); - } /* 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; @@ -7594,6 +8003,102 @@ void psx_netplay_rb_pump(void) int do_live_realign = 0; uint32_t cls; uint32_t cool_n; + + /* §101/§102: peer frontier ahead of refused load = zombie load + * (or §62 snap eviction). Raise peer_resolved + agreed, abort + * Seal immediately, clear hc-fork, and reopen one short light + * tip at the follower tip (no ownership SPAN cascade). */ + if (peer_frontier >= load && load > 0u) { + int local_slot = g_b.local_slot ? *g_b.local_slot : 0; + int remote_slot = (local_slot == 0) ? 1 : 0; + uint32_t light_mm; + if (g_b.slot_count && *g_b.slot_count < 2) + remote_slot = local_slot; + if (load > g_peer_nack_floor) { + g_peer_nack_floor = load; + fprintf(stderr, + "psxrecomp: rb peer NACK floor=%u " + "(peer frontier=%u ahead — zombie/evicted)\n", + (unsigned)g_peer_nack_floor, + (unsigned)peer_frontier); + fflush(stderr); + } + apply_peer_resolved(peer_frontier); + if (!g_agreed_valid || g_agreed_through < peer_frontier) { + g_agreed_through = peer_frontier; + g_agreed_span_lo = peer_frontier; + g_agreed_valid = 1; + } + advertise_agreed_resolved(peer_frontier); + snprintf(why, sizeof(why), + "peer follow NACK load=%u (peer tip=%u ahead)", + (unsigned)load, (unsigned)peer_frontier); + g_abort_wire_class = RNET_RB_ABORT_CLASS_ABORT; + g_abort_wire_realign_tick = 0u; + abort_episode(why); + if (snap_was_applied) { + uint32_t t = peer_frontier; + realign_to = 0u; + if (g_snaps) { + for (;;) { + if (netplay_snap_ring_has(g_snaps, t)) { + realign_to = t; + break; + } + if (t == 0u) + break; + --t; + } + } + if (realign_to > 0u) { + fprintf(stderr, + "psxrecomp: rb NACK peer-ahead realign " + "load=%u → %u (frontier=%u)\n", + (unsigned)load, (unsigned)realign_to, + (unsigned)peer_frontier); + fflush(stderr); + schedule_live_realign(realign_to, why); + } + } else { + fprintf(stderr, + "psxrecomp: rb NACK keep-live peer-ahead " + "load=%u frontier=%u live=%u (no rewind)\n", + (unsigned)load, (unsigned)peer_frontier, + (unsigned)live_sim); + fflush(stderr); + } + clear_rewind_cooldown(why); + psx_netplay_hc_fork_recovery_clear(); + /* Immediate short light tip at peer tip — not hc-fork SPAN. */ + g_peer_ahead_force_load = peer_frontier; + light_mm = peer_frontier + 1u; + if (live_sim > light_mm) + light_mm = live_sim; + if (light_mm <= peer_frontier) + light_mm = peer_frontier + 1u; + fprintf(stderr, + "psxrecomp: rb peer-ahead light reopen " + "mismatch=%u load=%u depth≤%u\n", + (unsigned)light_mm, (unsigned)peer_frontier, + (unsigned)RB_PEER_AHEAD_LIGHT_DEPTH); + fflush(stderr); + if (!psx_netplay_rb_begin_rewind(light_mm, remote_slot)) { + g_peer_ahead_force_load = 0u; + g_peer_ahead_light_episode = 0; + fprintf(stderr, + "psxrecomp: rb peer-ahead light reopen " + "deferred (begin refused)\n"); + fflush(stderr); + } + continue; + } + + /* 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; snprintf(why, sizeof(why), "peer follow NACK load=%u", (unsigned)load); /* Peer refused load as past its frontier — usually because we * tip-held / advanced agreed_through without their POST ack @@ -7610,7 +8115,14 @@ void psx_netplay_rb_pump(void) * demote watermarks + prime HC so live hash_confirm cannot * immediately re-ADVANCE past the refused tip (soak: NACK * demote 3840 then `agreed ADVANCE 3840→3856` on the next - * pump from stale HC). */ + * pump from stale HC). + * + * §103 (session 142): deep demote Live 827→763 after a light + * tip NACK (snap never applied) forked cores @764 → forced + * MEDIA-KF ~3.7MB + ownership FMV refuse. When demote depth + * exceeds RB_NACK_DEEP_REALIGN_MAX and the snap was never + * applied: keep-live, soft-demote agreed to load-1 only, + * sticky-clamp peer_resolved to the follower frontier. */ if (g_snaps && demote > 0u) { uint32_t t; for (t = demote;; --t) { @@ -7622,6 +8134,58 @@ void psx_netplay_rb_pump(void) break; } } + { + int deep_keep_live = + !snap_was_applied && live_sim > load && demote > 0u && + live_sim > demote && + (live_sim - demote) > RB_NACK_DEEP_REALIGN_MAX; + if (deep_keep_live) { + uint32_t soft = + (load > 0u) ? (load - 1u) : 0u; + if (g_snaps && soft > 0u) { + uint32_t t; + for (t = soft;; --t) { + if (netplay_snap_ring_has(g_snaps, t)) { + soft = t; + break; + } + if (t == 0u) + break; + } + } + if (g_agreed_valid && g_agreed_through >= load) { + g_agreed_through = soft; + g_agreed_span_lo = soft; + } + /* Clamp peer tip to what the follower proved — do not + * drag local HC/agreed down to a stale frontier. */ + if (peer_frontier > 0u) { + if (g_peer_resolved_through == 0u || + g_peer_resolved_through > peer_frontier) + g_peer_resolved_through = peer_frontier; + g_peer_resolved_gate_expired = 0; + g_peer_resolved_wait_t0_ms = 0ull; + g_past_frontier_sticky = 1; + rnet_rb_demote_resolved_through(g_rb, + peer_frontier); + } + if (g_pin_valid && g_pin_tick >= load) + clear_baseline_pin(); + g_episode_baseline_matched = 0; + g_abort_wire_class = RNET_RB_ABORT_CLASS_ABORT; + g_abort_wire_realign_tick = 0u; + abort_episode(why); + fprintf(stderr, + "psxrecomp: rb NACK keep-live deep " + "frontier=%u soft_agreed=%u live=%u load=%u " + "(no demote Live; sticky peer gate)\n", + (unsigned)peer_frontier, (unsigned)soft, + (unsigned)live_sim, (unsigned)load); + fflush(stderr); + clear_rewind_cooldown(why); + continue; + } + } if (g_agreed_valid && g_agreed_through >= load) { g_agreed_through = demote; g_agreed_span_lo = demote; @@ -7646,7 +8210,8 @@ void psx_netplay_rb_pump(void) * §71: also realign when the snap was NEVER applied but * Live already walked past the refused load — keep-live * + cooldown promote-no-resim forks START/menu presses - * (soak: NACK load=880 live=887 → cores diverge @896). */ + * (soak: NACK load=880 live=887 → cores diverge @896). + * §103: deep cases handled above (keep-live). */ uint32_t hole_end = remote_wire_hole_end_after(demote, live_sim); if (hole_end > 0u) { diff --git a/runtime/src/psx_netplay_sched.c b/runtime/src/psx_netplay_sched.c index 364c40c3..226ac9b1 100644 --- a/runtime/src/psx_netplay_sched.c +++ b/runtime/src/psx_netplay_sched.c @@ -26,6 +26,10 @@ #include "psx_netplay_rb.h" #include "recomp_net/recomp_net.h" +/* §104/§105: auto-delay / lobby seed floors (shared with sync_delay). */ +#define RB_FORCE_TURN_DELAY_FLOOR 6 +#define RB_AUTO_DELAY_LOWER_FLOOR 5 + /* ------------------------------------------------------------------ */ /* Bridge into psx_netplay.c state */ /* ------------------------------------------------------------------ */ @@ -114,6 +118,22 @@ void np_sched_sync_delay_from_session(void) if (!s || !g_sb.input_delay) return; d = rnet_session_committed_delay(s); + /* §105: rollback never runs undersized D from lobby seed (session-149 + * started D=4 → invent storm → auto-delay ratchet to 7). Floor matches + * auto-delay lower bound; Force-TURN keeps its higher floor via adapt. */ + if (d >= 2u && d < (rnet_u8)RB_AUTO_DELAY_LOWER_FLOOR && + np_sched_real_delay_enabled()) { + static int s_floor_log; + if (!s_floor_log) { + fprintf(stderr, + "psxrecomp: rb delay floor %u → %u (rollback min D; " + "lobby seed was undersized)\n", + (unsigned)d, (unsigned)RB_AUTO_DELAY_LOWER_FLOOR); + fflush(stderr); + s_floor_log = 1; + } + d = (rnet_u8)RB_AUTO_DELAY_LOWER_FLOOR; + } if (d >= 2u && (int)d != *g_sb.input_delay) { fprintf(stderr, "psxrecomp: rb delay committed %d → %u (session)\n", *g_sb.input_delay, (unsigned)d); @@ -335,20 +355,21 @@ static uint32_t g_gap1_expire_invent_streak; #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: A/B classification — tip considered "advancing" if its last advance * was within this multiple of its own arrival-period EMA (x2 fixed point). */ #define RB_TIP_FRESH_MULT_X2 3u -/* §43: LAN micro-grace before a gap=1 invent. 2026-08-02 soak #4: with - * rtt_raw 0–1ms both peers still ran ~100% of admissions 1 tick ahead of - * the confirmed wire — a sub-frame PHASE offset, not latency. §29's - * immediate-invent is right for WAN (a transit delay cannot be waited out) - * but on LAN the row is in flight and lands within a few ms. Under §44 real - * delay this only fires once the cushion is empty. */ -#define RB_GAP1_LAN_RTT_MAX_MS 12u -#define RB_GAP1_LAN_GRACE_BASE_MS 3u -#define RB_GAP1_LAN_GRACE_CAP_MS 6u +/* §43/§104/§105/§107: micro-grace before a gap=1 invent. Session-149 invents + * were 100% remote_lead=-1. §105 waited only when RTT≤48 or tip-due (cap 12). + * Post-§105 soak: 1277/1305 host invents had rtt>48 (SFU ~75ms) → lan gate + * off → gap1_cap=0 → invent every tip tick. §107: always grace on gap=1 + * (non-FMV), scale ½RTT+base, LAN cap 12 / relay cap 20. */ +#define RB_GAP1_LAN_RTT_MAX_MS 48u +#define RB_GAP1_LAN_GRACE_BASE_MS 4u +#define RB_GAP1_LAN_GRACE_CAP_MS 12u +#define RB_GAP1_RELAY_GRACE_CAP_MS 20u +/* §104: runway (gap≥2) grace — was 8; guest RUNWAY_EMPTY invent was high. */ +#define RB_INVENT_RUNWAY_GRACE_CAP_MS 12u /* §28: tip arrival cadence — tracks how often the confirmed remote tip * (highest_remote_wire) actually advances, independent of whether admit @@ -954,17 +975,28 @@ static void np_scorecard_note_miss(rnet_u32 wire) if (s_counted_wire != wire) { s_counted_wire = wire; g_sc_miss_at_need++; - /* §57: feed the delay controller and arm the lateness timer — if we - * end up waiting this miss out, note_remote_hit measures how late - * the row was; if we invent instead, the pending flag is cleared at - * the invent exit (lateness then shows up as a mispredict age, not - * an arrival stat). */ + /* §57/§105: feed the delay controller and arm the lateness timer — + * if we wait this miss out, note_remote_hit measures how late the + * row was. If we invent instead, np_auto_delay_undo_invent_miss + * drops the controller sample (session-149: invent→miss 993‰ + * ratcheted D 4→7 without real arrival lateness). */ g_ad_miss++; g_ad_miss_pending = 1; g_ad_miss_t0_ms = sched_mono_ms(); } } +/* §105: invent is not an arrival miss — undo the controller sample so tip + * invent storms cannot raise D (mushy input with no cushion gain). */ +static void np_auto_delay_undo_invent_miss(void) +{ + if (g_ad_miss_pending) { + if (g_ad_miss > 0u) + g_ad_miss--; + g_ad_miss_pending = 0; + } +} + static void np_scorecard_sample(uint32_t now, rnet_u32 sim, rnet_u32 wire, const RNetSessionStats *st) { @@ -1160,9 +1192,8 @@ static void np_pcap_freeze_exit(void) #define RB_AUTO_DELAY_EVAL_MS 5000u #define RB_AUTO_DELAY_AGREE 3u #define RB_AUTO_DELAY_COOLDOWN_MS 30000u -/* Match launcher Force TURN Play floor — do not shrink below this mid-match - * when the session is relay-only (soak: auto delay 6→5 then invent/fork). */ -#define RB_FORCE_TURN_DELAY_FLOOR 6 +/* Delay floors: RB_FORCE_TURN_DELAY_FLOOR / RB_AUTO_DELAY_LOWER_FLOOR + * defined near top of file (§104/§105). */ static void np_auto_delay_tick(uint32_t now) { @@ -1240,7 +1271,10 @@ static void np_auto_delay_tick(uint32_t now) return; /* host proposes; guests follow DELAY_SYNC */ miss_per_mille = (uint32_t)(((uint64_t)miss * 1000u) / ticks); - if (miss_per_mille > 20u) { + /* §105: raise only on waited-out arrival pressure (late_n>0). Invent-only + * miss storms (session-149 lead_avg≈-2, late_n=1, miss=993‰) must not + * ratchet D — raising delay does not stop tip invent. */ + if (miss_per_mille > 20u && late_n > 0u) { uint32_t late_avg = late_n ? (late_sum / late_n) : 0u; int bump = (int)((late_avg + tick_ms - 1u) / tick_ms); if (bump < 1) @@ -1248,13 +1282,15 @@ static void np_auto_delay_tick(uint32_t now) if (bump > 2) bump = 2; target = d + bump; - } else if (miss_per_mille < 2u && lead_avg_x16 >= 2 * 16) { - target = d - 1; /* ≥2 spare ticks sustained — cushion oversized */ + } else if (miss_per_mille < 1u && lead_avg_x16 >= 3 * 16) { + /* §104: tighter lower bar (was miss<2‰ + lead≥2) — session-144 + * shrank D 5→4 under healthy fight cushion and fueled invent. */ + target = d - 1; } else { target = d; } { - int floor_d = 2; + int floor_d = RB_AUTO_DELAY_LOWER_FLOOR; if (g_sb.force_turn) floor_d = RB_FORCE_TURN_DELAY_FLOOR; if (target < floor_d) @@ -1666,26 +1702,35 @@ int np_sched_on_remote_miss(int slot, uint32_t sim, uint32_t wire, else g_admit_gap1_case_b++; gap1_cap = 0u; /* §29 default: no wait — classify only */ - /* §43: LAN micro-grace — wait a few ms for the in-flight row - * instead of inventing, when the link is provably fast or the - * next tip is due by cadence. Expiry still invents - * (np_gap1_note_expire_invent), so WAN behavior degrades to - * §29 with a bounded 6ms tax at worst. */ - if (case_a && !psx_netplay_rb_fmv_media_active()) { + /* §43/§105/§107: micro-grace for gap=1 tip invent (lead typically + * -1). Session-149: invents were 100% remote_lead=-1 / case_b — + * the §104 "healthy lead ≥ D-1" arm never fired. §105 waited + * only when RTT≤LAN gate or tip-due — SFU soaks (rtt~75) skipped + * grace and invented every tick. §107: always wait + * min(½RTT+base, lan_cap|relay_cap) outside FMV; tip_due still + * uses the short LAN cap so a due tip does not stall a full + * relay budget. Expiry still invents. */ + if (!psx_netplay_rb_fmv_media_active()) { uint32_t rtt_raw = 0u; int lan; int tip_due; + uint32_t cap; (void)np_invent_rtt_ms(&rtt_raw); lan = (rtt_raw <= RB_GAP1_LAN_RTT_MAX_MS); tip_due = (tip_age != 0xffffffffu && tip_age + RB_GAP1_LAN_GRACE_CAP_MS >= period); - if (lan || tip_due) { - gap1_cap = rtt_raw / 2u + RB_GAP1_LAN_GRACE_BASE_MS; - if (gap1_cap > RB_GAP1_LAN_GRACE_CAP_MS) - gap1_cap = RB_GAP1_LAN_GRACE_CAP_MS; - } + /* Prefer trusted POST sample; synth/zero still get base. */ + gap1_cap = rtt_raw / 2u + RB_GAP1_LAN_GRACE_BASE_MS; + if (lan || tip_due) + cap = RB_GAP1_LAN_GRACE_CAP_MS; + else + cap = RB_GAP1_RELAY_GRACE_CAP_MS; + if (gap1_cap > cap) + gap1_cap = cap; + if (gap1_cap < RB_GAP1_LAN_GRACE_BASE_MS) + gap1_cap = RB_GAP1_LAN_GRACE_BASE_MS; } - /* §98: during FMV media invent immediately (no LAN micro-grace). */ + /* §98: during FMV media invent immediately (no micro-grace). */ } if (gap1_cap != 0u) { if (np_invent_grace_stall_ex(slot, wire, gap1_cap, 0)) { @@ -1736,7 +1781,8 @@ int np_sched_on_remote_miss(int slot, uint32_t sim, uint32_t wire, 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); - g_ad_miss_pending = 0; /* §57: invented — lateness timer no longer valid */ + /* §105: invent is not arrival lateness — drop controller miss sample. */ + np_auto_delay_undo_invent_miss(); if (reason_out) *reason_out = invent_reason; np_sched_clear_admit_stall(); /* inventing — not a barrier stall */