Z: per-call-site ledger -- every word of a turn attributed, nothing left over

Seven entry points detoured, each call keyed by __builtin_return_address(0)
with the word cost from left before/after. Three consecutive turns on
ref-turn2: site sums 19/18/20 against independently measured ProcessTurn totals
of 19/18/20, residual 0 every time. The 18-20 spread is now explained rather
than reported -- it is the two gated research draws.

The dominant consumer is FUN_00893290: two Chance calls per player across all
eight player-vector entries, 16 of every turn's 18-20 words, and it is NOT one
of lane I's 22 sites. Lane I said its closure covered direct edges only and
that indirect reachability was unsettled; this is that gap, measured. The
function is unidentified and is the highest-value target left.

Two bookkeeping corrections are in the report tool, not the shim: helper-
internal rows (Chance's own NextFloat) double-count, and 8 calls per turn are
on the StrategyClient's generator, not the strategic one. The first build did
not distinguish generators and reported 44 words against a bracket of 18 --
which is what caught it. A per-site ledger that cannot say which generator a
draw came from is not a ledger.
This commit is contained in:
alex 2026-09-08 10:41:54 -04:00
parent 707da6f848
commit e1735eea7e
3 changed files with 153 additions and 0 deletions

View file

@ -488,3 +488,78 @@ and unknown".
occurred. It is still one observation.
* **No `EVENT_*` or state-side check was made.** These hooks declare the generator and nothing else, so
this says the battle was RNG-free and says nothing about whether it was *computed* correctly.
---
## 11. The per-call-site ledger — every word of a turn, attributed
§2 said *where* a turn's words are spent (all inside `ProcessTurn`); this says *which call site* spends
them. The seven generator entry points are detoured and each call records
`__builtin_return_address(0)` — the game instruction after its own `call` — with the word cost taken from
`left` before and after (`sots-engine/src/shim/hooks/draw_sites.{h,cpp}`; report tool
`tools/rng_site_report.py`). This is attribution, not discovery: lane I closed the search space at seven
entry points and 22 sites in `ProcessTurn`'s closure.
Three consecutive End Turns on `ref-turn2`, build `z-sites2-20260908T1432Z`:
| call site | owner | entry point | calls/turn | words/turn |
|---|---|---|---|---|
| 0x0050329d | `FUN_00503200`+0x9d ← `DetectEncounters` (lane I, depth 4) | `NextFloat` | 1 | 1 |
| 0x007929a4 | `FUN_00792750`+0x254 ← `DetectEncounters` (lane I, depth 3) | `NextInt` | 1 | 1 |
| 0x00587888 | `TechTree::ProcessResearch`+0x1c8 | `NextFloat` | 0–1 | 0–1 |
| 0x0088df4f | `ServerPlayer::RollResearchEvent`+0x2f (lane T) | `NextFloat` | 0–1 | 0–1 |
| **0x00893426** | **`FUN_00893290`+0x196** | **`Chance`** | **8** | **8** |
| **0x00893513** | **`FUN_00893290`+0x283** | **`Chance`** | **8** | **8** |
| turn | site sum | `ProcessTurn`, measured independently | residual |
|---|---|---|---|
| 3 | **19** | 19 | **0** |
| 4 | **18** | 18 | **0** |
| 5 | **20** | 20 | **0** |
**Nothing is unattributed, on any of the three turns.** The two instruments share no code path — one reads
`left` around a boundary and reconstructs an absolute position, the other reads `left` around a single call
and keys on a return address — and they agree word for word. The 18–20 spread that §2 could only report is
now *explained*: it is the two optional research draws, both of which are gated.
### 11.1 The dominant consumer of a turn is not in the static inventory
**`FUN_00893290` spends 16 of every turn's 18–20 words** — two `Chance` calls per player across all eight
entries of the server's player vector — and it is **not one of lane I's 22 sites**. Lane I stated plainly
that its closure covers **direct** call edges only and that indirect-call reachability was unsettled. This
is that gap, measured: the single largest RNG consumer in a strategic turn is invisible to a direct-call
sweep from `StrategyServer::ProcessTurn`.
That is not a criticism of lane I's inventory — its recall claim is about the *tempering-immediate scan*,
which is complete, and its 22-site list is explicitly a direct-edge closure. It is the demonstration that
the caveat mattered. **`FUN_00893290` is unidentified as of this writing** and is the highest-value target
left: it decides something twice per player per turn, on a coin the game bothers to flip.
### 11.2 Two bookkeeping corrections the raw numbers need
The shim's raw totals are 35 / 34 / 36, not 19 / 18 / 20, and both differences are accounting rather than
measurement — `tools/rng_site_report.py` applies them and shows its working:
* **Helper-internal rows double-count.** `Chance` calls `NextFloat` internally, so its 16 words appear
twice: once on the `Chance` rows and once on a row whose return address (0x008e6e04) is *inside*
`Chance`'s own body. The report subtracts any row landing inside another entry point's body.
* **Other generators are not this generator.** 8 calls per turn come from `FUN_00578cf0`, `FUN_005798e0`
and `FUN_0069dbb0` drawing on a **different `Mars::RNG` instance** — the `StrategyClient`'s at
`client+0x134` (§7). They are real draws and they are correctly excluded: they never touch the strategic
generator the save serialises. The first version of this instrument did not distinguish them and reported
44 words against a bracket of 18, which is what caught it.
The second point is worth keeping: **a per-site RNG ledger that does not identify which generator each draw
came from is not a ledger.** The boundary instrument was immune to this by construction because it watches
one object; the site instrument had to be told.
### 11.3 What the site ledger did not see
* **`EncounterDetect_AssignContacts` never ran** on these three turns — its gate in
`EncounterDetect_ProcessTeamRecord` (some team-record member must have `+0xfc != 0`) was not satisfied.
Its hook recorded no call, so lane I's one inlined site in `ProcessTurn`'s closure contributed **0**,
which is consistent with the sums reconciling exactly. **The inlined-draw path is therefore still
unexercised**, and if it fires on some other save the site sum will fall short of the bracket by exactly
its cost — which is how it will announce itself.
* Three turns of one save. The two `Chance` sites fired 8/8 every turn with no variation, so nothing here
says what makes them fire *fewer* times, and `FUN_00893290`'s own gating is unmeasured.

78
tools/rng_site_report.py Executable file
View file

@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Per-call-site RNG ledger from a lane-Z trace with the draw-site detours enabled.
The boundary ledger (tools/rng_ledger_report.py) says how many words a turn spent and in which
phase. This says which CALL SITE spent them: the seven generator entry points are detoured and each
call records __builtin_return_address(0) -- the game instruction after its own `call` -- with the
word cost taken from `left` before and after.
Two corrections are applied here rather than in the shim, because both are bookkeeping:
* rows whose return address falls INSIDE another entry point's body are the helper's own internal
draw (Chance calls NextFloat; IntRangeBell calls NextInt twice). Those words are already counted
against the helper's row, so including them double-counts.
* rows tagged `strategic: false` came from a different Mars::RNG instance (the StrategyClient's at
+0x134, the tactical CombatSim's, a map-generation temporary). They do not touch the save's
strategic generator and must not be added to the turn's total.
The sum of the remaining rows must equal the bracket total the boundary ledger measured
independently. A shortfall is an unattributed word.
uv run python3 tools/rng_site_report.py <trace.jsonl>
"""
import json, sys, bisect, os
# entry-point bodies (RVAs), for the helper-internal test
BODIES = {"Chance": (0x4E6DD0, 0x4E6E2E), "FloatRange": (0x7D8A0, 0x7D8C9),
"IntRangeBell": (0x4E6D80, 0x4E6DC6), "GaussianRange": (0x4E6E30, 0x4E6FCA)}
def internal_of(rva):
for name, (lo, hi) in BODIES.items():
if lo <= rva <= hi:
return name
return None
fns, addrs = {}, []
fp = os.path.expanduser("~/sots-re/dumps/functions.json")
if os.path.exists(fp):
fns = json.load(open(fp))
addrs = sorted(int(k, 16) for k in fns)
def owner(va):
if not addrs:
return ""
i = bisect.bisect_right(addrs, va) - 1
if i < 0:
return ""
a = addrs[i]
name, _ = fns[f"0x{a:08x}"]
return f"{name}+0x{va - a:x}"
for line in open(sys.argv[1]):
d = json.loads(line)
if "hook" not in d or not d["hook"].endswith("Autosave"):
continue
a = {x.get("n"): x for x in d.get("args", []) if x.get("n")}
if a.get("end_turn", {}).get("v") is not False:
continue
rows = [r["v"] for r in a.get("draw_sites", {}).get("v", [])]
print(f"turn: strategic {a.get('draw_site_words',{}).get('v')} words / "
f"{a.get('draw_site_calls',{}).get('v')} calls; "
f"other generators {a.get('draw_site_words_other_rng',{}).get('v')} words / "
f"{a.get('draw_site_calls_other_rng',{}).get('v')} calls; "
f"overflow {a.get('draw_site_overflow',{}).get('v')}")
net = 0
for v in sorted(rows, key=lambda r: (not r["strategic"]["v"], r["ret_rva"]["v"])):
rva = v["ret_rva"]["v"]
ins = internal_of(rva)
strat = v["strategic"]["v"]
counted = strat and not ins
if counted:
net += v["words"]["v"]
call = rva - 5 # every entry point here is reached by a 5-byte call rel32
tag = "STRAT" if strat else "other"
note = f" [internal to {ins}: already counted on that row]" if ins else ""
print(f" {'*' if counted else ' '} {tag} {v['entry']['v']:<13} "
f"call 0x{call+0x400000:08x} {owner(call + 0x400000):<34} "
f"calls={v['calls']['v']:>3} words={v['words']['v']:>3}{note}")
print(f" => attributed strategic words: {net}")

Binary file not shown.