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:
parent
707da6f848
commit
e1735eea7e
3 changed files with 153 additions and 0 deletions
|
|
@ -488,3 +488,78 @@ and unknown".
|
||||||
occurred. It is still one observation.
|
occurred. It is still one observation.
|
||||||
* **No `EVENT_*` or state-side check was made.** These hooks declare the generator and nothing else, so
|
* **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.
|
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
78
tools/rng_site_report.py
Executable 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}")
|
||||||
BIN
verify/traces/tailrng-drawsites.jsonl.gz
Normal file
BIN
verify/traces/tailrng-drawsites.jsonl.gz
Normal file
Binary file not shown.
Loading…
Add table
Reference in a new issue