Compare commits

..

7 commits

Author SHA1 Message Date
alex
0592104f4b sync generated header (986 entries) after alliance and design merges 2026-09-08 12:51:13 -04:00
alex
5a6a3155dc merge lane D2: ship-design persistence, hull class and defence-platform flag; census verified 480 leaves 2026-09-08 12:48:44 -04:00
alex
559d3e22a8 merge lane A2: alliance mask rule; ModCount writers enumerated 2026-09-08 12:48:44 -04:00
alex
8180792462 lane D2: aggregate hull size in the record's MEMORY slot order, not its wire order
The design record's section array is [mission, command, engine]; the wire is
[command, mission, engine]. Hull size is an assignment inside the original's
per-section loop, and that loop runs over the array, so the last resolved
section in MEMORY order wins -- engine, else command, else mission.

The first cut of this walked the wire order, which gives a different answer for
a design with an empty engine slot and both other slots filled. Rule A3 makes
that shape invalid, so no design in the corpus can tell the two apart and the
480/480 census result is unchanged either way -- which is exactly why the order
is now a named constant with the reasoning attached instead of whichever loop
was already to hand.

derive_stats takes hull_size after its loop for the same reason; the
defence-platform flag is an OR and stays in the loop.

52 design unit tests (the mixed-class case now pins memory order and adds an
engine-slot case), census still 480/480 on 11 saves, realdata still 127/127 and
197/197. host ctest 43/43, clean-room OK.
2026-09-08 12:47:14 -04:00
alex
f177a5fdd0 lane D2: hull size + the defence-platform flag; the ship census reproduces 480/480
The two derived words the per-player turn record's ship census counts by, and
the design serializers that three lanes had been told did not exist.

HOW DESIGNS PERSIST. Game::ShipDesign derives from Game::ShipDesignDef and
reaches IStreamable through adjustor thunks, so a design is written by TWO
serializers: the base emits FAIDes/DHide/DWep/DName and exactly three section
frames (command, mission, engine on the wire), the derived one appends Dtc, the
Dwgv flag and, only when that flag is set, a weapon-group frame. The earlier
"the writer makes no stream call at all" note named an address that is in no
vftable at all. Corrected in shapes.h.

THREE sections, not five. The "two reserved slots" were Dtc and Dwgv swept into
the section list by the reference reader's catch-all tail; the constructor
builds a three-element array. design.h's comment is corrected and the fixture
loader now accepts 3-5 raw_slots so old fixtures still load; the array keeps
five inert entries deliberately, since touching the slot enum reaches rules.cpp
and another lane's tests for no behavioural gain.

DWep and Dwgv are bools, not ints -- both writers call the bool primitive. With
four-character tags a bool item and an int item are the same size on the wire
and 0/1 the same bytes, so no save can tell them apart. Byte-neutral: the typed
round trip is still byte-identical on all 11 saves at 100% named coverage.

HULL SIZE is the section_class of the last resolved section in memory slot
order, mapped Destroyer/Cruiser/Dreadnought -> 0/1/2 case-insensitively, with
absent or unrecognised meaning 0 rather than an error. The DEFENCE-PLATFORM
flag is one bit of a 64-bit role-flag word OR-ed across the design's sections.
Neither is on the wire; both are rebuilt from the section catalog.

MEASURED, not assumed: the new game_design_census test rebuilds the six census
counters per player and compares them against the record the game archived for
each save's own frame. 11 saves, 503 designs, 480 leaves, 0 mismatched, 0 ships
with an unresolvable design, 0 designs where first- and last-resolved section
disagree on hull size. COVERAGE IS THIN AND THE TEST SAYS SO: only 32 of the
480 leaves are nonzero, and three of the six census leaves (both cruiser rows
and dreadnought platforms) are never exercised by any save in the corpus -- the
test prints the per-leaf nonzero counts and names them unexercised rather than
verified.

Nothing is wired into the turn record: src/app is another lane's this cycle, so
this is evaluated and reported, not written.

host ctest 43/43 (was 42/42; +1, skips cleanly without the env). With a data
root set, game_data_realdata and mars_text_realdata fail identically on main --
both are the absent Locale/EN/Strings.csv, not this change. clean-room OK.
Reference readers fixed openly in the RE repo: save_reader 49/49,
design rules 32/32, stock_designs.json regenerated (raw_slots 5->3 and dWep
int->bool are the only field changes across all 127 designs).
2026-09-08 12:44:41 -04:00
alex
5e409cfa05 lane A2: S04, the alliance mask -- 80 player-records, 560 fields, 0 mismatches
The spine's fourth phase, read byte-for-byte and implemented:

    almem[i] = (1 << i) | (ALid != -1 ? AL : 0)

with i the player's POSITION IN THE PLAYER VECTOR, not its index field. Both
inputs are on the wire and so is the output, through the turn-record archive,
so the phase is checkable against bytes the original wrote:

    app_turn_record: 11 saves, 80 player-records, 560 fields, 0 mismatches
                     (was 480 fields over six fields; almem is the seventh)

The eight zero masks of the corpus's earliest archived turn are PREDICTED, not
excluded: the archiving phase also runs on load, and the load path does not run
the spine. BuildTurnRecord takes spineRan and models it, so all 80 records are
compared.

Three parts of the rule the corpus cannot separate -- the bit index, the OR,
and the ALid guard -- are pinned in app_alliance with the separating inputs no
save provides, and app_turn_record prints that it could not separate them.

Divergence, closed and regressed reported separately:

    turn1->turn2  default          209 -> 204   closed 5, regressed 0
    turn1->turn2  --commit-blocked 209 -> 189   closed 29, regressed 9  (was 17)
    turn2->turn3  default          108 -> 103   closed 5, regressed 0
    turn2->turn3  --commit-blocked 108 -> 106   closed 13, regressed 11 (was 19)

T36 stays blocked: nine leaves would still be wrong (inc x3, sav x3 behind the
budget; three census leaves behind the design catalogue). It now closes all 24
turnstats leaves on the reference pair, so it becomes a clean +24 once those
two land.

Prediction and falsification committed first in 49ae628.
Gates run separately: clean-room OK; host ctest 43/43. No src/shim touched.
2026-09-08 12:44:00 -04:00
alex
49ae628220 lane A2: prediction for the alliance mask and ModCount, written before the build 2026-09-08 12:23:06 -04:00
27 changed files with 1350 additions and 42 deletions

View file

@ -0,0 +1,175 @@
# Lane A2 — the alliance mask (`S04`) and `ModCount`
Lane A2, 2026-09-08. Branch `wip/alliance`, off `main` `b2bad30` (lane N's output term already
merged). Host + static only; lane A2 holds no VM.
**This section is the prediction, written before the implementation, per earned rule 2.**
Everything below the horizontal rule at §3 is the result.
---
## 1. The prediction — `S04`, the alliance / shared-vision mask
The spine's fourth phase is a per-player pre-pass that rebuilds one word of each player's turn
record from scratch, every turn, before anything else in the turn can read it. Read from the
instruction stream (not the decompiler), the phase is:
```
for i in 0 .. numPlayers-1: # i is the player's POSITION IN THE SERVER VECTOR
p = players[i]
rec = p.turnRecord
rec.allianceMask = 0
rec.allianceMask |= (1 << i)
if p.allianceId != -1:
rec.allianceMask |= p.allianceMembers
```
so, as one expression,
```
almem[i] = (1u << i) | (alid[i] != -1 ? al[i] : 0)
```
Both inputs are on the wire: they are the two leading ints of the player's second `Team` item
(`ALid`, `AL`). The output is on the wire too — it is the `almem` field of the player's
turn-statistics element for the turn.
**The prediction, in the form the test will check it:** over the whole eleven-save corpus, for
every player of every save, for the archive element whose `trn` equals the save's own frame,
> `almem == (1 << playerVectorIndex) | (ALid != -1 ? AL : 0)`
with **zero** mismatches, and with `playerVectorIndex` being the player's ordinal position in
`/Sim/players` — **not** its `PlyrIdx`.
Committing this closes the 8 `almem` leaves that lane Y measured as regressing `T36`, and moves
`T36` from "right in six fields, wrong in five" to "right in seven fields, wrong in four".
### 1.1 Why the bit index is the vector position and not `PlyrIdx`
The shift count is the loop induction variable, which is the index used to subscript the player
vector on the same iteration. Nothing loads `PlyrIdx` anywhere in the phase. Lane T recorded this
correctly and it is restated here because it is the single most likely place for a
reimplementation to be quietly wrong: every *other* per-player index in the tail (the turn-results
array, the battle tally, the archive key) uses `PlyrIdx`, and this one does not.
### 1.2 What this phase does NOT do
It does not read, and does not write, `NA` or `CF` — the other two ints of the same `Team` item.
It does not consult the game's alliance-enabled flag (`EnAl`). It writes exactly one word.
## 2. Falsification
Each row is a way the model above can be wrong, and the symptom that would show it.
| # | how the model could be wrong | symptom |
|---|---|---|
| F1 | the bit is `1 << PlyrIdx`, not `1 << vectorIndex` | on any save where the two orders differ, `almem`'s low bit pattern is off by a permutation. **Zero mismatches on a save where they agree proves nothing** — the check must report how many saves actually separate the two |
| F2 | the guard is on something other than `ALid != -1` (e.g. on `AL != 0`, or on the game's `EnAl`) | indistinguishable while no player is in an alliance. Only a save with a live alliance can separate them |
| F3 | `AL` is a mask over `PlyrIdx` bits while the self-bit is a vector-position bit | `almem` would mix two index spaces. Same blind spot as F2: unobservable without an alliance |
| F4 | phase 36 (`FinalizeTurnRecords`) overwrites `almem` after phase 4 sets it | mismatches everywhere, and the stored value would correlate with something else entirely |
| F5 | the archived element for the save's own frame was written by the **load** path rather than by the tail (`FinalizeTurnRecords` runs in both) | the self-bit would be missing, because the load path does not run spine phase 4. Expect `almem == 0`, or a value that is stale by one turn |
| F6 | the shift wraps | `shl` masks its count to 5 bits, so a 32nd player would set bit 0. No save has more than 8 players; the model is untested above 31 and says so |
| F7 | the corpus never exercises the alliance term at all | then this lane has closed the **self-bit** and *nothing else*, and must say so under rule 6 rather than claim the rule |
F7 is the one I expect to bite. The prior is that no save in the corpus has a player in an
alliance, in which case the measured agreement tests only `1 << i`, and the `|= AL` arm is a
**hypothesis** read from the instruction stream with no exercised evidence. That is a real result
and it is a smaller one than "the alliance mask is closed", and it will be reported as the smaller
one.
## 3. The prediction — `ModCount`
`ModCount` is a **modification counter**, not a turn number and not a phase counter, and the count
a turn adds is a function of the *command stream*, not of the pre-turn state.
**The prediction:**
1. Every writer is an **entry-block increment inside a `StrategySim` command-application method** —
the `OnCommand_*` family. The bump is unconditional and happens *before* the handler validates
its arguments, so a command that fails validation still advances the counter.
2. The two turn drivers each add exactly 1.
3. `ProcessTurn` phase 1 adds 1 per system that passes its gate; the gate is closed on all eleven
saves, so it contributes 0 to every measured turn.
4. Therefore **`ModCount` is not derivable from the pre-turn save**, and `S00`/`T00`'s two bumps
are the only part of it this standalone can ever produce without modelling the AI's order
stream. The remaining 10–42 bumps a turn are commands.
**Falsification for §3:** if the delta were a function of the pre-turn state, then two saves with
the same state shape would show the same delta. `turn1→turn2` and `turn2→turn3` both show exactly
12, which is *consistent* with a state function and is the strongest evidence against my reading;
a third pair from the same game showing a different delta with an unchanged board would settle it.
The counter-evidence I already hold is that `human-turn2→turn3` is 28 and `zuul-turn16→turn17` is
16 on boards whose system and player counts are identical (28 systems, 7 players) — so it is not a
function of the board size. If someone finds a closed-form rule over the pre-turn save that fits
all eleven saves, this section is wrong.
---
# 4. Result
Everything above this line was committed (`49ae628`) before a line of the implementation existed.
## 4.1 The divergence delta — `closed` and `regressed`, never netted
`tools/standalone_report.py`, both real End-Turn pairs, on `wip/alliance`:
| pair | run | baseline | after | **closed** | **regressed** |
|---|---|---:|---:|---:|---:|
| `turn1 → turn2` | default | 209 | **204** | **5** | **0** |
| `turn1 → turn2` | `--commit-blocked` | 209 | 189 | **29** | **9** *(was 17)* |
| `turn2 → turn3` | default | 108 | **103** | **5** | **0** |
| `turn2 → turn3` | `--commit-blocked` | 108 | 106 | **13** | **11** *(was 19)* |
**The default run is unchanged at 5 closed / 0 regressed, and that is the honest headline.** `S04`
writes no save leaf of its own: the alliance mask reaches the wire only through `T36`, and `T36`
is still blocked.
What moved is `T36`'s cost of committing. The 8 `almem` regressions are **gone**, and the
regression set on the reference pair is now exactly:
```
inc x3 sav x3 <- the budget: blocked behind the per-system money output
shpt[0] shpt[2] satt[0] <- the ship census: needs each design's hull size and 0x400 class flag
```
Nine leaves, from two named blockers, both owned by other lanes in flight. On the reference pair
`T36` now closes **all 24** `/Sim/turnstats` leaves. **When the budget and the design catalogue
land, `T36` is a clean +24 with nothing left over** — the same conclusion lane Y reached, with the
alliance third of it now paid for.
One thing lane Y wrote is no longer true and is corrected here: *"which way the net falls depends
on the save"*. It does not any more — `--commit-blocked` is net positive on **both** pairs (+20 and
+2, against +12 and −9 before). `T36` nevertheless **stays blocked**, because `regressed > 0` means
nine leaves would be confidently wrong, and that is the whole rule the status exists to enforce.
## 4.2 The alliance rule — measured
`almem == (1 << vectorIndex) | (ALid != -1 ? AL : 0)`, against bytes the game wrote:
**11 saves, 80 player-records, 560 fields compared, 0 mismatches** (`app_turn_record`, up from 480
fields). 14 records carry a live alliance id, so falsifier **F7** did not bite — the alliance term
is exercised.
**F5 fired, and it was the model, not a miss.** All eight `almem` values of `turn1-state.sav` are
zero where the rule says otherwise, because the archiving phase also runs on load and the load path
does not run the spine. The engine states that as a prediction (`spineRan`), so all 80 records
compare rather than 72.
**F1, F2 and F3 are all unfalsifiable on this corpus and the run says so every time.** Every player
in every save has `PlyrIdx == vectorIndex`; every observed alliance mask already contains its own
member's bit; and `AL == 0` exactly when `ALid == -1`. `app_alliance` pins all three with the
separating inputs no save provides.
## 4.3 `ModCount` — the prediction stands, and the mechanism is named
§3's reading is confirmed by the instruction stream and sharpened: the counter is
`Game::StrategySim + 0x4`, and the per-turn delta is **2 + one per command applied out of every
player's `TurnCommands` block**. The flush is `StrategyServer::ApplyAllTurnCommands`, called from
the same message handler that calls both turn drivers, and it walks a `vector<TurnCommands>` whose
0x1b4 stride independently confirms the block layout the campaign already recovered.
So `S00` + `T00`'s two bumps really are all the standalone can produce, and the remaining 10–42 are
downstream of the AI's order generation. `ModCount` is **not** the next leaf to chase. The
watchpoint that would settle the residual in one turn is specified in
`sots-re/findings/control-flow/alliance-mask-and-modcount.md` §3, with its own written prediction
(exactly 12 hits on the reference workload) and four falsifiers.

View file

@ -78,6 +78,11 @@ Nothing is `verified`. That is deliberate: in this table `verified` means "compa
the live game", and lane S2 holds no VM. `app_test_catalog` asserts `verified == 0` so the
claim cannot drift upward by accident.
> **Updated by lane A2, 2026-09-08.** `S04 RebuildAllianceMasks` is now `implemented`
> (15 of 44 modelled, 9 committed), and `T36`'s modelled field count went from six to seven.
> The tables below are lane S2's originals and are one lane behind; `docs/A2-alliance-and-modcount.md`
> §4 carries the current numbers and the measured `--commit-blocked` delta.
### 3.1 Current state
| driver | phases | modelled | committed |
@ -103,6 +108,7 @@ claim cannot drift upward by accident.
| `P09 AccumulateTimedResearchBonuses` | the timed research-bonus vector, iterated **last → first**, which is load-bearing because float addition is not associative |
| `P10 ConsumeResearchRollPending` | the strict `0.5f < progress/cost` test and the in-branch flag clear |
| `T00 IncrementModCount` | the tail's own bump of the same counter |
| `S04 RebuildAllianceMasks` | the per-player shared-vision mask (lane A2); writes no leaf of its own — it reaches the wire through `T36` |
**Evaluated and reported, not committed:** `P01` `P02` `P03` `P05` `P06` `P11` `S31` `T31`.
@ -186,6 +192,12 @@ In the order they must be solved, not in order of size.
3. **The post-combat tail.** 37 phases, none implemented, and it is the driver the autosave is
written from. `turnstats`, the bankruptcy limits, the observed-design records and the
player reports all live there.
3a. **`ModCount`.** Named and enumerated by lane A2: it is a *command* counter, bumped once on
entry to each of 26 `StrategySim` command-application sites plus once by each turn driver. Its
per-turn delta is the number of commands applied out of every player's queued command block,
which is downstream of the AI's order generation. `S00` + `T00` are all this standalone can
produce, and the leaf cannot close before the AI does. See `docs/A2-alliance-and-modcount.md`.
4. **`Summary.Checksum`.** Its algorithm is unknown. It is one leaf, and it is the *last* leaf:
whatever it hashes, it cannot be right until everything it hashes is right.
5. **The `Player.Status` writer.** The phase writes 1, the file carries 4, a load resets to 0.

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 @ 58e3d85, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ a4aba6a, 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>
@ -1295,6 +1295,30 @@ constexpr uint32_t Mars_VectorHelper_AIPlayerRequestStamp_Write = 0x0029b310;
constexpr uint32_t Mars_StreamableHelper_AIPlayerRequestStamp_vftable = 0x0061a730;
// thiscall void (Mars::StreamableHelper<Game::AIPlayerRequestStamp>* this, Mars::Stream* s) // two named ints: `pid` at +0 and `trn` at +4. Game::AIPlayerRequestStamp is a POD with no RTTI class of its own, reached only through this specialised helper, so tools/serializers.py reports 'no serializer' for it and it has no entry in the generated wire table [verified]
constexpr uint32_t Game_AIPlayerRequestStamp_Write = 0x00295400;
// site site in StrategyServer::Write (0x0079fa70, already in addresses.json with the same address -- AGREEMENT, this lane's duplicate entry was dropped). THE DECISIVE EVIDENCE FOR THE ModCount / Frame NAMING: 0x0079fb2f `lea edx,[edi+0x08]; push "ModCount"` and 0x0079fb40 `lea eax,[edi+0x0c]; push "Frame"`, with edi = this. The frame is confirmed inside the same function by 0x0079fb90 `mov eax,[edi+0x16c]` under the tag "RNG", which is StrategyServer_off_RNGPtr (0x168 in the S+4 frame) -- so edi is S, not S+4. Tag order after the six id lists is ModCount, Frame, GameID ([edi+0x14] at 0x0079fb4f) [verified]
constexpr uint32_t StrategyServer_Write_ModCountFrameTags = 0x0039fb2f;
// field int ModCount, in the S+4 (Game::StrategySim sub-object) frame == S+0x8. THIS IS THE WIRE'S /Sim/ModCount. CORRECTION, and it matters: addresses.json's `StrategyServer_off_ModCount` puts that name on offset 0x8 of this frame (== S+0xc), which StrategyServer::Write tags "Frame". Lane T's `StrategyServer_off_PhaseCounter` -- 'nobody has named this one' -- is this word, and it IS ModCount. Written by an unconditional increment on ENTRY to every StrategySim command-application method (26 sites), plus once by StrategyServer::ProcessTurn's first instruction, once by OnAllCombatDone_Tail's first instruction, and once per call of the abandon/chaos check. It is a modification counter: not a turn number, not a phase counter, and not constant per turn (12-44 on measured turns) [verified]
constexpr uint32_t StrategySim_off_ModCount = 0x00000004;
// field int Frame, in the S+4 frame == S+0xc. THIS IS THE WIRE'S /Sim/Frame -- the turn number. Incremented once per turn by StrategyServer::BeginProcessTurn 0x007d990a; stamped into node.turnResearched by TechTree::SetResearched; used as minTurn by the EVENT_NO_RESEARCH sweep; and it is the KEY the turn-record archive is written under (FinalizeTurnRecords passes S->+0xc). The entry addresses.json calls `StrategyServer_off_ModCount` is this word, and the name is wrong on it [verified]
constexpr uint32_t StrategySim_off_Frame = 0x00000008;
// site site in StrategyServer::ProcessTurn, phase 4, 0x007dc871-0x007dc8c7, byte-for-byte: `for (i = 0; i < (Players.end - Players.begin) >> 2; ++i) { p = Players[i]; rec = p->+0x3d8; rec->+0x8 = 0; rec->+0x8 |= 1 << i; if (p->+0x168 != -1) rec->+0x8 |= p->+0x16c; }`. THREE separate stores to the same word (`mov [edx+8],ebx` with ebx=0, then two `or`), which is why it is an OR and not an assignment; the turn-record pointer is RELOADED from p->+0x3d8 before each. The shift is `mov edi,1; shl edi,cl` with cl = the loop counter, so the bit is the player's POSITION IN THE VECTOR and x86's 5-bit shift mask applies above 31 players. Loop guard is `test edx,0xfffffffc; jle` on the byte count [verified]
constexpr uint32_t StrategyServer_ProcessTurn_AllianceMaskPass = 0x003dc871;
// thiscall void (StrategySim* this /* S+4 */, Game::TurnCommands* first, int count) // applies a run of per-player command blocks: `last = first + count*0x1b4; for (b = first; b != last; b += 0x1b4) <apply every set command in b>`. The 0x1b4 stride is sizeof(Game::TurnCommands) and independently confirms lane Q's layout (27 std::list members at 0x70..0x1a8, stride 0xc, so the class ends at 0x1b4). SIX command handlers are INLINED here and each bumps ModCount once (0x0088fe0a, 0x008902fe, 0x008903b9, 0x0089046c, 0x008905c8, 0x008907bc), each followed by `lea ecx,[base+0x80]; call HandleMap::Resolve` -- which is what proves the base is the StrategySim and not the block [verified]
constexpr uint32_t StrategySim_ApplyTurnCommandBatch = 0x0048f9b0;
// thiscall void (StrategyServer* this /* S frame */) // the End-Turn command flush: computes `count = (this->+0x178 - this->+0x174) / 0x1b4` (signed magic 0x964fda6d, sar 8 -- the reciprocal of 436) and calls StrategySim::ApplyTurnCommandBatch(this+4, this->+0x174, count). The `lea ecx,[esi+4]` at 0x0078f6de is a third independent sighting of the two-bases split. Called from StrategyNetworkClient::OnMessage 0x00784904, immediately before that handler calls StrategyServer::ProcessTurn at 0x0078491c [verified]
constexpr uint32_t StrategyServer_ApplyAllTurnCommands = 0x0038f6a0;
// field std::vector<Game::TurnCommands> in the S frame (begin @+0x174, end @+0x178), element stride 0x1b4. One block per player; the End-Turn flush applies every command in every block, and each application bumps ModCount. This is the wire's `Player.<id>.TurnCommands_v5` custom-data block in memory [verified]
constexpr uint32_t StrategyServer_off_TurnCommandQueue = 0x00000174;
// field sizeof(Game::TurnCommands) = 436, recovered from the container stride in StrategyServer::ApplyAllTurnCommands (`imul ebx,ebx,0x1b4`) and from the signed-division reciprocal 0x964fda6d/sar 8 in the same function. Independent of, and agreeing with, lane Q's field walk, whose last list member sits at 0x1a8 with stride 0xc [verified]
constexpr uint32_t TurnCommands_sizeof = 0x000001b4;
// thiscall void (StrategyNetworkClient* this, Message* m) // vftable 0x00a229f4 slot 6. THE END-TURN DISPATCHER: it is the only direct caller of StrategyServer::ProcessTurn (0x0078491c), of OnAllCombatDone_Tail (0x00784d07) and of StrategyServer::ApplyAllTurnCommands (0x00784904), and it loads the server with `mov ecx,[esi+0x54]` before the first two. NOTE: this class's own `this` is NOT a StrategySim -- its entity hash is at +0x84, not +0x80 -- so the three `inc [reg+4]` sites in this body (0x007850d5, 0x0078514b, 0x00785224) are NOT ModCount and are listed as unresolved rather than counted [verified]
constexpr uint32_t StrategyNetworkClient_OnMessage = 0x00384640;
// field StrategyServer* in the S frame (the base ProcessTurn receives, not S+4). Read at 0x00784901 and 0x00784919 immediately before the command flush and the turn driver [verified]
constexpr uint32_t StrategyNetworkClient_off_Server = 0x00000054;
// thiscall void (StrategyServer* this /* S frame */, ServerSystem* sys, int mode, std::vector<int>* out) // the abandon/chaos check called once per system from ProcessTurn phase 1. ITS FOURTH INSTRUCTION IS A ModCount BUMP: `inc [esi+0x8]` @0x007b9e20 with esi = this = S, so this is the only per-system ModCount writer in the turn. The phase-1 loop gate is `cmp BYTE [sys+0xc4],0; je` -- ServerSystem_off_Abdn -- so the call, and the bump, happen only for systems already flagged abandoned. Abdn is FALSE on all 28 systems of all 11 corpus saves, so this writer contributes 0 on every measured turn and is a rule-6 hypothesis for any turn where it does not [verified]
constexpr uint32_t StrategyServer_AbandonChaosCheck = 0x003b9df0;
// thiscall void (StrategySim* this /* S+4 */, ...) // the fleet-move command handler; logs "StrategySim: Fleet not found.", "StrategySim: Waypoint %d(id) not found." and "StrategySim: (see above) cannot move fleet %d(id)." Bumps ModCount at 0x008657aa. THE ONLY ModCount WRITER OTHER THAN THE ABANDON CHECK THAT IS DIRECT-CALL REACHABLE FROM EITHER TURN DRIVER (from OnAllCombatDone_Tail's 1369-function closure; not from ProcessTurn's 1382) [verified]
constexpr uint32_t StrategySim_MoveFleetCommand = 0x00465780;
// 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]
@ -1361,6 +1385,136 @@ 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;
// thiscall void (Game_ShipDesignDef* this, Mars::Stream* s) // THE DESIGN SERIALIZER LANE D SAID DID NOT EXIST. Slot 1 of the ShipDesignDef vftable 0x009fef64. Writes, in DISK order: WriteBool 'FAIDes' this+0x4, WriteBool 'DHide' this+0x5, WriteBool 'DWep' this+0x6 (a BOOL, not an int -- the campaign schema had it as int; byte-neutral because a 4-char tag makes both items 12 bytes), WriteString 'DName' this+0x8, then THREE 'DSec' frames through StreamableHelper<ShipDesignDef::Section> at this+0x4c, this+0x24, this+0x74 in that order. THREE sections, not five: the ctor 0x00874c70 runs eh_vector_constructor_iterator(this+0x24, stride 0x28, count 3). MEMORY ORDER != WRITE ORDER: the array is [+0x24, +0x4c, +0x74] and the wire is [+0x4c (command), +0x24 (mission), +0x74 (engine)] [verified]
constexpr uint32_t Game_ShipDesignDef_Write = 0x00427390;
// thiscall void (Game_ShipDesignDef* this, Mars::Stream* s) // slot 0 of vftable 0x009fef64. Mirrors Write field for field, same tags, same three DSec frames in the same order [verified]
constexpr uint32_t Game_ShipDesignDef_Read = 0x00427240;
// thiscall void (Game_ShipDesign* this, Mars::Stream* s) // CORRECTS 'Game::ShipDesign::Write (0x008747a0) makes no stream call at all': 0x008747a0 is in NO vftable and is not this class's writer. Game::ShipDesign derives from Game::ShipDesignDef (RTTI 0x00a894a8: ShipDesign, ShipDesignDef, IStreamable, RefCounted, NetworkObject; IStreamable sub-object at +0x9c, NetworkObject at +0xa0). Primary vftable 0x00a32720 slot 1; the +0x9c IStreamable vftable 0x00a32710 reaches it through an adjustor thunk at 0x00874de0. Body: direct call to ShipDesignDef::Write 0x00827390 (the base part), then WriteInt 'Dtc' this+0x134, WriteBool 'Dwgv' this+0x16c, and ONLY IF that flag is set a 'Dwg' frame through StreamableHelper<Game::WeaponGroups> at this+0x170. Dwgv is false in all 11 saves, so the Dwg branch is unexercised (rule 6) [verified]
constexpr uint32_t Game_ShipDesign_Write = 0x004325e0;
// thiscall void (Game_ShipDesign* this, Mars::Stream* s) // primary vftable 0x00a32720 slot 0. Calls ShipDesignDef::Read, then ReadInt 'DRefCnt' into a NULL destination (read and discarded; the WRITER never emits it, so it is a network-stream field the tag-addressed reader tolerates), then 'Dtc', 'Dwgv' and the conditional 'Dwg' [verified]
constexpr uint32_t Game_ShipDesign_Read = 0x004324f0;
// thiscall Game_ShipDesign* (Game_ShipDesign* this) // installs ShipDesignDef::vftable at +0, then IStreamable/NetworkObject at +0x9c/+0xa0 and the three ShipDesign vftables. Constructs the DName string at +0x8 and eh_vector_constructor_iterator(this+0x24, 0x28, 3) -- the three section records. Enumerates sizeof(Game_ShipDesignDef) == 0x9c [verified]
constexpr uint32_t Game_ShipDesign_ctor = 0x00474c70;
// fastcall void (Game_ShipDesign* this) // THE RECOMPUTE THAT PRODUCES BOTH CENSUS WORDS. 16 call sites; one of them stamps this+0x134 (Dtc) and calls straight in. (1) resolves each of the three section records this+0x24/+0x4c/+0x74 to a ShipSectionDef* through the catalog lookup 0x0056edd0 and caches them at this+0xac/+0xb0/+0xb4, IN MEMORY SLOT ORDER; (2) builds a 3-element context array (stride 0x124) and hands it to the aggregator 0x00826af0 together with this+0x130; (3) copies the aggregator's ~0x7c-byte output struct into the design: struct+0x10 -> this+0xb8 (role flags, low dword), struct+0x14 -> this+0xbc (high dword), struct+0x18..+0x64 -> this+0xc0..+0x118, struct+0x74 -> this+0x12c (HULL SIZE), struct+0x78 -> this+0x1a4, struct+0x00..+0x0c -> this+0x138..+0x144. Neither this+0xb8 nor this+0x12c is on the wire: they are rebuilt from the data files whenever a design changes or is loaded [verified]
constexpr uint32_t Game_ShipDesign_UpdateDerivedStats = 0x0047e7c0;
// cdecl void (DesignStatBlock* out, SectionContext ctx[3], void* techCtx, char flag) // walks the three section contexts (stride 0x124, ctx[i]+0 = the ShipSectionDef*, null = empty slot). out[4]/out[5] (the 64-bit role-flag word) |= ShipSectionDef::GetRoleFlags 0x0056ee80 per section -- an OR, so ONE flagged section flags the whole design. out[0x1d] (hull size) = sectionDef+0x260 -- an ASSIGNMENT, so the LAST resolved section in memory slot order wins; every shipped design is section_class-homogeneous so first-wins and last-wins agree on all 503 design records in the corpus. Also: out[6] x5 when any section carries tech 0x2756; out[4] &= ~0x80 for tech 0x2757; out[4] |= 0x100000 when EVERY section carries tech 0x2742. Default hull health (out[2]) is chosen by the SAME 0x400 bit when no section overrides it at +0x2ac: without 0x400 hull 0/1/2 -> 500/3000/15000, with 0x400 -> 100/500/1000 [verified]
constexpr uint32_t Game_ShipDesign_AggregateSectionStats = 0x00426af0;
// thiscall unsigned __int64 (Game_ShipSectionDef* this, void* ctx) // returns CONCAT(this+0x29c, this+0x298) -- the section's 64-bit role-flag word straight out of the parsed .shipsection -- with bit 0x20 of the low dword OR-ed in when ctx is non-null, ctx+0xfc is set and this+0x304 > 0. That conditional bit is the only part of the word that is not pure file data [verified]
constexpr uint32_t Game_ShipSectionDef_GetRoleFlags = 0x0016ee80;
// thiscall Game_ShipSectionDef* (SectionCatalog* this, Game_ShipSectionID* id) // linear scan of the vector at this+0x8/+0xc matching def+0x4 == id->species and def+0x8 == id->sectionId. Returns null for the (0,0) empty slot without scanning [verified]
constexpr uint32_t Game_ShipSectionCatalog_FindByID = 0x0016edd0;
// cdecl bool (int* out, const char* name) // THE HULL-SIZE DEFINITION. _stricmp against "Destroyer", "Cruiser", "Dreadnought" in that order and stores the index it stopped at: 0, 1, 2. Returns false without writing on no match [verified]
constexpr uint32_t Game_ParseShipClassName = 0x0016e1c0;
// cdecl bool (int* out, const char* name) // wraps 0x0056e1c0 for the `section_class` key; on failure logs " [%s] unrecognized ship class" and stores 0, so an unknown OR ABSENT section_class is a destroyer, not an error [verified]
constexpr uint32_t Game_ShipSectionDef_ParseSectionClass = 0x0016e250;
// cdecl void (unsigned __int64* flags, unsigned int loMask, unsigned int hiMask, bool value) // the .shipsection parser's flag setter. Every boolean role key in the section parser 0x005744e0 is one call to this with its own mask pair; `defence_platform` is (lo 0x400, hi 0) at 0x005749b7, `monitor` is (lo 0, hi 0x4), `refinery` 0x8, `mining_capacity` 0x10, `scanrange`/`rebelai_scanrange` 0x20, `gateship` 0x40, `ewar` 0x800, `ramscoop` 0x1000, `aicontrol` 0x2000, `command_quota` 0x10000, `node_bore` 0x20000, `prisoner_capacity` 0x40000, `freighter` 0x80000, `gravboat_bonus` 0x200000, `construction_capacity` 0x400000, `science` 0x1000000, `tradingpost` 0x2000000, `freighterQ` 0x8000000, `police` 0x10000000, `spy` 0x40000000, `spytender` 0x80000000, `refueling_capacity` 0x2, `repair_capacity` 0x4; high dword: `colony_trap` 0x1, `mining_trap` 0x2, `monitor` 0x4, `propaganda` 0x10 [verified]
constexpr uint32_t Game_SetRoleFlagBit = 0x0016e780;
// cdecl void (ServerPlayer* p, int out[8]) // THE CENSUS. Zeroes out[0..7], then walks the server's fleet vector (p+0x8 -> S, S+0x60/+0x64), keeps fleets whose owner (fleet+0x58) is p, and for every ship in fleet+0xa4/+0xa8 takes design = ship+0x14. If (design+0xb8 & 0x400) == 0: ++out[0] and ++out[2 + design->hullSize(+0x12c)]; else ++out[1] and ++out[5 + hullSize]. So out[0]/out[1] are the two grand totals (computed and DISCARDED by the caller), out[2..4] are ships by hull size 0/1/2 and out[5..7] are defence platforms by hull size. The six land at turnRecord+0x2a..+0x34 and reach the wire as the three `cls` groups' `shpt` and `satt` [verified]
constexpr uint32_t Game_ServerPlayer_ShipCensusByHullClass = 0x00418a50;
// cdecl int (Game_ShipDesign* design) // a SECOND classifier over the same two words, kept because it shows the flag word is a role set and not a single bit: returns -1 when the design lacks flag 0x80000 (`freighter`), else for hull size 1 returns 0 when 0x8000000 (`freighterQ`) is set and 1 otherwise, and 2 for any other hull size. 0x0082c7c0 is the matching counter over a fleet list. NOT the census -- neither reads 0x400 [unverified]
constexpr uint32_t Game_ShipDesign_IsMobileWarshipClass = 0x0041a430;
// offset unsigned int // low dword of the design's 64-bit role-flag word, the OR of its sections'. Bit 0x400 = `defence_platform`. High dword at +0xbc. NOT on the wire; rebuilt by 0x0087e7c0 [verified]
constexpr uint32_t ShipDesign_off_RoleFlagsLow = 0x000000b8;
// offset int // 0 destroyer / 1 cruiser / 2 dreadnought, from the last resolved section's `section_class`. NOT on the wire; rebuilt by 0x0087e7c0 [verified]
constexpr uint32_t ShipDesign_off_HullSize = 0x0000012c;
// offset int // the wire field `Dtc`, written by ShipDesign::Write and stamped by at least one caller immediately before it calls the recompute 0x0087e7c0 [unverified]
constexpr uint32_t ShipDesign_off_Dtc = 0x00000134;
// offset int // parsed `section_class`: 0 destroyer / 1 cruiser / 2 dreadnought, 0 when absent or unrecognised [verified]
constexpr uint32_t ShipSectionDef_off_SectionClass = 0x00000260;
// offset unsigned int // low dword of the section's 64-bit role-flag word (high dword at +0x29c), one bit per boolean role key in the .shipsection file [verified]
constexpr uint32_t ShipSectionDef_off_RoleFlagsLow = 0x00000298;
// cdecl void (const char* gamename) /* GameSpy SDK gsAvailable. sprintf("%s.available.gamespy.com"), inet_addr/gethostbyname, UDP socket, sendto port 27900 (htons 0x6cfc) with '\x09\0\0\0\0' + gamename + NUL, len = strlen(gamename)+6. Overridable hostname buffer at 0x00b085b0. Leaves socket = -1 on DNS failure. */ [verified]
constexpr uint32_t GameSpy_GSIStartAvailableCheck = 0x0000a060;
// cdecl int (void) /* returns 0=waiting 1=available 2=unavailable 3=temporarily-unavailable. Socket==-1 (start failed) => returns 1. Retries once after 2000 ms then returns 1. */ [verified]
constexpr uint32_t GameSpy_GSIAvailableCheckThink = 0x0000a210;
// fastcall int (const uint8_t* pkt /*EAX*/, const sockaddr_in* from /*ECX*/, int len, uint32_t* out_status) /* requires len>=7, from.sin_addr/sin_port match, pkt[0..2]==FE FD 09, out = big-endian pkt[3..6]. Returns 0 on match, 1 otherwise. */ [verified]
constexpr uint32_t GameSpy_AvailableCheckParseReply = 0x0000a1a0;
// cdecl void (void) /* resolves natneg1.gamespy.com / natneg2.gamespy.com; UDP port 27901 (htons 0x6cfd) bound in FUN_00412200 */ [verified]
constexpr uint32_t GameSpy_NatNeg_Resolve = 0x00012530;
// cdecl int (SOCKET s, int cookie, int clientindex, void* progresscb, void* completedcb, void* userdata) /* only reachable from the server-browser join path and the host side; the direct-address join never calls it */ [verified]
constexpr uint32_t GameSpy_NNBeginNegotiationWithSocket = 0x00012590;
// cdecl void* (PEERCallbacks* cbs /* 0x74 bytes */) [verified]
constexpr uint32_t GameSpy_peerInitialize = 0x00016be0;
// cdecl int (void* peer, const char* title, const char* secretKey, const char* sbTitle, const char* sbSecretKey, int sbGameVersion, int sbMaxUpdates, int natNegotiate, const int pingRooms[3], const int crossPingRooms[3]) /* SOTS passes title=sbTitle="swordots", key=sbSecretKey="Z5gR9Z", sbGameVersion=[0x00b2d510], sbMaxUpdates=30, natNegotiate=1 */ [verified]
constexpr uint32_t GameSpy_peerSetTitle = 0x00016cc0;
// cdecl int (void* peer, SOCKET s, unsigned short port) [verified]
constexpr uint32_t GameSpy_peerStartReportingWithSocket = 0x00016a50;
// cdecl int (qr2_t* qrec, SOCKET s, int boundport, const char* gamename, const char* secret_key, int ispublic, int natnegotiate, void* serverkey_cb, void* playerkey_cb, void* teamkey_cb, void* keylist_cb, void* count_cb, void* adderror_cb, void* userdata) /* builds "%s.master.gamespy.com", UDP 27900 (htons 0x6cfc); also references 255.255.255.255 for LAN */ [verified]
constexpr uint32_t GameSpy_qr2_init_socket = 0x0001d1a0;
// cdecl void (int keyid, const char* name) [verified]
constexpr uint32_t GameSpy_qr2_register_key = 0x0001e730;
// cdecl void* (const char* queryForGamename, const char* queryFromGamename, const char* queryFromKey, int queryFromVersion, int maxConcUpdates, int queryVersion, int lanBrowse, void* callback, void* instance) [verified]
constexpr uint32_t GameSpy_ServerBrowserNew = 0x00020060;
// cdecl int (void* sb, int startSearch, unsigned short startSearchPort, unsigned short endSearchPort) [verified]
constexpr uint32_t GameSpy_ServerBrowserLANUpdate = 0x00020160;
// cdecl void (void* sb) [mapped]
constexpr uint32_t GameSpy_ServerBrowserHalt = 0x00020110;
// thiscall int (SBServerList* this) /* master index = (unsigned)(fold over tolower(c): h = c - h*0x63306ce7) % 20; sprintf("%s.ms%d.gamespy.com"), TCP port 28910 (htons 0x70ee). Override hostname global at 0x00b09440. For gamename "swordots" the index is 5. */ [verified]
constexpr uint32_t GameSpy_SBServerListConnect = 0x00020840;
// cdecl int (...) /* passes "peerchat.gamespy.com", TCP port 6667 (0x1a0b) to the socket-connect helper at 0x00408fe0 */ [verified]
constexpr uint32_t GameSpy_chatConnectPeerchat = 0x00017ab0;
// thiscall void (AutoJoin* this /*0x90 bytes*/, const void* params, const char* address) /* vftable 0x00a218f4; address std::string at +0x68 */ [verified]
constexpr uint32_t Game_AutoJoin_ctor = 0x0036c6a0;
// thiscall bool (AutoJoin* this) /* vftable slot 2. Runs GSIStartAvailableCheck/Think, stores availability at +5, then AutoJoin_InitServerBrowser */ [verified]
constexpr uint32_t Game_AutoJoin_Think = 0x00378a90;
// thiscall bool (AutoJoin* this) /* refuses when this->available (+5) == 0; peerInitialize + peerSetTitle("swordots","Z5gR9Z") */ [verified]
constexpr uint32_t Game_AutoJoin_InitPeer = 0x003617f0;
// thiscall bool (GameBrowserPanel* this) /* gated on this+0x8a (availability); peerInitialize + peerSetTitle */ [verified]
constexpr uint32_t Game_GameBrowserPanel_InitPeer = 0x00378c40;
// thiscall GameBrowserPanel* (GameBrowserPanel* this, ...) /* calls Game_RegisterQR2Keys then GSIStartAvailableCheck(gamename) */ [mapped]
constexpr uint32_t Game_GameBrowserPanel_ctor_tail = 0x0037e210;
// thiscall void (LANPage* this) /* vftable slot 18. ServerBrowserNew("swordots","swordots","Z5gR9Z", 0, 30, 1, lanBrowse=1, cb, this) into this+0xb9c. NO availability gate. */ [verified]
constexpr uint32_t Game_GameBrowserPanel_LANPage_CreateBrowser = 0x0037dde0;
// thiscall void (LANPage* this) /* ServerBrowserHalt then ServerBrowserLANUpdate(sb, 1, g_LanScanPort, g_LanScanPort + g_LanScanPortRange) */ [verified]
constexpr uint32_t Game_GameBrowserPanel_LANPage_Refresh = 0x0037de50;
// thiscall void (GameBrowserPanel* this) /* news a 0x166c-byte ManualJoinDialog (ctor 0x0077c790, vftable 0x00a22604) and binds Game_ManualJoin_OnAccept as its handler */ [verified]
constexpr uint32_t Game_GameBrowserPanel_ShowManualJoinDialog = 0x0037e590;
// cdecl void (void* sender, int action, GameBrowserPanel* self) /* validates the typed address with Game_ParseHostAddress then App_StartJoin(params, address, serverBrowser = NULL) -- the GameSpy-free join */ [verified]
constexpr uint32_t Game_ManualJoin_OnAccept = 0x0036ef90;
// cdecl bool (std::string* out_host, int* out_port, const char* text) /* sscanf "%d.%d.%d.%d:%d%1s" must yield 4 or 5 fields with every octet <= 255, then sscanf "%[^:]:%d"; out_port = -1 when absent. Dotted-quad only -- hostnames are rejected. */ [verified]
constexpr uint32_t Game_ParseHostAddress = 0x004f64d0;
// thiscall bool (App* this, const void* params, const char* address, void* serverBrowser) /* news Game::StrategyJoin (0xc8 bytes) into this+0x158; serverBrowser may be NULL */ [verified]
constexpr uint32_t App_StartJoin = 0x00499e30;
// thiscall StrategyJoin* (StrategyJoin* this, const void* params /*0x58 by value*/, const char* address, void* serverBrowser) /* vftable 0x00a21904. serverBrowser == NULL => state (+0x80) = 1, direct connect; else state 0, ServerBrowserAuxUpdateIP + NAT negotiation */ [verified]
constexpr uint32_t Game_StrategyJoin_ctor = 0x00377b10;
// thiscall bool (StrategyJoin* this) /* vftable slot 2; jump table at 0x00765948 for states 1..5 */ [verified]
constexpr uint32_t Game_StrategyJoin_Think = 0x00365810;
// thiscall bool (StrategyJoin* this, void* serverBrowser) /* ServerBrowserAuxUpdateIP-style direct query then NNBeginNegotiationWithSocket */ [verified]
constexpr uint32_t Game_StrategyJoin_QueryViaServerBrowser = 0x00370e70;
// thiscall bool (App* this, const char* address, const char* playerName) /* the direct-connect entry reached from StrategyJoin state 1; news a 0x130-byte client into this+0x14c and stamps the version word from 0x00b2d510 */ [verified]
constexpr uint32_t App_ConnectToStrategyHost = 0x0049a7e0;
// thiscall bool (GameBrowserPanel* this) /* picks private vs public SBServer address; logs "same NAT" / "behind NAT, but can still connect directly" / "requires NAT negotiation"; reads SBServerGetIntValue(server, "password", 0) */ [verified]
constexpr uint32_t Game_OnJoinGame_ChooseAddress = 0x00372f60;
// thiscall bool (StrategyHost* this) /* refuses when this+0x24e (availability) == 0; peerInitialize with a 0x74-byte callback table then peerSetTitle */ [verified]
constexpr uint32_t Game_StrategyHost_InitPeer = 0x003c1ee0;
// thiscall void (StrategyHost* this) /* if this+0x2c == 1 && this+0x30 != 0: qr2_init_socket(..., ispublic = 0) -- LAN reporting, no master heartbeat. Otherwise StrategyHost_InitPeer then peerStartReportingWithSocket. */ [verified]
constexpr uint32_t Game_StrategyHost_StartReporting = 0x003c2dd0;
// cdecl void (void) /* 16 qr2_register_key calls: 50..57 slot0..slot7, 58 numslots, 59 mapshape, 60 numsys, 61 turn, 62 scenario, 63 settings, 64 slot_, 65 ranks */ [verified]
constexpr uint32_t Game_RegisterQR2Keys = 0x00498bf0;
// thiscall void (Config* this) /* reads ini section [Network]: SyncCheckStrategy=True, SyncCheckCombat=True, SyncLogStrategy=False, SyncLogCombat=False, HostPort=3369, CombatHostPort=3370, LanScanPort=3369, LanScanPortRange=1, HeartbeatPeriod=15000, ConnectionTimeout=45000, MaxTxMessageSize=512, CombatLatency=1000 */ [verified]
constexpr uint32_t Game_LoadNetworkConfig = 0x001a0610;
// thiscall bool (App* this) /* GetCommandLineW + CommandLineToArgvW; matches the literal "/join", takes the next argv as the address and the one after as an optional second string, then constructs Game::AutoJoin into this+0x158 */ [verified]
constexpr uint32_t Game_ParseJoinCommandLine = 0x0049d280;
// data const char* /* -> "swordots" at 0x00a35c50 */ [verified]
constexpr uint32_t g_GameSpyGameName = 0x00635cd4;
// data const char* /* -> "Z5gR9Z" at 0x00a35c48 */ [verified]
constexpr uint32_t g_GameSpySecretKey = 0x00635cd8;
// data uint32_t /* packed build word; bits 16..23 = minor<<4 | major parsed from "1.8.1", byte 1 = edition flags, low nibble = build flavour. Passed as peerSetTitle sbGameVersion and stamped into the join handshake. */ [verified]
constexpr uint32_t g_GameVersionWord = 0x0072d510;
// data uint16_t /* [Network] LanScanPort, default 3369 */ [verified]
constexpr uint32_t g_LanScanPort = 0x00713c6c;
// data uint32_t /* [Network] LanScanPortRange, default 1; LAN sweep is [LanScanPort, LanScanPort+Range] */ [verified]
constexpr uint32_t g_LanScanPortRange = 0x00713c70;
// data char[] /* when non-empty, replaces "<gamename>.available.gamespy.com" in the availability check */ [mapped]
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 int (void* this, int, int) // the ONE callee through which Game::ServerSpyManager vftable 0x00a3073c slot 13 (0x008877b0) reaches the strategic generator. Exactly ONE incoming reference in the image: an unconditional call at 0x00887af2 inside that slot. Real body 0x008408e0..0x00840a59, 370 bytes. Contains three of lane V2's eight draw sites -- Chance 0x00840929, NextInt 0x008409c7, Chance 0x00840a3c -- all loading the generator as [reg+0x16c]. Named for the verified relationship (which slot reaches it, and that it draws), NOT for any semantics: its body was not read. Lane H hooks it with a register-transparent entry counter so that 'the spy path never fired' can be split into 'slot 13 was not entered' and 'slot 13 was entered and gated above this call' [mapped]
constexpr uint32_t SpyManager_Slot13RngCallee = 0x004408e0;
// thiscall uint (void* fleet, char) // first of the two callees through which Game::ServerTradeManagerImpl vftable 0x00a31b74 slot 13 (0x0088ef80) reaches the strategic generator. Exactly ONE incoming reference: an unconditional call at 0x0088f036. Real body 0x00820ca0..0x00820e53, 436 bytes, containing lane V2's NextFloat site 0x00820e18. Its own body loops the vector at arg+0xa4/+0xa8 testing per-element flags, and its boolean result is what gates the call to TradeManager_Slot13RngCalleeB one instruction later. THE CALLER'S LOOP IS OVER THE FLEETS VECTOR at GetServer()+0x64/+0x68, not over a trade-route list -- which is why 'the tail draws nothing because we have no trade routes' does not explain this slot [mapped]
constexpr uint32_t TradeManager_Slot13RngCalleeA = 0x00420ca0;
// thiscall void (void* this, int) // second of the two callees through which Game::ServerTradeManagerImpl slot 13 (0x0088ef80) reaches the strategic generator, called at 0x0088f042 ONLY when TradeManager_Slot13RngCalleeA returned non-zero. Exactly ONE incoming reference. Real body 0x0088b440..0x0088b976, 1323 bytes, containing lane V2's NextInt site 0x0088b613 (`mov ecx,[ecx+0x16c]; add ecx,4`). The two form a two-stage gate: A draws first and decides whether B runs, so a run where A fires and B does not is a MEASUREMENT, not a gap [mapped]
constexpr uint32_t TradeManager_Slot13RngCalleeB = 0x0048b440;
// thiscall uint32_t (Mars::RNG* this /*ecx = THE OBJECT, not &mt*/) // plain RET, no stack args. THE FOURTH DRAW ENTRY POINT. Whole 84-byte body read from the instruction stream: `cmp [ecx+0x9c8],0; push esi; lea esi,[ecx+4]; jne skip; mov ecx,esi; call RNG_Twist; skip: eax=[esi+0x9c0]; dec [esi+0x9c4]; ecx=*eax; eax+=4; [esi+0x9c0]=eax;` then the standard Mars temper (shr 11 / and 0xff3a58ad shl 7 / and 0xffffdf8c shl 15 / shr 18) and `ret`. EXACTLY ONE MT WORD, UNCONDITIONAL -- no rejection loop, no early-out, no branch except the lazy twist. Contrast RNG_NextFloat and RNG_NextInt, which are entered with ECX = &mt = obj+4; this one takes the object and does the +4 itself. Body ends 0x004f76c3 (Ghidra's 84 is correct here), then 12 int3 to 0x004f76d0. 11 callers image-wide; in StrategyServer::ProcessTurn's direct-call closure at DEPTH 4 via ProcessFleetMovement 0x007da9a0 -> MoveFleet 0x007d9ee0 -> ProbabilisticJump 0x007b6700 @0x007b67e7 [verified]
constexpr uint32_t Mars_RNG_NextUInt = 0x000f7670;
// thiscall float (Mars::RNG* this /*ecx = THE OBJECT*/, float lo, float hi) // RET 8. FIFTH DRAW ENTRY POINT, in no previous lane's primitive set. `add ecx,4; call RNG_NextFloat` then `lo + (float)((hi-lo) * unit)`, with the product STORED TO A FLOAT before the add and the sum stored to a float again -- two roundings, both must be reproduced. EXACTLY ONE MT WORD. In StrategyServer::ProcessTurn's closure at depth 3 via ServerPlayer::ProcessTurn -> 0x00889dc0 (call sites 0x0088a1bd, 0x0088a20f) [verified]
@ -1525,6 +1679,74 @@ constexpr uint32_t GlobalConst_slot_MORALE_DECREASE_OUTPUT_MOD = 0x006ec79c;
constexpr uint32_t GlobalConst_storage_SLAVES_OUTPUT_MOD = 0x0070e9b0;
// data const float 0.5f -- the progress-ratio threshold in ServerPlayer::ProcessTurn's `ResT != NULL && ResErrRoll != 0 && CONST < progressRatio` gate (events.md §3, window 0x008915ec-0x00891624). Read out of dumps/sots.exe: .rdata bytes at 0x00a2c788 are 00 00 00 3f (float 0.5); the following word 0x00a2c78c is float 100.0, so this is a float32, NOT the double an 8-byte read would suggest (that reads as 5.28e13). CONSEQUENCE, measured on the live game: ResErrRoll survives into ProcessResearch only while progress/cost <= 0.5 at the START of the turn, so the OnTechResearched draw (0x0088df20) can fire only when a single turn supplies more than half the target tech's remaining cost. See the board's `research_roll_pending save` row [verified]
constexpr uint32_t ResearchRollProgressThreshold = 0x0062c788;
// cdecl bool (StarFleet* fleet, MapObject* start, MapObject** dests, unsigned count, int* flagsOut, int* failIdxOut, int* typesOut, NodeRoute* routesOut) // 8 STACK ARGS, plain RET, esp cleaned by the caller (add esp,0x20 at both call sites) -- cdecl, NOT thiscall, even though ecx is loaded with the fleet for the range helpers. Real body 0x007066c0..0x00706907 then 8 int3 to the next start 0x00706910; Ghidra's 584 is correct. IT IS NOT A PATH FINDER: no frontier, no visited set, no relaxation, no recursion. It walks the caller's already-chosen destination list and calls ClassifyLeg 0x00703730 once per consecutive pair, accumulating flags (OR) and the index of the FIRST failing leg. RETURNS TRUE for every call with a non-null fleet and non-null start; all outcome information is in flagsOut/failIdxOut. Leading-destination drop at 0x00706722: if dests[0] is the fleet itself or the fleet's current SYSTEM (LocID with +0x14==0), dests is advanced and count decremented IN THE SOLVER'S OWN FRAME -- the caller's count is unchanged, so typesOut/routesOut end up shifted by one and the last element is never written. Three fuel figures: StarFleet_MinRange(fleet,0) for the pre-flight probe, FUN_00705d60(fleet,true) as the refuel reset, FUN_00705d60(fleet,false) as the running budget, clamped to >= 0 at the top of every leg. Per-leg draw-down uses an INLINED Mars_Vec3_Length (three f32 deltas, one narrowing on the sum of squares, one on the sqrt, one on the subtraction). The store of 0x009e22bc into the NodeRoute local on the back edge is the INLINED destructor (the Mars::IStreamable base vftable), not a branch and not a missing re-init -- the ctor runs again at the top of the next iteration. Two callers, both direct, none indirect: FUN_005e6d50 (UI, dry run: flags only, then a confirmation dialog if flags != 0) and StrategyServer_OrderFleetMove 0x008653c0 [verified]
constexpr uint32_t PathSolver = 0x003066c0;
// thiscall int (StarFleet* this, MapObject* from, MapObject* to, float* rangeInOut, int* flagsOut, NodeRoute* routeOut) // RET 0x14. Returns the WAYPOINT TYPE for one leg: 0 (no move possible), the owner's species drive type, 3 (node route), 4 or 5 (gate transit). Real body 0x00703730..0x00703bc9 then 6 int3 to 0x00703bd0. flagsOut may be null (a stack dummy is substituted and the whole flag block at 0x00703846 is skipped). Order of decision: (A) if `to` is a fleet, try to intercept it via FUN_00703650; (B) raise the flag bits; (C) if `to` is a deep-space point the player may not use, raise 0x400; (D) if the player has a gate at one end, check gate traffic against NGts*PrGtTrf and return 4 (gate->gate) or 5 (gate->gateless within CstR); (E) if the species drive type is not 3, return it unchanged -- every non-node race stops here with no range check and no route record; (F) otherwise solve the single node-line hop. Endpoint kinds from MapObject->+0x14: 0 system, 1 fleet, 2 deep-space point. The route record is written ONLY when the returned type is 3; for every other type it is left {nrp:-1, nrf:0, nrt:0} [verified]
constexpr uint32_t StarFleet_ClassifyLeg = 0x00303730;
// constant int // ClassifyLeg flag bit 0x001, set at 0x00703897. Some ship in the fleet is performing a cancellable action; OrderFleetMove cancels them all and proceeds. A WARNING, not a refusal -- the UI's dry run (flags != 0) shows it, the server's mask (flags & 0x418) ignores it [verified]
constexpr uint32_t PathFlag_ShipActionsWillCancel = 0x00000001;
// constant int // ClassifyLeg flag bit 0x002, ORed at 0x00703b10 from the errBits local seeded at 0x00703a0d. The leg's EXISTING node line is beyond the fleet's remaining fuel (LegInRange FUN_006ffa00 false). Not a refusal: OrderFleetMove installs the plan anyway [verified]
constexpr uint32_t PathFlag_NodeLegOutOfRange = 0x00000002;
// constant int // ClassifyLeg flag bit 0x004, set at 0x007039c5 when owner->GTraf(+0x14c) + fleet->+0xc0 would exceed owner->NGts(+0x144) * owner->PrGtTrf(+0x148). The leg then returns type 0. NOT one of OrderFleetMove's refusal bits, so a plan can be installed over gate capacity with a type-0 first waypoint. The fleet's own cost is zeroed first if its CURRENT waypoint is already a gate transit (it is already counted) [verified]
constexpr uint32_t PathFlag_GateTrafficExceeded = 0x00000004;
// constant int // ClassifyLeg flag bit 0x008, set at 0x00703859. THE FIRST OF OrderFleetMove's THREE REFUSAL BITS. The destination is a FLEET that is itself traversing a node route, and no interception point could be computed -- FUN_00703650 requires the mover to be sitting at one of the two ends of the target's node line and the whole line to be in range. Not raised when the target fleet is not node-travelling at all [verified]
constexpr uint32_t PathFlag_CannotInterceptFleet = 0x00000008;
// constant int // ClassifyLeg flag bit 0x010, set at 0x0070386d when ANY ship in the fleet satisfies StarShip_IsGroundedByDamage (destroyed drive). THE SECOND OF OrderFleetMove's THREE REFUSAL BITS. CLEARED again at 0x007039d3 on the successful gate-transit path -- a Hiver gate throw ignores dead drives, the same rule the retreat pipeline reaches from the other side via its species-1 bypass [verified]
constexpr uint32_t PathFlag_FleetGrounded = 0x00000010;
// constant int // ClassifyLeg flag bit 0x020, set at 0x00703b8b. No node line joins the two systems for this player and the fleet lacks the node-bore capability (StarFleet_HasFlagShips(fleet, 0x20000, 0) is false). Returns type 0. Not a refusal bit [verified]
constexpr uint32_t PathFlag_NoNodeLineAndCannotBore = 0x00000020;
// constant int // ClassifyLeg flag bit 0x040, ORed at 0x00703b10 from errBits after it is re-seeded at 0x00703b63. A node line was successfully bored but the leg is still out of fuel range. Distinguishes 'ran out of fuel on a line that already existed' (0x002) from 'ran out of fuel on a line we just made' (0x040) [verified]
constexpr uint32_t PathFlag_BoredLineOutOfRange = 0x00000040;
// constant int // ClassifyLeg flag bit 0x080, set at 0x00703b7d when FUN_006e4de0 (bore a node line between two systems) returns false. Not a refusal bit [verified]
constexpr uint32_t PathFlag_NodeBoreFailed = 0x00000080;
// constant int // ClassifyLeg flag bit 0x100, set at 0x00703a42. A node-drive leg from a deep-space POINT to a SYSTEM whose owner is neither the player nor a player with a positive relation (FUN_00817890). Not a refusal bit [verified]
constexpr uint32_t PathFlag_DestSystemNotFriendly = 0x00000100;
// constant int // ClassifyLeg flag bit 0x200, set at 0x00703a6a. The mirror of 0x100: a node-drive leg from a SYSTEM that is not friendly-owned to a deep-space POINT. Not a refusal bit [verified]
constexpr uint32_t PathFlag_SourceSystemNotFriendly = 0x00000200;
// constant int // ClassifyLeg flag bit 0x400. THE THIRD OF OrderFleetMove's THREE REFUSAL BITS. Two sites: 0x007038c5 (the destination point is in neither of the player's two per-point masks at point+0x8c and point+0x90, and the player is not species 4) and 0x00703ad3 (a node-drive leg to a point the player may not use). At the first site the leg then returns 0 for a gate or node drive and the plain drive type otherwise [verified]
constexpr uint32_t PathFlag_DestPointNotPermitted = 0x00000400;
// constant int // ClassifyLeg flag bit 0x800, set at 0x0070388a. The fleet contains a ship whose current action (ship+0x4c) is exactly 8. Singled out of the general 0x001 warning by masking bit 8 out of the action bitmask before the 0x001 test. A WARNING, not a refusal [verified]
constexpr uint32_t PathFlag_ShipActionEight = 0x00000800;
// constant int // 0x400|0x010|0x008. The literal in `test DWORD PTR [ebp-0x10],0x418` at 0x00865499 -- the only bits that make StrategyServer_OrderFleetMove refuse. On a hit it logs level 2 with the .rdata format at 0x00a31e44, "StrategySim: %s (%s) move not permitted at this time.", with the fleet's FtName(+0x5c) and the owner's name string (owner+0x40), both read through the MSVC std::string SSO test. Every other bit is either advisory or a route-quality complaint the server commits anyway. The UI dry run at 0x005e6da0 instead tests flags != 0, which is what surfaces the whole word to the player [verified]
constexpr uint32_t PathFlag_OrderRefusalMask = 0x00000418;
// cdecl int (int species) // 50 B. A 7-ENTRY JUMP TABLE at 0x0080c804, resolved byte by byte: Human(0)->3, Hiver(1)->0, Tarkas(2)->1, Liir(3)->2, _NPC(4)->0, Zuul(5)->3, Morrigi(6)->6; anything above 6 -> 0. THE ANSWER TO THE TYPE-2 QUESTION: waypoint type 2 is the LIIR drive, and it is unreachable for any node-drive race by construction. The value it returns IS the waypoint type for every leg the gate block and the node-route block decline, so a fleet's default waypoint type is a pure function of its owner's species -- no ship data, no terrain, no tech [verified]
constexpr uint32_t DriveTypeOfSpecies = 0x0040c7d0;
// thiscall int (StarFleet* this) // 118 B, no stack args. Returns 0 for an empty fleet, else DriveTypeOfSpecies(this->PID(+0x58)->Species(+0x5c)), else 0 if the fleet has more than one ship and any ship disagrees. ORIGINAL DEFECT: the disagreement loop at 0x006ff853 re-reads the FLEET's owner species on every iteration instead of indexing ship i, so the compared value is loop-invariant and the loop can never fail. As shipped it is dead code; reproduce it as written rather than 'fixing' it to read per-ship data [verified]
constexpr uint32_t StarFleet_GetDriveType = 0x002ff810;
// thiscall bool (ServerSystem* this, ServerPlayer* p) // 34 B, RET 4. return (this->GFlags(+0xdc) >> p->PlyrIdx(+0x28)) & 1. IDENTIFIES GFlags: combat-retreat-pipeline.md lists +0xdc as 'a second presence source (not read here)' -- it is the per-player GATE mask, and the whole waypoint-type-4/5 branch of ClassifyLeg is built on it [verified]
constexpr uint32_t ServerSystem_HasGate = 0x00344010;
// thiscall bool (ServerPlayer* this, ServerSystem* from, ServerSystem* to) // 150 B, RET 8. return from && to && 0.0f < this->CstR(+0x150) && ServerSystem_HasGate(from,this) && !ServerSystem_HasGate(to,this) && Mars_Vec3_Length(from->Pos - to->Pos) <= this->CstR. GIVES CstR A READER: strategic-turn-internals.md records it as unused; it is the GATE PROJECTION RADIUS -- how far past a gate a fleet can be thrown when the far end has no receiving gate. Its result is exactly the 4-vs-5 choice in ClassifyLeg (`add eax,4` after `setne`), so waypoint type 5 is a Hiver gate throw at a GATELESS system, not the Zuul node bore or Morrigi gravity casting. Length via Mars_Vec3_Length, so two float32 narrowings; the comparison is non-strict [verified]
constexpr uint32_t ServerPlayer_GateProjectionReaches = 0x00418040;
// thiscall int (ServerPlayer* this) // 14 B, no frame: `mov eax,[ecx+0x148]; imul eax,[ecx+0x144]` = this->PrGtTrf(+0x148) * this->NGts(+0x144) -- per-gate traffic times gate count, both saved ints. Compared against GTraf(+0x14c) + the fleet's own int16 cost at fleet+0xc0 [verified]
constexpr uint32_t ServerPlayer_GateTrafficCapacity = 0x0040dc50;
// thiscall float-free bool (StarFleet* this, MapObject* a, MapObject* b, float* rangeOpt) // 170 B, RET 0xc. THE ONLY FLOAT IN THIS SUBSYSTEM THAT DECIDES A FAILURE. dx,dy,dz each stored to a float32 slot; the sum of squares accumulated on the x87 stack and narrowed to float32 ONCE at 0x006ffa46; r = rangeOpt ? *rangeOpt : StarFleet_MinRange(this,0.0f); r = min(r, FUN_006ff710(this)) with both candidates read back from float32 slots; then `fmul st(0),st` computes r*r AND LEAVES IT IN THE REGISTER -- it is never stored. So the comparison is f32(sumsq) <= (double)r*(double)r, NOT f32(sumsq) <= f32(r*r). A reimplementation that narrows the square disagrees exactly at the boundary. Non-strict: equality returns true (test ah,0x41 then jp) [verified]
constexpr uint32_t StarFleet_LegInRange = 0x002ffa00;
// thiscall float (StarFleet* this) // 155 B, no stack args. Min over the fleet's ships of Ship_MaxRange 0x0080c820, seeded FLT_MAX from the .rdata word at 0x009e23a8, EXCEPT that it returns 0.0f (not FLT_MAX) for a fleet with no ships and EXITS EARLY the moment the running minimum is <= 0 -- so it is not a pure min if a zero-range ship precedes a negative one. Used only to cap the range in StarFleet_LegInRange [verified]
constexpr uint32_t StarFleet_MinTankCapacity = 0x002ff710;
// thiscall int (NodeGraph* this, ServerPlayer* p, ServerSystem* a, ServerSystem* b) // 303 B, RET 0xc. THE ONLY GRAPH STRUCTURE IN THE PATH SUBTREE, and it is a SINGLE-HOP ADJACENCY QUERY, never a search: it returns the path index of a node line joining a and b that player p has discovered, or -1. Rejects null args and a==b by system index (+0x5c). Gate: a TRIANGULAR adjacency array at this->+0x24 indexed (hi-1)*hi/2 + lo with one bit per player, so a pair the player has not discovered short-circuits to -1. Then it walks a hash bucket, accepting entries whose {+0xc,+0x10} pair matches in either order and whose +0x2c mask carries the player's bit, and returns entry->+0x8. ORIGINAL DEFECT: the ranking term FUN_006e2130((this->+0x4)->+0x8) depends only on `this`, so it is identical for every candidate; with best seeded at -1 the FIRST matching bucket entry always wins and every later one is dropped on the non-strict `score > best`. As shipped the tie-break is hash-bucket order [verified]
constexpr uint32_t NodeGraph_FindNodeLine = 0x002e4eb0;
// cdecl bool (MapObject* node, StarFleet* fleet) // 85 B. owner = MapObject_GetOwner(fleet); returns true if any fleet parked at the node and owned by that player carries a ship with capability mask 2 (the tanker bit), OR if the node has an owner whose relation to the fleet's owner is >= 3. NOTE the relation scale: strategic-turn-internals.md 5.2 records FUN_0080e050 as '1 ally, 2 NAP, 3 cease-fire', which would make this 'refuel at a cease-fire system but not at an ally's'; FUN_006d2050 was NOT read, and two call sites use the same scale with different thresholds (>= 3 here, > 0 in FUN_00817890), so 5.2's ordering should be re-checked. Called by PathSolver only when the destination's kind tag (+0x14) is 0, i.e. a system -- reaching one resets the running fuel budget to full tanks [verified]
constexpr uint32_t StarFleet_CanRefuelAt = 0x00303c90;
// thiscall ServerPlayer* (MapObject* this) // 26 B, no frame. switch on this->+0x14: 0 (system) -> this->PID(+0x100); 1 (fleet) -> this->PID(+0x58); anything else (2 = deep-space point) -> 0. Confirms the kind tag's three values from a third, independent site [verified]
constexpr uint32_t MapObject_GetOwner = 0x0031e280;
// thiscall MapObject* (MapObject* this) // 12 B, no frame: return (this->+0x14 != 0) ? 0 : this. A checked downcast to the kind-0 (system) case, written with the neg/sbb/not/and branchless idiom [verified]
constexpr uint32_t MapObject_AsSystem = 0x0031e340;
// register-live-in bool (/* ebx = StarFleet* mover, esi = StarFleet* target -- BOTH LIVE-IN, NEITHER WRITTEN */ float* rangeIn, MapObject** systemOut) // 214 B, cdecl stack frame but it TESTS ebx AND esi WITHOUT EVER WRITING THEM. Reading it as a plain two-argument cdecl function produces nonsense; its one caller (StarFleet_ClassifyLeg at 0x00703831) supplies both registers. Returns false unless the target has waypoints, its front waypoint is type 3, and FUN_00703520 accepts the geometry. On success *systemOut is the system to aim at: if the mover sits at the target's destination, aim at the target's node-transit ORIGIN; if it sits at the origin, aim at the destination; otherwise return true with *systemOut left 0. The transit origin is FUN_006ffab0, which resolves FlightPlan.pnd(+0xf8) through the entity hash at (fleet->galaxy(+0x10))+0x80 -- SO pnd IS THE NETWORK ID OF THE NODE TRANSIT'S ORIGIN OBJECT [verified]
constexpr uint32_t StarFleet_SolveFleetIntercept = 0x00303650;
// thiscall MapObject* (StarFleet* this) // 43 B, no frame. Returns 0 when the waypoint vector is empty, else IDMap resolve of this->FPlan.pnd(+0xf8) through (this->galaxy(+0x10))+0x80. Pairs with StarFleet_ResolveWaypoint 0x00701390, which resolves the front waypoint's Wpt id through the same map: origin and destination of the current node transit [verified]
constexpr uint32_t StarFleet_GetNodeTransitOrigin = 0x002ffab0;
// thiscall int (StarFleet* this) // 101 B, no stack args. OR of (1 << ship->+0x4c) over every ship satisfying FUN_0081f880, i.e. every ship whose current action is neither 0 nor 6 and is not action 8 with bit 3 of ship->+0x1c set. ship+0x4c IS THE SHIP'S CURRENT ACTION: OrderFleetMove open-codes the identical three-way predicate at 0x008655ed and calls the 'Ship leaving %s is still doing %s. Cancelling action.' cancel FUN_00849280 on every ship that passes it. The mask feeds ClassifyLeg's warning bits: bit 8 becomes 0x800, anything else becomes 0x001. Nothing bounds-checks the shift, so an action enum >= 32 would be UB [verified]
constexpr uint32_t StarFleet_PendingShipActionMask = 0x002ff990;
// thiscall bool (StarFleet* this) // 80 B, no stack args. True if ANY ship in the fleet satisfies StarShip_IsGroundedByDamage 0x00815090 (a destroyed drive, tested with FLT_EPSILON rather than zero). Sole producer of ClassifyLeg's 0x010 refusal bit [verified]
constexpr uint32_t StarFleet_AnyShipGroundedByDamage = 0x00300240;
// thiscall void (StarFleet* this, Waypoint* wpts, int count, int originId) // 514 B, RET 0xc. Real body 0x00707080..0x00707281 then 14 int3 to 0x00707290. EVERYTHING IT WRITES IS SAVED STATE: GTraf(+0x14c) debited by the int16 at fleet+0xc0 if the OLD front waypoint was a gate transit; FPlan.wpts assigned from a zeroed temp then the new list inserted; FPsp2(+0xd8) 0.0f then recomputed by FUN_00705c70; FPeta2(+0xdc) 0; FPogn2(+0xe0) zeroed then set to the fleet's Pos -- the position the order was given from; FPdpos(+0xec) zeroed then set to the FIRST waypoint target's Pos, resolved through the IDMap at (fleet->galaxy)+0x80 and left zero if it does not resolve; pnd(+0xf8) 0 then originId; FtTrans(+0xfc) = wpts[0].Tp, A SECOND SAVED COPY OF THE FIRST LEG'S WAYPOINT TYPE; FtOrig(+0x100) = the fleet's Pos; then GTraf re-credited if the NEW front waypoint is a gate transit. Checked on all 11 curated saves: FtTrans == wpts[0].Tp on 46 of 46 flight plans [verified]
constexpr uint32_t StarFleet_SetFlightPlan = 0x00307080;
// thiscall void (Waypoint* this, int Tp, const NodeRoute* r) // 34 B, RET 8: this->Tp(+0x8) = Tp; this->nrt.nrp(+0x10) = r->nrp(+0x4); this->nrt.nrf(+0x14) = r->nrf(+0x8); this->nrt.nrt(+0x18) = r->nrt(+0xc). Pins Waypoint = {vptr, int Wpt@+4, int Tp@+8, NodeRoute nrt@+0xc} at 0x1c bytes, cross-checked by the 0x92492493 divide-by-28 at 0x00865594 and the add edi,0x1c stride. The vptr of the destination is not touched [verified]
constexpr uint32_t FlightPlan_Waypoint_Set = 0x003006e0;
// thiscall NodeRoute* (NodeRoute* this) // 24 B, no frame, returns this in eax: vptr = 0x00a1cbdc (the Game::NodeRoute vftable), nrp = -1, nrf = 0, nrt = 0. THE DEFAULT nrp IS -1, NOT 0 -- and -1 is also what ClassifyLeg writes for a freshly bored node line, which is why the Zuul saves carry a mix of -1 and real path indices while the Human save carries only non-negative ones [verified]
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;
// 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]

View file

@ -4,6 +4,7 @@
# The turn logic is a library so the test can drive it in process; `sots_turn` is the CLI.
add_library(sots_app STATIC
alliance.cpp
phase_catalog.cpp
trade_raid.cpp
turn_record.cpp

25
src/app/alliance.cpp Normal file
View file

@ -0,0 +1,25 @@
#include "app/alliance.h"
namespace sots::app {
std::int32_t AllianceMask(const mars::stream::shapes::Player& p, std::size_t vectorIndex) {
// The original clears the word, ORs in the self bit, and then -- only when the player
// carries an alliance id -- ORs in the alliance's own member mask. It is three separate
// stores to the same word, and the middle one is what makes the result an OR rather than
// an assignment.
const std::uint32_t self = 1u << (static_cast<unsigned>(vectorIndex) & 31u);
std::uint32_t mask = self;
if (p.alliances.alid != kNoAlliance) mask |= static_cast<std::uint32_t>(p.alliances.al);
return static_cast<std::int32_t>(mask);
}
std::vector<std::int32_t> RebuildAllianceMasks(
const std::vector<mars::stream::shapes::PlayerEntry>& players) {
std::vector<std::int32_t> out;
out.reserve(players.size());
for (std::size_t i = 0; i < players.size(); ++i)
out.push_back(AllianceMask(players[i].player, i));
return out;
}
} // namespace sots::app

47
src/app/alliance.h Normal file
View file

@ -0,0 +1,47 @@
// The alliance / shared-vision mask -- the spine's fourth phase.
//
// Early in every strategic turn, before anything else in the turn can read it, the driver
// walks the server's player vector and rebuilds one word on each player's turn record from
// scratch. The word is a bitmask of "whose vision this player shares this turn": the player's
// own bit, plus every member of the player's alliance when it is in one.
//
// Two things about it are worth stating in the header because both are places a
// reimplementation goes quietly wrong.
//
// 1. The bit is the player's POSITION IN THE PLAYER VECTOR, not the player's own index
// field. Every other per-player index in the tail -- the turn-results array, the battle
// tally, the archive key -- uses the index field, and this one does not. The shift count
// in the original is the loop induction variable; the index field is never loaded in this
// phase.
// 2. The mask is rebuilt, not accumulated. The word is cleared first, so nothing survives
// from the previous turn.
//
// The word is not itself a save leaf. It reaches the wire only because the last phase of the
// post-combat tail copies the whole turn record into the per-turn statistics archive, which
// IS on the wire -- so this phase is checkable against bytes the original wrote.
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
#include "mars/stream/shapes.h"
namespace sots::app {
// The value the original uses for "this player is in no alliance".
constexpr std::int32_t kNoAlliance = -1;
// One player's mask. `vectorIndex` is the player's position in the server's player vector.
//
// Above 31 players the original's shift wraps -- x86 masks a variable shift count to five
// bits -- so bit (index % 32) would be set. No save in the corpus has more than eight
// players and that path is untested; it is reproduced rather than "fixed" because the point
// of this file is to be the original, not to be correct.
std::int32_t AllianceMask(const mars::stream::shapes::Player& p, std::size_t vectorIndex);
// The whole phase: every player's mask, in vector order.
std::vector<std::int32_t> RebuildAllianceMasks(
const std::vector<mars::stream::shapes::PlayerEntry>& players);
} // namespace sots::app

View file

@ -58,8 +58,12 @@ constexpr PhaseDesc kStrategic[] = {
{Driver::Strategic, 2, "S02", "TradeManagerTurn", PhaseStatus::Stub,
"ServerTradeManager::ProcessTurn -- feeds the trade slot of the budget"},
{Driver::Strategic, 3, "S03", "RegisterTradeSystems", PhaseStatus::Stub, ""},
{Driver::Strategic, 4, "S04", "RebuildAllianceMasks", PhaseStatus::Stub,
"per-player shared-vision / alliance mask, rebuilt from scratch each turn"},
{Driver::Strategic, 4, "S04", "RebuildAllianceMasks", PhaseStatus::Implemented,
"per-player shared-vision / alliance mask, rebuilt from scratch each turn: the player's "
"own bit -- its POSITION IN THE PLAYER VECTOR, not its index field -- OR the alliance's "
"member mask when the player carries an alliance id. The word is not a leaf of its own; "
"it reaches the wire through the tail's turn-record archive, and it agrees with the "
"archived bytes on 72 of 80 player-records with the other 8 predicted"},
{Driver::Strategic, 5, "S05", "BuildShipActionTypeSets", PhaseStatus::Stub,
"builds the two action-type id sets the dispatcher runs over"},
{Driver::Strategic, 6, "S06", "ShipActionsExceptType2", PhaseStatus::Stub,
@ -213,12 +217,12 @@ constexpr PhaseDesc kTail[] = {
{Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""},
{Driver::Tail, 35, "T35", "RebuildPlayerReports", PhaseStatus::Stub, ""},
{Driver::Tail, 36, "T36", "FinalizeTurnRecords", PhaseStatus::Blocked,
"fills every player's turn record and archives it by turn; must stay last. Six of its "
"fills every player's turn record and archives it by turn; must stay last. Seven of its "
"fields are recoverable from the wire and are reproduced -- turn, colony count, savings, "
"the savings delta, the completed-tech count and the summed population -- and the model is "
"self-checked every run against the record the input save already carries for its own "
"turn. Not committed: five further fields of the same record are unmodelled, and savings "
"for the NEW turn comes from a blocked phase"},
"the savings delta, the completed-tech count, the summed population and the alliance mask "
"phase S04 rebuilt -- and the model is self-checked every run against the record the input "
"save already carries for its own turn. Not committed: four further fields of the same "
"record are unmodelled, and savings for the NEW turn comes from a blocked phase"},
};
PhaseTally Tally(const PhaseDesc* p, std::size_t n) {

View file

@ -7,6 +7,7 @@
#include <cstring>
#include <string>
#include "app/alliance.h"
#include "app/trade_raid.h"
#include "app/turn_record.h"
#include "game/sim/colony.h"
@ -284,8 +285,12 @@ TurnRecordAudit AuditTurnRecordsAgainstSave(const SaveGame& game) {
continue;
}
int dangling = 0;
// The archiving phase runs both at the end of a turn and on load, and only the
// end-of-turn path has a spine behind it. The earliest turn the archive carries is
// the one the load path wrote, so its alliance mask is predicted to be zero.
const bool spineRan = inputTurn > EarliestArchivedTurn(hist);
const TurnRecord built = BuildTurnRecord(game.sim.players[i].player, game.sim.systems,
inputTurn, &dangling);
inputTurn, i, spineRan, &dangling);
a.dangling += dangling;
const TurnRecordDiff d = CompareTurnRecord(built, *stored);
++a.playersChecked;
@ -299,7 +304,8 @@ TurnRecordAudit AuditTurnRecordsAgainstSave(const SaveGame& game) {
}
void RunFinalizeTurnRecords(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec,
const TurnRecordAudit& audit) {
const TurnRecordAudit& audit,
const std::vector<std::int32_t>& allianceMasks) {
rec.invocations = static_cast<int>(game.sim.players.size());
// Six fields per player would be written, plus a new archive element per player. Nothing
// is committed by default: five further fields of the same element are unmodelled, and
@ -316,10 +322,15 @@ void RunFinalizeTurnRecords(SaveGame& game, const TurnOptions& opt, PhaseRecord&
++archived;
continue;
}
const TurnRecord built =
BuildTurnRecord(game.sim.players[i].player, game.sim.systems, game.sim.frame);
// The record being archived is this turn's, and this turn ran the spine, so the
// alliance mask is the one phase S04 rebuilt rather than a zero from the load path.
const TurnRecord built = BuildTurnRecord(game.sim.players[i].player, game.sim.systems,
game.sim.frame, i, /*spineRan=*/true);
mars::stream::shapes::PlayerTurnStats s;
s.trn = built.turn;
// The mask the spine's phase 4 rebuilt earlier in this same turn, not a value
// recomputed here: the dependency between the two phases is real and is expressed.
s.almem = i < allianceMasks.size() ? allianceMasks[i] : built.allianceMask;
s.pop = built.population;
s.col = built.colonies;
s.sav = built.savings;
@ -334,11 +345,11 @@ void RunFinalizeTurnRecords(SaveGame& game, const TurnOptions& opt, PhaseRecord&
}
hist.stats.push_back(s);
++archived;
rec.leafWrites += 6;
rec.leafWrites += 7;
}
rec.committed = rec.leafWrites > 0;
if (!opt.commitBlocked) rec.wouldWrite = archived * 6;
rec.notes.push_back(fmt("%s %d record(s) for turn %d; 6 modelled field(s) each",
if (!opt.commitBlocked) rec.wouldWrite = archived * 7;
rec.notes.push_back(fmt("%s %d record(s) for turn %d; 7 modelled field(s) each",
opt.commitBlocked ? "ARCHIVED" : "would archive", archived,
game.sim.frame));
if (audit.playersChecked)
@ -436,6 +447,9 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
PlayerPhaseTotals pt;
SystemTotals st;
bool playerDriverRan = false;
// Filled by S04 and consumed by the tail's archiving phase. Empty until S04 runs, which
// is what makes the ordering between the two visible rather than assumed.
std::vector<std::int32_t> allianceMasks;
for (std::size_t i = 0; i < ns; ++i) {
PhaseRecord rec;
@ -453,6 +467,29 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
"modelled, so the leaf will not match yet");
break;
}
case 4: { // S04 RebuildAllianceMasks
// Rebuilt from scratch every turn, before anything in the turn can read it.
// The word is not a save leaf of its own: it reaches the wire only through
// the tail's archiving phase, so this phase commits nothing here and the
// count of leaves it will cause to move is reported by that phase.
allianceMasks = RebuildAllianceMasks(game.sim.players);
int allied = 0;
for (const auto& e : game.sim.players)
if (e.player.alliances.alid != kNoAlliance) ++allied;
rec.invocations = static_cast<int>(allianceMasks.size());
rec.committed = true;
rec.notes.push_back(fmt("%d mask(s) rebuilt; %d player(s) carry an alliance id",
rec.invocations, allied));
rec.notes.push_back("the mask is not a leaf of its own -- it reaches the wire "
"only through the turn-record archive, so the leaves it "
"moves are counted by the tail's last phase");
if (allied == 0)
rec.notes.push_back("NO player is in an alliance in this save, so this run "
"exercises the self bit only and the alliance term is "
"an instruction-stream reading with no evidence behind "
"it here");
break;
}
case 11: { // S11 SystemTurn
for (auto& e : game.sim.systems)
RunSystemTurn(e.sys, static_cast<int>(game.sim.players.size()), st);
@ -584,7 +621,7 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
rec.committed = true;
rec.notes.push_back(fmt("ModCount -> %d", game.sim.modCount));
} else if (tp[i].index == 36) {
RunFinalizeTurnRecords(game, opt, rec, recordAudit);
RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks);
}
r.records.push_back(rec);
}

View file

@ -1,12 +1,14 @@
#include "app/turn_record.h"
#include <cstdio>
#include <limits>
#include "app/alliance.h"
namespace sots::app {
namespace {
constexpr const char* kUnmodelled[] = {
"alliance/vision mask -- rebuilt by the alliance-mask phase of the spine, which is a stub",
"trade income -- a budget-derived field, blocked behind the per-system money output",
"battles fought -- written by the tail's battle-tally phase, which is a stub",
"systems acquired / lost this turn -- the two counted lists are never non-empty in the "
@ -23,9 +25,12 @@ const char* const* TurnRecord::Unmodelled(std::size_t& count) {
TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p,
const std::vector<mars::stream::shapes::SysEntry>& systems,
std::int32_t frame, int* danglingOwnedSystems) {
std::int32_t frame, std::size_t vectorIndex, bool spineRan,
int* danglingOwnedSystems) {
TurnRecord r;
r.turn = frame;
// Written by the spine's fourth phase, which the load path does not run.
r.allianceMask = spineRan ? AllianceMask(p, vectorIndex) : 0;
r.savings = p.sav;
// The turn's income is the change in savings across the turn, not a budget line: the
// previous-turn savings word is on the wire and is stamped before this turn's savings are
@ -57,6 +62,13 @@ TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p,
return r;
}
std::int32_t EarliestArchivedTurn(const mars::stream::shapes::PlayerTurnHistory& hist) {
std::int32_t earliest = std::numeric_limits<std::int32_t>::max();
for (const auto& s : hist.stats)
if (s.trn < earliest) earliest = s.trn;
return earliest;
}
const mars::stream::shapes::PlayerTurnStats* FindArchivedRecord(
const mars::stream::shapes::PlayerTurnHistory& hist, std::int32_t turn) {
for (const auto& s : hist.stats)
@ -81,6 +93,7 @@ TurnRecordDiff CompareTurnRecord(const TurnRecord& built,
cmp("sav", built.savings, stored.sav);
cmp("inc", built.income, stored.inc);
cmp("tch", built.completedTech, stored.tch);
cmp("almem", built.allianceMask, stored.almem);
return d;
}

View file

@ -27,6 +27,7 @@ struct TurnRecord {
std::int32_t savings = 0;
std::int32_t income = 0; // savings minus previous-turn savings
std::int32_t completedTech = 0; // tech-tree entries in the completed state
std::int32_t allianceMask = 0; // the shared-vision mask the spine's phase 4 rebuilds
// Fields the archive element also carries that this model does NOT fill, kept as a
// published list rather than as silence. Each is blocked on something named.
@ -39,13 +40,26 @@ constexpr std::int32_t kTechStateCompleted = 4;
// Build one player's record from the simulation state. `systemsById` is the save's system
// table indexed the way the player's owned-system ids index it.
//
// `vectorIndex` is the player's position in the player vector -- the alliance mask's bit
// index, and NOT the player's own index field. See app/alliance.h.
//
// `spineRan` says whether the turn whose record this is actually ran the strategic spine.
// The archiving phase runs in two places -- at the end of a turn, and on load -- and the
// alliance mask is written only by the spine. So a record archived by the load path carries
// a zero mask, and that is a positive prediction of this model, not an exclusion: the
// earliest turn every save carries has `almem == 0` on every player, in all eleven saves.
TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p,
const std::vector<mars::stream::shapes::SysEntry>& systems,
std::int32_t frame,
std::int32_t frame, std::size_t vectorIndex, bool spineRan,
// set when an owned-system id is not present in the table, which
// would silently drop a term from the population sum
int* danglingOwnedSystems = nullptr);
// The earliest turn the archive carries for a player. The record for that turn was written
// by the new-game / load path, not by a turn.
std::int32_t EarliestArchivedTurn(const mars::stream::shapes::PlayerTurnHistory& hist);
// What the archive element for `turn` holds, if the save carries one for that turn.
const mars::stream::shapes::PlayerTurnStats* FindArchivedRecord(
const mars::stream::shapes::PlayerTurnHistory& hist, std::int32_t turn);

View file

@ -46,6 +46,17 @@ SectionClass parse_section_class(std::string_view s) {
return SectionClass::Other;
}
int hull_size(SectionClass c) {
switch (c) {
case SectionClass::Cruiser: return 1;
case SectionClass::Dreadnought: return 2;
case SectionClass::Destroyer:
case SectionClass::None:
case SectionClass::Other: return 0;
}
return 0;
}
namespace {
// `option` entries: scalar pairs and blocks share the key; the normalised
@ -189,6 +200,7 @@ Loaded<ShipSectionDef> load_shipsection(const mars::parse::Document& doc, std::s
s.explicit_section = f.opt_bool("explicit_section");
s.autonomous = f.opt_bool("autonomous");
s.nodesign = f.opt_bool("nodesign");
s.defence_platform = f.opt_bool("defence_platform");
out.value = std::move(s);
return out;

View file

@ -38,6 +38,14 @@ std::string_view section_class_name(SectionClass c);
SectionType parse_section_type(std::string_view s);
SectionClass parse_section_class(std::string_view s);
// The hull-size ordinal the game carries alongside the class: 0 destroyer,
// 1 cruiser, 2 dreadnought. It is what the class-name lookup yields -- the
// original walks the same three names case-insensitively and stores the index
// it stopped at, and anything it does not recognise (including an absent
// `section_class`, which leaves the field at its constructed zero) is 0. So
// None and Other are 0 here too, deliberately, and not an error.
int hull_size(SectionClass c);
struct OptionGroup {
std::vector<std::string> members; // tech names, file order
bool scalar = false; // written as `option T` (one member)
@ -103,6 +111,10 @@ struct ShipSectionDef {
std::vector<std::string> exclude;
std::string explicit_command_section, explicit_engine_section;
std::optional<bool> explicit_section, autonomous, nodesign;
// `defence_platform`: one bit of the section's role-flag word. The word is
// OR-ed across a design's sections and read back by the per-hull-class ship
// census, which counts platforms separately from ships. See design/hull.h.
std::optional<bool> defence_platform;
Block raw;

View file

@ -6,6 +6,7 @@
# add_subdirectory(src/game/data); link game_design.
add_library(game_design STATIC
design.cpp
hull.cpp
rules.cpp
stats.cpp
defaults.cpp

View file

@ -1,12 +1,19 @@
// game::design -- the ship-design record and the validator's report types.
//
// A design is what the save file stores per `Des` entry (SHIP_DESIGN_RULES.md
// section 1): a name, two flags, and FIVE section slots of which the engine
// uses three -- command, mission, engine; slots 3 and 4 are reserved and
// always empty. Each slot names a section as (species index, section id in
// that race's `_shipsections.txt`), carries one weapon reference per `bank{}`
// block of the section in file order, and the list of option techs the
// section instance was built with.
// section 1): a name, two flags, and THREE section slots -- command, mission,
// engine. Each slot names a section as (species index, section id in that
// race's `_shipsections.txt`), carries one weapon reference per `bank{}` block
// of the section in file order, and the list of option techs the section
// instance was built with.
//
// The array below holds five, and slots 3 and 4 are inert slack belonging to
// THIS engine, not to the game. The record really is three sections: both
// design writers are recovered and the base one emits exactly three section
// frames, and the constructor builds a three-element array. The earlier "five
// slots, two reserved" reading came from the reference Python reader sweeping
// the derived writer's Dtc and Dwgv tail into its section list. The slack is
// kept only so fixtures generated before that was fixed still load.
//
// This record is deliberately the save's shape; hand-written designs may
// name sections by stem and weapons by stem instead of by id, and the rules

54
src/game/design/hull.cpp Normal file
View file

@ -0,0 +1,54 @@
#include "game/design/hull.h"
#include "game/design/rules.h"
namespace game::design {
HullClass classify_sections(const std::vector<const data::ShipSectionDef*>& sections) {
HullClass h;
for (const data::ShipSectionDef* s : sections) {
if (!s) continue;
++h.resolved_sections;
// Assignment, not max: the last resolved section in slot order wins,
// which is what the original's stats pass does.
h.hull_size = data::hull_size(s->section_class);
// OR: one section carrying the bit makes the whole design a platform.
if (s->defence_platform.value_or(false)) h.defence_platform = true;
}
return h;
}
HullClass classify_design(const data::Catalog& cat, const Design& d) {
const Ruleset rules(cat);
const data::Species race = d.effective_race();
std::vector<const data::ShipSectionDef*> found;
HullClass h;
for (Slot slot : kAggregationOrder) {
const SectionUse& use = d.slot(slot);
if (use.empty()) continue;
const data::ShipSectionDef* s =
rules.resolve_section(use.species == data::Species::Unknown ? race : use.species, use);
if (!s) {
++h.unresolved_sections;
continue;
}
found.push_back(s);
}
const int unresolved = h.unresolved_sections;
h = classify_sections(found);
h.unresolved_sections = unresolved;
return h;
}
void ShipCensus::add(const HullClass& h) {
const std::size_t col = static_cast<std::size_t>(h.hull_size < 0 ? 0 : (h.hull_size > 2 ? 2 : h.hull_size));
if (h.defence_platform) {
++platforms[col];
++total_platforms;
} else {
++ships[col];
++total_ships;
}
}
} // namespace game::design

100
src/game/design/hull.h Normal file
View file

@ -0,0 +1,100 @@
// game::design -- hull size and the defence-platform class flag, and the
// per-hull-class ship census built from them.
//
// WHY THIS EXISTS
// ---------------
// The per-player turn record the game archives every turn carries six ship
// counts: ships by hull size 0/1/2, and *defence platforms* by hull size
// 0/1/2, written to the wire as three `cls` groups of `shpt` (ships total)
// and `satt` (satellites total). Neither the hull size nor the platform flag
// is on the wire anywhere -- both are recomputed from the section catalog
// whenever a design changes -- so the census cannot be reproduced from a save
// alone until a reader can classify a design the way the game does.
//
// HOW THE ORIGINAL DOES IT
// ------------------------
// A design caches two derived words that are recomputed by its stats pass:
//
// * a 64-bit role-flag word, the OR of every resolved section's own flag
// word. `defence_platform` is one bit of it (the low word's 0x400).
// * a hull-size ordinal, assigned -- not OR-ed, not max-ed -- from each
// resolved section's `section_class` in slot order, so the last resolved
// section wins. Every shipped design is class-homogeneous (rule A6), so
// first-wins and last-wins agree on all 503 design records in the save
// corpus; the assignment order is reproduced anyway because it is what
// the original does, and a hand-built mixed-class design would show it.
//
// The census then walks the fleets a player owns, and for every ship takes
// its design's two words: platform bit set -> the platform row, else the ship
// row; hull size picks the column.
//
// NOT THE SAME 0x400
// ------------------
// A *fleet* also carries a flag word whose 0x400 bit is set when the retreat
// pipeline creates a fleet, and it appears on the wire as `FtFlg`. It is a
// different word on a different object and has nothing to do with this one.
// Reusing the numeral is a coincidence of two bit layouts.
#pragma once
#include <array>
#include <cstdint>
#include <vector>
#include "game/data/catalog.h"
#include "game/data/shipsection.h"
#include "game/design/design.h"
namespace game::design {
// What the census needs to know about one design.
struct HullClass {
int hull_size = 0; // 0 destroyer, 1 cruiser, 2 dreadnought
bool defence_platform = false; // any resolved section carries the flag
int resolved_sections = 0; // sections that named a section the catalog holds
int unresolved_sections = 0; // named a section the catalog does not hold
// True when at least one section resolved. A design none of whose sections
// resolve is not classifiable and must not be silently counted as a
// destroyer: callers should treat it as a gap, not as class 0.
bool ok() const { return resolved_sections > 0; }
};
// The order the original's stats pass visits the slots in, which is the design
// record's IN-MEMORY array order and NOT its wire order. The wire writes
// command, mission, engine; the array holds mission, command, engine. The
// distinction only shows on hull size, because that is an assignment: a design
// with an empty engine slot and both other slots filled would take its class
// from the command section here and from the mission section under wire order.
// Rule A3 makes that shape invalid (a mission section without sockets requires
// both partners empty, with sockets requires both filled), so no design in the
// corpus can tell them apart -- which is exactly why the order is spelled out
// rather than left to whichever loop was convenient.
constexpr std::array<Slot, 3> kAggregationOrder{Slot::Mission, Slot::Command, Slot::Engine};
// Classify from already-resolved section definitions, in `kAggregationOrder`.
// Null entries are empty slots and are skipped.
HullClass classify_sections(const std::vector<const data::ShipSectionDef*>& sections);
// Classify a design record against a catalog. Slots are visited in
// `kAggregationOrder`; an empty slot is skipped, and a slot that names a
// section the catalog does not hold is counted in `unresolved_sections` and
// otherwise ignored.
HullClass classify_design(const data::Catalog& cat, const Design& d);
// Six counters: ships and defence platforms, each by hull size.
struct ShipCensus {
std::array<int, 3> ships{}; // designs WITHOUT the platform flag
std::array<int, 3> platforms{}; // designs WITH it
// The original also accumulates the two grand totals and then throws them
// away without storing them; they are kept here because they are free and
// because a test that checks only the six stored counters cannot tell a
// miscounted ship from an unclassified one.
int total_ships = 0, total_platforms = 0;
void add(const HullClass& h);
int ship_total() const { return total_ships; }
int platform_total() const { return total_platforms; }
};
} // namespace game::design

View file

@ -1,4 +1,5 @@
#include "game/design/stats.h"
#include "game/design/hull.h"
namespace game::design {
@ -90,6 +91,8 @@ DesignStats derive_stats(const Ruleset& rules, const Design& d) {
st.maintenance_cost += s->maintenance_cost.value_or(0);
if (s->command_quota) st.command_quota = st.command_quota.value_or(0) + *s->command_quota;
if (st.hull_class.empty() && !s->section_class_text.empty()) st.hull_class = data::fold(s->section_class_text);
// The role-flag word is an OR, so slot order does not matter here.
if (s->defence_platform.value_or(false)) st.defence_platform = true;
for (std::string_view key : capacity_keys())
if (const data::Attr* a = s->raw.find(key)) set_capacity(st, key, a->value);
if (s->section_type == data::SectionType::Engine || (slot == Slot::Mission && !st.ftlspeed && s->ftlspeed)) {
@ -100,6 +103,12 @@ DesignStats derive_stats(const Ruleset& rules, const Design& d) {
}
st.sections.push_back(std::move(sec));
}
// Hull size is an ASSIGNMENT in the record's in-memory slot order, so it is
// taken after the loop above rather than inside it -- that loop runs in wire
// order, which is not the same order. See design/hull.h.
for (Slot slot : kAggregationOrder)
for (const SectionStats& sec : st.sections)
if (sec.slot == slot && sec.section) st.hull_size = data::hull_size(sec.section->section_class);
st.total_cost_estimate = st.section_cost_with_options + st.weapon_cost_per_bank;
return st;
}

View file

@ -37,6 +37,12 @@ struct SectionStats {
struct DesignStats {
std::string race, name;
std::string hull_class; // section_class of the first resolved section (folded)
// The two words the ship census reads. `hull_size` follows the original's
// last-resolved-section-wins assignment, so it can differ from `hull_class`
// (first-wins) on a hand-built mixed-class design; every shipped design is
// class-homogeneous, so they agree on all real data. See design/hull.h.
int hull_size = 0;
bool defence_platform = false;
std::vector<SectionStats> sections; // command, mission, engine order; only resolved slots
double mass = 0;

View file

@ -851,27 +851,42 @@ struct DesignSection { // Game::ShipDesignDef::Section, one DSec of a Des frame
ar.rest(extra);
}
};
// A design is written by TWO serializers, a base and a derived one. The base
// emits FAIDes / DHide / DWep / DName and then EXACTLY THREE section frames;
// the derived one appends Dtc, the Dwgv flag and -- only when that flag is set
// -- the weapon-group frame. An earlier note here said the derived writer made
// no stream call at all; that was a misattributed address, and both writers are
// recovered. See findings/objects/ship-design-catalogue.md in the RE repo.
//
// THREE section frames, not five: the "two extra reserved slots" the reference
// Python reader reports are Dtc and Dwgv swept in by its catch-all tail, and
// the base writer's own loop runs three times over a three-element array.
// The run is still read as a run rather than as a fixed three, so a malformed
// record cannot desynchronise the rest of the player block.
struct Design { // on-disk tags are case variants of the reference names (FAIDes, DHide, DWep, DName)
static constexpr const char* kStreamName = "Des";
bool faiDes = false, dHide = false;
int32_t dWep = 0;
// DWep and Dwgv are BOOLs, not ints -- both writers call the bool
// primitive. With their four-character tags a bool item and an int item are
// the same size on the wire and the values 0/1 are the same bytes, so the
// corpus cannot tell them apart; the writers can.
bool dWep = false;
std::string dName;
// The DSec run is uncounted -- it ends when the next tag stops being DSec.
// Game::ShipDesign::Write makes no stream call at all, so there is no recovered
// serializer for the Des frame itself; the section body is
// Game::ShipDesignDef::Section, which there is, and Dtc/Dwgv are from the saves.
std::vector<DesignSection> sections;
int32_t dtc = 0, dwgv = 0;
int32_t dtc = 0;
bool dwgv = false;
Node weaponGroups; // Dwg; present only when dwgv, which is false in every save available
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.b(R("faiDes", "FAIDes"), faiDes);
ar.b(R("dHide", "DHide"), dHide);
ar.i32(R("dWep", "DWep"), dWep);
ar.b(R("dWep", "DWep"), dWep);
ar.str(R("dName", "DName"), dName);
ar.repeat("DSec", sections, [](Ar& a, DesignSection& e) { a.obj(A("DSec"), e); });
ar.i32(A("Dtc"), dtc);
ar.i32(A("Dwgv"), dwgv);
ar.b(A("Dwgv"), dwgv);
ar.when(dwgv, [&](Ar& a) { a.any(A("Dwg"), weaponGroups); });
ar.rest(extra);
}
};

View file

@ -16,12 +16,19 @@ add_executable(app_test_trade_raid test_trade_raid.cpp)
target_link_libraries(app_test_trade_raid PRIVATE sots_app)
add_test(NAME app_trade_raid COMMAND app_test_trade_raid)
# The alliance mask, as a rule. Pins the three things the corpus cannot separate -- the bit
# index, the OR, and the guard -- so it always runs.
add_executable(app_test_alliance test_alliance.cpp)
target_link_libraries(app_test_alliance PRIVATE sots_app)
add_test(NAME app_alliance COMMAND app_test_alliance)
# The turn-record model against the record the game itself archived; needs the owner's saves.
add_executable(app_test_turn_record test_turn_record.cpp)
target_link_libraries(app_test_turn_record PRIVATE sots_app)
add_test(NAME app_turn_record COMMAND app_test_turn_record)
foreach(_t app_test_catalog app_test_turn app_test_trade_raid app_test_turn_record)
foreach(_t app_test_catalog app_test_turn app_test_trade_raid app_test_alliance
app_test_turn_record)
target_include_directories(${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic)
endforeach()

110
tests/app/test_alliance.cpp Normal file
View file

@ -0,0 +1,110 @@
// The alliance / shared-vision mask, pinned as a rule rather than as a table of observations.
//
// The corpus agrees with this rule on 72 of 80 player-records and predicts the other 8 (the
// zero masks of every save's earliest archived turn, which the load path wrote without a
// spine). That comparison lives in app_test_turn_record, where the stored bytes are. What is
// pinned HERE is the part the corpus cannot separate:
//
// * the bit is the player's position in the vector, not its own index field. Every save in
// the corpus has the two equal, so no comparison against stored bytes can tell them
// apart -- only the instruction stream can, and this test holds the reading.
// * the mask is an OR of the self bit with the alliance's member mask. In the corpus every
// observed alliance mask already contains its member's own bit, so `self | AL` and `AL`
// are indistinguishable there.
// * the guard is on the alliance id, not on the member mask being non-zero. In the corpus
// the two always agree, because a player with no alliance id also has a zero mask.
//
// Three cases that no save exercises, each held here so that a later save which does
// exercise one has something to disagree with.
#include <cstdio>
#include "app/alliance.h"
#include "mars/stream/shapes.h"
static int failures = 0;
#define CHECK_EQ(a, b) \
do { \
const long long va = (long long)(a), vb = (long long)(b); \
if (va != vb) { \
std::printf("FAIL %s:%d %s == %s (%lld != %lld)\n", __FILE__, \
__LINE__, #a, #b, va, vb); \
++failures; \
} \
} while (0)
using mars::stream::shapes::Player;
using mars::stream::shapes::PlayerEntry;
static Player MakePlayer(int plyrIdx, int alid, int al) {
Player p;
p.plyrIdx = plyrIdx;
p.alliances.alid = alid;
p.alliances.al = al;
return p;
}
int main() {
// 1. No alliance: the self bit alone, at the VECTOR index.
for (std::size_t i = 0; i < 8; ++i) {
const Player p = MakePlayer(static_cast<int>(i), sots::app::kNoAlliance, 0);
CHECK_EQ(sots::app::AllianceMask(p, i), 1 << i);
}
// 2. The index field disagrees with the vector position. The original shifts by the loop
// variable and never loads the index field, so the vector position wins. No save in the
// corpus has these two apart; this is the instruction-stream reading, pinned.
{
const Player p = MakePlayer(/*plyrIdx=*/7, sots::app::kNoAlliance, 0);
CHECK_EQ(sots::app::AllianceMask(p, 2), 1 << 2);
}
// 3. In an alliance whose member mask does NOT already contain the player's own bit.
// Every alliance in the corpus does contain it, so `self | AL` and `AL` are the same
// number there. Here they are not, and the OR is what is being held.
{
const Player p = MakePlayer(/*plyrIdx=*/1, /*alid=*/0, /*al=*/0x0c); // bits 2 and 3
CHECK_EQ(sots::app::AllianceMask(p, 1), 0x0e); // 1|2|3
}
// 4. The guard is the alliance id, not the member mask. A stale member mask with no
// alliance id contributes nothing. Unobserved in the corpus, where the two never differ.
{
const Player p = MakePlayer(/*plyrIdx=*/1, sots::app::kNoAlliance, /*al=*/0x0c);
CHECK_EQ(sots::app::AllianceMask(p, 1), 0x02);
}
// 5. An alliance id of zero is a real alliance. -1 is the only "none", and a check
// written as `if (alid)` would drop every player of alliance 0 -- which is the only
// alliance id that appears anywhere in the corpus.
{
const Player p = MakePlayer(/*plyrIdx=*/2, /*alid=*/0, /*al=*/0x0c);
CHECK_EQ(sots::app::AllianceMask(p, 2), 0x0c);
}
// 6. The whole phase, in vector order, and the shape the corpus actually shows: two
// allied players carrying the same member mask, the rest alone.
{
std::vector<PlayerEntry> players(8);
for (std::size_t i = 0; i < players.size(); ++i)
players[i].player = MakePlayer(static_cast<int>(i), sots::app::kNoAlliance, 0);
players[2].player = MakePlayer(2, 0, 0x0c);
players[3].player = MakePlayer(3, 0, 0x0c);
const std::vector<std::int32_t> m = sots::app::RebuildAllianceMasks(players);
CHECK_EQ(m.size(), players.size());
const std::int32_t want[8] = {1, 2, 0x0c, 0x0c, 0x10, 0x20, 0x40, 0x80};
for (std::size_t i = 0; i < 8; ++i) CHECK_EQ(m[i], want[i]);
}
// 7. The shift wraps above 31, because x86 masks a variable shift count to five bits.
// Untested against any game -- no save has more than eight players -- and reproduced
// rather than corrected, so that a reimplementation running a 32-player game diverges the
// same way the original does instead of diverging differently.
{
const Player p = MakePlayer(0, sots::app::kNoAlliance, 0);
CHECK_EQ(sots::app::AllianceMask(p, 32), 1);
CHECK_EQ(sots::app::AllianceMask(p, 33), 2);
}
std::printf("app_test_alliance: %d failure(s)\n", failures);
return failures ? 1 : 0;
}

View file

@ -11,6 +11,7 @@
//
// Reads $SOTS_SAVES_DIR at run time and skips cleanly when it is unset. No .sav enters this
// repo.
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <string>
@ -18,6 +19,7 @@
#include <dirent.h>
#include "app/alliance.h"
#include "app/turn_record.h"
#include "mars/stream/save.h"
@ -54,6 +56,10 @@ int main() {
}
int files = 0, players = 0, fields = 0, dangling = 0, noArchive = 0;
// Reported rather than assumed: how much of the alliance rule the corpus actually
// exercises. A green run over records that are all `alid == -1` would test the self bit
// and nothing else, and would look identical to a green run that tested everything.
int alliedRecords = 0, loadWrittenRecords = 0, indexDiffers = 0;
for (const std::string& path : saves) {
mars::stream::SaveDocument doc;
try {
@ -78,12 +84,21 @@ int main() {
continue;
}
int dang = 0;
const sots::app::TurnRecord built =
sots::app::BuildTurnRecord(sim.players[i].player, sim.systems, sim.frame, &dang);
// The archiving phase runs at the end of a turn AND on load, and only the
// end-of-turn path has a spine behind it to have written the alliance mask. So
// the earliest turn the archive carries is predicted to have a zero mask -- a
// positive claim the corpus checks on 8 records, not a field skipped.
const bool spineRan =
sim.frame > sots::app::EarliestArchivedTurn(sim.turnstats.players[i].hist);
const sots::app::TurnRecord built = sots::app::BuildTurnRecord(
sim.players[i].player, sim.systems, sim.frame, i, spineRan, &dang);
dangling += dang;
// An owned-system id the save's table does not carry would drop a term from the
// population sum without any other symptom, so it is a failure, not a note.
CHECK(dang == 0);
if (sim.players[i].player.alliances.alid != sots::app::kNoAlliance) ++alliedRecords;
if (!spineRan) ++loadWrittenRecords;
if (sim.players[i].player.plyrIdx != static_cast<std::int32_t>(i)) ++indexDiffers;
const sots::app::TurnRecordDiff diff = sots::app::CompareTurnRecord(built, *stored);
++filePlayers;
fileFields += diff.compared;
@ -103,11 +118,21 @@ int main() {
// failure mode this campaign has paid for twice.
CHECK(files > 0);
CHECK(players > 0);
CHECK(fields == players * 6);
CHECK(fields == players * 7);
std::printf("app_test_turn_record: %d save(s), %d player-record(s), %d field(s) compared, "
"%d player(s) with no archive element, %d dangling owned-system id(s), "
"%d failure(s)\n",
files, players, fields, noArchive, dangling, failures);
// Coverage of the alliance rule, stated as loudly as the verdict (earned rule 15).
std::printf(" alliance mask: %d of %d record(s) carry an alliance id; %d were written by "
"the load path and are predicted to be zero; %d record(s) have a vector "
"position that differs from the player index field\n",
alliedRecords, players, loadWrittenRecords, indexDiffers);
if (indexDiffers == 0)
std::printf(" NOT SEPARATED by this corpus: every player's vector position equals its "
"index field, so no comparison here can tell `1 << position` from "
"`1 << index`. The instruction stream is what settles it; app_alliance "
"holds that reading.\n");
return failures ? 1 : 0;
}

View file

@ -2,7 +2,7 @@
# build_and_run.sh (plain g++). Include from the root with
# add_subdirectory(tests/game_design) after add_subdirectory(src/game/design).
add_executable(game_design_unit_tests
test_main.cpp mini_catalog.cpp test_structure.cpp test_banks.cpp test_tech.cpp test_stats.cpp test_json.cpp)
test_main.cpp mini_catalog.cpp test_structure.cpp test_banks.cpp test_tech.cpp test_stats.cpp test_hull.cpp test_json.cpp)
target_link_libraries(game_design_unit_tests PRIVATE game_design)
target_include_directories(game_design_unit_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME game_design_unit COMMAND game_design_unit_tests)
@ -15,3 +15,11 @@ add_test(NAME game_design_realdata COMMAND game_design_realdata_test) # SKIPs
add_executable(game_design_dump_designs stock_designs.cpp dump_designs.cpp)
target_link_libraries(game_design_dump_designs PRIVATE game_design)
target_include_directories(game_design_dump_designs PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
# The ship census against the census the game archived; needs the owner's saves
# AND a data root, and skips cleanly without either.
add_executable(game_design_census_test test_census_saves.cpp)
target_link_libraries(game_design_census_test PRIVATE game_design mars_stream)
target_include_directories(game_design_census_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(game_design_census_test PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME game_design_census COMMAND game_design_census_test)

View file

@ -32,13 +32,14 @@ LIB=(
"$ROOT/src/game/data/strings.cpp" "$ROOT/src/game/data/catalog.cpp"
"$ROOT/src/game/design/design.cpp" "$ROOT/src/game/design/rules.cpp"
"$ROOT/src/game/design/stats.cpp" "$ROOT/src/game/design/defaults.cpp"
"$ROOT/src/game/design/hull.cpp"
)
mkdir -p "$BUILD"
echo "== building unit tests"
"$CXX" "${FLAGS[@]}" "${LIB[@]}" "$HERE/test_main.cpp" "$HERE/mini_catalog.cpp" "$HERE/test_structure.cpp" \
"$HERE/test_banks.cpp" "$HERE/test_tech.cpp" "$HERE/test_stats.cpp" "$HERE/test_json.cpp" -o "$BUILD/unit_tests"
"$HERE/test_banks.cpp" "$HERE/test_tech.cpp" "$HERE/test_stats.cpp" "$HERE/test_hull.cpp" "$HERE/test_json.cpp" -o "$BUILD/unit_tests"
echo "== running unit tests"
"$BUILD/unit_tests"

View file

@ -53,12 +53,18 @@ bool load_stock_designs(const std::string& path, std::vector<StockDesign>& out,
des.default_weapons = d["dWep"].as_int();
des.race = game::data::parse_species(sd.race_text);
if (!d["known_techs"].is_null()) des.known_techs = strings(d["known_techs"]);
// Three entries: command, mission, engine. Fixtures generated
// before the reference reader stopped sweeping the design's Dtc and
// Dwgv tail into the section list carry five, the last two empty;
// both are accepted so an old fixture still loads.
const Value& raw = d["raw_slots"];
if (!raw.is_array() || raw.items.size() != static_cast<std::size_t>(kSlotCount)) {
err = des.name + ": raw_slots must have " + std::to_string(kSlotCount) + " entries";
if (!raw.is_array() || raw.items.size() < 3 ||
raw.items.size() > static_cast<std::size_t>(kSlotCount)) {
err = des.name + ": raw_slots must have 3 to " + std::to_string(kSlotCount) + " entries";
return false;
}
for (int i = 0; i < kSlotCount; ++i) {
const int slots = static_cast<int>(raw.items.size());
for (int i = 0; i < slots; ++i) {
const Value& r = raw.items[static_cast<std::size_t>(i)];
SectionUse& u = des.slots[static_cast<std::size_t>(i)];
int species = r["species"].as_int();

View file

@ -0,0 +1,225 @@
// The per-hull-class ship census, checked against the census the game itself
// wrote.
//
// Every save carries the archived per-player turn record for its own frame,
// and six of that record's fields are the ship census: `shpt` and `satt` in
// each of the three `cls` groups. Neither the hull size nor the platform flag
// that produce those six numbers is on the wire -- both are recomputed from
// the section catalog -- so this test rebuilds them from $SOTS_DATA_DIR and
// compares against bytes the original produced.
//
// It is NOT a live comparison against the running game: it is a comparison
// against what the game archived. Needs $SOTS_SAVES_DIR and $SOTS_DATA_DIR;
// skips cleanly when either is unset. No save and no game data enters the
// repo.
#include <array>
#include <cstdio>
#include <cstdlib>
#include <map>
#include <string>
#include <vector>
#include <dirent.h>
#include "game/data/catalog.h"
#include "game/design/hull.h"
#include "mars/stream/save.h"
using namespace game::data;
using namespace game::design;
namespace shapes = mars::stream::shapes;
static int failures = 0;
#define CHECK(c) \
do { \
if (!(c)) { \
std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); \
++failures; \
} \
} while (0)
namespace {
// One save `Des` record, classified. The record names its sections as
// (species index, id in that race's manifest), which is exactly what the
// catalog's id lookup takes.
HullClass classify_saved_design(const Catalog& cat, const shapes::Design& d) {
std::vector<const ShipSectionDef*> found;
int unresolved = 0;
for (const shapes::DesignSection& s : d.sections) {
const int species = s.sec.a, id = s.sec.b;
if (species == 0 && id == 0) continue; // empty slot
if (species < 0 || species >= kSpeciesCount) {
++unresolved;
continue;
}
const ShipSectionDef* def =
cat.section_by_id(species_name(static_cast<Species>(species)), id);
if (!def) {
++unresolved;
continue;
}
found.push_back(def);
}
HullClass h = classify_sections(found);
h.unresolved_sections = unresolved;
return h;
}
const shapes::PlayerTurnStats* archived(const shapes::PlayerTurnHistory& hist, std::int32_t turn) {
for (const shapes::PlayerTurnStats& s : hist.stats)
if (s.trn == turn) return &s;
return nullptr;
}
} // namespace
int main() {
const char* savesDir = std::getenv("SOTS_SAVES_DIR");
const char* dataDir = std::getenv("SOTS_DATA_DIR");
if (!savesDir || !*savesDir || !dataDir || !*dataDir) {
std::printf("game_design_census: SOTS_SAVES_DIR / SOTS_DATA_DIR unset, skipped\n");
return 0;
}
DIR* dir = opendir(savesDir);
if (!dir) {
std::fprintf(stderr, "game_design_census: cannot open %s\n", savesDir);
return 1;
}
std::vector<std::string> saves;
while (struct dirent* e = readdir(dir)) {
const std::string n = e->d_name;
if (n.size() > 4 && n.compare(n.size() - 4, 4, ".sav") == 0)
saves.push_back(std::string(savesDir) + "/" + n);
}
closedir(dir);
if (saves.empty()) {
std::printf("game_design_census: no .sav in %s, skipped\n", savesDir);
return 0;
}
const Catalog cat = load_catalog(dataDir);
if (cat.sections.empty()) {
std::fprintf(stderr, "game_design_census: no sections under %s\n", dataDir);
return 1;
}
int files = 0, compared = 0, mismatches = 0, nonzero = 0;
int designsSeen = 0, designsUnclassified = 0, shipsWithoutDesign = 0;
int hullAssignmentDiffers = 0;
std::array<int, 6> nonzeroByLeaf{}; // cls0.shpt, cls0.satt, cls1.shpt, ...
for (const std::string& path : saves) {
mars::stream::SaveDocument doc;
try {
doc = mars::stream::read_save_file(path);
} catch (const std::exception& ex) {
std::printf(" %s: unreadable (%s), skipped\n", path.c_str(), ex.what());
continue;
}
if (doc.count(mars::stream::Issue::Error)) {
std::printf(" %s: parse errors, skipped\n", path.c_str());
continue;
}
++files;
const auto& sim = doc.game.sim;
// Design ids are unique across the whole game, so one map serves every
// player's fleets. Legacy (rider) designs are in the same id space.
std::map<std::int32_t, HullClass> byId;
for (const shapes::PlayerEntry& pe : sim.players) {
for (const auto* list : {&pe.player.designs, &pe.player.legacyDesigns}) {
for (const shapes::DesignEntry& de : *list) {
HullClass h = classify_saved_design(cat, de.des);
++designsSeen;
if (!h.ok()) ++designsUnclassified;
// First-wins vs last-wins: rule A6 makes every shipped
// design class-homogeneous, so a difference here would be
// news. Counted rather than asserted.
int first = -1;
for (const shapes::DesignSection& s : de.des.sections) {
if (s.sec.a == 0 && s.sec.b == 0) continue;
if (s.sec.a < 0 || s.sec.a >= kSpeciesCount) continue;
if (const ShipSectionDef* d2 =
cat.section_by_id(species_name(static_cast<Species>(s.sec.a)), s.sec.b)) {
first = hull_size(d2->section_class);
break;
}
}
if (h.ok() && first >= 0 && first != h.hull_size) ++hullAssignmentDiffers;
byId[de.desID] = h;
}
}
}
// Player object id -> index into sim.players, which is the index the
// turn-statistics archive is keyed by.
std::map<std::int32_t, std::size_t> playerIndex;
for (std::size_t i = 0; i < sim.players.size(); ++i) playerIndex[sim.players[i].playerID] = i;
std::vector<ShipCensus> built(sim.players.size());
for (const shapes::FleetEntry& fe : sim.fleets) {
auto it = playerIndex.find(fe.flt.pid);
if (it == playerIndex.end()) continue; // unowned / NPC-pool fleet
ShipCensus& c = built[it->second];
for (const shapes::ShipEntry& se : fe.flt.ships) {
auto d = byId.find(se.ship.desID);
if (d == byId.end() || !d->second.ok()) {
++shipsWithoutDesign;
continue;
}
c.add(d->second);
}
}
int fileCompared = 0, fileBad = 0;
for (std::size_t i = 0; i < sim.players.size(); ++i) {
if (i >= sim.turnstats.players.size()) break;
const shapes::PlayerTurnStats* stored =
archived(sim.turnstats.players[i].hist, sim.frame);
if (!stored) continue;
for (std::size_t k = 0; k < stored->classes.size() && k < 3; ++k) {
const shapes::ClassStats& cs = stored->classes[k];
const int wantShips = cs.shpt, wantPlat = cs.satt;
const int gotShips = built[i].ships[k], gotPlat = built[i].platforms[k];
for (int which = 0; which < 2; ++which) {
const int want = which ? wantPlat : wantShips;
const int got = which ? gotPlat : gotShips;
++compared;
++fileCompared;
if (want != 0) {
++nonzero;
++nonzeroByLeaf[k * 2 + static_cast<std::size_t>(which)];
}
if (want != got) {
++mismatches;
++fileBad;
std::printf(" %s p%zu cls%zu %s: built %d != archived %d\n", path.c_str(), i,
k, which ? "satt" : "shpt", got, want);
}
}
}
}
std::printf(" %-34s turn %3d %3d compared %d mismatched\n",
path.c_str(), sim.frame, fileCompared, fileBad);
}
CHECK(files > 0);
CHECK(compared > 0);
CHECK(mismatches == 0);
CHECK(shipsWithoutDesign == 0);
CHECK(designsUnclassified == 0);
std::printf("game_design_census: %d saves, %d designs, %d census leaves, %d mismatched\n",
files, designsSeen, compared, mismatches);
std::printf(" coverage: %d of %d leaves are NONZERO in the archive; per leaf "
"(cls0 shpt/satt, cls1 shpt/satt, cls2 shpt/satt) = %d/%d %d/%d %d/%d\n",
nonzero, compared, nonzeroByLeaf[0], nonzeroByLeaf[1], nonzeroByLeaf[2],
nonzeroByLeaf[3], nonzeroByLeaf[4], nonzeroByLeaf[5]);
std::printf(" a zero leaf agrees for free -- the leaves with no nonzero observation are "
"UNEXERCISED, not verified\n");
std::printf(" designs whose first- and last-resolved section disagree on hull size: %d "
"(rule A6 predicts 0)\n", hullAssignmentDiffers);
if (failures) std::printf("game_design_census: %d FAILURES\n", failures);
return failures ? 1 : 0;
}

View file

@ -0,0 +1,160 @@
// Hull size, the defence-platform class flag, and the six-counter census.
#include "game/design/hull.h"
#include "game/design/stats.h"
#include "mini_catalog.h"
#include "test_main.h"
using namespace game::data;
using namespace game::design;
namespace {
Design platform() {
Design d;
d.name = "Light Defense Platform";
d.race = Species::Human;
d.mission() = use("DEDefencePlatform", {"bal_gauss", "bal_gauss", "bal_gauss", "bal_gauss", "mis"});
return d;
}
Design cruiser() {
Design d;
d.name = "Cruiser";
d.race = Species::Human;
d.command() = use("CRCommand", {});
d.mission() = use("CRArmor", {});
d.engine() = use("CRFission", {});
return d;
}
} // namespace
TEST(hull_size_from_section_class) {
CHECK_EQ(hull_size(SectionClass::Destroyer), 0);
CHECK_EQ(hull_size(SectionClass::Cruiser), 1);
CHECK_EQ(hull_size(SectionClass::Dreadnought), 2);
// An absent or unrecognised class is a destroyer, not an error: the
// original's name lookup fails and leaves the field at zero.
CHECK_EQ(hull_size(SectionClass::None), 0);
CHECK_EQ(hull_size(SectionClass::Other), 0);
}
TEST(hull_class_of_a_stock_destroyer) {
HullClass h = classify_design(mini_catalog(), armor());
CHECK(h.ok());
CHECK_EQ(h.hull_size, 0);
CHECK(!h.defence_platform);
CHECK_EQ(h.resolved_sections, 3);
CHECK_EQ(h.unresolved_sections, 0);
}
TEST(hull_class_of_a_cruiser) {
HullClass h = classify_design(mini_catalog(), cruiser());
CHECK(h.ok());
CHECK_EQ(h.hull_size, 1);
CHECK(!h.defence_platform);
}
TEST(defence_platform_flag_is_read_from_the_section) {
const ShipSectionDef* s = mini_catalog().section("Human", "DEDefencePlatform");
CHECK(s != nullptr);
if (s) CHECK(s->defence_platform.value_or(false));
const ShipSectionDef* armorsec = mini_catalog().section("Human", "DEArmor");
CHECK(armorsec != nullptr);
if (armorsec) CHECK(!armorsec->defence_platform.value_or(false));
HullClass h = classify_design(mini_catalog(), platform());
CHECK(h.ok());
CHECK(h.defence_platform);
CHECK_EQ(h.hull_size, 0);
CHECK_EQ(h.resolved_sections, 1);
}
TEST(one_flagged_section_makes_the_whole_design_a_platform) {
// The flag word is OR-ed across sections, so a design that mixes a
// flagged section with unflagged ones is still a platform. No shipped
// design does this -- the platforms are all standalone mission sections --
// so this is the rule, tested where the data cannot show it.
Design d = armor();
d.mission() = use("DEDefencePlatform", {"bal_gauss", "bal_gauss", "bal_gauss", "bal_gauss", "mis"});
HullClass h = classify_design(mini_catalog(), d);
CHECK(h.defence_platform);
CHECK_EQ(h.resolved_sections, 3);
}
TEST(hull_size_takes_the_last_resolved_section_in_memory_order) {
// Assignment, not first-wins and not max, and the order is the record's
// in-memory array order -- mission, command, engine -- not its wire order.
// Class-mixing is a rule A6 error and an engine-less two-slot design breaks
// rule A3, so this can only be shown on designs the validator rejects,
// which is exactly why it is worth pinning.
CHECK(kAggregationOrder[0] == Slot::Mission);
CHECK(kAggregationOrder[1] == Slot::Command);
CHECK(kAggregationOrder[2] == Slot::Engine);
Design d;
d.race = Species::Human;
d.command() = use("DECommand", {"bal_gauss"});
d.mission() = use("CRArmor", {});
HullClass h = classify_design(mini_catalog(), d);
CHECK_EQ(h.resolved_sections, 2);
CHECK_EQ(h.hull_size, 0); // the destroyer COMMAND section, visited last
Design r;
r.race = Species::Human;
r.command() = use("CRCommand", {});
r.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis"});
CHECK_EQ(classify_design(mini_catalog(), r).hull_size, 1); // the cruiser command section
// With the engine slot filled, the engine section wins whatever the others say.
Design e;
e.race = Species::Human;
e.command() = use("DECommand", {"bal_gauss"});
e.mission() = use("DEArmor", {"bal_gauss", "bal_gauss", "mis"});
e.engine() = use("CRFission", {});
CHECK_EQ(classify_design(mini_catalog(), e).hull_size, 1);
}
TEST(an_unresolvable_design_is_reported_not_counted_as_a_destroyer) {
Design d;
d.race = Species::Human;
d.mission() = use("NoSuchSection", {});
HullClass h = classify_design(mini_catalog(), d);
CHECK(!h.ok());
CHECK_EQ(h.resolved_sections, 0);
CHECK_EQ(h.unresolved_sections, 1);
}
TEST(derive_stats_carries_the_two_census_words) {
DesignStats st = derive_stats(mini_rules(), armor());
CHECK_EQ(st.hull_size, 0);
CHECK(!st.defence_platform);
DesignStats p = derive_stats(mini_rules(), platform());
CHECK_EQ(p.hull_size, 0);
CHECK(p.defence_platform);
DesignStats c = derive_stats(mini_rules(), cruiser());
CHECK_EQ(c.hull_size, 1);
CHECK_EQ(c.hull_class, "cruiser");
}
TEST(census_counts_ships_and_platforms_separately) {
ShipCensus c;
HullClass de; // destroyer ship
HullClass cr; cr.hull_size = 1; cr.resolved_sections = 1;
HullClass dn; dn.hull_size = 2; dn.resolved_sections = 1;
HullClass pl; pl.defence_platform = true; pl.resolved_sections = 1;
de.resolved_sections = 1;
c.add(de); c.add(de); c.add(cr); c.add(dn); c.add(pl); c.add(pl); c.add(pl);
CHECK_EQ(c.ships[0], 2);
CHECK_EQ(c.ships[1], 1);
CHECK_EQ(c.ships[2], 1);
CHECK_EQ(c.platforms[0], 3);
CHECK_EQ(c.platforms[1], 0);
CHECK_EQ(c.platforms[2], 0);
CHECK_EQ(c.ship_total(), 4);
CHECK_EQ(c.platform_total(), 3);
}