From 247285ac28129ad8bfb7a313bf805b04f8692f06 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 14:53:17 -0400 Subject: [PATCH] board: lane C3 output turn path; unspent construction cascades to money; S11 civilian growth is next --- campaign/board.md | 6 +++ ghidra/generated/sots_addresses.h | 80 ++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/campaign/board.md b/campaign/board.md index ffdae16..2f5baa1 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -267,3 +267,9 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`). | AI2 corrections to AI1 | meta | verified | high | 100% | 2026-09-08 | **31 task classes, not 34** - slot-1 x slot-8 across 31 vtables gives a gapless type enum 0..0x20 with two retired ids. **26 order methods, not 21.** **7 pure virtuals, not 8** - named: GetTypeId, GetTargetA/B, Execute(agent,pass), IsFinished(agent), GetTypeName, Describe. Also a test expectation was wrong on first run (ColonizeAt 1300 outranks NodeBore 1275) and lane AI2 FIXED THE TEST, NOT THE CODE | | AI2 boundary | meta | verified | high | 100% | 2026-09-08 | Slots 11/12/13 UNNAMED. The nine tasks with no reachable order method are INFERRED to be goal/planner tasks - bodies unread. The two-pass meaning is INFERENCE. The reachability table is a DIRECT-CALL CLOSURE = lower bound (rule 16 bites hard in a vtable system). **The StrategyApp+0x1c ENQUEUE SITE WAS NOT FOUND, so AI-stepping order is still open** - and that matters because ModCount is a count. The .data invade tunables 0x00a1795c/60 have no traced loader. NOTHING RAN UNDER AN INSTRUMENT; four falsifiable predictions with symptoms are in the finding | | INTEGRATOR NOTE: do not share a build dir with a background gate | meta | verified | high | 100% | 2026-09-08 | I read "98% tests passed, 1 failed" and started hunting a flaky test in a DETERMINISM project - a serious thing to chase. It was my own error: I ran an ad-hoc ctest against the same CT111 build directory while the BACKGROUND gate job was still writing binaries into it. The gate itself reported 47/47 and the shim built; 13 further clean runs (5 serial, 8 at -j4) confirm. RULE: one build directory per gate invocation, and do not query a dir while a background job owns it - the failure mode looks exactly like nondeterminism in the engine, which is the one thing this project must never misdiagnose | +| ComputeOutput turn path modelled | subsystems | verified | high | 90% | 2026-09-08 | Lane C3. **+1 leaf closed, 0 regressed, on BOTH pairs** (reference 209 -> 157, closed 52; pair 2 108 -> 86, closed 22); the leaf is /Sim/players/Player[576 "Independent Colony"]/Sav both times. THE FUNCTION IS THIN: 0x00751fb0 zeroes 12 slots, calls ComputeOutputFromRates(out, &sys->Rts) with the system's OWN, UNNORMALISED sliders, then fills three reporting slots - and **out[3], the only slot ComputeBudget reads (confirmed at 0x008631fd), is written entirely inside ComputeOutputFromRates and never touched again**. THE LOAD-BEARING FACT NOBODY HAD: **construction points the build queue and the ship-repair pass do NOT spend are REDISTRIBUTED over trade/terraform/infra, and the trade share is added to the money channel** - and unspent infra cascades into the TERRAFORM pool, which cascades into money. TWO HOPS, NOT ONE. So a colony with an empty build queue earns the same money whichever way its sliders point. Which float decides an output value: **SRsc and the build-queue demand**; everything else in the corpus is suppressed by the normaliser or rounds to nothing | +| the two-path cross-check decomposes TO THE UNIT | verify | verified | high | 100% | 2026-09-08 | Stronger than "the two paths agree". The engine now runs BOTH the projected path (BnkEl-constrained) and the turn path on the same colony state every load and reports the difference - and on all 11 saves **every delta decomposes to the unit into the build queue's points priced through the money chain**: 5 saves agree exactly (all queues empty); human-turn3 differs by 37 = one five-point block x 5 x IncMod 1.48; zuul-turn23 by 2035 = 370x5x1.1; zuul-turn5 by 39160 = 7120x5x1.1; zuul-turn17 by 21010; zuul-turn16 by 36438 (within the 1 unit two truncations of a .5 allow). The two paths differ by EXACTLY what the disassembly says | +| S11 CIVILIAN GROWTH is the highest-value next item | verify | backlog | — | 0% | 2026-09-08 | Lane C3's headline for the roadmap: the residual on the metric is UPSTREAM, not in the output formula. ComputeBudget runs AFTER S11 SystemTurn (strategic phases 11 then 13), and our S11 DOES NOT COMMIT CIVILIAN GROWTH. The human's savings come out 289,092 against the oracle's 289,688 - the growth gap (597) minus one trade point. **ONE INPUT (imperial carrying capacity) GATES the human's Sav, PvSav, BnkEl AND BnkPr on both pairs.** The AI's are wrong for a different reason: its ResRate/ResTNm are its own orders, i.e. Rung B | +| the last unmodelled output input: ship repair demand | verify | backlog | — | 0% | 2026-09-08 | Nine inputs to the turn path; EIGHT ARE ON THE WIRE (Rts, dsu, IdealSuit, TerraMod, Infra/ibon, BQ/ords/conleft, NumSnF, the output total). The ninth is **the repair demand of damaged ships in orbit**, needing Ship::RepairCost 0x00815180, which is UNREAD - it is taken as 0, so **a colony with a damaged fleet reads HIGH**. Also note this is the same repair pass that makes ComputeOutputFromRates unsafe to call for harvesting (the B1 replace double-run defect) | +| the two RNG words: the blocker MOVED again | verify | mapped | high | 90% | 2026-09-08 | They still cannot enter the generator model and THE REASON HAS CHANGED AGAIN. The allocation is now computable - the money input was the whole gap. What blocks them is one step further out: **the only corpus player reaching ProcessResearch with a target is the AI**, whose research rate and target are generated during the same turn (Rung B). On turn1-state NO player has a target, so both words cost zero on the reference pair - **but rule 20 applies to that sentence**: a count cannot separate "not entered" from "entered and drew nothing". AN ENTRY PROBE ON ProcessResearch SETTLES IT IN MINUTES. Ledger unchanged at 16 words modelled | +| two callees with NON-STANDARD conventions | objects | verified | high | 100% | 2026-09-08 | Recorded because they read WRONG as __thiscall and would silently mis-model: **0x0074c6f0 takes `this` in ESI**, and **0x007460b0 takes the system in EBX**. Same class of trap as lane P2's two helpers that read ebx/esi live-in without ever writing them | diff --git a/ghidra/generated/sots_addresses.h b/ghidra/generated/sots_addresses.h index 2074718..2651903 100644 --- a/ghidra/generated/sots_addresses.h +++ b/ghidra/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 @ 232397a, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 3b99e25, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1481,6 +1481,70 @@ constexpr uint32_t StrategyServer_DestroyFleet = 0x0048b980; constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0; // thiscall void** (std::map* this, const int* key) // 125 B, ret 4. MSVC std::map::operator[]: _Lbound over the tree from this->_Myhead(+0x04)->_Parent, testing _Isnil at node+0x15 and the key at node+0x0c; if found returns &node->_Myval.second (node+0x10), else default-inserts the pair {key, 0} via _Buynode 0x008b91d0 + _Insert 0x0072b400 and returns the same. NODE IS 0x18 BY ENUMERATION from _Buynode's operator new(0x18): _Left +0x00, _Parent +0x04, _Right +0x08, pair at +0x0c/+0x10, and _Color/_Isnil written as ONE 16-bit store at +0x14/+0x15, plus 2 bytes padding. DELEGATED instruction-level read [verified] constexpr uint32_t Map_IntPtr_Subscript = 0x0036bce0; +// site The byte AFTER Game::BuildQueue::ProcessTurn's last instruction (`ret 0x8` at 0x00891226, then int3 padding). Ghidra reports the function as 1230 bytes from 0x00890d50, i.e. ending at 0x0089121e -- INSIDE the epilogue, before the security cookie check. The body is 1264 bytes. Earned rule 17. Note that this address is ALSO the entry of SystemBuildQueue_AttachBuiltShip, the function's own slot-10 callee, which is why the fleet half of construction looked absent from the pass [verified] +constexpr uint32_t BuildQueue_ProcessTurn_RealEnd = 0x00491240; +// field ServerPlayer* -- the player that owns the queue. Read at 0x00890de9 (passed to the ship factory), 0x00890ec9 (passed to the system's post-build hook), 0x00890ef4 (the base of the ShipRecords update) and 0x00890f75 (the build-completed event's owner field) [verified] +constexpr uint32_t BuildQueue_off_Owner = 0x0000000c; +// field std::list head sentinel. The pass walks it as `node = *(head); while (node != head) node = *node`, so it is the MSVC circular list. Read at 0x00890d9a and re-read every iteration at 0x00890d9d / 0x00891020 [verified] +constexpr uint32_t BuildQueue_off_OrderList = 0x00000010; +// field int -- the list's element count. Used as the reserve hint for the removal sweep's scratch vector at 0x0089104a and decremented once per unlinked order at 0x008911b0 [verified] +constexpr uint32_t BuildQueue_off_OrderCount = 0x00000014; +// field int conleft, measured from the std::list NODE base (node+0x0 next, +0x4 prev, +0x8 the order's own vptr -- the list is polymorphic -- then +0xc desID, +0x10 con, +0x14 sav, +0x18 conleft, +0x1c ordID, +0x20 ShipDesign*). Compared against the remaining points at 0x00890db5, decremented at 0x008910c4, zeroed at 0x0089100c, and it is the removal sweep's predicate at 0x00891070 [verified] +constexpr uint32_t ShipBuildOrder_off_ConLeft = 0x00000018; +// field ShipDesign* -- from the same std::list node base as ShipBuildOrder_off_ConLeft. Loaded at 0x00890dbe and is the source of the money cost (+0xc0), the role flags (+0xb8) and the hull class (+0x12c) the completion reads [verified] +constexpr uint32_t ShipBuildOrder_off_Design = 0x00000020; +// vslot bool (StrategySim* sim, int64 cost) -- vtable slot 9. Called at 0x00890ddc only when design->+0xc0 > 0; a FALSE return SKIPS that order and the pass continues with the next one rather than stopping. Pure in Game::BuildQueue's own vtable (0x00a31328); Game::SystemBuildQueue (0x00a31358) binds 0x00809910 and Game::ShipBuildQueue (0x00a31388) binds 0x0091e480 [verified] +constexpr uint32_t BuildQueue_vslot_ChargeMoney = 0x00000024; +// vslot void (StrategySim* sim, StarShip* ship) -- vtable slot 10, called at 0x00890e20 immediately after the ship is created. Pure in the base vtable; Game::SystemBuildQueue binds 0x00891240, Game::ShipBuildQueue binds 0x0081c000. This is where join-an-existing-fleet vs create-a-new-one is decided [verified] +constexpr uint32_t BuildQueue_vslot_AttachBuiltShip = 0x00000028; +// __thiscall void (SystemBuildQueue* this, StrategySim* sim, StarShip* ship) /* RET 8. Game::SystemBuildQueue vtable slot 10. Routes the newly built hull by design role flags -- design->+0xb8 & 0x80000, design->+0xbc & 0x4, design->+0xb8 & 0x800000, design->+0xb8 & 0x400 each take their own handler -- and FALLS THROUGH at 0x00891328 to `if (ship->FltID == 0) ServerSystem_AttachShipToHomeFleet(sys, ship)`. NOTE the 0x400 tested at 0x00891311 is a DESIGN role bit and is NOT the fleet's FtFlg 0x400 */ [verified] +constexpr uint32_t SystemBuildQueue_AttachBuiltShip = 0x00491240; +// __thiscall void (ServerSystem* this, StarShip* ship) /* The join-or-create step. If this->+0x238 is non-null the cached home fleet is reused; otherwise an id is drawn from the object-id allocator (0x0074f500) and StrategyServer_CreateFleet (lane B5, 0x0085b340) builds a fleet at the system's position with a NULL name override, the result is cached in this->+0x238 (0x0074f513), FtFlg |= 0x20 (0x0074f519), and StarFleet_AddShip links the hull. ONE home fleet per system: every hull built at that system in later turns joins it */ [verified] +constexpr uint32_t ServerSystem_AttachShipToHomeFleet = 0x0034f4d0; +// field StarFleet* -- the cached fleet newly built hulls join. Tested at 0x0074f4d6 and written at 0x0074f513. NEW OFFSET: not in struct-recovery.md's ServerSystem table [verified] +constexpr uint32_t ServerSystem_off_HomeFleet = 0x00000238; +// __thiscall StarShip* (ObjectHost* this, int id, ServerPlayer* owner, ShipDesign* design) /* RET 0xc. operator new(0xb0) at 0x0086571c, ctor 0x00861280, then IDMap_Insert at 0x00865754 which is where the object id lands in ship->+0x4. Reached from exactly two places in the image: the build queue (through the 0x004f41a0 thunk) and the trade manager's encounter spawner */ [verified] +constexpr uint32_t StarShip_Create = 0x004656f0; +// __thiscall void (StarShip* this, ObjectHost* host, ShipDesign* design, ServerPlayer* owner) /* Zeroes the object through 0x0080c960 (which leaves +0x48, +0x60 and +0xac at -1), then writes +0xc host, +0x14 design (DesID), +0x10 owner (PlrID); allocates the ship-borne BuildQueue into +0x98 when design->+0xb8 & 0x400000, and three Population objects into +0x9c/+0xa0/+0xa4 when design->+0xb8 & 0x4000000; finally 0x00854680 copies the cached design stats -- Range +0x20 from design+0xe8, Health +0x24..+0x30 from design+0xec.., RefCap +0x6c, RepCap +0x70, ConCap +0x68 from design+0xd0. FltID (+0x64) is born NULL and is set by StarFleet_AddShip */ [verified] +constexpr uint32_t StarShip_Ctor = 0x00461280; +// field int tblt -- the turn the hull was completed. Born -1 in the constructor's default sweep (0x0080c9fb) and overwritten by the build queue at 0x00890e02 with the StrategySim's Frame word. MEASURED: the six hulls the zuul turn-16 -> turn-17 pair adds all carry tblt equal to the NEW turn number, which independently confirms that BeginProcessTurn's Frame increment happens BEFORE the spine, so a phase reading Frame during a turn sees the turn it is producing, not the one it started from [verified] +constexpr uint32_t StarShip_off_TurnBuilt = 0x000000ac; +// site `inc DWORD PTR [esi+edx*4+0x1b4]` with esi = the queue's owner and edx = design->+0x12c (the hull class). THE ONLY WRITER OF THE PER-CLASS BUILT COUNTER IN THE WHOLE IMAGE: a byte scan for the indexed-increment form at that displacement over all executable sections returns this one site. A hull that reaches the wire with the counter bumped came through the build queue and through nothing else [verified] +constexpr uint32_t ShipRecords_BuiltCounterSite = 0x00490ef7; +// field int built[3] -- the first of Game::ShipRecords' four parallel per-hull-class arrays (built, lost, killed, inService; wire tags srb/srl/srk/sri under the srnc count). SIZED BY ENUMERATION, not by what the code touches: three classes x four arrays x 4 bytes from 0x1b4 lands exactly on 0x1e4, the per-design vector, which is the next thing the same function reads. struct-recovery.md places the ShipRecords sub-object at 0x1b0, so 0x1b0 is its vptr [verified] +constexpr uint32_t ServerPlayer_off_ShipRecordsBuilt = 0x000001b4; +// field std::vector<{int srd; int src; int srb; int srl; int sri}> at +0x1e4/+0x1e8/+0x1ec, stride 0x14 (the wire's srbd section). The completion scans it linearly for a record whose first word equals the design's object id (0x00890f10), appends one when there is no hit (0x00890f4c) and increments the record's third word (0x00890f61). MEASURED: srd really is the design's save id -- the zuul corpus shows records keyed 608/576/114/816/18/34/130 against build orders naming exactly those desIDs [verified] +constexpr uint32_t ServerPlayer_off_ShipRecordsByDesign = 0x000001e4; +// __thiscall int (ServerTradeManagerImpl* this, int* spec) /* THE SECOND AND ONLY OTHER ROOT THAT CREATES SHIPS. Draws two ids from the object-id allocator, creates a StarFleet through StrategyServer_CreateFleet (lane B5, 0x0085b340) (0x0088f314) and then loops StarShip creation (0x0088f375) + StarFleet_AddShip (0x0088f381). Reached only from 0x008926ce, itself reached only from ServerTradeManagerImpl vtable slot 17 (0x008938a0), which picks its target with an RNG draw. It does NOT touch ModCount, and it does NOT touch ShipRecords -- so an encounter squadron is invisible to the per-class built counter, which is why that counter is a clean discriminator for player-built hulls */ [verified] +constexpr uint32_t TradeManager_SpawnEncounterSquadron = 0x0048f070; +// __thiscall void (ShipAction* this, ...) /* The construction-ship wrapper around BuildQueue::ProcessTurn (calls it at 0x00789551 with the ship's own queue at ship->+0x98 and its ConCap at ship->+0x68 as the point budget). REACHABILITY NOTE, and it is a new indirection class for the campaign: this function has ZERO call sites and is in NO vtable. Its address is written into a STACK-BUILT function-pointer table by the ship-action dispatcher (0x007b9c4b `mov eax,0x789500`, stored at 0x007b9c50), alongside five siblings. tools/vtable_map.py cannot see edges of this shape, so `no caller` and `no vtable caller` are BOTH lower bounds */ [verified] +constexpr uint32_t ShipBorneBuildQueue_ProcessTurn = 0x00389500; +// site CORRECTION to addresses.json's BuildQueue_ProcessTurn prototype, which reads `int (BuildQueue* this, ServerSystem* sys, int points)`. The FIRST STACK ARGUMENT IS NOT THE SYSTEM. At the only real call site the pushed value is `[sys+0x10] - 4` (0x0075257c `lea edi,[eax-0x4]` with eax = [esi+0x10], esi = the ServerSystem, ecx = [esi+0xa4] = the queue), i.e. the StrategyServer `S` frame -- the same object lane B5's StrategyServer_CreateFleet takes at S+4, one word higher. Two consequences the old prototype hides: the build-completed event is pushed onto a list at S+0x2b0, NOT onto the system; and the turn stamp written into the new hull at 0x00890e02 is S+0xc, which StrategyServer::Write tags `Frame`. AGREEMENT with lane T section 0 (the two bases four bytes apart) and with lane A2 (S+0xc is Frame, not ModCount) [verified] +constexpr uint32_t BuildQueue_ProcessTurn_Arg0Correction = 0x00352589; +// field ServerSystem* -- the system a SystemBuildQueue belongs to, read by the slot-10 attach handler at 0x00891246 as `(this->+0x4 == 0) ? this->+0x8 : 0`. Also read by the pass itself at 0x00890f89 for the build-completed event [verified] +constexpr uint32_t BuildQueue_off_OwningSystem = 0x00000008; +// thiscall int (ServerSystem* sys, int points, bool estimateOnly) // `ret 8`, real end 0x007517b5 (SEH frame, /GS cookie). Returns the points NOT consumed. Walks the system's fleets through the system's own vtable (slot 2 = count, slot 4 = element), keeps those whose +0x58 equals sys->PID and that pass 0x00813ab0(0,8), and inside each keeps the ships that pass 0x00814da0(0,8) with 0x00815180(ship,1) > 0. With estimateOnly it returns max(points - totalCost, 0) and touches nothing; without it, it distributes round-robin -- share = max(points / shipCount, 1) per pass, take = min(cost, share), applied by 0x008151c0 -- until no ship takes anything or the points run out. THE ROUND ROBIN IS EQUIVALENT TO points - min(points, totalCost): share is at least 1, so every ship with a positive cost takes at least one point per pass, and the only early exit needs every remaining cost to be zero. THIS IS THE SIDE EFFECT that makes ComputeOutputFromRates unsafe to call for its value (the B1 replace double-run defect): ComputeOutputFromRates passes estimateOnly = 0 [verified] +constexpr uint32_t ServerSystem_RepairShipsInOrbit = 0x00351590; +// thiscall int (ServerSystem* sys, double constructionShare) // `ret 8`, real end 0x00746883. Turns the ship-construction channel's rounded share into out[7]: with no owner it is ftol(share), else k = StationCount(sys, PID, 1) and b = STATION_BONUS_SHIPCON (slot 0x00af08ec) taken as 0 unless STRICTLY positive, returning ftol( k x (b x share) + share ). TRUNCATING, not rounding, and note the association -- neither is `share x (1 + b x k)` [verified] +constexpr uint32_t ServerSystem_ConstructionPoints = 0x00346830; +// thiscall int (BuildQueue* q) // 23 bytes, plain `ret`, no frame. Walks the std::list at q+0x10 from its sentinel and sums the dword at +0x18 of each node -- the order's `conleft`. This is the queue demand ComputeOutputFromRates charges against out[7] before anything cascades back to the money channel, and the ONE input of that function that a save can supply in full (Sys/BQ/ords/conleft is on the wire) [verified] +constexpr uint32_t BuildQueue_TotalConstructionLeft = 0x004251e0; +// thiscall double (ServerSystem* sys) // real end 0x00745dc0. The suitability the terraform channel aims at, and the value NormaliseOutputRates compares sys->Suit against with an exact ==. With no owner it returns sys->Suit itself, so an unowned system is always 'at its ideal'. Otherwise it starts from owner->IdealSuit (+0xb0), replaces that with StrategyServer::IdealSuit(sys->server, sys->indi->indsp) when the system carries an independence record (+0x1c8), and finally overrides both with sys->dsu (+0x118) whenever dsu differs from the float behind 0x00aeca6c. NOT the same source as CalcSuitMod's ideal, which is the server's per-species array unconditionally -- the two agree on every corpus save, so the difference is instruction-verified only. Every corpus system carries dsu = FLT_MAX, which is why the sentinel is READ AS FLT_MAX (inferred from the corpus, not from the data files) [verified] +constexpr uint32_t ServerSystem_IdealSuitability = 0x00345d60; +// custom int (/* ESI = ServerSystem* sys */) // real end 0x0074c80f. TAKES ITS `this` IN ESI, not ECX -- a compiler-local helper that inherits the register its caller holds; hooking or calling it as a __thiscall reads the wrong object. Returns 0 with no owner and 0 on an independent colony (+0x1c8), else derives a civilian-share ratio from three int64 population helpers (0x0074a870, 0x0074a8c0, 0x0074a920), scales CIVILIAN_RESOURCES_CONSUMED (slot 0x00ae2ea4) by it and floors the result at 1. Feeds only the resource ledger (out[1], out[2]); it is NOT on the path to out[3] [unverified] +constexpr uint32_t ServerSystem_CivilianConsumption = 0x0034c6f0; +// custom int (/* EBX = ServerSystem* sys */ ServerPlayer* owner) // real end 0x0074615a. TAKES THE SYSTEM IN EBX and the player on the stack -- the second compiler-local helper in this call graph with an inherited register. Returns 0 when either is null. Walks the system's fleets through the system's vtable (slot 2 = count, slot 3 = element), keeps those whose owner (0x0071e280) is the argument and whose +0x78 byte is set, and sums 0x00829180 over the ship vector at +0xa4..+0xa8. ComputeOutput stores the result in out[6]; nothing downstream of out[3] reads it [verified] +constexpr uint32_t SystemRepairDemandForOwner = 0x003460b0; +// thiscall void (TradeManager* mgr, ServerSystem* sys, ServerPlayer* owner, int* a, int* b) // `ret 0x10`. Returns immediately when `sys` is null, else forwards to 0x00833a10(owner, a, b, 0, sys) with the manager still in ECX. ComputeOutput reaches it as `mgr = server->vtbl[2]()` -- a zero-argument getter whose four argument pushes were scheduled BEFORE the call, which reads as a five-argument virtual call and is not one. Fills out[4] and out[5]; nothing downstream of out[3] reads them. The name is INFERRED from the callee's neighbourhood (the trade manager's difficulty multiplier lives at 0x00833938), not from a symbol [unverified] +constexpr uint32_t TradeManager_SystemRouteIncome = 0x0043a5b0; +// cdecl int (ServerSystem* sys, ServerPlayer* owner, int kind) // the station count both station bonuses read. ConstructionPoints 0x00746830 passes kind = 1 (shipyards); GroupOutput 0x0074b7a0 passes kind = 0 (the imperial output bonus). Body not read this lane; the argument order and the two kinds are read off the two call sites [unverified] +constexpr uint32_t ServerSystem_StationCount = 0x00415c10; +// thiscall int (StarShip* ship, int kind) // the per-ship repair demand the orbit repair pass sums and then spends against, always called with kind = 1. Body not read; this is the ONE input of ComputeOutput that no save can currently supply, and until it is read the engine takes the demand as 0 -- which reads a colony with a damaged fleet HIGH, because every point the repair pass would have taken is a point that comes back to the money channel instead [unverified] +constexpr uint32_t Ship_RepairCost = 0x00415180; +// thiscall void (StarShip* ship, int points) // the write half of the orbit repair pass. Body not read [unverified] +constexpr uint32_t Ship_ApplyRepair = 0x004151c0; +// thiscall bool (StarFleet* fleet, int a, int b) // the fleet-level gate of the orbit repair pass, called as (0, 8); the ship-level counterpart is 0x00814da0 with the same arguments. Body not read [unverified] +constexpr uint32_t Fleet_TestFlags = 0x00413ab0; // thiscall void (Game_ShipDesignDef* this, Mars::Stream* s) // THE DESIGN SERIALIZER LANE D SAID DID NOT EXIST. Slot 1 of the ShipDesignDef vftable 0x009fef64. Writes, in DISK order: WriteBool 'FAIDes' this+0x4, WriteBool 'DHide' this+0x5, WriteBool 'DWep' this+0x6 (a BOOL, not an int -- the campaign schema had it as int; byte-neutral because a 4-char tag makes both items 12 bytes), WriteString 'DName' this+0x8, then THREE 'DSec' frames through StreamableHelper at this+0x4c, this+0x24, this+0x74 in that order. THREE sections, not five: the ctor 0x00874c70 runs eh_vector_constructor_iterator(this+0x24, stride 0x28, count 3). MEMORY ORDER != WRITE ORDER: the array is [+0x24, +0x4c, +0x74] and the wire is [+0x4c (command), +0x24 (mission), +0x74 (engine)] [verified] constexpr uint32_t Game_ShipDesignDef_Write = 0x00427390; // thiscall void (Game_ShipDesignDef* this, Mars::Stream* s) // slot 0 of vftable 0x009fef64. Mirrors Write field for field, same tags, same three DSec frames in the same order [verified] @@ -2005,6 +2069,20 @@ constexpr uint32_t g_flt_RebOutModDecay = 0x00617870; constexpr uint32_t g_flt_RebOutModMin = 0x00617868; // data const float = 2.0f, the upper clamp of RebOutMod [verified] constexpr uint32_t g_flt_RebOutModMax = 0x0061786c; +// thiscall void (StrategyServer* S, vector* allianceBroken, vector* napBroken, vector* cfBroken) // 700 B, ret 0xc. THE DIPLOMACY LEDGER'S PER-TURN STAMP, and the only writer of DiplomacyStats on a turn with no combat and no diplomatic command. Pass A (0x00789920..0x007899c4): over every ORDERED pair (A,B) of the S-frame player vector at S+0x54/+0x58, skipping A==B by POINTER, rel = A->GetRelation(B) 0x0080e050; rel 1 -> slot 8, rel 2 -> slot 0, rel 3 -> slot 4, else skip; then ctor a DiplomacyStats on the stack, GetDipStat(&local,B) 0x008180e0, store (int16)S->Frame(+0xc) at local+8+slot*2, SetDipStat(&local,B) 0x00863950. Field mapping: rel 3 -> lastally(+0x10), rel 2 -> lastnap(+8), rel 1 -> lastcf(+0x18). Pass B (0x007899ca..) is the betrayal counter and indexes the three broken-mask arguments by the inner loop index, substituting a zero local when a vector's length != nPlayers -- with no command stream it is a no-op. SOLE CALLER: ApplyTurnCommands 0x007b18b0 at 0x007b2461, so this runs BEFORE both turn drivers and AFTER BeginProcessTurn's frame bump [mapped] +constexpr uint32_t StrategyServer_StampTreatyTurns = 0x003898c0; +// cdecl int (int myPlyrIdx, PlayerAlliances* a, int otherPlyrIdx) // 58 B. if (myPlyrIdx == otherPlyrIdx) return 3; bit = 1 << otherPlyrIdx (shl by cl, so masked to 5 bits); if (a->AL(+4) & bit) return 3; if (a->NA(+8) & bit) return 2; return (a->CF(+0xc) & bit) ? 1 : 0. THE RELATION CODES ARE 3 = ALLIED (and self), 2 = NON-AGGRESSION, 1 = CEASE-FIRE, 0 = WAR -- strategic-turn-internals.md section 5.2 had 1 and 3 the other way round. The bit is the INDEX FIELD, not the position in the player vector (the opposite of the shared-vision mask), and AL is tested with NO alliance-id guard [mapped] +constexpr uint32_t PlayerAlliances_Relation = 0x002d2050; +// thiscall int (ServerPlayer* this, ServerPlayer* other) // 33 B, ret 4. A thin forwarder: tail-calls the cdecl PlayerAlliances_Relation 0x006d2050 with (this->PlyrIdx(+0x28), &this->Alliances(+0x168), other->PlyrIdx(+0x28)). 60+ call sites across the image [mapped] +constexpr uint32_t ServerPlayer_GetRelation = 0x0040e050; +// thiscall DiplomacyStats* (DiplomacyStats* this) // 55 B. vptr = 0x00a21430; every field zeroed; then lastcf(+0x18) = lastnap(+8) = lastally(+0x10) = -1. So a fresh entry's three 'last in force' fields are -1, NOT 0, and every counter (lastnapbty/bkn*/bty*/deadhome) is 0. This is what distinguishes 'never' from 'on turn 0' in the ledger [mapped] +constexpr uint32_t DiplomacyStats_ctor = 0x0040e7b0; +// thiscall void (ServerPlayer* this, DiplomacyStats* out, ServerPlayer* other) // 294 B, ret 8. if (!out) return; re-initialise *out to the ctor's defaults IN PLACE (the vptr is not touched); out->other(+4) = other->+0x4 (the handle id, i.e. the wire's PlayerID); if (!other) return; then a LINEAR FIRST-MATCH scan of the 0x24-stride vector at this->dipstats(+0x230/+0x234) for entry.other == GetId(other) 0x0042bfb0, copying the entry's thirteen int16 fields (out+8..out+0x21) on a hit. Stride read as 0x38e38e39 / sar 3 [mapped] +constexpr uint32_t ServerPlayer_GetDipStat = 0x004180e0; +// thiscall void (ServerPlayer* this, const DiplomacyStats* src, ServerPlayer* other) // 328 B, ret 8. if (!src || !other) return; the same linear first-match scan; ON A MISS default-construct a DiplomacyStats on the stack and push_back it (0x0085bc40) so a NEW ENTRY IS APPENDED AT THE END, then back().other = other->+0x4; finally copy src's thirteen int16 fields into the entry and re-write other. The append order is therefore the order in which pairs are first stamped, which is player-vector order [mapped] +constexpr uint32_t ServerPlayer_SetDipStat = 0x00463950; +// thiscall bool (StrategyServer* this, int playerId) // 60 B, ret 4. p = HandleMap::Resolve(this + 0x80, playerId) 0x008b9240; if (!p) { Log(2, <0x00a2fb30>, playerId); return false; } p->Status(+0x164) = 4; return true. THE ONLY WRITER OF Player.Status = 4 IN THE IMAGE. Three callers, all End Turn SUBMISSION paths that run before the turn is processed: EndTurn 0x00783be0 (+0x70, passes the client's own id at client+0x148), EndTurnForced 0x00783d30 (+0x7b), OnPlayerEndTurn 0x007d9af0 (+0x35). The other two immediate stores to +0x164 in the image are ProcessTurn +0x5ca (value 1, inside the 0x44-stride encounter-member loop) and ResumePlaying +0xb1 (value 0, the load-path normalisation determinism-oracle.md recorded as 'Status resets 4 -> 0 on load'). There is NO writer between tail phase 31 and the autosave; backlog.md item 6 looks in the wrong place [mapped] +constexpr uint32_t StrategyServer_MarkPlayerTurnEnded = 0x00421a40; // thiscall bool (TechTree* this, TechPrereqs* prereqs /* = TechDef + 0x88 */) // RET 4. An AND over groups, each group an OR over techs: a group is satisfied by any listed tech whose node exists in this->nodes AND whose state (+0x14) is 4. Zero groups -> TRUE (the function returns satisfied==total with both 0); a group with ZERO entries -> FALSE and the whole test fails, because the inner loop cannot break and the outer one then exits with that group uncounted. Reads only. Called from SetResearched twice: the unforced completion gate on the argument def, and the availability sweep on each node's self-resolved def [verified] constexpr uint32_t TechTree_PrereqsMet = 0x0017d8e0; // field TechPrereqs prereqs -- the block TechTree::PrereqsMet is called on. Two MSVC vectors back to back: the flat entry array at +0x00 and the group array at +0x10. SetResearched passes `def + 0x88` at both call sites [verified]