diff --git a/docs/AC-predictions.md b/docs/AC-predictions.md new file mode 100644 index 0000000..10985dd --- /dev/null +++ b/docs/AC-predictions.md @@ -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. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index aafa5a6..2539fc6 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 @ 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). #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]