Compare commits

..

No commits in common. "011749571b83628772c5d8d8e6b4f8555f49890e" and "5d245e4c8b38bb82dfc1c53504e13fd19d66ff0c" have entirely different histories.

14 changed files with 44 additions and 1090 deletions

View file

@ -1,264 +0,0 @@
# SV — what updates `SvSctOb` during a turn
Lane SV. Written **before** the build (rule 2). The falsification section is §5.
The eight leaves of `/Sim/SvSctOb` that diverge on the reference pair belong to three of the
twelve script objects the save carries. This lane asked what writes them, found the mechanism,
and implements the part of it the standalone can reach.
---
## 1. The mechanism: one event bus, two steps per delivery
`StrategyServer+0x1b4` holds the root script object (a `Game::SVSOSots`). Every site that
notifies it does the **same two-step**: a generic handler taking the event id, then one
event-specific virtual slot with no id:
```
script->vft[0x10](evt, arg); // generic: every object sees every event
script->vft[<slot(evt)>](...); // specific: one slot per event id
```
The root's generic handler fans the same delivery out to **every child** script object through
a shared dispatcher, which repeats both steps per child and carries a **33-entry jump table**
mapping `evt` (0..0x20) to the specific slot. So `evt -> slot` is a real, exhaustive encoding
in the image, not a guess:
| evt | slot | evt | slot | evt | slot |
|---|---|---|---|---|---|
| 0x00 | +0x14 | 0x0b | +0x40 | 0x16 | +0x70 |
| 0x01 | +0x18 | 0x0c | +0x44 | 0x17 | +0x74 |
| 0x02 | +0x1c | 0x0d | +0x48 | 0x18 | +0x68 |
| 0x03 | +0x20 | 0x0e | +0x4c | 0x19 | +0x7c |
| 0x04 | +0x24 | 0x0f | +0x50 | 0x1a | +0x80 |
| 0x05 | +0x28 | 0x10 | +0x54 | 0x1b | +0x84 |
| 0x06 | +0x2c | 0x11 | +0x58 | 0x1c | +0x78 |
| 0x07 | +0x30 | 0x12 | +0x5c | 0x1d | +0x88 |
| 0x08 | +0x34 | 0x13 | +0x60 | 0x1e | +0x8c |
| 0x09 | +0x38 | 0x14 | +0x64 | 0x1f | +0x90 |
| 0x0a | +0x3c | 0x15 | +0x6c | 0x20 | +0x94 |
The table is what proves the hand-written pairs in the two turn drivers are event deliveries and
not ad-hoc calls: the tail's `vft[0x10](8,0); vft[0x34]()` is exactly `evt 8`, and
`vft[0x10](0x14,0); vft[0x64]()` is exactly `evt 0x14`.
## 2. Where a turn delivers events
Every site that reads `StrategyServer+0x1b4` and dispatches, with the id it sends:
| driver | event | when |
|---|---|---|
| `BeginProcessTurn` | **0x13** | first thing in the turn, right after the frame counter |
| `StrategyServer::ProcessTurn` | 6, then 0x1c | the spine |
| `StrategyServer::MoveFleet` | 0xe | per fleet move |
| `ApplyEncounterResult` | 7 | per encounter, in the tail |
| `OnAllCombatDone_Tail` | **8**, then **0x14**, **0x15**, then **0x1c** | tail phases 8, 20 and one site lane K's phase map does not list |
| `BuildTurnEvents` | 0x1a, ?, 0x1b | after the tail |
| `SynchronizePlayer`, `LoadGame`, `ResumePlaying`, and eight others | 1..5, 0xd, 0x17, 0x18, 0x2 | not a turn |
**Correction to lane K.** `combat-done-tail.md` lists three script-hook sites in the tail
(phases 8 and 20). There is a **fourth**, after the maintenance/research recompute, and it sends
**event 0x1c** — the same id `ProcessTurn` sends. Lane K's tier-4 note attributes 0x1c to
`ProcessTurn` alone.
Of the twelve classes our saves carry, only these override a slot a turn delivers:
| class | evt 0x13 (turn begin) | evt 8 | evt 0x14 | evt 0x1c | evt 6 | evt 7 |
|---|---|---|---|---|---|---|
| VonNeumann (1) | yes | yes | — | yes | yes | yes |
| Swarm (3) | yes | — | — | — | — | yes |
| Derelict (4) / Monitor (5) / CrowRuins (17) | — | — | — | — | — | yes |
| SlaversRefuel (9) | — | — | **yes** (via its generic handler) | — | — | yes |
| SwarmQueen (10) | **yes** | — | **yes** | — | — | — |
| Refugees (20) | **yes** | yes | — | — | yes | yes |
| Traps / CrowDefenders / IndependentSystems | yes | yes | yes | — | yes | — |
| GrandMenaceTrigger | yes | — | — | — | — | — |
`evt 0x15` is overridden by **nobody** — the tail's second phase-20 pair is dead in every class
our saves hold.
## 3. The three writers behind the eight leaves
`EncObj[3]` is EncID 9 (SlaversRefuel), `EncObj[5]` is EncID 10 (SwarmQueen), `EncObj[6]` is
EncID 20 (Refugees).
### 3a. `CDiff` (1 leaf) — SlaversRefuel, event 0x14, the tail
The class overrides only the **generic** handler, which does nothing unless `evt == 0x14`. The
body builds a **three-record table on the stack** and walks it against the server's frame
counter:
| threshold | payload |
|---|---|
| 1 | (1, 1) |
| 50 | (2, 3) |
| 100 | (2, 5) |
It scans for the first record whose threshold is **greater** than the frame, and writes
`index - 1` into `CDiff` — but only if that differs from what is there. Three consequences,
all from the instruction stream and none of them guessable from the data:
* frame ≤ 0 → the first record already exceeds it, index 0, `jle` exit: **no write**;
* frame in 1..49 → `CDiff = 0`; frame in 50..99 → `CDiff = 1`;
* **frame ≥ 100 → the scan runs off the end and there is no write at all**, so `CDiff` can never
reach 2 through this path. That looks like an off-by-one in the original and is recorded as
what the code does, not as what it presumably meant.
Only when `CDiff` changes does the function continue into a per-system pass. That pass writes
nothing this object serialises (`NAsg`, `NTD`, `NAD` are unchanged across the pair), so it is
**not** modelled and is labelled below.
### 3b. `ini` + `didc`/`did` (3 leaves) — Refugees, event 0x13, turn begin
```
if (!ini) {
ini = true;
obj = <instantiate "_Refugee_Trader" from the "Mission" section of the data files>;
if (obj) dids.push_back(obj.handleId);
}
```
`ini` is a one-shot latch and it is the whole gate. The push-back is **not** modelled: the id it
appends is `1712`, and the same turn's save also gains design `1712`, ship `1728` and fleet
`1744` — three consecutive handle allocations, `NMnx` 106 → 109. That is the refugee-trader
convoy being created from the data files, and nothing in the standalone allocates handles or
instantiates a design template. So this lane commits the latch and names the rest.
### 3c. `Hives` (4 leaves) — SwarmQueen, event 0x13, turn begin
The constructor is decisive about the two ids this class carries: it stores **3** in the
`SVScriptObject` scenario tag and **10** in its own encounter id. So the queen operates on the
**Swarm's** systems, not on its own.
```
for each system with system.EggScio == this.scenarioTag (== 3):
if no hive already references it:
hive.system = system
hive.queen = 0
hive.nextQ = frame + LO + rand(HI - LO) <-- ONE MT DRAW PER NEW HIVE
hives.push_back(hive)
prune hives whose system.EggScio != 3
for each hive with queen == 0:
if <spawn gates fail>: ++hive.nextQ
elif hive.nextQ <= frame: <spawn a queen>
```
`EggScio` is confirmed as the system's owning-scenario tag by the data and not only by the code:
in `turn1-state.sav` exactly the two systems with `EggScio == 3` (336, 400) are the two the
Swarm has infested and the two that get hives; `EggScio == 4` are the two systems the Derelict
has fleets on; `EggScio == 5` is the Monitor's one system.
`++nextQ` is the whole explanation of a number that looked impossible: `NextQ` reads 31/29 after
turn 1 and 32/30 after turn 2. It is not re-rolled — it **slips forward by one every turn the
spawn gates fail**, so a hive's queen date walks away from it until the gates open.
## 4. What is predicted
Implemented in `src/game/sim/scriptobjects.{h,cpp}` and driven from three phases in `src/app`.
**P1 — `CDiff`.** `/Sim/SvSctOb/EncObj[3]/CDiff` closes, `-1 -> 0`, on both reference pairs.
This is the only one of the eight that is completely free of anything the standalone lacks.
**P2 — `ini`.** `/Sim/SvSctOb/EncObj[6]/ini` closes, `False -> True`, on both pairs.
`didc` and `did` do **not** close and do **not** regress: `didc` stays 0 against the oracle's 1
and `did` stays absent.
**P3 — `Hives`.** `/Sim/SvSctOb/EncObj[5]/Hives/.` and `.[0]` (the count, 2) close. `.[1]` and
`.[2]` do **not**, because `NextQ` needs a draw this lane cannot place. Two of four.
**P4 — regressions: zero.** Nothing here writes a leaf that currently agrees.
**P5 — the pair total.** 128 → 124, closed 4, regressed 0, on `turn1 -> turn2`. Pair 2 is
`turn2 -> turn3`, where the latch and the tier are already set and the hives already exist, so
**pair 2 moves by 0** — and that asymmetry is itself the check that these are one-shot rules and
not per-turn ones.
**P6 — an RNG claim, not measured here.** Lane Z measured a turn at 18–22 generator words, *all*
inside `ProcessTurn`, residual outside the two drivers **exactly zero** — on turns 6 and 64,
where the hives already existed. On the reference pair the hives are **created**, and creation
draws once per hive from the strategic generator inside `BeginProcessTurn`, which is **outside
both turn drivers and before either of them**. So the reference turn should cost lane Z's
`ProcessTurn` total **plus at least two words**, and lane Z's "residual is exactly zero" is a
statement about the turns it measured, not about a turn.
## 5. How this could be wrong, and the symptom of each way
1. **`CDiff` reads a different counter than the frame.** The handler reads the server's `+0xc`,
which `BeginProcessTurn` increments while logging "Begin processing turn N", so it is the
frame. If it were instead the modification counter, the tier for the reference pair would
still be 0 (both are small), so **this corpus cannot separate them** — the 1..49 window
swallows the difference. Symptom elsewhere: a save at frame ~50 with a very different
ModCount would put `CDiff` on the wrong side of the boundary.
2. **The stack table is read with the wrong stride.** If the records were 2 dwords rather than 3,
the thresholds would be 1/1/50 and the reference pair would come out `CDiff = 1`, not 0. The
symptom is immediate and visible in the very leaf we are trying to close.
3. **`ini` is set somewhere else as well.** If some other handler also latches it, committing it
at turn begin is right by accident. Symptom: none on this corpus. Stated as a risk.
4. **`ini` should not be set without the design.** If the original's latch were written only
*after* a successful instantiation, then a standalone that cannot instantiate should leave it
false, and closing it here is closing a leaf with the wrong reason. Read from the instruction
stream: the store to the latch is the **second instruction of the guarded block**, before the
lookup and unconditional on its result. So the latch is not conditional on the design.
5. **The hive set is keyed by something other than `EggScio`.** Symptom: the wrong count, or
hives on systems 448/480/64. The count leaf would then regress rather than close.
6. **`NextQ` might be reachable after all.** If the two creation draws are the first draws of the
turn and the standalone's generator is loaded from the save, a future lane that models the
two config constants could reproduce them exactly. This lane does not claim they are
unreachable, only that it has not placed them.
7. **The per-system pass after a `CDiff` change might write something.** It runs on the
reference pair (the tier changes on turn 1). If it wrote a leaf, a regression would appear
somewhere outside `SvSctOb` — which is exactly what P4 would catch.
## 5a. What actually happened
Measured on CT111, `tools/standalone_report.py`. Closed and regressed are stated separately
and never netted, and the two configurations are stated separately too.
**Default (hive registration off).**
| pair | before | after | closed | regressed |
|---|---|---|---|---|
| turn1 -> turn2 | 209 -> 128 | 209 -> **126** | 83 (was 81) | **0** |
| turn2 -> turn3 | 108 -> 69 | 108 -> **67** | 41 (was 39) | **0** |
**With `--commit-blocked=H03` (hive registration on).**
| pair | before | after | closed | regressed |
|---|---|---|---|---|
| turn1 -> turn2 | 209 -> 128 | 209 -> **124** | 87 | **2** |
| turn2 -> turn3 | 108 -> 69 | 108 -> **67** | 41 | **0** |
Scoring the predictions.
* **P1 held.** `CDiff` closed, `-1 -> 0`, on pair 1; pair 2 already carried 0 and the store
is conditional, so it correctly did nothing there.
* **P2 held**, including its negative half: `didc` and `did` neither closed nor regressed.
* **P3 held**, and only under the flag: the count and the list-shape leaves close, the two
`NextQ` leaves do not — and they show up as **regressions**, because the tool's baseline
had those two positions on the "agrees" side while our tree had no hive there at all. That
is the honest reading of a knowingly-wrong value and it is why the registration is opt-in.
* **P4 held** in the default configuration and **failed as stated** under the flag: 2
regressions, both named above, both the same field.
* **P5 was WRONG, and wrong in the direction that matters.** It said pair 2 would move by 0,
because the latch and the tier are already set there and the hives already exist. Pair 2
moved by **2**: `Hives/.[1]/NextQ` and `.[2]/NextQ` closed, 31 -> 32 and 29 -> 30. The
prediction forgot that the hives being present is exactly what lets the **slip** run, and
the slip is a per-turn rule, not a one-shot. So the second pair is not the null control
P5 called it — it is the only **exact** test of the rule this lane recovered, and it
passes: two hives, two independent target turns, both landing on the oracle's value with
no fitting and no draw. A rule that reproduces two numbers it was not built from is worth
more than the pair-1 leaves it was aimed at.
* **P6 is untested here.** It is a claim about the generator, not about leaves, and this lane
did not instrument it. It is recorded so the next lane to touch the RNG ledger can falsify
it cheaply: hook the turn-begin delivery on a save whose swarm hives do not yet exist.
## 6. What no save exercises (rule 6)
* `evt 0x15` — no class our saves hold overrides it. The tail's second phase-20 pair is a no-op
on every workload we can build from this corpus.
* The queen **spawn** arm: no hive in any corpus save has a queen, so only the `++NextQ` slip
arm has ever run. Workload needed: a swarm game run past the spawn gates.
* `CDiff` tiers 1 and 2: needs a save at frame ≥ 50. The Zuul saves reach turn 23.
* The Refugees `dids` push: needs design instantiation from the data files, not a different save.
* Two script objects with a factory entry and no occurrence, and the whole alliance/contact
family of events, remain unexercised — the corpus has no alliance and no two-empire contact.

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ 2e935b8, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ d4f9404, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
@ -1439,38 +1439,12 @@ constexpr uint32_t StrategyAIAgent_AssignFleetsAndIssueOrders = 0x002c16c0;
constexpr uint32_t StrategyAIAgent_IsClaimedByAnotherTask = 0x002a8d20;
// thiscall int __thiscall Game::StrategyAIAgent::RangePenaltyForTask() -- the ONLY consumer of IAITask vtable slot 13 found in the image, dispatched at 0x00696630 on the `this` receiver. `budget = this ? this->vt[13]() : 15; n = max(1, agent->+0x10->+0x8 - 0x0080da80(player) + 1); if (n < budget) return 0;` else a 7-arm species switch on player->+0x5c through the byte index at 0x006966a8 = [0,0,0,0,2,1,0] and the table at 0x0069669c: species 0,1,2,3,4,6 and out-of-range -> 1000000 (0x000f4240), species 5 (Zuul) -> 0. So slot 13 is a RANGE/HOP BUDGET compared against a count, with a prohibitive penalty past it -- and the Zuul are exempt, a FOURTH independent cross-check on lane AI2's species reading (after Hiver gates, Zuul node-bore and NPC building nothing). The two 'Incoming' defence tasks return INT_MAX from slot 13, so they never take the penalty [verified]
constexpr uint32_t StrategyAIAgent_RangePenaltyForTask = 0x00296620;
// thiscall int __thiscall Game::IAITask::GetPriority_Default() -- 17 bytes: `return AITask_PriorityForType(this->vt[1]() /*GetTypeId*/);`, i.e. the vt[1] dispatch followed by a direct call to the 33-arm table at 0x00691f00. This is what the two tuned GetPriority overrides tail-jump to when their flag bit is CLEAR, so lane AI2's priority table stands with an extra hop in front of it [verified]
constexpr uint32_t AITask_GetPriorityDefaultThunk = 0x00294220;
// data int -- image-initialised value 650 (0x0000028a). AITInvade::GetPriority 0x00683670 is `movzx eax,byte [ecx+4]; not al; test al,1; je +5; jmp 0x00694220; mov eax,ds:0xa1795c; ret` -- so the tunable is returned when BIT 0 OF this->+0x4 IS SET, which is the OPPOSITE of lane AI2's stated `if (!(this->+0x4 & 1))`. It has EXACTLY ONE reference in the whole image (this load) and no writer anywhere: no loader, no CSV path. It is a code constant that happens to live in the writable data section. AITInvade's table priority is 500, so the flag raises it to 650 [verified]
constexpr uint32_t g_AITInvadeUncommittedPriority = 0x0061795c;
// data int -- image-initialised value 750 (0x000002ee), the twin of 0x00a1795c. AITEscortGateInvade::GetPriority 0x006835e0 has the identical shape and the identical inverted polarity: the tunable applies when bit 0 of this->+0x4 IS SET. Exactly one reference in the image, no writer. AITEscortGateInvade's table priority is 400, so the flag raises it to 750 [verified]
constexpr uint32_t g_AITEscortGateInvadeUncommittedPriority = 0x00617960;
// __thiscall void (StrategySim* this, int playerId, float* rate) -- the server-side applier for TurnCommands prologue gate @0x0c. Bumps ModCount as its SECOND instruction, `inc [ecx+4]` @0x00821a84. Called from ApplyTurnCommandBatch+0x616 inside the per-player gate loop that opens at 0x0088fdb0 with esi = block+0x38: the gate tested is `[esi-0x2c]` (= block+0x0c) and the arguments are `[esi-0x34]` (= block+0x04, playerId) and `&[esi-0x30]` (= block+0x08, the rate). THIS NAMES ONE OF LANE W2'S FOUR UNRESOLVED HANDLER EIPs, and it is the one that fires most: because StrategyClient::BuildTurnCommands sets that gate UNCONDITIONALLY, it costs one ModCount bump per SUBMITTED BLOCK regardless of what the player did -- 4 of the 10 command bumps on the reference turn [verified]
constexpr uint32_t TurnCommandApply_ResearchRate = 0x00421a80;
// __thiscall void (StrategySim* this, int playerId, void* element) -- the applier for TurnCommands list 5 (system rates: `{i32 systemId, StarSystem::OutputRates}`). Bumps ModCount @0x0086c3e6. Called from ApplyTurnCommandBatch+0x32d, in the block that walks member +0xa0. Names lane W2's trap EIP 0x0086c3e9. On the reference turn exactly one element was applied, from the one AI player with an empire [verified]
constexpr uint32_t TurnCommandApply_SystemRates = 0x0046c3e0;
// __thiscall void (StrategySim* this, int playerId, void* element) -- the applier for TurnCommands list 3 (build orders: `{i32 ordinal, i32 designId, i32 systemId, i32}`). Bumps ModCount @0x0084946b. Called from ApplyTurnCommandBatch+0x6ef, walking member +0x88. Names lane W2's trap EIP 0x0084946e. This is the site that puts the AI's destroyer into the build queue on the reference pair -- the order lane B6 could not find in the pre-turn save because it does not exist yet when that file is written [verified]
constexpr uint32_t TurnCommandApply_BuildOrder = 0x00449460;
// __thiscall void (StrategySim* this, int playerId, void* element, int flag=1) -- the applier for TurnCommands list 10 (`{i32, i32, counted i32}`). Bumps ModCount @0x0088befe. Called from ApplyTurnCommandBatch+0x8ff with a literal 1 pushed as a fourth argument, walking member +0xdc. Names lane W2's trap EIP 0x0088bf01, which W2 recorded only as 'unresolved (near DestroyFleet)'. WHAT list 10 IS is still open: AI3 has AssignFleetsAndIssueOrders reaching lists 14, 8 and 10 together, the element is an id pair plus an id vector, and the turn that produced it also produced a brand-new fleet -- but that is a shape argument, not a name [verified]
constexpr uint32_t TurnCommandApply_List10 = 0x0048bed0;
// site `inc [ecx+4]` with ecx = the StrategySim -- the ModCount bump for TurnCommands list 14, INLINED into ApplyTurnCommandBatch at +0xabc rather than living in a handler. The containing block starts at 0x00890445 with `add ecx,0x10c` (list 14's member offset) and fetches playerId as `[eax-0x108]`. Lane W2 measured this site firing EXACTLY TWICE on a turn in which exactly ONE fleet was moved (one list-8 bump), which CONFIRMS lane AI2's prediction P1: the AI's fleet-order bridge calls ClientOrder_FleetTask twice, mode 0 then mode 1, and the adder's (fleetId, mode) key makes those two distinct elements, where the UI emits only mode 0 [verified]
constexpr uint32_t TurnCommandApply_List14_Inline = 0x0049046c;
// site `inc [ebx+4]` -- the ModCount bump for TurnCommands prologue gate @0x14 (research target), inlined into ApplyTurnCommandBatch at +0x45a. Guarded by `cmp BYTE [edi-0x24],0` @0x0088fdfa with edi = block+0x38, i.e. the gate at block+0x14; playerId from `[eax-0x34]` and the tech id from `[ecx-0x28]` (= block+0x10). NEVER FIRED in either of lane W2's measured windows, which is consistent with the saves: no player's ResTNm changes on turn 2->3 or 3->4. Lane AI4 predicts it fires exactly THREE times on turn 1->2, once for each of the three AI players, all of which go ResTNm '' -> a named tech [mapped]
constexpr uint32_t TurnCommandApply_ResearchTarget_Inline = 0x0048fe0a;
// site `inc [ebx+4]` -- the ModCount bump for TurnCommands prologue gate @0x20 (research boost), inlined into ApplyTurnCommandBatch at +0xe0c. This is a SEPARATE per-player gate loop that runs AFTER all twenty-seven command lists, opening at 0x008907a6 with esi = block+0x20; the payload `[esi-0x8]`/`[esi-0x4]` is block+0x18 (savings spent) and block+0x1c (a fraction), handed to 0x00820560. So the six prologue gates are applied in THREE separate loops at three different points in the batch, not together [mapped]
constexpr uint32_t TurnCommandApply_ResearchBoost_Inline = 0x004907bc;
// __thiscall void (StrategySim* this, int playerId, void* payload) -- the applier for TurnCommands prologue gate @0x2c (a bool at +0x24 and an int at +0x28). Bumps ModCount @0x00821b93. Called from the LAST loop in ApplyTurnCommandBatch, at +0xe6d, with esi = block+0x24 and the gate tested as `[esi+0x8]`. Never observed set in any save; its AI producer is Process Turn phase 14 (0x006c3510 -> client order method 0x00763910), so an AI turn on which phase 14's own gate opens costs one bump nothing in the corpus has ever paid [mapped]
constexpr uint32_t TurnCommandApply_Group4Gate = 0x00421b90;
// __thiscall void (StrategySim* this, int playerId, void* element) -- the applier for TurnCommands list 1 (`StreamableHelper<ShipDesignDef>` frame plus an int). Bumps ModCount @0x0088291a. Called from ApplyTurnCommandBatch+0x652, walking member +0x70. Lane AI4 predicts this is the site that registers the AI's new hull on the reference pair's turn 1 (`Des[18 "Honor Lance"]`, NumDes 5 -> 6) and that it fires exactly once there; if it does NOT fire, an AI design is not a command at all and is carried some other way -- which is the single most useful falsifier in that prediction [mapped]
constexpr uint32_t TurnCommandApply_NewDesign = 0x00482910;
// site `inc [ebx+4]` -- the ModCount bump for TurnCommands list 7 (colonize: `{i32 shipId, i32}`), inlined into ApplyTurnCommandBatch at +0xc18. The containing block opens at 0x008905b5 with `lea ecx,[eax+0xb8]`, which is why an `add reg,imm` scan for the list member offsets misses it; the per-element mutator is 0x00849280 and does not itself bump. Recorded because the AI's colonize orders are the one command family whose producer is currently mis-attributed: Process Turn phase 32 reaches the colonize order method but runs AFTER cl_EndTurn has latched the client, so its orders are refused (see AIProcessTurn_SubmitLatch) [mapped]
constexpr uint32_t TurnCommandApply_Colonize_Inline = 0x004905c8;
// site `mov BYTE [esi+0x15c],1` inside StrategyClient::EndTurn -- the turn-ended latch. It is set BEFORE the calls to BuildTurnCommands (0x00783cd7) and SendEndTurn (0x00783d0d), and EVERY client order method opens with `if (this->+0x15c) return false`. Since cl_EndTurn is Process Turn phase 28 of 34, PHASES 29-33 ARE DEAD: they run, they call their order methods, and every one is refused -- including phase 32 (0x006af790 -> 0x00769640), which would otherwise issue a colonize order. A reimplementation that ports the phase list literally is one command per AI per turn over on ModCount [verified]
constexpr uint32_t AIProcessTurn_SubmitLatch = 0x00383c59;
// site `cmp DWORD PTR [eax+0x5c],1; jne 0x006c5a28` at the head of Process Turn phase 2 (0x006c57e0), where eax = client->+0x150 (the ClientPlayer) and +0x5c is Species. SPECIES 1 IS HIVER, so the whole of phase 2 -- a cl_Chance draw at 0x006c592a and the client order method 0x00762ca0 at 0x006c59b6, which sets TurnCommands prologue gate @0x3c -- is skipped for every other species. Two consequences: gate @0x3c is AI-only AND Hiver-only, which is why no save has ever carried it (the corpus has no Hiver player); and this is a FIFTH independent cross-check on lane AI2's reading of player+0x5c as Species [verified]
constexpr uint32_t AIProcessTurn_Phase2_HiverGate = 0x002c5813;
// __cdecl The `cl_*` facade entry for the research-target order, reaching client order method 0x00763110 at 0x00578faa. It has EXACTLY ONE caller in the whole image: 0x006caf70, which is AI Process Turn phase 18. So this entry point is AI-only surface, exactly as cl_EndTurn is -- extending lane AI1's P4 list, which was drawn from the order methods rather than the facade. The UI reaches the same order methods directly from 0x005d20a0 / 0x005d2140, so the *method* is shared and the *facade entry* is not [verified]
constexpr uint32_t cl_SetResearchTarget = 0x00178f60;
// __thiscall AI Process Turn phase 18 (called at 0x006cfc82). The only caller of cl_SetResearchTarget 0x00578f60, and it also calls cl_SetResearchRate. This is where an AI player's research target is chosen, and therefore where the ResTNm change lane C3 attributes to 'the AI's own orders, generated during the same turn' originates. Its internal gate -- when the AI does and does not pick a target -- was NOT read; the corpus says it opens on turn 1 for all three AI players and stays shut on turns 2 and 3 [mapped]
constexpr uint32_t AIProcessTurn_Phase18_Research = 0x002caf70;
// thiscall void (CombatResolveContext* this) // THE POST-BATTLE RETREAT PIPELINE. Exactly one caller: CombatResolver_Run 0x007d5af0, unconditionally, at 0x007d5be2. Real body 0x007d5a00..0x007d5abb; the only jcc in it is the operator-new null test whose false arm is a _CxxThrowException. It builds a ~0x2c-byte RetreatContext stack local from the resolver's ctx (rc->+0x00 = ctx->+0x00 = S; rc->+0x04 = ctx->+0x08 = enc; rc->+0x08 = ctx->+0x0c = res; a std::map<int,ServerSystem*> at rc->+0x0c with an operator_new(0x18) head node at rc->+0x10 and _Mysize rc->+0x14; a std::vector<RetreatGroup*> at rc->+0x1c/+0x20/+0x24) and runs SIX unconditional this-calls in a straight line: FUN_0079bb90 (per-player destinations), FUN_0079bcd0 (build groups), FUN_007b0320 (whole vs partial), FUN_00790790 (split partial fleets), FUN_007d5650 (execute; EVENT_FLEET_RETREATED_VIA_TELEPORT), FUN_007a7cd0 (destructor). CORRECTS combat-resolver.md's characterisation of this as 'the per-phase combat pipeline': it is ONE subsystem, retreat, not six combat phases. DRAW-FREE: a 327-function closure (E8 calls plus E9 tail-call thunks) contains zero calls to the four RNG primitives and zero inlined MT tempering immediates [verified]
constexpr uint32_t CombatResolve_Retreat = 0x003d5a00;
// thiscall void (RetreatContext* this) // RETREAT PHASE 1. One loop over enc->members (stride 0x44, magic 0x78787879 / sar 5). Per member: FUN_00787210(&enc->+0x1c, enc->+0x0c, member->+0x00 /*ServerPlayer*/, &r1, &r2, &r3), then this->dest[player->PlyrIdx(+0x28)] = the FIRST NON-NULL of (r1, r2, r3) via std::map<int,T*>::operator[] 0x0076bce0. So the per-player retreat destination is: nearest system you own, else nearest system with no hostile presence, else nearest system at all [verified]
@ -1537,6 +1511,8 @@ constexpr uint32_t StrategyServer_DestroyFleet = 0x0048b980;
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]
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]
@ -1769,8 +1745,12 @@ constexpr uint32_t g_LanScanPortRange = 0x00713c70;
constexpr uint32_t g_GameSpyAvailableHostOverride = 0x007085b0;
// data const char* /* when non-NULL, replaces "<gamename>.ms<N>.gamespy.com" in SBServerListConnect */ [mapped]
constexpr uint32_t g_GameSpyMasterHostOverride = 0x00709440;
// thiscall void (ServerSystem* sys) // plain `ret`, REAL END 0x00754b59 -- the body is a loop over group types 0,1,2 whose back edge is at 0x00754b2e (`inc esi; cmp esi,3; jl 0x7543e9`) and lies outside every decompiler `if`, so the function reads as straight-line code if you stop at the first `ret`. Returns immediately when the system has no owner. Per group type: skip when Population::TotalOfType(Pop2, t) <= 0, then skip unless t == 1 -- so only CIVILIANS grow here. Per species: delta = PopGrowthDelta(1, sp); cur = Count(Pop2,1,sp) + Count(pbon2,1,sp); cap = MaxPopGeneric(1, sp, PID, NULL); soft = CivilianSettleLimit(sp); limit = soft < cap ? soft : cap (and soft < cap with cur+delta > soft raises a per-species settle flag); applied = min(delta, limit - cur); haltv[1] with applied > 0 zeroes both. The SYSTEM TOTAL is then clamped into [-50,000,000 (a literal here), POPTYPE[1]+0x08 = 20,000,000] and, when the clamp bit, every entry of the clamp's own sign is rescaled by trunc(applied x (clamped / total)) with NO renormalisation. Write-back is Population::SetCount(Pop2, 1, sp, Count(Pop2,1,sp) + applied) -- note the headroom counted pbon2 and the write does not. THE 20,000,000 CLAMP IS WHAT DECIDES THE VALUE on both reference pairs: the uncapped delta is 7.5x it and the capacity headroom 25x it [verified]
constexpr uint32_t ServerSystem_GrowCivilianPops_G3 = 0x00354220;
// thiscall int64 (ServerSystem* sys, int species) // `ret 4`, ends 0x0074aa2d. Returns 0 without an owner. Otherwise min( MaxPopGeneric(1, sp, PID, &StrategyServer::IdealSuit(server, sp)) , Population::Count(sys->dcs /*+0x104*/, 1, sp) ). The first term is the capacity the colony would have if the planet were perfectly suited; the second is the system's own `dcs` Population, which is on the wire. On Gamma Cephei `dcs` = 1e9 and it is the binding limit, not either capacity [verified]
constexpr uint32_t ServerSystem_CivilianSettleLimit = 0x0034a9a0;
// thiscall int64 (ServerSystem* sys, int groupType, int species, ServerPlayer* p, float* suitOverride) // `ret 0x10`, REAL END 0x0074a6cd -- 0x0074a6d0 is a DIFFERENT function (lane N and E1 both cite 0x0074a6d0 for the capacity-surplus pair; that is the next one along, not this). Returns 0 when species == 4; when the OWNER species' SpeciesDef+0x168[groupType] is not > 0 (which is why Zuul have no civilians); when groupType != 0 and the owner is RebAI; when groupType == 1 and bit 3 of ServerPlayer+0x348[species] is clear; and when groupType == 2 and 0x0082bdf0(p, species) is false. Otherwise ftoi64( (int64)Size x 1e8 x [ hazard x (POPTYPE[t]+0x20 x SpeciesDef(sp)+0x168[t] x (crossSpecies ? SpeciesDef(sp)+0x174[t] : 1)) ] ) via 0x00535eb0, plus the arcology flat bonus 0x0080dd30(p, t), then clamped to the int64 at POPTYPE[t]+0x28 -- see PopTypeTableStaticInit, that clamp is always a no-op -- and finally, for groupType 0 ONLY, scaled by INDSYS_IMPERIAL_POPULATION_MOD when the owner species is 4 [verified]
constexpr uint32_t ServerSystem_MaxPopGeneric_G3 = 0x0034a4a0;
// thiscall float (SpeciesDef* d, int groupType) // `ret 4`, 15 bytes. Returns d->float[0x168 + groupType*4]. The per-species, per-population-group capacity factor -- a DATA FILE value. Its imperial entry for Human is pinned at exactly 1.0 by the corpus (Gamma Cephei's pbon never drains and its Pop never shrinks, which bracket the imperial capacity at Size x 1e8); the civilian entry is bounded below at 0.27 by the observed growth and is otherwise unmeasured [verified]
constexpr uint32_t SpeciesDef_GroupCapacityFactor = 0x0013bb00;
// thiscall float (SpeciesDef* d, int groupType) // `ret 4`, 15 bytes. Returns d->float[0x174 + groupType*4]; applied by MaxPopGeneric only when the system owner's species differs from the population species and is not 4 [verified]
@ -1783,6 +1763,12 @@ constexpr uint32_t PopGrowthSuitabilityDistance = 0x00136eb0;
constexpr uint32_t PopGrowthDeltaHelper = 0x00137140;
// cdecl void () // the CRT static initialiser for the population-type table at 0x00b104e8, and THE ONLY WRITER of the group-ceiling field. It sets every row's +0x28 to 0xffffffff and +0x2c to 0x7fffffff -- the int64 INT64_MAX -- along with +0x00/+0x04/+0x08/+0x0c to -1/1.0/-1/-1 and the float columns to 1.0. InitPopTypeTable 0x00535ca0 runs later and overwrites +0x00 through +0x20 but NEVER touches +0x24/+0x28/+0x2c. So MaxPopGeneric's group-ceiling clamp is present, always enabled, and always a no-op; a reader who opens only InitPopTypeTable sees zero there and would cap every carrying capacity in the game at nothing [verified]
constexpr uint32_t PopTypeTableStaticInit = 0x005abe20;
// thiscall int (StarShip* sh, bool useAllowance) // `ret 4`, REAL END 0x008151b7 (0x008151ba..0x008151bf is int3). max(0, design->+0xcc - (sh->ConCap /*+0x68*/ + (useAllowance ? design->+0xd0 : 0))). No floating point, no clamp but the floor at zero. Its only caller, RepairShipsInOrbit, always passes 1. UNEXERCISED on the corpus, and that is a measurement rather than an absence: the independent colony's fleet sits over Koa'Vo on both reference pairs and that player's Sav closes exactly with the demand taken as zero [verified]
constexpr uint32_t Ship_RepairCost_G3 = 0x00415180;
// thiscall void (StarShip* sh, int points) // `ret 4`, ends 0x00815221. Does NOTHING unless the ship's cached role word (+0x18, not on the wire) carries bit 0x400000 -- a different bit from the one RepairShipsInOrbit's candidate filter tests, so a ship can be charged points that never reach it. Otherwise sh->ConCap += max(points, 0) and then ConCap = min(max(ConCap, 0), design->+0xcc). THIS IS WHAT NAMES THE TWO FIELDS: ConCap is the construction invested in the hull so far -- not a per-turn capacity, despite the save-format name -- and design+0xcc is its ceiling [verified]
constexpr uint32_t Ship_ApplyRepair_G3 = 0x004151c0;
// cdecl-through-register int (ServerPlayer* owner) /* the system arrives in EBX */ // ends 0x0074615a. CORRECTION to output-turn-path.md, which names this SystemRepairDemandForOwner: it is NOT a repair function. It walks the fleets at the system through the system's own vtable slots 2 and 3, skips a fleet whose owner is not the argument or whose +0x78 byte is clear, and sums 0x0081f8c0 over each ship -- and 0x0081f8c0 gates on the design's CARRIED-POPULATION bit (design->+0xb8 & 0x04000000) and computes GroupIncome over the ship's Population at ship+0x9c. So ComputeOutput's out[6] is the income of population carried in slaver/colony hulls in orbit, which is a slot the engine's BudgetInputs already has, and it feeds no save leaf [verified]
constexpr uint32_t ServerSystem_ShipCarriedPopIncome = 0x003460b0;
// thiscall int (StarShip* sh) // returns 0 unless design->+0xb8 & 0x04000000 (the carried-population bit lane B6 named); otherwise sums GroupIncome over the ship's `hsp` Population at ship+0x9c for group types 0 and 1. Body read to the second GroupIncome call only [unverified]
constexpr uint32_t ShipCarriedPopIncomePerShip = 0x0041f8c0;
// data double 0.009999999776482582 = (double)0.01f -- the savings-interest multiplier ComputeBudget uses at 0x008631ce, then truncated by _ftol2. It is a WIDENED FLOAT, not the exact decimal, and the difference is not cosmetic: a treasury of exactly 50,000 earns 499, not 500. That one money was the whole residual on the human's Sav after civilian growth landed [verified]
@ -1889,6 +1875,8 @@ constexpr uint32_t StrategyServer_off_TurnResultsOutbox = 0x00000304;
constexpr uint32_t SETurnResults_ctor = 0x003a7ae0;
// thiscall void (std::vector<SETurnResults>* this, int n) // MISNAMED as DispatchTurnResults: it dispatches nothing. std::vector<Game::SETurnResults>::resize(n) -- shrink to _Erase 0x007c5610, grow to _Reserve 0x007cb340 + _Ufill 0x007c5850. Likewise 0x007c5850 ("SendTurnResultsToPlayers") is _Ufill: per element default-construct a stack temp with 0x007a7ae0, copy-construct into the destination with 0x007c24d0, destroy the temp with 0x0079ac10, dest += 0x11c. The ONLY send of an SETurnResults in the image is SynchronizePlayer 0x007c86d1 (push 0x25) [verified]
constexpr uint32_t vector_SETurnResults_resize = 0x003cd2a0;
// note THE GHIDRA SYMBOL NAMES ON 0x00825bb0 / 0x00825c40 ARE SWAPPED, and so is EventStorage_Read 0x00825cc0 (which is the Write). 0x00825c40 is the WRITE: it calls 0x008b9d50, which invokes stream vtable slot +0x24 and pushes the MEMBER'S VALUE -- identical in shape to the golden Game::ObservedTech::Write 0x00817cf0. 0x00825bb0 is the READ: it calls 0x008b9d20, which invokes slot +0x10 and passes a stack scratch as a DESTINATION. objects/layouts.json and objects/streams.json already have the direction right (write 0x825c40, read 0x825bb0); the Ghidra names and findings/subsystems/events.md repeat the swap. Wire schema of Game::EventStorage::TurnEvents, in order: "EvTurn" by WriteInt (FOUR BYTES ON THE WIRE, default -1) at this+0x04; then "Events" through slot +0x28 as a framed counted array of Game::EventStorage::Event bound via Mars::VectorHelper<Game::EventStorage::Event> (vtable 0x00a2da7c) at this+0x08. sizeof == 0x18 by enumeration four ways: serializer span (0x08+0x10), the SETurnResults default ctor (subobject 0x08..0x1f, next member at +0x20), its copy ctor, and the 0x18 container stride in EventStorage::FindTurnBucket 0x00811f70 [verified]
constexpr uint32_t TurnEvents_serializer_direction = 0x00425c40;
// cdecl void* () // whole 10-byte body: `mov ecx,0x00b29f98; jmp 0x005f6450` and 0x005f6450 is `mov eax,[ecx+4]; ret`, i.e. `return *(void**)0x00b29f9c`. 0x00b29f98 is the SAME global StrategyHost::Autosave takes as its `this`, and +0x4 is the same strat-game pointer it null-checks and hands to SaveGame_WriteFile. GetGame()+0x84 is the game's global handle map. 670 xrefs [verified]
constexpr uint32_t GetGame = 0x00178050;
// thiscall void* (HandleMap* this, uint id) // `if (!id) return 0; slot = id & 0xF; if (slot >= (this->+0xc - this->+0x8)/0x14) return 0; b = this->+0x8 + slot*0x14; lower_bound(b, &it, &id); return it == b->+0x4 ? 0 : *(void**)(it + 0x10);`. A 16-BUCKET stdext::hash_map<uint32 handle, Object*>: vector<Bucket> at +0x8/+0xc/+0x10 with 0x14-byte stride, bucket index = id & 0xF, each bucket a red-black tree whose head is at bucket+0x4 (node layout _Left@0 _Parent@4 _Right@8 key@0xc value@0x10 _Color@0x14 _Isnil@0x15). NOTE the `this` at the call site is &bucketVector, i.e. map+0x84 on the game root, not the map object [verified]
@ -1935,6 +1923,8 @@ constexpr uint32_t PopTypeRow = 0x00135e00;
constexpr uint32_t InitPopTypeTable = 0x00135ca0;
// cdecl int (int groupType, int64 count) // ftol(POPTYPE[groupType].incomeModifier x (count / 14000.0)). The income analogue of GroupOutput; note the divisor is 14000, not the output law's 500000, and there is no 1.8 factor [verified]
constexpr uint32_t GroupIncome = 0x00135e80;
// cdecl SpeciesDef* (int species) // table base 0x00b10a00, stride 0x184, seven rows; species > 6 returns a lazily-constructed default at 0x00b105b0. Fields read by the output chain: +0x4c the base resource demand (an int) and +0x50 the resource output factor (a float). The table is .bss, so both come from the data files [verified]
constexpr uint32_t SpeciesDefTable_Get = 0x00145cc0;
// cdecl double (double x) // x >= 0 ? pow(x, 1/3) : -pow(-x, 1/3), with the exponent taken from the double 0.3333333333333333 at 0x00a3a7c8. Used by StripMineFraction [verified]
constexpr uint32_t SignedCubeRoot = 0x004e5680;
// offset offset float ScOutMod -- the last of the five output multipliers ComputeTotalOutput applies, and the innermost in the x87 chain [verified]
@ -1943,6 +1933,8 @@ constexpr uint32_t ServerPlayer_off_ScOutMod = 0x0000012c;
constexpr uint32_t ServerPlayer_off_SetupOutputMult = 0x00000224;
// data PopTypeRow[3] -- .bss, filled by InitPopTypeTable at startup. Stride 0x30 [verified]
constexpr uint32_t PopTypeTable_base = 0x007104e8;
// data SpeciesDef[7] -- .bss, filled from the data files. Stride 0x184 [verified]
constexpr uint32_t SpeciesDefTable_base = 0x00710a00;
// data float** -- pointer slot; storage 0x00af08f0, which unusually carries a value (0.1f) in the file image rather than being .bss [verified]
constexpr uint32_t GlobalConst_slot_STATION_BONUS_IMPERIAL_OUTPUT = 0x006f08f4;
// data int** -- pointer slot read by MoraleOutputMod; the threshold is an INT compared against the Morale int[7] entry [verified]
@ -2025,12 +2017,6 @@ constexpr uint32_t FlightPlan_Waypoint_Set = 0x003006e0;
constexpr uint32_t NodeRoute_Construct = 0x002e1b20;
// cdecl bool (StarFleet* f) // 90 B. FUN_006fe320(f) returns f->LocID(+0xa0) ONLY when the location's kind tag (+0x14) is 2, a DEEP-SPACE POINT -- never a system. Returns true iff any of the three position components differs by exact IEEE comparison (fucompp, test ah,0x44, jp), no epsilon. OrderFleetMove's opening snap is therefore point-only; combat-retreat-pipeline.md 2.5's 'snaps the fleet's position onto its current system' is corrected here -- a fleet parked at a system is never snapped [verified]
constexpr uint32_t StarFleet_PosDiffersFromPointLocation = 0x0040ec50;
// data double 0xBFC3333340000000 = -0.15000000596046448 = (double)(float)-0.15f -- the elimination-limit divisor, loaded by ServerPlayer::UpdateBankruptcyLimits at 0x00818612 with `DD 05` (fld QWORD). It is the negation of g_DebtInterestRate (0x009ed188), and the two are stored separately. Named here because lane N's fix cited the value but not the address [verified]
constexpr uint32_t g_BankruptcyEliminationDivisor = 0x0062ec30;
// data float* -> 0x00b23e28. The config loader's pointer slot for the key BANKRUPTCY_PROTECTION_LIMIT_FACTOR, the same indirection pattern as ADDICTION_INCOME_MOD's (0x00aeca48 -> 0x00aeca44). Read once in the image, by UpdateBankruptcyLimits at 0x0081866e [verified]
constexpr uint32_t PTR_g_BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 0x006edfdc;
// data FLOAT32 -- and the width is the finding. UpdateBankruptcyLimits multiplies it in at 0x00818674 with `D8 09` = FMUL m32fp, i.e. a DWORD, while the elimination divisor two instructions earlier is `DD 05` = FLD m64fp. So whatever decimal the data file carries for this key is narrowed to a 4-byte float before the multiply, and a `double` multiply truncates to a different integer at every boundary -- for a factor of 3.3 that is EVERY max income divisible by ten, starting at maxIncome = 10 (decimal 33.0 -> 33, image 32.99999952 -> 32). The file image of the slot is zero: the loader fills it at run time, so the value itself is a data-file fact, not a binary one. The corpus cannot see the difference -- all seven of its non-zero BnkPr records invert to a max income where the two agree -- so this is an instruction-stream reading, thin-covered by construction (rule 23) [verified]
constexpr uint32_t g_BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 0x00723e28;
// thiscall void __thiscall Game::TurnCommands::Write(Mars::IStream* s) -- the writer for the `Player.<id>.TurnCommands_v5` custom-data block (CDT id -> one CD frame). 764 bytes, no loops of its own. Two halves. (1) A PROLOGUE of six flag-gated groups, each a WriteBool on a member followed, only when that bool is set, by the group's payload; write order is NOT offset order, which is why the offset-sorted layout view cannot be aligned to the wire: bool@0x0c gates f32@0x08 (research rate); bool@0x14 gates i32@0x10 (research target tech); bool@0x20 gates i32@0x18 + f32@0x1c (research boost spend + fraction); bool@0x2c gates bool@0x24 + i32@0x28; bool@0x3c gates f32@0x30,0x34,0x38; bool@0x6c gates a StreamableHelper<Game::CivilianRatios> frame on the member at 0x40. i32@0x04 (player id) is written first and unconditionally. (2) TWENTY-SEVEN std::list<T> members at 0x70..0x1a8, stride 0x0c ({_Myhead, _Mysize, _Alval}), each passed to its own free-function writer as helper(stream, &list). Every one is written unconditionally, so an empty list still costs one zero int: 8 prologue items + 27 zero counts = the 35-item block every no-orders save carries [verified]
constexpr uint32_t TurnCommands_Write = 0x00442540;
// thiscall void __thiscall Game::TurnCommands::Read(Mars::IStream* s) -- the reader paired with TurnCommands_Write (0x00842540). 1543 bytes; not decompiled by lane Q, listed so the pair is on the record [mapped]
@ -2043,30 +2029,6 @@ constexpr uint32_t TurnCommands_WriteBuildOrderList = 0x00422870;
constexpr uint32_t TurnCommands_WriteSystemRatesList = 0x0042e3d0;
// cdecl void __cdecl (Mars::IStream* s, std::list<T>* orders) -- writer for TurnCommands member +0xb8, the COLONIZE list. Per element two WriteInts (node+0x8 then node+0xc): {shipId, w}. Observed once, in zuul-turn17-orders2.sav: three ships 2512/2544/2624, each with w = 1 [verified]
constexpr uint32_t TurnCommands_WriteColonizeList = 0x00422960;
// thiscall int (Game::SVScriptObject* this, int evt, void* arg) // the script-object event bus. Calls this->vft[0x10](evt, arg) -- the GENERIC handler every object sees -- then `cmp evt,0x20; ja done; jmp dword [evt*4 + SVScriptObject_EventSlotJumpTable]`, which dispatches to ONE event-specific vtable slot with the argument shape that event carries. Every hand-written `vft[0x10](id,0); vft[slot]()` pair in the two turn drivers is this same two-step done on the root object [verified]
constexpr uint32_t SVScriptObject_DispatchEvent = 0x003a60d0;
// data void* [33] // evt (0..0x20) -> the vtable slot SVScriptObject_DispatchEvent calls. Slot byte offsets in evt order: 0x14 0x18 0x1c 0x20 0x24 0x28 0x2c 0x30 0x34 0x38 0x3c 0x40 0x44 0x48 0x4c 0x50 0x54 0x58 0x5c 0x60 0x64 0x6c 0x70 0x74 0x68 0x7c 0x80 0x84 0x78 0x88 0x8c 0x90 0x94. Note 0x15->+0x6c, 0x16->+0x70, 0x17->+0x74, 0x18->+0x68 and 0x1c->+0x78 are NOT in slot order [verified]
constexpr uint32_t SVScriptObject_EventSlotJumpTable = 0x003a6480;
// thiscall int (Game::SVSOSots* this, int evt, void* arg) // Game::SVSOSots vftable 0x00A063C4 slot +0x10, the ONLY slot that class overrides. evt 3 or 0x1b re-runs the new-game seeder 0x005a7d70; evt 0x1a runs 0x005a37e0; then it fans the delivery out to every child in the pointer vector at this+0x1c..0x20 through SVScriptObject_DispatchEvent. The loop re-reads both bounds every iteration, so a callee may resize the child vector under it [verified]
constexpr uint32_t SVSOSots_HandleEvent = 0x001a7e40;
// thiscall int (Game::SVSOSlaversRefuel* this, int evt, void* arg) // generic handler; the class overrides no event-specific slot at all. Body is `if (evt == 0x14) SVSOSlaversRefuel_UpdateDifficultyTier(this)` and nothing else [verified]
constexpr uint32_t SVSOSlaversRefuel_HandleEvent = 0x0011a800;
// thiscall void (Game::SVSOSlaversRefuel* this) // writes CDiff at this+0x38. Builds a 3x3-dword table on the stack -- thresholds 1 / 50 / 100 -- and scans for the FIRST threshold GREATER than StrategyServer+0xc (the frame), then stores index-1 if it differs from the stored value. Frame <= 0 exits at index 0; frame >= 100 runs off the end and stores NOTHING, so the tier can never reach 2. Only on a change does it continue into the per-system pass at 0x005158d4 [verified]
constexpr uint32_t SVSOSlaversRefuel_UpdateDifficultyTier = 0x00115820;
// thiscall void (Game::SVSORefugees* this) // vtable slot +0x60, event 0x13, sent from BeginProcessTurn. `if (!this->ini(+0x14)) { this->ini = 1; obj = <instantiate "Mission" / "_Refugee_Trader" from the data files>; if (obj) this->dids(+0x18).push_back(obj->handle(+0xa0)->id(+4)); }`. The store to the latch is unconditional on the lookup's result [verified]
constexpr uint32_t SVSORefugees_OnTurnBegin = 0x00111260;
// thiscall void (Game::SVSORefugees* this) // vtable slot +0x34, event 8, sent from OnAllCombatDone_Tail phase 8. Walks StrategyServer+0x44 (Systems) and drains the object vector at this+0x28..0x2c, destroying what it resolves. It does NOT write dids -- that is SVSORefugees_OnTurnBegin [verified]
constexpr uint32_t SVSORefugees_OnCombatDone = 0x00111310;
// thiscall void (Game::SVSOSwarmQueen* this) // vtable slot +0x60, event 0x13. Runs SVSOSwarmQueen_RegisterHives, then prunes hives whose system's EggScio (+0x184) no longer equals this->scenarioTag (+0x4), then SVSOSwarmQueen_TickHives and 0x00505100 [verified]
constexpr uint32_t SVSOSwarmQueen_OnTurnBegin = 0x00129930;
// thiscall void (Game::SVSOSwarmQueen* this) // for every system in StrategyServer+0x44 whose EggScio (+0x184) equals this->scenarioTag (+0x4, which the ctor sets to 3 -- the SWARM's tag, not the queen's own EncID 10), and that no hive already references, appends a HiveInfo {vptr 0x009f1a68, sys, nextQ, queen=0} to the vector at this+0x10..0x14, stride 0x10. nextQ = frame + *(int*)[0x00ae0204] + RNG_NextInt (0x004271c0, already in addresses.json; INCLUSIVE of its bound) over (*(int*)[0x00ae0208] - *(int*)[0x00ae0204]) -- ONE strategic-generator draw per new hive, taken inside BeginProcessTurn and therefore OUTSIDE both turn drivers [verified]
constexpr uint32_t SVSOSwarmQueen_RegisterHives = 0x00127630;
// thiscall void (Game::SVSOSwarmQueen* this) // per hive with queen (+0xc) == 0: if any spawn gate fails, `inc [hive+8]` -- the target turn SLIPS FORWARD BY ONE, which is why NextQ reads 31 after turn 1 and 32 after turn 2. Otherwise, if nextQ <= frame, spawn a queen (0x0050dfc0 then 0x004fe810) and append to the Queens vector at this+0x20. Gates read config pointers at 0x00ae0210, 0x00ae0228 and 0x00ae0220 [verified]
constexpr uint32_t SVSOSwarmQueen_TickHives = 0x00127770;
// thiscall void (Game::SVSOSwarmQueen* this) // vtable slot +0x64, event 0x14, sent from OnAllCombatDone_Tail phase 20. The same hive prune as the turn-begin handler, then 0x00521150. It does NOT register hives [verified]
constexpr uint32_t SVSOSwarmQueen_OnTurnEnd = 0x001275d0;
// cdecl Game::SVSOSwarmQueen* () // operator new(0x40); vftable 0x009f49e4; and the two ids that settle who the queen works for: [+0x4] = 3 (the scenario tag it selects systems by) and [+0x8] = 10 (its own EncID) [verified]
constexpr uint32_t SVSOSwarmQueen_Ctor = 0x0011ae20;
// thiscall void (ServerPlayer* this, float dt) // RET 4. The per-player turn driver, called once per player from StrategyServer::ProcessTurn's player loop. THE dt ARGUMENT IS NEVER READ: the whole 1086-byte body contains zero [ebp+N] references (mechanical check over the full instruction decode), so a reimplementation may ignore it. Order: ComputeBudget -> Sav = SatAdd(Sav, net) -> record aid given -> ProcessSpecialProjects -> (ResT ? RollResearchAccident/ProcessResearch) -> research refund -> zero TRM/TRA/TRP -> RebAI decay -> timed-bonus sweep -> ResearchRollPending site -> EVENT_NO_RESEARCH -> PruneRaidTargets [verified]
constexpr uint32_t ServerPlayer_ProcessTurn = 0x00491340;
// site site in ServerPlayer::ProcessTurn: `if (this->ResT(+0x294) != 0) { if (!RollResearchAccident(&budget)) TechTree::ProcessResearch(this->TechTree(+0xf4), rng, &budget.researchAlloc, &overBudget); }`. Argument order read off the push order at 0x00891496-0x008914a5: pushes are (edx=&overBudget), (ecx=&allocVector), (eax=rng), so left-to-right the args are (RNG*, vector*, int*). The RNG is `*(ServerPlayer+8 - 4 + 0x16c)`. `overBudget` is a FRESH STACK LOCAL at [ebp-0x14], NOT Budget+0x64 [verified]
@ -2165,6 +2127,8 @@ constexpr uint32_t StrategyServer_UpdateSensors = 0x0046a8d0;
constexpr uint32_t StrategyServer_StepAIRebellion = 0x00418530;
// thiscall void (StrategyServer* this, std::vector<TeamRecord>* out) // the LAST phase of the turn: per system with combatants present (0x0078cb10), builds the pairwise 0x74-byte team records for fleet pairs whose relation (0x0080e050) is war, into StrategyServer+0x1e4 (S+4 frame). The turn's Status-back-to-playing sweep then walks the records it produced [mapped]
constexpr uint32_t StrategyServer_DetectEncounters = 0x003d7f70;
// data const float = 0.5f. The ONLY consumer is the ResearchRollPending block in ServerPlayer::ProcessTurn: the roll fires when 0.5f < progress/Cost, strictly. Not a registered config key - it is an image literal [verified]
constexpr uint32_t g_flt_ResearchRollProgressThreshold = 0x0062c788;
// data const float = 0.04f, subtracted from RebOutMod each turn for a RebAI player [verified]
constexpr uint32_t g_flt_RebOutModDecay = 0x00617870;
// data const float = 1.0f, the lower clamp of RebOutMod [verified]
@ -2205,6 +2169,10 @@ constexpr uint32_t TechDef_off_Name = 0x00000040;
constexpr uint32_t TechTree_SetResearched_flag_Refresh = 0x00000008;
// site site inside TechTree::ProcessResearch: the tail loop that collects the newly available nodes for EVENT_TECHS_UNLOCKED. Runs only when tree->owner != 0, after the per-node loop AND after the decay sweep. Collects every node n with n != NULL, n->def != NULL, p = tree->nodes[n->def->techId] != NULL, p->state (+0x14) == 2, and n->turnAvailable (+0x20) == the owner's ModCount. Posts once if the collected vector is non-empty. NOTE the asymmetry: the state test is on the SELF-RESOLVED node p, the turn test on the iterated node n [verified]
constexpr uint32_t TechTree_ProcessResearch_TechsUnlockedCollector = 0x00187cc3;
// site site at the very head of ServerPlayer::OnTechResearched: RecordObservedTech is the FIRST statement, called unconditionally on every completion -- before the ResT/roll block and before the !silent event post. It de-duplicates by tech name, so the observed-tech vector grows by one 0x2c element per completion of a tech not already observed and by nothing otherwise [verified]
constexpr uint32_t ServerPlayer_OnTechResearched_RecordObservedTech = 0x00491790;
// site site in ServerPlayer::OnTechResearched, second statement: `if (this->ResT(+0x294) == def) { if (this->ResearchRollPending(+0x3b4)) RollResearchEvent(this); this->ResearchRollPending = 0; this->ResT = 0; }`. RollResearchEvent (0x0088df20) draws ONE NextFloat unconditionally and then enters ServerPlayer_OnResearchRollSucceeded (0x00889d60) only when roll < ResearchEventOdds -- the odds are 0 for every tech outside the plague and AI-rebellion families, so that branch is normally dead. CORRECTED BY LANE K 2026-09-08: that one word is the cost of REACHING the branch, not of a fired roll -- the plague path draws a SECOND word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, the rebellion path cancels the research. A fired roll costs one or two words. This is the extra RNG a completion consumes, and clearing ResT means a second completion in the same pass consumes none [verified]
constexpr uint32_t ServerPlayer_OnTechResearched_ResearchRollBlock = 0x00491790;
// member Game::ServerSpyManager* StrategyServer::SpyManager, in lane T's S+4 frame (absolute StrategyServer+0x15c). StrategyServer ctor 0x007d78d0: `call 0x00832a30` (the ServerSpyManager ctor, identified by its store of vftable 0x00a3073c) then `mov [esi+0x15c],eax` at 0x007d7d8e. Corroborated independently by 0x007dcf90, which the RTTI inverse map shows is Game::StrategyServer vftable 0x00a26034 slot 14 at sub-object +4: it calls the same two ctors and stores at [esi+0x154] and [esi+0x158], exactly 4 lower than the base-frame 0x158/0x15c, as a +4 `this` requires. Sits immediately after StrategyServer_off_TradeManager (lane T, 0x154 in the same frame) [verified]
constexpr uint32_t StrategyServer_off_SpyManager = 0x00000158;
// data void* Game::ServerSpyManager::vftable[18] // sub-object +0, COL 0x00a87ed4, bases Game::IServerSpyManager / Game::ISpyManager / Mars::IStreamable. Unlike the trade manager there is no *Impl: ServerSpyManager is itself concrete (no purecall slots) and has no derived class. A second vftable 0x00a30728 sits at sub-object +4 with 3 slots [verified]
@ -2253,6 +2221,8 @@ constexpr uint32_t SVSOSots_vftable = 0x006063c4;
constexpr uint32_t SVSOIndependentSystems_vftable = 0x00620314;
// thiscall void (void*, Mars::IStream*) // `C2 04 00` -- a bare RET 4 shared as the inherited Read/Write for classes that serialize nothing [verified]
constexpr uint32_t Streamable_NoOpStub = 0x001f8ac0;
// thiscall void (Game::SVSOCrowDefenders* this, Mars::IStream* s) // sys; ndsys count then a loop writing dsys; ndes count then a loop writing des; drad. NOTE: `dsys` is INSIDE the ndsys loop -- objects/layouts.json records it as a plain member, which is wrong, and no save can settle it because both counts are 0 everywhere [verified]
constexpr uint32_t SVSOCrowDefenders_Write = 0x000f8c90;
// thiscall void (Game::SVSOMonitor* this, Mars::IStream* s) // calls SVSODerelict::Write (0x004fc2b0) as its first act -- Monitor derives from Derelict, which is why its tag run starts NDsn/DsnID/Dwght + NAsg/Eflt/Esys before nt/scnm/spwt/rsmd/dsgn [verified]
constexpr uint32_t SVSOMonitor_Write = 0x000fd810;
// thiscall void (Game::SVSODerelict* this, Mars::IStream* s) // NDsn count then a loop of (DsnID, Dwght); NAsg count then a loop of (Eflt, Esys). Two fields per iteration in each, confirmed by the 8-byte element strides [verified]
@ -2277,78 +2247,6 @@ constexpr uint32_t StrategySim_ModCountBump_unresolved_00821a84 = 0x00421a84;
constexpr uint32_t StrategySim_ModCountBump_unresolved_0084946b = 0x0044946b;
// site A ModCount bump observed live once per turn; trap EIP 0x0088bf01, nearest known symbol StrategyServer_DestroyFleet 0x0088b980 at +0x581 -- plausibly inside it, but unproven. Return address 0x008902b4 (StrategySim::ApplyTurnCommandBatch), so it is a command handler called from the batch applier rather than an inlined site [mapped]
constexpr uint32_t StrategySim_ModCountBump_unresolved_0088befe = 0x0048befe;
// offset int AFlags -- the per-player 'has something here' mask, bit = PlyrIdx. Confirmed from ServerSystem::Write 0x00749630 at 0x00749aec (`mov ecx,[edi+0xcc]` with the "AFlags" tag at 0xa1f3b0; Write runs on the IStreamable subobject at +0x8, so 0xcc + 8 = 0xd4). Lane E3's +0xd4 is right [verified]
constexpr uint32_t ServerSystem_off_AFlags = 0x000000d4;
// offset int SnLv -- 2 bits per player (level = 0 none / 1 partial / 3 full), NOT SERIALISED. Written once per turn in the sensor phase at 0x0086a789 as `s->SnLv = (s->SnLv & ~(3<<2i)) | (level<<2i)`; read by ServerSystem::IsKnownTo 0x00746390 and RefreshVisibleOwnerIfKnown 0x0075bd70, and shipped to clients by SynchronizePlayer at 0x007c76a4. Because it is absent from every save, any NVO/TShn claim that depends on it is a hypothesis on the corpus alone (rule 6) [verified]
constexpr uint32_t ServerSystem_off_SnLv = 0x00000024;
// offset std::map<int PlyrIdx, OwnRec>::_Myhead for NVO (the visible-owner record). The map object starts at +0x270 and is 0x10 bytes: allocator +0x0, _Myhead +0x4, _Mysize +0x8, comparator +0xc. From ServerSystem::Write 0x0074a097/0x0074a195 (`[edi+0x26c]`, +8 for the IStreamable subobject) and from the live arming probe, which validated +0x274 on 9 of 28 systems and +0x26c on 0 [verified]
constexpr uint32_t ServerSystem_off_NVO_Myhead = 0x00000274;
// offset std::map _Mysize for NVO. Write 0x0074a07d (`[edi+0x270]`) carries the "NVO" tag 0xa1f2c0, so the wire's NVO count is this word. CORRECTS objects/layouts.md, which lists NVO@0x278 as an int field and PID@0x274 as a handle: +0x274 is _Myhead and the "PID" tag belongs to the node KEY, not to the object [verified]
constexpr uint32_t ServerSystem_off_NVO_Mysize = 0x00000278;
// offset std::map _Myhead for NVE (the sighting record). Write 0x0074a1bb. Confirms lane E3's +0x284/+0x288 [verified]
constexpr uint32_t ServerSystem_off_NVE_Myhead = 0x00000284;
// offset std::map _Mysize for NVE. Write 0x0074a1a1, tag "NVE" [verified]
constexpr uint32_t ServerSystem_off_NVE_Mysize = 0x00000288;
// offset std::map _Myhead for NVs (the per-player PlayerView record). Write 0x0074a27d [verified]
constexpr uint32_t ServerSystem_off_NVs_Myhead = 0x00000294;
// offset std::map _Mysize for NVs. Write 0x0074a263, tag "NVs" [verified]
constexpr uint32_t ServerSystem_off_NVs_Mysize = 0x00000298;
// offset int16 TShn, offset within the NVO map NODE (not the value). Node is _Left +0x0, _Parent +0x4, _Right +0x8, key(PlyrIdx) +0xc, value +0x10 = {int16 touched +0x10, int16 TShn +0x12, int32 OID +0x14, bool isind +0x18, IndependenceInfo indi +0x1c}, _Color +0x8c, _Isnil +0x8d -- so an NVO node is ~0x8e bytes, unlike NVE's 0x20. From Write 0x0074a0ca (`lea ecx,[ebx+0x12]`, tag "TShn") and confirmed live: a 4-byte watchpoint on value+0x0 trapped both halves separately [verified]
constexpr uint32_t ServerSystem_NVO_node_off_TShn = 0x00000012;
// thiscall OwnRec* (NVO_map* this /* ServerSystem+0x270 */, const int* plyrIdx) // std::map::operator[]; returns node+0x10. Exactly ONE caller in the image: ServerSystem::SetVisibleOwner 0x0075b94b (NVE's operator[] 0x00752730 has three). That single-caller fact is what makes the writer inventory below complete [verified]
constexpr uint32_t ServerSystem_NVO_operator_index = 0x0075a890;
// thiscall void (ServerSystem* this, ServerPlayer* p, const OwnRec* src) // ret 8. Stamps value.touched = (int16)this->owner(+0x10)->Frame(+0x8) and copies src's TShn/OID/isind/indi verbatim into NVO[p->PlyrIdx]. THE ONLY WRITER OF NVO.TShn IN THE IMAGE. Local gate (converge point 0x0075b983, the inlined IndependenceInfo dtor, so it is a real branch and not a rule-4 artifact): skip the whole write when src->OID == p->id but this->PID(+0x100)->id != p->id, logging "VisibleOwner: %s sees their self as owner of %s, but they are not." [verified]
constexpr uint32_t ServerSystem_SetVisibleOwner = 0x0075b880;
// offset site `mov WORD PTR [eax+0x2],dx` -- the TShn store. Trapped live by a DR watchpoint on Spica's NVO root node: EIP 0x0075b965 (the trap is taken after the store retires), twice per End Turn. The touched-word store is the instruction before it at 0x0075b957 (trap EIP 0x0075b95a) [verified]
constexpr uint32_t ServerSystem_SetVisibleOwner_TShnStore = 0x0075b961;
// thiscall void (ServerSystem* this, ServerPlayer* p) // ret 4. Builds an OwnRec with TShn = (int16)server->Frame, OID = this->PID(+0x100)->id, isind/indi from this->indi(+0x1c8), then calls SetVisibleOwner at 0x0075bd45. THIS is where TShn = Frame is decided. Confirmed live: the trapped frame's [ebp+4] was 0x0075bd4a [verified]
constexpr uint32_t ServerSystem_RecordVisibleOwner = 0x0075bca0;
// thiscall void (ServerSystem* this, ServerPlayer* p) // TShn = Frame, OID = 0 -- 'you now see this system as unowned'. Frame read at 0x0075bc5f [mapped]
constexpr uint32_t ServerSystem_RecordVisibleOwnerNone = 0x0075bc00;
// thiscall void (ServerSystem* this, ServerPlayer* from, ServerPlayer* to) // the NVO analogue of ShareObservation 0x00754d90: passes the source player's record verbatim, so TShn is NOT refreshed. No corpus save has an alliance, so this path has never executed (rule 6) [mapped]
constexpr uint32_t ServerSystem_ShareVisibleOwner = 0x0075b9b0;
// thiscall OwnRec* (ServerSystem* this, ServerPlayer* p) // map find on NVO (0x0074d2f2 `lea ecx,[esi+0x270]`, 0x0074d300 `cmp edx,[esi+0x274]`); returns node+0x10 or NULL. Every caller only READS the record [verified]
constexpr uint32_t ServerSystem_FindVisibleOwner = 0x0074d2e0;
// thiscall int (ServerSystem* this, ServerPlayer* p) // FindVisibleOwner then rec->OID [mapped]
constexpr uint32_t ServerSystem_GetVisibleOwnerId = 0x0074f710;
// thiscall bool (ServerSystem* this, ServerPlayer* p) // THE NVO GATE, and it is looser than IsVisibleTo 0x00743fb0. Whole body: `p && ( (this->AFlags(+0xd4) >> p->PlyrIdx(+0x28)) & 1 || ( p->PlyrIdx < 15 && ((this->SnLv(+0x24) >> 2*p->PlyrIdx) & 3) != 0 && TechTree::HasResearched(p->TechTree(+0xf4), 10000 /* CCC_AdvSens */) ) )`. Byte-decoded at 0x007463ae / 0x007463c0 / 0x007463cb / 0x007463da [verified]
constexpr uint32_t ServerSystem_IsKnownTo = 0x00746390;
// thiscall bool (ServerSystem* this, ServerPlayer* p) // ret 4. IsKnownTo inlined byte-for-byte (0x0075bd88 AFlags, 0x0075bd93 idx<15, 0x0075bd9e SnLv, 0x0075bdb2 HasResearched(0x2710)), then RecordVisibleOwner at 0x0075bdbe. Confirmed live: the trapped frame's [[ebp]+4] was 0x0075bdc3, the return of that call [verified]
constexpr uint32_t ServerSystem_RefreshVisibleOwnerIfKnown = 0x0075bd70;
// fastcall void (StrategyServer_S4* server) // for p in players(+0x50..+0x54) for s in systems(+0x40..+0x44): s->RefreshVisibleOwnerIfKnown(p) at 0x00813737. The player loop is UNFILTERED -- no NPC/species test anywhere on this path. Called from StrategyServer::UpdateSensors 0x0086a8d0 at 0x0086a91d [verified]
constexpr uint32_t SensorPhase_RefreshAllVisibleOwners = 0x00813700;
// thiscall int (Sensors* this, ServerPlayer* p, Entity* e) // 3 if IsVisibleTo (AFlags), else 1 if inside p's sensor envelope, else 0 (0x00850d18 / 0x00850e1b). Its result is packed into ServerSystem::SnLv(+0x24) at 0x0086a789 [mapped]
constexpr uint32_t SensorPhase_ComputeContactLevel = 0x00850cf0;
// thiscall void (ServerSystem* this, ServerPlayer* oldOwner, ServerPlayer* newOwner) // Game::ServerSystem vftable 0x00a2044c slot 6; records a visible-owner entry for both parties at 0x0075be77 / 0x0075bf2b [mapped]
constexpr uint32_t ServerSystem_ChangeOwner = 0x0075bde0;
// thiscall bool (StrategyServer_S4* this, int playerId, const float* rate) // ret 8. Bumps ModCount at 0x00821a84 then sets player->ResRate(+0xbc) = clamp(*rate, 0.0f, 1.0f), logging "OnCommand: Research rate %f constrained to %f for %s." when it clamps. NAMES lane W2's `StrategySim_ModCountBump_unresolved_00821a84`, the handler that fires 4x per End Turn on ref-turn2 -- once per player that has a client [verified]
constexpr uint32_t StrategyServer_OnCommand_SetResearchRate = 0x00821a80;
// thiscall bool (StrategyServer_S4* this, int playerId, const SysRatesCmd* cmd) // ret 8. Bumps ModCount at 0x0086c3e6. Strings "OnCommand: Unable to set system rates for %d(id). System not found." (0xa32448) and "OnCommand: Player setting system rates for system he does not own." (0xa32400). NAMES lane W2's `..._unresolved_0086c3e6` [verified]
constexpr uint32_t StrategyServer_OnCommand_SetSystemRates = 0x0086c3e0;
// thiscall int (StrategyServer_S4* this, int playerId, const TransferCmd* cmd, ...) // ret 0xc. Bumps ModCount at 0x0088befe. Strings "OnCommand: Cannot transfer ships. Player %d(id) not found." (0xa33248) and "TransferShips: Fleet flagged as empty, but still has ships.". NAMES lane W2's `..._unresolved_0088befe` [verified]
constexpr uint32_t StrategyServer_OnCommand_TransferShips = 0x0088bed0;
// thiscall bool (StrategyServer_S4* this, int requesterId, const AddOrderCmd* cmd, int* outReason) // ret 0xc. Bumps ModCount at 0x0084946b. cmd = {int which, id target, id design, int arg}; picks target->+0xa4 (which==0) or +0x98 (which==1) as the BuildQueue, validates with 0x0083ce50, then BuildQueue::AddOrder 0x00845260 push_backs onto the list at queue+0x10. Reached from the Build Screen 0x00656540 via 0x00762fd0, and from the command replay at 0x0089009f. NAMES lane W2's `..._unresolved_0084946b` [mapped]
constexpr uint32_t StrategyServer_OnCommand_AddBuildOrder = 0x00849460;
// offset site `inc DWORD PTR [ebx+0x4]` -- a SECOND ModCount bump INLINED into ApplyTurnCommandBatch 0x0088f9b0 (at +0x45a), distinct from the one lane W2 found at 0x0089046c. String "OnCommand: Cannot set research project for player %d(id). Player not found." (0xa30ed0). Fires 3x on turn1-state and 0x on turn2-state -- once per AI player that picks a research target on turn 1; the human player set a rate but no project, which is why the reference game's map banner reads "No Research Project Assigned" [verified]
constexpr uint32_t StrategyServer_OnCommand_SetResearchProject = 0x0088fe0a;
// thiscall bool (StrategyServer_S4* this, int playerId, const DesignCmd* cmd) // ret 8. Bumps ModCount at 0x0088291a. Strings "OnCommand: Cannot create design for player %d(id), player does not exist." (0xa32c40) and "OnCommand: Unable to create design. ID not specified." (0xa32c04). Fires 1x on turn1-state, 0x on turn2-state. ANSWERS LANE AI4'S FALSIFIER: command list 1 emits a design COMMAND, not merely a design object that the build order names [verified]
constexpr uint32_t StrategyServer_OnCommand_CreateDesign = 0x00882910;
// thiscall void (BuildQueue* this, int arg, ShipDesign* design) // builds an order record with five fields pre-set to -1 and push_backs it onto the std::list at this+0x10/+0x14 ("list<T> too long" guard at 0x008452e4) [mapped]
constexpr uint32_t BuildQueue_AddOrder = 0x00845260;
// cdecl bool (ServerPlayer* who, BuildQueue* q, ShipDesign* d, int* outReason) // the 'can this be built' validator; writes a numbered reason (0,1,2,8,...) through arg 4 and calls BankruptcyLevel 0x0080db10 [mapped]
constexpr uint32_t StrategyServer_CanAddBuildOrder = 0x0083ce50;
// offset bool ReqCL -- 'this player requires a client'. THE PREDICATE BEHIND Player.Status: a player with ReqCL == 0 never reaches SetupAIPlayers' AI list (gate at 0x007bc25c), so never gets a StrategyClient, so is never the subject of MarkPlayerTurnEnded and keeps the 0 that ResumePlaying wrote. The script binding that creates the four '_NPC' pseudo-players clears it explicitly at 0x00797bcb [verified]
constexpr uint32_t ServerPlayer_off_ReqCL = 0x000000fd;
// thiscall bool (StrategyNetworkServer* this) // vftable slot 3. The game's OWN statement of which players must carry Status == 4: `for p in players: if (!p->ReqCL(+0xfd)) continue; if (p->Elim(+0xf8)) continue; if (p->Status(+0x164) != 4) return false;` -- loop 0x007cffb7..0x007d0002 with edi = 4 loaded at 0x007cffa3 [verified]
constexpr uint32_t StrategyNetworkServer_AllPlayersTurnEnded = 0x007cfd00;
// thiscall void (StrategyNetworkServer* this) // the ReqCL gate at 0x007bc25c decides which players get an AI client at all; AddAIPlayer 0x007bc333 -> 0x007b8ab0 [verified]
constexpr uint32_t StrategyNetworkServer_SetupAIPlayers = 0x007bc1c0;
// thiscall void (StrategyServer* this, const SNMPlayerEndTurn* msg) // ONE call per network message, not a loop: handle = msg->+0x4 straight off the wire, MarkPlayerTurnEnded(this+4, handle) at 0x007d9b25 with NO gate before the store. Reached from StrategyNetworkClient::OnMessage 0x00784816. The field tests at 0x007d9b5d/0x007d9ba2/0x007d9bb0 all follow the store and only decide whether to raise a 'waiting on X' event [verified]
constexpr uint32_t StrategyServer_OnPlayerEndTurn = 0x007d9af0;
// offset site `mov DWORD PTR [eax+0x164],1` -- the phase-31 Status write lane T2 located. Its gate, decoded at 0x007dcc78, is `if (p->IsAI(+0xf9) == 0 || p->+0xfa != 0)`, over the encounter records at S+0x1e8 (0x74-byte outer, 0x44-byte inner with the ServerPlayer* first). It reads NO species field [verified]
constexpr uint32_t StrategyServer_ProcessTurn_StatusOneStore = 0x007dcc8a;
// offset vftable for Game::SNMSetPlayerStatus (factory 0x0080f990, sizeof 0xc: vptr / +4 player INDEX / +8 status). Its handler is the OnMessage+0xa15 store lane W2 found: no gate, no field test -- the value comes verbatim off the wire. The sender is StrategyNetworkServer::Update 0x007cda40 at 0x007cdcf0, which computes `2*(flag!=0) + 2`, i.e. 4 when the player has nothing pending and 2 when it does [verified]
constexpr uint32_t Game_SNMSetPlayerStatus_vftable = 0x00a2d718;
// thiscall void (StrategyServer* this /*base S*/) // phase 11 of OnAllCombatDone_Tail, called at 0x007d9714 as `mov ecx,esi; call`. 1117 B, three loops. LOOP 1 (0x007ae07a..0x007ae1e2) walks the 0x30-stride Game::NodePath records in the vector at (*(S+0x154))+0x8/+0xc, re-reading _Myfirst/_Mylast every iteration, and per record: (1) NodePath::RemainingLife(r, S->Frame) 0x006e2130, `test eax,eax; jg` -> not expired, NEXT RECORD, NO DRAW; (2) THE DRAW, `mov ecx,[esi+0x16c]; fld [0x009e2ea0] /*0.5f*/; call 0x008e6dd0` = Mars::RNG::Chance(0.5f), exactly one MT word; (3) `test al,al; je` -> roll failed, next record; (4) the 0x20000-fleet scan over S->Fleets (S+0x64/+0x68) calling 0x00703500(fleet,0x20000,0) then 0x0078c360(fleet,npid) and dropping the record when that returns 3; (5) push_back npid into a scratch vector<int>. LOOP 2 collapses each collected line via 0x007a92e0(690 B) then 0x007a4700(2244 B); LOOP 3 posts the decay-stage events through NodePath::DecayStage 0x006e21b0. THE ONLY RNG SITE IN THE WHOLE 1117 BYTES: direct-call sweep to depth 5 over 140 functions from 0x007ae010 against {NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0, Twist 0x00426e00, Seed 0x0049fdf0} yields exactly one hit, 0x007ae010 -> 0x008e6dd0. Neither downstream function draws (138 and 49 functions reached, zero hits) -- caveat: direct calls only, their subtrees contain unresolved indirect sites [verified]
constexpr uint32_t StrategyServer_NodeLineDecay = 0x003ae010;
// site site, and a CORRECTION to findings/control-flow/combat-done-tail.md §3, which says "the roll is skipped for a line if any fleet with flag 0x20000 is targeting it". IT IS NOT: the fleet scan begins HERE, at 0x007ae0b2, which is 0x1d bytes AFTER the Chance(0.5f) call at 0x007ae0a5 and is reached only when the roll SUCCEEDED (`test al,al; je 0x007ae1e2` at 0x007ae0aa). The scan therefore cannot change the draw count -- it suppresses only the collapse (the 0x007a92e0 / 0x007a4700 pair), never the draw. The straight-line order in loop 1 is: expiry test -> DRAW -> roll gate -> fleet gate -> collect. Lane K's headline claim, one NextFloat per expired node line per turn, survives intact and is now pinned to a concrete expiry formula (NodePath_RemainingLife) [verified]
@ -2369,6 +2267,8 @@ constexpr uint32_t ServerNodeGraph_FindPathById = 0x002e23d0;
constexpr uint32_t StarFleet_HasFlagShips = 0x00303500;
// cdecl int (StarFleet* fleet, int npid) // 234 B. Returns 3 exactly when the fleet's FRONT waypoint (the deque at fleet+0xc4, element +0x10) names this npid -- i.e. the fleet is currently riding this line; 0/1/2/4 otherwise. Node-line decay drops a rolled line when any 0x20000-flagged fleet returns 3 for it [verified]
constexpr uint32_t StarFleet_PathRelation = 0x0038c360;
// note NAME CORRECTION, from StrategyServer::Write's own wire tags. At 0x0079fb2f `lea edx,[edi+0x08]; push "ModCount"` and at 0x0079fb40 `lea eax,[edi+0x0c]; push "Frame"`, with edi = S (the same edi that indexes the players vector at +0x54). So in the S frame **S+0x8 is ModCount and S+0xc is Frame**, i.e. in the stored (S+4) frame +0x4 is ModCount and +0x8 is Frame. `StrategyServer_off_ModCount = 0x8` therefore carries the WRONG NAME: that word is Frame, the turn number. The word it names is the one lane T recorded as StrategyServer_off_PhaseCounter = 0x4 and lane K called 'never named' -- it has a name, and it is ModCount. CONFIRMED FROM THE SAVES, which is an independent instrument: Frame reads 1/2/3 on turn1/2/3-state, 16 on zuul-turn16, 23 on zuul-turn23, while ModCount reads 0/12/24/241/412. And CONFIRMED LIVE: lane Z measured S+0x8 advancing 12, 14, 12 per turn on the early Human game (the saves say +12/turn) and 16, 21, 44 on the Zuul one (the saves say ~24/turn average). A modification counter is exactly what those numbers look like, and it explains why only 2 of the 12-44 increments come from the two turn drivers. Integrator: reconcile StrategyServer_off_ModCount / StrategyServer_off_PhaseCounter rather than adding a third name [verified]
constexpr uint32_t StrategyServer_wire_ModCount_vs_Frame = 0x0039fb2f;
// thiscall void (void* rawBase /* = S+4 */) // the StrategyServer base-class ctor, called from StrategyServer::StrategyServer 0x007d78d0 at 0x007d7905 as `lea ecx,[esi+0x4]`. It zero-initialises FOUR CONSECUTIVE std::vectors as three-word triples with the fourth word skipped: raw +0x40/+0x44/+0x48, +0x50/+0x54/+0x58, +0x60/+0x64/+0x68, +0x70/+0x74/+0x78, then `lea ecx,[esi+0x80]` for the entity hash. That is the campaign's `{_Myfirst,_Mylast,_Myend,_Alval}` = 0x10 allocator-last shape (method rule 5) enumerated four times in a row, and it independently pins StrategyServer_off_Players = 0x50 and _off_Fleets = 0x60 in the raw frame WITHOUT any frame arithmetic -- the ctor is entered with ecx = S+4, so the players triple is literally {S+0x54, S+0x58, S+0x5c}. This is the enumeration that closes the 0x60-vs-0x64 question the campaign paid for once [verified]
constexpr uint32_t StrategyServer_ctor_VectorBlock = 0x0045b120;
// thiscall ServerPlayer* (StrategyServer* this /*S frame*/) // five sibling accessors at 0x00788de0, 0x00788e10, 0x00788e40, 0x00788e70, 0x00788ea0, one per NPC pseudo-player index word at S+0x1b8/0x1bc/0x1c0/0x1c4/0x1c8 (the five words the ctor sets to -1 at 0x007d79fe..0x007d7a16, and the save's NPCm/NPCo/NPCi/NPCv/NPCa). Each is `idx = this->+0x1b8; if (idx < 0) return 0; first = [this+0x54]; last = [this+0x58]; if (idx >= (last-first)>>2) return 0; return first[idx];` -- a bounds check against the players vector's size followed by an index off _Myfirst, which is a third independent confirmation that S+0x54/S+0x58 are _Myfirst/_Mylast. THE PLAYER VECTOR IS NOT THE LOBBY'S PLAYER LIST: it is #empires + one rebel-AI per distinct empire species + 4 NPC pseudo-players (Alien Menace, Peacekeeper Enforcer, Von Neumann, Independent Colony, all Species 4). Hence NumPlrs 8 on the Human saves (two species) and 7 on the Zuul ones (one species), against a lobby that says '2 Players' in both -- Summary.Players counts EMPIRE SLOTS and is also right [verified]

View file

@ -12,7 +12,6 @@ add_library(sots_app STATIC
construction_phase.cpp
event_phase.cpp
growth_phase.cpp
script_phase.cpp
visibility_phase.cpp
turn.cpp
report.cpp)

View file

@ -44,22 +44,13 @@ constexpr PhaseDesc kHost[] = {
{Driver::Host, 0, "H00", "BeginProcessTurn", PhaseStatus::Implemented,
"advances the frame counter, which is the turn number the whole game displays; runs "
"before the spine and is where the 'Begin processing turn N' line comes from"},
{Driver::Host, 1, "H03", "ScriptHookTurnBegin", PhaseStatus::Partial,
"the turn's FIRST script-object event delivery, sent from inside BeginProcessTurn right "
"after the frame counter. Two of the twelve script objects our saves carry react to it. "
"The refugees' one-shot latch IS modelled and committed; the design instantiation behind "
"the same latch is not, because nothing here allocates handles. The swarm queen's hive "
"registration, prune and per-turn slip ARE modelled -- which system each hive sits on and "
"that it has no queen are exact -- but the hive's target turn is drawn from the strategic "
"generator here, outside both turn drivers, and that draw is not placed. See "
"docs/SV-script-objects.md"},
{Driver::Host, 2, "H02", "StampTreatyTurns", PhaseStatus::Implemented,
{Driver::Host, 1, "H02", "StampTreatyTurns", PhaseStatus::Implemented,
"the diplomacy ledger's 'this treaty was last in force on turn N' stamp, over every "
"ORDERED pair of players that holds one, creating the entry on demand. Runs in the "
"command-application step -- after the frame counter has advanced and before either "
"turn driver -- so it stamps the NEW turn. It is the only writer of these fields on a "
"turn with no combat and no diplomatic command"},
{Driver::Host, 3, "H01", "SaveWriterInvariants", PhaseStatus::Implemented,
{Driver::Host, 2, "H01", "SaveWriterInvariants", PhaseStatus::Implemented,
"the summary's turn number is the simulation's frame counter -- an identity that holds "
"across every save in the corpus and belongs to the writer, not to a turn phase"},
};
@ -246,10 +237,7 @@ constexpr PhaseDesc kTail[] = {
{Driver::Tail, 6, "T06", "ApplyEncounterResults", PhaseStatus::Stub,
"DRAWS RNG -- node-cannon and salvage paths; the word count is combat-dependent"},
{Driver::Tail, 7, "T07", "ClearEncounters", PhaseStatus::Stub, ""},
{Driver::Tail, 8, "T08", "ScriptHookCombatDone", PhaseStatus::Stub,
"script-object event 8. Five of the twelve classes our saves carry override it -- von "
"Neumann, refugees, traps, crow defenders, independent systems -- and NONE of them moves "
"a leaf that diverges on either reference pair, so this phase is listed and not run"},
{Driver::Tail, 8, "T08", "ScriptHookCombatDone", PhaseStatus::Stub, ""},
{Driver::Tail, 9, "T09", "AdvanceAIRebellionPostCombat", PhaseStatus::Stub, ""},
{Driver::Tail, 10, "T10", "NodeSpaceTravelSecondPass", PhaseStatus::Stub,
"node-space travel runs a SECOND time this turn"},
@ -269,13 +257,7 @@ constexpr PhaseDesc kTail[] = {
"pairs, so nothing here has evidence to build it against"},
{Driver::Tail, 18, "T18", "PostFleetWarnings", PhaseStatus::Stub, ""},
{Driver::Tail, 19, "T19", "DrainInfraTerraformQueue", PhaseStatus::Stub, ""},
{Driver::Tail, 20, "T20", "ScriptHooksTurnEnd", PhaseStatus::Partial,
"two script-object event deliveries, 0x14 then 0x15. The slavers-refuel difficulty tier "
"IS modelled and committed: a three-record table scanned against the frame, boundaries at "
"1/50/100, written only when it changes -- and at frame 100 and above the scan runs off "
"the end and writes nothing, so the tier can never reach 2. The per-system pass the "
"original enters after a tier change is not modelled. Event 0x15 is overridden by no "
"class in any save we hold"},
{Driver::Tail, 20, "T20", "ScriptHooksTurnEnd", PhaseStatus::Stub, ""},
{Driver::Tail, 21, "T21", "UpdateSurveyAndSystemStats", PhaseStatus::Partial,
"the explored sweep IS modelled and committed: every player who can currently see a "
"system has now surveyed it. The event this owes per newly-surveyed pair is not "

View file

@ -1,213 +0,0 @@
#include "app/script_phase.h"
#include <cstdarg>
#include <cstdio>
#include "game/sim/scriptobjects.h"
namespace sots::app {
namespace {
using mars::stream::shapes::EncounterObject;
using mars::stream::shapes::SaveGame;
using mars::stream::shapes::ScriptObjects;
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);
}
// The encounter body for one id, or null. Ids the save does not carry are normal: the
// factory has 23 slots and no game instantiates them all.
EncounterObject* Encounter(SaveGame& game, std::int32_t encID) {
if (!game.sim.svSctOb) return nullptr;
ScriptObjects& root = *game.sim.svSctOb;
for (auto& e : root.encounters)
if (e.id == encID) return &e.obj;
return nullptr;
}
// Every system's id and its owning-scenario tag, in save order -- which is the order the
// original visits them in, and therefore the order it would draw in.
void SystemScenarioTags(const SaveGame& game, std::vector<std::int32_t>& ids,
std::vector<std::int32_t>& tags) {
ids.reserve(game.sim.systems.size());
tags.reserve(game.sim.systems.size());
for (const auto& e : game.sim.systems) {
ids.push_back(e.sysID);
tags.push_back(e.sys.eggScio);
}
}
} // namespace
ScriptPhaseResult RunScriptTurnBegin(SaveGame& game, bool registerHives) {
ScriptPhaseResult r;
if (!game.sim.svSctOb) {
r.notes.push_back("this save carries no script-object tree; nothing to deliver to");
return r;
}
const int frame = game.sim.frame;
// --- EncID 20, refugees: the one-shot latch ------------------------------------------
if (EncounterObject* enc = Encounter(game, 20)) {
++r.objectsVisited;
const sim::RefugeesTurnBeginResult res = sim::RefugeesTurnBegin(enc->refugees.ini);
if (res.latched) {
r.leafWrites += 1;
r.notes.push_back("refugees: ini False -> True, the one-shot latch the original "
"sets on the first turn it is notified");
} else {
r.notes.push_back("refugees: already latched, so this delivery is a no-op -- "
"which is the rule, not a gap");
}
if (res.convoyOwed && res.latched)
r.notes.push_back("NOT MODELLED behind the same latch: the refugee-trader convoy. "
"The original instantiates a design from the data files and "
"appends its handle to `dids`; on the reference pair that is "
"design 1712, and the same turn also allocates ship 1728 and "
"fleet 1744 (NMnx 106 -> 109). Nothing here allocates handles, "
"so `didc`/`did` stay where the input save left them");
}
// --- EncID 10, swarm queen: register, prune, tick -------------------------------------
if (EncounterObject* enc = Encounter(game, 10)) {
++r.objectsVisited;
std::vector<std::int32_t> sysIds, tags;
SystemScenarioTags(game, sysIds, tags);
std::vector<sim::Hive> hives;
hives.reserve(enc->swarmQueen.hives.size());
for (const auto& h : enc->swarmQueen.hives)
hives.push_back(sim::Hive{h.hiveID, h.queenID, h.nextQ});
const std::size_t before = hives.size();
const std::vector<std::int32_t> owed = sim::HivesToRegister(sysIds, tags, hives);
const std::vector<std::int32_t> registered =
registerHives ? owed : std::vector<std::int32_t>{};
for (std::int32_t sysId : registered) {
sim::Hive h;
h.systemId = sysId;
h.queenId = 0;
// The original's target turn is `frame + LO + draw(HI - LO)`, one draw from the
// strategic generator per new hive, with LO and HI two data-file constants. The
// standalone models neither the constants nor the generator position at this
// point in the turn, so the field is left at the frame it was registered on and
// is KNOWN WRONG. It is written rather than skipped because the hive's identity
// -- which system, and that it has no queen -- is exact, and that is the part
// the save's structure records.
h.nextQueenTurn = frame;
hives.push_back(h);
}
const int pruned = sim::PruneHives(hives, sysIds, tags);
const sim::HiveTickResult tick = sim::TickHives(hives, frame);
bool changed = hives.size() != enc->swarmQueen.hives.size();
if (!changed) {
for (std::size_t i = 0; i < hives.size(); ++i) {
const auto& a = hives[i];
const auto& b = enc->swarmQueen.hives[i];
if (a.systemId != b.hiveID || a.queenId != b.queenID ||
a.nextQueenTurn != b.nextQ) {
changed = true;
break;
}
}
}
if (changed) {
enc->swarmQueen.hives.clear();
enc->swarmQueen.hives.reserve(hives.size());
for (const sim::Hive& h : hives) {
mars::stream::shapes::SVSOSwarmQueenHive w;
w.hiveID = h.systemId;
w.queenID = h.queenId;
w.nextQ = h.nextQueenTurn;
enc->swarmQueen.hives.push_back(w);
}
// The count is one leaf; each hive contributes its three.
r.leafWrites += 1 + 3 * static_cast<int>(hives.size());
}
r.notes.push_back(fmt("swarm queen: %d hive(s) before, %d owed, %d registered, "
"%d pruned, %d slipped a turn, %d now",
static_cast<int>(before), static_cast<int>(owed.size()),
static_cast<int>(registered.size()), pruned, tick.slipped,
static_cast<int>(hives.size())));
if (!owed.empty() && !registerHives)
r.notes.push_back(fmt("%d hive(s) are OWED and not written. Which systems they sit "
"on and that they have no queens are exact; their target "
"turn is not, and writing them would close four leaves and "
"open two carrying a number known to be wrong. "
"--commit-blocked=H03 takes that trade",
static_cast<int>(owed.size())));
if (!registered.empty())
r.notes.push_back("KNOWN WRONG on the hives registered here: `NextQ`. The "
"original draws it -- frame + LO + draw(HI - LO), one draw per "
"new hive, from the strategic generator, inside the turn-begin "
"step. That is OUTSIDE both turn drivers and before either of "
"them, and the campaign's measured RNG ledger (18-22 words, "
"residual zero) was taken on turns where the hives already "
"existed, so it has never seen this draw. Which system each "
"hive sits on, and that it has no queen, ARE exact");
if (tick.slipped > 0)
r.notes.push_back("the tick took the arm every corpus save takes: the spawn gates "
"fail and the target turn slips forward by exactly one. That "
"single increment is why the field reads 31 after turn 1 and 32 "
"after turn 2 -- it walks, it is not re-rolled");
if (tick.spawnsOwed > 0)
r.notes.push_back(fmt("NOT MODELLED: %d queen spawn(s). No hive in any corpus "
"save has ever had a queen", tick.spawnsOwed));
}
if (r.objectsVisited == 0)
r.notes.push_back("neither the refugees nor the swarm queen is in this save's tree");
return r;
}
ScriptPhaseResult RunScriptTurnEnd(SaveGame& game) {
ScriptPhaseResult r;
if (!game.sim.svSctOb) {
r.notes.push_back("this save carries no script-object tree; nothing to deliver to");
return r;
}
const int frame = game.sim.frame;
// --- EncID 9, slavers refuel: the difficulty tier -------------------------------------
if (EncounterObject* enc = Encounter(game, 9)) {
++r.objectsVisited;
const int stored = enc->slavers.cdiff;
const sim::SlaverTierResult res = sim::SlaversTurnEnd(stored, frame);
if (res.wrote) {
enc->slavers.cdiff = res.tier;
r.leafWrites += 1;
r.notes.push_back(fmt("slavers: CDiff %d -> %d at frame %d (tier boundaries "
"1/50/100)", stored, res.tier, frame));
r.notes.push_back("NOT MODELLED, and it runs only when the tier changes: the "
"per-system pass the original enters after this store. It "
"writes nothing this object serialises -- NAsg, NTD and NAD are "
"unchanged across both reference pairs -- so its effect "
"elsewhere is a labelled hypothesis, and a regression outside "
"SvSctOb is what would falsify it");
} else if (sim::SlaverDifficultyTier(frame) < 0) {
r.notes.push_back(fmt("slavers: at frame %d the tier scan writes nothing. Below 1 "
"the first threshold already exceeds the frame; at 100 and "
"above the scan runs off the end of a three-record table, "
"so the tier can never reach 2 by this path", frame));
} else {
r.notes.push_back(fmt("slavers: CDiff already %d at frame %d; the store is "
"conditional on a change", stored, frame));
}
}
if (r.objectsVisited == 0)
r.notes.push_back("the slavers-refuel encounter is not in this save's tree");
r.notes.push_back("the second delivery this tail step makes (event 0x15) is overridden by "
"NO class in any save we hold -- it is dead on this corpus, and that is "
"read from the vtables, not inferred from the bytes");
return r;
}
} // namespace sots::app

View file

@ -1,54 +0,0 @@
// The script-object event deliveries a turn makes, wired to the save shapes.
//
// Three of a turn's six deliveries move something the save records, and they land in two
// different drivers:
//
// H03 turn begin (event 0x13) -- the refugees latch, and the swarm queen's hives
// T08 the tail's combat-done hook (event 8) -- nothing our saves' classes override
// that this lane models
// T20 the tail's end-of-turn hooks (events 0x14, 0x15) -- the slavers' difficulty tier
//
// The bus, the id -> slot table and the per-class override map are in
// docs/SV-script-objects.md and in game/sim/scriptobjects.h. What lives here is only the
// bridge: pull the object out of the save's `SvSctOb` tree by its encounter id, run the
// rule, write the result back.
//
// The tree is a keyed variant list. An id we do not model is carried as an opaque node, so
// asking for one that is not there is normal and returns "no such object" rather than
// creating one -- these phases never add an encounter to a save that has none.
#pragma once
#include <string>
#include <vector>
#include "mars/stream/shapes.h"
namespace sots::app {
// What one script-object phase did, in the terms the run log prints.
struct ScriptPhaseResult {
int objectsVisited = 0; // script objects the phase had a rule for and found
int leafWrites = 0; // save leaves this phase changed
std::vector<std::string> notes;
};
// H03: the turn-begin delivery. Runs immediately after the frame counter, which is where
// the original sends it -- and the ordering is load-bearing, because both rules here read
// the NEW frame.
//
// `registerHives` is opt-in and defaults OFF, and the reason is worth stating rather than
// burying in a flag. Registering a hive is a rule this lane recovered exactly EXCEPT for one
// field: the hive's target turn is `frame + LO + draw(HI - LO)`, one draw from the strategic
// generator, and neither the two data-file constants nor the generator's position at this
// point in the turn is settled. Writing the hive therefore closes the four leaves that say
// WHICH systems have hives and that they have no queens, and opens two that carry a number
// we know is wrong. That is a trade an integrator should make deliberately, so it is a flag
// (`--commit-blocked=H03`) and not a default. Pruning and the per-turn slip are exact and
// always run.
ScriptPhaseResult RunScriptTurnBegin(mars::stream::shapes::SaveGame& game,
bool registerHives = false);
// T20: the tail's end-of-turn deliveries.
ScriptPhaseResult RunScriptTurnEnd(mars::stream::shapes::SaveGame& game);
} // namespace sots::app

View file

@ -11,7 +11,6 @@
#include "app/construction_phase.h"
#include "app/event_phase.h"
#include "app/growth_phase.h"
#include "app/script_phase.h"
#include "app/trade_raid.h"
#include "app/treaty.h"
#include "app/turn_record.h"
@ -998,28 +997,13 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
r.records.push_back(rec);
}
// H03 ScriptHookTurnBegin: the turn's first script-object event delivery, sent from
// inside BeginProcessTurn immediately after the frame counter above. The ordering is
// load-bearing in the same way H02's is -- both rules this delivery runs read the NEW
// frame, and a step placed before H00 would read the old one.
{
PhaseRecord rec;
rec.desc = &hp[1];
const ScriptPhaseResult s = RunScriptTurnBegin(game, opt.CommitBlocked("H03"));
rec.invocations = s.objectsVisited;
rec.leafWrites = s.leafWrites;
rec.committed = s.leafWrites > 0;
rec.notes = s.notes;
r.records.push_back(rec);
}
// H02 StampTreatyTurns: the diplomacy ledger's "last in force on turn N" stamp. It runs
// in the command-application step, which is AFTER the frame counter above and before
// either turn driver -- the ordering is load-bearing, because the value stamped is the
// new turn and a step placed before H00 would stamp every entry one turn short.
{
PhaseRecord rec;
rec.desc = &hp[2];
rec.desc = &hp[1];
const TreatyStampResult ts = StampTreatyTurns(game.sim.players, game.sim.frame);
rec.invocations = ts.pairsStamped;
rec.leafWrites = ts.leafWrites;
@ -1343,12 +1327,6 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
rec.leafWrites = v.leafWrites;
rec.committed = v.leafWrites > 0;
rec.notes = v.notes;
} else if (tp[i].index == 20) {
const ScriptPhaseResult s = RunScriptTurnEnd(game);
rec.invocations = s.objectsVisited;
rec.leafWrites = s.leafWrites;
rec.committed = s.leafWrites > 0;
rec.notes = s.notes;
} else if (tp[i].index == 31) {
RunUpdateBankruptcyLimits(game, opt, rec, difficultyColumns);
} else if (tp[i].index == 36) {
@ -1360,7 +1338,7 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
// H01 SaveWriterInvariants.
{
PhaseRecord rec;
rec.desc = &hp[3];
rec.desc = &hp[2];
const int before = game.summary.turn;
ApplySaveWriterInvariants(game, r);
rec.invocations = 1;

View file

@ -9,7 +9,6 @@ add_library(sots_game_sim STATIC
colony.cpp
movement.cpp
visibility.cpp
scriptobjects.cpp
techgraph.cpp
player_turn.cpp)
target_include_directories(sots_game_sim PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..)
@ -21,7 +20,7 @@ endif()
option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF)
if(SOTS_GAME_SIM_TESTS)
enable_testing()
set(_sim_tests economy research colony movement techgraph visibility construction player_turn scriptobjects)
set(_sim_tests economy research colony movement techgraph visibility construction player_turn)
foreach(_t IN LISTS _sim_tests)
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)

View file

@ -1,100 +0,0 @@
#include "game/sim/scriptobjects.h"
#include <algorithm>
namespace sots::sim {
int SlaverDifficultyTier(int frame) {
// The scan the handler performs: walk the table, stop at the first threshold GREATER
// than the frame, and take that index minus one. Running off the end is not "the last
// tier" -- it is no write at all.
int index = 0;
for (; index < 3; ++index) {
if (kSlaverTierThresholds[index] > frame) break;
}
if (index >= 3) return -1; // frame >= 100: the scan exhausted the table
if (index <= 0) return -1; // frame <= 0: the first record already exceeds it
return index - 1;
}
SlaverTierResult SlaversTurnEnd(int stored, int frame) {
SlaverTierResult r;
r.tier = stored;
const int tier = SlaverDifficultyTier(frame);
if (tier < 0) return r; // the handler returns before the store
if (tier == stored) return r;
r.wrote = true;
r.tier = tier;
return r;
}
RefugeesTurnBeginResult RefugeesTurnBegin(bool& ini) {
RefugeesTurnBeginResult r;
if (ini) return r;
// The store to the latch is the second instruction of the guarded block, before the
// data-file lookup and unconditional on whether it finds anything. So a caller that
// cannot instantiate the convoy still latches, and the two facts are separate.
ini = true;
r.latched = true;
r.convoyOwed = true;
return r;
}
namespace {
bool HasHiveFor(const std::vector<Hive>& hives, std::int32_t systemId) {
for (const Hive& h : hives)
if (h.systemId == systemId) return true;
return false;
}
} // namespace
std::vector<std::int32_t> HivesToRegister(const std::vector<std::int32_t>& systemIds,
const std::vector<std::int32_t>& scenarioTags,
const std::vector<Hive>& hives,
std::int32_t scenarioTag) {
std::vector<std::int32_t> out;
const std::size_t n = std::min(systemIds.size(), scenarioTags.size());
for (std::size_t i = 0; i < n; ++i) {
if (scenarioTags[i] != scenarioTag) continue;
if (HasHiveFor(hives, systemIds[i])) continue;
out.push_back(systemIds[i]);
}
return out;
}
int PruneHives(std::vector<Hive>& hives, const std::vector<std::int32_t>& systemIds,
const std::vector<std::int32_t>& scenarioTags, std::int32_t scenarioTag) {
const std::size_t n = std::min(systemIds.size(), scenarioTags.size());
const std::size_t before = hives.size();
hives.erase(std::remove_if(hives.begin(), hives.end(),
[&](const Hive& h) {
for (std::size_t i = 0; i < n; ++i) {
if (systemIds[i] != h.systemId) continue;
return scenarioTags[i] != scenarioTag;
}
// A hive whose system is not in the list at all has lost
// its system and goes the same way.
return true;
}),
hives.end());
return static_cast<int>(before - hives.size());
}
HiveTickResult TickHives(std::vector<Hive>& hives, int frame, bool gatesOpen) {
HiveTickResult r;
for (Hive& h : hives) {
if (h.queenId != 0) continue;
if (!gatesOpen) {
++h.nextQueenTurn;
++r.slipped;
continue;
}
if (h.nextQueenTurn > frame) continue; // the date has not arrived; no slip either
++r.spawnsOwed;
}
return r;
}
} // namespace sots::sim

View file

@ -1,131 +0,0 @@
// The script objects' per-turn rules.
//
// A SOTS game carries a tree of "script objects" -- the scripted encounters (von Neumann,
// swarm, derelicts, monitors, slavers, crow ruins, refugees) and the scenario objects
// (traps, crow defenders, independent systems, grand-menace trigger). They hang off the
// strategy server, they are serialised into the save, and they are updated by an EVENT BUS
// rather than by direct calls from the turn drivers.
//
// The bus is the thing to understand. A driver notifies the ROOT object with an event id,
// and the root fans the same delivery out to every child. Each delivery is TWO steps: a
// generic handler that receives the id, and one event-specific virtual slot that does not.
// The id -> slot map is a 33-entry table in the image, so "which object reacts to which
// event" is an exhaustive, recovered fact, not an inference from what the saves happen to
// show. See docs/SV-script-objects.md for the whole table and for the event a turn sends
// at each of its six delivery points.
//
// This header holds the rules themselves as pure functions -- no save shapes, no I/O -- for
// the three objects the reference pair actually moves. Everything they need is passed in.
//
// WHAT IS DELIBERATELY NOT HERE
//
// * The refugee-trader convoy. The refugees object latches an "initialised" flag on the
// first turn and, behind the same latch, instantiates a design from the data files and
// records its handle. The latch is modelled; the instantiation is not, because nothing
// in the standalone allocates handles. `RefugeesTurnBegin` says so in its result.
// * The queen spawn. Only the "not yet" arm of the swarm-queen tick has ever run on any
// save this project holds; no hive in the corpus has a queen.
// * The draw. A newly registered hive takes its target turn from the strategic generator.
// That draw happens inside the turn-begin step, which is OUTSIDE both turn drivers and
// before either of them -- a place the campaign's measured RNG ledger has never had a
// turn that reached. `HiveRegistration` reports the draw it would need and lets the
// caller decide; it never invents a number.
#pragma once
#include <cstdint>
#include <vector>
namespace sots::sim {
// ---------------------------------------------------------------------------------------
// Slavers refuel: the difficulty tier
// ---------------------------------------------------------------------------------------
// The slavers object reacts to exactly one event -- the tail's end-of-turn hook -- and all
// it does there is move a difficulty tier. The tier comes from a three-record table built
// on the stack of the handler, scanned for the FIRST record whose threshold EXCEEDS the
// frame; the tier is that record's index minus one.
//
// The scan has two edges that a reader of the data alone would never find, and both are
// what the instructions do rather than what they presumably meant:
//
// frame <= 0 the first record already exceeds it, so the index is 0 and the handler
// returns before writing anything;
// frame >= 100 the scan runs off the end of the table and again writes NOTHING, so the
// tier can never reach 2 by this path.
//
// The tier is written only when it differs from the stored one, which is why the reference
// pair moves it on turn 1 and never again.
inline constexpr int kSlaverTierThresholds[3] = {1, 50, 100};
// The tier this frame implies, or -1 for "the handler writes nothing at this frame".
int SlaverDifficultyTier(int frame);
// The result of running the slavers' end-of-turn hook.
struct SlaverTierResult {
bool wrote = false; // the stored tier changed
int tier = -1; // what it now is (unchanged when `wrote` is false)
};
// `stored` is the object's current CDiff.
SlaverTierResult SlaversTurnEnd(int stored, int frame);
// ---------------------------------------------------------------------------------------
// Refugees: the one-shot latch
// ---------------------------------------------------------------------------------------
struct RefugeesTurnBeginResult {
bool latched = false; // the flag moved false -> true on this call
bool convoyOwed = false; // a design instantiation is owed and is not modelled
};
// Runs at turn begin. `ini` is read and written.
RefugeesTurnBeginResult RefugeesTurnBegin(bool& ini);
// ---------------------------------------------------------------------------------------
// Swarm queen: hives
// ---------------------------------------------------------------------------------------
// One hive. `systemId` is the star system it sits on; `nextQueenTurn` is an ABSOLUTE turn,
// not a countdown, and `queenId` is 0 until a queen exists.
struct Hive {
std::int32_t systemId = 0;
std::int32_t queenId = 0;
std::int32_t nextQueenTurn = 0;
};
// The swarm queen's constructor stores TWO ids: the scenario tag it operates on (3, the
// swarm's) and its own encounter id (10). It is the first that selects systems, so the
// queen registers hives on the SWARM's systems. A star system names its owning scenario in
// the field the save calls `EggScio`.
inline constexpr std::int32_t kSwarmScenarioTag = 3;
// Which of `systemIds` need a hive, given the hives that already exist. `scenarioTags` is
// parallel to `systemIds` and holds each system's `EggScio`. The order is the order the
// systems are visited, which is the order the original draws in.
std::vector<std::int32_t> HivesToRegister(const std::vector<std::int32_t>& systemIds,
const std::vector<std::int32_t>& scenarioTags,
const std::vector<Hive>& hives,
std::int32_t scenarioTag = kSwarmScenarioTag);
// Drop hives whose system no longer carries the scenario tag. Returns how many went.
int PruneHives(std::vector<Hive>& hives, const std::vector<std::int32_t>& systemIds,
const std::vector<std::int32_t>& scenarioTags,
std::int32_t scenarioTag = kSwarmScenarioTag);
// The per-turn tick over the hives that have no queen. Every corpus save takes the same arm
// of it: the spawn gates fail and the target turn SLIPS FORWARD BY ONE. That single
// increment is the whole explanation of a target turn that reads 31 after turn 1 and 32
// after turn 2 -- it is not re-rolled, it walks.
//
// `gatesOpen` is the caller's answer for "may a queen spawn at all this turn". The
// standalone has no evidence for any of those gates, so it passes false and this function
// takes the arm the corpus has always taken; the parameter exists so the shape of the rule
// is stated rather than assumed away.
struct HiveTickResult {
int slipped = 0; // hives whose target turn moved forward one
int spawnsOwed = 0; // hives that would have spawned and are not modelled
};
HiveTickResult TickHives(std::vector<Hive>& hives, int frame, bool gatesOpen = false);
} // namespace sots::sim

View file

@ -202,7 +202,7 @@ TotalState g_total;
const void* species_def(int species) {
if (!g_env.exe_base || species < 0 || species > 6) return nullptr;
const void* p = reinterpret_cast<const void*>(A::g_SpeciesDefTable + g_env.exe_base +
const void* p = reinterpret_cast<const void*>(A::SpeciesDefTable_base + g_env.exe_base +
std::uintptr_t(species) * 0x184);
return readable(p, 0x54) ? p : nullptr;
}

View file

@ -49,9 +49,9 @@ int main() {
CHECK(static_cast<int>(ns + np) == kSpinePhaseCount);
// The host steps are deliberately NOT part of the milestone's denominator. Their ids run
// H00, H03, H02, H01 because the table is in EXECUTION order and each id was assigned when
// the step was read, not when it runs; the id is the stable name, the index is the ordinal.
CHECK(nh == 4);
// H00, H02, H01 because the table is in EXECUTION order and H02 was read later than the
// step it runs before; the id is the stable name, the index is the ordinal.
CHECK(nh == 3);
CheckTable(h, nh, Driver::Host, 0, ids, names);
CheckTable(s, ns, Driver::Strategic, 0, ids, names);
CheckTable(p, np, Driver::Player, 1, ids, names);

View file

@ -13,8 +13,7 @@ mkdir -p "$build"
CXX="${CXX:-g++}"
CXXFLAGS="${CXXFLAGS:--std=c++17 -O1 -g -Wall -Wextra -Werror -pedantic}"
srcs=("$root"/src/game/sim/economy.cpp "$root"/src/game/sim/research.cpp \
"$root"/src/game/sim/colony.cpp "$root"/src/game/sim/movement.cpp \
"$root"/src/game/sim/scriptobjects.cpp)
"$root"/src/game/sim/colony.cpp "$root"/src/game/sim/movement.cpp)
objs=()
for s in "${srcs[@]}"; do
@ -24,7 +23,7 @@ for s in "${srcs[@]}"; do
done
status=0
for t in economy research colony movement scriptobjects; do
for t in economy research colony movement; do
exe="$build/test_$t"
$CXX $CXXFLAGS -I"$root/src" -I"$here" "$here/test_$t.cpp" "${objs[@]}" -o "$exe"
if ! "$exe"; then status=1; fi

View file

@ -1,141 +0,0 @@
#include "game/sim/scriptobjects.h"
#include "check.h"
using namespace sots::sim;
// The tier scan, at the boundaries the corpus cannot reach. Rule 23: the reference pair only
// ever exercises frame 1 and 2, so every other row here is hand-computed from the table the
// handler builds and is the only check those rows will ever get.
static void test_slaver_tier() {
// Below the first threshold the scan stops at index 0 and returns before storing.
CHECK_EQ(SlaverDifficultyTier(-5), -1);
CHECK_EQ(SlaverDifficultyTier(0), -1);
// 1..49 is tier 0. Both ends by hand.
CHECK_EQ(SlaverDifficultyTier(1), 0);
CHECK_EQ(SlaverDifficultyTier(2), 0);
CHECK_EQ(SlaverDifficultyTier(49), 0);
// 50..99 is tier 1. Both ends by hand.
CHECK_EQ(SlaverDifficultyTier(50), 1);
CHECK_EQ(SlaverDifficultyTier(99), 1);
// And at 100 the scan runs off the end of a three-record table, so it writes NOTHING.
// Tier 2 is unreachable through this path. This is what the instructions do; a reader
// who assumed "last tier wins" would have it wrong from turn 100 on.
CHECK_EQ(SlaverDifficultyTier(100), -1);
CHECK_EQ(SlaverDifficultyTier(1000), -1);
}
static void test_slaver_store_is_conditional() {
// The reference pair: -1 at frame 1 becomes 0.
SlaverTierResult r = SlaversTurnEnd(-1, 1);
CHECK(r.wrote);
CHECK_EQ(r.tier, 0);
// The turn after: already 0, so no store. This is why the second reference pair moves
// nothing here, and that asymmetry is the check that the rule is one-shot per tier.
r = SlaversTurnEnd(0, 2);
CHECK(!r.wrote);
CHECK_EQ(r.tier, 0);
// Past 100 the scan writes nothing even though the stored tier is stale.
r = SlaversTurnEnd(1, 250);
CHECK(!r.wrote);
CHECK_EQ(r.tier, 1);
// A save that somehow carries a tier ahead of its frame is written back DOWN, because
// the store is on inequality, not on ordering.
r = SlaversTurnEnd(1, 5);
CHECK(r.wrote);
CHECK_EQ(r.tier, 0);
}
static void test_refugee_latch() {
bool ini = false;
RefugeesTurnBeginResult r = RefugeesTurnBegin(ini);
CHECK(r.latched);
CHECK(ini);
CHECK(r.convoyOwed);
// Second delivery: the latch holds and nothing is owed again.
r = RefugeesTurnBegin(ini);
CHECK(!r.latched);
CHECK(!r.convoyOwed);
CHECK(ini);
}
// The reference pair's map: two systems tagged with the swarm's scenario id, and three
// tagged with someone else's. Only the two get hives.
static void test_hive_registration() {
const std::vector<std::int32_t> ids = {64, 336, 400, 448, 480};
const std::vector<std::int32_t> tags = {5, 3, 3, 4, 4};
std::vector<Hive> hives;
std::vector<std::int32_t> owed = HivesToRegister(ids, tags, hives);
CHECK_EQ(owed.size(), std::size_t{2});
CHECK_EQ(owed[0], std::int32_t{336});
CHECK_EQ(owed[1], std::int32_t{400});
hives.push_back(Hive{336, 0, 30});
hives.push_back(Hive{400, 0, 28});
// Registration is idempotent: nothing is owed once the hives exist, which is why the
// second reference pair moves no hive leaf.
owed = HivesToRegister(ids, tags, hives);
CHECK(owed.empty());
}
static void test_hive_prune() {
const std::vector<std::int32_t> ids = {336, 400};
std::vector<std::int32_t> tags = {3, 3};
std::vector<Hive> hives = {Hive{336, 0, 30}, Hive{400, 0, 28}};
CHECK_EQ(PruneHives(hives, ids, tags), 0);
CHECK_EQ(hives.size(), std::size_t{2});
// The swarm loses one system: its hive goes.
tags[1] = -1;
CHECK_EQ(PruneHives(hives, ids, tags), 1);
CHECK_EQ(hives.size(), std::size_t{1});
CHECK_EQ(hives[0].systemId, std::int32_t{336});
// A hive whose system is not in the map at all goes the same way.
hives.push_back(Hive{9999, 0, 5});
CHECK_EQ(PruneHives(hives, ids, tags), 1);
CHECK_EQ(hives.size(), std::size_t{1});
}
// The whole explanation of a number that looked impossible: 31 after turn 1, 32 after turn
// 2. It is not re-rolled -- it walks forward one per turn the gates stay shut.
static void test_hive_tick_slips() {
std::vector<Hive> hives = {Hive{336, 0, 30}, Hive{400, 0, 28}};
HiveTickResult t = TickHives(hives, 1);
CHECK_EQ(t.slipped, 2);
CHECK_EQ(hives[0].nextQueenTurn, std::int32_t{31});
CHECK_EQ(hives[1].nextQueenTurn, std::int32_t{29});
t = TickHives(hives, 2);
CHECK_EQ(t.slipped, 2);
CHECK_EQ(hives[0].nextQueenTurn, std::int32_t{32});
CHECK_EQ(hives[1].nextQueenTurn, std::int32_t{30});
// A hive that already has a queen is not ticked at all.
hives[0].queenId = 77;
t = TickHives(hives, 3);
CHECK_EQ(t.slipped, 1);
CHECK_EQ(hives[0].nextQueenTurn, std::int32_t{32});
}
// The arm no corpus save has ever taken, stated so the shape of the rule is on record: with
// the gates open a hive whose date has arrived spawns, and one whose date has not does NOT
// slip -- the slip belongs to the failed-gate arm alone.
static void test_hive_tick_gates_open() {
std::vector<Hive> hives = {Hive{336, 0, 3}, Hive{400, 0, 99}};
const HiveTickResult t = TickHives(hives, 10, /*gatesOpen=*/true);
CHECK_EQ(t.spawnsOwed, 1);
CHECK_EQ(t.slipped, 0);
CHECK_EQ(hives[0].nextQueenTurn, std::int32_t{3});
CHECK_EQ(hives[1].nextQueenTurn, std::int32_t{99});
}
int main() {
test_slaver_tier();
test_slaver_store_is_conditional();
test_refugee_latch();
test_hive_registration();
test_hive_prune();
test_hive_tick_slips();
test_hive_tick_gates_open();
return simtest::finish("scriptobjects");
}