sots-re/tools/rng_ledger_report.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

92 lines
4.7 KiB
Python
Executable file

#!/usr/bin/env python3
"""Per-turn RNG ledger from a lane-Z trace (docs: findings/control-flow/tail-rng-ledger.md).
The lane-Z hooks declare the strategic Mars::RNG as a region whose `describe` reports an ABSOLUTE
WORD POSITION (see sots-engine/src/shim/hooks/rng_ledger.h). This tool turns those positions into
the three numbers a reimplementation needs:
* how many 32-bit words the generator consumed inside each hooked call;
* the same, attributed by phase, using the fact that the hooks nest;
* the bracket total between the pre-turn autosave and the post-turn autosave -- the interval a
standalone has to reproduce -- and the RESIDUAL left over after the attributed subtotals.
A residual > 0 is a draw site outside every hooked function. A `words: null` is a position the
ledger could not place and MUST NOT be read as zero.
uv run python3 tools/rng_ledger_report.py <trace.jsonl>
"""
import json, sys
def val(tv):
if tv is None: return None
return tv.get('v')
def sv(struct_tv, key):
if struct_tv is None or struct_tv.get('t') != 'struct': return None
return val(struct_tv['v'].get(key))
rows = []
for i, line in enumerate(open(sys.argv[1])):
d = json.loads(line)
if 'meta' in d and 'hook' not in d: continue
if 'hook' not in d: continue
args = {a.get('n'): val(a) for a in d.get('args', []) if a.get('n')}
side = d.get('side', {})
rng = side.get('rng', {})
b, a2 = rng.get('before'), rng.get('after')
wb, wa = sv(b, 'words'), sv(a2, 'words')
rows.append(dict(cid=d['call_id'], depth=d.get('depth'), hook=d['hook'].split('::')[-1],
turn=args.get('turn'), pc=args.get('phase_counter'),
enc=args.get('encounters'), end_turn=args.get('end_turn'),
paths=args.get('node_paths'),
perm=args.get('np_permanent'), imm=args.get('np_immortal'),
mortal=args.get('np_mortal'), minlife=args.get('np_min_life'),
within5=args.get('np_within5'), res_nb=args.get('res_no_battle'),
predict=args.get('predict_words', args.get('predict_nodeline_words')),
wb=wb, wa=wa, lb=sv(b,'left'), la=sv(a2,'left'),
bb=sv(b,'block'), ba=sv(a2,'block'),
words=(wa - wb) if (wa is not None and wb is not None) else None,
err=d.get('err'), undecl=d.get('undeclared_total')))
hdr = f"{'cid':>4} {'d':>1} {'hook':<28} {'turn':>4} {'S+8':>4} {'enc':>4} {'paths':>5} {'pred':>4} {'w_in':>8} {'w_out':>8} {'WORDS':>6} {'left':>10}"
print(hdr); print('-'*len(hdr))
for r in rows:
print(f"{r['cid']:>4} {r['depth']:>1} {r['hook']:<28} {str(r['turn']):>4} {str(r['pc']):>4} "
f"{str(r['enc']):>4} {str(r['paths']):>5} {str(r['predict']):>4} {str(r['wb']):>8} "
f"{str(r['wa']):>8} {str(r['words']):>6} {str(r['lb'])+'->'+str(r['la']):>10}"
+ (f" ERR={r['err']}" if r['err'] else ''))
# Brackets: Autosave(end_turn=1) .. Autosave(end_turn=0)
nl = [r for r in rows if r['hook'] == 'NodeLineDecay']
if nl:
print()
print("node-line population (phase 11's draw is one word per EXPIRED line):")
print(f"{'turn':>5} {'paths':>6} {'permanent':>10} {'immortal':>9} {'mortal':>7} {'min_life':>9} {'<=5':>4} {'expired':>8} {'words':>6}")
for r in nl:
print(f"{str(r['turn']):>5} {str(r['paths']):>6} {str(r['perm']):>10} {str(r['imm']):>9} "
f"{str(r['mortal']):>7} {str(r['minlife']):>9} {str(r['within5']):>4} "
f"{str(r['predict']):>8} {str(r['words']):>6}")
enc = [r for r in rows if r['hook'] == 'OnAllCombatDone_Tail']
if enc:
print()
print("tail invocations (P1: does it run on every End Turn?):")
for r in enc:
print(f" turn {r['turn']}: encounters={r['enc']} words={r['words']}")
print()
marks = [r for r in rows if r['hook'] == 'Autosave']
for k in range(len(marks) - 1):
lo, hi = marks[k], marks[k+1]
if not (lo['end_turn'] is True and hi['end_turn'] is False): continue
if lo['wb'] is None or hi['wb'] is None:
print(f"bracket {k}: INCOMPLETE (pre-turn marker has no ledger position)"); continue
total = hi['wb'] - lo['wb']
inner = [r for r in rows if lo['cid'] < r['cid'] < hi['cid'] and r['depth'] == 0 and r['words'] is not None]
acc = sum(r['words'] for r in inner)
# The autosave hook's `this` is a StrategyHost, so its record carries no turn; take the turn
# from the drivers the bracket encloses.
turn = next((r['turn'] for r in inner if r['turn'] is not None), None)
print(f"BRACKET turn {turn}: total={total} words attributed={acc} residual={total-acc}")
for r in inner:
print(f" {r['hook']:<30} {r['words']:>6}")