nx_rollback_session.nx source
↩ module page · 370 lines · 15616 B
1// nx_rollback_session.nx -- GGPO-class rollback netcode session handle.
2//
3// Composes the five foundation primitives:
4// - nx_sim_snapshot : ring of last-N state snapshots
5// - nx_state_delta_codec : 16-byte input packets via bit-packed varint
6// - nx_frame_hash : per-frame state hash for desync detection
7// - nx_zigzag_varint : signed-integer wire encoding
8// - (caller's sim primitives -- whatever advances the game state)
9//
10// ...into a single session handle that game code holds for one
11// multiplayer match. Modelled directly on GGPO's session callbacks
12// (Cannon 2006 / pond3r/ggpo) but adapted to NishiLang's callback-
13// less calling convention: storage-only, caller drives the sim loop.
14//
15// Caller pattern per tick:
16// 1. read local input -> save via session_save_local_input
17// 2. snapshot state -> save via session_save_state
18// 3. advance sim using local + last-known remote input
19// 4. compute frame_hash -> save via session_save_hash
20// 5. transmit (tick, local_input, frame_hash) to peer
21// 6. when (peer_tick, peer_input) arrives:
22// a. session_set_remote_input(peer_tick, peer_input)
23// b. if peer_tick < current_tick:
24// - session_restore_state(peer_tick) into sim state
25// - re-advance from peer_tick to current_tick with
26// freshly-corrected remote input each tick
27// 7. when (peer_tick, peer_hash) arrives:
28// a. our_hash = session_get_hash(peer_tick)
29// b. verdict = nx_frame_hash_compare(...)
30// c. if MISMATCH -> trigger desync recovery
31//
32// Session layout (i64 cells in a flat block):
33// h[0] = state_size
34// h[1] = max_rollback_frames (snapshot/input ring depth)
35// h[2] = current_tick (highest local frame saved)
36// h[3] = oldest_pending_remote_frame
37// (= highest local frame we have NOT yet received remote input for;
38// before we can rollback to frame F we need remote input at F)
39// h[4] = snapshot_ring_ptr (nx_snapshot_ring_alloc handle)
40// h[5] = local_input_ring_ptr (parallel i64 array, depth slots)
41// h[6] = remote_input_ring_ptr (parallel i64 array, depth slots)
42// h[7] = remote_input_present_ring_ptr (1/0 per slot)
43// h[8] = local_hash_ring_ptr (i64 hash per frame)
44// h[9] = local_hash_present_ring_ptr (1/0 per slot)
45//
46// Inputs are encoded as a single i64. Callers using >64 bits of input
47// state can pack into multiple sessions or use a wider encoding -- v1
48// stays i64 to match the GGPO 16-byte input convention (2x i64 = 16B).
49
50// nx_safety_envelope:
51// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
52// sil_target: SIL1
53// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
54// verdict: NOT_YET_EVALUATED
55
56import "nx_syscalls.nx"
57import "nx_tier.nx"
58import "nx_sim_snapshot.nx"
59import "nx_frame_hash.nx"
60
61const NX_RBS_HDR_STATE_SIZE: nx_int = 0
62const NX_RBS_HDR_MAX_ROLLBACK: nx_int = 1
63const NX_RBS_HDR_CURRENT_TICK: nx_int = 2
64const NX_RBS_HDR_OLDEST_PEND_REM: nx_int = 3
65const NX_RBS_HDR_SNAPSHOT_RING: nx_int = 4
66const NX_RBS_HDR_LOCAL_INPUT: nx_int = 5
67const NX_RBS_HDR_REMOTE_INPUT: nx_int = 6
68const NX_RBS_HDR_REMOTE_PRESENT: nx_int = 7
69const NX_RBS_HDR_LOCAL_HASH: nx_int = 8
70const NX_RBS_HDR_HASH_PRESENT: nx_int = 9
71const NX_RBS_HDR_SIZE: nx_int = 10
72
73// Result codes for the rollback-trigger query.
74const NX_RBS_NO_ROLLBACK: nx_int = 0
75const NX_RBS_NEED_ROLLBACK: nx_int = 1
76const NX_RBS_REMOTE_TOO_OLD: nx_int = 2
77
78// ===== Allocation ====================================================
79
80func nx_rollback_session_new(state_size: nx_int, max_rollback: nx_int) -> *i64 {
81 if state_size <= 0 { return 0 as *i64 }
82 if max_rollback <= 0 { return 0 as *i64 }
83 let h: *i64 = (sys_mmap(NX_RBS_HDR_SIZE * 8)) as *i64
84
85 let snap: *i64 = nx_snapshot_ring_alloc(state_size, max_rollback)
86 if (snap as i64) == 0 { return 0 as *i64 }
87
88 let loc_in: *i64 = (sys_mmap(max_rollback * 8)) as *i64
89 let rem_in: *i64 = (sys_mmap(max_rollback * 8)) as *i64
90 let rem_pre: *i64 = (sys_mmap(max_rollback * 8)) as *i64
91 let lhash: *i64 = (sys_mmap(max_rollback * 8)) as *i64
92 let hpre: *i64 = (sys_mmap(max_rollback * 8)) as *i64
93
94 var i: nx_int = 0
95 while i < max_rollback {
96 loc_in[i] = 0
97 rem_in[i] = 0
98 rem_pre[i] = 0
99 lhash[i] = 0
100 hpre[i] = 0
101 i = i + 1
102 }
103
104 h[NX_RBS_HDR_STATE_SIZE] = state_size
105 h[NX_RBS_HDR_MAX_ROLLBACK] = max_rollback
106 h[NX_RBS_HDR_CURRENT_TICK] = -1
107 h[NX_RBS_HDR_OLDEST_PEND_REM] = 0
108 h[NX_RBS_HDR_SNAPSHOT_RING] = snap as i64
109 h[NX_RBS_HDR_LOCAL_INPUT] = loc_in as i64
110 h[NX_RBS_HDR_REMOTE_INPUT] = rem_in as i64
111 h[NX_RBS_HDR_REMOTE_PRESENT] = rem_pre as i64
112 h[NX_RBS_HDR_LOCAL_HASH] = lhash as i64
113 h[NX_RBS_HDR_HASH_PRESENT] = hpre as i64
114 return h
115}
116
117// ===== Slot indexing ================================================
118
119func _rbs_slot(h: *i64, frame: nx_int) -> nx_int {
120 let depth: nx_int = h[NX_RBS_HDR_MAX_ROLLBACK]
121 var s: nx_int = frame % depth
122 if s < 0 { s = s + depth }
123 return s
124}
125
126// ===== Local-side save APIs =========================================
127
128func nx_rollback_session_save_local_input(s: *i64, frame: nx_int, input: i64) -> nx_int {
129 if (s as i64) == 0 { return -1 }
130 if frame < 0 { return -1 }
131 let slot: nx_int = _rbs_slot(s, frame)
132 let ring: *i64 = (s[NX_RBS_HDR_LOCAL_INPUT]) as *i64
133 ring[slot] = input
134 let cur: nx_int = s[NX_RBS_HDR_CURRENT_TICK]
135 if frame > cur { s[NX_RBS_HDR_CURRENT_TICK] = frame }
136 return 0
137}
138
139func nx_rollback_session_save_state(s: *i64, frame: nx_int, src: *u8) -> nx_int {
140 if (s as i64) == 0 { return -1 }
141 let ring: *i64 = (s[NX_RBS_HDR_SNAPSHOT_RING]) as *i64
142 return nx_snapshot_ring_save(ring, frame, src)
143}
144
145func nx_rollback_session_save_hash(s: *i64, frame: nx_int, hash: i64) -> nx_int {
146 if (s as i64) == 0 { return -1 }
147 if frame < 0 { return -1 }
148 let slot: nx_int = _rbs_slot(s, frame)
149 let h_ring: *i64 = (s[NX_RBS_HDR_LOCAL_HASH]) as *i64
150 let pre_ring: *i64 = (s[NX_RBS_HDR_HASH_PRESENT]) as *i64
151 h_ring[slot] = hash
152 pre_ring[slot] = 1
153 return 0
154}
155
156// ===== Remote-input arrival =========================================
157//
158// Returns one of:
159// NX_RBS_NO_ROLLBACK -- remote input was for current_tick or later;
160// no rollback needed; sim will pick it up
161// on the next tick.
162// NX_RBS_NEED_ROLLBACK -- remote input was for a past frame the sim
163// already advanced beyond. Caller must call
164// nx_rollback_session_restore_state(frame)
165// and re-advance from `frame` to current_tick.
166// NX_RBS_REMOTE_TOO_OLD-- remote input was older than oldest snapshot
167// still in the ring; rollback impossible
168// (caller should resync from scratch or
169// disconnect peer).
170
171func nx_rollback_session_set_remote_input(s: *i64, frame: nx_int, input: i64) -> nx_int {
172 if (s as i64) == 0 { return NX_RBS_REMOTE_TOO_OLD }
173 if frame < 0 { return NX_RBS_REMOTE_TOO_OLD }
174 let depth: nx_int = s[NX_RBS_HDR_MAX_ROLLBACK]
175 let cur_tick: nx_int = s[NX_RBS_HDR_CURRENT_TICK]
176
177 if frame > cur_tick {
178 // Future frame; just store -- sim hasn't advanced this far yet.
179 let slot: nx_int = _rbs_slot(s, frame)
180 let in_ring: *i64 = (s[NX_RBS_HDR_REMOTE_INPUT]) as *i64
181 let pre_ring: *i64 = (s[NX_RBS_HDR_REMOTE_PRESENT]) as *i64
182 in_ring[slot] = input
183 pre_ring[slot] = 1
184 return NX_RBS_NO_ROLLBACK
185 }
186
187 // frame <= cur_tick; check if it's still in the snapshot window.
188 let snap_ring: *i64 = (s[NX_RBS_HDR_SNAPSHOT_RING]) as *i64
189 let oldest: nx_int = nx_snapshot_ring_oldest(snap_ring)
190 if oldest < 0 { return NX_RBS_REMOTE_TOO_OLD }
191 if frame < oldest { return NX_RBS_REMOTE_TOO_OLD }
192
193 let slot: nx_int = _rbs_slot(s, frame)
194 let in_ring: *i64 = (s[NX_RBS_HDR_REMOTE_INPUT]) as *i64
195 let pre_ring: *i64 = (s[NX_RBS_HDR_REMOTE_PRESENT]) as *i64
196
197 let prev_present: i64 = pre_ring[slot]
198 let prev_input: i64 = in_ring[slot]
199 in_ring[slot] = input
200 pre_ring[slot] = 1
201
202 // If this is the FIRST time we've seen remote input for this frame,
203 // or if the input value differs from what we used during sim,
204 // rollback is needed.
205 if prev_present == 0 { return NX_RBS_NEED_ROLLBACK }
206 if prev_input != input { return NX_RBS_NEED_ROLLBACK }
207 return NX_RBS_NO_ROLLBACK
208}
209
210// ===== Restore from snapshot ========================================
211
212func nx_rollback_session_restore_state(s: *i64, frame: nx_int, dst: *u8) -> nx_int {
213 if (s as i64) == 0 { return -1 }
214 let ring: *i64 = (s[NX_RBS_HDR_SNAPSHOT_RING]) as *i64
215 return nx_snapshot_ring_restore(ring, frame, dst)
216}
217
218// ===== Read helpers =================================================
219
220func nx_rollback_session_get_local_input(s: *i64, frame: nx_int) -> i64 {
221 if (s as i64) == 0 { return 0 }
222 if frame < 0 { return 0 }
223 let slot: nx_int = _rbs_slot(s, frame)
224 let ring: *i64 = (s[NX_RBS_HDR_LOCAL_INPUT]) as *i64
225 return ring[slot]
226}
227
228func nx_rollback_session_get_remote_input(s: *i64, frame: nx_int) -> i64 {
229 if (s as i64) == 0 { return 0 }
230 if frame < 0 { return 0 }
231 let slot: nx_int = _rbs_slot(s, frame)
232 let ring: *i64 = (s[NX_RBS_HDR_REMOTE_INPUT]) as *i64
233 return ring[slot]
234}
235
236func nx_rollback_session_has_remote_input(s: *i64, frame: nx_int) -> nx_int {
237 if (s as i64) == 0 { return 0 }
238 if frame < 0 { return 0 }
239 let slot: nx_int = _rbs_slot(s, frame)
240 let pre_ring: *i64 = (s[NX_RBS_HDR_REMOTE_PRESENT]) as *i64
241 return pre_ring[slot] as nx_int
242}
243
244func nx_rollback_session_get_hash(s: *i64, frame: nx_int) -> i64 {
245 if (s as i64) == 0 { return 0 }
246 if frame < 0 { return 0 }
247 let slot: nx_int = _rbs_slot(s, frame)
248 let h_ring: *i64 = (s[NX_RBS_HDR_LOCAL_HASH]) as *i64
249 let pre_ring: *i64 = (s[NX_RBS_HDR_HASH_PRESENT]) as *i64
250 if pre_ring[slot] == 0 { return 0 }
251 return h_ring[slot]
252}
253
254func nx_rollback_session_current_tick(s: *i64) -> nx_int {
255 if (s as i64) == 0 { return -1 }
256 return s[NX_RBS_HDR_CURRENT_TICK]
257}
258
259// ===== Self-test ====================================================
260
261// A tiny deterministic faux-simulation: state is a single i64 counter;
262// each tick adds (local_input + remote_input) to it. This lets the
263// smoke verify rollback re-simulation matches "ran with the correct
264// inputs from the start" exactly.
265
266func _faux_advance(state: i64, local_in: i64, remote_in: i64) -> i64 {
267 return state + local_in + remote_in
268}
269
270func main() -> i64 {
271 let STATE_BYTES: nx_int = 8
272 let DEPTH: nx_int = 8
273
274 let s: *i64 = nx_rollback_session_new(STATE_BYTES, DEPTH)
275 if (s as i64) == 0 { return __syscall(93, 1, 0, 0, 0, 0, 0) }
276
277 // T1: empty session reports current_tick = -1.
278 if nx_rollback_session_current_tick(s) != -1 { return __syscall(93, 2, 0, 0, 0, 0, 0) }
279
280 // T2: drive 6 ticks with local=1, remote=2 each tick. Expected
281 // final state = 6 * (1 + 2) = 18.
282 let state_buf: *i64 = (sys_mmap(8)) as *i64
283 state_buf[0] = 0
284 var t: nx_int = 0
285 while t < 6 {
286 // Local pre-tick:
287 nx_rollback_session_save_local_input(s, t, 1)
288 nx_rollback_session_save_state(s, t, state_buf as *u8)
289 let remote: i64 = nx_rollback_session_get_remote_input(s, t)
290 // For T2 we already have remote input set externally; simulate
291 // having received it before tick.
292 state_buf[0] = _faux_advance(state_buf[0], 1, 2)
293 // Save hash of post-tick state.
294 let h: i64 = nx_frame_hash_region(state_buf as *u8, 8)
295 nx_rollback_session_save_hash(s, t, h)
296 // Treat remote as already-known for this run (skip the rollback
297 // path; we test it in T3).
298 nx_rollback_session_set_remote_input(s, t, 2)
299 t = t + 1
300 }
301 if state_buf[0] != 18 { return __syscall(93, 10, 0, 0, 0, 0, 0) }
302 if nx_rollback_session_current_tick(s) != 5 { return __syscall(93, 11, 0, 0, 0, 0, 0) }
303
304 // T3: ROLLBACK CORRECTION. Build a fresh session; advance 4 ticks
305 // assuming remote_input = 0 (we haven't received it yet); then
306 // remote arrives saying it was actually 3 for tick 2, triggering
307 // rollback. Verify post-rollback state matches "ran from start
308 // with correct values".
309 let s2: *i64 = nx_rollback_session_new(STATE_BYTES, DEPTH)
310 let sb2: *i64 = (sys_mmap(8)) as *i64
311 sb2[0] = 0
312 // Initial 4 ticks with assumed remote=0:
313 var t2: nx_int = 0
314 while t2 < 4 {
315 nx_rollback_session_save_local_input(s2, t2, 1)
316 nx_rollback_session_save_state(s2, t2, sb2 as *u8)
317 let assumed_remote: i64 = 0
318 sb2[0] = _faux_advance(sb2[0], 1, assumed_remote)
319 t2 = t2 + 1
320 }
321 // Expected: 4 ticks * (1+0) = 4.
322 if sb2[0] != 4 { return __syscall(93, 20, 0, 0, 0, 0, 0) }
323
324 // Remote correction for tick 2 arrives: it was actually 3.
325 let verdict: nx_int = nx_rollback_session_set_remote_input(s2, 2, 3)
326 if verdict != NX_RBS_NEED_ROLLBACK { return __syscall(93, 21, 0, 0, 0, 0, 0) }
327
328 // Caller-driven rollback: restore state at tick 2, re-advance.
329 nx_rollback_session_restore_state(s2, 2, sb2 as *u8)
330 // sb2[0] should now be the pre-tick-2 state which was 2 (after
331 // ticks 0 and 1 with assumed remote=0: 0 -> 1 -> 2).
332 if sb2[0] != 2 { return __syscall(93, 22, 0, 0, 0, 0, 0) }
333
334 // Re-advance ticks 2 and 3 with corrected remote=3 for tick 2 and
335 // remote=0 (unknown) for tick 3.
336 var rb: nx_int = 2
337 while rb < 4 {
338 let local_in: i64 = nx_rollback_session_get_local_input(s2, rb)
339 let rem_in: i64 = nx_rollback_session_get_remote_input(s2, rb)
340 let has: nx_int = nx_rollback_session_has_remote_input(s2, rb)
341 var use_rem: i64 = 0
342 if has == 1 { use_rem = rem_in }
343 sb2[0] = _faux_advance(sb2[0], local_in, use_rem)
344 rb = rb + 1
345 }
346 // Expected: state at tick 2 was 2; advance tick 2 (local=1, remote=3) -> 6;
347 // advance tick 3 (local=1, remote=0) -> 7.
348 if sb2[0] != 7 { return __syscall(93, 23, 0, 0, 0, 0, 0) }
349
350 // T4: remote input arriving for a future frame returns NO_ROLLBACK.
351 let v_future: nx_int = nx_rollback_session_set_remote_input(s2, 100, 5)
352 if v_future != NX_RBS_NO_ROLLBACK { return __syscall(93, 30, 0, 0, 0, 0, 0) }
353 if nx_rollback_session_get_remote_input(s2, 100) != 5 { return __syscall(93, 31, 0, 0, 0, 0, 0) }
354
355 // T5: idempotent remote input (same value twice for same frame)
356 // does NOT trigger rollback the second time.
357 nx_rollback_session_set_remote_input(s2, 2, 9) // first time, fresh value -> NEED_ROLLBACK
358 let v_again: nx_int = nx_rollback_session_set_remote_input(s2, 2, 9)
359 if v_again != NX_RBS_NO_ROLLBACK { return __syscall(93, 40, 0, 0, 0, 0, 0) }
360
361 // T6: hash save/retrieve.
362 nx_rollback_session_save_hash(s, 3, 0xCAFEBABEDEADBEEF)
363 if nx_rollback_session_get_hash(s, 3) != 0xCAFEBABEDEADBEEF { return __syscall(93, 50, 0, 0, 0, 0, 0) }
364
365 // T7: hash on never-saved frame returns 0 (not present).
366 let s3: *i64 = nx_rollback_session_new(STATE_BYTES, DEPTH)
367 if nx_rollback_session_get_hash(s3, 5) != 0 { return __syscall(93, 60, 0, 0, 0, 0, 0) }
368
369 return 0
370}