Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
ff994b2
runtime: land BB-defer cycle batching on netplay pin
TechnicallyComputers Jul 22, 2026
1237f27
runtime: fix starvation-ring throttle symbol names
TechnicallyComputers Jul 22, 2026
3d6b0f3
launcher: enumerate LAN IPs for host advertise dropdown
TechnicallyComputers Jul 22, 2026
2634a88
bump
TechnicallyComputers Jul 22, 2026
fc647f0
Merge remote-tracking branch 'origin' into feat/host-lobby-lan-ip-enum
TechnicallyComputers Jul 22, 2026
f8c9af3
Netplay: LAN/online lobby split, rematch soft-return, port conflict h…
TechnicallyComputers Jul 23, 2026
401ec6c
bump
TechnicallyComputers Jul 23, 2026
65d254c
Add MAX_PLAYERS build flag, multitap, and N-slot netplay wiring.
TechnicallyComputers Jul 23, 2026
0030238
Point nested recomp-net/ui at published 5P feature tips.
TechnicallyComputers Jul 23, 2026
20cfcc1
Defer offline multitap until after game entry.
TechnicallyComputers Jul 23, 2026
626a7d6
build: drop lib/recomp-ui; games vendor UI at repo root
TechnicallyComputers Jul 23, 2026
9b44327
launcher BIOS + ROM prompt
TechnicallyComputers Jul 24, 2026
0897932
Update recomp-net
TechnicallyComputers Jul 24, 2026
fc51dcc
bump sub
TechnicallyComputers Jul 24, 2026
0a1414f
Update main.cpp
TechnicallyComputers Jul 24, 2026
50334ae
psx 5p networking patch
TechnicallyComputers Jul 24, 2026
2431c77
Netplay 5p MotK sync: lobby client relay/bind fixes on feat/ui-5p.
TechnicallyComputers Jul 24, 2026
e2e3bbb
Fix accidental conflict markers from MotK stash merge on feat/ui-5p.
TechnicallyComputers Jul 24, 2026
75640d3
Update recomp-net
TechnicallyComputers Jul 24, 2026
2be7ce3
Update recomp-net
TechnicallyComputers Jul 24, 2026
ed397fc
rebump
TechnicallyComputers Jul 24, 2026
a4593df
Update psx_netplay.c
TechnicallyComputers Jul 24, 2026
2c37219
Update main.cpp
TechnicallyComputers Jul 24, 2026
2bba3cd
netplay patch
TechnicallyComputers Jul 24, 2026
80d2070
netplay patch
TechnicallyComputers Jul 24, 2026
5e75c84
Merge origin/feat/ui-5p into feat/ui-5p
TechnicallyComputers Jul 24, 2026
88290fa
delay sync patches
TechnicallyComputers Jul 25, 2026
acc085b
Merge branch 'feat/ui-5p' of https://github.com/mstan/psxrecomp into …
TechnicallyComputers Jul 25, 2026
ab99a6a
delay sync patches
TechnicallyComputers Jul 25, 2026
2b704eb
Merge branch 'feat/ui-5p' of https://github.com/mstan/psxrecomp into …
TechnicallyComputers Jul 25, 2026
f864d85
Update recomp-net
TechnicallyComputers Jul 25, 2026
256c0a7
Merge branch 'feat/ui-5p' of https://github.com/mstan/psxrecomp into …
TechnicallyComputers Jul 25, 2026
5f00ee5
mingw netplay patch & build
TechnicallyComputers Jul 25, 2026
59e8f8a
mingw netplay patch & build
TechnicallyComputers Jul 25, 2026
63bea43
Update recomp-net
TechnicallyComputers Jul 25, 2026
fbad0be
launcher performance, load state performance, game launch performance
TechnicallyComputers Jul 27, 2026
0944dfc
Update recomp-net
TechnicallyComputers Jul 27, 2026
227d395
ICE optimizations, connectivity, latency polling, save/loadstate perf…
TechnicallyComputers Jul 28, 2026
550153c
Update recomp-net
TechnicallyComputers Jul 28, 2026
7b1b890
ICE optimizations, connectivity, latency polling, save/loadstate perf…
TechnicallyComputers Jul 28, 2026
54a6916
PGO workflow CI hardening
TechnicallyComputers Jul 28, 2026
6cfe713
workflow and cross platform update
TechnicallyComputers Jul 28, 2026
3b72058
Update recomp-net
TechnicallyComputers Jul 28, 2026
e13e197
Update recomp-net
TechnicallyComputers Jul 28, 2026
437eee7
Update recomp-net
TechnicallyComputers Jul 28, 2026
070fc80
Merge origin/master into feat/ui-5p.
TechnicallyComputers Jul 29, 2026
92efb5c
no mods in MP, minor patches
TechnicallyComputers Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions docs/SAVESTATE_LOAD_PERFORMANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# Savestate load performance — solo and netplay

Status: **shipped** (MotK / Bomberman runtime + vendored `recomp-net`).
Scope: host-side restore hitch after F-key / protocol load, and lockstep
correctness when both peers restore together.

This is not the CD “load window” turbo work in [`LOAD_TIME_ZERO.md`](LOAD_TIME_ZERO.md).
That doc is about *in-game* disc loads. This one is about **user savestate
restore** (`.pst` / `boot_state_*`) feeling instant and staying in sync under
delay-netplay.

---

## Symptoms that drove the work

| Mode | Symptom | Host FPS | Root class |
|------|---------|----------|------------|
| Solo | Picture frozen ~1–several seconds after load; worse on 2nd/3rd load of same slot | ~60 | Giant `psx_advance_cycles` / IRQ blackout / present latch |
| Netplay | Host `applied, waiting for guest…`; guest stuck at `applying after transfer…` then disconnect | N/A | INPUT / admit deadlock across apply ↔ ready |
| Netplay spam | Hang when mashing load while holding directions | N/A | Same tip-starvation class; tip runway emptied by `hard_resync` |

---

## Solo restore path

Call chain (HLE scheduler required):

```
F-key / debug → savestate_request_load
→ savestate_poll (block leader)
→ boot_state_load
→ psx_cycles_resync_after_restore
→ interrupts_resync_after_restore
→ cdrom_accelerate_after_savestate
→ psx_frontend_on_savestate_loaded
→ psx_scheduler_resume_at(pc) /* longjmp; does not return */
```

### 1. Anchor host-only cycle deadlines (`psx_cycles_resync_after_restore`)

**Bug:** `gte_ts_done` / `muldiv_ts_done` and load-absorb fields live on
`CPUState` but are **not** in the savestate wire format. After a warm load,
`psx_cycle_count` rewinds to the snapshot while those deadlines still sit on
the pre-load live timeline. The next GTE / muldiv stall then advances
`(live_ts − restored_cycle)` in one shot — tens of millions of cycles, many
nested presents, `chk(e=0)`, sticky VBlank, no draw.

**Fix:** After restore, set `gte_ts_done` / `muldiv_ts_done` to the restored
`psx_cycle_count`, clear absorb/fudge state, and re-anchor device sync /
idle-skip latches.

- `runtime/src/psx_cycles.c` — `psx_cycles_resync_after_restore`
- Called from `savestate_poll` immediately after a successful `boot_state_load`

### 2. Clear absolute IRQ cooldowns (`interrupts_resync_after_restore`)

**Bug:** `post_exception_cooldown_until` is an absolute guest-cycle stamp.
Leaving it in the future after a clock rewind blocks every IRQ (including
VBlank) until the restored clock “catches up” — freeze for however long the
user played past the save, while host FPS stays ~60.

**Fix:** Zero the cooldown and related exception / VBlank phase bookkeeping.

### 3. Cap CD second-response debt (`cdrom_accelerate_after_savestate`)

Restored / imminent CD command delays (ReadTOC, Init, seeks) can freeze the
picture for ~1s after the restored frame presents. A short post-load boost
window clamps outstanding delays so the display recovers immediately.

### 4. Frontend present / audio re-anchor (`psx_frontend_on_savestate_loaded`)

After restore:

- Force present (`s_force_present_after_load`) so a disabled-display blank
latch or smooth-60 duplicate does not hide the restored frame.
- Invalidate GL present-dirty early-out (`gl_renderer_invalidate_present`) —
critical on **2nd+ load of the same slot**, where the framebuffer can match
the last swap and skip `SwapWindow`.
- Reset frame pacer + FPS baseline (admit / hitch can leave deadlines in the past).
- Resync guest-cycle→audio sample budgeting.

### 5. Smaller / faster `.pst` I/O (boot_state v4 zlib)

Large sections may be zlib-compressed on save (`BS_SEC` pad bit0). Shrinks
disk and helps slow storage; older readers still accept uncompressed v3.

### 6. No post-load request cooldown

A former 12-frame (earlier 60-frame) “load ignored” debounce in
`request_load_inner` was removed. It was only key-repeat padding and could
break netplay: protocol path entered `LOAD_APPLYING` while the cooldown
silently refused to stage `s_load_pending`.

Overlapping loads are gated elsewhere:

- Solo: single `s_load_pending` slot (last request wins).
- Netplay: `np_xfer_busy()` until the barrier clears.

### Diagnostics

`PSX_POST_LOAD_PROBE=1` arms a short post-load window that logs advance size,
IRQ check outcomes, dirty/idle/horizon, and host ms (`main.cpp` + cycle
attribution). Use this when a freeze returns; do **not** pause the runtime to
measure — extend the probe / rings instead.

---

## Netplay load path

Host-only initiate (`psx_netplay_request_load`). Guest follows via STATE_* on
the same UDP/relay path as inputs.

### High-level sequence

```
host: hash PROBE(op=LOAD, size, crc)
guest: REPLY match?
yes → both stage savestate_request_load_protocol + LOAD_APPLYING
no → host STATE_BEGIN/CHUNK → guest writes sandbox
→ both stage load in np_apply_ready_state (transfer complete)
both: admit while savestate_pending (guest cycles → savestate_poll)
both: local LOADED → LOAD_READY
host: ready PROBE(op=LOAD, size=0, NP_LOAD_READY_CRC)
guest: ACK when applied
both: hard_resync + prime_delay_inputs (once)
both: stay in LOAD_READY until try_admit succeeds → resume lockstep
```

### Correctness / performance rules (do not regress)

1. **Stage apply only when both peers have the bytes**
- Do **not** call `savestate_request_load_protocol` on the host at SEND
begin. Host would restore during transfer, enter ready early, and starve
the guest of tips needed for `savestate_poll`.
- Hash-miss: both stage in `np_apply_ready_state` after transfer.
- Hash-hit: both stage when the probe reply is handled.

2. **Keep INPUT flowing until mutual ready**
- `LOAD_APPLYING` / enter `LOAD_READY`: do **not** set
`input_send_suppress`.
- Suppress only inside `np_commit_load_sync` for the
`hard_resync` → `prime_delay_inputs` window (prime clears suppress).
- App barrier (`psx_netplay_poll_admit`) freezes sim after apply /
during ready; that is separate from INPUT emission.

3. **Ready probe must not stall INPUT (`recomp-net`)**
- `rnet_session_state_probe` with `LOAD` + `total_size == 0` sets
`state_stall_sim = 0` (same as SAVE coord).
- Previously `state_stall_sim = 1` blocked `send_input_bundle` on the host
as soon as the first peer finished apply — same deadlock as suppress,
worse under spam because `hard_resync` leaves only ~D tip frames of
runway.
- Hash probes (`total_size != 0`) still stall until finish / transfer.

4. **`hard_resync` + prime once at mutual ready, not at apply**
- Clearing rings / `sim_tick → 0` at apply time lets the later peer wipe
the earlier peer’s tip and stall resume.
- After mutual ready: clear local **and** remote rings, prime neutral
delay prefix, wait for `try_admit` (fresh tip + INPUT_CONFIRM) before
dropping `LOAD_READY`.

5. **Peer disconnect during barrier**
- `psx_netplay_peer_disconnected(0)` while `psx_netplay_in_load_barrier()`
so rx silence for a multi-second restore does not soft-exit to lobby.
BYE / `peer_gone` still honored.

6. **Spam loads**
- Host ignores new requests while `np_xfer_busy()`.
- No savestate-layer frame cooldown (see solo §6).

### Where the code lives

| Piece | Location |
|-------|----------|
| App xfer / barrier | `runtime/src/psx_netplay.c` |
| Session stall / tip / hard_resync | `lib/recomp-net/src/session/rnet_session.c` |
| Protocol notes | `lib/recomp-net/docs/protocol.md` |
| Staging API | `savestate_request_load_protocol` (bypasses netplay user block) |

---

## Expected log lines (healthy netplay load)

```
netplay load slot=N — hash probe (…)
netplay load slot=N — hashes match, applying… # or transferring / applying after transfer
savestate: LOADED slot N …
netplay load slot=N — applied, waiting for guest… # host
netplay guest load slot=N — applied, waiting for host… # or ready acked
netplay load slot=N — mutual ready, waiting lockstep…
netplay load slot=N — peer ready, resuming lockstep
```

Stuck on `waiting for guest` / guest never leaving `applying…` → tip
starvation (rules 1–3). Sticky `INPUT desync … stalled` → confirm/hash
disagreement after resume (inspect tip epoch / history collisions).

---

## Related docs

- [`LOAD_TIME_ZERO.md`](LOAD_TIME_ZERO.md) — in-game CD load wall-time (different problem).
- [`CYCLE_TIMING_ARCH.md`](CYCLE_TIMING_ARCH.md) — cycle / GTE stall model.
- `lib/recomp-net/docs/protocol.md` — STATE_PROBE / post-load ready rendezvous.
- `runtime/include/boot_state.h` — `.pst` section version / zlib flags.
4 changes: 4 additions & 0 deletions docs/internal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,7 @@ behind a specific subsystem.
## Overlay cache internals
- [`SLJIT_PERSIST_CACHE.md`](SLJIT_PERSIST_CACHE.md) — persisted overlay-shard
cache design.

## Savestate / netplay
- [`../SAVESTATE_LOAD_PERFORMANCE.md`](../SAVESTATE_LOAD_PERFORMANCE.md) —
solo restore hitch fixes and netplay load barrier / INPUT rules.
2 changes: 1 addition & 1 deletion lib/recomp-net
137 changes: 93 additions & 44 deletions recompiler/src/config_loader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,21 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path&
rt.deadzone = static_cast<int>(n);
rt.has_deadzone = true;
}
if (ct.contains("multitap_port")) {
const auto n = toml::find<int64_t>(ct, "multitap_port");
if (n != 1 && n != 2)
throw std::runtime_error(fmt::format(
"[controller] multitap_port must be 1 or 2, got {}", n));
rt.multitap_port = static_cast<int>(n);
rt.has_multitap_port = true;
}
// LEGACY per-game pad-config opt-in (default modern). Tomba sets this so
// its launcher Hybrid mode's analog<->digital type flip doesn't make libpad
// manufacture a disconnect; no other title is affected. Full history and
// the removal plan live in psxrecomp runtime/src/sio.c (g_pad_legacy_cfg).
if (ct.contains("legacy_pad_config")) {
rt.legacy_pad_config = toml::find<bool>(ct, "legacy_pad_config");
}
if (ct.contains("anti_deadzone")) {
const auto n = toml::find<int64_t>(ct, "anti_deadzone");
if (n < 0 || n > 32767)
Expand Down Expand Up @@ -2077,39 +2092,56 @@ UserSettings load_user_settings(const fs::path& path) {
}
if (doc.contains("controller")) {
const toml::value& ct = toml::find(doc, "controller");
if (ct.contains("p1_device")) try_get([&]{
const auto v = toml::find<std::string>(ct, "p1_device");
if (!v.empty()) { s.p1_device = v; s.has_p1_device = true; }
});
if (ct.contains("p2_device")) try_get([&]{
const auto v = toml::find<std::string>(ct, "p2_device");
if (!v.empty()) { s.p2_device = v; s.has_p2_device = true; }
});
// Legacy boolean form first (true->analog, false->digital); the new
// string `*_mode` keys override when present.
if (ct.contains("p1_analog")) try_get([&]{
s.p1_mode = toml::find<bool>(ct, "p1_analog")
? PAD_MODE_ANALOG : PAD_MODE_DIGITAL;
s.has_p1_mode = true;
});
if (ct.contains("p2_analog")) try_get([&]{
s.p2_mode = toml::find<bool>(ct, "p2_analog")
? PAD_MODE_ANALOG : PAD_MODE_DIGITAL;
s.has_p2_mode = true;
});
if (ct.contains("p1_mode")) try_get([&]{
s.p1_mode = pad_mode_from_string(
toml::find<std::string>(ct, "p1_mode"), PAD_MODE_HYBRID);
s.has_p1_mode = true;
});
if (ct.contains("p2_mode")) try_get([&]{
s.p2_mode = pad_mode_from_string(
toml::find<std::string>(ct, "p2_mode"), PAD_MODE_HYBRID);
s.has_p2_mode = true;
});
static const char* kDevKeys[] = {
"p1_device", "p2_device", "p3_device", "p4_device", "p5_device"};
static const char* kModeKeys[] = {
"p1_mode", "p2_mode", "p3_mode", "p4_mode", "p5_mode"};
static const char* kDzKeys[] = {
"p1_deadzone", "p2_deadzone", "p3_deadzone", "p4_deadzone",
"p5_deadzone"};
static const char* kAnalogKeys[] = {
"p1_analog", "p2_analog", "p3_analog", "p4_analog", "p5_analog"};
for (int i = 0; i < UserSettings::kMaxControllerPlayers; ++i) {
if (ct.contains(kDevKeys[i])) try_get([&]{
const auto v = toml::find<std::string>(ct, kDevKeys[i]);
if (!v.empty()) {
s.p_device[i] = v;
s.has_p_device[i] = true;
}
});
// Legacy boolean form first (true->analog, false->digital); the
// string `*_mode` keys override when present.
if (ct.contains(kAnalogKeys[i])) try_get([&]{
s.p_mode[i] = toml::find<bool>(ct, kAnalogKeys[i])
? PAD_MODE_ANALOG : PAD_MODE_DIGITAL;
s.has_p_mode[i] = true;
});
if (ct.contains(kModeKeys[i])) try_get([&]{
s.p_mode[i] = pad_mode_from_string(
toml::find<std::string>(ct, kModeKeys[i]), PAD_MODE_HYBRID);
s.has_p_mode[i] = true;
});
if (ct.contains(kDzKeys[i])) try_get([&]{
const auto n = toml::find<int64_t>(ct, kDzKeys[i]);
if (n >= 0 && n <= 32767) {
s.p_deadzone[i] = (int)n;
s.has_p_deadzone[i] = true;
}
});
}
if (ct.contains("deadzone")) try_get([&]{
const auto n = toml::find<int64_t>(ct, "deadzone");
if (n >= 0 && n <= 32767) { s.deadzone = (int)n; s.has_deadzone = true; }
if (n >= 0 && n <= 32767) {
s.deadzone = (int)n;
s.has_deadzone = true;
/* Legacy global: fill any slot that was not given pN_deadzone. */
for (int i = 0; i < UserSettings::kMaxControllerPlayers; ++i) {
if (!s.has_p_deadzone[i]) {
s.p_deadzone[i] = s.deadzone;
s.has_p_deadzone[i] = true;
}
}
}
});
}
return s;
Expand Down Expand Up @@ -2205,19 +2237,36 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) {
f << "enable2 = " << (s.memcard2_enabled ? "true" : "false") << "\n";
}

if (s.has_p1_device || s.has_p2_device || s.has_p1_mode || s.has_p2_mode ||
s.has_deadzone) {
f << "\n[controller]\n";
if (s.has_p1_device)
f << "p1_device = \"" << s.p1_device << "\"\n";
if (s.has_p1_mode)
f << "p1_mode = \"" << pad_mode_to_string(s.p1_mode) << "\"\n";
if (s.has_p2_device)
f << "p2_device = \"" << s.p2_device << "\"\n";
if (s.has_p2_mode)
f << "p2_mode = \"" << pad_mode_to_string(s.p2_mode) << "\"\n";
if (s.has_deadzone)
f << "deadzone = " << s.deadzone << "\n";
{
bool any_ctrl = s.has_deadzone;
for (int i = 0; i < UserSettings::kMaxControllerPlayers; ++i) {
if (s.has_p_device[i] || s.has_p_mode[i] || s.has_p_deadzone[i])
any_ctrl = true;
}
if (any_ctrl) {
static const char* kDevKeys[] = {
"p1_device", "p2_device", "p3_device", "p4_device", "p5_device"};
static const char* kModeKeys[] = {
"p1_mode", "p2_mode", "p3_mode", "p4_mode", "p5_mode"};
static const char* kDzKeys[] = {
"p1_deadzone", "p2_deadzone", "p3_deadzone", "p4_deadzone",
"p5_deadzone"};
f << "\n[controller]\n";
for (int i = 0; i < UserSettings::kMaxControllerPlayers; ++i) {
if (s.has_p_device[i])
f << kDevKeys[i] << " = \"" << s.p_device[i] << "\"\n";
if (s.has_p_mode[i])
f << kModeKeys[i] << " = \""
<< pad_mode_to_string(s.p_mode[i]) << "\"\n";
if (s.has_p_deadzone[i])
f << kDzKeys[i] << " = " << s.p_deadzone[i] << "\n";
}
/* Keep a global deadzone= for older readers (mirrors P1). */
if (s.has_deadzone || s.has_p_deadzone[0])
f << "deadzone = "
<< (s.has_p_deadzone[0] ? s.p_deadzone[0] : s.deadzone)
<< "\n";
}
}

if (s.has_language) {
Expand Down
Loading