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.
|
||||
* **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
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