sots-re/verify/results/shim/tailrng/save_rng_delta.py
alex 1d50f1edda lane Z: the RNG ledger for one strategic turn, measured end to end
We consume 18-22 generator words per turn and model none of them as a count.
All of it is inside StrategyServer::ProcessTurn; OnAllCombatDone_Tail costs 0
on every turn observed; the residual outside the two drivers is exactly 0. The
generator does not move between turns at all, so the interval a standalone has
to reproduce is closed at both ends.

The instrument reads generator STATE, not calls, and that choice paid: the
image has four draw entry points, not three (NextUInt 0x004f7670 is in no
lane's primitive set) plus inlined draws in twelve functions, two reachable
from the turn roots. A primitive-counting hook would have undercounted
silently.

Checked against the save files independently: the turn-6 autosave pair gives
18 words read from the two Sim.RNG blobs, and with twists == 0 that number
never passes through a twist implementation -- so the two instruments do not
share the hidden assumption they could have.

Corrections to combat-done-tail.md, in place:
  * the node-line 0x20000-fleet check runs AFTER the Chance(0.5f) call and
    cannot gate the draw; the expiry test is NodePath::RemainingLife 0x006e2130
    and is now a formula rather than a description
  * StrategyHost::Autosave is ret 8 and returns the std::string* in EAX
  * SNMAllCombatDone IS delivered every End Turn (8 of 8) -- lane K's inference
    was right; the stronger no-encounter reading is narrowed, not closed
  * S+0x8 advances 12-14 times per turn, not twice

Node-line decay still has not fired. The hook reports the distance instead of
the absence: 51 of 53 lines are permanent, the mortal ones are dug ~1/turn by
the Zuul, each ~40 turns from expiry. It stays a labelled hypothesis.
2026-09-08 09:45:06 -04:00

63 lines
2.3 KiB
Python

"""Independent check of the live ledger: pull the RNG blob out of two save files and
compute the word delta from the FILES, with no reference to the shim's numbers."""
import sys, os
sys.path.insert(0, os.path.expanduser('~/sots-re/verify/save-reader'))
import save_reader as sr
N, M = 624, 397
def twist(mt):
mt = list(mt)
for kk in range(N - M):
y = (mt[kk] & 0x80000000) | (mt[kk+1] & 0x7fffffff)
mt[kk] = mt[kk+M] ^ (y >> 1) ^ (0x9908b0df if y & 1 else 0)
for kk in range(N - M, N - 1):
y = (mt[kk] & 0x80000000) | (mt[kk+1] & 0x7fffffff)
mt[kk] = mt[kk+(M-N)] ^ (y >> 1) ^ (0x9908b0df if y & 1 else 0)
y = (mt[N-1] & 0x80000000) | (mt[0] & 0x7fffffff)
mt[N-1] = mt[M-1] ^ (y >> 1) ^ (0x9908b0df if y & 1 else 0)
return mt
def find_rng(node, out):
name = getattr(node, 'name', None)
if name == 'RNG':
out.append(node)
for c in getattr(node, 'children', []) or []:
find_rng(c, out)
def rng_of(path):
res = sr.read_save(path)
hits = []
find_rng(res.tree, hits)
if not hits:
raise SystemExit(f"no RNG frame in {path}")
n = hits[0]
raw = n.raw
if not raw and n.children:
raw = b"".join(c.raw for c in n.children)
if not raw:
raise SystemExit(f"RNG frame in {path} carries no bytes")
return raw
def parse(raw):
# the blob is mt[624] then left, little-endian; tolerate a leading/trailing frame byte
for off in range(0, len(raw) - 2500 + 1):
if len(raw) - off < 2500: continue
mt = [int.from_bytes(raw[off+4*i:off+4*i+4], 'little') for i in range(N)]
left = int.from_bytes(raw[off+2496:off+2500], 'little', signed=True)
if 0 <= left <= N:
return mt, left, off, len(raw)
raise SystemExit(f"cannot parse RNG blob of {len(raw)} bytes")
a, b = sys.argv[1], sys.argv[2]
ra, rb = rng_of(a), rng_of(b)
ma, la, oa, na = parse(ra)
mb, lb, ob, nb = parse(rb)
print(f"{os.path.basename(a)}: blob {na} B, offset {oa}, left={la}")
print(f"{os.path.basename(b)}: blob {nb} B, offset {ob}, left={lb}")
cur, tw = ma, 0
while tw <= 64 and cur != mb:
cur = twist(cur); tw += 1
if cur != mb:
raise SystemExit("second block is not on the first block's chain within 64 twists")
words = 624*tw + (la - lb)
print(f"twists={tw} words consumed between the two files = {words}")