board: claim VM140 for lane M; MoveFleet ULP + ObservedTech rows

This commit is contained in:
alex 2026-09-08 03:09:37 -04:00
parent 05095e93da
commit 5b74b98778
3 changed files with 1215 additions and 0 deletions

View file

@ -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 <tool> '<json>'` 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<TurnEvents{int EvTurn; vector<PlayerEvent>}>`, 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<ObservedTech> 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 |

View file

@ -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("<I", 0xBEEFBEEF)
END = struct.pack("<I", 0x41104110)
# --- policy -------------------------------------------------------------------
FLOAT_POLICIES = ("bits", "canonical")
#: Documented canonicalisation presets (see determinism-oracle.md). A rule is
#: (leaf tag, required path prefix, replacement kind, replacement value) -- the
#: path prefix keeps a mask from silently swallowing a same-named field
#: somewhere else in the tree, and the run reports how many leaves each rule
#: actually hit so an over-broad mask is visible rather than comfortable.
#:
#: Masking is opt-in. By default nothing is masked, so the known re-save delta
#: is *localised* rather than absorbed.
MASK_PRESETS = {
"none": (),
# load -> 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("<I", raw)
if bits == 0x80000000: # -0.0 -> +0.0
return b"\0\0\0\0"
if (bits & 0x7F800000) == 0x7F800000 and (bits & 0x007FFFFF):
return struct.pack("<I", 0x7FC00000) # any NaN -> 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("<f", a)
(y,) = struct.unpack("<f", b)
if math.isnan(x) or math.isnan(y):
return 0.0 if (math.isnan(x) and math.isnan(y)) else math.inf
if math.isinf(x) or math.isinf(y):
return 0.0 if x == y else math.inf
def ordinal(v: bytes) -> int:
(u,) = struct.unpack("<i", v)
return u if u >= 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("<I", len(p)))
d.update(p)
return d.digest()
def _label(node: sr.Node, index: int, lead: sr.Node | None,
unique: bool = False) -> 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 "<frame>"
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"<derived>", True
if mkind == "int":
return kind, struct.pack("<i", mval), True
raise ValueError(f"bad mask spec {(tag, under, mkind, mval)!r}")
raw = node.raw
if kind == "float":
return kind, float_key(raw, policy), False
if kind == "bool" and policy == "canonical":
return kind, b"\x01" if any(raw) else b"\x00", False
return kind, raw, False
def _build(node: sr.Node, path: str, label: str, policy: str, masks, hits: dict) -> 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("<i", len(b)) + b
pad()
out += BEGIN
for c in node.children:
reconstruct(c, out, base)
out += END
return
b = (node.name or "").encode("ascii")
out += struct.pack("<i", len(b)) + b
if node.kind == "string":
out += struct.pack("<i", len(node.raw)) # reader's raw excludes the length prefix
out += node.raw
pad()
def audit_coverage(tree: sr.Node, inflated: bytes) -> 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 = "<bytes>", 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("", "<save>", "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"<raw {len(a.raw)} B {_h(a.raw).hex()[:SHORT]}>"
bv = f"<raw {len(b.raw)} B {_h(b.raw).hex()[:SHORT]}>"
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())

View file

@ -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("<I", struct.pack("<f", 1.5))
(nudged,) = struct.unpack("<f", struct.pack("<I", bits + 1))
b = sc.checksum_bytes(tiny(fval=nudged))
self.assertNotEqual(a.digest, b.digest)
d = sc.diff(a.root, b.root)
self.assertEqual(len(d), 1)
self.assertEqual(d[0].ulps, 1.0)
def test_policy_is_domain_separated_into_the_root(self):
"""A strict root and a lenient root must never be confusable."""
data = tiny()
self.assertNotEqual(sc.checksum_bytes(data, floats="bits").digest,
sc.checksum_bytes(data, floats="canonical").digest)
# --- localisation -------------------------------------------------------------
class LocalisationTest(unittest.TestCase):
def test_diff_names_the_object_not_just_the_hash(self):
a = sc.checksum_bytes(tiny(status_a=4, status_b=4))
b = sc.checksum_bytes(tiny(status_a=0, status_b=0))
d = sc.diff(a.root, b.root)
paths = sorted(e.path for e in d)
self.assertEqual(paths, ['/Sim/players/Player[16 "p16"]/Status',
'/Sim/players/Player[32 "p32"]/Status'])
self.assertEqual([(e.a, e.b) for e in d], [(4, 0), (4, 0)])
def test_identical_trees_report_nothing(self):
a = sc.checksum_bytes(tiny())
b = sc.checksum_bytes(tiny())
self.assertEqual(sc.diff(a.root, b.root), [])
def test_an_inline_id_list_reports_as_one_list_not_a_shift_cascade(self):
a = sc.checksum_bytes(tiny())
b = sc.checksum_bytes(tiny(extra_ids=(48,)))
d = sc.diff(a.root, b.root)
lists = [e for e in d if e.kind == "list"]
self.assertEqual(len(lists), 1, [repr(e) for e in d])
self.assertEqual(lists[0].path, "/Sim/PlayerIDs[]")
self.assertEqual(lists[0].b, [48])
self.assertEqual(lists[0].a, [])
def test_sibling_indices_are_per_tag_so_insertions_do_not_renumber(self):
a = sc.checksum_bytes(tiny())
b = sc.checksum_bytes(tiny(extra_ids=(48,)))
# the players group keeps its labels even though ids were inserted before it
pa = {c.label for c in a.root.find("/Sim").find("/Sim/players").children}
pb = {c.label for c in b.root.find("/Sim").find("/Sim/players").children}
self.assertEqual(pa, pb)
def test_object_identity_folds_the_id_in(self):
"""Two objects with the same body but different ids must differ."""
w = sc.checksum_bytes(tiny())
p16 = w.root.find('/Sim/players/Player[16 "p16"]')
self.assertIsNotNone(p16)
self.assertEqual(p16.label, 'Player[16 "p16"]')
# --- float policy -------------------------------------------------------------
class FloatPolicyTest(unittest.TestCase):
NEG0 = struct.pack("<I", 0x80000000)
POS0 = struct.pack("<I", 0x00000000)
SNAN = struct.pack("<I", 0x7F800001)
QNAN = struct.pack("<I", 0x7FC00000)
FLT_MAX = struct.pack("<I", 0x7F7FFFFF)
def test_bits_policy_separates_signed_zero_and_nan_payloads(self):
self.assertNotEqual(sc.float_key(self.NEG0, "bits"), sc.float_key(self.POS0, "bits"))
self.assertNotEqual(sc.float_key(self.SNAN, "bits"), sc.float_key(self.QNAN, "bits"))
def test_canonical_policy_merges_them(self):
self.assertEqual(sc.float_key(self.NEG0, "canonical"), sc.float_key(self.POS0, "canonical"))
self.assertEqual(sc.float_key(self.SNAN, "canonical"), sc.float_key(self.QNAN, "canonical"))
def test_canonical_leaves_every_ordinary_value_alone(self):
for v in (0.0, 1.0, -1.0, 1e-30, 3.4028234663852886e38, 11.106206893920898):
raw = struct.pack("<f", v)
self.assertEqual(sc.float_key(raw, "canonical"), raw, v)
def test_flt_max_is_not_infinity(self):
"""events.md: the default EvPos is FLT_MAX (0x7f7fffff), not inf."""
(v,) = struct.unpack("<f", self.FLT_MAX)
self.assertFalse(math.isinf(v))
self.assertEqual(sc.float_key(self.FLT_MAX, "canonical"), self.FLT_MAX)
def test_ulps_apart(self):
one = struct.pack("<f", 1.0)
(b,) = struct.unpack("<I", one)
self.assertEqual(sc.ulps_apart(one, one), 0.0)
self.assertEqual(sc.ulps_apart(one, struct.pack("<I", b + 1)), 1.0)
self.assertEqual(sc.ulps_apart(one, struct.pack("<I", b + 5)), 5.0)
# crossing zero is continuous under the ordinal map
self.assertEqual(sc.ulps_apart(self.POS0, self.NEG0), 0.0)
self.assertEqual(sc.ulps_apart(one, struct.pack("<f", float("inf"))), math.inf)
self.assertEqual(sc.ulps_apart(self.QNAN, self.SNAN), 0.0)
def test_tolerance_is_not_available_as_a_hashing_policy(self):
"""Deliberate: a tolerant hash is a contradiction (see STATE_CHECKSUM.md 3.4)."""
self.assertEqual(set(sc.FLOAT_POLICIES), {"bits", "canonical"})
with self.assertRaises(ValueError):
sc.float_key(struct.pack("<f", 1.0), "tol:2")
# --- masking ------------------------------------------------------------------
class MaskTest(unittest.TestCase):
def test_resave_mask_absorbs_exactly_the_documented_delta(self):
a = sc.checksum_bytes(tiny(status_a=4, status_b=4, checksum=-1000), mask="resave")
b = sc.checksum_bytes(tiny(status_a=0, status_b=0, checksum=-1016), mask="resave")
self.assertEqual(a.digest, b.digest)
def test_mask_reports_what_it_hit(self):
ck = sc.checksum_bytes(tiny(), mask="resave")
self.assertEqual(ck.mask_hits, {"Status": 2, "Checksum": 1})
def test_mask_is_path_scoped(self):
"""A same-named tag outside the rule's path must NOT be masked."""
w = sw.SaveWriter("joint")
w.begin("Sim")
w.int("Status", 4) # not under /Sim/players/Player[...]
w.end()
ck = sc.checksum_bytes(w.bytes(), mask="resave")
self.assertEqual(ck.mask_hits, {})
other = sc.checksum_bytes(w.bytes().replace(struct.pack("<i", 4),
struct.pack("<i", 0)), mask="resave")
self.assertNotEqual(ck.digest, other.digest)
def test_no_mask_by_default(self):
a = sc.checksum_bytes(tiny(status_a=4))
b = sc.checksum_bytes(tiny(status_a=0))
self.assertNotEqual(a.digest, b.digest)
self.assertEqual(a.mask_hits, {})
# --- chain --------------------------------------------------------------------
class ChainTest(unittest.TestCase):
def _write(self, data: bytes) -> 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)