diff --git a/findings/control-flow/tail-rng-ledger.md b/findings/control-flow/tail-rng-ledger.md index fd776a8..83be8c3 100644 --- a/findings/control-flow/tail-rng-ledger.md +++ b/findings/control-flow/tail-rng-ledger.md @@ -9,9 +9,9 @@ autosave that byte-matches the original's*. Generator state is part of the saved and that both run **before** the autosave, and concluded that a reimplementation reproducing both `ProcessTurn` functions exactly would still diverge. Nobody had measured what a turn actually costs. -**The answer, up front.** On the reference save, a strategic turn advances the strategic generator by -**18–20 words**, *all* of it inside `StrategyServer::ProcessTurn`, and the residual outside the two turn -drivers is **exactly zero**. The tail's cost on these turns is **0**. The defect lane K found is real and +**The answer, up front.** Across eight measured End Turns on two saves, a strategic turn advances the +strategic generator by **18–22 words**, *all* of it inside `StrategyServer::ProcessTurn`, and the residual +outside the two turn drivers is **exactly zero**. The tail's cost on these turns is **0**. The defect lane K found is real and **latent**: it will bite the first turn a node line expires or a real battle resolves, and our saves reach neither. @@ -21,7 +21,7 @@ neither. Three sentences, then the evidence. -1. A turn on the reference save costs **18–20 generator words**, all of it inside +1. A turn costs **18–22 generator words**, all of it inside `StrategyServer::ProcessTurn`; the two autosave files bracket exactly that interval and nothing draws between them outside the two turn drivers. 2. `verify/results/shim/tailrng/z-t6-endturn.sav` → `z-t6-autosave.sav` is a **byte-identical oracle pair @@ -41,11 +41,23 @@ commissioned says by how much. The image has **four** draw entry points, not thr | 0x0047d830 | `Mars::RNG::NextFloat(&mt)` — `__thiscall`, no stack args, plain `ret` | **exactly 1** | | 0x004271c0 | `Mars::RNG::NextInt(&mt, uint* pMax)` — `ret 4`, rejection loop, bound re-read each iteration | **1 or more** | | 0x008e6dd0 | `Mars::RNG::Chance(RNG*, float p)` — `ret 4`; takes the **object** base and does the `add ecx,4` itself | **0 or 1** — see §6.1 | -| **0x004f7670** | **`Mars::RNG::NextUInt(RNG*)`** — plain `ret`, no stack args. **In no previous lane's primitive set**, and called from 0x007b6700 inside `ProcessTurn`'s closure | 1 | +| **0x004f7670** | **`Mars::RNG::NextUInt(RNG*)`** — plain `ret`, no stack args, 84 B. **In no previous lane's primitive set**, and called from 0x007b6700 inside `ProcessTurn`'s closure | 1 | -plus **inlined draws in twelve functions**, two of them reachable from the turn roots (0x007aa240 under -`ProcessTurn`, 0x007a7f30 under the combat resolver). A primitive-counting hook would have missed all of -those silently. +(Lane J's scan reaches 0x004f7670 from the other direction and calls it one of the fourteen *inlined-draw* +functions rather than a primitive. Both readings are of the same object and neither is wrong: it is a small +function whose draw is inlined, so it is a draw site that calls no primitive — which is exactly why a +call-graph sweep for `NextFloat`/`NextInt` cannot see it.) + +plus **inlined draws**: this lane's scan for the MT tempering immediates found twelve such functions, two of +them reachable from the turn roots (0x007aa240 under `ProcessTurn`, 0x007a7f30 under the combat resolver). +Lane J's independent scan, landed while this run was in flight, found **fourteen** and turned it into method +rule 16 — *inlined draws are invisible to call-graph sweeps, so any RNG accounting built from the call graph +alone is a lower bound.* Take lane J's number over this one; the two scans agree on the shape and the point +is the same either way. A primitive-counting hook would have missed every one of them silently. + +**This instrument is not built from the call graph**, which is why rule 16 does not apply to it: it reads +the generator's state before and after a boundary and reports the difference. An inlined draw, a draw in a +function nobody has named, a draw through a vtable — all of them move `left`, and all of them are counted. So the instrument reads **state**, not calls. `Mars::RNG` is `{void* vptr; uint32 mt[624]; uint32* next; int32 left}`, `sizeof 0x9cc` — `next` at +0x9c4 is a **pointer** @@ -97,7 +109,16 @@ file, which is exactly the interval a standalone has to reproduce. The turn-3 br construction** and is reported rather than dropped: the pre-turn autosave of the first End Turn after a load runs before any turn driver, so the hook has no server pointer yet and its record carries `words: null`. -**So: we consume 18–20 words per turn and model 0 of them.** +A second save, `zuul-turn16-noderoute` (Zuul vs Zuul), build `z-tailrng2-20260908T1328Z`: + +| turn | `ProcessTurn` | tail | `ApplyEncounterResult` | node-line decay | `ProcessNodeSpaceTravel` ×2 | **bracket total** | **residual** | +|---|---|---|---|---|---|---|---| +| 17 | **20** | 0 | 0 | 0 | 0, 0 | (incomplete) | — | +| 18 | **20** | 0 | 0 | 0 | 0, 0 | **20** | **0** | +| 19 | **22** | 0 | 0 | 0 | 0, 0 | **22** | **0** | +| 20 | **20** | 0 | 0 | 0 | 0, 0 | **20** | **0** | + +**So: we consume 18–22 words per turn and model 0 of them.** Not 0 because the modelling is bad — because *nothing in the repo models any part of a turn's RNG consumption as a count*. B1/B3/B4 compare the generator's state around three specific functions and get it @@ -254,10 +275,19 @@ by construction. ## 8. What is not settled, listed as loudly as the results -* **The combat resolver 0x007d5af0 (7499 B) has still never run under an instrument.** Every encounter this - workload produced had the no-battle flag set, so `ApplyEncounterResult` was a no-op every time. Its - measured 0 words says nothing whatever about combat's RNG cost. **The largest unmeasured term in the turn - is untouched**, and the residual-0 result above holds only for turns with no battle. +* **The combat resolver has still never run under an instrument.** Every encounter this workload produced + had the no-battle flag set, so `ApplyEncounterResult` was a no-op every time; its measured 0 words says + nothing whatever about combat's RNG cost, and the residual-0 result above holds only for turns with no + battle. + + Lane J read all 7,641 bytes of it in parallel with this run (`combat-resolver.md`; and note **7,641**, not + the 7,499 Ghidra reports — method rule 17). Its conclusion pairs with this one exactly: **the resolver has + no unconditional draw.** All three sites in its subtree are conditional — a node-cannon `NextInt`, an + inlined `NextFloat` per back-engineering candidate, and a `NextInt` per successful roll of that. So lane + J's cheap first prediction is directly testable with this instrument: **a plain fleet battle with no node + cannon and no salvage should cost the same 18–22 words as a peaceful turn.** That is the next run this + hook family should do, and it needs a workload nobody has built yet: a save where two hostile fleets + actually meet. * **Node-line expiry did not fire.** See §9 for the quantified distance rather than an absence. * **A turn with a genuinely empty encounter vector was not observed** (§3). Every turn of `ref-turn2` in contact produces exactly one sighting encounter. The tail-runs-every-turn claim is settled; the @@ -268,10 +298,16 @@ by construction. paid for once, at `S+0x64`). **Nothing in the ledger depends on it** — it is decoration on the argument record — but it should not be reused until someone resolves it. * **Which of the twelve-to-fourteen `S+0x8` increments per turn come from where** (§5). -* The direct-call sweeps behind "node-line decay's downstream pair draws nothing" and - "`ProcessNodeSpaceTravel` draws nothing" do not model indirect calls. Both are now **also** behavioural - facts on this workload (0 words, eight observations of `ProcessNodeSpaceTravel` and four of node-line - decay), which is the stronger evidence of the two. +* The direct-call sweeps behind "node-line decay's only RNG site is the `Chance(0.5f)`", "its downstream + pair draws nothing" and "`ProcessNodeSpaceTravel` draws nothing" are **worth less than they look**, and + method rule 16 (landed by lane J while this run was in flight) says why: an inlined draw leaves no + call-graph edge at all, so a call sweep is a lower bound. Rule 17 applies too — those sweeps clipped at + Ghidra's reported function sizes. + + **The behavioural measurement is what carries these claims, not the sweeps.** `ProcessNodeSpaceTravel` + moved the generator by 0 words on **16** observations (twice per turn, eight turns) and node-line decay by + 0 on **8**. That evidence is immune to both rules, because it does not ask which function drew — it asks + whether the generator moved. * **The ledger's block-chain machinery has never run live.** Every observation in both runs sat inside a single MT block — `left` walked 432 → 413 → 395 → 375 → 357 on `ref-turn2` and 263 → 243 → 223 → 201 → 181 on the Zuul save, never reaching 0. So every live word count reduces to `left_before − left_after`, and the