sots-re/findings/subsystems/ship-construction.md
lane-b6 5cc66f6fff findings: ship construction -- the build queue read byte for byte, and the missing destroyer is the AI's
BuildQueue::ProcessTurn 0x00890d50 disassembled to the next function start (Ghidra says 1230
bytes; the body is 1264 and its end lands inside the epilogue -- rule 17 again). Corrects the
recorded prototype: the first stack argument is the StrategyServer S frame, not the system, so
the build-completed event goes on the sim and the hull's turn stamp is Frame.

THE HEADLINE IS A CORRECTION. Lane E2's open census leaf (shpt[0], one destroyer short on both
reference pairs) cannot be closed by S11's build-queue sub-pass: measured over the corpus, both
reference saves carry three empty BQ frames, every hbq false, and the one TurnCommands_v5 block
empty. The order is created inside the turn by the AI -- PvSav shows 11,900 leaving the treasury
before spine phase 0. The blocker moves to game/ai.

Also: the per-class built counter has EXACTLY ONE writer in the image (image-wide byte scan);
ShipRecords sized by enumeration; the ship/fleet birth chain end to end, correcting lane B5 --
FtFlg 0x400 is set by EVERY fleet born through the create path, not by retreat; one home fleet
per system cached at ServerSystem+0x238; and a third indirection class vtable_map.py cannot see
(the ship-borne wrapper is dispatched from a stack-built pointer table).

21 addresses in ghidra/addresses.d/lane-b6.json, validated to a scratch path against the merged
set (1,077 entries, no duplicate names); two entries dropped as duplicates of lane B5's and
recorded as agreements instead.
2026-09-08 14:54:03 -04:00

24 KiB
Raw Permalink Blame History

Ship construction — the build queue, and where the missing destroyer actually comes from

Lane B6, 2026-09-08. Program sots / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.

Method. Game::BuildQueue::ProcessTurn was disassembled byte for byte with objdump -b binary -m i386 -M intel over the raw image, from 0x00890d50 to the next function start. No control-flow claim here comes from the decompiler. The ship/fleet birth chain (§4) was read the same way by a delegated sweep; ReVa was used only for cross-reference lists. The corpus measurements in §2 and §5 come from verify/save-reader/save_reader.py over the eleven saves.

Addresses: ghidra/addresses.d/lane-b6.json (21 entries, validated against the merged set — 1,077 entries, no duplicate names). Engine side: sots-engine wip/build, docs/B6-ship-construction.md.


0. The headline, and it is a correction

Lane E2's falsified prediction left one census leaf open — shpt[0], the archived destroyer count, one higher than ours on both reference pairs — and concluded "no phase we run builds a ship; S11's build-queue sub-pass is the declared input boundary."

The first half is right. The second half is wrong, and the build queue cannot close that leaf.

On both reference pairs there is no build order anywhere in the game at the moment the save is loaded. turn1-state.sav and turn2-state.sav each carry exactly three BQ frames — one per owned system, players 16, 32 and 576 — and all three hold zero orders. Every hbq in both files is false, so no ship-borne BQ2 queue exists either. The only TurnCommands_v5 block belongs to player 16 and is the byte-identical empty one; the other three custom-data blocks are AIAgent caches. A faithful build-queue pass has nothing to advance and closes nothing.

shpt[0] is blocked on AI order generation (roadmap §4.1, game/ai, Rung B), not on S11.

The evidence that the order is created inside the turn is in the oracle file itself. On the turn1→turn2 pair the owner is player 32, "Fane Lao", an AI, and the pair moves:

leaf turn1 turn2 what it means
Player[32]/NumDes 5 6 the AI designed a hull during the turn
/Sim/DesignIDs[] 41 43 ids 18 and 1712 appear
/Sim/NMnx 106 109 three ids issued: 1712 (design), 1728 (ship), 1744 (fleet) — counters 107, 108, 109 in that order
/Sim/ShipIDs[] 15 16 id 1728
/Sim/FleetIDs[], /Sim/NumFlts 6 7 id 1744, Flt[1744 "Alpha Fleet"] only-in-oracle
Player[32]/FNG/FNGNum 0 1 one fleet name generated
Player[32]/Maint 0 500 the new hull's upkeep
Player[32]/ShipRecs/srb[0] 0 1 the per-class built counter
Player[32]/ShipRecs/srb[3] — new a per-design record appended
Player[32]/PvSav 50,000 38,100 11,900 left the treasury before phase 0

That last row is the one that settles it. PvSav is written by spine phase 0, which snapshots Sav before anything else in the turn runs. The input file says Sav = 50,000; the output file's previous-turn snapshot says 38,100. So 11,900 was spent between the save and the first phase of the turn — the queue-time money deduction (lane O) of a build order that the input file does not contain. The AI issues its orders, the queue drains them in S11, and the queue is empty again by the time the next save is written.

NMnx 106 → 109 with the design allocated first also says the design did not exist before the turn: it is not a pre-existing design being built, it is designed and built in one turn.

0.1 What this lane delivers instead

  • the pass read byte for byte, which independently confirms lane B4's sim::ProcessBuildQueue and finds one divergence in it (§3.3);
  • the completion bookkeeping — the ShipRecords update — which nothing modelled (§3.4), and whose per-class counter has exactly one writer in the whole image;
  • the ship and fleet birth chain, end to end (§4), including a correction to lane B5's 0x400;
  • an oracle for the whole pass that the campaign already owned and had not noticed (§5): the zuul-turn16 → zuul-turn17 transition completes six orders and partially advances a seventh, and the model reproduces it from a single point total per system;
  • a third indirection class that tools/vtable_map.py cannot see (§6).

1. Where construction runs in the phase order

StrategyServer::ProcessTurn                      0x007dc6c0
 └ phase 11  per-system ServerSystem::ProcessTurn   0x007598e0        (no arguments)
    └ ProcessBuildQueue                             0x00752500
       └ BuildQueue::ProcessTurn                    0x00890d50   <-- the pass
          ├ (per completed order) StarShip creation + attach
          └ (after the loop) the built-ships event poster          0x0088ca00

Inside the colony turn the order is: unowned infra decay → infra/pop bonus apply → independent pop drift → stability → system bonus accrual → ProcessPlague → ProcessBuildQueue → imperial growth → civilian growth → resources → refuel-in-orbit → the countdown nibbles → ProcessSlaves → ProcessRebellion → addiction. So construction completes before the turn's population growth and after the plague pass, and it is inside the same per-system loop.

ProcessBuildQueue computes the system's output vector, writes out[2] through its out-parameter, applies out[11]/out[10], and hands out[8] — construction points already capped at the queue's demand — to the pass. The points are the pass's only real input, and they are the roadmap's item 1 (the per-system output term). Everything below is about what happens once points arrive.

The second caller, 0x00789500, is the construction-ship wrapper: it feeds the ship's own queue (ship+0x98, gated on hbq) with the ship's ConCap (ship+0x68) as the point budget. Same function, different queue.


2. Corpus coverage, stated before the model (rule 15)

Eleven saves. Six carry no build order at all; five do.

save frame orders where
turn1-state.sav, turn2-state.sav, turn3-state.sav 1, 2, 3 0 three BQ frames each, all empty
human-turn2-orders.sav, human-turn3-noderoute.sav — 0 20 BQ frames, all empty
zuul-turn15-orders.sav 15 0 five build orders sit pending in the command block, not yet in a queue
zuul-turn5-species5.sav 5 4 one queue, design 18 ×4
zuul-turn16-noderoute.sav 16 7 sys 80 (player 32) ×4, sys 384 (player 16) ×3
zuul-turn17-orders2.sav, zuul-turn17-rollpending.sav 17 2 sys 80 ×1, sys 384 ×1
zuul-turn23-fleet23.sav 23 1 sys 384 ×1

SAVE_FORMAT.md §11's note "count 0 in turn1..3" was read campaign-wide as "the build queue is never exercised". It is exercised, in five files — just not in either reference pair.

Never exercised anywhere in the corpus, and therefore hypotheses: a design with a non-zero money cost (design+0xc0), so the slot-9 money charge and its refusal path have never fired; a ship-borne queue with an order in it (every hbq in every save is false); an order surviving a turn with conleft <= 0; and any non-zero value of srl/srk (losses and kills) anywhere.


3. Game::BuildQueue::ProcessTurn — 0x00890d50 .. 0x00891240

__thiscall, ret 8. Ghidra says 1230 bytes; the body is 1264 and Ghidra's end lands inside the epilogue, before the security-cookie check (rule 17, sixth lane bitten).

3.1 The signature is not what addresses.json says

addresses.json carries 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], eax = [esi+0x10], esi = the ServerSystem, ecx = [esi+0xa4] = the queue). That is the StrategyServer S frame — one word below the S+4 frame lane B5's StrategyServer_CreateFleet takes, which is exactly lane T §0's two-bases-four-bytes-apart pattern showing up again. Call it P.

Two things the old prototype hides:

  • the build-completed event is pushed onto a list at P+0x2b0 (size at P+0x2b4), i.e. on the sim, not on the system;
  • the turn stamp written into the new hull at 0x00890e02 is P+0xc, which StrategyServer::Write tags Frame — the turn number, not the system's anything.

3.2 The body

0x00890d88  if (points <= 0) goto epilogue          ; RETURNS THE ARGUMENT, SWEEP AND ALL SKIPPED
0x00890d9a  node = *(this->orders @ +0x10)          ; MSVC circular std::list
0x00890da4  if (node == head) goto removal sweep

loop:
0x00890db5  if (node->conleft(+0x18) > points)      ; cannot finish this turn
0x008910c4      node->conleft -= points; points = 0; BREAK          ; <-- the pass STOPS
0x00890dbe  design = node->+0x20
0x00890dc7  if (design->cost(+0xc0) > 0)
0x00890ddc      if (!this->vft[9](P, (int64)cost)) goto next        ; refusal SKIPS, does not stop
0x00890df0  ship = CreateShip(P, this->owner(+0xc), design)         ; §4.1
0x00890e02  ship->tblt(+0xac) = P->Frame(+0xc)
0x00890e08  newShips.push_back(ship)
0x00890e20  this->vft[10](P, ship)                                  ; §4.2 -- fleet routing
0x00890e22..0x00890e3e   five per-ship refresh calls
0x00890e43  if (design->+0xb8 & 0x04000000)  transfer carried population
0x00890e91  if (design->+0xb8 & 0x00080000)  station/hab hook
0x00890ec1  this->OnShipBuilt(P, ship)
0x00890ed1  P->OnShipBuilt(this->owner, ship)
0x00890ef7  owner->ShipRecords.built[design->hullClass(+0x12c)]++   ; §3.4
0x00890efe..0x00890f61   the per-design record: find, append if absent, ++built
0x00890f64..0x00891004   build-completed event -> P's event list
0x00891006  points -= node->conleft; node->conleft = 0
next:
0x0089101c  node = *node ; if (node != head) goto loop

0x0089102b  if (!newShips.empty()) this->PostBuiltShipEvents(P, &newShips)   ; 0x0088ca00
0x00891041  REMOVAL SWEEP: collect every node with conleft <= 0, unlink, delete, --count
0x00891208  return points

3.3 Independent confirmation of lane B4, and one divergence

Lane B4's sim::ProcessBuildQueue (sots-engine game/sim/colony.cpp) was written from a different reading. Read against the bytes it is right on every rule that the corpus can reach: FIFO order, the strict conleft > points stop, the money refusal that continues rather than stopping, the removal sweep being a separate unconditional pass over the whole list rather than a delete-as-you-go, and the leftover being the return value. That is an agreement worth recording, not a formality — those are exactly the four rules a plausible reimplementation gets wrong.

One divergence. With points <= 0 the original's entry test at 0x00890d88 branches to the epilogue, so the removal sweep does not run either; the engine ran the sweep unconditionally. Fixed on wip/build. This is a labelled hypothesis, not a result: an order can only reach conleft <= 0 inside the pass, and the same pass erases it before returning, so a queue never starts a turn with one — unless a zero-construction-cost design is ever queued, which no corpus save has done. The workload that would decide it: queue a design whose con is 0 into a system whose construction output is 0 that turn.

3.4 The completion bookkeeping — ShipRecords

Two writes per completed hull, and the first has exactly one writer in the executable. An image-wide byte scan for the indexed-increment form at that displacement (ff 84 ?? b4 01 00 00, all executable sections, all SIB forms) returns one site: 0x00890ef7. So a hull that reaches the wire with the per-class counter bumped came through the build queue and through nothing else — including the encounter spawner of §4.4, which creates ships and touches no ShipRecords at all. That makes srb a clean discriminator between player-built hulls and spawned ones.

Layout, sized by enumeration rather than by what the code touches (rule 5). The wire writes srnc groups of {srb, srl, srk, sri} then srbd records of {srd, src, srb, srl, sri}. Three classes × four arrays × 4 bytes from 0x1b4 lands exactly on 0x1e4, which is the per-design vector the very next instruction reads. So:

ServerPlayer + 0x1b0   Game::ShipRecords vptr
           + 0x1b4     int built    [3]      srb   <- 0x00890ef7 increments this
           + 0x1c0     int lost     [3]      srl
           + 0x1cc     int killed   [3]      srk
           + 0x1d8     int inService[3]      sri
           + 0x1e4     std::vector<{int srd; int src; int srb; int srl; int sri}>   stride 0x14

The per-design half is a linear scan for a record whose first word equals *(design+0xa4), a push_back when there is no hit (writing key, hull class, and three zeroes), then ++record->built. srd is the design's object id — measured, not inferred: the zuul corpus carries records keyed 608, 576, 114, 816, 18, 34, 130 against build orders naming exactly those desIDs. The vector's order is first-seen and is load-bearing for the wire.

Nothing here writes srl, srk or sri.


4. What a new ship and a new fleet look like at birth

Instruction-verified unless marked.

4.1 The ship

0x004f41a0 is a 33-byte thunk: id = IDMap_AllocateLocalID(this+4); return StarShip_Create(this+4, id, owner, design).

  • The id comes from lane B5's allocator, confirmed at the instruction level. 0x0080f710 adds 0x80 to reach the IDMap sub-object and tail-jumps into 0x008b8b70, which pushes the network node index into IDMap_AllocateID 0x008b8ae0: counter at table + node*0x14 + 0x10, pre-incremented (inc at 0x008b8b16, value read after), id 0 skipped (0x008b8b20 branches on the inc's own flags and increments again), then (counter << 4) | (node & 0xF) at 0x008b8b38–0x008b8b3e, with a round-trip check. B5's model reproduced exactly.
  • StarShip_Create 0x008656f0: operator new(0xb0) — Game::StarShip is 176 bytes — ctor 0x00861280, then IDMap_Insert 0x008b9350, and that is where the object id lands, in ship+0x04 (0x008b93b5).
  • Birth values of the save-visible fields: PlrID(+0x10) = the queue's owner, DesID(+0x14) = the design, Range(+0x20) / Health(+0x24..+0x30) / ConCap(+0x68) / RefCap(+0x6c) / RepCap(+0x70) copied from the design's cached stats (design+0xe8, +0xec.., +0xd0, +0xd8, +0xdc), Plg(+0x48) and tsd(+0x60) at -1, everything else zero, and FltID(+0x64) NULL — the fleet link is made later. hbq(+0x98) gets a ship-borne BuildQueue when design+0xb8 & 0x400000; hsp/ppop(+0x9c/+0xa0) get Population objects when design+0xb8 & 0x4000000.
  • tblt(+0xac) is born -1 and the build queue overwrites it with Frame. MEASURED against the corpus: the six hulls the zuul-turn16 → zuul-turn17 pair adds all carry tblt equal to the new turn number, which independently confirms that BeginProcessTurn's Frame increment happens before the spine — a phase reading Frame mid-turn sees the turn it is producing, not the one it started from.

4.2 The fleet: join, not create, is the normal case

vft[10] for Game::SystemBuildQueue is 0x00891240 — which is the "next function start" that bounds ProcessTurn itself, and is why the fleet half of construction looked absent from the pass. It routes the hull by design role bits (+0xb8 & 0x80000, +0xbc & 4, +0xb8 & 0x800000, +0xb8 & 0x400, each to its own handler) and then falls through at 0x00891328 to

if (ship->FltID == 0) ServerSystem_AttachShipToHomeFleet(sys, ship)      ; 0x0074f4d0

ServerSystem_AttachShipToHomeFleet: if sys->+0x238 is non-null the cached home fleet is reused; only when it is null is a fleet created — id from the same allocator (0x0074f500), StrategyServer_CreateFleet (lane B5, 0x0085b340) at the system's position with a NULL name override, the result cached into sys->+0x238, FtFlg |= 0x20, then StarFleet_AddShip.

So one home fleet per system. The reference pair's NumFlts 6 → 7 is a first build at that system; a second turn of building there would add ships to fleet 1744 and create nothing.

ServerSystem+0x238 is a new offset — not in struct-recovery.md's table.

4.3 The fleet at birth, and a correction to lane B5

StrategyServer_CreateFleet 0x0085b340: operator new(0x120) — Game::StarFleet is 288 bytes — ctor 0x00706140, id registered into the same map (so fleet+0x04 = the id), +0x10 host, +0x58 = the owning ServerPlayer (PID), +0x18..+0x20 and +0x4c..+0x54 = the position, and then

0x0085b454  push 1 ; push 0x400 ; call 0x006fe1a0      ->  fleet->FtFlg(+0x10c) |= 0x400

Correction. Lane B5 recorded the 0x400 on FtFlg as something the retreat pipeline sets. It is not retreat-specific: every fleet born through 0x0085b340 gets it, at 0x0085b460, unconditionally, before the caller has done anything. The build path then ORs 0x20 on top. The bit means "created at runtime", not "created by a retreat".

And it remains a different word from the design/census 0x400 — that one is the defence-platform bit in a design's role flags (game/design/hull.h already says so), and a third 0x400 is a design role bit tested at 0x00891311 inside the routing ladder. Three unrelated 0x400s on this one code path.

The constructor leaves FtFlg zero, FtMS(+0x11c) at -1, and everything else zero. There is no scalar ship count: the wire's NShips is the length of the vector<StarShip*> at +0xa4/+0xa8/+0xac. The 16-bit word at +0xc0 is not a count — StarFleet_AddShip does +0xc0 += f(design->hullClass) at 0x00706348, an accumulated tonnage.

StarFleet_AddShip 0x007062e0 is the join step: push into the vector, ship->FltID = fleet (0x0070631b), OR the design's two role words into the fleet's aggregates (+0xb8, +0xbc), add the tonnage, and recompute.

The fleet name. StrategyServer_CreateFleet asks the generator only when the name-override argument is NULL, which the build path passes. The generator 0x00855f80 reads ServerPlayer+0x298 (FNG), divides the counter at FNG+0x4 by the name-table size to pick a name and a wrap count, and increments FNG+0x4 at 0x00855fe9 — so FNGNum is bumped once per generated name. That is the FNGNum 0 → 1 in the reference pair, and it is why the fleet is called "Alpha Fleet".

4.4 Ships are created outside the build queue too

StarShip_Create has exactly two roots in the image. The second is TradeManager_SpawnEncounterSquadron 0x0088f070: it draws ids from the same allocator, creates a fleet through StrategyServer_CreateFleet, then loops ship creation + StarFleet_AddShip. It is reached only from ServerTradeManagerImpl vtable slot 17, which picks its target with an RNG draw. It does not touch ModCount and does not touch ShipRecords — which is what makes the per-class srb counter a clean discriminator (§3.4). A functional reimplementation needs the ship factory reachable from both roots.


5. The oracle the campaign already owned

zuul-turn16-noderoute.sav (frame 16) → zuul-turn17-rollpending.sav (frame 17) is a genuine consecutive-turn pair of one game. rollpending is the clean turn-17 start state; orders2 is the same state with a command block on top.

system owner frame 16 queue (desID:conleft) frame 17 queue points that reproduce it
384 16 608:1753, 576:1860, 576:1860 576:1291 4182
80 32 114:959, 114:1889, 114:1889, 114:1889 (+816:6974 appended during the turn) 816:3818 9782

Six orders complete, one is partially advanced, and the after-state is reproduced by a single non-negative integer point total per system — searched for exhaustively over 0..200,000, and the solution is unique in both cases. That is the falsification test for the whole model at once: a per-order point budget, a non-FIFO drain, or "skip and continue" instead of "stop at the first order that cannot finish" each fail it.

The completions agree with the counters the same two files carry:

  • player 16: class-0 srb 2 → 4; design 608's record 2 → 3; a new record for design 576 with 1;
  • player 32: class-0 srb 53 → 57; design 114's record 18 → 22; design 816 unchanged at 6 because its order did not finish;
  • ship count 60 → 66; NMnx 159 → 165, one id per hull, no new fleet — every hull joined its system's cached home fleet, exactly as §4.2 predicts.

The one fitted element is order 58's presence in the frame-16 before-state: the AI appended it during the turn, and its con and ordID are taken from the frame-17 file. Everything else is measured on both sides. The engine carries this as tests/game_sim/test_construction.cpp::test_corpus_pair.

What this oracle does not test: the money charge (no order in the corpus has a money cost), the ship and fleet birth of §4 (only that the counters move), and the point derivation, which is item 1.


6. Indirect edges — a third class vtable_map.py cannot see

Checked before concluding anything about reachability, as lane V2's result demands.

  • BuildQueue::ProcessTurn 0x00890d50 is in no vftable. Its two direct callers (0x0075258a in ProcessBuildQueue, 0x00789551 in the ship-borne wrapper) are therefore the complete set.
  • The family's vtables: Game::BuildQueue 0x00a31328 is abstract from slot 4 through slot 10 — seven purecalls. Game::SystemBuildQueue 0x00a31358 binds slot 9 → 0x00809910 and slot 10 → 0x00891240; Game::ShipBuildQueue 0x00a31388 binds 0x0091e480 and 0x0081c000.
  • FUN_00789500, the ship-borne wrapper, 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 — 0x0078c930, 0x00789590, 0x00789620, 0x00792420, 0x00792450.

That last one is a new finding for the campaign's reachability model. Lane V2 established that "no direct caller" is a lower bound because of vtables; "no direct caller and no vtable slot" is also a lower bound, because of locally-constructed dispatch tables. tools/vtable_map.py should grow an "address stored into a stack or struct slot" edge class before any further "unreachable" claim is made.


7. Open, and named

  1. What creates the AI's build order, and when in the turn. The measured constraint is tight: the money leaves the treasury before spine phase 0, and the design is allocated before the ship. That points at command application, not at a spine phase. game/ai (lane AI2) owns this.
  2. The four non-default routing handlers in SystemBuildQueue_AttachBuiltShip (0x0088e7f0, 0x00751480, 0x00751460, 0x0074f460) — predicates and call sites verified, bodies not read. Every corpus completion takes the fall-through path.
  3. The money charge vft[9]. Never fired: no design in the corpus has design+0xc0 > 0. The refusal-continues rule is read from the bytes and has never been observed.
  4. The three message variants the built-ships event poster (0x0088ca00) produces, keyed on ship+0x18 & 0x80000 / & 0x800000 / neither. Structure read, format strings not resolved.
  5. FUN_00845430, which builds the ship-borne queue at ship+0x98. Not opened; no save has ever carried a non-empty one.
  6. The workload that would close shpt[0] without the AI: a save with a build order already in a system queue, plus the autosave the game writes from ending that turn. zuul-turn16 is such a save and the campaign does not hold its autosave — only the next turn's manual save, which also carries the AI's actions. One VM140 session (load zuul-turn16-noderoute, End Turn, keep the autosave) would turn §5's fitted point totals into a full before/after oracle for the pass, the ship birth and the fleet birth at once.