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.
This commit is contained in:
lane-b6 2026-09-08 14:54:03 -04:00
parent 90c665a3be
commit 5cc66f6fff
2 changed files with 577 additions and 0 deletions

View file

@ -0,0 +1,405 @@
# 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 `desID`s. 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
> `0x400`s 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 `purecall`s. `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.

View file

@ -0,0 +1,172 @@
{
"entries": [
{
"name": "BuildQueue_ProcessTurn_RealEnd",
"addr": "0x00891240",
"convention": "site",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08, instruction stream 0x00890d50-0x00891240)"
},
{
"name": "BuildQueue_off_Owner",
"offset": "0xc",
"convention": "field",
"prototype": "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)",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "BuildQueue_off_OrderList",
"offset": "0x10",
"convention": "field",
"prototype": "std::list<ShipBuildOrder> 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",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "BuildQueue_off_OrderCount",
"offset": "0x14",
"convention": "field",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "ShipBuildOrder_off_ConLeft",
"offset": "0x18",
"convention": "field",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "ShipBuildOrder_off_Design",
"offset": "0x20",
"convention": "field",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "BuildQueue_vslot_ChargeMoney",
"offset": "0x24",
"convention": "vslot",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "BuildQueue_vslot_AttachBuiltShip",
"offset": "0x28",
"convention": "vslot",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "SystemBuildQueue_AttachBuiltShip",
"addr": "0x00891240",
"convention": "__thiscall",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "ServerSystem_AttachShipToHomeFleet",
"addr": "0x0074f4d0",
"convention": "__thiscall",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "ServerSystem_off_HomeFleet",
"offset": "0x238",
"convention": "field",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "StarShip_Create",
"addr": "0x008656f0",
"convention": "__thiscall",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "StarShip_Ctor",
"addr": "0x00861280",
"convention": "__thiscall",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "StarShip_off_TurnBuilt",
"offset": "0xac",
"convention": "field",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "ShipRecords_BuiltCounterSite",
"addr": "0x00890ef7",
"convention": "site",
"prototype": "`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",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "ServerPlayer_off_ShipRecordsBuilt",
"offset": "0x1b4",
"convention": "field",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "ServerPlayer_off_ShipRecordsByDesign",
"offset": "0x1e4",
"convention": "field",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "TradeManager_SpawnEncounterSquadron",
"addr": "0x0088f070",
"convention": "__thiscall",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "ShipBorneBuildQueue_ProcessTurn",
"addr": "0x00789500",
"convention": "__thiscall",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "BuildQueue_ProcessTurn_Arg0Correction",
"addr": "0x00752589",
"convention": "site",
"prototype": "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)",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
},
{
"name": "BuildQueue_off_OwningSystem",
"offset": "0x8",
"convention": "field",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
}
]
}