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.
92 lines
4.7 KiB
Python
Executable file
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}")
|