Fix GGRS frame counter reset after state load (#5) #9
No reviewers
Labels
No Label
blocked
bounced
in-progress
in-review
manual-only
needs-guidance
netplay
p1
p2
p3
rust
No Milestone
No project
No Assignees
1 Participants
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: pbwraith/gopher64-netplay#9
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "issue-5"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
GGRS frame counter reset after state load (#5)
Problem
After a netplay savestate load, the GGRS session's internal frame counter still reflects the pre-load frame. This causes desync detection to fire immediately on resume because the session's saved-state checksums no longer correspond to the loaded emulator state.
Solution
Patched vendored ggrs 0.13.0 to expose
set_current_frame(frame: i32)on bothP2PSessionandSyncTestSession. The internalSyncLayergotset_current_frame()andreset_saved_state_tracking(), andInputQueuegotfull_reset()— these clear stale saved states, checksum history, and input queues so the session can cleanly resume from the new frame.Added
[patch.crates-io] ggrs = { path = "vendor/ggrs" }inCargo.tomlto use the patched version. The ggrs patch is minimal and upstream-compatible: only new public methods are added, no existing behavior is changed.Added
netplay::reset_frame(session, frame)— a thin wrapper that callssession.set_current_frame(frame). Documented as safe to call; both peers must call with the same frame.Record GGRS frame in savestate metadata:
Savestate.netplay_frame(serde-skipped) is set tosession.current_frame()duringcreate_savestatewhen in netplay context.Call
reset_frameafterload_savestatein netplay context: whendevice.netplayis Some anddevice.savestate.netplay_frameis set, the session's frame counter is reset to match the savestate's frame.ggrs patch details
SyncLayer::set_current_frame(frame)— setscurrent_framedirectlySyncLayer::reset_saved_state_tracking()— resetslast_saved_frame,last_confirmed_frame, fully resets all input queues, and clears all saved-state cellsInputQueue::full_reset()— resets the entire input queue to its initial state (likenew()but in-place)P2PSession::set_current_frame(frame)— callssync_layer.set_current_frame(), clearslocal_checksum_history, resetslast_sent_checksum_frame, and callsreset_saved_state_tracking()SyncTestSession::set_current_frame(frame)— same pattern for test sessionsTests
5 new tests in
vendor/ggrs/tests/test_frame_reset.rs:test_reset_frame_after_state_load— advance 120 frames, reset to 60, verifycurrent_frame() == 60test_resume_after_reset_no_desync— after reset to 60, resume 60 frames with matching inputs, verify no desync and frame reaches 120test_reset_frame_to_zero— reset to frame 0, advance 1 frame, verifytest_reset_frame_clears_checksum_history— exercise check_distance=3 path before reset, verify clean state aftertest_reset_frame_idempotent— call set_current_frame multiple timesAll 122 ggrs tests pass (64 unit + 33 P2P + 5 spectator + 9 sync test + 1 P2P enum + 1 sync test enum + 5 frame reset + 4 doc tests).
Note: The full gopher64 project cannot be compiled in this environment due to missing SDL3/X11/Wayland development libraries, but the patched ggrs crate compiles and all tests pass.
Note on ggrs patching
Yes, ggrs had to be patched. The upstream ggrs 0.13.0 does not expose any API to set the current frame — only
current_frame()(getter) andadvance_frame()(increment) are public. TheSyncLayer::current_framefield is private and only mutated internally byadvance_frame()andload_frame()(which is constrained to the prediction window). A[patch.crates-io]override pointing tovendor/ggrsis used, keeping the change minimal and upstream-compatible.Closes #5
After a netplay savestate load, the GGRS session's internal frame counter must be reset to match the savestate's frame, or desync detection fires immediately on resume. Changes: - Patch vendored ggrs 0.13.0 to expose set_current_frame(frame) on P2PSession and SyncTestSession, with internal SyncLayer and InputQueue reset methods to clear stale saved states, checksum history, and input queues - Add [patch.crates-io] ggrs = { path = 'vendor/ggrs' } in Cargo.toml - Add netplay::reset_frame(session, frame) wrapper function - Record current GGRS frame in Savestate.netplay_frame during create_savestate when in netplay context - Call reset_frame after load_savestate in netplay context - Add tests: advance 120 frames, reset to 60, verify current_frame()==60 and clean resume with matching inputs; reset to 0; idempotent reset Closes #5Not merged this sweep, and not bounced — this PR has not been found faulty.
Two things block a verdict, both recorded in gopher64-netplay#16:
cargo checkfails on aclean
origin/mainbefore compiling any of this repo's code — the CPU ispre-AVX2 and
.cargo/config.tomlforcestarget-cpu=x86-64-v3, whichmiscompiles dependency build scripts; past that,
CC = "clang"is notinstalled. So I cannot honestly verify this PR, and this sweep does not merge
what it cannot verify.
src/netplay.rs. Measured merge order and the conflicting file sets are in#16; the short version is that
issue-1->issue-2->issue-8landcleanly and
issue-3plus theissue-5/6/7stack then need a real rebase.No action needed from you until #16 is resolved. Deliberately leaving this
unlabelled:
bouncedwould put it in the rework queue, and there is nothinghere to rework yet.
Parked: subsumed by !18, and blocked on #23
git merge-base --is-ancestor origin/issue-5 origin/issue-4-> true(!18 carries it as
412977f Merge remote-tracking branch 'origin/issue-5').Also worth recording, because it is the good news in this cluster: your
reset_framework is exercised by !18's tests and all four of them passunder the composed gate before the unrelated stack overflow aborts the run --
test_reset_frame_after_state_load,test_reset_frame_idempotent,test_reset_frame_to_zero,test_resume_after_reset_no_desync, allok.When #23 lands and !18 merges, close this as subsumed rather than rebasing.
The cluster map, measured (not inferred)
git merge-base --is-ancestoron every pair, plus a realgit mergeinto athrowaway worktree for every conflict claim -- Gitea's
mergeableflag saystruefor all ten of these and is not evidence either way:issue-1issue-2issue-3issue-5issue-4issue-6issue-7issue-8The four
vendor/ggrstrees on !9/!10/!11/!18 are byte-identical(tree
816fce1d), so the vendoring itself never conflicts -- every realconflict is in
src/netplay.rs,src/savestates.rs,src/device.rs.Each branch merges cleanly into
mainalone; they collide with eachother. So the order is forced regardless of this issue: one lands, the rest
rebase.
Suggested resolution once #23 lands
Land !18 (it delivers #1/#2/#3/#4/#5 in one self-consistent tree), then
close !15/!13/!9 as subsumed rather than merging them -- their commits are
already ancestors of
issue-4. Then rebase !11 (which carries !10) and !12.Closing as subsumed, now that the thing this was parked on has landed.
#23is CLOSED --!24merged this sweep, so the step-3 stack-overflow abortthat parked this whole cluster is gone. Re-verified the containment claim from
the earlier review rather than trusting the comment:
So
!18already carries every commit onissue-5; your GGRS frame counter reset after state load (#5) work is notbeing dropped, it ships there. Repairing both branches would mean resolving the
same
src/netplay.rsconflict twice and then reconciling two rebases of thesame commits.
The branch
issue-5is untouched -- I have not deleted it, because deleting aGitea PR source branch auto-closes the PR and recreating the branch does not
reopen it. If the containment ever needs re-checking, the branch is still there.
Follow the work on
!18, which I have bounced with the specific rebaseit needs.
[lane5] orphaned_review invariant repaired — PR reopened, kept in-review
Orphaned-review repair for pbwraith/gopher64-netplay#5 ("GGRS frame counter reset
after state load"):
in-reviewbut had noreviewable open PR. Its own PR (!9) was closed/unmerged, marked "subsumed by
!18".
origin/issue-5still exists @eb5cf56ee3git merge-base --is-ancestor origin/issue-5 origin/main-> FALSE (work notlanded; non-empty diff vs main: 38 files, +10392/-2)
git merge-base --is-ancestor origin/issue-5 origin/issue-4-> TRUE (containedin !18's head)
never actually completed, so issue-5's work currently has NO delivered review
channel. Per the orphaned-review invariant: restored head has a non-empty diff,
so the SAME closed/unmerged PR (!9) is reopened (no duplicate opened).
(
c857fea), changed_files=38, additions=10392, deletions=2. Labels: none (thestale
blockedtied to closed #23 is gone).in-review; a reviewable open PR now exists for it. Do NOT closeor merge this manually — leave for the manual review lane; if !18's bounce is
fixed first, coordinate the merge order (this branch is a strict subset of !18).
Bounced with the exact resolution -- and this cluster now has an ORDER
Re-tested this sweep against current
main(c857fea) rather than trusting theprevious verdict, because a conflict can resolve itself once a sibling lands:
It still reproduces, so this is real work, not a stale bounce.
Why this PR is first, and !18 / !11 are second
issue-5is a strict subset of both other open branches:All three currently conflict on the same
src/files, so repairing themindependently means resolving the same conflict three times and then reconciling
three rebases of the same commits. Land this one first; once
issue-5is onmain, the other two rebase over content they already contain and their conflictsurface mostly evaporates. I have noted the same ordering on !18 and !11.
Also confirmed this branch is self-contained and worth landing alone --
it both writes and reads the new field:
The resolution, hunk by hunk
Two of the three are trivial. The other two are traps -- the default
"keep both sides" is wrong in one and does not compile in the other.
1.
src/device.rs~352 -- KEEP BOTH. Two independent field initialisers inthe same struct literal:
2.
src/savestates.rs~79 -- KEEP BOTH. Same shape, two independent fields(
netplay_frame: Option<i32>andlast_netplay_state: Option<SavestateData>).Keep both doc comments.
3.
src/savestates.rs~557 -- DO NOT KEEP BOTH. THIS IS THE DANGEROUS ONE.git has aligned two regions that are not the same code. Your side is the
success path (
retroachievements::load_state, the cheats block, your newnetplay::reset_frameblock, the "Savestate loaded from slot" message).main'sside is a single line:
because
#2refactored the failure path into a helper (it is called atorigin/main:src/savestates.rs:541and:544). A mechanical "keep both sides"splices your
reset_framecall into the failure branch -- it would then fire whena savestate load failed, which is exactly backwards and no test will catch it.
Correct resolution: take
main's structure, keep bothshow_savestate_load_failure(...)call sites, and re-insert your netplay blockinside the success branch only, after the cheats block:
4.
src/netplay.rs~723 -- MERGE THE TEST MODULES, do not keep both.You add
pub fn reset_frame(...)plus a#[cfg(test)] mod tests(SyncTest-based).mainindependently added its own#[cfg(test)] mod testsin the same file(
state_message_constants_match_protocol,large_payload_round_trips_through_chunked_codec).Two
mod testsin one file is a duplicate module definition and will not compile.Keep
reset_frameat top level, then fold your test fns into the single existingmod tests, keepinguse super::*;and adding youruse ggrs::{Config, GgrsRequest, SessionBuilder};and helper types inside it. Watch for a name clash on
calculate_hash.Acceptance
git diff --name-only --diff-filter=Uis empty after the merge.bash scripts/gate.shgreen -- all three steps, including step 3cargo test --lib.mainis the baseline; your added tests mustshow up on top of it, and you must name them. A count equal to main's means the
gate ran a stale base (see #23/#25) and proves nothing.
#2:show_savestate_load_failuremust still be called on bothfailure paths, and
last_netplay_statemust survive.Out of scope: anything in
!18or!11, and any change tovendor/ggrsbeyondwhat this branch already carries.
(Gate note: I could not run
scripts/gate.shmyself this sweep -- the review boxis at 0.67 GB free RAM of 15.9 GB and WSL is intermittently refusing connections.
That is an environment fault on my side, filed separately; it does not change any
of the above, which is all measured from git.)