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
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
inventory of `ProcessTurn`'s draw sites and is still unidentified. Model that one first.
are trade-raid generation — `ServerTradeManager::GenerateTradeRaidEncounters` 0x00893290, two `Chance`
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/`:
`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
@ -327,9 +328,8 @@ would have said so by construction rather than by anyone noticing.
lists it.
* ~~Node-line expiry did not fire.~~ **It fired on turn 64 — §9.1.** What is still open about it is in
§9.3.
* **`FUN_00893290` is unidentified** and spends 16 of every turn's 18–20 words (§11.1). This is the single
biggest open item for anyone modelling a turn's RNG, and it is bigger than everything else on this list
put together.
* ~~`FUN_00893290` is unidentified.~~ **Identified — §11.1.** What is still open there is whether a
*successful* raid roll costs a further word (0 or 1, undetermined on a workload where none succeeded).
* **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
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
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
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`.
**16 of every turn's 18–20 words are `ServerTradeManager::GenerateTradeRaidEncounters` 0x00893290** —
`ServerTradeManagerImpl` vftable 0x00a31b74 **slot 10**, `thiscall(this, vector<TeamRecord>*)`, `ret 4`.
It loops over `StrategyServer::Players` and rolls, per player, up to three `Mars::RNG::Chance` calls on the
strategic generator:
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.
| site | probability | StrategyVar | image default | fired |
|---|---|---|---|---|
| +0x196 (0x00893426) | player raid | `TRADE_RAID_ODDS_PLAYER` | **0.2f** | 8/8 |
| +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

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",
"status": "verified",
"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)"
}
]
}