Z: the dominant RNG consumer is trade-raid generation, behind a virtual call

FUN_00893290 is ServerTradeManager::GenerateTradeRaidEncounters --
ServerTradeManagerImpl vftable slot 10 -- rolling Chance(TRADE_RAID_ODDS_PLAYER
= 0.2) and Chance(TRADE_RAID_ODDS_NPC = 0.05) once per player. Both are
strictly inside (0,1) so each is exactly one word, and no back-edge contains
either site, so one word per player per site is a hard bound.

Why no sweep found it: zero direct calls to it exist in the image and its only
reference is a vtable slot. The dispatch is a "call edx" through slot 10 at
0x007d8469 inside DetectEncounters -- one instruction before the DIRECT call
that lane I's closure did follow. Lane I's inventory is not wrong; its stated
caveat about indirect edges was load-bearing, and this is what it was hiding.

strategic-turn-internals.md line 153 had already named 0x00893290 "raid
encounter generation" against these exact StrategyVars. What was missing was
that it is where a turn's RNG goes.

Ghidra's size is wrong again: real body 1546 bytes ending 0x0089389a, reported
1532, ending mid-instruction. Rule 17, third time.
This commit is contained in:
alex 2026-09-08 10:56:55 -04:00
parent 9bf86325d9
commit 3d5f83414a
2 changed files with 84 additions and 15 deletions

View file

@ -21,8 +21,9 @@ neither.
1. **A turn costs 18–22 generator words on the reference save, and §11 says which call site spends each 1. **A turn costs 18–22 generator words on the reference save, and §11 says which call site spends each
one**, summing to the measured total with nothing left over on three consecutive turns. Sixteen of them one**, summing to the measured total with nothing left over on three consecutive turns. Sixteen of them
are two `Chance` calls per player in `FUN_00893290` — a function that is **not** in the static are trade-raid generation — `ServerTradeManager::GenerateTradeRaidEncounters` 0x00893290, two `Chance`
inventory of `ProcessTurn`'s draw sites and is still unidentified. Model that one first. rolls per player at `TRADE_RAID_ODDS_PLAYER` (0.2) and `TRADE_RAID_ODDS_NPC` (0.05). Model that first;
it is most of a turn.
2. **Two byte-identical oracle pairs with a known RNG cost** are in `verify/results/shim/tailrng/`: 2. **Two byte-identical oracle pairs with a known RNG cost** are in `verify/results/shim/tailrng/`:
`z-t6-endturn` → `z-t6-autosave` (18 words) and `z2-endturn` → `z2-autosave` (20). Both were verified `z-t6-endturn` → `z-t6-autosave` (18 words) and `z2-endturn` → `z2-autosave` (20). Both were verified
from the file bytes independently of any hook. Test against those before any other save, because no from the file bytes independently of any hook. Test against those before any other save, because no
@ -327,9 +328,8 @@ would have said so by construction rather than by anyone noticing.
lists it. lists it.
* ~~Node-line expiry did not fire.~~ **It fired on turn 64 — §9.1.** What is still open about it is in * ~~Node-line expiry did not fire.~~ **It fired on turn 64 — §9.1.** What is still open about it is in
§9.3. §9.3.
* **`FUN_00893290` is unidentified** and spends 16 of every turn's 18–20 words (§11.1). This is the single * ~~`FUN_00893290` is unidentified.~~ **Identified — §11.1.** What is still open there is whether a
biggest open item for anyone modelling a turn's RNG, and it is bigger than everything else on this list *successful* raid roll costs a further word (0 or 1, undetermined on a workload where none succeeded).
put together.
* **A turn with a genuinely empty encounter vector was not observed** (§4). Both saves produce exactly one * **A turn with a genuinely empty encounter vector was not observed** (§4). Both saves produce exactly one
sighting encounter on every turn. The tail-runs-every-turn claim is settled; the no-encounters variant is sighting encounter on every turn. The tail-runs-every-turn claim is settled; the no-encounters variant is
still an inference, now a much narrower one. still an inference, now a much narrower one.
@ -543,18 +543,55 @@ Three consecutive End Turns on `ref-turn2`, build `z-sites2-20260908T1432Z`:
and keys on a return address — and they agree word for word. The 18–20 spread that §2 could only report is 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. 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 ### 11.1 The dominant consumer is trade-raid generation, reached by a virtual call
**`FUN_00893290` spends 16 of every turn's 18–20 words** — two `Chance` calls per player across all eight **16 of every turn's 18–20 words are `ServerTradeManager::GenerateTradeRaidEncounters` 0x00893290** —
entries of the server's player vector — and it is **not one of lane I's 22 sites**. Lane I stated plainly `ServerTradeManagerImpl` vftable 0x00a31b74 **slot 10**, `thiscall(this, vector<TeamRecord>*)`, `ret 4`.
that its closure covers **direct** call edges only and that indirect-call reachability was unsettled. This It loops over `StrategyServer::Players` and rolls, per player, up to three `Mars::RNG::Chance` calls on the
is that gap, measured: the single largest RNG consumer in a strategic turn is invisible to a direct-call strategic generator:
sweep from `StrategyServer::ProcessTurn`.
That is not a criticism of lane I's inventory — its recall claim is about the *tempering-immediate scan*, | site | probability | StrategyVar | image default | fired |
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 | +0x196 (0x00893426) | player raid | `TRADE_RAID_ODDS_PLAYER` | **0.2f** | 8/8 |
left: it decides something twice per player per turn, on a coin the game bothers to flip. | +0x283 (0x00893513) | NPC raid, gated on `0.0f < S->+0x1a0` (player-independent, so all-or-nothing per turn) | `TRADE_RAID_ODDS_NPC` | **0.05f** | 8/8 |
| +0x33e (0x008935ce) | refugee raid, gated on a subsystem manager being present | `TRADE_RAID_ODDS_REFUGEE` | **0.05f** | 0/8 |
All three probabilities are strictly inside (0,1), so `Chance` takes neither early-out and spends exactly
one word — which is why the measurement is 1 per call. **No back-edge in the function contains any of the
three sites**, so one word per player per site is a hard bound, not an observation. The records vector it
is handed is the same 0x74-stride `TeamRecord` vector lane I's `EncounterDetect_Run` receives.
**Why no static sweep found it.** There are **zero** direct `call rel32` targets equal to 0x00893290 in the
image, and exactly one dword `0x00893290` in `.rdata` — at 0x00a31b9c, vftable + 0x28. The edge is
```
007d845d mov ecx,[esi+0x158] ; S->tradeManager
007d8463 mov eax,[ecx] ; vptr
007d8465 mov edx,[eax+0x28] ; slot 10
007d8469 call edx ; <-- 0x00893290, VIRTUAL
007d8470 lea ecx,[ebp-0x30] ; ... ; call 0x007cb080 <-- lane I's EncounterDetect_Run, DIRECT
```
inside `StrategyServer::DetectEncounters`, which `ProcessTurn` calls directly. Lane I's closure **entered
this very function and followed the direct call one instruction later**; it could not follow the virtual
one, and said so. So this is not a hole in lane I's work — the tempering-immediate scan's recall claim is
intact and its 22-site list is explicitly a direct-edge closure. It is the demonstration that the caveat
was load-bearing: **the largest single RNG consumer of a strategic turn hangs off a virtual edge inside a
function the closure already contained.**
The repo had also already met this function without knowing what it cost:
`findings/subsystems/strategic-turn-internals.md` line 153 lists 0x00893290 as "raid encounter generation"
against these exact three StrategyVars. What was missing was the connection to the ledger.
**Ghidra's size is wrong again** (method rule 17): the real body ends at 0x0089389a, 1546 bytes, and
Ghidra's 1532 lands mid-instruction — the same failure lane I hit on 0x007aa240.
**One live cost is not yet bounded.** A *successful* roll calls slot 17,
`ServerTradeManager::CreateRaidEncounter` 0x008938a0, which draws a `NextInt` at 0x008939ee to pick a
target — but returns without drawing when the candidate list is empty. So a success costs **0 or 1 further
word**. On these three turns it cost 0, which is consistent either with no roll succeeding (≈11% on the
image defaults, so three quiet turns in a row is unremarkable) or with an empty candidate list every time.
A word count cannot separate those, and separating them is the cheapest experiment left on this path.
### 11.2 Two bookkeeping corrections the raw numbers need ### 11.2 Two bookkeeping corrections the raw numbers need

View file

@ -111,6 +111,38 @@
"prototype": "sizeof(Game::ServerPlayer) = 0x3e0, from the two `push 0x3e0` + operator new sites that precede the ctor call: 0x007865b3 (the bare factory reached through the class-registry word at 0x00a26078) and 0x0078a2f5 (the save loader, which also sets +0x8 = S+4 and inserts into the entity hash at S+0x84). The ctor itself is 0x008803d0 -- NOTE that findings/control-flow/turn-driver.md \u00a73 cites 0x00880474 as 'the ServerPlayer constructor', which is an address INSIDE it; the instruction there is `mov WORD [esi+0x3b4],0x100`, a 16-bit store, so it sets ResErrRoll(+0x3b4) = 0 and cta(+0x3b5) = 1, not '+0x3b4 = 1' as that note reads", "prototype": "sizeof(Game::ServerPlayer) = 0x3e0, from the two `push 0x3e0` + operator new sites that precede the ctor call: 0x007865b3 (the bare factory reached through the class-registry word at 0x00a26078) and 0x0078a2f5 (the save loader, which also sets +0x8 = S+4 and inserts into the entity hash at S+0x84). The ctor itself is 0x008803d0 -- NOTE that findings/control-flow/turn-driver.md \u00a73 cites 0x00880474 as 'the ServerPlayer constructor', which is an address INSIDE it; the instruction there is `mov WORD [esi+0x3b4],0x100`, a 16-bit store, so it sets ResErrRoll(+0x3b4) = 0 and cta(+0x3b5) = 1, not '+0x3b4 = 1' as that note reads",
"status": "verified", "status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)" "source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
},
{
"name": "ServerTradeManager_GenerateTradeRaidEncounters",
"addr": "0x00893290",
"convention": "thiscall",
"prototype": "void (ServerTradeManagerImpl* this, std::vector<TeamRecord>* records) // RET 4. THE DOMINANT STRATEGIC-RNG CONSUMER OF A TURN: 16 of every turn's 18-20 words on the reference save, measured live. `this+4` is the RAW StrategyServer base (GetServer 0x0080eb50 returns it minus 4). Loops over StrategyServer::Players (RAW+0x50/+0x54, 4-byte stride) -- 8 entries on the Human saves -- and per player rolls up to THREE Mars::RNG::Chance calls on the strategic generator at S+0x16c, each exactly one MT word because all three probabilities are strictly inside (0,1): +0x196 (0x00893426) Chance(TRADE_RAID_ODDS_PLAYER, image default 0.2f) -> kind 0; +0x283 (0x00893513) Chance(TRADE_RAID_ODDS_NPC, 0.05f) gated on 0.0f < S->+0x1a0, which is PLAYER-INDEPENDENT so the site is all-or-nothing per turn -> kind 1; +0x33e (0x008935ce) Chance(TRADE_RAID_ODDS_REFUGEE, 0.05f) gated on a subsystem manager being present -> kind 2. NO BACK-EDGE CONTAINS ANY OF THE THREE SITES, so the cost is a hard bound of one word per player per site. Two per-player skip gates exist (a visitedMask bit test at 0x00893302 and a `>2` pre-filter at 0x008933e0) and neither fired on ref-turn2. A SUCCESSFUL roll calls vslot 17 (ServerTradeManager_CreateRaidEncounter), which draws 0 or 1 FURTHER word. The record vector is the 0x74-stride TeamRecord vector at StrategyServer+0x1e8 -- the same one lane I's EncounterDetect_Run receives one instruction later. REAL SIZE 0x60a = 1546 BYTES, ending 0x0089389a; Ghidra reports 1532 and its end lands mid-instruction (method rule 17). Already named 'raid encounter generation' by findings/subsystems/strategic-turn-internals.md line 153 with these exact StrategyVars -- what was new is that it is where a turn's RNG goes",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md \u00a711 (lane Z 2026-09-08; per-call-site measurement live, body and vtable read from the instruction stream)"
},
{
"name": "StrategyServer_DetectEncounters_TradeRaidVCall",
"addr": "0x007d8469",
"convention": "site",
"prototype": "site in StrategyServer::DetectEncounters 0x007d7f70, and THE CONCRETE FALSIFIER FOR 'not in the direct-call closure'. `mov ecx,[esi+0x158]; mov eax,[ecx]; mov edx,[eax+0x28]; push edi; call edx` -- a VIRTUAL dispatch through ServerTradeManagerImpl vftable slot 10 to ServerTradeManager_GenerateTradeRaidEncounters, passing the same TeamRecord vector that the DIRECT call at 0x007d8470 (lane I's EncounterDetect_Run) receives one instruction later. There are ZERO direct `call rel32` targets equal to 0x00893290 in the whole image, and exactly one dword 0x00893290 in .rdata, at 0x00a31b9c = vftable 0x00a31b74 + 0x28. The interface vftable Game::ServerTradeManager 0x00a311a4 has purecall in that slot, so dispatch is the only way in. Lane I's 22-site inventory of ProcessTurn's closure follows E8/E9 rel32 only and says so; this edge is `call edx`, so THE LARGEST SINGLE RNG CONSUMER OF A TURN HANGS OFF A VIRTUAL EDGE INSIDE A FUNCTION THE CLOSURE ALREADY CONTAINS",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md \u00a711.1 (lane Z 2026-09-08)"
},
{
"name": "ServerTradeManager_CreateRaidEncounter",
"addr": "0x008938a0",
"convention": "thiscall",
"prototype": "bool (ServerTradeManagerImpl* this, TeamRecord* out, ServerPlayer* p, int kind, std::vector<TeamRecord>* records) // ServerTradeManagerImpl vftable 0x00a31b74 slot 17, called from GenerateTradeRaidEncounters at 0x0089345e / 0x00893548 / 0x00893603 on each successful Chance roll. Real body 0x008938a0..0x00893af9. Draws `Mars::RNG::NextInt(&S->rng.mt, cands.size()-1)` at 0x008939ee to pick a raid target -- ONE FURTHER MT WORD -- but returns false at 0x0089391c WITHOUT DRAWING when the candidate vector from 0x0083b110 is empty. So a successful raid roll costs 0 or 1 extra word. It drew 0 on ref-turn2 turns 3-5, which is consistent with no roll succeeding (P ~= 0.8^8 * 0.95^8 ~= 11% on the image defaults) OR with an empty candidate list every time; the two are not distinguishable from a word count and this is the cheapest remaining experiment on this path",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md \u00a711.1 (lane Z 2026-09-08)"
},
{
"name": "ServerTradeManagerImpl_vftable",
"addr": "0x00a31b74",
"convention": "note",
"prototype": "Game::ServerTradeManagerImpl offset-0 vftable, 22 slots, bases ServerTradeManagerImpl -> ServerTradeManager -> TradeManager -> Mars::IStreamable. SLOT 10 (+0x28, at 0x00a31b9c) = ServerTradeManager_GenerateTradeRaidEncounters 0x00893290; SLOT 17 = ServerTradeManager_CreateRaidEncounter 0x008938a0. The interface vftable Game::ServerTradeManager 0x00a311a4 has purecall in all 21 non-destructor slots. The instance is constructed by 0x00858f70 (writes vptrs 0x00a31b74 / 0x00a31b64) from 0x007d7d7b and 0x007dd1fd, and stored at StrategyServer+0x158 in the S frame (+0x154 in the stored frame, which addresses.json already calls StrategyServer_off_TradeManager). Lane K's combat-done-tail.md \u00a79 tier 4 calls the eight end-of-turn vtable calls on S+0x158 'the largest blind spot in the map' -- this closes two of that class's slots",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md \u00a711.1 (lane Z 2026-09-08)"
} }
] ]
} }