The hypothesis under test was the lockstep discipline: that each run of an AI client consumes a
fixed number of draws regardless of the path it takes, so a reimplementation could keep the
generator aligned with the right COUNT and order of draws while getting the decisions wrong.
It is false, and it fails at four scopes. Measured with a new bracket on
StrategyClient::OnResumePlaying over the per-client generator at +0x134, six runs across VM140 and
VM145, five fresh processes; every unpinned run's autosaves are byte-identical to the published
oracle, so the instrument is behaviour-neutral (rules 19 and 26 both discharged).
across clients turn2->turn3: 3 / 0 / 0 words for AI players 32 / 496 / 512; human 0
across turns client 32: 3 words on turn 2, 7 on turn 1
across processes client 512 makes ONE cl_RandRange call on turn 1 -- the research-target
tie-break at 0x006a8495, phase 18 -- and it cost 1 word in one process and
3 in another, because RNG_NextInt is an unbounded rejection loop
per site RNG_Chance costs ZERO words at p<=0 and p>=1
Twenty-one live draw sites in an AI turn, in twelve functions (plus two provably dead ones); two
fired on the reference turn, three on turn 1. Only three are unconditional, and all three only
given that their enclosing function was called. Six of client 32's seven turn-1 words come from
the ship-design composer 0x006ad700, which is also where the only loop-carried draw lives.
Also: cl_RandFloat 0x00579c70, a third cl_* RNG facade, found twice independently. It reaches
RNG_NextFloat by a TAIL JUMP, so no rel32 sweep for the entry points can see it -- which is why
ai-turn-logic.md 5's 'zero NextFloat calls from the AI module' reads as true and is not. All 29
call sites of the three facades are inside the AI band: the cl_* RNG facade is AI-only surface.
Positives for the engine: the AI draws from nothing but its own client's generator (foreign_words
0 on every bracket), the human client draws nothing at all, and the per-turn cost is single digits.
Rung B is unaffected. Rung C needs the decisions.
62 lines
3.9 KiB
Python
Executable file
62 lines
3.9 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Lane PAR: tabulate shim.airng.txt brackets across runs."""
|
|
import re, sys, glob, os
|
|
# Nested entry points: RNG_Chance's own body calls RNG_NextFloat, and both are hooked, so a
|
|
# Chance that drew is recorded TWICE -- once against the game's call site and once against
|
|
# 0x008e6e09, the instruction after Chance's internal `call RNG_NextFloat`. The same holds for
|
|
# IntRangeBell/GaussianRange (0x008e6d80.., 0x008e6e30..), which this workload never hits.
|
|
# `left_delta` is measured on the object and is unaffected; `observed` needs this subtraction.
|
|
# This is the double-count draw_sites.h warns about, seen for the first time.
|
|
NESTED = [(0x008e6dd0, 0x008e6e30), (0x008e6d80, 0x008e6dd0), (0x008e6e30, 0x008e6ec0)]
|
|
def nested(va): return any(lo <= va < hi for lo, hi in NESTED)
|
|
|
|
SITE = {
|
|
0x008e6e09: "(inner) RNG_Chance's own NextFloat -- double-count of the Chance row",
|
|
0x00578d15: "cl_Chance->RNG_Chance (facade 0x00578cf0)",
|
|
0x006ad878: "cl_RandFloat->NextFloat @AIComposeShipBlueprint 0x006ad700",
|
|
0x00579915: "cl_RandRange->NextInt (facade 0x005798e0)",
|
|
0x0069086f: "cl_Chance(0.5f) @0x00690860",
|
|
0x006cce89: "RNG_NextInt @DesignNameGen 0x006ccdb0",
|
|
}
|
|
def parse(p):
|
|
rows=[]; sites={}; gens=[]; census=[]
|
|
for ln in open(p, errors="replace"):
|
|
m=re.match(r"airng seq=(\d+) pid=(\d+) agent=0x(\w+) rng=0x(\w+) pin=(\S+) left=(-?\d+)->(-?\d+) idx=(-?\d+)->(-?\d+) left_delta=(\d+) observed=(\d+) calls=(\d+) residual=(-?\d+) foreign_words=(\d+) foreign_calls=(\d+)", ln)
|
|
if m:
|
|
g=m.groups()
|
|
rows.append(dict(seq=int(g[0]),pid=int(g[1]),agent=g[2],rng=g[3],pin=g[4],
|
|
left_in=int(g[5]),left_out=int(g[6]),idx_in=int(g[7]),idx_out=int(g[8]),
|
|
delta=int(g[9]),obs=int(g[10]),calls=int(g[11]),resid=int(g[12]),
|
|
foreign=int(g[13])))
|
|
lm=re.search(r"life_in=(\d+) life_out=(\d+)", ln)
|
|
if lm: rows[-1]['life_in']=int(lm.group(1)); rows[-1]['life_out']=int(lm.group(2))
|
|
continue
|
|
m=re.match(r"airngsite seq=(\d+) pid=(\d+) ret_rva=\S+ va=0x(\w+) entry=(\w+) calls=(\d+) words=(\d+)", ln)
|
|
if m:
|
|
sites.setdefault(int(m.group(1)),[]).append((int(m.group(3),16),m.group(4),int(m.group(5)),int(m.group(6))))
|
|
continue
|
|
m=re.match(r"airngen seq=(\d+) rng=0x(\w+) life_words=(\d+) life_calls=(\d+)", ln)
|
|
if m: gens.append((int(m.group(1)),m.group(2),int(m.group(3)),int(m.group(4))))
|
|
m=re.match(r"airngcensus seq=(\d+) rng=0x(\w+) ret_rva=\S+ va=0x(\w+) entry=(\w+) calls=(\d+) words=(\d+)", ln)
|
|
if m: census.append((int(m.group(1)),m.group(2),int(m.group(3),16),m.group(4),int(m.group(5)),int(m.group(6))))
|
|
return rows,sites,gens,census
|
|
|
|
for p in sys.argv[1:]:
|
|
tag=os.path.basename(os.path.dirname(p))
|
|
rows,sites,gens,census=parse(p)
|
|
print(f"===== {tag} ({p})")
|
|
for r in rows:
|
|
life = f" life={r.get('life_in','?')}->{r.get('life_out','?')}" if 'life_in' in r else ""
|
|
kind = "HUMAN" if r['agent']=="00000000" else "AI "
|
|
print(f" seq={r['seq']} pid={r['pid']:4d} {kind} words={r['delta']:3d} (obs={r['obs']} calls={r['calls']} resid={r['resid']} foreign={r['foreign']}) idx {r['idx_in']}->{r['idx_out']}{life} pin={r['pin']}")
|
|
dbl = sum(w for va,_,_,w in sites.get(r['seq'],[]) if nested(va))
|
|
if dbl:
|
|
print(f" [nested-entry double count: {dbl} word(s); true site sum = {r['obs']-dbl} = left_delta {r['delta']}]")
|
|
for va,entry,calls,words in sites.get(r['seq'],[]):
|
|
mark = " (nested)" if nested(va) else ""
|
|
print(f" 0x{va:08x} {entry:10s} calls={calls} words={words}{mark} {SITE.get(va,'')}")
|
|
if gens:
|
|
last=max(g[0] for g in gens)
|
|
print(" -- generator census at last bracket --")
|
|
for seq,rng,w,c in gens:
|
|
if seq==last: print(f" rng=0x{rng} lifetime_words={w} calls={c}")
|