Compare commits

..

8 commits

Author SHA1 Message Date
alex
7f45817473 sync generated header after B6 merge 2026-09-08 15:00:22 -04:00
alex
2fd08522ec merge lane B6: build-queue completion pass; shpt[0] cannot close from a save with no orders - blocker moves to game/ai 2026-09-08 14:56:16 -04:00
alex
071e9de83d sync generated header (1095 entries) after C3 merge 2026-09-08 14:56:12 -04:00
lane-b6
db3909bcd4 docs: B6 measured -- prediction held, closed 0 regressed 0 on both reference pairs
209 -> 158 (closed 51, regressed 0) and 108 -> 87 (closed 21, regressed 0), identical to the
pre-lane baseline, measured on CT111. Falsification hypotheses 1 and 2 refuted by measurement
(three empty BQ frames per reference save, every hbq false, the one command block empty);
hypothesis 3 refuted by the corpus oracle; hypothesis 4 stands as a labelled hypothesis.

construction.h also carries the ship/fleet birth shape as read but NOT implemented, with the
reason stated: the newborn hull copies four cached stat words the engine does not yet compute.
2026-09-08 14:54:21 -04:00
alex
493ec71817 merge lane C3: ComputeOutput turn path; unspent construction cascades to money; +1 leaf, 0 regressed 2026-09-08 14:52:40 -04:00
alex
d3ee45364b game/sim + app: ComputeOutput on the turn path, and P01/P02 committed
`ComputeBudget` takes a system's money from two different functions. Projected mode
calls `ComputeMaxIncome`, which lane E1 closed 25/25 against the BnkEl oracle. The
TURN calls `ComputeOutput` with the system's own rate sliders, where the build queue,
the ship-repair pass and the infrastructure -> terraform -> money cascade are all
live and E1's proof that the cascades are zero does not apply.

Read from the instruction stream, both ranges disassembled to the next function start:

* `sim::ComputeSystemOutput` -- the channel algebra of `ComputeOutputFromRates`, with
  every rounding site (round-half-even per channel, truncating for the construction
  and money slots) and the association of every x87 sum as the original has them.
* `sim::IdealSuitability` -- the owner's own field, the server's species baseline for
  an independent colony, and the per-system `dsu` override.
* `sim::RepairShipsInOrbit` -- the round robin, which is provably equivalent to
  `points - min(points, demand)`: the per-pass share is at least 1, so the only early
  exit needs every remaining cost to be zero.
* two corrections to `ConstructionPoints` and `SplitLeftover`: the station bonus is
  ignored unless strictly positive and its association is `k x (b x cons) + cons`, and
  the leftover weights sum as `wi + (wf + wt)`.

The load-bearing fact: the leftover construction points come back to the TRADE
channel, so a colony with an empty build queue earns the same money whichever way its
sliders point. The engine now runs both paths on every load and reports the
difference; on the 11-save corpus every delta decomposes to the unit into the build
queue's points priced through the money chain.

P01/P02 move from blocked to partial and are committed:

    turn1-state -> turn2-state    209 -> 157   closed 52  regressed 0   (was 51 / 0)
    turn2-state -> turn3-state    108 ->  86   closed 22  regressed 0   (was 21 / 0)

One leaf per pair, and it is the easy one: the independent colony, whose population
does not grow and whose orders the turn does not change. The human's savings are
still short by the civilian growth `S11` does not commit, and the AI's by its own
orders. The ship-repair demand is taken as 0 because `Ship::RepairCost` is unread.

sots-re: findings/subsystems/output-turn-path.md, ghidra/addresses.d/lane-c3.json
2026-09-08 14:50:18 -04:00
lane-b6
0fcbb69ba5 game/sim + app: ship construction -- the build-queue completion bookkeeping, and S11's sub-pass wired in
game/sim/construction.{h,cpp}: Game::ShipRecords (four per-hull-class arrays plus the
per-design vector, sized by ENUMERATION against the wire, not by what the code touches)
and the completion bookkeeping BuildQueue::ProcessTurn performs -- the per-class built
counter, whose indexed increment has EXACTLY ONE writer in the whole image, and the
find-or-append per-design record keyed by the design's object id. RunSystemConstruction
wraps the point pass and keeps each completion's design id, which the point pass alone
does not report.

game/sim/colony: corrected from the instruction stream -- with points <= 0 the entry test
branches to the epilogue, so the REMOVAL SWEEP IS SKIPPED TOO. Carried as a labelled
hypothesis: no corpus save can reach the state that shows it.

app/construction_phase.{h,cpp}: S11's build-queue sub-pass, reported on its own line
because what blocks it is not what blocks the rest of the colony turn. It is blocked on
the per-system output term for points; it is NOT what the archived ship census waits on.

tests/game_sim/test_construction.cpp: 65 checks, including a corpus oracle the campaign
already owned and had not noticed -- zuul-turn16-noderoute -> zuul-turn17-rollpending is a
real consecutive-turn pair in which six orders complete and one is partially advanced.
The test SOLVES for the point total rather than assuming it, so a non-FIFO order, a
per-order budget or skip-instead-of-stop each falsify it.
2026-09-08 14:39:20 -04:00
lane-b6
9f8d75d9cb docs: B6 prediction before the build -- the build queue closes nothing on either reference pair
Written before any code (earned rule 2). Both reference saves carry three BQ frames with
zero orders, every hbq false and the only TurnCommands_v5 block empty, so the pass has
nothing to advance; the archived destroyer is created inside the turn by the AI. Includes
the falsification list and names the oracle pair the campaign already owns.
2026-09-08 14:33:16 -04:00
15 changed files with 1520 additions and 45 deletions

View file

@ -0,0 +1,162 @@
# B6 — ship construction: the build queue, and where the missing destroyer actually comes from
Lane B6. Branch `wip/build` off `main` `0f1c007`.
This file was written and committed **before** the code (earned rule 2). Section 1 is the
prediction as first written; section 2 is the falsification list; sections 3 onward are filled
in after the measurement and correct section 1 in place where it was wrong.
---
## 1. Prediction, written before the build
The brief's target is `hist[*]/stats[*]/shpt[0]` on both reference pairs — the archived ship
census is one destroyer higher than ours, and lane E2 concluded that no phase we run builds a
ship. That conclusion is right. The inference drawn from it — that `S11`'s build-queue
sub-pass is what closes it — is **wrong**, and this lane predicts the pass closes **nothing**:
> **Both reference pairs: closed 0, regressed 0.**
>
> `turn1-state.sav` and `turn2-state.sav` each carry exactly **three** `BQ` frames (one per
> owned system: players 16, 32 and 576), and **every one of them holds zero orders**. Every
> `hbq` in both files is `false`, so there is no ship-borne `BQ2` queue either. The only
> `TurnCommands_v5` block belongs to player 16 and is the byte-identical empty one. So at the
> moment the standalone loads either save, **the whole game contains no build order at all**,
> and a faithful build-queue pass has nothing to advance.
>
> The destroyer the archive counts is created *inside* the turn: on the reference pair the
> owner is player 32 ("Fane Lao"), an AI, and the same turn shows `NumDes` 5 → 6 (a design the
> AI designed during the turn), `FNG/FNGNum` 0 → 1 (a fleet named), `Maint` 0 → 500,
> `ShipRecs/srb[0]` 0 → 1 and a new `ShipRecs` design record, with `NMnx` 106 → 109 — three
> ids issued in the order design(107), ship(108), fleet(109). The order that fed the queue was
> issued by the AI and consumed in the same turn.
>
> **`shpt[0]` is blocked on AI order generation (`game/ai`, Rung B), not on S11.** This lane
> moves the blocker; it does not close the leaf.
What this lane does deliver, and what it is worth:
* the build-queue advance read byte for byte from `Game::BuildQueue::ProcessTurn`
(`0x00890d50`, real end `0x00891240` — Ghidra says 1230 bytes, the body runs 1264), which
**independently confirms** lane B4's `sim::ProcessBuildQueue` and finds **one divergence**
in it (§4, "What changed in the engine");
* the `ShipRecords` update the pass performs, whose per-class counter has **exactly one
writer in the whole image**;
* the pass wired into the standalone's S11 as its own reported sub-pass;
* a real oracle for the pass that the campaign already owns and had not noticed:
`zuul-turn16-noderoute.sav` → `zuul-turn17-rollpending.sav` is a genuine consecutive-turn
pair (frames 16 and 17) in which **six orders complete and one is partially advanced**
across two systems (§3, "The corpus oracle").
## 2. How this prediction could be wrong, and the symptom of each way
1. **A build order exists somewhere I did not look.** The reader types unknown regions as
`raw`; a queue hidden inside one would not show up in a tag scan. *Symptom:* the standalone
reports a non-zero order count on a reference pair, and `closed` is not 0.
2. **The order is created before the spine rather than during it.** If the AI's orders were
applied at load time by something the standalone also runs, the queue would be non-empty by
S11 and the pass would build. *Symptom:* same as (1). (The measured `PvSav` says otherwise:
Fane Lao's savings are 50,000 in the input file and the previous-turn snapshot in the output
file is 38,100, so 11,900 left the treasury *between the save and phase 0* — the queue-time
deduction of a build order the file does not contain.)
3. **The FIFO + stop-at-the-first-short-order model is wrong.** *Symptom:* on the
`zuul-turn16 → zuul-turn17` pair there is **no** single non-negative integer point total per
system that turns the observed before-queue into the observed after-queue. This is a real
test: a per-order point budget, a non-FIFO order, or a "skip and continue" rule instead of
"stop" each fail it.
4. **The `points <= 0` gate on the removal sweep is wrong.** Unobservable in this corpus
and therefore carried as a labelled hypothesis, not a result.
5. **`shpt` is not the fleet walk.** If the census leaf were fed by `ShipRecs` rather than by
walking fleets, the fix would be different. Lane E2 already falsifies this: the fleet walk
reproduces 480/480 archived census leaves.
---
## 3. Measured
Built on CT111 (`/srv/re-lab/build/sots-engine-b6`), report run there with
`tools/standalone_report.py --binary …`. Host gates run separately, never `&&`-chained.
| pair | baseline | after | **closed** | **regressed** |
|---|---:|---:|---:|---:|
| `turn1-state.sav` → `turn2-state.sav` | 209 | 158 | **51** | **0** |
| `turn2-state.sav` → `turn3-state.sav` | 108 | 87 | **21** | **0** |
Identical to the pre-lane baseline, which is the prediction holding: **this lane closed 0 and
regressed 0.** `regressedPaths` is empty on both pairs.
The prediction's own numbers, checked one by one:
* three `BQ` frames per reference save (systems owned by players 16, 32 and 576), **0 orders in
all three**, on both sides of both pairs — the standalone now prints this on its own line;
* every `hbq` in `turn1/2/3-state.sav` is `false` (15, 16 and 17 of them), so **0 ship-borne
orders**;
* the four `CDT` ids are `Player.00000016.TurnCommands_v5` plus three `AIAgent` blobs, and the
one command block is the 36-node empty one.
Falsification hypotheses 1 and 2 are therefore both refuted by measurement rather than by
argument, and hypothesis 3 is refuted by the corpus oracle below. Hypothesis 4 stands as a
labelled hypothesis; hypothesis 5 was already refuted by lane E2.
### What the pass does on the saves that are not the reference pair
`sots_turn <save> --phases` now prints a `build queue:` line under S11 on every save:
| save | queues | pending orders | demand (points) |
|---|---:|---:|---:|
| `turn1/2/3-state.sav` | 3 | 0 | 0 |
| `human-turn2-orders.sav` | 20 | 0 | 0 |
| `zuul-turn5-species5.sav` | 4 | 4 | 7,120 |
| `zuul-turn16-noderoute.sav` | 4 | 7 | 12,099 |
| `zuul-turn17-orders2.sav` | 4 | 2 | 5,109 |
| `zuul-turn23-fleet23.sav` | 4 | 1 | 370 |
Five of eleven saves exercise the pass the moment points exist. Nothing is committed: an order
that advanced with no ship behind it leaves a state the game never produces.
### The corpus oracle, which the campaign already owned
`zuul-turn16-noderoute.sav` (frame 16) → `zuul-turn17-rollpending.sav` (frame 17) is a real
consecutive-turn pair. Two systems, six completions, one partial advance:
| system | owner | before (`desID`:`conleft`) | after | solved points |
|---|---|---|---|---:|
| 384 | 16 | 608:1753, 576:1860, 576:1860 | 576:**1291** | **4182** |
| 80 | 32 | 114:959, 114:1889 ×3 (+816:6974 appended in-turn) | 816:**3818** | **9782** |
`test_construction.cpp` does not assume those totals — it **searches** 0..200,000 and asserts a
solution exists and is **unique**. A per-order budget, a non-FIFO drain, or skip-instead-of-stop
each make the search come back empty. The completions then reproduce the `ShipRecs` deltas the
same two files carry (class-0 `srb` 2→4 and 53→57; design 608 2→3; a new record for 576;
design 114 18→22; design 816 unchanged because its order did not finish).
## 4. What changed in the engine
* **`game/sim/construction.{h,cpp}`** — `ShipRecords` and the completion bookkeeping. The
per-class `built` counter's indexed increment has **exactly one writer in the whole image**
(an image-wide byte scan for the form at that displacement returns one site, inside this
pass), so a hull on the wire with that counter bumped came through the build queue and
nothing else — the encounter spawner that also creates ships touches no `ShipRecords`.
`RunSystemConstruction` wraps the point pass and keeps each completion's design id.
* **`game/sim/colony.{h,cpp}`** — one corrected rule: with `points <= 0` the original skips the
**removal sweep** as well, because the entry test branches to the epilogue. Carried as a
labelled hypothesis; no corpus save can reach the state that shows it, and the workload that
would is named in the header.
* **`app/construction_phase.{h,cpp}`** — S11's build-queue sub-pass, reported on its own line.
* **`app/phase_catalog.cpp`** — S11's blurb no longer calls the build queue the input boundary
for the census leaf.
## 5. What this lane did NOT do, said plainly
* **It did not close `shpt[0]`, and no build-queue work can.** The blocker moves to `game/ai`.
* **It did not model the ship or the fleet at birth in code.** The chain is read end to end and
written up in the RE repo (`findings/subsystems/ship-construction.md` §4): `StarShip` is 0xb0
bytes, its id comes from the object-id allocator and lands in `+0x04`, `FltID` is born NULL;
the hull joins its system's **cached home fleet** (`ServerSystem+0x238`) and a fleet is created
only when that slot is empty; every fleet born that way gets `FtFlg |= 0x400` — which
**corrects lane B5**, that bit is not a retreat marker. It is not implemented because the
newborn hull's stats are copied from design words the engine does not yet compute, and because
no save exercises the path with an oracle behind it.
* **The money charge has never fired.** No design in the corpus costs money, so the slot-9
refusal path is read from the bytes and never observed.
* **`srl`, `srk`, `sri` carry no model.** Losses and kills are zero in all eleven saves.

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ 232397a, generated 2026-09-08 by tools/gen_addresses.py // Source: sots-re ghidra/addresses.json @ 5cc66f6, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once #pragma once
#include <cstdint> #include <cstdint>
@ -1481,6 +1481,70 @@ constexpr uint32_t StrategyServer_DestroyFleet = 0x0048b980;
constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0; constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0;
// thiscall void** (std::map<int, void*>* this, const int* key) // 125 B, ret 4. MSVC std::map<int,T*>::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<int,void*> at +0x0c/+0x10, and _Color/_Isnil written as ONE 16-bit store at +0x14/+0x15, plus 2 bytes padding. DELEGATED instruction-level read [verified] // thiscall void** (std::map<int, void*>* this, const int* key) // 125 B, ret 4. MSVC std::map<int,T*>::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<int,void*> 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; 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<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 [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<ShipDesignDef::Section> 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] // 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<ShipDesignDef::Section> 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; 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] // 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; constexpr uint32_t g_flt_RebOutModMin = 0x00617868;
// data const float = 2.0f, the upper clamp of RebOutMod [verified] // data const float = 2.0f, the upper clamp of RebOutMod [verified]
constexpr uint32_t g_flt_RebOutModMax = 0x0061786c; constexpr uint32_t g_flt_RebOutModMax = 0x0061786c;
// thiscall void (StrategyServer* S, vector<uint32>* allianceBroken, vector<uint32>* napBroken, vector<uint32>* 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] // 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; 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] // 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]

View file

@ -8,6 +8,7 @@ add_library(sots_app STATIC
phase_catalog.cpp phase_catalog.cpp
trade_raid.cpp trade_raid.cpp
turn_record.cpp turn_record.cpp
construction_phase.cpp
visibility_phase.cpp visibility_phase.cpp
turn.cpp turn.cpp
report.cpp) report.cpp)

View file

@ -0,0 +1,96 @@
#include "app/construction_phase.h"
#include <cstdarg>
#include <cstdio>
#include <map>
#include "game/sim/construction.h"
namespace sots::app {
namespace {
std::string fmt(const char* f, ...) {
char buf[512];
va_list ap;
va_start(ap, f);
std::vsnprintf(buf, sizeof buf, f, ap);
va_end(ap);
return std::string(buf);
}
} // namespace
ConstructionPhaseResult RunBuildQueues(mars::stream::shapes::SaveGame& game) {
ConstructionPhaseResult r;
// The owner of a queue is the system's `PID`, which is the player's OBJECT id, not its
// position in the player vector. The two are different numbers and confusing them is a
// trap this codebase has already paid for once.
std::map<std::int32_t, std::size_t> playerIndex;
for (std::size_t i = 0; i < game.sim.players.size(); ++i)
playerIndex[game.sim.players[i].playerID] = i;
int shipBorne = 0;
int ordersByOwner = 0;
int unownedQueues = 0;
for (auto& e : game.sim.systems) {
if (!e.sys.bq.has_value()) continue;
++r.queuesVisited;
auto& q = *e.sys.bq;
r.ordersPending += static_cast<int>(q.orders.size());
for (const auto& o : q.orders) r.pointsDemanded += o.conleft;
if (!q.orders.empty()) {
if (playerIndex.count(e.sys.pid))
++ordersByOwner;
else
++unownedQueues;
}
}
// The ship-borne queues. Every one of them is gated behind the ship's `hbq` flag, and
// the count is reported because an order hiding in one would falsify the "no orders on
// the reference pair" reading, which is the load-bearing claim of this phase.
for (const auto& fe : game.sim.fleets)
for (const auto& se : fe.flt.ships)
if (se.ship.hbq) shipBorne += static_cast<int>(se.ship.bq2.orders.size());
r.blockedOnPoints = r.ordersPending > 0;
if (r.queuesVisited == 0) {
r.notes.push_back("no system carries a build queue: a queue is written only for a "
"system with an owner");
return r;
}
r.notes.push_back(fmt("%d system build queue(s), %d pending order(s) demanding %d "
"construction point(s); %d order(s) in ship-borne queues",
r.queuesVisited, r.ordersPending, r.pointsDemanded, shipBorne));
if (r.ordersPending == 0 && shipBorne == 0) {
r.notes.push_back("NOTHING TO BUILD. The pass is faithful and idle: with no order "
"anywhere in the game it cannot create a ship, so it closes no "
"leaf here. On the reference pairs the archived ship census is "
"still one destroyer higher than ours, and the order behind that "
"destroyer is created inside the turn by the AI -- that leaf is "
"blocked on AI order generation, not on this phase");
return r;
}
// Points would come from the system's output vector; that term is the roadmap's item 1
// and is not this lane's. The pass is therefore driven with nothing and reports the
// demand, so the shape of the gap is visible in the run log.
r.notes.push_back(fmt("BLOCKED: construction points come from the per-system output "
"term (out[7] scaled by the shipyard bonus, then out[8] = min(out"
"[7], demand)), which is unmodelled. %d order(s) would be offered "
"points this turn", r.ordersPending));
if (unownedQueues)
r.notes.push_back(fmt("%d queue(s) hold orders but their system's owner id resolves "
"to no player in the save -- reported, not skipped silently",
unownedQueues));
(void)ordersByOwner;
return r;
}
} // namespace sots::app

View file

@ -0,0 +1,58 @@
// S11's build-queue sub-pass, wired to the save shapes.
//
// WHERE IT SITS
// -------------
// `StrategyServer::ProcessTurn` phase 11 walks the systems; each system's own turn runs the
// build queue between the plague pass and the population growth. The engine's S11 already
// runs the parts of the colony turn that need neither the tuning table nor a carrying
// capacity; this is the build-queue part, kept in its own file and reported as its own line
// so its contribution is never folded into S11's other writes.
//
// WHAT IT IS BLOCKED ON, and what it is NOT blocked on
// ----------------------------------------------------
// Two different things, and the campaign had them confused:
//
// * the POINTS. `BuildQueue::ProcessTurn` takes construction points by value. They come
// from the system's output vector -- `out[7]` scaled by the shipyard station bonus, then
// `out[8] = min(out[7], queue demand)` -- which is the per-system output term (roadmap
// item 1). Until that lands this phase has no points to spend and it says so with the
// demand named, rather than inventing a number.
//
// * the ORDERS. On BOTH reference pairs there are none. `turn1-state.sav` and
// `turn2-state.sav` each carry three build queues, one per owned system, and all three
// are empty; every `hbq` is false, so no ship-borne queue exists either; and the only
// `TurnCommands_v5` block is the human's, which is the byte-identical empty one. The
// destroyer the archived turn record counts is built from an order the AI creates
// *during* the turn. So the census leaf `shpt[0]` is blocked on AI order generation, not
// on this phase, and this phase closes nothing on either reference pair by design.
//
// Five of the eleven corpus saves DO carry orders (4, 7, 2, 2 and 1 of them), so the pass is
// exercised the moment the points arrive; the run log reports what it would do on each.
#pragma once
#include <string>
#include <vector>
#include "mars/stream/shapes.h"
namespace sots::app {
struct ConstructionPhaseResult {
int queuesVisited = 0; // systems that carry a build queue at all
int ordersPending = 0; // orders sitting in those queues
int pointsDemanded = 0; // sum of `conleft` over every pending order
int shipsBuilt = 0; // completions this run actually performed
int leafWrites = 0; // save leaves changed (0 while the points are blocked)
int wouldWrite = 0; // leaves a points-fed pass would change
bool blockedOnPoints = false;
std::vector<std::string> notes;
};
// Run the build-queue sub-pass over every system that owns a queue.
//
// `points` is not available from the save, so the pass is driven with zero points and
// reports the demand. Nothing is committed: an order that advanced with no ship behind it
// would leave the save in a state the game never produces, which is worse than not running.
ConstructionPhaseResult RunBuildQueues(mars::stream::shapes::SaveGame& game);
} // namespace sots::app

View file

@ -79,8 +79,12 @@ constexpr PhaseDesc kStrategic[] = {
"upkeep of population carried aboard colony/slaver hulls in transit"}, "upkeep of population carried aboard colony/slaver hulls in transit"},
{Driver::Strategic, 11, "S11", "SystemTurn", PhaseStatus::Partial, {Driver::Strategic, 11, "S11", "SystemTurn", PhaseStatus::Partial,
"runs game::sim ProcessColonyTurn per system and commits the parts that need neither the " "runs game::sim ProcessColonyTurn per system and commits the parts that need neither the "
"tuning table nor a carrying capacity; plague, growth, resources, slaves, rebellion and " "tuning table nor a carrying capacity; plague, growth, resources, slaves and rebellion "
"the build queue are the sub-passes the model already declares as its input boundary"}, "are the sub-passes the model still declares as its input boundary. The BUILD QUEUE is "
"modelled and runs (it is the only writer of the per-class built counter in the whole "
"image) but has no points to spend: they come from the per-system output term. It is "
"NOT what the missing destroyer waits on -- no build order exists anywhere in either "
"reference save, so that order is created inside the turn by the AI"},
{Driver::Strategic, 12, "S12", "TradeSliderFinalisation", PhaseStatus::Stub, {Driver::Strategic, 12, "S12", "TradeSliderFinalisation", PhaseStatus::Stub,
"re-normalises the per-system output rates"}, "re-normalises the per-system output rates"},
{Driver::Strategic, 13, "S13", "PlayerTurn", PhaseStatus::Partial, {Driver::Strategic, 13, "S13", "PlayerTurn", PhaseStatus::Partial,
@ -128,26 +132,36 @@ constexpr PhaseDesc kStrategic[] = {
// ServerPlayer::ProcessTurn -- 12 phases, 1..12 // ServerPlayer::ProcessTurn -- 12 phases, 1..12
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
constexpr PhaseDesc kPlayer[] = { constexpr PhaseDesc kPlayer[] = {
{Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Blocked, {Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Partial,
"the formula is verified (0 divergences over 4,284 live calls) but one input is not " "the formula is verified (0 divergences over 4,284 live calls) and the per-system money "
"modelled: the money output of each owned system. NOT the same function T31 sums -- the " "input is now modelled on the TURN path -- ComputeOutput with the system's own rate "
"turn path takes ComputeOutput with the system's OWN rate sliders, so its money channel " "sliders, so the build queue, the ship-repair pass and the infrastructure -> terraform "
"carries the repair pass (which is not side-effect free) and the unspent-industry and " "-> money cascade are all live, none of which is the max-income form T31 sums. What is "
"unspent-terraforming cascades, none of which are zero once the other channels are " "still missing is upstream, not here: S11's civilian growth is not committed, so a "
"funded. Only ComputeBudget's PROJECTED mode uses the max-income form that is now " "colony that grew this turn is priced from its pre-growth population, and the repair "
"modelled. Evaluated and reported, not committed"}, "demand of damaged ships in orbit is taken as 0. The phase self-checks every run by "
{Driver::Player, 2, "P02", "ApplyNetToSavings", PhaseStatus::Blocked, "running the same colonies through the projected path, which the save's own BnkEl "
"saturating add of the budget net into savings; blocked behind P01's missing input"}, "states"},
{Driver::Player, 2, "P02", "ApplyNetToSavings", PhaseStatus::Partial,
"saturating add of the budget net into savings, committed. Exact for a player whose "
"colonies did not grow and whose own orders the turn does not change (the independent "
"colony, on both reference pairs); short by the growth for the human, and wrong for an "
"AI whose research rate and target are set by its own orders during the turn (Rung B)"},
{Driver::Player, 3, "P03", "RecordBudgetDerivedFields", PhaseStatus::Blocked, {Driver::Player, 3, "P03", "RecordBudgetDerivedFields", PhaseStatus::Blocked,
"trade income, savings-given-away and research-points-given-away land on the turn record " "trade income, savings-given-away and research-points-given-away land on the turn record "
"and on two player words that are not identified on the wire"}, "and on two player words that are not identified on the wire"},
{Driver::Player, 4, "P04", "ProcessSpecialProjectsSpend", PhaseStatus::Stub, {Driver::Player, 4, "P04", "ProcessSpecialProjectsSpend", PhaseStatus::Stub,
"special-project spend; the project bodies are opaque on the wire"}, "special-project spend; the project bodies are opaque on the wire"},
{Driver::Player, 5, "P05", "ProcessResearch", PhaseStatus::Blocked, {Driver::Player, 5, "P05", "ProcessResearch", PhaseStatus::Blocked,
"the research slice is verified end to end (35 live calls, 0 divergences) but its " "the research slice is verified end to end (35 live calls, 0 divergences) and P01 now "
"allocation comes from P01's budget, so it cannot be driven yet"}, "supplies the allocation, but the blocker has MOVED rather than cleared: the only "
"corpus player that reaches this phase with a research target is the AI, and its "
"research rate and target are set by its own orders during the same turn, so the "
"allocation fed in would be wrong. Evaluated and reported, not committed, until AI "
"order generation exists"},
{Driver::Player, 6, "P06", "ResearchRefund", PhaseStatus::Blocked, {Driver::Player, 6, "P06", "ResearchRefund", PhaseStatus::Blocked,
"unspent research points converted back to money at the turn's own rate; needs P01 and P05"}, "unspent research points converted back to money at the turn's own rate; needs P05, "
"which is now blocked on the AI's orders rather than on the budget"},
{Driver::Player, 7, "P07", "ClearTimedResearchAccumulators", PhaseStatus::Partial, {Driver::Player, 7, "P07", "ClearTimedResearchAccumulators", PhaseStatus::Partial,
"zeroes the three timed-research accumulators that are on the wire; two further words the " "zeroes the three timed-research accumulators that are on the wire; two further words the "
"phase also zeroes are not identified"}, "phase also zeroes are not identified"},

View file

@ -8,6 +8,7 @@
#include <string> #include <string>
#include "app/alliance.h" #include "app/alliance.h"
#include "app/construction_phase.h"
#include "app/trade_raid.h" #include "app/trade_raid.h"
#include "app/turn_record.h" #include "app/turn_record.h"
#include "app/visibility_phase.h" #include "app/visibility_phase.h"
@ -96,16 +97,37 @@ struct PlayerPhaseTotals {
int fired[13] = {}; // how many players the phase actually did something for int fired[13] = {}; // how many players the phase actually did something for
}; };
// What the caller has to hand the player driver for P01: the per-system money of every
// owned, non-abandoned system, on the TURN path, and whether the driver may trust it.
struct PlayerBudgetFeed {
std::vector<int> systemIncome;
bool isAI = false;
sim::DifficultyMods difficulty;
bool held = true; // false when a system the player owns could not be found
// The self-check: the same systems run through the PROJECTED path, which is the number
// the save's own `BnkEl` states and lane E1 scored 25/25 against. The two paths are not
// the same function and need not agree -- but on a corpus where every colony is at its
// ideal suitability with full infrastructure and an empty build queue, the whole
// construction channel returns to trade and they should agree to within the trade-point
// rounding. A large delta here is the model failing, and it is visible without a VM.
int projectedIncome = 0;
int turnIncome = 0;
};
void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng, void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng,
PlayerPhaseTotals& t) { PlayerPhaseTotals& t, const PlayerBudgetFeed& feed) {
// --- P01 ComputeBudget -- blocked on the per-system money output ----------------- // --- P01 ComputeBudget ------------------------------------------------------------
// The formula is here and is verified; what is missing is `systemIncome`. We build the // The per-system money is `ComputeOutput(s).out[3]`, NOT `ComputeMaxIncome(s)`: the
// inputs we do hold so the shape of the gap is visible, then stop. // turn path runs the system's own sliders, so the build queue, the ship-repair pass and
// the infrastructure -> terraform -> money cascade are all live. See
// sots-re findings/subsystems/output-turn-path.md.
{ {
sim::BudgetInputs in; sim::BudgetInputs in;
in.savings = p.sav; in.savings = p.sav;
in.ownsSystems = !p.owners.empty(); in.ownsSystems = !p.owners.empty();
in.maintenance = p.maint; in.maintenance = p.maint;
in.maintenanceDivisor = feed.difficulty.maintenanceDivisor;
in.researchDifficultyMult = feed.difficulty.researchMult;
in.isAI = p.npc; in.isAI = p.npc;
in.researchRate = p.resRate; in.researchRate = p.resRate;
in.resMod = p.resMod; in.resMod = p.resMod;
@ -122,14 +144,19 @@ void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng,
s.fraction = e.xper; s.fraction = e.xper;
in.expenses.push_back(s); in.expenses.push_back(s);
} }
// in.systemIncome stays empty: unmodelled input. in.systemIncome = feed.systemIncome;
const sim::Budget b = sim::ComputeBudget(in, /*projected=*/false); const sim::Budget b = sim::ComputeBudget(in, /*projected=*/false);
const int wouldBe = sim::SaturatingAdd(p.sav, b.net); const int wouldBe = sim::SaturatingAdd(p.sav, b.net);
++t.fired[1]; ++t.fired[1];
if (wouldBe != p.sav) ++t.wouldWrite[2]; if (wouldBe != p.sav) ++t.wouldWrite[2];
if (opt.CommitBlocked("P02")) { // P02 is the write. It is committed by default now that the money channel is
p.sav = wouldBe; // modelled; a player whose owned systems could not all be resolved is still
++t.writes[2]; // evaluate-and-report.
if (feed.held || opt.CommitBlocked("P02")) {
if (wouldBe != p.sav) {
p.sav = wouldBe;
++t.writes[2];
}
} }
} }
@ -319,12 +346,23 @@ bool AddictedTo(const std::vector<mars::stream::shapes::AdctEntry>& a, int speci
return false; return false;
} }
// `max(ComputeMaxIncome(s), 0)` for one owned system. // The two terms both money paths share: the output total and everything in the money chain
int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI, // except the trade points. `ComputeMaxIncome` and the turn's `ComputeOutput` differ only in
const MaxIncomeInputs& ctx) { // how they arrive at those trade points.
struct SystemIncomeTerms {
double totalOutputRaw = 0;
sim::SystemMoneyInputs money;
int popSpecies = 0;
double idealSuitability = 0; // the SERVER's per-species baseline (the money cost's ideal)
};
SystemIncomeTerms SystemIncomeTermsFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const MaxIncomeInputs& ctx, double overHarvestRate) {
SystemIncomeTerms out;
// The system's population is credited to the independent race's species when the colony // The system's population is credited to the independent race's species when the colony
// has one, otherwise to the owner's. `hindi` is the gate; `indi` is written either way. // has one, otherwise to the owner's. `hindi` is the gate; `indi` is written either way.
const int popSpecies = s.hindi ? s.indi.indsp : owner.species; const int popSpecies = s.hindi ? s.indi.indsp : owner.species;
out.popSpecies = popSpecies;
const auto species = static_cast<sim::Species>(owner.species); const auto species = static_cast<sim::Species>(owner.species);
const std::int64_t resAvail = const std::int64_t resAvail =
@ -332,7 +370,6 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const std::int64_t imperial = static_cast<std::int64_t>(s.pop) + s.pbon; const std::int64_t imperial = static_cast<std::int64_t>(s.pop) + s.pbon;
// --- the output total (lane N's term) --- // --- the output total (lane N's term) ---
double total = 0.0;
if (s.rbfl == 0) { if (s.rbfl == 0) {
sim::BaseOutputInputs b; sim::BaseOutputInputs b;
b.imperialPopulation = imperial; b.imperialPopulation = imperial;
@ -343,7 +380,7 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
b.resourcesAvailable = resAvail; b.resourcesAvailable = resAvail;
b.infra = s.infra; b.infra = s.infra;
b.infraBonus = s.ibon; b.infraBonus = s.ibon;
b.overHarvestRate = 0.0; // the max-income rate vector puts nothing on over-harvest b.overHarvestRate = overHarvestRate;
b.speciesBaseDemand = sim::ConstantsOf(species).resourceDemand; b.speciesBaseDemand = sim::ConstantsOf(species).resourceDemand;
b.speciesResourceOutput = sim::ConstantsOf(species).resourceOutput; b.speciesResourceOutput = sim::ConstantsOf(species).resourceOutput;
sim::OutputModifiers m; sim::OutputModifiers m;
@ -353,10 +390,10 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
m.rebOutMod = owner.rebOutMod; m.rebOutMod = owner.rebOutMod;
m.scOutMod = owner.scOutMod; m.scOutMod = owner.scOutMod;
m.techOutMod = 1.0; // ServerPlayer+0x224, not on the wire m.techOutMod = 1.0; // ServerPlayer+0x224, not on the wire
total = sim::TotalSystemOutputRaw(m, ctx.tuning); out.totalOutputRaw = sim::TotalSystemOutputRaw(m, ctx.tuning);
} }
// --- the money chain (this lane's term) --- // --- the money chain (lane E1's term) ---
sim::PopIncomeRow impRows[sim::kSpeciesCount] = {}; sim::PopIncomeRow impRows[sim::kSpeciesCount] = {};
sim::PopIncomeRow civRows[sim::kSpeciesCount] = {}; sim::PopIncomeRow civRows[sim::kSpeciesCount] = {};
sim::PopIncomeRow slvRows[sim::kSpeciesCount] = {}; sim::PopIncomeRow slvRows[sim::kSpeciesCount] = {};
@ -371,7 +408,7 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
impRows[q].addicted = civRows[q].addicted = slvRows[q].addicted = add; impRows[q].addicted = civRows[q].addicted = slvRows[q].addicted = add;
} }
sim::SystemMoneyInputs mi; sim::SystemMoneyInputs& mi = out.money;
mi.popIncomeImperial = mi.popIncomeImperial =
sim::PopulationIncome(sim::PopGroup::Imperial, impRows, true, s.hindi, ctx.tuning); sim::PopulationIncome(sim::PopGroup::Imperial, impRows, true, s.hindi, ctx.tuning);
mi.popIncomeCivilian = mi.popIncomeCivilian =
@ -384,12 +421,113 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
mi.serverIncomeMod = ctx.serverIncomeMod; mi.serverIncomeMod = ctx.serverIncomeMod;
mi.difficultyIncomeMult = mi.difficultyIncomeMult =
sim::DifficultyModsFor(owner.aidf, ownerIsAI, owner.npc).incomeMult; sim::DifficultyModsFor(owner.aidf, ownerIsAI, owner.npc).incomeMult;
const double ideal = popSpecies >= 0 && popSpecies < static_cast<int>(ctx.idealSuit.size()) out.idealSuitability = popSpecies >= 0 && popSpecies < static_cast<int>(ctx.idealSuit.size())
? ctx.idealSuit[static_cast<std::size_t>(popSpecies)] ? ctx.idealSuit[static_cast<std::size_t>(popSpecies)]
: owner.idealSuit; : owner.idealSuit;
mi.suitCostMod = mi.suitCostMod = sim::SuitabilityCostMod(s.suit, out.idealSuitability, owner.suitTol,
sim::SuitabilityCostMod(s.suit, ideal, owner.suitTol, owner.rebAI, true, s.vnh); owner.rebAI, true, s.vnh);
return sim::SystemMaxIncome(total, mi); return out;
}
// `ComputeOutput(s).out[3]` -- the money a system contributes to the TURN's budget, as
// opposed to the projected maximum T31 sums. Not clamped: `ComputeBudget` splits a negative
// system into its expense column itself.
//
// One input of the nine this needs is not on the wire and is taken as zero here: the repair
// demand of the owner's damaged ships in orbit, which would need `Ship::RepairCost` over the
// fleets at the system. Every point it would consume is a point that does NOT come back to
// the money channel, so a colony with a damaged fleet reads HIGH.
int SystemTurnMoneyFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const MaxIncomeInputs& ctx) {
const SystemIncomeTerms terms =
SystemIncomeTermsFromWire(s, owner, ownerIsAI, ctx, s.rts.sroh);
sim::IdealSuitabilityInputs isi;
isi.owned = true;
isi.systemSuitability = s.suit;
isi.ownerIdealSuitability = owner.idealSuit;
isi.independent = s.hindi;
isi.serverIdealSuitability = terms.idealSuitability;
isi.systemOverride = s.dsu;
const double ideal = sim::IdealSuitability(isi);
sim::SystemOutputInputs in;
in.rates.trade = s.rts.srt;
in.rates.construction = s.rts.srsc;
in.rates.terraform = s.rts.srtf;
in.rates.infra = s.rts.sri;
// The normaliser's two suppressions, with the predicates the original uses: an EXACT
// equality for suitability and `float32(Infra + ibon) >= 1` for infrastructure.
in.suitAtIdeal = static_cast<double>(s.suit) == ideal;
in.infraFull = sim::F32(static_cast<double>(s.ibon) + s.infra) >= 1.0;
// The leftover split's own infrastructure test reads the RAW Infra against 1.
in.infraExactlyOne = static_cast<double>(s.infra) == 1.0;
in.infra = s.infra;
in.totalOutputRaw = terms.totalOutputRaw;
in.shipyardStations = 0; // StationCount(sys, owner, 1): no corpus system has a station
in.buildQueueDemand = 0;
if (s.bq)
for (const auto& o : s.bq->orders) in.buildQueueDemand += o.conleft;
in.repairDemand = 0; // see the note above
in.terraformPointsNeeded = sim::TerraformPointsNeeded(s.suit, ideal, owner.terraMod);
in.terraformDown = ideal < static_cast<double>(s.suit);
in.terraformMod = owner.terraMod;
in.money = terms.money;
return sim::ComputeSystemOutput(in, ctx.tuning).money;
}
// `max(ComputeMaxIncome(s), 0)` for one owned system.
int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const MaxIncomeInputs& ctx) {
// The max-income rate vector puts nothing on over-harvest, and the two cascade channels
// are provably zero under it, so the trade points are simply the rounded output total.
const SystemIncomeTerms terms = SystemIncomeTermsFromWire(s, owner, ownerIsAI, ctx, 0.0);
return sim::SystemMaxIncome(terms.totalOutputRaw, terms.money);
}
// One `PlayerBudgetFeed` per player, in save order. Built once, from the colony state as it
// stands when the player driver runs -- which is AFTER the per-system turn (the strategic
// driver runs the system turn at phase 11 and the player driver at phase 13), so anything
// `S11` fails to commit is missing from these numbers as well.
std::vector<PlayerBudgetFeed> BuildBudgetFeeds(const SaveGame& game, const TurnOptions& opt) {
MaxIncomeInputs ctx;
ctx.serverIncomeMod = game.sim.incMod;
for (const auto& sp : game.sim.species) ctx.idealSuit.push_back(sp.issu);
std::vector<const Sys*> byId;
std::vector<std::int32_t> ids;
for (const auto& e : game.sim.systems) {
ids.push_back(e.sysID);
byId.push_back(&e.sys);
}
const auto find = [&](std::int32_t id) -> const Sys* {
for (std::size_t i = 0; i < ids.size(); ++i)
if (ids[i] == id) return byId[i];
return nullptr;
};
std::vector<PlayerBudgetFeed> feeds;
feeds.reserve(game.sim.players.size());
for (const auto& pe : game.sim.players) {
const Player& p = pe.player;
PlayerBudgetFeed f;
f.isAI = opt.IsAIPlayer(p.plyrIdx);
f.difficulty = sim::DifficultyModsFor(p.aidf, f.isAI, p.npc);
for (std::int32_t id : p.owners) {
const Sys* s = find(id);
if (!s) {
f.held = false; // a dangling owner id: the sum is incomplete, say so
continue;
}
if (s->abdn) continue; // an abandoned colony is skipped, not counted as zero
const int money = SystemTurnMoneyFromWire(*s, p, f.isAI, ctx);
if (money != 0) f.systemIncome.push_back(money);
f.turnIncome += money;
f.projectedIncome += SystemMaxIncomeFromWire(*s, p, f.isAI, ctx);
}
feeds.push_back(std::move(f));
}
return feeds;
} }
void RunUpdateBankruptcyLimits(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec) { void RunUpdateBankruptcyLimits(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec) {
@ -785,11 +923,43 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
rec.notes.push_back(fmt("%d system(s) left their countdown words alone: the " rec.notes.push_back(fmt("%d system(s) left their countdown words alone: the "
"companion active-player mask is not identified", "companion active-player mask is not identified",
st.skippedCountdown)); st.skippedCountdown));
// The build-queue sub-pass reports on its own line rather than folding its
// numbers into S11's, because what blocks it is not what blocks the rest of
// the colony turn. See app/construction_phase.h.
{
const ConstructionPhaseResult b = RunBuildQueues(game);
rec.wouldWrite += b.wouldWrite;
for (const auto& n : b.notes) rec.notes.push_back("build queue: " + n);
}
break; break;
} }
case 13: { // S13 PlayerTurn -- the nested driver case 13: { // S13 PlayerTurn -- the nested driver
for (auto& e : game.sim.players) const std::vector<PlayerBudgetFeed> feeds = BuildBudgetFeeds(game, opt);
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt); std::size_t fi = 0;
int fed = 0, agree = 0, worst = 0;
for (auto& e : game.sim.players) {
const PlayerBudgetFeed& f = feeds[fi++];
if (!f.systemIncome.empty()) ++fed;
else {
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt, f);
continue;
}
const int d = f.turnIncome - f.projectedIncome;
if (d == 0) ++agree;
if (d > worst || -d > worst) worst = d < 0 ? -d : d;
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt, f);
}
rec.notes.push_back(fmt(
"%d player(s) had a non-empty per-system money roll-up on the TURN path "
"(ComputeOutput, not ComputeMaxIncome); the ship-repair demand of damaged "
"ships in orbit is taken as 0 and S11's civilian growth is not committed, "
"so a colony that grew this turn is priced from its pre-growth population",
fed));
rec.notes.push_back(fmt(
"turn path vs projected path on the same colony state: %d of %d landed "
"players agree exactly, worst |delta| %d money (the projected sum is what "
"the save's own BnkEl states, so this is a check without a VM)",
agree, fed, worst));
rec.invocations = static_cast<int>(game.sim.players.size()); rec.invocations = static_cast<int>(game.sim.players.size());
for (int k = 1; k <= 12; ++k) { for (int k = 1; k <= 12; ++k) {
rec.leafWrites += pt.writes[k]; rec.leafWrites += pt.writes[k];

View file

@ -4,6 +4,7 @@
# sources with plain g++ in the meantime. # sources with plain g++ in the meantime.
add_library(sots_game_sim STATIC add_library(sots_game_sim STATIC
economy.cpp economy.cpp
construction.cpp
research.cpp research.cpp
colony.cpp colony.cpp
movement.cpp movement.cpp
@ -18,7 +19,7 @@ endif()
option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF) option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF)
if(SOTS_GAME_SIM_TESTS) if(SOTS_GAME_SIM_TESTS)
enable_testing() enable_testing()
set(_sim_tests economy research colony movement techgraph visibility) set(_sim_tests economy research colony movement techgraph visibility construction)
foreach(_t IN LISTS _sim_tests) foreach(_t IN LISTS _sim_tests)
add_executable(game_sim_test_${_t} ${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_sim/test_${_t}.cpp) add_executable(game_sim_test_${_t} ${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_sim/test_${_t}.cpp)
target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim) target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim)

View file

@ -389,7 +389,12 @@ OutputSplit SplitOutput(double total, const OutputRates& rates) {
int ConstructionPoints(double constructionShare, int stations, const TuningTable& t) { int ConstructionPoints(double constructionShare, int stations, const TuningTable& t) {
// Truncating, not rounding -- this slot goes through the float-to-int helper. // Truncating, not rounding -- this slot goes through the float-to-int helper.
return Ftol(constructionShare * (1.0 + t.STATION_BONUS_SHIPCON * stations)); // C3 correction, from the instruction stream of 0x00746830: the bonus is ignored unless
// it is STRICTLY positive (the same unloaded-table guard the output term carries), and
// the association is `k x (b x cons) + cons`, not `cons x (1 + b x k)`. Both differences
// are invisible while no system has a shipyard station, which is the whole corpus.
const double b = t.STATION_BONUS_SHIPCON > 0.0 ? t.STATION_BONUS_SHIPCON : 0.0;
return Ftol(static_cast<double>(stations) * (b * constructionShare) + constructionShare);
} }
OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAtIdeal, OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAtIdeal,
@ -406,7 +411,9 @@ OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAt
wf = rates.terraform; wf = rates.terraform;
wi = rates.infra; wi = rates.infra;
} }
const double sum = wt + wf + wi; // C3 correction: the original accumulates `wi + (wf + wt)` on the x87 stack, in that
// association. Reordering it is not free in floating point.
const double sum = wi + (wf + wt);
OutputSplit s; OutputSplit s;
if (sum <= 0 || leftover <= 0) { if (sum <= 0 || leftover <= 0) {
s.trade = std::max(0.0, leftover); s.trade = std::max(0.0, leftover);
@ -488,6 +495,87 @@ int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in) {
return money > 0 ? money : 0; return money > 0 ? money : 0;
} }
double IdealSuitability(const IdealSuitabilityInputs& in) {
if (!in.owned) return in.systemSuitability;
double v = in.ownerIdealSuitability;
if (in.independent) v = in.serverIdealSuitability;
// An `!=` against the sentinel, so a NaN override would also win. Nothing in the corpus
// exercises either side of that.
if (in.systemOverride != kIdealSuitabilityNoOverride) v = in.systemOverride;
return v;
}
RepairPassResult RepairShipsInOrbit(int points, int repairDemand) {
RepairPassResult r;
if (points <= 0 || repairDemand <= 0) {
r.left = points;
return r;
}
r.spent = points < repairDemand ? points : repairDemand;
r.left = points - r.spent;
return r;
}
SystemOutput ComputeSystemOutput(const SystemOutputInputs& in, const TuningTable& t) {
SystemOutput o;
const OutputRates r = NormaliseOutputRates(in.rates, in.suitAtIdeal, in.infraFull);
o.normalisedRates = r;
// One rounding of the total, then one rounding per channel off that same value.
const double total = RoundHalfEven(in.totalOutputRaw);
o.totalOutput = Ftol(total);
const OutputSplit split = SplitOutput(total, r);
o.tradePoints = split.trade;
// --- construction: the queue first, then the repair pass -----------------------------
o.construction = ConstructionPoints(split.construction, in.shipyardStations, t);
o.constructionToQueue =
in.buildQueueDemand < o.construction ? in.buildQueueDemand : o.construction;
int rem = o.construction - o.constructionToQueue;
if (rem < 0) rem = 0;
if (rem > 0) {
const RepairPassResult rep = RepairShipsInOrbit(rem, in.repairDemand);
o.constructionToRepair = rep.spent;
rem = rep.left;
}
// --- the leftover redistribution -----------------------------------------------------
// `SplitLeftover`'s `infraFull` argument is the RAW `Infra == 1` test, not the
// `Infra + ibon >= 1` one the normaliser used.
const OutputSplit left =
rem > 0 ? SplitLeftover(static_cast<double>(rem), r, in.suitAtIdeal, in.infraExactlyOne)
: OutputSplit{};
o.leftoverToTrade = left.trade;
// --- infrastructure ------------------------------------------------------------------
const double infraNeed = std::ceil((1.0 - in.infra) / 3.3e-5);
const double poolInfra = left.infra + split.infra;
const double spendInfra = poolInfra < infraNeed ? poolInfra : infraNeed;
double leftInfra = poolInfra - spendInfra;
if (!(leftInfra > 0.0)) leftInfra = 0.0;
// Three separate 80-bit steps, not one x3.3e-5.
const double infraGain = spendInfra / 500.0 * 0.01 * 1.65;
o.infraDelta = F32(infraGain > 0.0 ? infraGain : 0.0);
// --- terraforming: the infrastructure leftover lands in THIS pool ---------------------
const double terraNeed = std::ceil(in.terraformPointsNeeded);
const double poolTerra = (left.terraform + split.terraform) + leftInfra;
const double spendTerra = poolTerra < terraNeed ? poolTerra : terraNeed;
double leftTerra = poolTerra - spendTerra;
if (!(leftTerra > 0.0)) leftTerra = 0.0;
o.leftoverToMoney = leftTerra;
// The same helper the colony pass uses; its sign test is `suit > ideal`, which is the
// original's `IdealSuitability() < Suit` with the operands swapped.
o.suitabilityDelta = TerraformDelta(spendTerra, in.terraformMod,
in.terraformDown ? 1.0 : 0.0, 0.0);
// --- money ----------------------------------------------------------------------------
SystemMoneyInputs m = in.money;
m.tradePoints = (o.leftoverToTrade + o.tradePoints) + leftTerra;
o.money = SystemMoneyIncome(m);
return o;
}
BonusApplyResult ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity, BonusApplyResult ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity,
std::int64_t& pendingBonus, bool owned, bool homeSystem) { std::int64_t& pendingBonus, bool owned, bool homeSystem) {
BonusApplyResult r; BonusApplyResult r;
@ -541,7 +629,14 @@ void AccrueSystemBonus(const SystemBonusInputs& in, std::int64_t& popBonus, doub
BuildQueueResult ProcessBuildQueue(std::vector<BuildOrder>& queue, int points) { BuildQueueResult ProcessBuildQueue(std::vector<BuildOrder>& queue, int points) {
BuildQueueResult r; BuildQueueResult r;
if (points > 0) { if (points <= 0) {
// The whole body is skipped, REMOVAL SWEEP INCLUDED: the entry test branches
// straight to the epilogue, which returns the points untouched. Corrected by lane
// B6 from the instruction stream; see the header for why no save can show it.
r.pointsLeft = points;
return r;
}
{
for (BuildOrder& o : queue) { for (BuildOrder& o : queue) {
if (o.constructionLeft > points) { if (o.constructionLeft > points) {
o.constructionLeft -= points; o.constructionLeft -= points;

View file

@ -520,6 +520,126 @@ int SystemMoneyIncome(const SystemMoneyInputs& in);
// for zero science points, which is INFERRED rather than read. // for zero science points, which is INFERRED rather than read.
int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in); int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in);
// ---------------------------------------------------------------------------------------
// The turn path: `ServerSystem::ComputeOutput`
// ---------------------------------------------------------------------------------------
//
// `ComputeBudget` has two modes and they take a system's money from DIFFERENT functions.
// Projected mode calls `ComputeMaxIncome` (SystemMaxIncome above). The turn calls
// `ComputeOutput`, which runs `ComputeOutputFromRates` with the system's OWN stored sliders,
// so the construction, terraform and infrastructure channels are funded and three edges that
// are provably dead under the max-income vector are live:
//
// * the build queue and the ship-repair pass consume construction points;
// * whatever they leave over is redistributed across trade / terraform / infrastructure
// and the TRADE share is added to the money channel;
// * unspent infrastructure points cascade into the terraform pool, and unspent terraform
// points cascade into the money channel -- two hops, not one.
//
// See sots-re findings/subsystems/output-turn-path.md. Note that the field the earlier
// income-term note called "science" is the SHIP CONSTRUCTION slider; there is no science
// channel in this function (research is bought with money at the empire level).
// `ServerSystem::IdealSuitability` (0x00745d60) -- the suitability the terraform channel
// aims at, and the `==` the rate normaliser tests against.
// unowned -> the system's own suitability (so it is "at its ideal")
// independent colony -> the SERVER's per-species baseline for `indi->indsp`
// otherwise -> the OWNER's own `IdealSuit` field
// and a system-level `dsu` override wins over all three when it is not the sentinel.
// CONFIDENCE: high on the branch order. The sentinel is FLT_MAX: that is INFERRED from the
// corpus (every system carries exactly FLT_MAX there) rather than read out of the data files.
constexpr double kIdealSuitabilityNoOverride = 3.4028234663852886e+38; // FLT_MAX
struct IdealSuitabilityInputs {
bool owned = true;
double systemSuitability = 0; // sys.Suit
double ownerIdealSuitability = 0; // owner's IdealSuit field
bool independent = false; // sys.hindi
double serverIdealSuitability = 0; // server->IdealSuit[indi.indsp], independent only
double systemOverride = kIdealSuitabilityNoOverride; // sys.dsu
};
double IdealSuitability(const IdealSuitabilityInputs& in);
// C3 note on `TerraformPointsNeeded` above (0x00746890): the original does NOT round -- the
// `ceil` belongs to `ComputeOutputFromRates`, which applies it to the returned double. Our
// version folds the `ceil` in, which is harmless because `ceil` is idempotent and every
// caller applies it, but the boundary is worth stating. The sign multiply inside the divisor
// is cancelled by a `fabs`, so the result is always >= 0, and a zero `TerraMod` yields +inf,
// which makes the terraform channel absorb its whole pool with nothing cascading to money.
// That branch is UNEXERCISED -- no corpus player carries TerraMod 0.
// `ServerSystem::RepairShipsInOrbit` (0x00751590) -- **the side effect** that makes
// `ComputeOutputFromRates` unsafe to call for its value. It hands each damaged ship of the
// owner's fleets at the system a share of the construction points left over after the build
// queue, round-robin, and returns what is left.
//
// The round robin is EQUIVALENT to `points - min(points, demand)` and this is a proof rather
// than an observation: the per-pass share is `max(points / shipCount, 1)`, so every ship with
// a positive remaining cost takes at least one point per pass, and the loop's only early exit
// requires every remaining cost to be zero. So it ends either with the points exhausted or
// with the demand met. CONFIDENCE: high; the equivalence is pinned by a test.
struct RepairPassResult {
int spent = 0;
int left = 0;
};
RepairPassResult RepairShipsInOrbit(int points, int repairDemand);
struct SystemOutputInputs {
// --- the rate vector, exactly as the system stores it (NOT normalised) ---
OutputRates rates;
// The two suppressions the normaliser applies. `suitAtIdeal` is an exact `==` against
// IdealSuitability(); `infraFull` is `float32(Infra + ibon) >= 1`.
bool suitAtIdeal = false;
bool infraFull = false;
// The leftover-weight test reads the RAW `Infra` against 1.0 and does NOT add the pending
// bonus, so it is a different predicate from `infraFull` and is carried separately.
bool infraExactlyOne = false;
// --- the output total, unrounded (lane N's TotalSystemOutputRaw) ---
double totalOutputRaw = 0;
// --- construction ---
int shipyardStations = 0; // StationCount(sys, owner, 1)
int buildQueueDemand = 0; // sum of `conleft` over the system's build queue
// Sum of `Ship::RepairCost` over the owner's damaged ships in orbit. NOT modelled from
// the wire anywhere yet; a caller that cannot compute it must leave it 0 and say so.
int repairDemand = 0;
// --- infrastructure ---
double infra = 0; // sys.Infra, for `ceil((1 - Infra) / 3.3e-5)`
// --- terraforming ---
double terraformPointsNeeded = 0; // TerraformPointsNeeded(...)
bool terraformDown = false; // IdealSuitability() < sys.Suit
double terraformMod = 1.0; // owner's TerraMod
// --- money: every field except `tradePoints`, which this function computes ---
SystemMoneyInputs money;
};
struct SystemOutput {
int totalOutput = 0; // out[0], truncated
int money = 0; // out[3] <- the ONLY slot ComputeBudget reads
int construction = 0; // out[7]
int constructionToQueue = 0; // out[8]
int constructionToRepair = 0; // out[9]
double infraDelta = 0; // out[10], a float32
double suitabilityDelta = 0; // out[11], a float32
// Reported so a caller can see which edges actually carried anything.
double tradePoints = 0; // round(total x SRt)
double leftoverToTrade = 0; // the construction leftover's trade share
double leftoverToMoney = 0; // the terraform leftover that reached the money channel
OutputRates normalisedRates;
};
// `ServerSystem::ComputeOutput` restricted to the channels the campaign has models for.
// out[1], out[2], out[4], out[5] and out[6] -- the resource ledger, the trade-route income
// pair and the repair demand -- are NOT produced here: none of them feeds `out[3]`, they have
// their own inputs, and inventing them would be coverage theatre.
// CONFIDENCE: high on the channel algebra and the rounding sites (every one read off the
// instruction stream). The repair spend is only as good as `repairDemand`.
SystemOutput ComputeSystemOutput(const SystemOutputInputs& in, const TuningTable& t);
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
// System bonus and build queue // System bonus and build queue
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
@ -599,6 +719,13 @@ struct BuildQueueResult {
// including ones that were already at zero before this turn. More than one order can // including ones that were already at zero before this turn. More than one order can
// complete in a turn. CONFIDENCE: high -- B4 corrected the money-refusal path (continue, not // complete in a turn. CONFIDENCE: high -- B4 corrected the money-refusal path (continue, not
// stop), the removal sweep's predicate, and that the leftover is the return value. // stop), the removal sweep's predicate, and that the leftover is the return value.
//
// The `points <= 0` case skips **everything**, the removal sweep included: the entry test
// branches to the epilogue, which returns the argument. Corrected by lane B6 from the
// instruction stream, and carried as a LABELLED HYPOTHESIS rather than a result because no
// save can exercise it: an order can only reach `conleft <= 0` inside this pass, and this
// pass erases it before returning, so a queue never *starts* a turn with one -- unless a
// design with zero construction cost is ever queued, which no corpus save has done.
BuildQueueResult ProcessBuildQueue(std::vector<BuildOrder>& queue, int points); BuildQueueResult ProcessBuildQueue(std::vector<BuildOrder>& queue, int points);
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------

View file

@ -0,0 +1,66 @@
#include "game/sim/construction.h"
#include <algorithm>
#include <map>
namespace sots::sim {
ShipDesignRecord& FindOrAppendDesignRecord(ShipRecords& r, int designKey, int hullClass) {
for (ShipDesignRecord& d : r.designs)
if (d.designKey == designKey) return d;
ShipDesignRecord fresh;
fresh.designKey = designKey;
fresh.hullClass = hullClass;
// The original zeroes the remaining three words at the append site; the default member
// initialisers already do that, and they are spelled out here so the append's shape is
// visible next to the read of it.
fresh.built = 0;
fresh.lost = 0;
fresh.inService = 0;
r.designs.push_back(fresh);
return r.designs.back();
}
void RecordShipBuilt(ShipRecords& r, int designKey, int hullClass) {
if (hullClass >= 0 && hullClass < kHullClassCount) ++r.built[hullClass];
++FindOrAppendDesignRecord(r, designKey, hullClass).built;
}
SystemConstructionResult RunSystemConstruction(std::vector<BuildOrder>& queue, int points) {
SystemConstructionResult out;
out.pointsIn = points;
out.ordersBefore = static_cast<int>(queue.size());
// The design an order names is lost once the order is unlinked, so it is captured here.
// Order ids are unique within a queue in every save observed; a duplicate would make the
// last one win, which is why the map is built before the pass rather than after it.
std::map<int, int> designOfOrder;
for (const BuildOrder& o : queue) designOfOrder[o.orderId] = o.designId;
// Whether any order will absorb the remaining points and stop the pass. Recomputed from
// the queue rather than inferred from the result, so it is reported even when the stop
// happens on the first order.
const BuildQueueResult r = ProcessBuildQueue(queue, points);
out.pointsLeft = r.pointsLeft;
out.pointsSpent = out.pointsIn - r.pointsLeft;
out.moneyCharged = r.moneyCharged;
out.ordersAfter = static_cast<int>(queue.size());
out.ordersRemoved = out.ordersBefore - out.ordersAfter;
out.sweepRan = points > 0;
// Points went in, some were spent, and none came back out: the pass stopped inside an
// order rather than running off the end of the queue.
out.advancedPartially = points > 0 && r.pointsLeft == 0 && !queue.empty();
out.completed.reserve(r.completedOrderIds.size());
for (int id : r.completedOrderIds) {
Completion c;
c.orderId = id;
const auto it = designOfOrder.find(id);
c.designId = it == designOfOrder.end() ? 0 : it->second;
out.completed.push_back(c);
}
return out;
}
} // namespace sots::sim

146
src/game/sim/construction.h Normal file
View file

@ -0,0 +1,146 @@
// game::sim -- ship construction: what a completed build order writes.
//
// The point-consuming half of the pass lives in colony.h as `ProcessBuildQueue`, because
// that is where the colony turn's other point channels live. This file holds the half that
// runs *per completed order*: the player's `ShipRecords`, which is the only thing a
// completion writes that the save can see without also creating the ship.
//
// WHERE THIS RUNS
// ---------------
// `StrategyServer::ProcessTurn` phase 11 -> `ServerSystem::ProcessTurn` ->
// `ServerSystem::ProcessBuildQueue` -> `BuildQueue::ProcessTurn`. A second caller exists
// (the ship-borne queue reached from the ship-action dispatcher, i.e. a construction ship
// building a station), and it reuses the same function.
//
// WHAT A COMPLETION WRITES, in the order the original writes it
// -------------------------------------------------------------
// 1. the ship is created and attached, and the new ships of the pass are collected into a
// vector that is handed to the fleet-forming step after the loop;
// 2. `ShipRecords.built[hullClass]` is incremented -- indexed by the design's cached hull
// class ordinal, stride 4;
// 3. the per-design record whose key equals the design's object id is found, appended if
// absent, and its own `built` field is incremented;
// 4. a build-completed event is pushed onto the system's event list;
// 5. `points -= conleft`, `conleft = 0`.
//
// Step 2 has EXACTLY ONE writer in the whole executable -- an image-wide scan for the
// indexed increment at that displacement returns one site, inside this function. So a ship
// that reaches the wire with the per-class counter bumped came through this pass and no
// other. (Losses, kills and in-service are three further parallel arrays with the same
// stride; nothing increments them here, and nothing in the save corpus is ever non-zero for
// losses or kills, so they carry no model.)
//
// The record layout is settled by ENUMERATION, not by what this function touches: the wire
// writes `srnc` groups of {srb, srl, srk, sri} followed by `srbd` records of
// {srd, src, srb, srl, sri}, and the class array's base plus four arrays of three ints lands
// exactly on the per-design vector's first word. Three classes, four arrays, then the
// vector.
#pragma once
#include <cstdint>
#include <vector>
#include "game/sim/colony.h"
namespace sots::sim {
// Destroyer / cruiser / dreadnought. The wire's `srnc` is 3 in every save in the corpus.
constexpr int kHullClassCount = 3;
// One element of the second counted section (`srbd`). `src` is the design's hull class and
// is written once, when the record is appended; a later completion of the same design only
// touches `built`.
struct ShipDesignRecord {
int designKey = 0; // srd -- the design's OBJECT id, not its index
int hullClass = 0; // src
int built = 0; // srb
int lost = 0; // srl
int inService = 0; // sri
};
// Game::ShipRecords, held inline in the player.
struct ShipRecords {
int built[kHullClassCount] = {}; // srb
int lost[kHullClassCount] = {}; // srl
int killed[kHullClassCount] = {}; // srk
int inService[kHullClassCount] = {}; // sri
std::vector<ShipDesignRecord> designs;
};
// Linear search for `designKey` over the per-design vector, appending a fresh record when
// there is no hit. The search is a plain forward scan and the append is a push_back, so the
// vector's order is first-seen and is load-bearing for the wire.
// CONFIDENCE: high -- read instruction by instruction, including the append's field order.
ShipDesignRecord& FindOrAppendDesignRecord(ShipRecords& r, int designKey, int hullClass);
// One completed hull: bump the class counter and the design record's own counter.
// A hull class outside [0, kHullClassCount) leaves the class array alone -- the original
// indexes it unchecked, so an out-of-range class is a corrupt design, not a policy.
// CONFIDENCE: high on both increments; the guard is ours.
void RecordShipBuilt(ShipRecords& r, int designKey, int hullClass);
// ---------------------------------------------------------------------------------------
// One system's construction pass
// ---------------------------------------------------------------------------------------
struct Completion {
int orderId = 0;
int designId = 0;
};
struct SystemConstructionResult {
std::vector<Completion> completed;
int pointsIn = 0;
int pointsLeft = 0; // the original's return value
int pointsSpent = 0; // pointsIn - pointsLeft
int moneyCharged = 0;
int ordersBefore = 0;
int ordersAfter = 0;
int ordersRemoved = 0; // completed here, plus any that were already at or below zero
bool advancedPartially = false; // an order absorbed everything and stopped the pass
bool sweepRan = false; // false when points <= 0: the whole body is skipped
};
// FIFO consumption with the design id of every completion kept, which the queue pass alone
// does not report. `queue` is modified in place exactly as the original modifies the list.
// CONFIDENCE: high -- see colony.h's ProcessBuildQueue for the rules and their evidence.
SystemConstructionResult RunSystemConstruction(std::vector<BuildOrder>& queue, int points);
// ---------------------------------------------------------------------------------------
// The hull and the fleet at birth -- READ, NOT YET IMPLEMENTED
// ---------------------------------------------------------------------------------------
//
// This is written down here rather than coded because the newborn hull copies four cached
// stat words out of its design, and those words are recomputed by the design's own stats
// pass, which this engine models only far enough to get a hull class. Coding it now would be
// writing fields we cannot compute. The whole chain is read instruction by instruction in
// the RE repo; the shape, for the lane that gets the design stats:
//
// THE SHIP. 176 bytes. The object id comes from the network-node id allocator --
// `(counter << 4) | (node & 0xF)`, per-node counters, PRE-incremented, never issuing 0 --
// and is written into the object by the id-map insert, not by the constructor. At birth:
// the owner is the queue's owner and the design is the order's design; range, health,
// construction capacity, refuel capacity and repair capacity are copied from the design's
// cached stat words; the plague and one other word are -1 and everything else is zero.
// Two fields matter to a reimplementation:
// * the FLEET LINK IS NULL at birth and is set by the fleet-join step, not here;
// * the TURN-BUILT stamp is the sim's frame counter, i.e. the number of the turn being
// produced -- MEASURED: the six hulls the zuul frame-16 -> frame-17 pair adds all
// carry the NEW turn number, which is independent evidence that the frame counter is
// incremented before the spine runs.
//
// THE FLEET. 288 bytes. A system caches ONE home fleet; every hull built there joins it,
// and a fleet is created only when that cache is empty. So a second turn of building at
// the same system creates no fleet. A created fleet takes an id from the same allocator,
// is born at the system's position, and always carries flag 0x400 -- which is NOT a
// retreat marker (that reading is corrected) and is NOT the design flag of the same
// numeral that game/design/hull.h warns about. The build path adds 0x20 on top. There is
// NO scalar ship count: the wire's count is the length of the fleet's ship vector.
// A fleet created with no name override asks the player's name generator, which is what
// bumps the generator's counter -- one bump per generated name.
//
// Ships are also created OUTSIDE this pass, by the trade manager's encounter spawner. That
// path touches no ShipRecords at all, which is what makes the per-class built counter a
// clean discriminator between player-built hulls and spawned ones.
} // namespace sots::sim

View file

@ -1,5 +1,5 @@
# game/sim tests: four hand-computed suites + a real-save smoke test (skips unless SOTS_SAVES_JSON). # game/sim tests: four hand-computed suites + a real-save smoke test (skips unless SOTS_SAVES_JSON).
foreach(_t economy research colony movement techgraph visibility) foreach(_t economy research colony movement techgraph visibility construction)
add_executable(game_sim_test_${_t} test_${_t}.cpp) add_executable(game_sim_test_${_t} test_${_t}.cpp)
target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim) target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim)
target_include_directories(game_sim_test_${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(game_sim_test_${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})

View file

@ -559,6 +559,219 @@ static void test_max_income() {
}())); }()));
} }
// ---------------------------------------------------------------------------------------
// The turn path: ComputeOutput
// ---------------------------------------------------------------------------------------
static void test_ideal_suitability() {
IdealSuitabilityInputs in;
in.owned = false;
in.systemSuitability = 7.5;
in.ownerIdealSuitability = 11.0;
// An unowned system reports its OWN suitability, which is what makes it "at its ideal"
// and suppresses the terraform channel.
CHECK_NEAR(IdealSuitability(in), 7.5, 0.0);
in.owned = true;
CHECK_NEAR(IdealSuitability(in), 11.0, 0.0);
in.independent = true;
in.serverIdealSuitability = 9.25;
CHECK_NEAR(IdealSuitability(in), 9.25, 0.0);
// The per-system override beats both, and the sentinel is FLT_MAX.
in.systemOverride = 3.0;
CHECK_NEAR(IdealSuitability(in), 3.0, 0.0);
in.systemOverride = kIdealSuitabilityNoOverride;
CHECK_NEAR(IdealSuitability(in), 9.25, 0.0);
}
static void test_terraform_points() {
// A planet at its ideal needs nothing, whichever direction it would move.
CHECK_NEAR(TerraformPointsNeeded(10.0, 10.0, 1.0), 0.0, 0.0);
// The rate is TerraMod x 1.8f / 20000, and the sign cancels: the count is the same
// whether the planet is above or below the ideal.
const double up = TerraformPointsNeeded(9.0, 10.0, 1.0);
const double down = TerraformPointsNeeded(11.0, 10.0, 1.0);
CHECK_NEAR(up, down, 0.0);
// Our helper folds in the `ceil` that ComputeOutputFromRates applies to the original's
// return value, so the expected numbers are the ceilings.
CHECK_NEAR(up, std::ceil(1.0 / (1.8000000715255737 / 20000.0)), 0.0); // 11112
// A bigger TerraMod needs proportionally fewer points.
CHECK_NEAR(TerraformPointsNeeded(9.0, 10.0, 3.7),
std::ceil(1.0 / (3.7 * 1.8000000715255737 / 20000.0)), 0.0); // 3004
}
static void test_repair_pass() {
// The round robin's outcome, as a min. Every case the loop can reach:
RepairPassResult r = RepairShipsInOrbit(100, 0); // nothing damaged
CHECK_EQ(r.spent, 0);
CHECK_EQ(r.left, 100);
r = RepairShipsInOrbit(100, 40); // points win
CHECK_EQ(r.spent, 40);
CHECK_EQ(r.left, 60);
r = RepairShipsInOrbit(40, 100); // demand wins; nothing cascades
CHECK_EQ(r.spent, 40);
CHECK_EQ(r.left, 0);
r = RepairShipsInOrbit(0, 100); // the early return
CHECK_EQ(r.spent, 0);
CHECK_EQ(r.left, 0);
}
// A helper matching the corpus's shape: at the ideal, infrastructure exactly 1, no station,
// so the terraform and infrastructure channels are suppressed and both needs are zero.
static SystemOutputInputs CorpusColony(double trade, double construction, double total) {
SystemOutputInputs in;
in.rates.trade = trade;
in.rates.construction = construction;
in.suitAtIdeal = true;
in.infraFull = true;
in.infraExactlyOne = true;
in.infra = 1.0;
in.totalOutputRaw = total;
in.terraformPointsNeeded = 0.0;
return in;
}
static void test_turn_path_output() {
const TuningTable t; // unloaded: no station bonus, which the corpus never exercises
// 1. The claim the whole lane turns on: with an empty build queue and nothing to repair,
// every construction point comes back to the money channel, so a colony that puts
// everything into ship construction earns exactly as much as one that puts everything
// into trade.
{
const SystemOutput allTrade = ComputeSystemOutput(CorpusColony(1.0, 0.0, 4000.0), t);
const SystemOutput allCons = ComputeSystemOutput(CorpusColony(0.0, 1.0, 4000.0), t);
const SystemOutput half = ComputeSystemOutput(CorpusColony(0.5, 0.5, 4000.0), t);
CHECK_EQ(allCons.money, allTrade.money);
CHECK_EQ(half.money, allTrade.money);
// and the leftover really is what carries it on the construction colony
CHECK_NEAR(allCons.tradePoints, 0.0, 0.0);
CHECK_NEAR(allCons.leftoverToTrade, 4000.0, 0.0);
CHECK_EQ(allCons.construction, 4000);
}
// 2. ... which makes it equal to the PROJECTED path, up to the trade-point rounding.
// An even total agrees exactly; an odd one can differ by one trade point because
// `2 x round(T/2)` is not `T`.
{
SystemMoneyInputs m;
const SystemOutput even = ComputeSystemOutput(CorpusColony(0.5, 0.5, 4000.0), t);
CHECK_EQ(even.money, SystemMaxIncome(4000.0, m));
const SystemOutput odd = ComputeSystemOutput(CorpusColony(0.5, 0.5, 4001.0), t);
// 4001 x 0.5 = 2000.5, ties to even -> 2000 twice, so 4000 trade points, not 4001.
CHECK_NEAR(odd.tradePoints + odd.leftoverToTrade, 4000.0, 0.0);
}
// 3. The build queue eats construction points BEFORE the leftover is redistributed, so a
// funded queue is a direct loss of money.
{
SystemOutputInputs in = CorpusColony(0.0, 1.0, 4000.0);
in.buildQueueDemand = 1500;
const SystemOutput o = ComputeSystemOutput(in, t);
CHECK_EQ(o.constructionToQueue, 1500);
CHECK_NEAR(o.leftoverToTrade, 2500.0, 0.0);
// a queue larger than the output takes all of it and leaves nothing
in.buildQueueDemand = 999999;
const SystemOutput starved = ComputeSystemOutput(in, t);
CHECK_EQ(starved.constructionToQueue, 4000);
CHECK_NEAR(starved.leftoverToTrade, 0.0, 0.0);
CHECK_EQ(starved.money, 0);
}
// 4. The repair pass takes its share after the queue and before the redistribution.
{
SystemOutputInputs in = CorpusColony(0.0, 1.0, 4000.0);
in.buildQueueDemand = 1000;
in.repairDemand = 700;
const SystemOutput o = ComputeSystemOutput(in, t);
CHECK_EQ(o.constructionToQueue, 1000);
CHECK_EQ(o.constructionToRepair, 700);
CHECK_NEAR(o.leftoverToTrade, 2300.0, 0.0);
}
// 5. The two cascades, which the max-income path proves ARE zero and this one does not.
// A colony below full infrastructure with a funded infra channel spends what it needs
// and passes the rest to terraforming; terraforming passes ITS rest to money.
{
SystemOutputInputs in;
in.rates.trade = 0.0;
in.rates.construction = 0.0;
in.rates.infra = 1.0;
in.suitAtIdeal = true; // so the terraform channel is suppressed and needs 0
in.infraFull = false;
in.infraExactlyOne = false;
in.infra = 1.0 - 3.3e-5 * 100.0; // exactly 100 points short of full
in.totalOutputRaw = 4000.0;
in.terraformPointsNeeded = 0.0;
const SystemOutput o = ComputeSystemOutput(in, t);
// 100 points close the infrastructure gap, the other 3900 fall through terraforming
// (which needs nothing) into the money channel.
CHECK_NEAR(o.leftoverToMoney, 3900.0, 1e-6);
CHECK(o.infraDelta > 0.0);
SystemMoneyInputs m;
m.tradePoints = 3900.0;
CHECK_EQ(o.money, SystemMoneyIncome(m));
}
// 6. A terraforming colony consumes what it needs and cascades the rest, and the sign of
// the suitability delta follows the direction of travel.
{
SystemOutputInputs in;
in.rates.terraform = 1.0;
in.suitAtIdeal = false;
in.infraFull = true;
in.infraExactlyOne = true;
in.infra = 1.0;
in.totalOutputRaw = 4000.0;
in.terraformPointsNeeded = 250.0;
in.terraformMod = 1.0;
const SystemOutput up = ComputeSystemOutput(in, t);
CHECK_NEAR(up.leftoverToMoney, 3750.0, 1e-6);
CHECK(up.suitabilityDelta > 0.0);
in.terraformDown = true;
const SystemOutput down = ComputeSystemOutput(in, t);
CHECK_NEAR(down.suitabilityDelta, -up.suitabilityDelta, 0.0);
// The point count and the point value use the same rate, so spending exactly the
// needed points closes exactly the gap it was computed from.
const double gap = 250.0 * (1.5 * kTerraform12 * 1.0) / 20000.0;
CHECK_NEAR(up.suitabilityDelta, static_cast<double>(static_cast<float>(gap)), 0.0);
}
// 7. The `SRsc == 1` leftover branch really is a different rule: with construction at
// exactly 1 the weights become 1 / (suit off ideal) / (infra below 1) rather than the
// sliders, so a colony that is off its ideal sends HALF its leftover to terraforming
// instead of all of it to trade.
{
SystemOutputInputs in;
in.rates.construction = 1.0;
in.suitAtIdeal = false; // terraform weight 1
in.infraFull = true; // infra suppressed by the normaliser ...
in.infraExactlyOne = true; // ... and weight 0 in the leftover split
in.infra = 1.0;
in.totalOutputRaw = 4000.0;
in.terraformPointsNeeded = 0.0; // nothing to spend it on, so it cascades to money
const SystemOutput o = ComputeSystemOutput(in, t);
CHECK_NEAR(o.normalisedRates.construction, 1.0, 0.0);
CHECK_NEAR(o.leftoverToTrade, 2000.0, 0.0);
CHECK_NEAR(o.leftoverToMoney, 2000.0, 0.0);
// Both halves reach the money channel here, so the total is the same as if it had
// all gone to trade -- the split matters only when terraforming has work to do.
SystemMoneyInputs m;
m.tradePoints = 4000.0;
CHECK_EQ(o.money, SystemMoneyIncome(m));
}
// 8. An unfunded channel produces nothing, and a total of zero produces no money.
{
const SystemOutput o = ComputeSystemOutput(CorpusColony(0.5, 0.5, 0.0), t);
CHECK_EQ(o.totalOutput, 0);
CHECK_EQ(o.construction, 0);
CHECK_EQ(o.money, 0);
}
}
static void test_difficulty_table() { static void test_difficulty_table() {
// Level 0 gives the break to the human; levels 1 and 2 give it to the AI. // Level 0 gives the break to the human; levels 1 and 2 give it to the AI.
const DifficultyMods e_ai = DifficultyModsFor(0, true, false); const DifficultyMods e_ai = DifficultyModsFor(0, true, false);
@ -714,6 +927,10 @@ int main() {
test_system_money(); test_system_money();
test_population_income(); test_population_income();
test_max_income(); test_max_income();
test_ideal_suitability();
test_terraform_points();
test_repair_pass();
test_turn_path_output();
test_difficulty_table(); test_difficulty_table();
test_bonuses(); test_bonuses();
test_build_queue(); test_build_queue();

View file

@ -0,0 +1,244 @@
// Ship construction: the completion bookkeeping, and the two rules of the pass that the
// corpus can and cannot show.
//
// The corpus fixtures at the bottom are NOT invented. They are the build queues of
// `zuul-turn16-noderoute.sav` and the queues the same game carries one turn later in
// `zuul-turn17-rollpending.sav` (frames 16 and 17, a genuine consecutive-turn pair). The
// test does not assume the point totals: it SOLVES for them, and the solve is the
// falsification -- a non-FIFO order, a per-order point budget, or a "skip and continue"
// rule instead of "stop at the first order that cannot finish" each make the solve fail.
#include "game/sim/construction.h"
#include "check.h"
using namespace sots::sim;
// ---------------------------------------------------------------------------------------
// The records a completion writes
// ---------------------------------------------------------------------------------------
static void test_records() {
ShipRecords r;
RecordShipBuilt(r, 608, 0);
CHECK_EQ(r.built[0], 1);
CHECK_EQ(r.designs.size(), std::size_t{1});
CHECK_EQ(r.designs[0].designKey, 608);
CHECK_EQ(r.designs[0].hullClass, 0);
CHECK_EQ(r.designs[0].built, 1);
// A second hull of the same design finds the record rather than appending one.
RecordShipBuilt(r, 608, 0);
CHECK_EQ(r.designs.size(), std::size_t{1});
CHECK_EQ(r.designs[0].built, 2);
CHECK_EQ(r.built[0], 2);
// A different design appends, and the vector's order is first-seen.
RecordShipBuilt(r, 576, 0);
RecordShipBuilt(r, 1136, 2);
CHECK_EQ(r.designs.size(), std::size_t{3});
CHECK_EQ(r.designs[1].designKey, 576);
CHECK_EQ(r.designs[2].designKey, 1136);
CHECK_EQ(r.designs[2].hullClass, 2);
CHECK_EQ(r.built[0], 3);
CHECK_EQ(r.built[2], 1);
// Nothing here touches losses, kills or in-service.
CHECK_EQ(r.lost[0], 0);
CHECK_EQ(r.killed[0], 0);
CHECK_EQ(r.inService[0], 0);
// An out-of-range hull class leaves the class array alone but still gets its own record;
// the original indexes the array unchecked, so this guard is ours and is stated as such.
ShipRecords g;
RecordShipBuilt(g, 7, 9);
CHECK_EQ(g.built[0], 0);
CHECK_EQ(g.designs.size(), std::size_t{1});
CHECK_EQ(g.designs[0].built, 1);
}
// ---------------------------------------------------------------------------------------
// The pass
// ---------------------------------------------------------------------------------------
static void test_pass_reports_designs() {
// {designId, orderId, con, conleft, money, moneyAvailable}
std::vector<BuildOrder> q = {{608, 3, 1980, 1753, 0, true},
{576, 4, 1860, 1860, 0, true},
{576, 5, 1860, 1860, 0, true}};
const SystemConstructionResult r = RunSystemConstruction(q, 4182);
CHECK_EQ(r.completed.size(), std::size_t{2});
CHECK_EQ(r.completed[0].orderId, 3);
CHECK_EQ(r.completed[0].designId, 608);
CHECK_EQ(r.completed[1].orderId, 4);
CHECK_EQ(r.completed[1].designId, 576);
CHECK_EQ(r.pointsSpent, 4182);
CHECK_EQ(r.pointsLeft, 0);
CHECK(r.advancedPartially);
CHECK_EQ(r.ordersRemoved, 2);
CHECK_EQ(q.size(), std::size_t{1});
CHECK_EQ(q[0].orderId, 5);
CHECK_EQ(q[0].constructionLeft, 1291);
}
static void test_points_gate_skips_the_sweep() {
// An order already at zero. This state cannot arise in the corpus -- the pass that
// zeroes an order also erases it -- so the rule is a labelled hypothesis about a
// zero-construction-cost design, and the test pins the behaviour, not a measurement.
std::vector<BuildOrder> q = {{608, 3, 0, 0, 0, true}};
SystemConstructionResult r = RunSystemConstruction(q, 0);
CHECK_EQ(q.size(), std::size_t{1}); // points <= 0: the whole body is skipped
CHECK_EQ(r.pointsLeft, 0);
CHECK(!r.sweepRan);
r = RunSystemConstruction(q, -5);
CHECK_EQ(q.size(), std::size_t{1});
CHECK_EQ(r.pointsLeft, -5);
r = RunSystemConstruction(q, 1); // one point is enough to run the sweep
CHECK(q.empty());
CHECK(r.sweepRan);
CHECK_EQ(r.ordersRemoved, 1);
}
static void test_money_refusal_skips_not_stops() {
std::vector<BuildOrder> q = {{608, 1, 100, 100, 500, false},
{576, 2, 100, 100, 500, true}};
const SystemConstructionResult r = RunSystemConstruction(q, 300);
CHECK_EQ(r.completed.size(), std::size_t{1});
CHECK_EQ(r.completed[0].orderId, 2);
CHECK_EQ(r.moneyCharged, 500);
// The refused order keeps its points and survives the sweep; the pass did not stop at it.
CHECK_EQ(q.size(), std::size_t{1});
CHECK_EQ(q[0].orderId, 1);
CHECK_EQ(r.pointsLeft, 200);
CHECK(!r.advancedPartially);
}
static void test_running_off_the_end() {
std::vector<BuildOrder> q = {{608, 1, 100, 100, 0, true}};
const SystemConstructionResult r = RunSystemConstruction(q, 900);
CHECK_EQ(r.pointsLeft, 800);
CHECK(!r.advancedPartially);
CHECK(q.empty());
}
// ---------------------------------------------------------------------------------------
// The corpus oracle: zuul-turn16-noderoute.sav -> zuul-turn17-rollpending.sav
// ---------------------------------------------------------------------------------------
//
// System 80 (owner 32, an AI) and system 384 (owner 16) each hold a queue at frame 16 and a
// different queue at frame 17. The AI appended one order (58) during the turn, so its
// before-state is the frame-16 queue with that order pushed on the end -- the only fitted
// element in this fixture, and it is fitted from the frame-17 file's own `con`/`ordID`, not
// from the model.
//
// The test searches every point total in a wide range and asserts that the set of totals
// that reproduce the observed after-state is non-empty and is a contiguous run of ONE value
// per system (the transition is exact, not a band), then checks the completions against the
// per-design `srb` deltas the two files carry.
struct Fixture {
const char* what;
std::vector<BuildOrder> before;
std::vector<BuildOrder> after;
std::vector<int> expectedCompletedDesigns;
};
static int solve_points(const Fixture& f, int* solutions) {
int found = -1;
*solutions = 0;
for (int p = 0; p <= 200000; ++p) {
std::vector<BuildOrder> q = f.before;
const SystemConstructionResult r = RunSystemConstruction(q, p);
if (q.size() != f.after.size()) continue;
bool same = true;
for (std::size_t i = 0; i < q.size(); ++i)
if (q[i].orderId != f.after[i].orderId ||
q[i].constructionLeft != f.after[i].constructionLeft)
same = false;
if (!same) continue;
std::vector<int> designs;
for (const Completion& c : r.completed) designs.push_back(c.designId);
if (designs != f.expectedCompletedDesigns) continue;
++*solutions;
if (found < 0) found = p;
}
return found;
}
static void test_corpus_pair() {
// System 384, owner 16. Frame 16: three orders. Frame 17: one, advanced by 569.
Fixture human{"sys 384 / player 16",
{{608, 3, 1980, 1753, 0, true},
{576, 4, 1860, 1860, 0, true},
{576, 5, 1860, 1860, 0, true}},
{{576, 5, 1860, 1291, 0, true}},
{608, 576}};
// System 80, owner 32. Frame 16: four orders of design 114. Frame 17: order 58 only,
// which the AI appended during the turn (con 6974) and which was advanced by 3156.
Fixture ai{"sys 80 / player 32",
{{114, 54, 1889, 959, 0, true},
{114, 55, 1889, 1889, 0, true},
{114, 56, 1889, 1889, 0, true},
{114, 57, 1889, 1889, 0, true},
{816, 58, 6974, 6974, 0, true}},
{{816, 58, 6974, 3818, 0, true}},
{114, 114, 114, 114}};
for (const Fixture* f : {&human, &ai}) {
int solutions = 0;
const int p = solve_points(*f, &solutions);
simtest::report(p >= 0, "a point total reproduces the observed transition", __FILE__,
__LINE__, std::string(f->what));
simtest::report(solutions == 1, "the point total is unique", __FILE__, __LINE__,
std::string(f->what) + " solutions=" + std::to_string(solutions));
}
// The two totals the solve finds, stated so a change to the model is visible as a number.
int n = 0;
CHECK_EQ(solve_points(human, &n), 4182);
CHECK_EQ(solve_points(ai, &n), 9782);
// The per-design `srb` deltas the two saves carry, reproduced by feeding the solved
// totals through the records model. Player 16 (index 0 on the wire): design 608 goes
// 2 -> 3 and a record for 576 appears with 1. Player 32 (index 1): design 114 goes
// 18 -> 22, and the class-0 counters go 2 -> 4 and 53 -> 57.
{
ShipRecords r;
r.built[0] = 2;
r.designs.push_back({656, 0, 0, 0, 2});
r.designs.push_back({608, 0, 2, 0, 2});
std::vector<BuildOrder> q = human.before;
for (const Completion& c : RunSystemConstruction(q, 4182).completed)
RecordShipBuilt(r, c.designId, 0);
CHECK_EQ(r.built[0], 4);
CHECK_EQ(r.designs.size(), std::size_t{3});
CHECK_EQ(r.designs[1].built, 3); // 608
CHECK_EQ(r.designs[2].designKey, 576); // appended, in first-seen order
CHECK_EQ(r.designs[2].built, 1);
}
{
ShipRecords r;
r.built[0] = 53;
r.designs.push_back({816, 0, 6, 0, 8});
r.designs.push_back({18, 0, 23, 0, 23});
r.designs.push_back({34, 0, 4, 0, 4});
r.designs.push_back({114, 0, 18, 0, 18});
r.designs.push_back({130, 0, 2, 0, 2});
std::vector<BuildOrder> q = ai.before;
for (const Completion& c : RunSystemConstruction(q, 9782).completed)
RecordShipBuilt(r, c.designId, 0);
CHECK_EQ(r.built[0], 57);
CHECK_EQ(r.designs.size(), std::size_t{5}); // nothing appended
CHECK_EQ(r.designs[3].built, 22); // 114
CHECK_EQ(r.designs[0].built, 6); // 816 did not complete
}
}
int main() {
test_records();
test_pass_reports_designs();
test_points_gate_skips_the_sweep();
test_money_refusal_skips_not_stops();
test_running_off_the_end();
test_corpus_pair();
return simtest::finish("game_sim_construction");
}