code wiki / (root) / nx_sim_snapshot.nx

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}