sync header after AC merge

This commit is contained in:
alex 2026-09-08 21:34:48 -04:00
commit 68a9fc824d
2 changed files with 240 additions and 1 deletions

227
docs/AC-predictions.md Normal file
View file

@ -0,0 +1,227 @@
# 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.
---
## 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 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
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 `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
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.

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ 1914c92, generated 2026-09-08 by tools/gen_addresses.py // Source: sots-re ghidra/addresses.json @ bdaa26f, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once #pragma once
#include <cstdint> #include <cstdint>
@ -1319,6 +1319,18 @@ constexpr uint32_t StrategyNetworkClient_off_Server = 0x00000054;
constexpr uint32_t StrategyServer_AbandonChaosCheck = 0x003b9df0; 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] // 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; 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 `<fleet> 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] // 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; 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] // 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]