Seven End Turns from ref-turn2.sav on VM140, six control words, whole-state checksum on every post-turn autosave. 53-bit and 64-bit x87 give byte-identical state across all 35,394 leaves, so an x64/SSE port computing in IEEE double has NO double-rounding budget to preserve and floats=bits is free. Two settings do move state, each reproduced on a repeat run: 0x007f (24-bit) Sys[112 "Gamma Cephei"]/Pop2/PopG/PopC 540000000 -> 540000002 0x1a7f (round-up) Flt[34 "Beta Fleet"]/Pos/.[0] and /Pos/.[2], 1 ULP each So the port must hold intermediates at 53 bits and use round-to-nearest -- both SSE defaults, now measured rather than assumed, each with a named regression witness. The briefed triple was under-powered: 0x027f is 53-bit (it differs from 0x127f only in bit 12, infinity control, ignored since the 387) and 0x137f is 64-bit, not a rounding change. Run as written all three come back identical, and that would have "proved" something false on both axes that matter. Evidence the forced word actually held: read-back at each force site plus 38 independent in-pipeline hook samples per run spanning turn phases 4, 6 and 8, all reading the forced value. Mars::Application::Run calls _controlfp(0x50000,0x3070300) at 0x0089f606 every frame, which is 0x127f, so forcing at StrategyClient::EndTurn is wiped before the turn runs; StrategyServer::BeginProcessTurn is the point that works. Also re-confirms the End-Turn determinism oracle on engine cef889e: bb4fd9ac... / 978041ac... unchanged. New tools: verify/fpu-cw/cw_census.py, verify/fpu-cw/trace_bitdiff.py (the latter exists because under a forced 24-bit word the CRT's own %g rendering degrades, so trace text is not a valid comparison surface).
119 lines
4.1 KiB
Python
119 lines
4.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Bit-exact comparison of two shim traces of the same turn, hook by hook.
|
|
|
|
Why not just diff the text: **the trace's own float rendering is not a stable comparison
|
|
surface when the x87 control word has been forced.** The emitter prints an f32 with `%.9g`,
|
|
and the CRT's digit generation is itself done in x87 arithmetic, so under `fpu_cw = 0x007f`
|
|
(24-bit) the *same* float32 renders as `1.5647336` instead of `1.56473362`. Comparing the text
|
|
of a 24-bit run against a 53-bit run reports dozens of "differences" that are all the printf,
|
|
not the simulation. Every float here is therefore re-quantised to its IEEE bit pattern before
|
|
comparison, and per-process noise (pointers, thread id, timestamps, call ids, the `fpu_cw`
|
|
field that is the independent variable) is scrubbed.
|
|
|
|
Usage: trace_bitdiff.py <a.jsonl[.gz]> <b.jsonl[.gz]> [hook ...]
|
|
"""
|
|
import gzip
|
|
import json
|
|
import re
|
|
import struct
|
|
import sys
|
|
|
|
PTR = re.compile(r"^0x[0-9a-f]{8}$")
|
|
# Only hooks that fire *inside* the turn pipeline: ServerPlayer::ComputeBudget also runs once
|
|
# per UI frame, so its call count depends on how long the session sat at a menu and is not
|
|
# comparable between runs. Name it explicitly on the command line if you want it anyway.
|
|
DEFAULT_HOOKS = (
|
|
"Game::ServerSystem::ProcessTurn",
|
|
"Game::StrategyServer::MoveFleet",
|
|
"Game::TechTree::ProcessResearch",
|
|
"Game::ServerPlayer::OnTechResearched",
|
|
)
|
|
NOISE = ("ts", "call_id", "thread")
|
|
|
|
|
|
def f32_bits(v):
|
|
return struct.unpack("<I", struct.pack("<f", float(v)))[0]
|
|
|
|
|
|
def f64_bits(v):
|
|
return struct.unpack("<Q", struct.pack("<d", float(v)))[0]
|
|
|
|
|
|
def norm(o):
|
|
if isinstance(o, dict):
|
|
t = o.get("t")
|
|
if t == "ptr":
|
|
return {"t": "ptr", "n": o.get("n")}
|
|
if t == "f32":
|
|
return {"t": "f32", "n": o.get("n"), "bits": f32_bits(o["v"])}
|
|
if t == "f64":
|
|
return {"t": "f64", "n": o.get("n"), "bits": f64_bits(o["v"])}
|
|
return {k: norm(v) for k, v in o.items() if k not in NOISE}
|
|
if isinstance(o, list):
|
|
return [norm(x) for x in o]
|
|
if isinstance(o, str) and PTR.match(o):
|
|
return "<ptr>"
|
|
return o
|
|
|
|
|
|
def _open(path):
|
|
op = gzip.open if path.endswith(".gz") else open
|
|
return op(path, "rt", encoding="utf-8", errors="replace")
|
|
|
|
|
|
def load(path, hook):
|
|
out = []
|
|
needle = '"hook":"%s"' % hook
|
|
with _open(path) as f:
|
|
for line in f:
|
|
if needle not in line:
|
|
continue
|
|
rec = norm(json.loads(line))
|
|
rec["args"] = [a for a in rec.get("args", []) if a.get("n") != "fpu_cw"]
|
|
for side in (rec.get("side") or {}).values():
|
|
if not isinstance(side, dict):
|
|
continue
|
|
for snap in side.values():
|
|
if isinstance(snap, dict) and isinstance(snap.get("v"), dict):
|
|
snap["v"].pop("fpu_cw", None)
|
|
out.append(rec)
|
|
return out
|
|
|
|
|
|
def leafdiff(a, b, path=""):
|
|
out = []
|
|
if isinstance(a, dict) and isinstance(b, dict):
|
|
for k in sorted(set(a) | set(b)):
|
|
out += leafdiff(a.get(k), b.get(k), f"{path}.{k}")
|
|
elif isinstance(a, list) and isinstance(b, list) and len(a) == len(b):
|
|
for i, (x, y) in enumerate(zip(a, b)):
|
|
out += leafdiff(x, y, f"{path}[{i}]")
|
|
elif a != b:
|
|
out.append((path, a, b))
|
|
return out
|
|
|
|
|
|
def main():
|
|
pa, pb = sys.argv[1], sys.argv[2]
|
|
hooks = sys.argv[3:] or list(DEFAULT_HOOKS)
|
|
print(f"A {pa}\nB {pb}")
|
|
total = 0
|
|
for hook in hooks:
|
|
a, b = load(pa, hook), load(pb, hook)
|
|
if not a and not b:
|
|
continue
|
|
if len(a) != len(b):
|
|
print(f"{hook}: CALL COUNT DIFFERS {len(a)} vs {len(b)}")
|
|
total += 1
|
|
continue
|
|
diffs = [(i, p, x, y) for i, (ra, rb) in enumerate(zip(a, b)) for p, x, y in leafdiff(ra, rb)]
|
|
total += len(diffs)
|
|
print(f"{hook}: {len(diffs)} leaf difference(s) over {len(a)} record(s)")
|
|
for i, p, x, y in diffs:
|
|
print(f" rec[{i}] {p}: {x} -> {y}")
|
|
print(f"TOTAL: {total}")
|
|
return 1 if total else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|