The hypothesis under test was the lockstep discipline: that each run of an AI client consumes a
fixed number of draws regardless of the path it takes, so a reimplementation could keep the
generator aligned with the right COUNT and order of draws while getting the decisions wrong.
It is false, and it fails at four scopes. Measured with a new bracket on
StrategyClient::OnResumePlaying over the per-client generator at +0x134, six runs across VM140 and
VM145, five fresh processes; every unpinned run's autosaves are byte-identical to the published
oracle, so the instrument is behaviour-neutral (rules 19 and 26 both discharged).
across clients turn2->turn3: 3 / 0 / 0 words for AI players 32 / 496 / 512; human 0
across turns client 32: 3 words on turn 2, 7 on turn 1
across processes client 512 makes ONE cl_RandRange call on turn 1 -- the research-target
tie-break at 0x006a8495, phase 18 -- and it cost 1 word in one process and
3 in another, because RNG_NextInt is an unbounded rejection loop
per site RNG_Chance costs ZERO words at p<=0 and p>=1
Twenty-one live draw sites in an AI turn, in twelve functions (plus two provably dead ones); two
fired on the reference turn, three on turn 1. Only three are unconditional, and all three only
given that their enclosing function was called. Six of client 32's seven turn-1 words come from
the ship-design composer 0x006ad700, which is also where the only loop-carried draw lives.
Also: cl_RandFloat 0x00579c70, a third cl_* RNG facade, found twice independently. It reaches
RNG_NextFloat by a TAIL JUMP, so no rel32 sweep for the entry points can see it -- which is why
ai-turn-logic.md 5's 'zero NextFloat calls from the AI module' reads as true and is not. All 29
call sites of the three facades are inside the AI band: the cl_* RNG facade is AI-only surface.
Positives for the engine: the AI draws from nothing but its own client's generator (foreign_words
0 on every bracket), the human client draws nothing at all, and the per-turn cost is single digits.
Rung B is unaffected. Rung C needs the decisions.
21 lines
3.1 KiB
JSON
21 lines
3.1 KiB
JSON
{
|
|
"_note": "Lane PAR 2026-09-08 -- ROLL PARITY: does one run of a strategic AI client consume a fixed number of RNG words regardless of path? Live bracket on StrategyClient::OnResumePlaying over the per-client generator, plus a static classification of every draw site in the AI turn. Two entries: the client player-id offset the bracket needs, and a THIRD cl_* RNG facade nobody had recorded because it reaches the generator by a TAIL JUMP and so leaves no rel32 call edge to any RNG entry point.",
|
|
"entries": [
|
|
{
|
|
"name": "StrategyClient_off_PlayerId",
|
|
"offset": "0x00000148",
|
|
"convention": "offset",
|
|
"prototype": "int -- the owning player's NET id on a Game::StrategyClient (0x708 bytes). Verified from the instruction stream in Game::StrategyApp::RunPendingAITurns 0x00838c60: the pending-AI vector at app+0x1c..+0x20 is walked in index order and each entry `edi` is matched against the client vector at app+0xc..+0x10 with `mov esi,[edx]; cmp DWORD PTR [esi+0x148],edi; je ...` at 0x00838cf0-0x00838cf8, where esi is a StrategyClient* and edi is a player net id. Corroborated by ClientOrder_FleetTask 0x007634d0, which passes `this->+0x148` as the first argument of the local validate 0x00821cf0. LIVE CROSS-CHECK: read out of the running game by lane PAR's OnResumePlaying bracket, it gives 16 / 32 / 496 / 512 on the reference board -- exactly the player ids the lane-L4 aiorders dump prints from the submitted TurnCommands blocks",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/roll-parity.md -- lane PAR 2026-09-08; instruction-stream read of dumps/sots.exe swept to the next function start (rule 17), plus a live read on VM140 in two fresh processes"
|
|
},
|
|
{
|
|
"name": "cl_RandFloat",
|
|
"addr": "0x00579c70",
|
|
"convention": "cdecl",
|
|
"prototype": "float () -- the THIRD member of the cl_* RNG facade, alongside cl_Chance 0x00578cf0 and cl_RandRange 0x005798e0. Whole 0x22-byte body: `eax = g_StrategyClients[g_CurrentClientIndex]; fldz; if (!eax) ret 0.0f; ecx = eax->+0x134; fstp st(0); lea ecx,[ecx+4]; JMP RNG_NextFloat` -- it reaches the generator by a TAIL JUMP, not a call, which is why an image-wide rel32 scan for the seven RNG entry points does not see it and why ai-turn-logic.md 5's \\\"zero calls from the AI module to NextFloat\\\" reads as true when it is not. Exactly ONE caller in the image, at 0x006ad873 inside the ship-design composer 0x006ad700, where the drawn unit is compared against 0.5. Because of the tail jump the draw-site detour records the return address 0x006ad878 -- the AI call site itself -- so this facade is the one whose consumer is directly visible in a draw-site table. Measured live: it spends exactly one MT word per call, 1 word on the reference turn 2->3 and 2 on turn 1->2",
|
|
"status": "verified",
|
|
"source": "findings/subsystems/roll-parity.md -- lane PAR 2026-09-08. Found twice independently: from the live draw-site table on VM140 (a NextFloat attributed to 0x006ad878, inside the AI band, which ai-turn-logic.md says cannot happen) and from a boundary-accurate image-wide rel32/rel8 scan that follows E9 tail jumps as well as E8 calls"
|
|
}
|
|
]
|
|
}
|