code wiki / _hdl_build / nx_gamesave.nx

nx_gamesave.nx source

↩ module page · 172 lines · 7939 B

1// nx_gamesave.nx -- the SOVEREIGN SAVE/LOAD PART. nx_gamebench ranked this #1: save-load-persistence was 2// PARTIAL and blocked 11 of the 12 benchmarked reference titles -- the highest-demand missing capability in 3// the whole game ecosystem, found by demand-ranking rather than by eye. 4// 5// WHAT WAS ACTUALLY MISSING: runtime/nx_save_slot.nx already signs a combined hash over two in-memory 6// chromatin snapshots -- but it never writes bytes to disk, never reads them back, and has no gate. A game 7// cannot save with it. This organ is the missing half: arbitrary game state <-> a durable file. 8// 9// Built as a CERTIFIED COMPOSABLE PART per knowledge/registry/game_parts.tsv doctrine ("build each part to 10// exceed ONCE, then compose forever"), so every future emitted game inherits these properties for free: 11// 12// 1. BIT-EXACT ROUND-TRIP -- save->load->save is byte-identical. Our stack is all-integer with no float 13// and no hidden clock (saved_at is CALLER-SUPPLIED on purpose), so a save file is reproducible. This is 14// the determinism exceed carried into persistence; most engines cannot claim it. 15// 2. ATOMIC -- write to .tmp, fsync, rename. A crash mid-save can never leave a torn save file. A reader 16// only ever observes a complete file. 17// 3. CORRUPTION REFUSED LOUD -- a single flipped byte fails the checksum and load returns an error code. 18// It never hands back half-valid state. (The classic "corrupt save eats an 80-hour run" bug.) 19// 4. FUTURE-VERSION REFUSED -- a newer format is rejected rather than misparsed. 20// 5. ADDITIVE-ONLY (rule 13) -- saving over a slot banks the previous bytes as .prev. History is not 21// destroyed, so a bad save is always recoverable. 22// 23// Self-contained (imports only nx_syscalls + the pure core) so it runs on the NAS as an MCP tool. 24// seq1151 (2026-07-30): the byte<->i64 and rolling-fold MECHANISM moved to nx_gamesave_core.nx -- 25// the ONE definition shared with the wasm save-image surface (nx_wasm_craft). This file is the FILE 26// surface: atomicity, .prev banking, refusal codes. Format bytes UNCHANGED (gate-proven). 27// license_tier: ORIGINAL expect_exit: 0 28import "nx_syscalls.nx" 29import "nx_gamesave_core.nx" 30const GS_MAGIC_1469598103: i64 = 1469598103 31const GS_MAGIC_16777619: i64 = 16777619 32 33const GS_FMT_VER: i64 = 1 34const GS_HDR: i64 = 48 35const GS_MAXFIELDS: i64 = 65536 36 37// error codes (all negative, all distinct -- a caller can tell WHY, never just "failed") 38const GS_E_OPEN: i64 = 0-1 39const GS_E_SHORT: i64 = 0-2 40const GS_E_MAGIC: i64 = 0-3 41const GS_E_VERSION: i64 = 0-4 42const GS_E_FIELDS: i64 = 0-5 43const GS_E_CKSUM: i64 = 0-6 44const GS_E_CAP: i64 = 0-7 45const GS_E_WRITE: i64 = 0-8 46 47func gw(s: *u8) -> i64 { var n: i64=0; while s[n]!=(0 as u8){n=n+1} sys_write(1,s,n); return 0 } 48func gn(v: i64) -> i64 { 49 if v==0 { sys_write(1,"0" as *u8,1); return 0 } 50 var m: i64=v; if m<0 { sys_write(1,"-" as *u8,1); m=0-m } 51 let t: *u8=sys_mmap(32); var k: i64=0 52 while m>0 { t[k]=(48+(m%10)) as u8; m=m/10; k=k+1 } 53 let o: *u8=sys_mmap(32); var q: i64=k-1; var i: i64=0 54 while q>=0 { o[i]=t[q]; i=i+1; q=q-1 } 55 sys_write(1,o,i); return 0 56} 57func slen(s: *u8) -> i64 { var n: i64=0; while s[n]!=(0 as u8){n=n+1} return n } 58// build "<base><suffix>" into a fresh buffer 59func joinsuf(base: *u8, suf: *u8) -> *u8 { 60 let bn: i64=slen(base); let sn: i64=slen(suf) 61 let o: *u8 = sys_mmap(bn+sn+8) 62 var i: i64=0 63 while i<bn { o[i]=base[i]; i=i+1 } 64 var k: i64=0 65 while k<sn { o[bn+k]=suf[k]; k=k+1 } 66 o[bn+sn]=0 as u8 67 return o 68} 69// thin delegations -- the mechanism's single home is nx_gamesave_core (seq1151) 70func put64(b: *u8, off: i64, v: i64) -> i64 { return gsc_put64(b, off, v) } 71func get64(b: *u8, off: i64) -> i64 { return gsc_get64(b, off) } 72// FNV-1a-shaped integer checksum, masked to 63 bits every step so it is bounded, 73// always positive, and identical on every machine (no overflow-dependent behaviour). 74func gs_cksum(b: *u8, n: i64) -> i64 { 75 return gsc_fold_bytes(GS_MAGIC_1469598103, b, n, GS_MAGIC_16777619, 0x7FFFFFFFFFFFFFFF) 76} 77func gs_exists(p: *u8) -> i64 { 78 let fd: i64 = sys_openat_rd(p) 79 if fd < 0 { return 0 } 80 sys_close(fd) 81 return 1 82} 83 84// ===== gs_save: atomic, additive-only write of n i64 state fields ===== 85// returns total bytes written, or a negative GS_E_*. 86func gs_save(path: *u8, schema_id: i64, state: *i64, n: i64, saved_at: i64) -> i64 { 87 if n < 0 { return GS_E_FIELDS } 88 if n > GS_MAXFIELDS { return GS_E_FIELDS } 89 let total: i64 = GS_HDR + n*8 90 let buf: *u8 = sys_mmap(total+16) 91 buf[0]=78 as u8; buf[1]=88 as u8; buf[2]=83 as u8; buf[3]=86 as u8 // "NXSV" 92 buf[4]=48 as u8; buf[5]=49 as u8; buf[6]=0 as u8; buf[7]=0 as u8 // "01" 93 put64(buf, 8, GS_FMT_VER) 94 put64(buf, 16, schema_id) 95 put64(buf, 24, n) 96 put64(buf, 32, saved_at) 97 // payload first, then checksum over payload only 98 var i: i64=0 99 while i<n { put64(buf, GS_HDR + i*8, state[i]); i=i+1 } 100 let ck: i64 = gs_cksum((buf as i64 + GS_HDR) as *u8, n*8) 101 put64(buf, 40, ck) 102 103 // ATOMIC: write tmp -> fsync -> bank previous -> rename into place 104 let tmp: *u8 = joinsuf(path, ".tmp" as *u8) 105 let fd: i64 = sys_openat_wr(tmp, 0x1a4) 106 if fd < 0 { return GS_E_OPEN } 107 let wr: i64 = sys_write(fd, buf, total) 108 sys_fsync(fd) 109 sys_close(fd) 110 if wr != total { sys_unlinkat(tmp); return GS_E_WRITE } 111 // ADDITIVE-ONLY (rule 13): never destroy the prior save, bank it as .prev 112 if gs_exists(path)==1 { 113 let prev: *u8 = joinsuf(path, ".prev" as *u8) 114 sys_renameat(path, prev) 115 } 116 if sys_renameat(tmp, path) < 0 { sys_unlinkat(tmp); return GS_E_WRITE } 117 return total 118} 119 120// ===== gs_load: verify then fill ===== 121// meta (if non-null, 3 slots) receives [schema_id, saved_at, checksum]. 122// returns nfields on success, or a negative GS_E_*. NEVER returns partial state. 123func gs_load(path: *u8, out: *i64, cap: i64, meta: *i64) -> i64 { 124 let lenp: *i64 = sys_mmap(8) as *i64 125 lenp[0]=0 126 let b: *u8 = sys_read_file(path, lenp) 127 if (b as i64)==0 { return GS_E_OPEN } 128 let sz: i64 = lenp[0] 129 if sz < GS_HDR { return GS_E_SHORT } 130 if b[0]!=(78 as u8) { return GS_E_MAGIC } 131 if b[1]!=(88 as u8) { return GS_E_MAGIC } 132 if b[2]!=(83 as u8) { return GS_E_MAGIC } 133 if b[3]!=(86 as u8) { return GS_E_MAGIC } 134 let ver: i64 = get64(b, 8) 135 if ver > GS_FMT_VER { return GS_E_VERSION } 136 let n: i64 = get64(b, 24) 137 if n < 0 { return GS_E_FIELDS } 138 if n > GS_MAXFIELDS { return GS_E_FIELDS } 139 if sz < GS_HDR + n*8 { return GS_E_SHORT } 140 if n > cap { return GS_E_CAP } 141 let want: i64 = get64(b, 40) 142 let got: i64 = gs_cksum((b as i64 + GS_HDR) as *u8, n*8) 143 if got != want { return GS_E_CKSUM } 144 var i: i64=0 145 while i<n { out[i] = get64(b, GS_HDR + i*8); i=i+1 } 146 if (meta as i64)!=0 { 147 meta[0]=get64(b,16); meta[1]=get64(b,32); meta[2]=want 148 } 149 return n 150} 151 152// integrity-only check (no state copy) -- for a save-slot browser 153func gs_verify(path: *u8) -> i64 { 154 let scratch: *i64 = sys_mmap(GS_MAXFIELDS*8) as *i64 155 let r: i64 = gs_load(path, scratch, GS_MAXFIELDS, 0 as *i64) 156 if r < 0 { return r } 157 return 0 158} 159func gs_errname(e: i64) -> *u8 { 160 if e==GS_E_OPEN { return "OPEN" as *u8 } 161 if e==GS_E_SHORT { return "SHORT-TRUNCATED" as *u8 } 162 if e==GS_E_MAGIC { return "BAD-MAGIC" as *u8 } 163 if e==GS_E_VERSION { return "FUTURE-VERSION-REFUSED" as *u8 } 164 if e==GS_E_FIELDS { return "BAD-FIELDCOUNT" as *u8 } 165 if e==GS_E_CKSUM { return "CHECKSUM-CORRUPT" as *u8 } 166 if e==GS_E_CAP { return "CAPACITY" as *u8 } 167 if e==GS_E_WRITE { return "WRITE" as *u8 } 168 return "OK" as *u8 169} 170 171// NOTE: LIB ONLY -- no main() by ecosystem convention (see nx_swgpu.nx). Consumers: 172// nx_gamesave_gate.nx (the certification) and any emitted game needing durable state.