diff --git a/findings/control-flow/tail-rng-ledger.md b/findings/control-flow/tail-rng-ledger.md index 95879be..02d8417 100644 --- a/findings/control-flow/tail-rng-ledger.md +++ b/findings/control-flow/tail-rng-ledger.md @@ -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. diff --git a/tools/rng_site_report.py b/tools/rng_site_report.py new file mode 100755 index 0000000..d18798d --- /dev/null +++ b/tools/rng_site_report.py @@ -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 +""" +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}") diff --git a/verify/traces/tailrng-drawsites.jsonl.gz b/verify/traces/tailrng-drawsites.jsonl.gz new file mode 100644 index 0000000..0b89937 Binary files /dev/null and b/verify/traces/tailrng-drawsites.jsonl.gz differ