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.