From 5b74b987787f800c1b9e9ec2609ad37e02c22691 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 03:09:37 -0400 Subject: [PATCH] board: claim VM140 for lane M; MoveFleet ULP + ObservedTech rows --- campaign/board.md | 2 + verify/state-checksum/state_checksum.py | 810 +++++++++++++++++++ verify/state-checksum/test_state_checksum.py | 403 +++++++++ 3 files changed, 1215 insertions(+) create mode 100644 verify/state-checksum/state_checksum.py create mode 100644 verify/state-checksum/test_state_checksum.py diff --git a/campaign/board.md b/campaign/board.md index 4170e58..f93f872 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -75,3 +75,5 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`). | ReVa MCP link drop (workaround) | meta | verified | high | 100% | 2026-09-08 | The ReVa MCP client link dropped mid-session while the CT111 server stayed healthy (systemd active, :8080 listening, valid key -> 200). `tools/reva_call.py ''` calls the same server over plain HTTP (initialize -> notifications/initialized -> tools/call; replies are SSE with a leading `id:` line, initialize is plain JSON). Key is NEVER stored in the repo: $REVA_KEY, else ~/.claude.json, else ssh to the CT properties file. Use this whenever mcp__plugin_ReVa_ReVa__* is unavailable | | event posting API | subsystem | mapped | high | 90% | 2026-09-08 | RECOVERED (lane E, `findings/subsystems/events.md`). Container: `EventStorage` embedded at `ServerPlayer+0x29c` (0x1c), `EvNxID` at +0x14 = player+0x2b0 — exactly the guard's byte run. Nested `vector}>`, record 0x74 B, tags `EvEID EvDsc EvMsg EvImg EvLoc EvPos EvAct EvCID`; layout confirmed field-by-field against turn3-state.sav, which CONTAINS the overbudget record. Entry point `int __thiscall EventStorage::PostEvent(this, string BYVAL, string BYVAL, obj*, Vector3*, turn, const char* img, int act)` 0x008862b0 RET 0x4c — **161 call sites in 113 functions, the whole sim's event API**. B3 defect fully explained: 0x00587b97, in the completion-roll-FAILED branch under `!wasDone && nowDone && owner`. 3 note corrections (EvPos is FLT_MAX not inf; the save array is turn-bucketed not flat; TECHS_UNLOCKED has no parent clause). 56 entries in addresses.json; 11 prototypes + 13 labels + 12 comments + 2 structs written back to Ghidra. Engine: `sots-engine` branch `wip/events` a7348be, `src/game/events` + 112 checks, ctest 32/32. NOT YET WIRED INTO A HOOK — see `docs/E-events.md` for the proposed region/Coverage change | | state-checksum replay harness | verify | in-progress | — | 0% | 2026-09-08 | Lane C: whole-state per-turn checksum as the COMPLEMENT to per-function compares (no region-declaration mistake can hide from it). Diagnostic tree that localises WHICH object moved, explicit float-parity policy (float32 + fpu_cw 0x127f + fistp ties-to-even; matters for the future x64/SSE port). Replay loop designed but unrun - lane R holds VM140 | +| MoveFleet position ULP divergence | phase2 | in-progress | — | 0% | 2026-09-08 | Lane M. FIRST arithmetic divergence caught by BEHAVIOURAL compare rather than static reading: 8 of 45 calls differ by 1 ULP on a position component (worst 64 ULP at a near-zero result; absolute error ~1.2e-7 everywhere = half an ULP of the INPUTS) => one rounding too many/few in the position update. Step length and all ship ranges match. B4 saw only 1 moving call and it still matches 4 of 5 moves, which is exactly why 1-call coverage is not evidence. All 15 moving calls are the same straight-run waypoint type; types 2-5 never occurred | +| ObservedTech append (undeclared) | verify | backlog | — | 0% | 2026-09-08 | Lane R's guards caught a vector append at `player+0x274` during SetResearched. It is SERIALIZED state and appears in NO coverage note anywhere - found only because guards localise rather than just flag a moved hash. Needs a declared region + a model in ours | diff --git a/verify/state-checksum/state_checksum.py b/verify/state-checksum/state_checksum.py new file mode 100644 index 0000000..3d22ee9 --- /dev/null +++ b/verify/state-checksum/state_checksum.py @@ -0,0 +1,810 @@ +#!/usr/bin/env python3 +"""state_checksum.py -- whole-state, diagnostic checksum of a SOTS1 save. + +Why this exists +--------------- +Every other verification in this project is *per-function*: hook one routine, +compare the regions it declares, print a verdict. That is only as good as the +region declaration, and region declarations have been wrong (B4: three hooks +that reported "0 diverged" while comparing nothing; the harness audit: 23 +undeclared side effects). A green verdict over an empty region set is a +failure, not a pass. + +This tool is the complement. It does not ask "did the declared outputs match". +It asks **"is the entire simulation state still identical"**, computed from the +one artefact the game emits as a pure function of state -- the save file, which +`findings/subsystems/determinism-oracle.md` proved byte-identical across runs +and processes. + +Two properties make it evidence rather than a comforting number: + +1. **Provable coverage.** The checksum is computed from a tree that is + re-serialised and compared byte-for-byte against the inflated save + (`--audit`, on by default). If the reconstruction reproduces the stream, + then every byte of the save is a function of the checksum's inputs, so *no* + state change can be invisible to it. Coverage is asserted by construction, + not by a hand-written region list that someone can forget to fill in. +2. **It localises.** The root digest is the fold of a tree of per-subsystem / + per-object digests, and objects are named (`Player[16 "re"]`, + `Sys[112 "Gamma Cephei"]`, `Events/[turn=3]/[id=3]`), so a divergence + reports *which object moved*, not just that the hash moved. + +Float parity: see STATE_CHECKSUM.md section 3. Short version -- the digest is +always exact (raw IEEE-754 bits, or bits with -0.0/NaN normalised under +`--floats canonical`). Tolerance is deliberately *not* a hashing mode; it +exists only in the differ (`--ulps`), because a tolerant hash is a +contradiction: quantisation just moves the cliff, it does not remove it. + +Usage +----- + state_checksum.py SAVE # root + subsystem digests + state_checksum.py SAVE --tree --depth 3 # digest tree + state_checksum.py A B # localise divergence + state_checksum.py A B --ulps 2 # classify float diffs + state_checksum.py --chain chain.json S1 S2 ... # verify a recorded chain + state_checksum.py SAVE --json # machine-readable + +Stdlib only (plus save_reader.py, which is stdlib only). +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +import struct +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_READER = os.path.join(os.path.dirname(_HERE), "save-reader") +if _READER not in sys.path: + sys.path.insert(0, _READER) + +import save_reader as sr # noqa: E402 + +__all__ = [ + "FLOAT_POLICIES", "MASK_PRESETS", "CkNode", "checksum_save", "checksum_bytes", + "reconstruct", "audit_coverage", "diff", "DiffEntry", "float_key", +] + +DIGEST_BYTES = 16 # blake2b-128; 2**-64 collision floor at our object counts +SHORT = 16 # hex chars shown in text output + +BEGIN = struct.pack(" save of a post-turn autosave: the server clears the per-player + # "turn ended" flag (4 -> 0) and the derived top-level Checksum follows. + "resave": ( + ("Status", "/Sim/players/Player[", "int", 0), + ("Checksum", "/Summary/", "derived", None), + ), +} + + +def float_key(raw: bytes, policy: str) -> bytes: + """Hashing key for a 4-byte IEEE-754 single under `policy`.""" + if policy == "bits": + return raw + if policy != "canonical": + raise ValueError(f"unknown float policy {policy!r}") + (bits,) = struct.unpack(" +0.0 + return b"\0\0\0\0" + if (bits & 0x7F800000) == 0x7F800000 and (bits & 0x007FFFFF): + return struct.pack(" one quiet NaN + return raw + + +def ulps_apart(a: bytes, b: bytes) -> float: + """Distance in float32 ULPs between two raw singles. inf if not comparable.""" + (x,) = struct.unpack(" int: + (u,) = struct.unpack("= 0 else -0x80000000 - u # monotone map across the sign + return float(abs(ordinal(a) - ordinal(b))) + + +# --- naming ------------------------------------------------------------------- + +#: lead-int tag -> (frame tag, group name). The save stores object tables as a +#: flat run of (id, frame) sibling pairs; folding each run into a named group +#: turns `Sim`'s 259 children into a subsystem tree. +PAIR_GROUPS = { + "PlayerID": ("Player", "players"), + "SysID": ("Sys", "systems"), + "FltID": ("Flt", "fleets"), + "ShipID": ("Ship", "ships"), + "DesID": ("Des", "designs"), + "TradeID": ("Trade", "trades"), + "ply": ("hist", "history"), +} + +#: frame tag -> child tags to use as a human label, first match wins +NAME_FIELDS = { + "Player": ("PlryName",), + "Sys": ("Name",), + "Flt": ("FltNm", "Name", "FName"), + "Ship": ("ShpNm", "Name", "SName"), + "Des": ("DName",), +} + +#: child tags that identify a NULL-named ('.') element frame +ELEM_KEYS = ("EvTurn", "EvEID", "ordID", "npid", "desID", "Wpt", "seno2", "ID", "set") + +#: NonComplexArray count tags whose elements are a run of `.` scalars in the +#: *same* frame (SAVE_FORMAT.md section 4, "inline count"). Left flat, a single +#: id insertion shifts every following sibling and the differ reports a hundred +#: spurious moves; folded, it reports one list that gained one id. +INLINE_ID_LISTS = ("PlayerIDs", "DesignIDs", "SystemIDs", "FleetIDs", + "ShipIDs", "TradeIDs") + +#: top-level digest rows printed by the default (non-tree) view. Paths that do +#: not exist in a given save are silently skipped. +SUBSYSTEM_PATHS = ( + "/Summary", + "/CreateParams", + "/Sim/RNG", + "/Sim/Attrib", + "/Sim/turnstats", + "/Sim/players", + "/Sim/systems", + "/Sim/fleets", + "/Sim/NdGr2", + "/Sim/trdmgr", + "/Sim/spymgr", + "/Sim/SvSctOb", + "/CDT", +) + +#: per-player sub-rollups, appended under each player row in --subsystems +PLAYER_PARTS = ("TechTree", "Events", "designs", "Diplo", "spy2", "civr", "comms", "Ojvs") + + +# --- the digest tree ---------------------------------------------------------- + +class CkNode: + """One node of the checksum tree.""" + + __slots__ = ("path", "label", "kind", "digest", "offset", "size", + "leaves", "value_bytes", "children", "value", "raw", "masked", + "values") + + def __init__(self, path, label, kind, digest, offset, size, + leaves, value_bytes, children, value=None, raw=b"", masked=False, + values=None): + self.values = values # inline id list contents, for set-diffing + self.path = path + self.label = label + self.kind = kind + self.digest = digest + self.offset = offset + self.size = size + self.leaves = leaves # number of scalar leaves below (or 1) + self.value_bytes = value_bytes # bytes of *value* payload below (framing excluded) + self.children = children + self.value = value + self.raw = raw + self.masked = masked + + @property + def hexd(self) -> str: + return self.digest.hex() + + def short(self) -> str: + return self.digest.hex()[:SHORT] + + def find(self, path: str): + if self.path == path: + return self + for c in self.children: + if path == c.path or path.startswith(c.path + "/"): + return c.find(path) + return None + + def walk(self): + yield self + for c in self.children: + yield from c.walk() + + def as_dict(self, depth: int = 99, _d: int = 0) -> dict: + out = {"path": self.path, "kind": self.kind, "digest": self.hexd, + "offset": self.offset, "size": self.size, + "leaves": self.leaves, "valueBytes": self.value_bytes} + if self.masked: + out["masked"] = True + if self.kind != "complex" and self.value is not None: + out["value"] = self.value + if self.children and _d < depth: + out["children"] = [c.as_dict(depth, _d + 1) for c in self.children] + elif self.children: + out["childCount"] = len(self.children) + return out + + +def _h(*parts: bytes) -> bytes: + d = hashlib.blake2b(digest_size=DIGEST_BYTES) + for p in parts: + d.update(struct.pack(" str: + """Human, stable, greppable label for one item. + + A key is appended only when it carries information: an object id, an object + name, or an element key. A bare positional index is appended only when the + tag is ambiguous among its siblings (`unique=False`), so a uniquely-named + field reads as `Status`, not `Status[36]`. + """ + base = node.name if node.name is not None else "" + key = None + if lead is not None and isinstance(lead.value, int): + key = str(lead.value) + if node.kind == "complex": + want = NAME_FIELDS.get(node.name or "") + if want: + for c in node.children: + if c.name in want and isinstance(c.value, str): + key = f'{key} "{c.value}"' if key else f'"{c.value}"' + break + if key is None and (node.name in (".", None)): + for c in node.children: + if c.name in ELEM_KEYS: + key = f"{c.name}={c.value}" + break + if key is None: + if unique: + return base + key = str(index) + return f"{base}[{key}]" + + +def _name_counts(nodes) -> dict: + counts: dict = {} + for n in nodes: + counts[n.name] = counts.get(n.name, 0) + 1 + return counts + + +def _group_children(nodes: list[sr.Node]): + """Fold object runs and inline id lists into synthetic groups. + + Returns a list of entries: + ("item", node, None) -- a plain child + ("group", name, [(id_node, frame_node), ...]) -- an object table + ("list", name, count_node, [value_nodes]) -- an inline id list + Order and byte coverage are preserved exactly; nothing is dropped, so the + reconstruction audit still proves total coverage. + """ + out, i, seen = [], 0, {} + n = len(nodes) + while i < n: + cur = nodes[i] + spec = PAIR_GROUPS.get(cur.name or "") + if (spec and i + 1 < n and nodes[i + 1].name == spec[0] + and nodes[i + 1].kind == "complex" and cur.kind in ("int", "float")): + frame_tag, gname = spec + pairs = [] + while (i + 1 < n and nodes[i].name == cur.name + and nodes[i + 1].name == frame_tag and nodes[i + 1].kind == "complex"): + pairs.append((nodes[i], nodes[i + 1])) + i += 2 + seen[gname] = seen.get(gname, 0) + 1 + label = gname if seen[gname] == 1 else f"{gname}({seen[gname]})" + out.append(("group", label, pairs)) + continue + if (cur.name in INLINE_ID_LISTS and cur.kind == "int" + and isinstance(cur.value, int) and 0 <= cur.value <= n - i - 1 + and all(nodes[i + 1 + k].name == "." and nodes[i + 1 + k].kind != "complex" + for k in range(cur.value))): + vals = nodes[i + 1:i + 1 + cur.value] + out.append(("list", cur.name, cur, vals)) + i += 1 + cur.value + continue + out.append(("item", cur, None)) + i += 1 + return out + + +def _value_key(node: sr.Node, path: str, policy: str, masks, hits: dict): + """(kind, key_bytes, masked) for a scalar/raw leaf.""" + kind = node.kind + for tag, under, mkind, mval in masks: + if node.name != tag or not path.startswith(under): + continue + hits[tag] = hits.get(tag, 0) + 1 + if mkind == "derived": + return kind, b"", True + if mkind == "int": + return kind, struct.pack(" CkNode: + if node.kind == "complex": + entries = _group_children(node.children) + counts = _name_counts(e[1] for e in entries if e[0] == "item") + # positional index is the ordinal among *same-named* siblings, so one + # insertion elsewhere in the frame does not renumber everything after it + ordinals: dict = {} + kids: list[CkNode] = [] + for ent in entries: + if ent[0] == "item": + child = ent[1] + k = ordinals.get(child.name, 0) + ordinals[child.name] = k + 1 + lab = _label(child, k, None, unique=counts.get(child.name) == 1) + kids.append(_build(child, f"{path}/{lab}", lab, policy, masks, hits)) + elif ent[0] == "list": + _, lname, cnode, vals = ent + lpath = f"{path}/{lname}[]" + keys = [_value_key(v, lpath, policy, masks, hits)[1] for v in vals] + ldig = _h(b"list", lname.encode("ascii"), cnode.raw, *keys) + off = cnode.offset + last = vals[-1] if vals else cnode + kids.append(CkNode(lpath, f"{lname}[]", "list", ldig, off, + last.offset + last.size - off, + 1 + len(vals), + len(cnode.raw) + sum(len(v.raw) for v in vals), + [], values=[v.value for v in vals])) + else: + _, gname, pairs = ent + gpath = f"{path}/{gname}" + gkids = [] + for j, (idn, frame) in enumerate(pairs): + lab = _label(frame, j, idn) + ck = _build(frame, f"{gpath}/{lab}", lab, policy, masks, hits) + # the id int is part of the object's identity; fold it in + idk = _h(b"id", (idn.name or "").encode("ascii"), idn.raw) + ck.digest = _h(b"obj", idk, ck.digest) + ck.offset = idn.offset + ck.size = frame.offset + frame.size - idn.offset + ck.value_bytes += len(idn.raw) + ck.leaves += 1 + gkids.append(ck) + gdig = _h(b"group", gname.encode("ascii"), + *[k.digest for k in gkids]) + off = gkids[0].offset if gkids else node.offset + size = (gkids[-1].offset + gkids[-1].size - off) if gkids else 0 + kids.append(CkNode(gpath, gname, "group", gdig, off, size, + sum(k.leaves for k in gkids), + sum(k.value_bytes for k in gkids), gkids)) + dig = _h(b"frame", (node.name or "").encode("ascii"), *[k.digest for k in kids]) + return CkNode(path, label, "complex", dig, node.offset, node.size, + sum(k.leaves for k in kids) or 0, + sum(k.value_bytes for k in kids), kids) + + kind, key, masked = _value_key(node, path, policy, masks, hits) + dig = _h(b"leaf", (node.name or "").encode("ascii"), kind.encode("ascii"), key) + return CkNode(path, label, kind, dig, node.offset, node.size, 1, len(node.raw), + [], value=node.value, raw=node.raw, masked=masked) + + +# --- coverage proof ----------------------------------------------------------- + +def reconstruct(node: sr.Node, out: bytearray, base: int) -> None: + """Re-serialise a reader Node back into `out`. + + `base` is the inflated offset `out` starts at, so joint padding (which is + computed against the absolute 4-byte grid) lands where it did originally. + """ + def pad(): + while (base + len(out)) & 3: + out.append(0) + + if node.name is None and node.kind == "raw": + out += node.raw # unnamed payload / stray bytes + return + if node.kind == "complex": + if node.name is not None: + b = node.name.encode("ascii") + out += struct.pack(" dict: + """Re-serialise the whole tree and compare with the inflated stream.""" + out = bytearray() + for c in tree.children: + reconstruct(c, out, 0) + ok = bytes(out) == inflated + first = None + if not ok: + n = min(len(out), len(inflated)) + for i in range(n): + if out[i] != inflated[i]: + first = i + break + if first is None: + first = n + return {"ok": ok, "rebuiltBytes": len(out), "inflatedBytes": len(inflated), + "firstDiff": first} + + +# --- top level ---------------------------------------------------------------- + +class SaveChecksum: + def __init__(self, path, root: CkNode, coverage: dict, res: sr.SaveResult, + policy: str, mask: str, mask_hits: dict | None = None): + self.path = path + self.root = root + self.coverage = coverage + self.res = res + self.policy = policy + self.mask = mask + #: leaves each mask rule actually replaced. Printed on every masked run + #: so an over-broad mask is visible rather than comfortable. + self.mask_hits = mask_hits or {} + + @property + def digest(self) -> str: + return self.root.hexd + + def subsystems(self) -> list[tuple[str, CkNode]]: + rows = [] + for p in SUBSYSTEM_PATHS: + n = self._by_tag(p) + if n is not None: + rows.append((p, n)) + return rows + + def _by_tag(self, tagpath: str): + """Resolve a '/Sim/players'-style path against labelled nodes.""" + cur = self.root + for seg in tagpath.strip("/").split("/"): + nxt = None + for c in cur.children: + base = c.label.split("[")[0] + if base == seg or c.label == seg: + nxt = c + break + if nxt is None: + return None + cur = nxt + return cur + + def as_dict(self, depth: int = 2) -> dict: + return { + "file": self.path, + "root": self.digest, + "policy": {"floats": self.policy, "mask": self.mask, + "digest": f"blake2b-{DIGEST_BYTES * 8}"}, + "coverage": self.coverage, + "readerIssues": {lvl: self.res.count(lvl) for lvl in ("error", "warn", "info")}, + "maskHits": self.mask_hits, + "subsystems": [{"path": p, "digest": n.hexd, "leaves": n.leaves, + "valueBytes": n.value_bytes} for p, n in self.subsystems()], + "tree": self.root.as_dict(depth), + } + + +def checksum_bytes(data: bytes, path: str = "", floats: str = "bits", + mask: str = "none", audit: bool = True, + strict: bool = False) -> SaveChecksum: + if floats not in FLOAT_POLICIES: + raise ValueError(f"floats must be one of {FLOAT_POLICIES}") + if mask not in MASK_PRESETS: + raise ValueError(f"mask must be one of {tuple(MASK_PRESETS)}") + res = sr.read_bytes(data, strict=strict) + masks = MASK_PRESETS[mask] + hits: dict = {} + kids = [] + counts = _name_counts(res.tree.children) + ordinals: dict = {} + for c in res.tree.children: + k = ordinals.get(c.name, 0) + ordinals[c.name] = k + 1 + lab = _label(c, k, None, unique=counts.get(c.name) == 1) + kids.append(_build(c, "/" + lab, lab, floats, masks, hits)) + root_dig = _h(b"save", floats.encode("ascii"), mask.encode("ascii"), + *[k.digest for k in kids]) + root = CkNode("", "", "complex", root_dig, 0, len(res.inflated), + sum(k.leaves for k in kids), sum(k.value_bytes for k in kids), kids) + cov = audit_coverage(res.tree, res.inflated) if audit else {"ok": None} + return SaveChecksum(path, root, cov, res, floats, mask, hits) + + +def checksum_save(path: str, **kw) -> SaveChecksum: + with open(path, "rb") as f: + return checksum_bytes(f.read(), path=path, **kw) + + +# --- diffing ------------------------------------------------------------------ + +class DiffEntry: + __slots__ = ("path", "kind", "a", "b", "ulps", "note") + + def __init__(self, path, kind, a=None, b=None, ulps=None, note=""): + self.path, self.kind, self.a, self.b = path, kind, a, b + self.ulps, self.note = ulps, note + + def __repr__(self): + if self.kind == "value": + u = f" [{self.ulps:g} ulp]" if self.ulps is not None else "" + return f"{self.path}: {self.a!r} -> {self.b!r}{u}{self.note}" + if self.kind == "list": + return (f"{self.path}: removed {self.a!r}, added {self.b!r}{self.note}") + return f"{self.path}: {self.kind}{self.note}" + + def as_dict(self): + d = {"path": self.path, "kind": self.kind} + if self.kind in ("value", "list", "list-reordered"): + d["a"], d["b"] = self.a, self.b + if self.ulps is not None: + d["ulps"] = self.ulps + if self.note: + d["note"] = self.note.strip() + return d + + +def diff(a: CkNode, b: CkNode, out: list | None = None, limit: int = 200) -> list: + """Descend two digest trees, reporting only where they differ.""" + out = [] if out is None else out + if len(out) >= limit: + return out + if a.digest == b.digest: + return out + if a.children or b.children: + an = {c.label: c for c in a.children} + bn = {c.label: c for c in b.children} + # positional first: order is part of the state + if [c.label for c in a.children] != [c.label for c in b.children]: + for lab in an: + if lab not in bn: + out.append(DiffEntry(a.path + "/" + lab, "only-in-A")) + for lab in bn: + if lab not in an: + out.append(DiffEntry(b.path + "/" + lab, "only-in-B")) + if not (set(an) - set(bn)) and not (set(bn) - set(an)): + out.append(DiffEntry(a.path, "reordered")) + for ca in a.children: + cb = bn.get(ca.label) + if cb is not None: + diff(ca, cb, out, limit) + return out + if a.kind == "list" and b.kind == "list": + av, bv = a.values or [], b.values or [] + added = [v for v in bv if v not in av] + removed = [v for v in av if v not in bv] + if added or removed: + out.append(DiffEntry(a.path, "list", removed, added, + note=f" ({len(av)} -> {len(bv)} entries)")) + else: + out.append(DiffEntry(a.path, "list-reordered", av, bv)) + return out + ulps = None + if a.kind == "float" and b.kind == "float" and len(a.raw) == 4 == len(b.raw): + ulps = ulps_apart(a.raw, b.raw) + note = "" + if a.kind != b.kind: + note = f" (kind {a.kind} -> {b.kind})" + av, bv = a.value, b.value + if a.kind == "raw" or b.kind == "raw": + # the reader's raw value is a truncated hex preview; two different blobs + # can preview identically, so report a digest of the whole blob instead + av = f"" + bv = f"" + out.append(DiffEntry(a.path, "value", av, bv, ulps, note)) + return out + + +# --- chain -------------------------------------------------------------------- + +def build_chain(files: list[str], **kw) -> dict: + """Record a chain: one entry per save, in turn order.""" + entries = [] + for f in files: + ck = checksum_save(f, **kw) + t = ck.res.typed.get("summary") or {} + entries.append({ + "name": os.path.basename(f), + "turn": t.get("Turn"), + "root": ck.digest, + "coverage": bool(ck.coverage.get("ok")), + "subsystems": {p: n.hexd for p, n in ck.subsystems()}, + }) + return {"format": "sots-state-chain/1", + "policy": {"floats": kw.get("floats", "bits"), + "mask": kw.get("mask", "none"), + "digest": f"blake2b-{DIGEST_BYTES * 8}"}, + "turns": entries} + + +def verify_chain(chain: dict, files: list[str], **kw) -> tuple[bool, list[str]]: + msgs, ok = [], True + rec = chain["turns"] + if len(rec) != len(files): + msgs.append(f"chain has {len(rec)} turns, {len(files)} saves given") + ok = False + for i, f in enumerate(files): + if i >= len(rec): + break + ck = checksum_save(f, **kw) + want = rec[i] + if ck.digest == want["root"]: + msgs.append(f"turn {want.get('turn')} MATCH {ck.digest[:SHORT]} {os.path.basename(f)}") + continue + ok = False + msgs.append(f"turn {want.get('turn')} DIVERGE {ck.digest[:SHORT]} != {want['root'][:SHORT]}" + f" {os.path.basename(f)}") + for p, n in ck.subsystems(): + w = want["subsystems"].get(p) + if w and w != n.hexd: + msgs.append(f" subsystem {p}: {w[:SHORT]} -> {n.hexd[:SHORT]}") + msgs.append(" (re-run with the recorded save to localise to an object)") + break # first divergent turn is the one that matters + return ok, msgs + + +# --- CLI ---------------------------------------------------------------------- + +def _mask_note(ck: "SaveChecksum") -> str: + """Always say what a mask actually hit -- a mask nobody audits is a hiding place.""" + if ck.mask == "none": + return "" + if not ck.mask_hits: + return " [mask matched NOTHING -- check the rule paths]" + return " [masked: " + ", ".join(f"{k}x{v}" for k, v in sorted(ck.mask_hits.items())) + "]" + + +def _print_tree(n: CkNode, depth: int, out, indent: int = 0, top: bool = True): + if not top: + pad = " " * indent + extra = f" {n.leaves} leaves, {n.value_bytes} B" if n.kind in ("complex", "group") else "" + val = "" if n.kind in ("complex", "group") else f" = {n.value!r}" + mk = " [masked]" if n.masked else "" + out.append(f"{pad}{n.short()} {n.label}{val}{extra}{mk}") + if indent < depth: + for c in n.children: + _print_tree(c, depth, out, indent + 1, top=False) + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser( + description="whole-state diagnostic checksum for SOTS1 saves") + ap.add_argument("saves", nargs="*", help="one save (report), two (diff), or N with --chain") + ap.add_argument("--floats", choices=FLOAT_POLICIES, default="bits", + help="float hashing policy (default: bits = exact IEEE-754)") + ap.add_argument("--mask", choices=tuple(MASK_PRESETS), default="none", + help="canonicalisation preset (default: none, so deltas localise)") + ap.add_argument("--tree", action="store_true", help="print the digest tree") + ap.add_argument("--depth", type=int, default=2, help="tree depth (default 2)") + ap.add_argument("--json", action="store_true") + ap.add_argument("--no-audit", action="store_true", + help="skip the byte-for-byte coverage proof (faster, weaker)") + ap.add_argument("--strict", action="store_true", help="reader --strict") + ap.add_argument("--ulps", type=float, default=None, + help="in a diff, flag float differences at or below N ULPs") + ap.add_argument("--limit", type=int, default=200, help="max diff entries") + ap.add_argument("--chain", metavar="FILE", help="chain JSON to verify against") + ap.add_argument("--record-chain", metavar="FILE", help="write a chain JSON instead") + args = ap.parse_args(argv) + + kw = dict(floats=args.floats, mask=args.mask, + audit=not args.no_audit, strict=args.strict) + + if not args.saves: + ap.error("no saves given") + + if args.record_chain: + chain = build_chain(args.saves, **kw) + with open(args.record_chain, "w") as f: + json.dump(chain, f, indent=1) + print(f"recorded {len(chain['turns'])} turns -> {args.record_chain}") + for t in chain["turns"]: + cov = "cov-ok" if t["coverage"] else "COV-FAIL" + print(f" turn {t['turn']} {t['root'][:SHORT]} {cov} {t['name']}") + return 0 if all(t["coverage"] for t in chain["turns"]) else 1 + + if args.chain: + with open(args.chain) as f: + chain = json.load(f) + ok, msgs = verify_chain(chain, args.saves, **kw) + print("\n".join(msgs)) + return 0 if ok else 1 + + if len(args.saves) == 1: + ck = checksum_save(args.saves[0], **kw) + if args.json: + print(json.dumps(ck.as_dict(args.depth if args.tree else 2), indent=1)) + return 0 if ck.coverage.get("ok") is not False else 1 + cov = ck.coverage + covs = ("coverage: PROVED (%d bytes rebuilt == inflated)" % cov["rebuiltBytes"] + if cov.get("ok") else + "coverage: SKIPPED" if cov.get("ok") is None else + "coverage: FAILED at inflated offset 0x%x" % (cov.get("firstDiff") or 0)) + print(f"file: {ck.path}") + print(f"root: {ck.digest}") + print(f"policy: floats={ck.policy} mask={ck.mask} digest=blake2b-{DIGEST_BYTES * 8}" + f"{_mask_note(ck)}") + print(f"{covs}; {ck.root.leaves} leaves, {ck.root.value_bytes} value bytes") + print(f"reader: {ck.res.count('error')} error, {ck.res.count('warn')} warn") + if args.tree: + lines: list[str] = [] + _print_tree(ck.root, args.depth, lines) + print("\n".join(lines)) + else: + print("subsystems:") + for p, n in ck.subsystems(): + print(f" {n.short()} {p:<22} {n.leaves:>6} leaves {n.value_bytes:>8} B") + return 0 if cov.get("ok") is not False else 1 + + if len(args.saves) != 2: + ap.error("give one save, two saves, or use --chain / --record-chain") + + a = checksum_save(args.saves[0], **kw) + b = checksum_save(args.saves[1], **kw) + entries = diff(a.root, b.root, limit=args.limit) + if args.ulps is not None: + for e in entries: + if e.kind == "value" and e.ulps is not None and e.ulps <= args.ulps: + e.note += f" <= {args.ulps:g} ULP" + if args.json: + print(json.dumps({"a": a.as_dict(0), "b": b.as_dict(0), + "identical": a.digest == b.digest, + "diffs": [e.as_dict() for e in entries]}, indent=1)) + return 0 if a.digest == b.digest else 1 + print(f"A {a.digest} {a.path}") + print(f"B {b.digest} {b.path}") + print(f"policy: floats={a.policy} mask={a.mask}{_mask_note(a)}") + for ck, nm in ((a, "A"), (b, "B")): + if ck.coverage.get("ok") is False: + print(f"!! {nm}: coverage FAILED at 0x{ck.coverage.get('firstDiff') or 0:x} " + f"-- the digest does not bound this file") + if a.digest == b.digest: + print("IDENTICAL") + return 0 + print(f"DIVERGED: {len(entries)} leaf difference(s)" + f"{' (limit reached)' if len(entries) >= args.limit else ''}") + for e in entries: + print(f" {e}") + if args.ulps is not None: + flt = [e for e in entries if e.kind == "value" and e.ulps is not None] + near = [e for e in flt if e.ulps <= args.ulps] + print(f"floats: {len(flt)} differ, {len(near)} within {args.ulps:g} ULP") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/verify/state-checksum/test_state_checksum.py b/verify/state-checksum/test_state_checksum.py new file mode 100644 index 0000000..960237a --- /dev/null +++ b/verify/state-checksum/test_state_checksum.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +"""Tests for state_checksum.py. + +Synthetic tests always run. Real-save tests are skipped cleanly when no saves +are available: they look in $SOTS_SAVES_DIR first, then at the in-repo +verify/results/saves/. No save is ever written into either repo by this file. + + uv run python3 -m unittest discover -s verify/state-checksum -v +""" +from __future__ import annotations + +import gzip +import json +import math +import os +import struct +import sys +import tempfile +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_VERIFY = os.path.dirname(_HERE) +for _p in (_HERE, os.path.join(_VERIFY, "save-reader")): + if _p not in sys.path: + sys.path.insert(0, _p) + +import save_reader as sr # noqa: E402 +import save_writer_stub as sw # noqa: E402 +import state_checksum as sc # noqa: E402 + + +# --- helpers ------------------------------------------------------------------ + +def tiny(status_a=4, status_b=4, checksum=-1000, extra_ids=(), fval=1.5) -> bytes: + """A miniature save with the shapes the tool special-cases.""" + w = sw.SaveWriter("joint") + w.begin("Summary") + w.string("GameName", "T") + w.int("Turn", 2) + w.int("Checksum", checksum) + w.end() + w.begin("Sim") + ids = (16, 32) + tuple(extra_ids) + w.int("PlayerIDs", len(ids)) + for i in ids: + w.int(".", i) + w.int("NumPlrs", 2) + for pid, st in ((16, status_a), (32, status_b)): + w.int("PlayerID", pid) + w.begin("Player") + w.string("PlryName", f"p{pid}") + w.int("Status", st) + w.float("IdealSuit", fval) + w.end() + w.end() + return w.bytes() + + +def find_saves() -> list[str]: + d = os.environ.get("SOTS_SAVES_DIR") or os.path.join(_VERIFY, "results", "saves") + if not os.path.isdir(d): + return [] + return sorted(os.path.join(d, f) for f in os.listdir(d) if f.endswith(".sav")) + + +REAL = find_saves() +needs_saves = unittest.skipUnless(REAL, "no saves in $SOTS_SAVES_DIR or verify/results/saves") + + +# --- coverage: the property that makes the digest evidence -------------------- + +class CoverageTest(unittest.TestCase): + def test_reconstruction_is_exact_on_a_synthetic_save(self): + data = tiny() + ck = sc.checksum_bytes(data) + self.assertTrue(ck.coverage["ok"]) + self.assertEqual(ck.coverage["rebuiltBytes"], len(data)) + + def test_reconstruction_is_exact_on_the_schema_fixture(self): + data, _ = sw.build_fixture() + ck = sc.checksum_bytes(data) + self.assertTrue(ck.coverage["ok"], f"first diff at {ck.coverage['firstDiff']}") + + def test_reconstruction_handles_gzip_and_bare_streams_alike(self): + data = tiny() + a = sc.checksum_bytes(data) + b = sc.checksum_bytes(gzip.compress(data, mtime=0)) + self.assertEqual(a.digest, b.digest) + self.assertTrue(b.coverage["ok"]) + + def test_a_broken_reconstruction_is_reported_not_swallowed(self): + res = sr.read_bytes(tiny()) + # corrupt the parse, not the file: a leaf whose raw no longer matches + for n in res.tree.children[0].children: + if n.name == "Turn": + n.raw = b"\xff\xff\xff\xff" + cov = sc.audit_coverage(res.tree, res.inflated) + self.assertFalse(cov["ok"]) + self.assertIsNotNone(cov["firstDiff"]) + + +# --- determinism and sensitivity --------------------------------------------- + +class DigestTest(unittest.TestCase): + def test_same_bytes_same_digest(self): + data = tiny() + self.assertEqual(sc.checksum_bytes(data).digest, sc.checksum_bytes(data).digest) + + def test_every_value_leaf_is_load_bearing(self): + """Flip each leaf in turn; the root must move every single time. + + This is the anti-'empty region set' test: a leaf the digest does not + actually consume would pass silently under any other check. + """ + base = sc.checksum_bytes(tiny()) + seen = 0 + for name, kw in (("status", dict(status_a=5)), ("checksum", dict(checksum=-1001)), + ("float", dict(fval=1.5000001))): + with self.subTest(name): + other = sc.checksum_bytes(tiny(**kw)) + self.assertNotEqual(base.digest, other.digest) + seen += 1 + self.assertEqual(seen, 3) + + def test_a_one_bit_float_change_moves_the_root(self): + a = sc.checksum_bytes(tiny(fval=1.5)) + (bits,) = struct.unpack(" str: + f = tempfile.NamedTemporaryFile(suffix=".sav", delete=False) + f.write(gzip.compress(data, mtime=0)) + f.close() + self.addCleanup(os.unlink, f.name) + return f.name + + def test_record_then_verify_matches(self): + files = [self._write(tiny(checksum=-1000)), self._write(tiny(checksum=-2000))] + chain = sc.build_chain(files) + self.assertTrue(all(t["coverage"] for t in chain["turns"])) + ok, msgs = sc.verify_chain(chain, files) + self.assertTrue(ok, msgs) + + def test_verify_stops_at_the_first_divergent_turn(self): + files = [self._write(tiny(checksum=-1000)), self._write(tiny(checksum=-2000))] + chain = sc.build_chain(files) + bad = [files[0], self._write(tiny(checksum=-2001))] + ok, msgs = sc.verify_chain(chain, bad) + self.assertFalse(ok) + self.assertIn("MATCH", msgs[0]) + self.assertIn("DIVERGE", msgs[1]) + self.assertTrue(any("/Summary" in m for m in msgs)) + + def test_chain_records_the_policy_it_was_built_under(self): + files = [self._write(tiny())] + chain = sc.build_chain(files, floats="canonical", mask="resave") + self.assertEqual(chain["policy"]["floats"], "canonical") + self.assertEqual(chain["policy"]["mask"], "resave") + json.dumps(chain) # must stay serialisable + + +# --- real saves --------------------------------------------------------------- + +@needs_saves +class RealSaveTest(unittest.TestCase): + def test_coverage_is_proved_on_every_available_save(self): + for p in REAL: + with self.subTest(os.path.basename(p)): + ck = sc.checksum_save(p) + self.assertTrue(ck.coverage["ok"], + f"reconstruction diverged at {ck.coverage['firstDiff']}") + self.assertEqual(ck.coverage["rebuiltBytes"], ck.coverage["inflatedBytes"]) + + def test_identical_bytes_give_identical_digests(self): + by_hash: dict = {} + for p in REAL: + with open(p, "rb") as f: + data = f.read() + by_hash.setdefault(data, []).append(p) + for data, paths in by_hash.items(): + if len(paths) < 2: + continue + digs = {sc.checksum_save(p).digest for p in paths} + self.assertEqual(len(digs), 1, paths) + + def test_repeated_runs_are_stable(self): + p = REAL[0] + self.assertEqual(sc.checksum_save(p).digest, sc.checksum_save(p).digest) + + def test_reader_is_clean_on_every_save(self): + for p in REAL: + with self.subTest(os.path.basename(p)): + ck = sc.checksum_save(p, audit=False) + self.assertEqual(ck.res.count("error"), 0) + self.assertEqual(ck.res.count("warn"), 0) + + def test_canonical_float_policy_is_a_noop_on_this_corpus(self): + """No save holds a NaN, a -0.0 or an infinity, so 'canonical' changes + nothing below the root. Recorded so a future save that breaks it fails + here rather than silently.""" + for p in REAL: + with self.subTest(os.path.basename(p)): + a = sc.checksum_save(p, audit=False, floats="bits") + b = sc.checksum_save(p, audit=False, floats="canonical") + self.assertEqual(sc.diff(a.root, b.root), []) + + def test_known_resave_delta_localises_to_five_named_leaves(self): + """determinism-oracle.md: loading a post-turn autosave and re-saving + changes exactly Player.Status (4 -> 0) on the four turn-participating + players, plus the derived Summary.Checksum. The tree must name them.""" + pairs = [] + for a in REAL: + for b in REAL: + if a >= b: + continue + ca, cb = sc.checksum_save(a, audit=False), sc.checksum_save(b, audit=False) + d = sc.diff(ca.root, cb.root) + if d and all(e.kind == "value" for e in d) and \ + {e.path.rsplit("/", 1)[-1] for e in d} == {"Status", "Checksum"}: + pairs.append((a, b, d)) + if not pairs: + self.skipTest("no re-save pair among the available saves") + for a, b, d in pairs: + self.assertEqual(len(d), 5, [repr(e) for e in d]) + statuses = [e for e in d if e.path.endswith("/Status")] + self.assertEqual(len(statuses), 4) + for e in statuses: + self.assertIn("/Sim/players/Player[", e.path) + self.assertEqual((e.a, e.b), (4, 0)) + chk = [e for e in d if e.path == "/Summary/Checksum"] + self.assertEqual(len(chk), 1) + self.assertEqual(chk[0].b - chk[0].a, -16) # 4 x (4 -> 0) + + def test_resave_mask_makes_that_pair_identical(self): + found = False + for a in REAL: + for b in REAL: + if a >= b: + continue + if sc.checksum_save(a, audit=False, mask="resave").digest == \ + sc.checksum_save(b, audit=False, mask="resave").digest and \ + sc.checksum_save(a, audit=False).digest != \ + sc.checksum_save(b, audit=False).digest: + found = True + if not found: + self.skipTest("no re-save pair among the available saves") + self.assertTrue(found) + + def test_a_real_turn_transition_localises_to_named_objects(self): + by_turn = {} + for p in REAL: + ck = sc.checksum_save(p, audit=False) + t = (ck.res.typed.get("summary") or {}).get("Turn") + by_turn.setdefault(t, ck) + if not {2, 3} <= set(by_turn): + self.skipTest("need a turn-2 and a turn-3 save") + d = sc.diff(by_turn[2].root, by_turn[3].root, limit=500) + self.assertGreater(len(d), 10) + self.assertTrue(any(e.path == "/Summary/Turn" for e in d)) + # every difference must be attributed to a path, never to a bare hash + for e in d: + self.assertTrue(e.path.startswith("/"), repr(e)) + named = [e for e in d if "/Sim/players/Player[" in e.path] + self.assertTrue(named, "a turn transition must move at least one player") + + +if __name__ == "__main__": + unittest.main(verbosity=2)