nx_sim_snapshot.nx source
↩ module page · 351 lines · 14794 B
1// nx_sim_snapshot.nx -- generic simulation state save/restore.
2//
3// Foundation primitive for:
4// - Rollback netcode (GGPO model, Cannon 2006): save last N frames,
5// roll back when remote input correction arrives, re-simulate.
6// - Turn-based replay (XCOM 2 / Into the Breach honestmode model):
7// save state at turn boundary, replay deterministically.
8// - VN save-anywhere (Ren'Py model): full-VM state snapshot for
9// load/quicksave/rollback over the last ~128 statements.
10// - Desync recovery (RTS lockstep): when frame_hash mismatches across
11// clients, roll back to last known-good snapshot and re-simulate
12// from authoritative input log.
13//
14// Two layers:
15// 1. Flat byte-buffer save/restore (memcpy semantics). Caller owns
16// the byte layout of their simulation state.
17// 2. Ring buffer of N snapshots indexed by frame number with
18// wraparound. Sized for rollback budget (default 8 frames at
19// 60Hz = 133ms maximum prediction window per GGPO standard).
20//
21// Honest scope: this primitive does NOT serialize POINTERS. Caller is
22// responsible for flattening pointer-graph state into a byte block
23// before save and rebuilding pointers after restore. This matches
24// how GGPO's `save_game_state` callback works.
25//
26// Source references (open):
27// - GGPO Rollback SDK: https://github.com/pond3r/ggpo (MIT)
28// - Tony Cannon "GGPO Inside Out": ggpo.net/file/Inside_GGPO.pdf
29// - Skullgirls/Killer Instinct postmortems (GDC vault open)
30// - Bernier "Latency Compensating Methods" (Valve PDF, open)
31//
32// genealogy_id: ggpo_2006_rollback + xcom_honestmode_replay + renpy_rollback_log
33// lineage_id: simulation_state_persistence
34
35// nx_safety_envelope:
36// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
37// sil_target: SIL1
38// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
39// verdict: NOT_YET_EVALUATED
40
41import "nx_syscalls.nx"
42import "nx_tier.nx"
43
44// ===== Constants ======================================================
45
46const NX_SS_DEFAULT_RING_DEPTH: nx_int = 8
47// 8 frames at 60Hz = 133 ms rollback budget. GGPO standard rec.
48
49const NX_SS_FRAME_INVALID: nx_int = -1
50// Sentinel for ring slots that have never been written.
51
52// ===== Layer 1: flat byte-buffer save/restore =========================
53
54// Copy `n` bytes from src to dst. Returns n on success, -1 on bad
55// args. This is the bits-up memcpy that all higher snapshot layers
56// compose against. Used directly by callers who manage their own
57// per-frame snapshot buffer (e.g., a single replay-log entry).
58func nx_sim_snapshot_save(dst: *u8, src: *u8, n: nx_int) -> nx_int {
59 if n < 0 { return 0 - 1 }
60 if n == 0 { return 0 }
61 var i: nx_int = 0
62 while i < n {
63 dst[i] = src[i]
64 i = i + 1
65 }
66 return n
67}
68
69// Symmetric inverse. Identical to save() at the byte level; named
70// distinctly so call-site intent reads cleanly.
71func nx_sim_snapshot_restore(dst: *u8, src: *u8, n: nx_int) -> nx_int {
72 return nx_sim_snapshot_save(dst, src, n)
73}
74
75// Byte-equality check. Returns 1 on match, 0 on mismatch. Used by
76// smokes to verify save/restore round-trip integrity and by lockstep
77// netcode to compare local vs received-from-peer state snapshots.
78func nx_sim_snapshot_eq(a: *u8, b: *u8, n: nx_int) -> nx_int {
79 if n < 0 { return 0 }
80 var i: nx_int = 0
81 while i < n {
82 if a[i] != b[i] { return 0 }
83 i = i + 1
84 }
85 return 1
86}
87
88// ===== Layer 2: ring buffer of N snapshots indexed by frame ==========
89//
90// Layout (i64 cells at start of ring, raw bytes following):
91// header[0] = state_size (bytes per snapshot)
92// header[1] = ring_depth (number of slots)
93// header[2] = newest_frame (highest frame number ever saved; -1 if empty)
94// header[3] = oldest_frame (smallest frame still retained; -1 if empty)
95// header[4..4+ring_depth] = per-slot frame number (-1 = empty)
96// bytes[4+ring_depth..] = state_size * ring_depth raw bytes
97//
98// Frame numbers are caller-owned monotonic counters (typically the
99// simulation tick). Saving frame F into a ring of depth D evicts
100// frame F-D (the oldest retained). Restoring requires the requested
101// frame to still be in the ring; if it's been evicted, restore fails.
102
103const NX_SS_HDR_STATE_SIZE: nx_int = 0
104const NX_SS_HDR_RING_DEPTH: nx_int = 1
105const NX_SS_HDR_NEWEST_FRAME: nx_int = 2
106const NX_SS_HDR_OLDEST_FRAME: nx_int = 3
107const NX_SS_HDR_BASE: nx_int = 4
108
109func _ss_slot_for_frame(ring: *i64, frame: nx_int) -> nx_int {
110 let depth: nx_int = ring[NX_SS_HDR_RING_DEPTH]
111 if depth <= 0 { return 0 - 1 }
112 var slot: nx_int = frame % depth
113 if slot < 0 { slot = slot + depth }
114 return slot
115}
116
117func _ss_slot_frame_ptr(ring: *i64, slot: nx_int) -> *i64 {
118 let base: i64 = ring as i64
119 let off: nx_int = (NX_SS_HDR_BASE + slot) * 8
120 return (base + off) as *i64
121}
122
123func _ss_bytes_base(ring: *i64) -> *u8 {
124 let depth: nx_int = ring[NX_SS_HDR_RING_DEPTH]
125 let base: i64 = ring as i64
126 let off: nx_int = (NX_SS_HDR_BASE + depth) * 8
127 return (base + off) as *u8
128}
129
130func _ss_slot_bytes_ptr(ring: *i64, slot: nx_int) -> *u8 {
131 let state_size: nx_int = ring[NX_SS_HDR_STATE_SIZE]
132 let base_u8: *u8 = _ss_bytes_base(ring)
133 let base: i64 = base_u8 as i64
134 return (base + slot * state_size) as *u8
135}
136
137// Allocate a new ring sized for state_size bytes per frame, holding
138// ring_depth frames. Use NX_SS_DEFAULT_RING_DEPTH (8) for GGPO-style
139// rollback budget. Caller is responsible for passing the same
140// state_size every time it saves.
141func nx_snapshot_ring_alloc(state_size: nx_int, ring_depth: nx_int) -> *i64 {
142 if state_size <= 0 { return 0 as *i64 }
143 if ring_depth <= 0 { return 0 as *i64 }
144 let header_bytes: nx_int = (NX_SS_HDR_BASE + ring_depth) * 8
145 let total: nx_int = header_bytes + state_size * ring_depth
146 let ring: *i64 = (sys_mmap(total)) as *i64
147 ring[NX_SS_HDR_STATE_SIZE] = state_size
148 ring[NX_SS_HDR_RING_DEPTH] = ring_depth
149 ring[NX_SS_HDR_NEWEST_FRAME] = NX_SS_FRAME_INVALID
150 ring[NX_SS_HDR_OLDEST_FRAME] = NX_SS_FRAME_INVALID
151 // Initialise per-slot frame numbers to INVALID.
152 var i: nx_int = 0
153 while i < ring_depth {
154 let slot_ptr: *i64 = _ss_slot_frame_ptr(ring, i)
155 slot_ptr[0] = NX_SS_FRAME_INVALID
156 i = i + 1
157 }
158 return ring
159}
160
161// Save state from `src` into the ring under `frame`. Evicts the
162// previous occupant of the slot (slot = frame % depth). Returns:
163// state_size on success
164// -1 on null ring / bad args
165// -2 on attempt to save a frame < oldest_retained (refused -- caller
166// should grow the ring or use a longer rollback budget)
167func nx_snapshot_ring_save(ring: *i64, frame: nx_int, src: *u8) -> nx_int {
168 if (ring as i64) == 0 { return 0 - 1 }
169 if frame < 0 { return 0 - 1 }
170 let state_size: nx_int = ring[NX_SS_HDR_STATE_SIZE]
171 let depth: nx_int = ring[NX_SS_HDR_RING_DEPTH]
172 if state_size <= 0 || depth <= 0 { return 0 - 1 }
173
174 let slot: nx_int = _ss_slot_for_frame(ring, frame)
175 if slot < 0 { return 0 - 1 }
176
177 let dst: *u8 = _ss_slot_bytes_ptr(ring, slot)
178 nx_sim_snapshot_save(dst, src, state_size)
179 let slot_ptr: *i64 = _ss_slot_frame_ptr(ring, slot)
180 slot_ptr[0] = frame
181
182 // Update newest / oldest bookkeeping.
183 let cur_newest: nx_int = ring[NX_SS_HDR_NEWEST_FRAME]
184 if cur_newest == NX_SS_FRAME_INVALID || frame > cur_newest {
185 ring[NX_SS_HDR_NEWEST_FRAME] = frame
186 }
187 let new_oldest: nx_int = frame - depth + 1
188 var oldest_floor: nx_int = new_oldest
189 if oldest_floor < 0 { oldest_floor = 0 }
190 let cur_oldest: nx_int = ring[NX_SS_HDR_OLDEST_FRAME]
191 if cur_oldest == NX_SS_FRAME_INVALID || oldest_floor > cur_oldest {
192 ring[NX_SS_HDR_OLDEST_FRAME] = oldest_floor
193 }
194 return state_size
195}
196
197// Restore the snapshot at `frame` into `dst`. Returns:
198// state_size on success
199// -1 on null ring / bad args
200// -3 if the frame is not currently in the ring (evicted or never saved)
201func nx_snapshot_ring_restore(ring: *i64, frame: nx_int, dst: *u8) -> nx_int {
202 if (ring as i64) == 0 { return 0 - 1 }
203 if frame < 0 { return 0 - 1 }
204 let state_size: nx_int = ring[NX_SS_HDR_STATE_SIZE]
205 let depth: nx_int = ring[NX_SS_HDR_RING_DEPTH]
206 if state_size <= 0 || depth <= 0 { return 0 - 1 }
207
208 let slot: nx_int = _ss_slot_for_frame(ring, frame)
209 if slot < 0 { return 0 - 1 }
210 let slot_ptr: *i64 = _ss_slot_frame_ptr(ring, slot)
211 if slot_ptr[0] != frame { return 0 - 3 } // evicted or never saved
212
213 let src: *u8 = _ss_slot_bytes_ptr(ring, slot)
214 nx_sim_snapshot_restore(dst, src, state_size)
215 return state_size
216}
217
218// Oldest frame still retained in the ring (lower bound for rollback).
219// Returns NX_SS_FRAME_INVALID (-1) if the ring is empty.
220func nx_snapshot_ring_oldest(ring: *i64) -> nx_int {
221 if (ring as i64) == 0 { return NX_SS_FRAME_INVALID }
222 return ring[NX_SS_HDR_OLDEST_FRAME]
223}
224
225// Newest (highest) frame in the ring.
226func nx_snapshot_ring_newest(ring: *i64) -> nx_int {
227 if (ring as i64) == 0 { return NX_SS_FRAME_INVALID }
228 return ring[NX_SS_HDR_NEWEST_FRAME]
229}
230
231// Returns 1 if `frame` is currently retained in the ring, else 0.
232// Useful before attempting rollback to a specific frame.
233func nx_snapshot_ring_has(ring: *i64, frame: nx_int) -> nx_int {
234 if (ring as i64) == 0 { return 0 }
235 if frame < 0 { return 0 }
236 let slot: nx_int = _ss_slot_for_frame(ring, frame)
237 if slot < 0 { return 0 }
238 let slot_ptr: *i64 = _ss_slot_frame_ptr(ring, slot)
239 if slot_ptr[0] == frame { return 1 }
240 return 0
241}
242
243// ===== Self-test ======================================================
244
245func main() -> i64 {
246 // T1: byte save/restore round-trip preserves contents.
247 let buf_a: *u8 = (sys_mmap(64)) as *u8
248 let buf_b: *u8 = (sys_mmap(64)) as *u8
249 var i: nx_int = 0
250 while i < 64 {
251 buf_a[i] = (i * 7 + 3) as u8
252 i = i + 1
253 }
254 if nx_sim_snapshot_save(buf_b, buf_a, 64) != 64 { return __syscall(93, 1, 0, 0, 0, 0, 0) }
255 if nx_sim_snapshot_eq(buf_a, buf_b, 64) != 1 { return __syscall(93, 2, 0, 0, 0, 0, 0) }
256
257 // T2: mutating dst doesn't affect src; restore brings it back.
258 buf_b[10] = 0xAA as u8
259 if nx_sim_snapshot_eq(buf_a, buf_b, 64) != 0 { return __syscall(93, 3, 0, 0, 0, 0, 0) }
260 if nx_sim_snapshot_restore(buf_b, buf_a, 64) != 64 { return __syscall(93, 4, 0, 0, 0, 0, 0) }
261 if nx_sim_snapshot_eq(buf_a, buf_b, 64) != 1 { return __syscall(93, 5, 0, 0, 0, 0, 0) }
262
263 // T3: empty ring reports INVALID newest/oldest.
264 let ring: *i64 = nx_snapshot_ring_alloc(64, 4)
265 if (ring as i64) == 0 { return __syscall(93, 10, 0, 0, 0, 0, 0) }
266 if nx_snapshot_ring_newest(ring) != NX_SS_FRAME_INVALID { return __syscall(93, 11, 0, 0, 0, 0, 0) }
267 if nx_snapshot_ring_oldest(ring) != NX_SS_FRAME_INVALID { return __syscall(93, 12, 0, 0, 0, 0, 0) }
268 if nx_snapshot_ring_has(ring, 0) != 0 { return __syscall(93, 13, 0, 0, 0, 0, 0) }
269
270 // T4: save then restore via ring round-trips.
271 if nx_snapshot_ring_save(ring, 0, buf_a) != 64 { return __syscall(93, 20, 0, 0, 0, 0, 0) }
272 if nx_snapshot_ring_has(ring, 0) != 1 { return __syscall(93, 21, 0, 0, 0, 0, 0) }
273 if nx_snapshot_ring_newest(ring) != 0 { return __syscall(93, 22, 0, 0, 0, 0, 0) }
274 let buf_c: *u8 = (sys_mmap(64)) as *u8
275 if nx_snapshot_ring_restore(ring, 0, buf_c) != 64 { return __syscall(93, 23, 0, 0, 0, 0, 0) }
276 if nx_sim_snapshot_eq(buf_a, buf_c, 64) != 1 { return __syscall(93, 24, 0, 0, 0, 0, 0) }
277
278 // T5: ring depth 4 -- saving frames 0..3 retains all, frame 4 evicts frame 0.
279 let ring2: *i64 = nx_snapshot_ring_alloc(64, 4)
280 let scratch: *u8 = (sys_mmap(64)) as *u8
281 var f: nx_int = 0
282 while f < 4 {
283 var j: nx_int = 0
284 while j < 64 {
285 scratch[j] = (f * 13 + j) as u8
286 j = j + 1
287 }
288 nx_snapshot_ring_save(ring2, f, scratch)
289 f = f + 1
290 }
291 if nx_snapshot_ring_has(ring2, 0) != 1 { return __syscall(93, 30, 0, 0, 0, 0, 0) }
292 if nx_snapshot_ring_has(ring2, 3) != 1 { return __syscall(93, 31, 0, 0, 0, 0, 0) }
293 if nx_snapshot_ring_newest(ring2) != 3 { return __syscall(93, 32, 0, 0, 0, 0, 0) }
294
295 // Save frame 4 -- should evict frame 0 (slot 0 reused).
296 var k: nx_int = 0
297 while k < 64 {
298 scratch[k] = (4 * 13 + k) as u8
299 k = k + 1
300 }
301 nx_snapshot_ring_save(ring2, 4, scratch)
302 if nx_snapshot_ring_has(ring2, 0) != 0 { return __syscall(93, 33, 0, 0, 0, 0, 0) }
303 if nx_snapshot_ring_has(ring2, 4) != 1 { return __syscall(93, 34, 0, 0, 0, 0, 0) }
304 if nx_snapshot_ring_newest(ring2) != 4 { return __syscall(93, 35, 0, 0, 0, 0, 0) }
305 if nx_snapshot_ring_oldest(ring2) != 1 { return __syscall(93, 36, 0, 0, 0, 0, 0) }
306
307 // Restore frame 4 contents.
308 let out: *u8 = (sys_mmap(64)) as *u8
309 if nx_snapshot_ring_restore(ring2, 4, out) != 64 { return __syscall(93, 37, 0, 0, 0, 0, 0) }
310 var m: nx_int = 0
311 while m < 64 {
312 if out[m] != ((4 * 13 + m) as u8) { return __syscall(93, 38, 0, 0, 0, 0, 0) }
313 m = m + 1
314 }
315
316 // Restore frame 2 (still in ring after eviction).
317 if nx_snapshot_ring_restore(ring2, 2, out) != 64 { return __syscall(93, 39, 0, 0, 0, 0, 0) }
318 var n2: nx_int = 0
319 while n2 < 64 {
320 if out[n2] != ((2 * 13 + n2) as u8) { return __syscall(93, 40, 0, 0, 0, 0, 0) }
321 n2 = n2 + 1
322 }
323
324 // T6: attempting to restore an evicted frame returns -3.
325 if nx_snapshot_ring_restore(ring2, 0, out) != 0 - 3 { return __syscall(93, 50, 0, 0, 0, 0, 0) }
326
327 // T7: GGPO-typical use -- depth=8 (133 ms at 60Hz), simulate
328 // 100 frames of saves, prove latest 8 are restorable and frame
329 // 91 is the oldest retained.
330 let ring3: *i64 = nx_snapshot_ring_alloc(64, 8)
331 var p: nx_int = 0
332 while p < 100 {
333 var q: nx_int = 0
334 while q < 64 {
335 scratch[q] = (p + q) as u8
336 q = q + 1
337 }
338 nx_snapshot_ring_save(ring3, p, scratch)
339 p = p + 1
340 }
341 if nx_snapshot_ring_newest(ring3) != 99 { return __syscall(93, 60, 0, 0, 0, 0, 0) }
342 if nx_snapshot_ring_oldest(ring3) != 92 { return __syscall(93, 61, 0, 0, 0, 0, 0) }
343 if nx_snapshot_ring_has(ring3, 91) != 0 { return __syscall(93, 62, 0, 0, 0, 0, 0) }
344 if nx_snapshot_ring_has(ring3, 92) != 1 { return __syscall(93, 63, 0, 0, 0, 0, 0) }
345 if nx_snapshot_ring_has(ring3, 99) != 1 { return __syscall(93, 64, 0, 0, 0, 0, 0) }
346 // Restore frame 95 and verify byte 5 = 100 (95 + 5).
347 if nx_snapshot_ring_restore(ring3, 95, out) != 64 { return __syscall(93, 65, 0, 0, 0, 0, 0) }
348 if out[5] != (100 as u8) { return __syscall(93, 66, 0, 0, 0, 0, 0) }
349
350 return 0
351}