From 7e726b9074a0b1654a1705dea16cfbf3517fa2f7 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 20:14:14 -0400 Subject: [PATCH 1/3] AC: predictions before the active-contents workload Written before the game was started. Records one correction to lane L3 up front: the turn-15 save already has a trade sector with tsnumflt=1 (the AI's), so 'any freighter anywhere' is already falsified as the gate for the trade callees. --- docs/AC-predictions.md | 140 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/AC-predictions.md diff --git a/docs/AC-predictions.md b/docs/AC-predictions.md new file mode 100644 index 0000000..06559c6 --- /dev/null +++ b/docs/AC-predictions.md @@ -0,0 +1,140 @@ +# Lane AC — predictions, written before the workload was built + +Rule 2. Committed before the game was started, before the first End Turn, and before any probe +counter was read. Guest VM141 (`sots-re-win10-b`, `192.168.10.143`), starting from lane L3's +`human-turn15-spyprogram.sav`. + +The question: **does the strategic-turn tail draw RNG when the trade and spy containers hold +*active* contents rather than merely present ones?** Lane L3 retired lane W3's risk for populated +containers; the residual condition it named is freighters in service (`tsnumflt > 0`) and a +deployed spy (`spyon != 0`). + +--- + +## 0. One fact from the save that changes the prior before anything is run + +Lane L3's §3.5 says *"every trade sector has `tsnumflt = 0` — no freighters in service"*. Read out +of the save L3 shipped, that is **not true**. `human-turn15-spyprogram.sav`, `trdmgr`, sector +`TradeID 816` (`tsgridID 5`): + +``` +tssec 2 tsct 2 tsnumflt 1 tsflt 2736 +``` + +and the fleet with id `2736` is `Freighters`, `PID 32` — the **AI**'s. Its six member systems +(`tssys` 192, 288, 304, 336, 352, 464 → indices 9, 15, 16, 18, 19, 26) are all AI-owned. The +single serialized route record `trdmgr.rt` (`tro 32, trfow 1, trto 16, trtow 1, trfr 18, trtc 10`) +is likewise the AI's. + +So across L3's seven instrumented turns the game **already had a trade sector with a freighter in +service and an active route**, and `TradeManager::Slot13RngCalleeA/B` were entered **0** times +anyway. The five sectors with `tsnumflt = 0` are the ones the human player owns or shares. + +This does not make the experiment pointless — it sharpens it. It rules out "any freighter anywhere" +as the gate before I spend a turn, and it means the surviving trade-side hypotheses are narrower: +a per-player gate, a count threshold, a route-*state* condition (`TROUTESTATUS_*` is +Inactive/New/Developing/Established/Shutting Down), or the raid-candidate list being the thing that +is empty rather than the roll being un-taken. + +--- + +## 1. Predictions + +### P1 — the trade half (primary) + +**Freighters in service on the human player's own trade sectors will NOT make +`TradeManager::Slot13RngCalleeA` (0x00820ca0) or `Slot13RngCalleeB` (0x0088b440) fire.** Both stay +at `calls = 0` on every instrumented End Turn, while `ServerTradeManagerImpl::vslot13` (0x0088ef80) +and `vslot15` (0x0082cca0) stay at exactly 1. + +Confidence: **moderate-to-high**, and it rests on §0 rather than on L3's stated reason. An AI +freighter has already failed to trip it for seven turns; the only way a human freighter trips it is +if the gate is per-player, which is possible (`vslot13` may loop players) but is not the simplest +reading of "entered once per turn". + +*Falsification and its symptom.* If A or B reports `calls >= 1` on the first turn after +`tsnumflt` goes non-zero on a human sector, P1 is dead and the answer to the lane's question is +"yes, the tail draws under active contents". Symptom: a non-zero `calls` in the `probe_entries` +table on the post-turn `Autosave` record, with `installed = true` on that row. + +### P2 — the trade half, second order + +If P1 holds, then either (a) the gate is not "a freighter exists" at all, or (b) it is a threshold +above 1. **I predict (a).** Falsifier: if the callees fire only once the human side reaches +`TRADE_ROUTE_MAX_FREIGHTERS = 5` on a sector, (b) is right and P2 is wrong; symptom is a step +change in `calls` exactly at the turn `tsnumflt` reaches 5. + +### P3 — the spy half + +**A deployed spy (`spyon != 0`, `tdep >= 0`, `spyat` set) WILL make +`SpyManager::Slot13RngCallee` (0x008408e0) fire, at 1 entry per turn per deployed spy.** + +Confidence: **low-to-moderate**, and deliberately opposite in sign to P1. The reason to split them +is that the spy path has a per-turn stochastic element the trade path does not obviously have — +`sdet` ("spy detection", −1 in the save) and `deat` are per-spy fields that a deployed spy must +have rolled for each turn, and a detection roll is exactly the shape of thing +`Slot13RngCallee` would be. `cbh`, `cm`, `cmo`, `ncp` are all still zero on the undeployed spy. + +*Falsification.* If a spy reaches `spyon != 0` and `Slot13RngCallee` still reports `calls = 0` on +the following End Turn, P3 is dead and both containers are confirmed inert at every reachable +state — which closes lane W3's risk outright. Symptom: `calls = 0` with `installed = true` on that +row while the save shows `spyon != 0`. + +### P4 — `spies2` + +**`spies2` will become non-empty in exactly the system a spy is deployed at, and nowhere else.** +i.e. `spies2` *is* a per-system spy list and it fills only for a **deployed** spy. That is the +second of the two readings L3 left open, and it is the one I expect. + +Confidence: moderate. `spies2` sits on `ServerSystem`, and a built-but-docked spy has no system to +belong to (`tdep = -1`, `spyat = 0`), so a container keyed by system cannot hold it. + +*Falsification.* If a spy is deployed and `spies2` is still `0` in all 28 systems, then `spies2` is +**not** the spy list and the first reading is right; the rule-6 flags on `spies2`, `SysMem` and +`mts` stay up and the field needs a different explanation. Symptom: post-deploy save parses with +`spies2` count 0 everywhere while `spymgr.spy.spyon != 0`. + +### P5 — reachability of the active spy state at all + +**Deploying a spy in this save may be unreachable, and if so I will say so rather than infer.** +The engine's own refusals require a system that is *not mine*, has *no enemy fleet present*, has +*no spy already*, and **has an asteroid belt**. `ARes2` on `ServerSystem` is the asteroid-resource +field (its non-zero values 1703–2531 sit inside `SYSTEM_MIN/MAX_ASTEROID_RESOURCES` 1500–3000). +In the turn-15 save only five of 28 systems have `ARes2 > 0`: + +| idx | id | name | `ARes2` | owner | +|---|---|---|---|---| +| 1 | 64 | Ka'Palum | 2061 | unowned | +| 4 | 112 | Regulus | 1703 | unowned | +| 10 | 208 | Kam'Tara | 1948 | unowned, **AI fleet present** (Omikron Fleet II, 4 ships) | +| 12 | 240 | Downbelow | 2172 | **mine** | +| 27 | 480 | Hitomi | 2531 | player 576, `AFlags 128`, position (0,0,0) | + +**No AI-owned system has an asteroid belt.** So the textbook target — an enemy colony with a belt — +does not exist here. I predict the reachable routes are, in order: deploy at an **unowned** belt +system (Regulus is 9.01 ly from the tender's berth at Epsilon Eridani against a hull range of 9.0, +so this is marginal and node travel may or may not save it); or wait for the AI to colonize +Kam'Tara, where it already has a fleet, and deploy once its fleet leaves. + +*Falsification.* If the Special menu offers `Deploy Spy` at an unowned belt system, P5's pessimism +is wrong and the path is short. Symptom: the menu entry is enabled and the action is accepted. + +--- + +## 2. What would make me wrong about the whole framing + +- **The outer callees may not iterate the containers that were measured.** `vslot13`/`vslot15` are + entered once per turn regardless of container contents, which is equally consistent with their + loop being over *players* or over *encounters* and the trade/spy containers being read further in. + If so, "active contents" is the wrong axis entirely and no workload on these two vectors will ever + trip the inner callees. Symptom: a static read of the gate shows a predicate on neither container. +- **The instrument may not be armed.** `probes=11` is L3's config and is line-for-line comparable + with lane H, but lane H bisected `probes=11` as **not** determinism-neutral (the detour on probe + index 8, `GenerateTradeRaidEncounters`, suppresses a draw elsewhere). `probes=8` is the largest + proven byte-neutral configuration and it still installs indices 0–7, i.e. **all four outer callees + and all three inner ones**. I will take the primary reading at `probes=8` for that reason and + cross-check at `probes=11` for comparability, and I will report `installed=` for every row rather + than a bare zero (rule 20; and lane L3's own `hooks=off watch=on` defect is the worked example of + a config that reports a confident nothing). +- **A control that does not agree with itself is not a control** (rule 26). The `watch=on`/`watch=off` + pair goes on `ref-turn2.sav` in two fresh processes before any workload number is trusted. From 0f750488495419a1f0bf1902bd8b7048269b08e5 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 20:35:31 -0400 Subject: [PATCH 2/3] AC: prediction addendum from the static gate decode, before any active-state measurement --- docs/AC-predictions.md | 87 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/docs/AC-predictions.md b/docs/AC-predictions.md index 06559c6..7ea2064 100644 --- a/docs/AC-predictions.md +++ b/docs/AC-predictions.md @@ -138,3 +138,90 @@ is wrong and the path is short. Symptom: the menu entry is enabled and the actio a config that reports a confident nothing). - **A control that does not agree with itself is not a control** (rule 26). The `watch=on`/`watch=off` pair goes on `ref-turn2.sav` in two fresh processes before any workload number is trusted. + +--- + +## 3. Addendum — written after the static decode of the gates, before any measurement of an active state + +The gates on all three inner callees were decoded out of the instruction stream (`sots.exe`, +image base 0x00400000) before the workload was played. That changes what is worth measuring, so the +new predictions are written here rather than fitted afterwards. **Nothing below had been measured +when it was written**; the only live readings so far are the VM141 determinism-oracle control +(matched the published hashes exactly) and the shim banner showing the watchpoint module armed. + +### The decoded gates + +`Slot13RngCalleeA` (Q, 0x00820ca0) and `Slot13RngCalleeB` (R, 0x0088b440) are called **directly** +from `ServerTradeManagerImpl::vslot13` (0x0088ef80) at 0x0088f036 and 0x0088f042. That function +does **not** iterate trade routes or trade sectors — it iterates +`StrategyServer+0x64..+0x68`, the **fleet** vector, and for each fleet requires: + +- `StarFleet+0xa0` (`LocID`) to resolve to a node whose kind word (`node+0x14`) is **2**. Kind is + set by the `StarMapNode(int)` ctor: `StarSystem` passes 0, `StarFleet` 1, **`TradeSector` 2**. So + the fleet must be located *at a trade-sector node*, not at a star. +- the fleet's `Pos` (`+0x18`) to be **bit-equal** to the sector's `Pos` — arrived, not in transit. +- `sector+0x90` (`tscr`) to have the **fleet owner's player-index bit set**; +- and if `sector+0x8c` (`tsct`) also has that bit, the fleet to carry `FtFlg & 0x800`. + +`SpyManager::Slot13RngCallee` (P, 0x008408e0) is called from `ServerSpyManager::vslot13` +(0x008877b0) at 0x00887af2, behind: `deat != 0` (deployed at a real system), `sdet != -1` +(**already detected on an earlier turn**), `turn - sdet >= 3`, the system still owned, and owned by +someone other than the spy's owner. + +### P6 — the trade half is structurally closed to both real players in this save + +`tscr` is **252 = 0b11111100** on all six sectors of `human-turn15-spyprogram.sav`, and the save has +eight player slots: index 0 `re` (human), index 1 `The Eternal Empire` (AI), and indices 2–7 the +six `NPC = true` slots (`Spengler` ×2 rebel-AI, `Alien Menace`, `Peacekeeper Enforcer`, +`Von Neumann`, `Independent Colony`). **252 is exactly the NPC set and excludes both real players.** + +So I predict: **parking a player-owned fleet exactly on a trade-sector node — even flagged as a +raider — will NOT make Q or R fire.** Trade raiding is an NPC mechanic; `TRADE_RAID_ODDS_NPC` and +`TRADE_RAID_ODDS_REFUGEE` exist for that reason and `TRADE_RAID_ODDS_PLAYER` is what a *raid against* +a player pays. + +Falsifier: Q reports `calls >= 1` on the turn after a player fleet is parked on a sector node. +Symptom: non-zero `calls` on the `TradeManager::Slot13RngCalleeA` row with `installed = true`. +That result would kill the `tscr`-is-a-player-mask reading and reopen the ledger. + +This is the experiment worth running because it separates two explanations that seven turns of +L3's workload could not: "no lane ever parked a fleet on a sector node" versus "players are excluded +by `tscr`". The AI's own `Freighters` fleet (`LocID 816`, the id of trade sector `TradeID 816`, +`Pos` bit-equal to that sector's) **already passes the node-kind and position gates** and is still +rejected — which is the first evidence for the mask reading and is why P6 is stated with confidence +rather than as a coin flip. + +### P7 — the spy half WILL make the tail draw, but not through an entry probe + +This is the prediction that matters most, and it is the one L3's instrument could not have seen. + +The `sdet == -1` arm of `vslot13` (taken at 0x00887856 → 0x00887c5b) is the **detection roll**: +it calls `FUN_0081f570(spy, system, owner)`, tests `FUN_00743f80(system) & 1`, and on that bit rolls +`FUN_008e6dd0(StrategyServer+0x16c /* RNG */, spy->sdo)`, then stamps `sdet = turn` and posts +`EVENT_SPY_DETECTED`. That roll is **inline in the outer callee**, not inside P. + +But it is unreachable today because the gate *before* it is `deat != 0` at 0x0088781d, and the +corpus spy has `deat = 0` (`tdep = -1`, `spyon = 0`, docked to its tender). + +So: **deploying the spy is predicted to make `OnAllCombatDone_Tail` consume more RNG words per turn +than it does with the spy docked, while all three inner-callee entry probes still read 0.** +Roughly one extra draw per deployed spy per turn, conditional on `FUN_00743f80(system) & 1`. + +If that holds, the honest headline is **yes, the tail draws under active contents** — the RNG ledger +and the standalone's generator model do need extending — and the reason seven instrumented turns +said otherwise is that the probe table measures *entry into P/Q/R* while the draw lives in the +outer function. That is rule 20 pointing the other way: instrumenting the entry is necessary and +was not sufficient. **The primary measurement is therefore the `side.rng` word delta on +`OnAllCombatDone_Tail`, not the probe counts.** + +Falsifier: the tail's word delta is unchanged after deployment. Symptom: identical +`rng.after.words - rng.before.words` on the tail hook across the docked and deployed turns of the +same save lineage. + +### P8 — where the spy can go + +`ARes2` is the asteroid-belt field, and the only non-own system with a belt in this save is +**Hitomi** (idx 27, id 480, `ARes2 2531`), owned by player index 7 `Independent Colony` +(`AFlags 128`). Not the AI. So the deploy target, if one exists at all, is Hitomi — two fleet moves +from the tender's berth at Epsilon Eridani via Aldebaran. If Hitomi is undiscovered or unreachable, +the active spy state is out of reach in this save and I will report that rather than infer around it. From 06d2ddfeec10f93ce89a4341a0051b7d8a11c9b4 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 21:31:39 -0400 Subject: [PATCH 3/3] AC: address fragment for the trade-raid gate (regenerated header) Adds lane-ac.json's six entries via tools/gen_addresses.py: the trade-sector node-kind gate, the StarMapNode kind ctor, the raid predicate and the two per-player trade-sector masks, plus the spy detection-roll branch as a hypothesis. Header regenerated, never hand-resolved (rule 14). Also removes FUN_xxxxxxxx identifiers from docs/AC-predictions.md, which clean_room_check.sh caught. --- docs/AC-predictions.md | 6 ++-- include/generated/sots_addresses.h | 50 +++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/docs/AC-predictions.md b/docs/AC-predictions.md index 7ea2064..10985dd 100644 --- a/docs/AC-predictions.md +++ b/docs/AC-predictions.md @@ -196,8 +196,8 @@ rather than as a coin flip. This is the prediction that matters most, and it is the one L3's instrument could not have seen. The `sdet == -1` arm of `vslot13` (taken at 0x00887856 → 0x00887c5b) is the **detection roll**: -it calls `FUN_0081f570(spy, system, owner)`, tests `FUN_00743f80(system) & 1`, and on that bit rolls -`FUN_008e6dd0(StrategyServer+0x16c /* RNG */, spy->sdo)`, then stamps `sdet = turn` and posts +it calls a helper at `0x0081f570(spy, system, owner)`, tests `0x00743f80(system) & 1`, and on that bit rolls +`0x008e6dd0(StrategyServer+0x16c, the RNG, with spy->sdo)`, then stamps `sdet = turn` and posts `EVENT_SPY_DETECTED`. That roll is **inline in the outer callee**, not inside P. But it is unreachable today because the gate *before* it is `deat != 0` at 0x0088781d, and the @@ -205,7 +205,7 @@ corpus spy has `deat = 0` (`tdep = -1`, `spyon = 0`, docked to its tender). So: **deploying the spy is predicted to make `OnAllCombatDone_Tail` consume more RNG words per turn than it does with the spy docked, while all three inner-callee entry probes still read 0.** -Roughly one extra draw per deployed spy per turn, conditional on `FUN_00743f80(system) & 1`. +Roughly one extra draw per deployed spy per turn, conditional on `0x00743f80(system) & 1`. If that holds, the honest headline is **yes, the tail draws under active contents** — the RNG ledger and the standalone's generator model do need extending — and the reason seven instrumented turns diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 7cbb9cd..606e166 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 7d51767, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ f84ffc0, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1319,6 +1319,18 @@ constexpr uint32_t StrategyNetworkClient_off_Server = 0x00000054; constexpr uint32_t StrategyServer_AbandonChaosCheck = 0x003b9df0; // thiscall void (StrategySim* this /* S+4 */, ...) // the fleet-move command handler; logs "StrategySim: Fleet not found.", "StrategySim: Waypoint %d(id) not found." and "StrategySim: (see above) cannot move fleet %d(id)." Bumps ModCount at 0x008657aa. THE ONLY ModCount WRITER OTHER THAN THE ABANDON CHECK THAT IS DIRECT-CALL REACHABLE FROM EITHER TURN DRIVER (from OnAllCombatDone_Tail's 1369-function closure; not from ProcessTurn's 1382) [verified] constexpr uint32_t StrategySim_MoveFleetCommand = 0x00465780; +// __thiscall Game::TradeSector* (Game::StarFleet* this) /* THE GATE NOBODY HAD SATISFIED. Reads StarFleet+0xa0 (`LocID`, resolved to a StarMapNode*) and returns it ONLY if node+0x14 == 2, else NULL. Whole body, validated at this address: `mov eax,[ecx+0xa0]; test eax,eax; je +8; cmp dword ptr [eax+0x14],2; je +2; xor eax,eax; ret`. Called twice from ServerTradeManagerImpl::vslot13 (0x0088ef80+0x39 and +0x98) and once from the raid predicate 0x0080ed50. Because every fleet in every corpus save before this lane was parked at a STAR (kind 0), this returned NULL for all of them, which is why the trade-raid callees had never been entered by any lane. CORROBORATED BY SAVE: after this lane moved a fleet onto a sector node, its serialized `LocID` reads 832, which is the `TradeID` of trade sector 6 in the same file, and its `Pos` is bit-equal to that sector's. */ [mapped] +constexpr uint32_t StarFleet_GetLocationAsTradeSector = 0x002fe320; +// __thiscall void (Mars::StarMapNode* this, int kind) /* StarMapNode::StarMapNode(int). Stores the ctor argument to this+0x14, the word GetLocationAsTradeSector tests. Enumerating EVERY call site is what pins the kind constants, and there are exactly three: Game::StarSystem's ctor 0x00752ea0 passes 0, Game::StarFleet's 0x00706140 passes 1, and Game::TradeSector's 0x00858020 passes 2. So node+0x14 == 2 means TradeSector and nothing else. Prologue validated at this address (push ebp; mov ebp,esp; push -1; push 0x982dc8; fs:[0] SEH frame; security cookie from 0xaf8b78). NOT READ IN GHIDRA: the rest of the body. */ [mapped] +constexpr uint32_t StarMapNode_ctor_kind = 0x00330900; +// __cdecl bool (Game::StarFleet* fleet) /* The raid predicate -- gate G3 of ServerTradeManagerImpl::vslot13, called at 0x0088efc7. Returns false unless: the fleet's location is a trade-sector node; FUN_00813ab0(fleet, 0x80000, 0) is false (a runtime-only 64-bit flag pair at StarFleet+0xb8/+0xbc, NOT serialized, so it has no save tag); the fleet has an owner (StarFleet+0x58 -> Game::ServerPlayer*); and the owner's player index bit is set in the sector's `tscr` mask at TradeSector+0x90. If the owner's bit is ALSO set in `tsct` (TradeSector+0x8c), or the owner's ServerPlayer+0x5c == 4, the fleet must additionally carry StarFleet.FtFlg (+0x10c) & 0x800. Mask test validated at 0x0080ed8f: `mov ecx,[edx+0x28]; push edi; mov edi,1; shl edi,cl; test [eax+0x90],edi`. THE UI SURFACES THIS PREDICATE VERBATIM: ordering a fleet onto a sector node without it pops ` cannot raid trade sector.` (MOVEWARNING_NO_RAID); the server-side twin string is `OnCommand: Fleet not able to raid.` */ [mapped] +constexpr uint32_t TradeSector_FleetMayRaid = 0x0040ed50; +// __cdecl bool (Game::TradeSector* sector, Game::ServerPlayer* player) /* Tests the player's index bit in the sector's `tsct` mask (TradeSector+0x8c) at 0x0080e9de..0x0080e9e8, short-circuiting true when player->+0x5c == 4. Used by the raid predicate to decide whether the raider additionally needs FtFlg & 0x800, and by the juicy-target scan 0x0081a150. CORROBORATED BY SAVE: `tsct` is 2 on the three sectors the AI (player index 1) trades through and 0 on sector 6, which is the human player's -- and the human raider on sector 6 needed no flag. */ [mapped] +constexpr uint32_t TradeSector_PlayerTradesHere = 0x0040e9c0; +// __cdecl void (Game::TradeSector* sector, uint32 keepMask) /* ANDs six consecutive words with a low-N-players mask: +0x88 `tssec`, +0x8c `tsct`, +0x90 `tscr`, +0x94 `ptssec`, +0x98 `ptsct`, +0x9c `ptscr` (stores at 0x0080ead7, 0x0080eadd, 0x0080eae3, 0x0080eae9, 0x0080eaef, 0x0080eaf5). This is what identifies all six of those save tags as PER-PLAYER BITMASKS rather than counts or colours, which had never been established -- `tscr` reads 252 in every save in the corpus and 252 is 0b11111100, i.e. the six NPC player slots. CORROBORATED BY SAVE: researching CCC_ComRaid takes `tscr` from 252 to 253 on all six sectors of the same game, i.e. it sets bit 0, the researching player's index, and sets that player's serialized `CnRad`. NOT LOCATED: the writer that sets a bit -- no `or`/`bts` against +0x8c/+0x90 was found in .text, so the masks are rebuilt wholesale somewhere this lane did not find. */ [mapped] +constexpr uint32_t TradeSector_TrimPlayerMasks = 0x0040eac0; +// __none /* NOT A FUNCTION -- the `sdet == -1` arm inside Game::ServerSpyManager::vslot13 (0x008877b0), branched to from 0x00887856. It calls FUN_0081f570(spy, system, owner), tests FUN_00743f80(system) & 1, and on that bit ROLLS FUN_008e6dd0(StrategyServer+0x16c, the RNG, with spy->sdo at SpyCraft+0x3c), then stamps sdet = turn and posts EVENT_SPY_DETECTED. Recorded here because it is a SECOND, INDEPENDENT WAY FOR THE TAIL TO DRAW that no entry probe on SpyManager::Slot13RngCallee can see: the draw is inline in the outer callee. It is unreachable in every save in the corpus because the gate before it, `SpyCraft.deat (+0x10) != 0` at 0x0088781d, rejects a built-but-undeployed spy. HYPOTHESIS, NOT MEASURED (rule 6): no save has a deployed spy and this lane did not build one. */ [hypothesis] +constexpr uint32_t SpyManager_DetectionRoll_Branch = 0x00487c5b; // thiscall void __thiscall Game::StrategyApp::RunAI(int playerNetId, const char* aiCustomDataName, unsigned char aiPersonality, unsigned int rngSeed) -- RET 0x10, four stack args. The ONE-SHOT AI construction path, reached only from StrategyNetworkClient::OnMessage 0x00784640+0x96e, case SNMRunAI (net msg id 0x3d). Resolves the player through the handle registry at server+0x84; refuses on a human (`p->IsAI(+0xf9) == 0` -> "RunAI: Cannot create a StrategyClient/AI for a human player."); logs "RunAI: Creating AI client for %s using %08x for random seed."; operator_new(0x708) + StrategyClient ctor 0x00782ed0 WITH rngSeed; StrategyServer::InitGame; StrategyClient::CreateAI 0x007653c0(aiPersonality) which builds the Game::StrategyAIAgent into StrategyClient+0x12c ("RunAI: Failed to create AI for %s (player %d)."); if a save-game blob was supplied, "Loading AI custom data from save game..." through agent vt[5]/vt[6]; finally RaiseAIPrepareTurn 0x00815f20. NOT a per-turn entry point [verified] constexpr uint32_t StrategyApp_RunAI = 0x004706f0; // cdecl void (Game::StrategyServer* srv) with EBX = Game::StrategyAIAgent* (register-passed; both call sites set EBX before the call). Builds a stack Game::SEAIPrepareTurn (vftable 0x00a23c00) and invokes agent->vt[1](9, &ev) -- i.e. StrategyAIAgent::OnEvent with client event type 9. Also fires the SVScriptObject hooks at srv+0x1b4 with ids 9 and 0xa. Exactly two call sites: RunAI 0x008706f0+0x26c and StrategyApp::CreateGame 0x00888e80+0x3f5 -- both are game/AI construction, so SEAIPrepareTurn is NOT raised once per turn despite the name [verified] @@ -1831,6 +1843,28 @@ constexpr uint32_t InlinedDrawScan_FalsePositive_008cca30 = 0x004cca8f; constexpr uint32_t MT_TemperMask1 = 0xff3a58ad; // constant 0xffffdf8c -- the second tempering mask as the image spells it, `y ^= (y & 0xffffdf8c) << 15`, equal to the textbook `(y << 15) & 0xefc60000` (0xefc60000 >> 15 == 0x0001df8c == 0xffffdf8c & 0x0001ffff). Image-wide there are 34 occurrences of these bytes inside decoded instructions; 33 are genuine and ONE (0x008cca90) is the rel32 displacement of a call. Always require BOTH masks plus a preceding `shr r32,0xb` before calling a hit a draw [verified] constexpr uint32_t MT_TemperMask2 = 0xffffdf8c; +// thiscall bool (IDMap* this, int numNodes, int localNode, int startId) // ret 0xc. THE SEEDING ENTRY POINT, and it names the save's three NM* tags. Calls FUN_008b8f80 (clear), then REFUSES numNodes > 0x10 ('IDMap: Cannot support %d nodes.') -- SIXTEEN is the hard cap, and it is the low nibble of IDMap_AllocateID's id. Refuses localNode outside [0, numNodes) ('IDMap: Node %d does not exist, valid nodes are 0-%d.'). On success: resize the 0x14-stride node vector at this->+0x08 via FUN_008b99d0, ZERO EVERY NODE'S COUNTER (the loop at 0x008b9b20 stores 0 to +eax+0x10, eax += 0x14), then write startId into nodes[localNode].counter ALONE (0x008b9b38), then this->+0x18 = localNode (0x008b9b3d). SO: exactly one node's counter is ever seeded; every other node starts at 0, and nothing else in the class ever restores one -- IDMap_Insert 0x008b9350 does NOT touch a counter. Four call sites: StrategyServer_Read +0x105 = Initialize(NMSz, NMLc, NMnx) on S+0x84 -- WHICH NAMES THE TAGS: NMSz = node count, NMLc = LOCAL NODE INDEX, NMnx = that node's counter; StrategyServer_LoadGame +0x107 = Initialize(16, 0, 0); StrategySim_OnCreateGame +0x45 = Initialize(msg->+4, msg->+8, msg->+0xc) on the CLIENT's map at +0x80; and FUN_008a9f80 +0x1ce = Initialize(1, 0, 0) on an unrelated Mars scene object's map at +0x68 [verified] +constexpr uint32_t IDMap_Initialize = 0x004b9ad0; +// thiscall int (IDMap* this) // 10 B: push [ecx+0x18] (the local node index); call IDMap_AllocateID; ret. The IDMap-relative form of the same thunk IDMap_AllocateLocalID 0x0080f710 wraps from the StrategySim side (that one is `ecx += 0x80` then jmp here). Five direct call sites, all StrategySim methods reached on BOTH the server and a client: StrategySim_CreateDesign +0xe0, FUN_008713a0 +0x3df, SystemBuildQueue_AttachBuiltShip 0x0088e7f0 +0x42, TradeManager_SpawnEncounterSquadron 0x0088f070 +0x216 and +0x28f [verified] +constexpr uint32_t IDMap_AllocateOnLocalNode = 0x004b8b70; +// thiscall int (IDMap* this, int nodeIndex) // 29 B, ret 4: node = IDMap_FindNode(nodeIndex); return node ? node->+0x10 : 0. A READ of a per-node counter with no side effect. Its only interesting caller is StrategyServer_InitGameForPlayer 0x007c8f05, which uses it to SEED a client: the CreateGame message carries the server's current counter for the node that client is about to own. Since a save restores only ONE node's counter (Initialize zeroes the rest), this returns 0 for every client node in a freshly loaded game [verified] +constexpr uint32_t IDMap_GetNodeCounter = 0x004b8b80; +// thiscall void* (IDMap* this, int nodeIndex) // 101 B, ret 4. Bounds-checks nodeIndex against (this->+0x0c - this->+0x08)/0x14 -- the 0x66666667 / sar 3 divide-by-20 that pins the NodeEntry stride at 0x14 -- logs 'IDMap: Node %d does not exist, valid nodes are 0-%d.' and returns 0 when out of range; else returns _Myfirst + nodeIndex*0x14. Shared by IDMap_AllocateID and IDMap_GetNodeCounter [verified] +constexpr uint32_t IDMap_FindNode = 0x004b8a70; +// thiscall void (StrategySim* this, CreateGameMsg* msg) // ret 4. CASE 0 of StrategyClient::RaiseEvent 0x00783ee0's 0x2c-entry jump table at 0x00784200 (the handler body is at 0x00783f05). FIRST ACT: IDMap_Initialize(this+0x80, msg->+0x04 numNodes, msg->+0x08 localNode, msg->+0x0c startId) -- THIS IS WHERE A CLIENT'S ID SPACE IS SET UP, and the only path by which an IDMap ever gets a local node index other than 0. Then copies the rest of the message into the sim: +0x10 -> this->+0x08, +0x48 -> this->+0x10, +0x14 -> this->+0xb8, the two floats at +0x18/+0x1c -> this->+0xbc/+0xc0, the bools at +0x20/+0x21 -> this->+0xc4/+0xc5, +0x6c -> this->+0x154, +0x4c -> this->+0xf8, then the vectors from this+0x40 on [verified] +constexpr uint32_t StrategySim_OnCreateGame = 0x00376f20; +// thiscall void (StrategyServer* this /*the S frame*/, int playerObjectId, int arg2) // THE NODE-INDEX ASSIGNMENT, in four instructions. Resolves the player through this->+0x84 (the id-to-object lookup FUN_008b9240) and takes esi = player->PlyrIdx(+0x28). Then at 0x007c8ecb: `cmp esi,-1 / je L / inc esi / jmp / L: xor esi,esi` -- localNode = (PlyrIdx == -1) ? 0 : PlyrIdx + 1. NODE 0 IS THE SERVER'S; PLAYER k GETS NODE k+1. It then fills the CreateGame message on the stack at [ebp-0x138]: +0x00 vtable 0x00a252c0, +0x04 numNodes = (server IDMap node vector length, the /20 divide at 0x007c8ee1 over ebx+0x8c/+0x90), +0x08 localNode = that esi, +0x0c startId = IDMap_GetNodeCounter(S+0x84, localNode). Sends it, and StrategySim_OnCreateGame is what receives it. See findings/subsystems/id-allocation.md section 3 for why startId is always 0 for a client after a save load [verified] +constexpr uint32_t StrategyServer_InitGameForPlayer = 0x003c8d90; +// thiscall void (StrategyServer* this, ...) // At +0x107 it calls IDMap_Initialize(this+0x84, 16, 0, 0) -- SIXTEEN NODES, LOCAL NODE 0, COUNTER 0. That is where the corpus's NMSz 16 / NMLc 0 come from, and it is why every id created by the host carries low nibble 0 [verified] +constexpr uint32_t StrategyServer_LoadGame = 0x003dd530; +// thiscall void (StrategySim* this, ServerPlayer* owner, void* params, int explicitId, char, char, char, char) // ret 0x18. THE CLIENT-ALLOCATES / SERVER-HONOURS SPLIT, in one branch. operator new(0x1a8) -> ctor FUN_00874c70 -> FUN_0057c6d0(params); design->+0x130 = owner, design->+0x134 = this->+0x08. Then at 0x008828b3: `if (explicitId != 0) use it; else id = IDMap_AllocateOnLocalNode(this+0x80)`, followed by IDMap_Insert(this+0x80, design+0xa0, id). BECAUSE StrategyClient AND StrategyServer BOTH DERIVE FROM StrategySim, this is the SAME code on both sides: the client runs it with explicitId 0 and mints an id on ITS node, puts that id in the turn command, and the server runs it again with explicitId set and mints nothing. A reimplementation that allocates on apply produces every AI-created id wrong [verified] +constexpr uint32_t StrategySim_CreateDesign = 0x004827e0; +// label THE techId -> NAME MAP, in eight instructions inside MasterTechTree_ctor 0x0058b870, immediately after the LoadTechFile loop. 0x0058b9df: vector::operator=(this+0x24, this+0x14) -- the sorted list at +0x24 is a COPY of the parse-order list at +0x14, which is why a parse-order dump does not match the ids. 0x0058b9ff: std::sort(first=[this+0x24], last=[this+0x28], ideal=(last-first)/4, pred) = FUN_00583b10, MSVC _Sort (the _ISORT_MAX 0x20 test and the _Ideal 3/4 halving are both there); the predicate is INLINED in FUN_00581130 at 0x00581190 and 0x005811b8 as `_stricmp(a->name, b->name) < 0` on TechDef+0x04 (a std::string, SSO-tested at +0x14 against 0x10) -- MSVCR100 _stricmp, so the order is CASE-INSENSITIVE, not byte-wise. 0x0058ba19..0x0058ba37: `for (i = 0; i < n; ++i) sortedList[i]->+0x00 = i;` -- THE WIRE techId IS LITERALLY THE 0-BASED INDEX INTO THE _stricmp-SORTED MASTER LIST. The same sorted vector is then the binary-search index: FUN_0057f120 (std::lower_bound) resolves every `requires`/`allows` name against it at 0x0058baed. NOT the 10000-based TechID enum, which is a separate 196-entry .rdata table [verified] +constexpr uint32_t MasterTechTree_SortAndNumber = 0x0018b9df; +// thiscall bool (TechTree* this /*per-player*/, int techId) // THE WIRE-SPACE BOUNDS CHECK, and it is what proves the space. Rejects techId < 0 and techId >= (this->+0x14 - this->+0x10)/4 -- the per-player node vector, 293 entries in stock data. Then esi = masterList[techId] from this->+0x04 -> +0x24/+0x28 (the SORTED vector, MasterTechTree_SortAndNumber) and edx = this->+0x10[techId] (the player's node). Reads the name as a std::string at masterEntry+0x04 with the SSO test at +0x18 -- so TechDef is {+0x00 int techId, +0x04 std::string name, ...}. Called by the research-target gate applier at 0x0088ff49 with the techId straight off the command payload ([block-0x28]) [verified] +constexpr uint32_t TechTree_IsResearchable = 0x0017e820; +// cdecl void (T** first, T** last, int ideal, Pred pred) // MSVC std::sort's _Sort: the `(last-first)/4 <= 0x20` insertion-sort cutoff at 0x00583b26, the `ideal -= ideal/2 + ideal/4` heap-sort fallback counter, and _Unguarded_partition FUN_00581130. Identified here because MasterTechTree_ctor uses it to build the tech id space; the same body will be reached from anywhere else that sorts a pointer vector [verified] +constexpr uint32_t MSVC_Sort = 0x00183b10; // thiscall void (CombatResolveContext* this) // PLAIN RET, no stack args. THE COMBAT RESOLVER under phase 6 of StrategyServer::OnAllCombatDone_Tail. Exactly one caller: StrategyServer::ApplyEncounterResult 0x007d8920 at 0x007d8d24, on the full-battle path only (res->+0x4 == 0). REAL BODY IS 0x007d5af0..0x007d78c8 = 7641 B; Ghidra's 7499 stops mid-instruction at 0x007d783b. SHAPE, read from the instruction stream: (1) prologue + 16 unconditional this-calls 0x007d5b1e..0x007d5c02; (2) ONE loop over enc->members, stride 0x44, 0x007d5c30..0x007d779d -- 7021 of the 7641 bytes, with four inner loops and no other outer control flow; (3) five more unconditional this-calls 0x007d77a3..0x007d77cd; (4) a victor block gated on ctx->+0xa34 != -1; (5) FUN_0079c740 and the epilogue. ONLY EIGHT NON-STACK STORES IN THE WHOLE BODY and exactly ONE indirect call (inside a _CxxThrowException path): it composes and posts per-player events and delegates every state mutation to callees. Strings it composes: EVENT__FIGHT, EVENTSUM_, EVENTMSG_<...>, EVENT_TRADERAIDERS, EVENT_COMBAT_OBSERVED, EVENT_DEFEAT, EVENT_VICTORY, EVENT_ENGAGED, EVENT_STATION_KILLED, and the ENTITYVICTORY/ENTITYDEFEAT/UNRESOLVED outcome tokens. DRAWS NO RNG ITSELF -- see CombatResolve_NodeCannon and CombatResolve_SalvageBackEng [verified] constexpr uint32_t CombatResolver_Run = 0x003d5af0; // thiscall CombatResolveContext* (CombatResolveContext* this, StrategyServer* S /*the S frame*/, Encounter* enc, Game::EncounterResults* res) // built as a ~0xea0-byte STACK local at [ebp-0xea0] in StrategyServer::ApplyEncounterResult, immediately before the resolver call. Field assignment read from the instruction stream: this->+0x00 = S; this->+0x04 = operator new(0x5c) then FUN_005a13f0 (a per-player lookup object); this->+0x08 = enc; this->+0x0c = res; this->+0x10 = 0; this->+0x14 = 0 (byte); this->+0x18 = an empty std::string; this->+0x38 = FUN_00536890. Further fields the resolver uses: +0x290/+0x330/+0x430 per-player int arrays indexed by PlyrIdx*4; +0x7b0 + PlyrIdx*0x10 a per-player vector; +0x9b0 + PlyrIdx*4 the posted-event pointer; +0xa34 the winner PlyrIdx (-1 = none); +0xe7c = FUN_00787690(enc), set by the resolver's first act [verified] @@ -2113,6 +2147,20 @@ constexpr uint32_t TurnCommands_WriteColonizeList = 0x00422960; constexpr uint32_t StrategySim_ApplyTurnCommandBatch_GateLoopB = 0x004907b1; // label the THIRD and last per-player gate loop inside StrategySim::ApplyTurnCommandBatch. `esi = block+0x24`; it tests the group-4 gate at +0x2c and applies its {bool, int} payload through 0x00821b90. It is the final step of the whole batch. Together with the loop-A head at 0x0088fdb0 and the loop-B head at 0x008907b1, and the six inlined ModCount bump sites, this gives nine positions of the thirty-step apply schedule an address-monotonicity check -- the only part of the sequence that can be re-derived rather than inherited from the read of the `add edi, imm` chain [verified] constexpr uint32_t StrategySim_ApplyTurnCommandBatch_GateLoopC = 0x0049080a; +// custom int (ECX: StrategyAIAgent* agent, EDX: SectionContext parts[3], STACK: DesignRequest* req, STACK: char costOnly) /* ECX+EDX in registers, TWO stack arguments, and the CALLER cleans them (`add esp,0x8` at 0x006ae719) -- neither cdecl nor MSVC __fastcall, but the private convention MSVC gives a static function whose address never escapes. Returns 0 on success and 1..8 for eight distinct bail-outs: 1 over budget, 2 no hull candidate, 3 no command section, 4 no engine section, 5/6/7 a forced-tech lookup failed for request-flag bit 4/8/0x10, 8 no weapon matched a bank. `req` = {int hullSize; float budget; int role; uint flags}. `parts` is the same 0x124-stride three-section array lane D2 found on the other side of the pipeline at Game::ShipDesign::AggregateSectionStats: +0 ShipSectionDef*, +4.. one weapon id per weapon BANK, +0xcc the bank count (min(hull banks, 0x32)). `costOnly` non-zero returns right after pricing, having already drawn at sites A/B/C -- which is why one AI design request can cost two composer calls */ [verified] +constexpr uint32_t AIComposeShipBlueprint = 0x002ad700; +// custom void (ECX: ?, EDX: StrategyAIAgent* agent, STACK: ..., DesignRequest* req, char costOnly) /* the composer's ONLY caller. Builds a list of 1..3 hull sizes from req.hullSize and request-flag bits 0 and 1 (bit 0 adds size-1, bit 1 adds size+1 when size<2), clears both bits, then calls AIComposeShipBlueprint once per size and BREAKS ON THE FIRST SUCCESS. So one design request costs 1..3 composer calls, and every failed attempt has already spent the RNG words it reached before failing. Three call sites: 0x006cdaa6 and 0x006cdb1c in 0x006cda40 (the real design pass) and 0x006b7ecb in 0x006b7e40, which passes costOnly=1 */ [verified] +constexpr uint32_t AIComposeShipDesign = 0x002ae620; +// custom void* (EAX: int count, STACK: void** array) /* 0x1f bytes whole: `if (!count) return 0; return array[cl_RandRange(0, count-1)];`. The count arrives in EAX. This is a LIVE DRAW SITE that lane PAR's 23-row table does not list -- PAR measured it live at return address 0x00691ea0 (2 calls, 2 words on turn 1) without adding it to the static inventory, so the AI turn has 22 live sites, not 21. The composer calls it once, at 0x006ada61, to pick the command section */ [verified] +constexpr uint32_t AIPickRandomElement = 0x00291e90; +// offset size -- one entry of the three-section design context array the composer fills and Game::ShipDesign::AggregateSectionStats 0x00826af0 consumes. +0x000 ShipSectionDef* (null = empty slot), +0x004..+0x0cb up to 0x32 weapon ids, ONE PER BANK, which is exactly the DGbnk2 list the design record carries on the wire (SHIP_DESIGN_RULES B1: one weapon per bank{} block, in file order); +0x0cc the bank count = min((def+0x320 - def+0x31c)/0x30, 0x32); +0x0d0..+0x11f up to 20 candidate weapon ids gathered for that section; +0x120 that candidate count, capped at 20 -- the cap the string "AIComposeShipBlueprint: SectionBlueprint::MAX_OPTIONS" names. Slot order in memory is mission, command, engine (verify/design-rules/SHIP_DESIGN_RULES.md 1), NOT the on-disk order. Live: on turn 1 a Human destroyer design gives bank counts 3 / 1 / 2 for DEExtendedRange / DECommand / DEFission [verified] +constexpr uint32_t AIShipSectionContext_stride = 0x00000124; +// offset std::vector begin -- weapon-bank descriptors on a Game::ShipSectionDef, stride 0x30, end pointer at +0x320. Bank+0x00 = turretclass index (0 = `standard`, the class 1,533 of the 3,721 shipped banks carry; 0xc and 0x17 are two others the composer's large-bank pass also accepts), Bank+0x04 = turretsize index (1 = `small`, 3 = `large`). The composer's only loop-carried draw fires once per (class 0, size 1) bank -- a small standard bank -- and its job is to overwrite a fraction of them with a point-defence weapon [verified] +constexpr uint32_t ShipSectionDef_off_Banks = 0x0000031c; +// offset Mars::String -- the section's file name on a Game::ShipSectionDef; capacity word at +0x1c8, so the payload is the inline buffer when capacity < 0x10 and *(char**)(def+0x1b4) otherwise. The composer compares it case-insensitively against "DEPointDefence" and "CRPointDefence" at 0x006ae4e6/0x006ae4fd: a section whose name matches gets a point-defence weapon in EVERY qualifying bank instead of every D'-th one [verified] +constexpr uint32_t ShipSectionDef_off_Name = 0x000001b4; +// cdecl int (StrategyAIAgent* agent, int hullClass, std::vector* restrictTo, int bankClass, int bankSize, void* ctx, int flag) /* seven stack arguments, caller cleans 0x38 for the two calls at 0x006ae3c1/0x006ae3dc. Returns a weapon id, or 0 when nothing fits. The composer calls it in the weapon-assignment loop and then TWICE more just before the point-defence pass: once with a one-element list holding the literal 0x25 (read as the point-defence weapon family -- INFERRED, from the DEPointDefence/CRPointDefence section names the same pass matches and the 0.2 probability it rolls) and once unrestricted. A null from the unrestricted call skips the whole point-defence block; a null from the restricted one makes every iteration short-circuit without drawing. Both make the loop-carried draw cost zero, which is why lane SD's probe records the two return values separately */ [verified] +constexpr uint32_t AIChooseWeaponForBank = 0x002ad2a0; // thiscall int (Game::SVScriptObject* this, int evt, void* arg) // the script-object event bus. Calls this->vft[0x10](evt, arg) -- the GENERIC handler every object sees -- then `cmp evt,0x20; ja done; jmp dword [evt*4 + SVScriptObject_EventSlotJumpTable]`, which dispatches to ONE event-specific vtable slot with the argument shape that event carries. Every hand-written `vft[0x10](id,0); vft[slot]()` pair in the two turn drivers is this same two-step done on the root object [verified] constexpr uint32_t SVScriptObject_DispatchEvent = 0x003a60d0; // data void* [33] // evt (0..0x20) -> the vtable slot SVScriptObject_DispatchEvent calls. Slot byte offsets in evt order: 0x14 0x18 0x1c 0x20 0x24 0x28 0x2c 0x30 0x34 0x38 0x3c 0x40 0x44 0x48 0x4c 0x50 0x54 0x58 0x5c 0x60 0x64 0x6c 0x70 0x74 0x68 0x7c 0x80 0x84 0x78 0x88 0x8c 0x90 0x94. Note 0x15->+0x6c, 0x16->+0x70, 0x17->+0x74, 0x18->+0x68 and 0x1c->+0x78 are NOT in slot order [verified]