New shim module `ai_rng`: a bracket on StrategyClient::OnResumePlaying over the per-client generator at +0x134, with two independent measurements per bracket (an observer on the existing seven draw-site detours, and `left` read straight off the object) plus AI-call-site attribution through the cl_* facades and an optional seed pin. Measured on VM140/VM145, six runs, five fresh processes, unpinned autosaves byte-identical to the published oracle: turn2->turn3 human 0, AI 32 -> 3 words, AI 496 -> 0, AI 512 -> 0 turn1->turn2 human 0, AI 32 -> 7 words, AI 496 -> 0, AI 512 -> 1 word ... or 3 The last row is the answer: 512 makes ONE cl_RandRange call (the research-target tie-break at 0x006a8495, phase 18) and that one call cost 1 word in one process and 3 in another, because RNG_NextInt is an unbounded rejection loop. The count is not fixed across clients, across turns, or across processes with the path held fixed. Also found: cl_RandFloat 0x00579c70, a third cl_* RNG facade that reaches RNG_NextFloat by a TAIL JUMP and so leaves no rel32 edge for a call-graph sweep -- which is why ai-turn-logic.md 5's "zero NextFloat calls from the AI module" reads as true when it is not. The instrument caught its own defect: RNG_Chance calls RNG_NextFloat and both are detoured, so a drawn word was reported twice. left_delta was never affected, which is the point of having two measurements. Gates run separately on a fresh build directory: clean_room_check OK, host ctest 55/55, CT111 shim cross-build OK.
115 lines
6.8 KiB
Markdown
115 lines
6.8 KiB
Markdown
# Lane PAR — predictions, written before the build
|
||
|
||
Question, as redirected by the coordinator: **is there *roll* parity?** Does one run of an AI client
|
||
consume a **fixed number of RNG words regardless of the path it takes** — the classic lockstep
|
||
discipline of drawing unconditionally so peers stay aligned?
|
||
|
||
Instrument: a bracket on `StrategyClient::OnResumePlaying 0x00777480` (the whole AI turn runs inside
|
||
it), measuring the per-client generator at `StrategyClient+0x134` two ways — the observer sum over
|
||
the seven hooked entry points, and the raw `left` delta on the object — plus per-return-address
|
||
attribution inside the bracket. Optional `airng.pin_seed` re-seeds that generator at bracket entry
|
||
so two runs start from an identical state and any remaining difference is *path*, not seed.
|
||
|
||
All predictions below are written before the instrument was built and before any run.
|
||
|
||
---
|
||
|
||
## P1 — the count is **not** constant across clients on the same turn
|
||
|
||
On `turn2-state.sav` there are three AI clients: **32** (owns a homeworld, fleets, a build queue) and
|
||
**496** / **512** (two Singularity shadow empires that own nothing at all — `Sav = 0`, no colonies,
|
||
no fleets, no systems, per `ai-order-emission.md` §2).
|
||
|
||
**Prediction:** client 32's bracket consumes **strictly more** words than 496's and 512's, and 496
|
||
and 512 consume the **same** count as each other.
|
||
|
||
*Falsified if:* all three consume the same count ⇒ the AI really does draw unconditionally and the
|
||
lockstep hypothesis survives its first and cheapest test. That would be the strong result.
|
||
*Also falsified, differently, if:* 496 and 512 differ from each other ⇒ the count depends on
|
||
something finer than "owns nothing", and the property is not even constant across identical boards.
|
||
|
||
## P2 — the human client's bracket is exactly 0 words
|
||
|
||
`OnResumePlaying` runs for the human client too; the AI branch is `if (this->+0x12c)`.
|
||
|
||
**Prediction:** the human client (pid 16) shows `agent=0` and **0 words**.
|
||
*Falsified if:* it draws ⇒ there is client-side RNG on the human path and the bracket is not
|
||
measuring what I think it is.
|
||
|
||
## P3 — the surrender roll costs **0** words, not 1
|
||
|
||
`RNG_Chance 0x008e6dd0` is documented `verified` as returning at `p <= 0` and `p >= 1` **without a
|
||
draw**. Process Turn phase 12 is `cl_Chance` on the surrender probability, which is 0 on a healthy
|
||
empire.
|
||
|
||
**Prediction:** no bracket on the reference board attributes a word to phase 12's return address.
|
||
*Falsified if:* a `Chance` row appears with `words > 0` ⇒ the surrender probability is non-zero on
|
||
this board, which is itself interesting.
|
||
*Why it matters:* the resolver's list of "draws whose result never reaches the save" names "the
|
||
surrender `Chance` at 0 %". If `Chance` early-outs, that entry is **wrong** and the ~8 words/turn
|
||
figure needs re-deriving. This is a rule-11 correction if it lands.
|
||
|
||
## P4 — the count is seed-dependent even when the path is fixed
|
||
|
||
`RNG_NextInt 0x004271c0` is documented `verified` as a **rejection loop**: mask = smallest `2^k-1 >=
|
||
n`, redraw while `(y & mask) > n`. So a `NextInt` whose bound is not `2^k-1` costs 1 word with
|
||
probability `(n+1)/(mask+1)` and more otherwise.
|
||
|
||
**Prediction:** with the *same save and the same path*, pinning two different seeds produces two
|
||
different bracket totals for at least one client, differing by a small number of words.
|
||
*Falsified if:* the total is invariant under the pinned seed ⇒ either no `NextInt` with a
|
||
non-power-of-two bound runs on this path, or `NextInt`'s body is not what the header says.
|
||
*Why it matters:* it separates the two things that could be meant by "fixed count". Seed-dependence
|
||
is **harmless** for a reimplementation (we reproduce the rejection loop bit-for-bit).
|
||
Path-dependence is the fatal one. P1 and P4 must be read together: only P1 tests the hypothesis.
|
||
|
||
## P5 — the one-shot schedule draw is **outside** this bracket
|
||
|
||
`0x0069dbb0` (`if (agent->+0x36c == 0) sched = turn + 3 + NextInt(0..37)`, two `NextInt` sites at
|
||
`0x0069dbfd`/`0x0069dc29`) sits in the **Prepare Turn** body, and `SEAIPrepareTurn` is raised only at
|
||
agent construction (`RunAI` / `CreateGame`), never per turn.
|
||
|
||
**Prediction:** those two return addresses never appear in an `OnResumePlaying` bracket; they fire
|
||
once per agent per process, on load. So the *load* consumes words that no turn does, and a
|
||
per-turn count that ignored the load would be wrong about the generator's position.
|
||
*Falsified if:* they appear in a bracket ⇒ Prepare Turn is reachable per-turn after all and
|
||
`ai-turn-logic.md` §1's "not a per-turn event" is wrong.
|
||
|
||
## P6 — the residual: `left`-delta will exceed the observer sum on at least one bracket
|
||
|
||
Rule 16: inlined draws are invisible to a call-graph sweep, and the seven entry-point detours are a
|
||
call-graph sweep by another name. The bracket also reads `left` on the object directly.
|
||
|
||
**Prediction:** for the AI clients the two agree exactly (lane I's inlined-draw scan found no hit in
|
||
the AI band 0x00680000–0x006e0000), and the instrument prints the residual on every bracket so a
|
||
disagreement cannot hide.
|
||
*Falsified if:* a residual appears ⇒ there is an inlined draw on the AI path that the campaign's
|
||
draw-site inventory does not have, and the whole per-site table is a lower bound.
|
||
|
||
## P7 — the overall verdict, predicted
|
||
|
||
Static reading already shows at least three genuinely conditional constructs on the AI path
|
||
(`Chance`'s two early-outs, `NextInt`'s rejection loop, the `+0x36c` one-shot guard) and the task
|
||
system is a *variable-length list* dispatched twice.
|
||
|
||
**Prediction: the answer is (d) genuinely state-dependent, not (a)/(b)/(c)** — but with a useful
|
||
qualification: the *variation is small and localised*, so the engine's problem is not "the count is
|
||
unpredictable" but "the count is a function of the decisions", i.e. `game/ai` must get the decisions
|
||
right after all, and C-exact does **not** get cheaper.
|
||
*Falsified if:* P1 comes back with all three clients equal.
|
||
|
||
---
|
||
|
||
## What would make me wrong in a way I would not notice
|
||
|
||
* **Rule 26.** A single run is not a control. Every configuration below is run in **two fresh
|
||
processes** and must agree with itself before it is used. The canonical pair is
|
||
`turn2-state → turn3-state`; `turn1-state` is known non-deterministic and is used only *with*
|
||
pinned seeds.
|
||
* **Rule 19.** The bracket detour is a MinHook detour on a function that runs the AI turn. If it
|
||
perturbs, the autosave moves. The instrumented run's autosave is compared against the published
|
||
oracle (`bb4fd9ac…` / `978041ac…`) on the unpinned configuration; a pinned run is *expected* to
|
||
differ and is never compared against the oracle.
|
||
* **Rule 20.** A bracket that shows 0 words for a client cannot distinguish "the client did not run"
|
||
from "it ran and drew nothing". The bracket therefore records entry/exit unconditionally and
|
||
prints a row even when the delta is 0, and records whether `agent != 0`.
|