Compare commits

...

3 commits

Author SHA1 Message Date
alex
1754cc20ac board: lane P2 nav classifier; type 2 is the Liir drive, a naming error not a gap 2026-09-08 12:45:27 -04:00
alex
728348b4a0 lane A2: the alliance mask read from the bytes, and every ModCount writer
Spine phase 4, byte for byte at 0x007dc871-0x007dc8c7. Three separate stores to
the same word -- clear, OR the self bit, then conditionally OR the alliance
mask -- which is what makes the alliance term an OR and is invisible in any
summary of the phase. The bit is the player's POSITION IN THE VECTOR.

Checked against the almem bytes the game archived: 72 of 80 player-records
agree, and the other 8 are predicted by the same model (FinalizeTurnRecords also
runs on load, and the load path does not run the spine, so every save's earliest
archived turn carries a zero mask -- true on all 11).

Reported as loudly: the corpus cannot separate `1 << vectorIndex` from
`1 << PlyrIdx` (0 of 80 records differ), cannot separate the OR from a plain
assignment (every observed alliance mask already contains its member's own bit),
and cannot separate the ALid guard from an AL != 0 guard. Those three are
instruction-stream readings only.

ModCount, named and enumerated. StrategyServer::Write tags both words itself, so
S+0x8 is the wire's ModCount and S+0xc is Frame -- addresses.json has the name
on the wrong word, and turn-driver.md's "they stay in lockstep" is corrected in
place. RTTI gives the reason for the two bases: Game::StrategySim is a base
sub-object at offset 4.

29 writer sites: 20 command handlers plus 6 inlined in the command-batch applier
(each an unconditional bump on ENTRY, before validation), the two turn drivers,
and the abandon/chaos check -- which is gated on Abdn, false on all 28 systems
of all 11 saves. So the per-turn delta is 2 + one per command applied out of
every player's TurnCommands block; the 0x1b4 container stride independently
confirms lane Q's block layout. It is not derivable from the pre-turn save.

The residual is a watchpoint, specified with its own written prediction (exactly
12 hits on turn1-state) and four falsifiers. Direct-call reachability is stated
as the lower bound it is, per lane V2's indirect-edge measurement.

Engine side: sots-engine wip/alliance 5e409cf.
2026-09-08 12:44:30 -04:00
alex
e30619630e lane P2: the path solver read from the instruction stream, and OrderFleetMove's three failure bits
FUN_007066c0 is not a path finder. It performs no graph search: it walks the caller's
already-chosen destination list and classifies each consecutive pair through
FUN_00703730, accumulating flags and the index of the first failing leg. The only graph
structure in the subtree is a single-hop adjacency query. A multi-hop node route in a save
is n waypoints, one per hop.

OrderFleetMove's three failure bits, from the 0x418 literal: 0x008 the destination fleet
is on a node route and no intercept could be solved, 0x010 a ship's drive is destroyed,
0x400 the destination point is not one the player may use. The other nine bits are
advisory or route-quality complaints the server commits anyway -- the UI's dry run tests
the whole word, which is what separates the two groups.

Waypoint type 2 is the Liir drive, not a node line. The species-to-drive jump table maps
Human and Zuul (the two node races) to 3 and Liir to 2, so lane O's 20+ all-type-3
observations were forced by the table. Nothing is unreachable and nothing needs fixing;
exercising type 2 needs a Liir fleet, not a node line.

Also: GFlags(+0xdc) is the per-player gate mask; CstR is the gate-projection radius and
type 5 is a gate throw at a gateless system, not the Zuul bore; pnd is the node transit's
origin id; FtTrans is a second saved copy of the first waypoint's type. Two original
defects recorded as shipped (a loop-invariant drive comparison, a loop-invariant node-line
ranking term) and one predicted (the leading-destination drop shifting the output arrays).

P1/P2/P5 written before the run and checked offline against all 11 saves: 58 waypoints,
46 flight plans, 0 failures. 37 addresses filed; merge generates 912, validated to a
scratch path.
2026-09-08 12:43:34 -04:00
10 changed files with 2657 additions and 7 deletions

View file

@ -212,3 +212,10 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| corrections from lane N | subsystems | verified | high | 100% | 2026-09-08 | strategic-turn-internals.md 3.3 had the SHAPE of the output formula wrong, and 0x007483b0 is the over-harvest RESOURCE demand (its population is Res, not Pop; species +0x4c is ADDED, not a cap) - both corrected in place. AND LANE N CORRECTED ITSELF BEFORE VM TIME: its own 4.2 prediction mis-evaluated the strip-mine branch as a min when it is a SUBSTITUTION, so Gamma Cephei's fraction is 2.0 and its resource term 9000, not 4500 - caught by the FIRST HOST TEST, before any VM run. Static-only and labelled hypotheses (unexercised): the over-harvest branch and its max(v,1) floor (SRoh is 0 on every save), the station factor, the slave term, both morale branches, addiction, the capacity surplus |
| bankruptcy divisor FIXED | engine | verified | high | 100% | 2026-09-08 | kBankruptcyInterestDivisor = -0.15000000596046448 named in the header, with tests at maxIncome 3/6/9 (-19/-39/-59, WHERE IT FIRST BITES) and at 238592 and 3000001. Closes the defect lane Y found and correctly escalated from the repo's "one ulp on large empires" understatement to "6 of 25 corpus records get a different BnkEl than the game wrote" |
| VM AUTOSAVE CONTENTS OVERWRITTEN (recoverable) | meta | verified | high | 100% | 2026-09-08 | Lane N reported this rather than papering over it: its two End Turns overwrote the three autosave CONTENTS on VM140 (they now hold its turn-3 Human and turn-6 Zuul states). THE FILE SET - names and count - IS UNCHANGED, so every documented Load-dialog row position still holds. Lane O's byte-identical autosaves are gone from the VM AND LANE N DID NOT FAKE A RESTORE from lane F's older, differently-sized snapshot. NOT A REAL LOSS: all 11 curated saves live in the REPO (verify/results/saves/), and the determinism-oracle hashes are REPRODUCIBLE by loading ref-turn2 and ending one turn - any VM lane can regenerate them. Record the regenerated hashes when someone next does |
| FUN_007066c0 is NOT a path finder | control-flow | verified | high | 95% | 2026-09-08 | Lane P2, instruction-verified over raw .text to the next function start (Ghidra's size was RIGHT on all four functions this time - recorded because rule 17 exists, not because it was violated). THERE IS NO GRAPH SEARCH: no frontier, no visited set, no relaxation, no recursion. It is a PER-LEG CLASSIFIER AND VALIDATOR over a destination list THE CALLER ALREADY CHOSE - a helper classifies each consecutive pair and returns the waypoint type; this walks the chain, ORs the flags, records the first failing index, draws down fuel and resets it at refuelling systems. CDECL WITH 8 ARGS (not thiscall), and it ALWAYS RETURNS TRUE past the null checks - all outcome data is in out-params. The only graph structure in the whole subtree is a SINGLE-HOP ADJACENCY QUERY over a triangular per-player bit matrix; a multi-hop node route in a save is n waypoints, one per hop. NEW FAILURE MODE: two helpers READ ebx/esi LIVE-IN WITHOUT EVER WRITING THEM, so reading them as plain cdecl gives nonsense (adjacent to lane B5's tail-call-thunk-in-an-argument-list trap) |
| WAYPOINT TYPE 2 EXPLAINED - it is a NAMING ERROR | verify | verified | high | 100% | 2026-09-08 | THREE LANES CIRCLED THIS AND THE ANSWER IS THAT THE NAME WAS WRONG. A 7-entry jump table re-verified from raw bytes maps SPECIES to waypoint type: **Human->3, Hiver->0, Tarkas->1, Liir->2, NPC->0, Zuul->3, Morrigi->6**. TYPE 2 IS THE LIIR DRIVE. Lane O's 20+ observations were all on the two NODE-DRIVE races, both forced to 3 - so nothing is unreachable and NOTHING NEEDS FIXING. "Node line" is the wrong name for type 2 (its speed profile is the STUTTER_* curve, slowest at a star), which is exactly why IsNodeWaypoint accepts only 3 - CONSISTENT, NOT ANOMALOUS. This also puts B4's "the type-2 node-line step is wrong by construction" in doubt: that claim was made about a type it had misidentified. Exercising type 2 needs a LIIR FLEET, not a node line - far cheaper than the save lane M was contemplating |
| OrderFleetMove's three failure bits | control-flow | verified | high | 100% | 2026-09-08 | `test DWORD [ebp-0x10],0x418` at 0x865499 = 0x400|0x010|0x008, exactly lane B5's three. 0x008 destination is a fleet on a node route and no intercept could be solved (you must sit at one end of its line); 0x010 a ship's drive is destroyed, CLEARED on a successful gate leg (a gate ignores dead drives - the same rule B5 found from the retreat side); 0x400 the destination point is not one this player may use. The OTHER NINE BITS are advisory route-quality complaints the server commits anyway. PROOF OF THE SPLIT: the UI caller does a DRY RUN and tests `flags != 0` to raise a confirm dialog, while the server tests `& 0x418`. Notably 0x004 (gate traffic exceeded) DOES NOT REFUSE - a plan can be installed over capacity with a type-0 waypoint |
| nav precision: one float decides a failure | verify | verified | high | 100% | 2026-09-08 | Lane P2 ranked precision by CONSEQUENCE rather than listing it. FUN_006ffa00 is the only float that decides a failure: three f32 deltas, sum-of-squares narrowed ONCE, range = min(rangeLeft, tankCap) from f32 slots - and then `fmul st(0),st` computes r*r AND NEVER STORES IT. The comparison is `f32(sumsq) <= (double)r^2`, NON-STRICT. Writing f32(r*r) - the natural mirror of every other narrowing here - DISAGREES EXACTLY AT THE BOUNDARY. Leg length is Mars_Vec3_Length INLINED (lane M's five narrowings verbatim, plus a third narrowing on the subtraction). fpu_cw=0x127f throughout |
| P2 predictions: written first, then checked offline | verify | verified | high | 100% | 2026-09-08 | Three predictions written from the disassembly BEFORE checking, then run against all 11 saves: **58 waypoints, 46 flight plans, 0 failures**. nrt == Wpt on all 57 type-3 records; the nrf chain closes through `pnd` across the three-hop plans; nrp splits exactly as the branch structure says - Human (cannot bore) has only real indices, Zuul has a MIX of real indices and -1, which is what the bore path writes. AND A BONUS: turn3-state.sav's Tp=1 with nrt{-1,0,0} can only be a TARKA fleet by species elimination - a live confirmation of a SECOND table row from a save nobody was looking at. Also corrected: type 5 is a HIVER GATE THROW at a gateless system within CstR, NOT the Zuul bore; CstR now has a reader; GFlags(+0xdc) is the per-player GATE MASK (closing lane B5's open item); `pnd` is the node transit's ORIGIN id; FtTrans is a SECOND SAVED COPY of wpts[0].Tp |
| P4: a ~1-minute VM test of a predicted ORIGINAL bug | verify | backlog | — | 0% | 2026-09-08 | Lane P2, for whoever holds VM140: order a fleet to the system it is ALREADY AT, then onward. The leading-destination drop happens in the solver's frame only, so types[]/routes[] end up SHIFTED BY ONE and the last slot is never written. PREDICTED SYMPTOM: the final waypoint saves as Tp 0 with an empty route. Note the framing - a CLEAN result FALSIFIES the reading rather than merely failing to confirm it |
| P2 honest boundary | meta | verified | high | 100% | 2026-09-08 | Fully read: FUN_007066c0, three siblings and 20 helpers. NOT read: FUN_00705d60's tanker fold past ~0x90 bytes (the input to EVERY range decision, the largest remaining hole in the NUMBERS), the node bore, the ranking term and hash walk inside FindNodeLine, the relation scale, and MoveFleet's two classifier call sites. THREE INDIRECT EDGES ON THE MAIN LINE UNRESOLVED, the most important being `(fleet->galaxy)->vft[1]()`, the node-graph getter the whole node branch hangs off - vtable_map.py plus an image-wide E8/E9 sweep confirm the function has exactly two direct callers and no tail-call thunks, but those three are OUTBOUND edges lane V2's inversion does not reach. AND NOT ONE LEG HAS EVER BEEN OBSERVED EXECUTING UNDER AN INSTRUMENT - only its saved output has. Also caught internally: one test check was VACUOUS (the float32-squaring probe never fired) - found and fixed with an unconditional witness |

View file

@ -0,0 +1,354 @@
# The alliance mask (spine phase 4), and every writer of `ModCount`
Lane A2, 2026-09-08. Static + host, no VM held. Engine worktree `wip/alliance` off `main`
`b2bad30`; the prediction is `sots-engine/docs/A2-alliance-and-modcount.md`, committed
(`49ae628`) before any implementation. Program `sots` / "Sword of the Stars.exe",
ImageBase 0x00400000, all addresses VAs.
**Method.** Every control-flow and offset claim below was read from the instruction stream —
`objdump -b binary -m i386 -M intel` over the raw image, file offsets from the PE section table.
The decompiler was not used at all in this lane. Reachability came from a whole-image direct-call
edge sweep built with `tools/x86disp.py`'s decoder, sweeping each body **to the next function
start** (rule 17).
---
## 0. The two results, up front
1. **The alliance mask is closed.** `almem == (1 << vectorIndex) | (ALid != -1 ? AL : 0)`, and it
agrees with the bytes the game wrote on **72 of 80 player-records**; the other 8 are
*predicted* by the same model. `S04` is implemented and `T36`'s regression on the reference
pair drops from **17 to 9**.
2. **`ModCount` is a command counter, and it is not derivable from the pre-turn save.** It is
`StrategySim+0x4` (= `S+0x8`), bumped once on entry to each of **26 command-application
sites**, once by each turn driver, and once per abandoned-system check. The per-turn delta is
the number of commands applied, and the AI's commands are generated inside the turn. Static
reading has taken this as far as it goes; the residual is a **watchpoint**, specified in §3.
---
## 1. Spine phase 4 — the alliance / shared-vision mask
### 1.1 The bytes
`StrategyServer::ProcessTurn` 0x007dc871–0x007dc8c7, byte for byte:
```
007dc871 mov edx,[esi+0x58] ; Players._Mylast (esi = S)
007dc874 sub edx,[esi+0x54] ; - Players._Myfirst
007dc877 xor ecx,ecx ; i = 0
007dc879 test edx,0xfffffffc
007dc87f jle 0x7dc8c8 ; empty vector -> skip the whole phase
007dc881 mov eax,[esi+0x54]
007dc884 mov eax,[eax+ecx*4] ; p = Players[i]
007dc887 mov edx,[eax+0x3d8] ; rec = p->turnRecord
007dc88d mov edi,0x1
007dc892 shl edi,cl ; bit = 1 << i <-- the LOOP COUNTER
007dc894 mov [edx+0x8],ebx ; rec->almem = 0 (ebx == 0)
007dc897 mov edx,[eax+0x3d8] ; ... reloaded
007dc89d or [edx+0x8],edi ; rec->almem |= bit
007dc8a0 cmp DWORD PTR [eax+0x168],0xffffffff ; ALid == -1 ?
007dc8a7 je 0x7dc8b8
007dc8a9 mov edx,[eax+0x3d8] ; ... reloaded again
007dc8af mov eax,[eax+0x16c] ; AL
007dc8b5 or [edx+0x8],eax ; rec->almem |= AL
007dc8b8 mov edx,[esi+0x58]
007dc8bb sub edx,[esi+0x54]
007dc8be inc ecx
007dc8bf sar edx,0x2
007dc8c2 xor ebx,ebx
007dc8c4 cmp ecx,edx
007dc8c6 jl 0x7dc881
```
So:
```c
for (int i = 0; i < numPlayers; ++i) {
ServerPlayer* p = Players[i];
TurnRecord* rec = p->turnRecord; // +0x3d8
rec->almem = 0; // +0x8
rec->almem |= 1 << i;
if (p->ALid != -1) rec->almem |= p->AL; // +0x168, +0x16c
}
```
Three separate stores to the same word. That is what makes it an **OR** rather than an
assignment, and it is not visible in any summary of the phase. The record pointer is reloaded
from `p->+0x3d8` before each store.
**Lane T's reading of this phase (`turn-driver.md` §1 row 4) is correct in every particular**,
including the load-bearing one: the bit index is the player's **position in the server's player
vector**, not `PlyrIdx`. Lane T's `ServerPlayer_off_TurnRecord`, `_off_AllianceId` and
`_off_AllianceMask` already carry the rule; this lane's duplicate address entries were dropped
and the agreement recorded instead. The only thing added here is the byte-level range (lane T's
table gives it as `0x007dc8c8–0x007dc8c6`, which is a transcription slip — the phase runs
0x007dc871–0x007dc8c7 and 0x007dc8c8 is where phase 5 begins).
### 1.2 The output is on the wire, and it agrees
`p->+0x3d8` is the per-player turn record that tail phase 36 archives into
`/Sim/turnstats/nply[]/hist/stats[]`; `almem` is the archive element's second int. So the phase
is checkable against bytes the original wrote, with no game.
Over the eleven-save corpus, one element per player per save at the save's own frame:
| | records | result |
|---|---:|---|
| `almem == (1 << i) \| (ALid != -1 ? AL : 0)` | **72** | agree |
| `almem == 0` on `turn1-state.sav` | **8** | **also predicted** — see below |
| disagree | **0** | |
`sots-engine/tests/app/test_turn_record.cpp` now compares **7** fields, not 6:
**11 saves, 80 player-records, 560 fields, 0 mismatches.**
### 1.3 The eight zeros are the model, not an exception
`turn1-state.sav` is the game's first frame and every one of its eight `almem` values is 0 while
the rule says 1, 2, 12, 12, 16, 32, 64, 128. That is falsifier **F5** of the written prediction,
and it fires exactly as the prediction said it would:
`FinalizeTurnRecords` 0x0078a0e0 is called from **two** places — the tail (0x007d98ba) and
`LoadGame` (0x007ddc40) — and `almem` is written **only** by spine phase 4. A record archived by
the load / new-game path therefore carries the turn record's initial zero. The corpus confirms
this on every save independently: the archive is cumulative, and in **all eleven** saves the
`trn == 1` element is `almem == 0` for every player while every `trn >= 2` element matches the
rule. `zuul-turn23-fleet23.sav` carries 23 elements per player and shows the same shape.
The engine expresses this as a positive claim rather than a skipped field: `BuildTurnRecord`
takes a `spineRan` flag, the caller sets it from `frame > EarliestArchivedTurn(hist)`, and the
model **predicts zero** for the load-written element. All 80 records are compared.
### 1.4 What the corpus does NOT separate — reported as loudly as the agreement
Three parts of the rule are instruction-stream readings that **no comparison against these saves
can test**, and the run prints the fact every time:
| claim | why the corpus cannot separate it |
|---|---|
| the bit is `1 << vectorIndex`, **not** `1 << PlyrIdx` | every player in every save has `PlyrIdx == i`. **0 of 80 records separate them.** |
| the alliance term is an **OR** with the self bit | every observed alliance mask already contains its own member's bit (`AL == 12` on players 2 and 3, `AL == 4` on player 2), so `self \| AL == AL` there |
| the guard is `ALid != -1`, **not** `AL != 0` | in the corpus `AL == 0` exactly when `ALid == -1`, so the two guards never disagree |
14 of 80 records do carry a live alliance id, so the alliance term is **exercised** — falsifier F7
did not bite — but it is exercised only in the shape where the three readings above coincide.
`sots-engine/tests/app/test_alliance.cpp` pins all three as unit cases with the separating inputs
the corpus lacks, so a later save that does separate them has something to disagree with.
One more untested branch, reproduced rather than corrected: `shl edi,cl` masks the count to five
bits, so a 32nd player would set bit 0. No save has more than 8 players.
---
## 2. `ModCount` — every writer
### 2.1 It is `S+0x8`, and `addresses.json` has the name on the wrong word
`StrategyServer::Write` 0x0079fa70 tags its own members. With `edi = this`:
```
0079fb2f lea edx,[edi+0x08] ; push "ModCount" (0x00a23844)
0079fb40 lea eax,[edi+0x0c] ; push "Frame" (0x00a2383c)
0079fb4f mov eax,[edi+0x14] ; push "GameID" (0x00a238f0)
0079fb90 mov eax,[edi+0x16c] ; push "RNG" (0x00a23828)
```
The last line settles the frame: `StrategyServer_off_RNGPtr` is `0x168` in the **S+4** frame, so
`[edi+0x16c]` means `edi = S`. Therefore
* **`S+0x8` is the wire's `ModCount`** — the word `StrategyServer::ProcessTurn`'s first
instruction bumps;
* **`S+0xc` is the wire's `Frame`** — the word `BeginProcessTurn` bumps, that `TechTree::SetResearched`
stamps as `turnResearched`, and that `FinalizeTurnRecords` uses as the archive key.
**Corrections that follow, stated plainly (rule 11):**
1. `ghidra/addresses.json`'s `StrategyServer_off_ModCount` (offset `0x8`, S+4 frame, i.e. `S+0xc`)
carries the name of the *other* word. It is `Frame`. **Flagged for the integrator rather than
edited here**, because `addresses.json` is the shared file the per-lane fragments exist to keep
out of. `ghidra/addresses.d/lane-a2.json` adds `StrategySim_off_ModCount` (0x4) and
`StrategySim_off_Frame` (0x8) with the correction in their prototypes.
2. `turn-driver.md` §0.1's "the word at `S+0x8` has never been named" and "both advance once per
turn … so they stay in lockstep" are wrong on both counts. Lane K corrected the lockstep half
in `combat-done-tail.md` §7.1 and lane Z named the word there. This lane re-derives the name
independently from the serializer and confirms it. §0.1 is corrected in place.
3. RTTI gives the reason the two bases exist: `Game::StrategyServer` has bases
`Mars::IStreamable` at offset 0 and **`Game::StrategySim` at offset 4** (vftables 0x00a26084
and 0x00a26034). The "RAW base" every `StrategyServer_off_*` is expressed in is the
`StrategySim` sub-object. `ModCount` is `StrategySim+0x4`.
### 2.2 The writers: a command counter
A whole-image sweep for `inc dword [reg+4]` and `inc dword [reg+8]` at real instruction
boundaries, with the base proved to be a `StrategySim` by an independent fingerprint — the same
register feeding `lea ecx,[base+0x80]; call 0x008b9240`, the entity-hash lookup that
`addresses.json` already places at `StrategySim+0x80`.
**Twenty command handlers, each bumping once on entry:**
| VA | the command, from its own log string |
|---|---|
| 0x00821a80 | set research rate |
| 0x00821b40 | boost research |
| 0x00821b90 | surrender |
| 0x00821c30 | abandon system |
| 0x00821cf0 | set fleet auto-support |
| 0x00821d70 | set fleet to guard |
| 0x00821e20 | set fleet to raid |
| 0x0083d5d0 | remove design |
| 0x0083d6e0 | set research project |
| 0x00842f40 | cancel build order |
| 0x00849460 | (no log string; same shape) |
| 0x00849520 | set ship action |
| 0x00849570 | set fleet name |
| 0x0085b6d0 | ship requesting support |
| 0x00865780 | **move fleet** ("StrategySim: … cannot move fleet") |
| 0x00865910 | set fleet layout |
| 0x0086c3e0 | set system rates |
| 0x008784e0 | set player notes |
| 0x00882910 | create design |
| 0x0088bed0 | transfer ships |
Plus **six more inlined** inside `StrategySim::ApplyTurnCommandBatch` 0x0088f9b0 (0x0088fe0a,
0x008902fe, 0x008903b9, 0x0089046c, 0x008905c8, 0x008907bc) — cancel project, set civilian
ratios, set performance mods, set weapon groups, and two more its strings name.
**The bump is unconditional and precedes validation.** In every one of the twenty it is the
second or third instruction of the body, *before* the handle lookup that decides whether the
command is even applicable. A command naming a player that does not exist still advances
`ModCount`.
**The shape of a turn**, from `StrategyNetworkClient::OnMessage` 0x00784640 — the single direct
caller of all three:
```
0x00784904 StrategyServer::ApplyAllTurnCommands(S) ; -> ApplyTurnCommandBatch(S+4, blocks, nPlayers)
0x0078491c StrategyServer::ProcessTurn(S) ; +1
0x00784d07 StrategyServer::OnAllCombatDone_Tail(S) ; +1
```
`ApplyAllTurnCommands` 0x0078f6a0 computes `count = (S->+0x178 − S->+0x174) / 0x1b4` — signed
magic `0x964fda6d`, `sar 8`, the reciprocal of **436** — and hands the run to the batch applier.
**0x1b4 = sizeof(`Game::TurnCommands`)**, which independently confirms lane Q's field walk
(27 `std::list` members at 0x70…0x1a8, stride 0xc, so the class ends at 0x1b4). So `ModCount`'s
per-turn delta is:
> **2 (the two drivers) + one per command applied out of every player's `TurnCommands` block**
> (+ one per abandoned system, §2.3).
That is exactly what "12 to 44 times a turn" looks like, and it is why the delta is not a
function of the board: `human-turn2→turn3` is 28 and `zuul-turn16→turn17` is 16 on boards with
**identical** system and player counts (28 and 7).
### 2.3 The one writer inside the turn drivers, and it is gated shut
`FUN_007b9df0` — the abandon/chaos check that spine **phase 1** calls once per system — bumps
`ModCount` as its fourth instruction (`inc [esi+8]` @0x007b9e20, `esi = ecx = S`, entered from
0x007dc7fd `push edi; mov ecx,esi; call 0x7b9df0`).
The phase-1 loop gate is `cmp BYTE PTR [edi+0xc4],0; je <next>` and `ServerSystem+0xc4` is
**`Abdn`**. `Abdn` is **false on all 28 systems of all 11 saves**, so this writer contributes
**0** on every measured turn. Per rule 6 that makes its per-turn cost a hypothesis, not a
measurement — but it also means it is not the missing term.
A direct-call reachability sweep from each driver over the whole image:
| root | closure | `ModCount` writers reachable |
|---|---:|---|
| `StrategyServer::ProcessTurn` 0x007dc6c0 | 1382 | `FUN_007b9df0` only |
| `OnAllCombatDone_Tail` 0x007d92a0 | 1369 | `FUN_007b9df0`, `MoveFleetCommand` 0x00865780 |
| `ServerPlayer::ProcessTurn` 0x00891340 | 272 | `FUN_007b9df0` only |
**This is a lower bound and is labelled as one.** Lane V2 measured that 5,045 of 5,207
vtable-named functions have zero direct call sites, and that sweeping for jumps into another
function's start yields 14,958 further edges. A writer reached only through a vtable slot or a
tail-call thunk is invisible to the table above — which is precisely the failure mode rule 18 was
written about.
### 2.4 Three sites examined and ruled out, named rather than dropped
| site | why it is not `ModCount` |
|---|---|
| `StrategyNetworkClient::OnMessage` 0x007850d5 / 0x0078514b / 0x00785224 | its `this` is not a `StrategySim`: the class holds the server at `+0x54` (`mov ecx,[esi+0x54]` before both driver calls) and its own entity hash at `+0x84`, not `+0x80`. `[this+4]` is a client-side word. **Unresolved, not counted.** |
| `FUN_007b10c0` 0x007b1304 | `inc [eax+4]` with `eax` from a stack slot; the body's fingerprint is 0x74-stride team records and `[esi+0x250/0x254]`, not the `StrategySim` layout. **Unresolved.** |
| `FUN_00890d50` 0x00890f61 | `eax = this->+0x1e8 − 0x14; if (eax) inc [eax+8]`. The −0x14 adjust is not the +4 that reaches a `StrategySim`. **Unresolved.** |
### 2.5 The corpus numbers
| pair | `ModCount` | Δ | note |
|---|---|---:|---|
| `turn1-state` → `turn2-state` | 0 → 12 | **12** | no queued commands in either block |
| `turn2-state` → `turn3-state` | 12 → 24 | **12** | ditto |
| `human-turn2-orders` → `human-turn3-noderoute` | 25 → 53 | **28** | 20 owned systems |
| `zuul-turn15-orders` → `zuul-turn16` | 210 → 241 | **31** | |
| `zuul-turn16` → `zuul-turn17` | 241 → 257 | **16** | |
| `zuul-turn17` → `zuul-turn23` | 257 → 412 | 155 / 6 turns | |
The two 12s look like a state function and are the strongest evidence *against* the reading
above; they are also both turns on which the human issued nothing, so the 10 non-driver bumps are
the AI's commands on a two-colony board, and a stable board plausibly produces a stable command
count. The `TurnCommands_v5` block in each of these saves is the **empty** 35-item block
(lane Q §1c), so the commands that produced the 12 were issued *after* the autosave that wrote
the input file — which is exactly why the pre-turn save cannot predict them.
---
## 3. What needs the VM — the watchpoint, specified (rule 18)
`ModCount` is a single 4-byte counter with an unknown writer set. That is the textbook shape for
a hardware watchpoint, and it settles in one turn what static reading has spent this lane
bounding.
**Probe 1 — write watchpoint on `ModCount`.**
* **Address.** `ModCount = (char*)S + 8`, where `S` is `StrategyServer::ProcessTurn`'s `this`.
Equivalently `*(void**)(ServerPlayer + 0x8) + 4`, or `*(void**)(ServerSystem + 0x10) + 4`.
Read it once at a breakpoint on 0x007dc6c0 and set the watchpoint from there; do **not** try to
compute it from a global, and do **not** use `S+0xc` — that is `Frame`, and getting the two
the wrong way round is exactly the error this document corrects.
* **Type.** DR0 as a 4-byte data-write watchpoint. One debug register suffices.
* **Record per hit.** EIP, the two return addresses up the stack, the value written, and a
monotonic hit index.
* **Workload.** Load `turn1-state.sav`, press End Turn once, wait for the post-turn autosave.
* **Prediction, written before the run:** **exactly 12 hits.** Two of them at 0x007dc6f0 and
0x007d92ca. Zero of them at 0x007b9e20 (`Abdn` is false everywhere). The other **10** inside
the twenty handlers of §2.2 or the six inlined sites of `ApplyTurnCommandBatch`, all with a
return address in `StrategySim::ApplyTurnCommandBatch` 0x0088f9b0 or its callers, and all
**before** `ProcessTurn` is entered.
* **Falsifiers.** (a) more or fewer than 12 hits ⇒ something writes `ModCount` that is not an
increment (check `StrategyServer::Read` on load first); (b) a hit whose EIP is not in the 29
enumerated sites ⇒ this static sweep missed a writer, which is the result worth having, and
lane V2's indirect-edge map is where to look next; (c) hits *after* `ProcessTurn` is entered ⇒
commands are applied during the turn, not before it, and the "the queue is flushed first"
reading is wrong; (d) hits at 0x007850d5 / 0x0078514b / 0x00785224 ⇒ §2.4's exclusion of
`OnMessage` is wrong and `StrategyNetworkClient` shares the base.
**Probe 2 — cheaper, and it answers the modelling question rather than the enumeration one.**
Break on `StrategyServer::ApplyAllTurnCommands` 0x0078f6a0 entry and log, for each of the `count`
blocks at `S->+0x174`, the six prologue gate bools and the 27 list sizes. That is the exact term
`ModCount` counts, in the exact layout lane Q recovered, and it says directly whether the
standalone could ever produce the number. **If probe 2 shows the AI's blocks are non-empty at
this point, `ModCount` is downstream of the whole AI and is the wrong leaf to chase before it;
if they are empty, the bumps come from somewhere else entirely and probe 1's hit list names it.**
Both probes are minutes of work for whoever holds VM140 next. Neither is worth another lane of
reading.
---
## 4. What this lane did not do
* **Nothing was compared against the live game.** The alliance rule is checked against *bytes the
original wrote*, which is stronger than nothing and weaker than a live compare, and the phase
catalog's `verified` count stays 0.
* **The alliance rule's three unseparated readings** (§1.4) rest on the instruction stream alone.
A save with a player whose `PlyrIdx` differs from its vector position would settle the first
one; none exists, and one could be **manufactured** (rule 6) by editing `PlyrIdx` on a save,
which is a named wire field.
* **The `ModCount` writer set is a lower bound.** It is complete for *direct* `inc [this+4]` /
`inc [this+8]` forms with a proved `StrategySim` base. A writer that stores rather than
increments, or that reaches the counter through a pointer this sweep could not type, is not
excluded. §2.4 lists the three it could not type.
* **The abandon/chaos writer has never fired** on any save in the corpus. Its per-turn cost is a
hypothesis.
* No shim TU was touched, so no CT111 cross-build was required.

View file

@ -35,9 +35,30 @@ empty player vector and zero declared regions — the exact failure the campaign
| `S+0x8` (= `(S+4)+0x4`) | **`StrategyServer::ProcessTurn`, first instruction** (`inc [esi+8]` @0x007dc6f0) | not observed by this lane |
| `S+0xc` (= `(S+4)+0x8`, the entry `addresses.json` calls `StrategyServer_off_ModCount`, turn-spine calls `Frame`) | **`StrategyServer::BeginProcessTurn`** 0x007d990a (`inc [esi+0xc]`, then `"Begin processing turn %d."`) | `TechTree::SetResearched` 0x00581e83 stamps `node.turnResearched`; `ServerPlayer::ProcessTurn` reads it for `EVENT_NO_RESEARCH` and for the completed-tech sweep |
`turn-spine.md` and `addresses.json` give the **same word** (`S+0xc`) two different names ("Frame" / "ModCount").
The word at `S+0x8` has never been named. Both advance once per turn, in different functions, so they stay in
lockstep and no existing result is invalidated — but a reimplementation needs both.
~~`turn-spine.md` and `addresses.json` give the **same word** (`S+0xc`) two different names ("Frame" /
"ModCount"). The word at `S+0x8` has never been named. Both advance once per turn, in different functions, so
they stay in lockstep and no existing result is invalidated — but a reimplementation needs both.~~
> **WRONG ON BOTH COUNTS — corrected by lane K/Z (`combat-done-tail.md` §7.1) and re-derived
> independently by lane A2 (`alliance-mask-and-modcount.md` §2.1).**
>
> `StrategyServer::Write` names both words itself: `0x0079fb2f lea edx,[edi+0x08]; push "ModCount"` and
> `0x0079fb40 lea eax,[edi+0x0c]; push "Frame"`, with `edi = S` (settled 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:
>
> * **`S+0x8` is the wire's `ModCount`** — the word this section says has never been named. It advances
> **12–44 times a turn**, not once: it is bumped on entry to every `StrategySim` command-application method
> (26 sites), and the two drivers account for only 2 of them. `addresses.json`'s
> `StrategyServer_off_ModCount` has the name on the **other** word.
> * **`S+0xc` is the wire's `Frame`** — the turn number, which is also the key the turn-record archive is
> written under.
>
> They are **not** in lockstep; a reimplementation must not treat `S+0x8` as a turn number.
> `ghidra/addresses.d/lane-a2.json` carries `StrategySim_off_ModCount` (0x4) and `StrategySim_off_Frame` (0x8)
> in the `S+4` frame, with the correction stated on each. Also settled there: the `S+4` frame is the
> **`Game::StrategySim`** base sub-object — RTTI gives `Game::StrategyServer`'s bases as `Mars::IStreamable`
> at offset 0 and `Game::StrategySim` at offset **4**.
---
@ -54,7 +75,7 @@ Phases, in execution order. "verified" = read from the instruction stream in thi
| 1 | 0x007dc6fb–0x007dc859 | **per-system pre-pass** over `Systems` (`S+0x44/0x48`), ascending | skips systems with `sys->+0xc4 == 0`. For an owned system: push **morale event id 0x26** onto the owner (`FUN_00752a10` ctor → `FUN_00743420/0x007433f0` id wrap → `FUN_00743530`+`FUN_008c97f0` format the text from the system's inline `std::string` at `sys+0xa8` → `FUN_00841420(owner, ev, sys)`). Then, for **every** system that passed the `+0xc4` gate, `FUN_007b9df0(S, sys, 0, &emptyVec)` (abandon / chaos) |
| 2 | 0x007dc85f | `FUN_0086b300(S->+0x158)` — `ServerTradeManager::ProcessTurn` | 1494 B |
| 3 | 0x007dc86a | `FUN_007adc80(S)` — `RegisterTradeSystems` | 192 B |
| 4 | 0x007dc8c8–0x007dc8c6 | **per-player pre-pass**: `rec = p->+0x3d8; rec->+0x8 = (1 << playerSlot); if (p->ALid(+0x168) != -1) rec->+0x8 \|= p->AL(+0x16c)` | the turn's shared-vision / alliance mask, rebuilt from scratch each turn. Note the bit index is the player's **position in the server vector**, not `PlyrIdx` |
| 4 | 0x007dc871–0x007dc8c7 | **per-player pre-pass**: `rec = p->+0x3d8; rec->+0x8 = 0; rec->+0x8 \|= (1 << playerSlot); if (p->ALid(+0x168) != -1) rec->+0x8 \|= p->AL(+0x16c)` | the turn's shared-vision / alliance mask, rebuilt from scratch each turn. Note the bit index is the player's **position in the server vector**, not `PlyrIdx`. *(VA range corrected by lane A2 — it was given here as 0x007dc8c8–0x007dc8c6, which is phase 5's start. The three separate stores are also from lane A2: the clear-then-OR is what makes the alliance term an OR. Checked against the archived `almem` bytes on 80 player-records, 0 mismatches — `alliance-mask-and-modcount.md` §1.)* |
| 5 | 0x007dc8c8–0x007dc928 | build `vector<int> A = {2}` (`FUN_00483410` reserve 1, store 2, bump `_Mylast`); `FUN_00794ad0(&B)` builds `vector<int> B = {0..14}` (the 15 ship-action type ids); `FUN_00513110(&B, &A)` is a **set-difference / erase-if**, so `B = {0..14} \ {2}` | |
| 6 | 0x007dc92b | `FUN_007b9b90(S, B, 0)` — **the ship-action dispatcher**, run over every action type *except* type 2 | 591 B; see §1.2 |
| 7 | 0x007dc93a | **`ProcessNodeSpaceTravel(S)`** 0x007a0e20 | 2945 B |

View file

@ -0,0 +1,487 @@
# Multiplayer & GameSpy — what a private replacement server must answer
- **Type:** subsystem
- **Address / RVA:** GameSpy SDK occupies `0x00408000`–`0x00422000` (ImageBase `0x00400000`); game-side callers in `0x0076xxxx`–`0x007cxxxx` and `0x0089xxxx`
- **RTTI / vftable:** `Game::AutoJoin` `0x00a218f4`, `Game::StrategyJoin` `0x00a21904`, `Game::GameBrowserPanel::ManualJoinDialog` `0x00a22604`
- **Status:** mapped (static); **no VM run yet**
- **Confidence:** high on everything marked **[V]**; the **[I]** items are inference
- **Owner / date:** lane G2 · 2026-09-08
Notation used throughout: **[V]** = read out of this binary. **[I]** = inferred, including from
public GameSpy SDK documentation. Rule 18's corollary was honoured — `strings-and-config.md` §5 and
`ui-screen-map.md` F12/F13 already had the architecture; this note is the code behind it.
---
## 0. The headline: GameSpy is **not** on the critical path to joining a game
Three GameSpy-free paths into a multiplayer session exist, all reachable in the shipped UI:
| Path | Entry | GameSpy services touched |
|---|---|---|
| **Manual join / Favorites** | F12 → *Join Manually* → `Game::GameBrowserPanel::ManualJoinDialog` | **none** |
| **Command line** | `"Sword of the Stars.exe" /join <a.b.c.d[:port]>` | availability check only, and it fails open |
| **LAN browse + join** | F12 → *LAN* page | **none** — UDP broadcast on the game's own ports |
**[V]** `Game_ManualJoin_OnAccept` (`0x0076ef90`) validates the typed text with
`Game_ParseHostAddress` (`0x008f64d0`) and then calls
`App_StartJoin(params, address, serverBrowser = NULL)` (`0x00899e30`). A NULL browser makes
`Game::StrategyJoin::ctor` (`0x00777b10`) take its `0x0077c71` arm, setting state `+0x80 = 1`;
`StrategyJoin::Think` (`0x00765810`) dispatches state 1 through the jump table at `0x00765948` to
`App_ConnectToStrategyHost` (`0x0089a7e0`), which opens the game's own UDP connection to the typed
address. Nothing in that chain calls the availability check, `peerInitialize`, the server browser, or
NAT negotiation.
**[V]** Cross-check from the other side: `GameSpy_NNBeginNegotiationWithSocket` (`0x00412590`) has
exactly **two** call sites in the whole image — `Game_StrategyJoin_QueryViaServerBrowser`
(`0x00770e70`, the browser-mediated join) and the host side (`0x00787cb0`). The direct-address arm
reaches neither. **NAT negotiation is not on the direct-join path.**
**[V]** LAN hosting skips GameSpy entirely. `Game_StrategyHost_StartReporting` (`0x007c2dd0`) branches
on `this+0x2c == 1 && this+0x30 != 0`; on that branch it calls `qr2_init_socket` (`0x0041d1a0`)
directly with `ispublic = 0` and never touches the Peer SDK. The same predicate at `0x007c5088` in
`0x007c4f90` **skips `GSIStartAvailableCheck` altogether**. So in that mode the host never resolves
any `gamespy.com` name.
**[V]** LAN browsing is a local broadcast. `LANPage::CreateBrowser` (`0x0077dde0`) calls
`ServerBrowserNew(..., lanBrowse = 1)` with **no availability gate at all**, and
`LANPage::Refresh` (`0x0077de50`) calls
`ServerBrowserLANUpdate(sb, 1, g_LanScanPort, g_LanScanPort + g_LanScanPortRange)` — defaults
**3369..3370**, i.e. a UDP broadcast answered by the host's own QR2 socket. No master server.
**Independent corroboration.** Kerberos staff (`castewarkp`) said the same thing on the official
forums on **2012-12-08**, when SOTS1's GameSpy backend died — 18 months *before* the general 2014
GameSpy shutdown, because SOTS1's agreement ran through the defunct Lighthouse Interactive:
> "you can still play MP SotS1 using direct connection in the game … For direct connect to work, host
> needs to start their game in LAN mode, then other can join direct connect. **It will not work in
> Internet mode.**"
That is exactly the `this+0x2c == 1 && this+0x30 != 0` predicate, arrived at from the other
direction, and it is the strongest evidence available that the direct path still works. PCGamingWiki
independently lists SOTS as `online play = false, lan play = true, direct ip = true, dedicated =
true`, with the community workaround being "LAN mode + forward port **3369**" — the same port
`Game_LoadNetworkConfig` yields (§3). Same thread, user `Vinco`: *"The dedicated server does not seem
to work. Games must be hosted from the SOTS client."* — a reproducible bug worth its own lane.
**Correct the date in the campaign's framing:** SOTS1's backend went dark in **December 2012**, not
May 2014. Any dated evidence should be read against that.
**Consequence.** Restoring *finding* games needs a replacement GameSpy backend. Restoring *playing*
games needs nothing: two boxes on a LAN, or a typed IP over a VPN/tunnel, already work as shipped —
if the direct path is not broken by something we have not measured. That is the weekend-versus-project
fork, and it lands on "weekend", with the master server as an optional convenience layer on top.
### The availability check fails **open**, not closed
**[V]** `GSIStartAvailableCheck` (`0x0040a060`) sets its socket to `-1` up front, and on a DNS failure
(`gethostbyname` returns NULL at `0x0040a0f0`) it returns with the socket still `-1`.
`GSIAvailableCheckThink` (`0x0040a210`) then takes the `0x0040a2e5` arm and returns **1**, which
`AutoJoin::Think` (`0x00778a90`) records as *available* (`sete cl` on `eax == 1` at `0x00778b11`).
The two-attempt timeout path (`0x0040a2bb`, 2000 ms, one retry) also lands on the same **return 1**.
So the string `MATCHINGSERVICE_UNSUPPORTED` fires only when a server actually **answers** with the
"unavailable" bit set — which is what GameSpy did to shut titles down in 2014, and which nothing does
today. **This corrects an inference in `ui-screen-map.md` §5**: the 2017 build does not "know" GameSpy
is dead; it carries the string GameSpy's own kill-switch would have triggered. Whether the dialog
appears today is a VM question, not a static one (see §7).
---
## 1. Which GameSpy services are linked, and which are called
The SDK sits in one contiguous run of `.text`. Attribution below is by the string constants each
function references **[V]**, with the SDK component names **[I]** from the public SDK layout.
| Component | Code range | Linked | Called from game code | Endpoint |
|---|---|---|---|---|
| **gsAvailable** (availability check) | `0x0040a060`–`0x0040a320` | yes | yes — 3 sites (`AutoJoin`, `GameBrowserPanel`, `StrategyHost`) | `<gamename>.available.gamespy.com` **UDP 27900** |
| **serverbrowsing / SB** (master list) | `0x0041c700`–`0x00421000` | yes | yes — via `ServerBrowserNew` `0x00420060` | `<gamename>.ms<N>.gamespy.com` **TCP 28910** |
| **QR2** (query & reporting, heartbeat) | `0x0041d000`–`0x0041f000` | yes | yes — `qr2_init_socket` `0x0041d1a0`, `qr2_register_key` `0x0041e730` ×16 | `<gamename>.master.gamespy.com` **UDP 27900** |
| **NatNeg** | `0x00412200`–`0x00412a80` | yes | yes — but only on the browser-mediated join and on the host | `natneg1/natneg2.gamespy.com` **UDP 27901** |
| **Peer SDK** (rooms + reporting wrapper) | `0x00414700`–`0x0041a300` | yes | yes — `peerInitialize` `0x00416be0`, `peerSetTitle` `0x00416cc0`, `peerStartReportingWithSocket` `0x00416a50` | wraps peerchat + QR2 + SB |
| **peerchat** (IRC-derived chat) | `0x00408000`–`0x00409f00` | yes | yes — F12 *Internet Chat* page | `peerchat.gamespy.com` **TCP 6667** |
| **ghttp** (GameSpy HTTP client) | `0x0040b000`–`0x0040e000` | yes | yes — 5 sites, all in the App layer (`0x00898ce0`, `0x00899cd0`, `0x0089a1c0`, `0x0089cba0`, `OnTick`) | **[I]** the MOTD fetch from `www.kerberos-productions.com/motd`; no `gamespy.com` host is passed to it |
| **nonport / socket helpers** | `0x0040e900`–`0x0040ed50` | yes | yes — heavily, by the in-house `NetworkManager` | n/a |
**Not present at all [V]** — no string, no host, no code:
- **GP / GPCM / GPSP** (presence, profiles, account login). No `gpcm.gamespy.com`, no
`gpsp.gamespy.com`. **SOTS has no GameSpy account login.** A replacement backend does not need one.
- **motd.gamespy.com**, **gamestats**, **sake**, **atlas**, **keymaster / gcdkey**.
- **CD-key validation of any kind.** There is no `CDKEY` substring anywhere in the 49,781 extracted
strings; the `NETERROR_*` family is 13 entries and none of them is a key error, and there is no
`STARTUPERROR_*` family at all. **This corrects two claims in `ui-screen-map.md`** (F12's
"`NETERROR_*` (21 incl. 6 CD-key)", and §5's "CD-key strings … survive"). Nothing about CD keys is
on any path, so the brief's constraint about not touching key checks has nothing to bite on.
Also present but unreferenced by game code **[V]**: the `gsi_am_rating` auto-match keys
(`0x0041abd0`, `0x0041aee0`) are SDK-internal only.
---
## 2. The identity constants
**[V]** A pointer table in `.rdata` at `0x00a35cc4`–`0x00a35d00` holds the app's global string
constants. The GameSpy identity sits in it:
| Slot | Value | Role |
|---|---|---|
| `0x00a35cd4` | **`swordots`** | GameSpy **gamename** — passed as `peerSetTitle` `title` *and* `sbTitle`, as `ServerBrowserNew`'s `queryForGamename` *and* `queryFromGamename`, as `qr2_init_socket`'s `gamename`, and as the availability-check gamename |
| `0x00a35cd8` | **`Z5gR9Z`** | GameSpy **secret key** — passed as `peerSetTitle` `secretKey` *and* `sbSecretKey`, as `ServerBrowserNew`'s `queryFromKey`, and as `qr2_init_socket`'s `secret_key` |
| `0x00a35cdc` | `"1381"` | the GameSpy **game id** — see below. In this binary it is `atoi`'d once in `OnStartup` (`0x0089d6fb`) and passed to `Startup`→`Create`→`NetworkManager`, landing in `0x00b2e624`; the SDK itself never reads it. |
| `0x00a35ce0` | `"10759"` | **zero references anywhere in the image.** Dead constant. **[I]** a second GameSpy product id (GameSpy Arcade SKU), never read by this build. |
| `0x00a35ce4`..`0x00a35cf4` | `openwaiting`, `closedwaiting`, `openplaying`, `closedplaying`, `exiting` | the five values SOTS reports for the QR2 `gamemode` key |
| `0x00a35d00` | `"1.8.1"` | game version string, parsed into the packed version word |
`swordots`/`Z5gR9Z` is the pair a replacement master server needs. Both are reachable only through
those two `.rdata` slots — the string literals themselves have no direct code xref, which is why a
naive "find the constant near the SDK init" sweep misses them.
**External corroboration of the whole tuple.** The published GameSpy game table that every revival
project seeds from carries the row `id 1381 · gamename swordots · secretkey Z5gR9Z · "Sword of the
Stars" · queryport 6500`. It appears identically in OpenSpy's `openspy-web-backend/sql/Gamemaster.sql`,
UniSpyServer's `common/UniSpy_pg.sql`, 333networks' `data/SupportedGames.json` and Luigi Auriemma's
`gslist.cfg`. Those four are one lineage, not four witnesses — but they are independent of *this*
binary, and they agree with it on gamename, key **and** on `1381` being the game id rather than an
arbitrary Kerberos constant. Treat the binary as authoritative and the table as confirmation.
Note the divergence worth watching: the seeded row says `queryport 6500` (the SDK default), whereas
SOTS reports on its own game socket. **[V]** `qr2_init_socket`'s `boundport` argument comes from
`getsockname` on the already-bound game socket (`0x0040aa30`), i.e. **3369**, and the heartbeat's
source port is the same. A master server should therefore learn the real port from the heartbeat and
ignore the 6500 default; if any implementation trusts the column instead, it will hand clients a dead
port. Flagged for the VM test.
**Not the identity [V]:** `aFl4uOD9sfWq1vGp`, `qJ1h4N9cP3lzD0Ka` and `14saFv19` (`0x009e1b38`,
`0x009e1b4c`, `0x009e1b64`) are referenced only from **inside** the SDK's peerchat crypt functions
(`0x00417320`, `0x004173e0`) and never from game code. **[I]** They are the SDK's built-in peerchat
`CRYPT des` constants, identical in every GameSpy title. Do not mistake them for the game key.
### The version word
**[V]** `0x00b2d510` is a packed 32-bit build word assembled in `0x0089cc70`: bits 16..23 carry
`minor << 4 | major` parsed from `"1.8.1"`, byte 1 carries edition flags from `0x00496e00`
(Collector's Edition / Complete Collection / Argos Naval Yard), and the low nibble a build flavour.
It is passed as `peerSetTitle`'s `sbGameVersion`, and stamped into the join handshake by
`App_ConnectToStrategyHost` (`0x0089a881`). `NETERROR_INVALIDVERSION` exists.
**Answer to "will a version check bite":** yes, but not against the *server* — it is a client-to-host
check, and both ends will be the same GOG 1.8.1 build. A replacement master server only has to carry
the value through as an opaque `gamever` field. **[I]** on the exact comparison site; I did not chase
the handshake comparison.
---
## 3. Hostnames and ports — the hosts-redirect target list
All **[V]** unless noted. Every hostname is built with `sprintf` from the gamename, so the concrete
set for `swordots` is:
| Host | Port | Proto | Built at | Purpose |
|---|---|---|---|---|
| `swordots.available.gamespy.com` | **27900** | UDP | `0x0040a0ac` | availability check |
| `swordots.master.gamespy.com` | **27900** | UDP | `0x0041d38e` | QR2 heartbeat (server → master) |
| **`swordots.ms5.gamespy.com`** | **28910** | TCP | `0x004208cc` | server-list fetch (client → master) |
| `natneg1.gamespy.com` | **27901** | UDP | `0x00412540` | NAT negotiation |
| `natneg2.gamespy.com` | **27901** | UDP | `0x00412562` | NAT negotiation (secondary) |
| `peerchat.gamespy.com` | **6667** | TCP | `0x00417b9c` | chat rooms (F12 *Internet Chat*) |
| `www.kerberos-productions.com/motd` | 80 | TCP | — | MOTD, not GameSpy |
**The `ms5` is derived, not guessed.** `SBServerListConnect` (`0x00420840`) folds the gamename to an
index: `h = 0` then per character `h = tolower(c) - h * 0x63306ce7` (32-bit wrap), and
`index = (unsigned)h % 20`. Re-implementing that fold over `"swordots"` gives `h = 0xfb2f91c5`,
`index = 5`. Only `swordots.ms5.gamespy.com` is ever contacted; the other nineteen never are.
**Two override hooks exist [V]**, and are worth knowing about because they may make a hosts file
unnecessary: `0x00b085b0` overrides the availability hostname (checked at `0x0040a099`) and
`0x00b09440` overrides the master hostname (checked at `0x0042089d`). I did **not** find a game-side
writer for either — **[I]** they look like the SDK's `gsiSetAvailableCheckHostname` /
`SBSetMasterHostname` globals left settable but unused. They are, however, a clean patch/hook point.
### SOTS's own ports (nothing to do with GameSpy)
**[V]** `Game_LoadNetworkConfig` (`0x005a0610`) reads ini section **`[Network]`**:
| Key | Default |
|---|---|
| `HostPort` | **3369** |
| `CombatHostPort` | **3370** |
| `LanScanPort` | **3369** |
| `LanScanPortRange` | **1** |
| `HeartbeatPeriod` | 15000 ms |
| `ConnectionTimeout` | 45000 ms |
| `MaxTxMessageSize` | 512 |
| `CombatLatency` | 1000 ms |
| `SyncCheckStrategy` / `SyncCheckCombat` | True |
| `SyncLogStrategy` / `SyncLogCombat` | False |
**[V]** The import table has `connect`, `send`, `recv`, `sendto`, `recvfrom`, `bind` but **no
`listen` and no `accept`** — the game's own transport is **UDP only**. TCP appears only as client
connects (peerchat, the SB list, ghttp).
---
## 4. Wire formats
### 4.1 Availability check — fully specified, ~20 lines of server
**[V]**, byte for byte, from `0x0040a060` and `0x0040a1a0`.
*Request* (client → `swordots.available.gamespy.com:27900/UDP`), length `strlen(gamename) + 6`:
```
09 00 00 00 00 's' 'w' 'o' 'r' 'd' 'o' 't' 's' 00
```
*Response* (must come from the same address:port the request went to, and be ≥ 7 bytes):
```
FE FD 09 <status : 4 bytes, big-endian>
```
The client tests **only the low byte** of `status`:
- `status & 1` → `GSIACUnavailable` (2) → `MATCHINGSERVICE_UNSUPPORTED`
- else `status & 2` → `GSIACTemporarilyUnavailable` (3) → `MATCHINGSERVICE_TEMP_UNAVAILABLE`
- else → `GSIACAvailable` (1) → proceed
So **`FE FD 09 00 00 00 00` is "yes, this game is alive"**. Timeout is 2000 ms with one retry.
### 4.2 QR2 — query, reporting and the custom keys
**[I]** The query/heartbeat protocol itself is the documented GameSpy QR2 standard: `\status\`,
`\basic\\info\`, `\final\`, `\echo\test`, `splitnum`, `queryid`, and the challenge/response keyed on
the secret key. All those literals are **[V]** present at `0x0041ea00`, `0x0041eeb0`, `0x0041f560`,
`0x00421e90`, `0x0041cae0`. The client-visible failure string is **[V]**
`"No challenge value was received from the master server."` (`0x0041e550`).
**Where SOTS is custom [V]:** `Game_RegisterQR2Keys` (`0x00898bf0`) registers sixteen game keys —
a replacement browser/master must carry these opaquely, and our own tooling can read them:
| id | name | | id | name |
|---|---|---|---|---|
| 50–57 | `slot0` … `slot7` | | 61 | `turn` |
| 58 | `numslots` | | 62 | `scenario` |
| 59 | `mapshape` | | 63 | `settings` |
| 60 | `numsys` | | 64 | `slot_` |
| | | | 65 | `ranks` |
Standard keys SOTS also reports **[V]** (strings present in the QR2 block): `hostname`, `gamename`,
`gamever`, `hostport`, `mapname`, `numplayers`, `maxplayers`, `password`, `gamemode`, `statechanged`,
`natneg`, `localip%d`, `localport`, `publicip`, `publicport`. `gamemode` takes one of
`openwaiting` / `closedwaiting` / `openplaying` / `closedplaying` / `exiting`.
### 4.3 Server list (SB)
**[I]** TCP 28910, the documented GameSpy serverbrowsing-v2 request/challenge/encrypted-list
protocol, keyed on `Z5gR9Z`. **[V]** only the endpoint construction, the port, and the fact that the
same gamename/key pair is what is handed to `ServerBrowserNew`. I did not decode the request framing
or confirm the encryption type — that is the one place a replacement server has real work, and it is
also the place the open-source reimplementations have already done it.
**[V]** The fields the join path reads back off an `SBServer`: public IP/port (`0x0041f470` /
`0x0041f480`), private IP/port (`0x0041f4f0` / `0x0041f520`), a "same NAT" predicate (`0x0041f4b0`),
a "can connect directly" predicate (`0x0041f4d0`), and `SBServerGetIntValue(server, "password", 0)`
(`0x0041fd00`).
### 4.4 The in-game protocol — entirely SOTS's own
**[V]** Once connected, nothing is GameSpy. UDP on `HostPort` (3369), the in-house `Network:` group
layer (`DoHost` / `DoConnect` / `DoDisconnect`, host migration, proxies), `SNM*` strategy messages,
`FNM*` chunked file transfer. Documented already in `strings-and-config.md` §5.
`Game_OnJoinGame_ChooseAddress` (`0x00772f60`) logs the three cases:
- *"Server %s:%d is behind same NAT."* → use the private address
- *"Server %s:%d is behind NAT, but can still connect directly to it."* → use the public address
- *"Server requires NAT negotiation."* → only then is NatNeg entered
---
## 5. Ranked revival plan
**Tier 0 — no server at all (hours).** Two clients on a routable path (LAN, or WireGuard/Tailscale),
host forwards UDP 3369 and 3370, joiner types the IP in *Join Manually*. Predicted to work with zero
GameSpy anything. This is the thing to try first and it is the thing most likely to just work.
**Tier 1 — availability responder (an afternoon).** One `hosts` line
`127.0.0.1 swordots.available.gamespy.com` plus a ~20-line UDP server on port 27900 that answers
`FE FD 09 00 00 00 00`. This unblocks `AutoJoin`, the *Internet* page and `StrategyHost`'s Internet
mode from the "matching services not available" refusal. Because the check already fails open on DNS
failure, Tier 1 may turn out to be a **no-op** — that is exactly what the VM test in §7 settles, and
finding it unnecessary is a good outcome.
**Tier 2 — LAN discovery instead of a master (an afternoon).** Nothing to build: LAN browse already
works. If the two boxes are on a routed VPN rather than a broadcast domain, either bridge the segment
(so `255.255.255.255:3369` reaches the host) or fall back to Tier 0's manual join.
**Tier 3 — real master server: configuration, not code (a day).** This was expected to be the
weeks-long piece. It is not. Two maintained, self-hostable projects implement **exactly** the service
set SOTS needs, and **both already ship the `swordots` / `Z5gR9Z` row**:
| | **OpenSpy** (`openspy/openspy-core`, C++) | **UniSpyServer** (`GameProgressive/UniSpyServer`, Python, AGPLv3) |
|---|---|---|
| availability check (UDP 27900) | `code/qr/server/v2/handle_available.cpp` — replies `FE FD 09` + `htonl(disabled_services)`, byte-identical to §4.1 | QR v2, `AVALIABLE_CHECK = 0x09` |
| QR2 heartbeat / challenge (UDP 27900) | `code/qr/server/v2/handle_{heartbeat,challenge,keepalive}.cpp` | `protocols/gamespy/query_report/v2` |
| server list, SB **v2** / TCP 28910 | `code/serverbrowsing/server/V2Peer.cpp` + `sb_crypt` (GOA/enctypex) | `protocols/gamespy/server_browser/v2` (v2 only — fine, SOTS is v2) |
| NatNeg (UDP 27901) | `code/natneg/server/handlers/*` (needs 3 IPs) | `protocols/gamespy/natneg` |
| peerchat (TCP 6667) | `code/peerchat/` incl. `handle_crypt.cpp` | `protocols/gamespy/chat` |
| deploy | `openspy/compose` docker-compose; Redis + RabbitMQ + MySQL + MongoDB + .NET 8 backend | docker-compose, Postgres + Redis, ships a dnsmasq compose for the DNS redirect |
| add a title | one row in `games` (`gamename`, `secretkey`, `queryport`, `keylist`, `disabledservices`) then `POST /v1/Game/SyncToRedis` — **no code** | one DB row |
**So the procedure is: `docker compose up`, confirm the seeded `swordots` row, point DNS at it.**
No protocol work unless something diverges.
Two caveats, both **[I]** from the projects' own source and both testable:
- **`swordots` is in OpenSpy's *database dump*, not on its *supported-games* list** (132 tested
titles, no SOTS). Seeded ≠ verified. Expect to be the first to exercise it.
- **The public openspy.net availability responder answers `status 0` for any gamename at all**,
including nonsense — so a successful availability check against the public instance proves nothing
about whether `swordots` is really registered there. The real gates are the QR2 challenge and the
SB v2 handshake, both keyed on `Z5gR9Z`, and peerchat's `CRYPT` (which OpenSpy's
`handle_crypt.cpp` rejects outright when the DB secret key is empty). **Self-host; do not test
against the public instance and conclude anything from it.**
- The seeded `keylist` for `swordots` is the generic nine (`country/gamemode/gametype/gamever/
hostname/mapname/maxplayers/numplayers/password`) and does **not** include SOTS's sixteen custom
keys from §4.2. Widen the row from a live heartbeat capture, or the browser will show games with
no slot/turn/scenario detail.
**333networks is not a fit [V-by-their-docs].** It implements the **GameSpy v0** master only — UDP
27900 beacons in, TCP **28900** list out. No SB v2/28910, no NatNeg, no peerchat, no availability
responder. Its `SupportedGames.json` does contain `swordots`/`Z5gR9Z`, but every entry in that file
has `"port": 0` — it is a bulk import of the same leaked table, not a support claim. Likewise
`gsmaster` (enctype 0/1 only), `mgmse` (archived, v1 master), `PRMasterServer` (BF2-specific).
**Two further assets worth knowing about:** `GameProgressive/UniSpySDK` is a cleaned, still-building
copy of the original GameSpy SDK source — the best available reference for the campaign's
functional-reimplementation north star, and a much better way to name the SDK functions in this
binary than guessing. `anzz1/openspy-client` is an in-memory client-side DNS shim (no file patching)
with per-title headers; **there is no `game_sots.h`**, so for our lab the hosts-file route is
simpler.
**There is no SOTS-specific revival project.** Checked across OpenSpy's supported list,
openspy-client's headers, GitHub topic search, PCGamingWiki and the Steam/GOG threads.
**Tier 4 — peerchat and NatNeg (optional, and skippable).** Chat rooms are cosmetic. NatNeg only
matters for two players who are *both* behind NAT and unwilling to port-forward — and it is bypassed
whenever the host is directly reachable, which a VPN guarantees.
**Deliberately out of scope:** GP/GPCM/GPSP login and CD-key auth, because §1 shows this binary
contains neither.
### Testing needs two clients on one Windows guest — and the game ships the switch for it
**[V]** `WinMain` (`0x0089ddaf`) does `CreateMutexA(NULL, TRUE, "Kerberos_SwordOfTheStars_Mutex")`
and, on `GetLastError() == ERROR_ALREADY_EXISTS (0xb7)`, walks argv doing a case-insensitive compare
against the literal **`/concurrent`**. On a match it *continues*; otherwise it `FindWindowA`s the
existing `Kerberos_SwordOfTheStars_WndCls`, foregrounds it, and exits.
So a second instance on the same box is a supported, shipped configuration. **[V]** the switches
present in the image are `/join`, `/concurrent`, `/startup:`, `/motd_`, `/tell`. Command-line parsing
is `Game_ParseJoinCommandLine` (`0x0089d280`), and `/join`'s argument goes into `Game::AutoJoin`.
**[V]** `Game_ParseHostAddress` accepts **dotted quad only** — `sscanf("%d.%d.%d.%d:%d%1s")` must
yield 4 or 5 fields with every octet ≤ 255. **Hostnames are rejected.** Use `127.0.0.1:3369`, never
`localhost`.
**[V]** A **dedicated server** (`sots_server.exe`, `Dedicated Server Launchpad.exe`, `SERVERERROR_*`)
ships alongside the client — the cleanest host for a two-client test, and it removes the mutex
question entirely. It is not in `dumps/` and has not been examined.
---
## 6. Cross-refs
- Callers/callees and the full address list: `ghidra/addresses.d/lane-g2.json` (43 entries).
- Related: [[strings-and-config]] §5 (the string-level architecture), [[ui-screen-map]] F12/F13
(the screens), [[data-model]] / `objects/layouts.md` (`Game::StrategyHostParams`,
`Game::StrategySessionParams`).
- Corrections filed against [[ui-screen-map]]: the CD-key claim (§1 here) and the
"the 2017 build knows GameSpy is dead" inference (§0 here).
---
## 7. Falsifiable prediction, written before the VM run
Written 2026-09-08, before VM140 was available to this lane. Build: GOG 1.8.1, single Win10 guest.
**Setup.** One guest. Instance A: launch normally, *Host Multiplayer* → **LAN**, create a 2-player
custom game, note the port from `sots.ini`/`[Network] HostPort` (expect 3369). Instance B:
`"Sword of the Stars.exe" /concurrent /join 127.0.0.1:3369`. No hosts file, no server, no network
changes. Capture with Wireshark on the loopback and the guest NIC for the whole run.
**Predictions.**
1. **P1 — the join succeeds with zero GameSpy traffic.** Instance B reaches the lobby (F13) and
appears in a slot on instance A. The capture shows **no** packet to UDP 27900, UDP 27901, TCP
28910 or TCP 6667, and **no** DNS query for any `*.gamespy.com` name from instance B after
`/join` is parsed. All traffic is UDP on 3369 between the two instances.
*Falsified if:* any `gamespy.com` DNS lookup or any packet to 27900/27901/28910/6667 appears on
the join path, or the join fails with `NETERROR_NOCONNECTION` / `NETERROR_TIMEDOUT` while both
instances are up.
2. **P2 — the availability check runs, gets no answer, and reports *available* anyway.** On entering
F12 (Join Multi-Player) the capture shows exactly one DNS query for
`swordots.available.gamespy.com`; if it resolves, one UDP datagram to port 27900 whose payload
begins `09 00 00 00 00 73 77 6f 72 64 6f 74 73 00`, retried once at ~2 s. Either way the UI does
**not** show `MATCHINGSERVICE_UNSUPPORTED`; at most it shows an empty Internet list.
*Falsified if:* the "Online support … is no longer available" dialog appears, which would mean
something is answering the check with the unavailable bit — in which case §0's fail-open reading
is wrong, or a stale wildcard DNS record is still live, and Tier 1 becomes mandatory rather than
probably-unnecessary.
3. **P3 — LAN discovery finds the host without any server.** With A hosting in LAN mode, B's F12
*LAN* page lists the game after a refresh, and the capture shows a UDP broadcast to
`255.255.255.255:3369` (`\status\`-family QR2 query) answered by A on the same port.
*Falsified if:* the LAN list stays empty while a direct manual join to the same address succeeds
— which would mean the LAN sweep uses a port or a mechanism I have mis-read.
4. **P4 — a self-hosted OpenSpy or UniSpyServer, with a hosts file and no code changes, makes the
*Internet* page work.** With `swordots.available.gamespy.com`, `swordots.master.gamespy.com`,
`swordots.ms5.gamespy.com`, `natneg1/2.gamespy.com` and `peerchat.gamespy.com` all pointed at the
container, a host started in Internet mode appears in the client's Internet list, and joining it
from the list succeeds.
*Falsified if:* the QR2 challenge or the SB v2 handshake fails — the visible symptom would be
`"No challenge value was received from the master server."` or an Internet list that stays empty
while the availability check reports available. Either would mean the seeded row or the enctype
assumption is wrong, and Tier 3 stops being configuration.
*Partial-credit case to watch:* the game appears in the list but joining hands back the wrong port
(6500 rather than 3369) — that is the `queryport` column, a one-row fix, not a protocol problem.
5. **P5 — the mutex bypass works.** Instance B started with `/concurrent` reaches the main menu
rather than foregrounding instance A.
*Falsified if:* B exits immediately, in which case the two-client test needs a cloned guest and
the VM requirement is one machine larger than assumed.
**How the model could be wrong.** (a) The manual-join arm may be reachable in the code but
unreachable in the UI — e.g. the *Join Manually* button could be disabled until the Internet page
has a browser object, which static reading of the button's enable predicate would catch and I did not
do. (b) `App_ConnectToStrategyHost` stamps the version word; if the host also demands a matching
GameSpy-side field the direct path never fills in, the join could fail with
`NETERROR_INVALIDGAMEDATA` — a symptom distinct from a timeout. (c) The host may only start its QR2
socket when reporting succeeds, in which case a host whose Peer init failed would be unqueryable but
still directly connectable — P1 would pass and P3 would fail.
## 8. Open questions
- Is `Game_ParseHostAddress`'s dotted-quad-only restriction also enforced on `/join`? (Same function
is called from `0x0089d3ca` in the command-line parser, so almost certainly yes — but the
command-line arg is copied into `AutoJoin` *before* validation, so the failure mode may differ.)
- What sets `StrategyHost+0x2c` / `+0x30`? Verified only that `+0x2c == 1 && +0x30 != 0` selects the
GameSpy-free host mode; the enum behind `+0x2c` (**[I]** likely the F8 Single-Player/LAN/Internet
session type) is not confirmed.
- **Which SB encryption type does `ServerBrowserNew` request?** Undecoded here, and it decides
whether the enctype-0/1-only emulators (`gsmaster`, `mgmse`, 333networks) are even theoretically
usable. The `queryVersion` argument is **[V]** `1` on the LAN path (`0x0077de1c`); the Internet
path's value was not read. OpenSpy carries both enctype1 and GOA/enctypex, so it is covered either
way — this only matters for the narrower projects and for our own reimplementation.
- The SB list request framing on TCP 28910 — undecoded here.
- `disabledservices` semantics differ between implementations (OpenSpy's SQL comment says
`1 = unavailable, 2 = temporarily unavailable`; UniSpy's enum says `0 available, 1 waiting,
2 permanent, 3 temporary`). §4.1 is the tiebreaker for **this client**: it tests bit 0 then bit 1
of the status low byte, so `0` is the only safe "available" value and `1` and `2` both mean
something is wrong. Worth telling both projects if it bites.
- Does anything ever write the two hostname-override globals (`0x00b085b0`, `0x00b09440`)? If a
config key reaches them, a private server needs no hosts file.
- `sots_server.exe` is unexamined and is the natural host for the two-client test.

View file

@ -0,0 +1,870 @@
# `FUN_007066c0` and `FUN_00703730` — the "path solver", read from the instruction stream
Lane P2, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
**Method.** Every function below was disassembled with `objdump -D -b binary -m i386 -M intel` over the raw
`.text` image (file offset `VA - 0x400C00`), **each to the next function start** (rule 17), and the padding
inspected. Jump tables were resolved byte by byte from `.text`. No claim about control flow here comes from a
decompiler. Everything is marked **[V]** instruction-verified by me or **[I]** inferred; there are no delegated
reads in this lane.
Closes `combat-retreat-pipeline.md` §6's *"The three failure bits `0x008 / 0x010 / 0x400` of `OrderFleetMove`"*
and §8's *"the single biggest hole"*. It also closes the type-2 question that `movefleet-position-rounding.md`
§6/§7, `strategic-turn-internals.md` §4.3 and lane O's behavioural sweep have each circled from a different
side, and it **corrects the name of waypoint type 2**.
---
## 0. Lead: it is not a path *finder*
**`FUN_007066c0` performs no graph search over systems.** There is no frontier, no visited set, no
relaxation, no priority queue, and no recursion. It is a **per-leg classifier and validator** over a waypoint
list its caller already chose:
```
solver(fleet, start, dests[], n) :
drop dests[0] if it is the fleet itself or the fleet's current system
probe leg (start -> dests[0])
prev = start
for i in 0 .. n-1:
types[i] = ClassifyLeg(fleet, prev, dests[i], &rangeLeft, &legFlags, &routes[i])
flags |= legFlags ; failIndex = first i with legFlags != 0
rangeLeft = f32(rangeLeft - f32(sqrt(f32(|prev.pos - dests[i].pos|^2))))
if dests[i] is a system where the fleet may refuel: rangeLeft = fullTank
prev = dests[i]
return true // ALWAYS true past the argument checks
```
The only graph lookup in the whole subtree is `FUN_006e4eb0`, and it is a **single-hop adjacency query**:
"is there a node line between system A and system B that this player has discovered?" It answers with the
line's path index or −1. It never looks at a third system. **A multi-hop node route in a save is therefore
`n` waypoints, one per hop, each classified independently** — the routing decision was made by whoever built
`dests[]` (the UI's map click chain, or the AI), never here. [V]
The function's **return value is `true` for every call that has a non-null fleet and a non-null start
object**; all outcome information travels in the two out-parameters. [V]
`ret` (no `ret N`) with eight stack arguments — **cdecl, not thiscall**, despite `ecx` being loaded with the
fleet at three call sites. The fleet is passed as argument 1 *and* used as `this` for the range helpers. [V]
### 0.1 Real boundaries (rule 17)
| function | Ghidra size | real body | verdict |
|---|---|---|---|
| `FUN_007066c0` | 584 | `0x7066c0..0x706907`, then 8 × `int3` to `0x706910` | **correct** |
| `FUN_00703730` | 1178 | `0x703730..0x703bc9`, then 6 × `int3` to `0x703bd0` | **correct** |
| `FUN_008653c0` | 801 | `0x8653c0..0x8656ea`, then 5 × `int3` to `0x8656f0` | **correct** |
| `FUN_00707080` | 514 | `0x707080..0x707281`, then 14 × `int3` to `0x707290` | **correct** |
Four for four. Recorded because the rule exists — but this lane found the sizes right, and the disassembly to
the next start is what establishes that rather than assuming it.
### 0.2 Two functions that read live-in registers
`FUN_00703650` and `FUN_00703bd0` each **test a register they never write**:
```
703650 push ebp; mov ebp,esp
703653 mov eax,[ebp+0xc] ; test eax,eax ; je ... ; mov DWORD PTR [eax],0x0
703660 test ebx,ebx ; <-- ebx is live-in
703668 test esi,esi ; <-- esi is live-in
```
At `FUN_00703730`'s call site 0x703831, `ebx` = the moving fleet and `esi` = the target fleet; at
`FUN_00703c90`'s call site 0x703caa, `esi` = the system. Both have exactly one caller. **Treating either as a
plain cdecl function and reading only its stack arguments produces nonsense** — I nearly did. This is a new
failure mode for the rules file, adjacent to §7.3 of `combat-retreat-pipeline.md`: *check whether a callee
reads a callee-saved register before it writes it.* [V]
---
## 1. `FUN_007066c0` — the driver
### 1.1 Signature, from the two call sites and the frame
```c
// __cdecl, 8 args, always returns true past the guards.
bool PathSolver(StarFleet* fleet, // +0x08 the moving fleet ("this" for the range helpers)
MapObject* start, // +0x0c where leg 0 begins (OrderFleetMove passes the fleet)
MapObject** dests, // +0x10 the ordered destination list
unsigned count, // +0x14 its length (DECREMENTED IN PLACE, see 1.2)
int* flagsOut, // +0x18 OR of every leg's flags (optional)
int* failIdxOut, // +0x1c index of the FIRST failing leg (optional, -1 = none)
int* typesOut, // +0x20 one waypoint type per dest, stride 4 (optional)
NodeRoute* routesOut); // +0x24 one NodeRoute per dest, stride 0x10 (optional)
```
`typesOut` and `routesOut` are written **only when non-null**, and each is guarded separately
(0x706808 / 0x706814), so a caller may ask for flags alone. Both callers do exactly that or ask for
everything:
| caller | call | what it wants |
|---|---|---|
| `FUN_005e6d50` (UI move command) | `(f, f, dests, n, &flags, 0, 0, 0)` | **dry run.** If `flags != 0` it allocates a 0x8f8-byte dialog and hands it `(f, dests, n, flags)` — the move-confirmation prompt. If `flags == 0` it sends the command. [V] |
| `FUN_008653c0` `OrderFleetMove` | `(f, f, dests, n, &flags, 0, types._Myfirst, routes._Myfirst)` | the real thing |
**The UI tests `flags != 0`; `OrderFleetMove` tests `flags & 0x418`.** That split is what separates the
warning bits from the refusal bits, and it is the cleanest evidence for the reading in §3. [V]
### 1.2 The leading-destination drop
```
706722 ecx = dests[0]
706724 if (fleet == ecx) goto drop
706728 eax = fleet->LocID(+0xa0)
70672e if (!eax || eax->+0x14 != 0) eax = 0 ; inlined StarFleet_GetLocationIfNode
706739 if (eax == ecx) goto drop
goto keep
drop: dests += 4 ; --count ; 0x70673d
```
So a destination list whose first entry is the fleet itself, or the *system* the fleet is already at, has that
entry removed — `dests` and `count` are advanced/decremented **in the solver's own stack copies only**
(`count` lives at `[ebp+0x14]`, a by-value argument), so `OrderFleetMove`'s `count` is untouched and it still
builds `count` waypoints from its **un-dropped** list. **The `types[]` and `routes[]` the solver writes are
therefore shifted by one relative to the waypoints `OrderFleetMove` builds whenever the drop fires**, and
`types[count-1]` / `routes[count-1]` are never written at all — they keep the value the vector was
constructed with. That is an original defect and it is testable: §7, P4. [V]
`FUN_00459f70`, `FUN_0085bf00` and `FUN_00703e90` are all MSVC `vector::resize(n)` — shrink-by-erase on one
side, grow-and-value-initialise on the other (`rep stos eax=0` for the `int` vector at 0x459fd8) — **not
`reserve`**, so `_Myfirst != _Mylast` and the two out-pointers are non-null whenever `count > 0`, and the
untouched tail element is a genuine zero rather than uninitialised memory. Checked precisely because the whole
paragraph above collapses if any of the three were a `reserve`. [V]
The `+0x14 != 0` guard means the drop only fires for a **system** location, never a point or a fleet. [V]
### 1.3 The three fuel figures
```
70674b [ebp-0x24] = StarFleet_MinRange(fleet, 0.0f) ; 0x006ff6a0
706757 [ebp-0x38] = FUN_00705d60(fleet, true) ; full tanks
706762 [ebp-0x10] = FUN_00705d60(fleet, false) ; fuel remaining <- the running budget
```
`StarFleet_MinRange` is already `verified` in `addresses.json` with the identical body I read here
(min over ships of `ship->Range(+0x20)`, seeded `FLT_MAX` from the `.rdata` word at 0x009e23a8 = `0x7f7fffff`,
strictly-greater update, then `f32(best + bias)`); **agreement recorded, no duplicate entry filed.** [V]
`FUN_00705d60(fleet, bool useMaxRange)` builds a `vector<float>` of per-ship ranges — `ship->Range(+0x20)`
when the flag is false, `Ship_MaxRange` (0x0080c820) when true — alongside a per-design list keyed on
`design->+0x12c`, and folds them. **I read only its first ~0x90 bytes and its two call sites**; the fold
itself (tanker redistribution) is *not* read. What is established is the argument's polarity and that the
result is a float. [V for the polarity, **not read** for the fold]
The **pre-flight probe** at 0x706795 passes `&[ebp-0x24]` — the plain `MinRange`, not the effective range — so
the first leg is evaluated **twice**, once against the pessimistic budget and once for real. Its
`flagsOut` is *assigned* (not OR'd) and its `failIdx` set to 0; its return value and its route are discarded
(`routeOut = NULL`). [V]
### 1.4 The per-leg loop, exactly
```
7067c3 rangeLeft = max(rangeLeft, 0.0f) ; fcomp 0.0 vs rangeLeft, strict
7067df legFlags = 0
7067e6 NodeRoute tmp; ctor 0x006e1b20 -> {vptr=0x00a1cbdc, nrp=-1, nrf=0, nrt=0}
706803 t = ClassifyLeg(fleet, prev, cur, &rangeLeft, &legFlags, &tmp) ; FUN_00703730
706808 if (typesOut) typesOut[i] = t
706814 if (routesOut) { routesOut[i].nrp = tmp.nrp; .nrf = tmp.nrf; .nrt = tmp.nrt } ; vptr NOT copied
70682e if (legFlags) { if (failIdx == -1) failIdx = i; flags |= legFlags }
706844 rangeLeft -= legLength(prev, cur) ; see 1.5
706886 if (cur->+0x14 == 0 && CanRefuelAt(cur, fleet)) rangeLeft = fullTank
7068b2 ++i; dests += 4; routesOut += 0x10; prev = cur
7068ce tmp.vptr = 0x009e22bc ; the INLINED NodeRoute destructor -- see below
```
**Rule 4 trap, and it is a live one here.** `mov DWORD PTR [ebp-0x48],0x9e22bc` on the back edge looks like a
second object being installed. It is not: 0x009e22bc is the `Mars::IStreamable` vftable (rtti), and this store
is the whole of the inlined `~NodeRoute()` — MSVC's base-class-vptr reset with nothing left to free. The
*constructor* runs again at 0x7067e6 at the top of the next iteration, so `nrp` really is reset to −1 every
leg. Reading the store as a branch or as a missing re-init would have produced a false "stale node route
carried between legs" bug report. [V]
The `[ebp-0x4]` writes (0 before the call, −1 on the back edge) are the SEH try-level for that same local, not
control flow. [V]
### 1.5 The leg length — and its precision
```
706844 fld [edi+0x18] ; fsub [esi+0x18] ; fstp DWORD [ebp-0x30] ; dx -> FLOAT32
70684d fld [edi+0x1c] ; fsub [esi+0x1c] ; fstp DWORD [ebp-0x24] ; dy -> FLOAT32
706856 fld [edi+0x20] ; fsub [esi+0x20] ; fstp DWORD [ebp-0x34] ; dz -> FLOAT32
70685f..706876 dx*dx + dy*dy + dz*dz entirely on the x87 stack (53-bit)
706878 fstp DWORD [ebp-0x34] ; sumsq -> FLOAT32
70687b fld [ebp-0x34] ; call 0x924f52 (sqrt) ; fstp DWORD [ebp-0x34] ; len -> FLOAT32
70688a fld [ebp-0x34] ; fstp DWORD [ebp-0x34] ; a no-op reload/store
706890 fld [ebp-0x10] ; fsub [ebp-0x34] ; fstp DWORD [ebp-0x10] ; rangeLeft -> FLOAT32
```
This is `Mars_Vec3_Length` (lane M §1) **inlined**, with the same two narrowings, plus a third on the
subtraction. Lane M's rule holds verbatim: the delta a reimplementation subtracts is `f32(a.c − b.c)`, not the
exact difference, and the sum of squares is narrowed **once**, after a 53-bit accumulation. Because a float32
delta has 24 significand bits, each square is exact in double and so is the three-term sum, so a `double`
accumulator with a single final narrowing is **bit-identical** here. `fpu_cw = 0x127f`. [V]
`prev` is the **previous destination object**, not the fleet's position, from leg 1 onward
(`edi = esi` at 0x7068c5) — so the budget is drawn down along the *waypoint chain*, not from where the fleet
actually is. On leg 0, `prev` is the `start` argument, which `OrderFleetMove` sets to the fleet. [V]
### 1.6 Refuelling
`FUN_00703c90(node, fleet)` — 85 B [V]:
```
owner = MapObject_GetOwner(fleet) ; FUN_0071e280, switch on +0x14:
; 0 -> sys->PID(+0x100), 1 -> fleet->PID(+0x58), else 0
a = FUN_00703bd0(owner) ; esi = node LIVE-IN. For each fleet at the node (vft[2] count, vft[3] index),
; skipping any whose owner differs when owner != 0:
; true if StarFleet_HasFlagShips(f, 2, 0) -- capability mask 2, the tanker bit
b = node && owner && node->PID(+0x100) && Relation(node->PID, owner) >= 3 ; FUN_0080e050
return a || b
```
`FUN_0080e050` is `FUN_006d2050(this->PlyrIdx, &this->Team(+0x168), other->PlyrIdx)`. `strategic-turn-internals`
§5.2 records that function's result as *"1 ally, 2 NAP, 3 cease-fire"*; if that ordering is right, `>= 3`
would mean *"refuel at a cease-fire system but not at an ally's"*, which is almost certainly backwards.
**I did not read `FUN_006d2050`.** The `>= 3` test is [V]; the meaning of 3 is **open**, and §5.2's ordering
should be re-checked by whoever needs it. Two independent call sites use the same scale with different
thresholds (`>= 3` here, `> 0` in `FUN_00817890`), which is itself a hint that the scale is monotone in
friendliness and §5.2 has it inverted. [I]
Note the refuel test is gated on `cur->+0x14 == 0` — **systems only**, never a point or a fleet. [V]
---
## 2. `FUN_00703730` — `ClassifyLeg`, the actual rule
```c
// __thiscall, ret 0x14. Returns the WAYPOINT TYPE for this leg (0 = "no move possible").
int ClassifyLeg(StarFleet* this, MapObject* from, MapObject* to,
float* rangeInOut, int* flagsOut, NodeRoute* routeOut);
```
`flagsOut` may be null — the function substitutes a stack dummy at `[ebp-0x18]` so the `or` sites never need a
null test, and then **skips the whole flag block** at 0x703846 when the caller passed null. So a caller that
wants only the type gets no flag work done at all. [V]
### 2.1 Endpoint classification
`MapObject->+0x14` is the kind tag, already established by `MoveFleet`'s arrival switch
(`strategic-turn-internals` §4.3): **0 = system, 1 = fleet, 2 = deep-space point**. [V]
```
from: kind 2 -> fromPoint = from
kind 0 -> fromSystem = from
kind 1 -> loc = from->LocID(+0xa0); loc kind 0 -> fromSystem = loc; loc kind 2 -> fromPoint = loc
to: toSystem = (kind==0) ? to : 0 (the neg/sbb/not/and idiom, 0x7037bd)
toFleet = (kind==1) ? to : 0
toPoint = (kind==2) ? to : 0
```
[V]
### 2.2 The default: species decides the waypoint type
```
703770 driveType = FUN_006ff810(fleet)
```
`FUN_006ff810` (118 B) [V]:
```
if (fleet has no ships) return 0
t = DriveTypeOfSpecies(fleet->PID(+0x58)->Species(+0x5c)) ; FUN_0080c7d0
if (t == 0) return 0
if (fleet has exactly one ship) return t
for i in 1 .. nShips-1:
if (DriveTypeOfSpecies(fleet->PID->Species) != t) return 0 ; <-- LOOP-INVARIANT, see below
return t
```
**Original defect.** The loop body re-reads `fleet->PID->Species` — the *fleet's* owner — on every iteration
rather than indexing ship `i`. The compared value is therefore constant and the loop can never fail. The
intent was evidently "every ship in this fleet must have the same drive"; as shipped it is dead code. It costs
nothing behaviourally (a fleet's ships all belong to one player) but a reimplementation that "fixes" it to
read per-ship data would diverge the moment a mixed fleet exists. Recorded as-shipped. [V]
`FUN_0080c7d0(species)` (50 B) is a **7-entry jump table** at 0x0080c804, resolved byte by byte [V]:
| species | index | jump target | drive type |
|---|---|---|---|
| Human | 0 | 0x0080c7e2 | **3** |
| Hiver | 1 | 0x0080c7fe | **0** |
| Tarkas | 2 | 0x0080c7e9 | **1** |
| **Liir** | **3** | 0x0080c7f0 | **2** |
| _NPC | 4 | 0x0080c7fe | **0** |
| Zuul | 5 | 0x0080c7e2 | **3** |
| Morrigi | 6 | 0x0080c7f7 | **6** |
(species enum from `strategic-turn-internals` §0; index out of 0..6 returns 0.)
### 2.3 The decision, in order
```
A. if (to is a FLEET):
targetOnNodeRoute = to->wpts non-empty && IsNodeWaypoint(to->wpts[0].Tp)
ok = SolveIntercept(...) ; FUN_00703650, ebx=fleet esi=to live-in
if (ok) toSystem = the resolved system
B. if (flagsOut): ; the whole block is skipped when null
if (to is a fleet && !ok && targetOnNodeRoute) flags |= 0x008
if (AnyShipGroundedByDamage(fleet)) flags |= 0x010 ; FUN_00700240
m = PendingShipActionMask(fleet) ; FUN_006ff990
if (m & 0x100) { m &= ~0x100; flags |= 0x800 }
if (m) flags |= 0x001
C. if (to is a POINT):
if (!PointVisibleTo(to, owner) && !PointKnownTo(to, owner)) ; +0x8c / +0x90 masks
flags |= 0x400
return IsGateTransitWaypoint(driveType) || IsNodeWaypoint(driveType) ? 0 : driveType
D. fromHasGate = fromSystem && ServerSystem_HasGate(fromSystem, owner) ; FUN_00744010, GFlags(+0xdc)
toHasGate = toSystem && ServerSystem_HasGate(toSystem, owner)
canProject = GateProjection(owner, fromSystem, toSystem) ; FUN_00818040
if ( (fromHasGate && (toHasGate || canProject ||
(toPoint && PointUsableBy(toPoint, owner)))) ; FUN_0080ea30
|| (toHasGate && fromPoint) ):
capacity = owner->NGts(+0x144) * owner->PrGtTrf(+0x148) ; FUN_0080dc50
cost = (int16)fleet->+0xc0
if (fleet's CURRENT waypoint is already a gate transit) cost = 0
if (owner->GTraf(+0x14c) + cost > capacity) { flags |= 0x004; return 0 }
flags &= ~0x010 ; <-- clears the grounded bit
return canProject ? 5 : 4
E. if (driveType != 3) return driveType ; every non-node race stops here
F. ... the node-route branch, §2.5 ...
```
[V] throughout.
Step **E** is the whole story for Hiver (0), Tarkas (1), Liir (2), NPC (0) and Morrigi (6): once the gate block
declines, the leg's waypoint type is the species' drive type and **nothing else is checked** — no range, no
node line, no route record. Type 0 for Hiver/NPC is not a failure; `MoveFleet`'s switch treats every type it
does not name as `step = FPsp2 × dt`.
### 2.4 The gate block, decoded
`FUN_00744010(sys, player)` (34 B) is `(sys->GFlags(+0xdc) >> player->PlyrIdx(+0x28)) & 1`. `GFlags` is the
one `ServerSystem` presence mask `combat-retreat-pipeline.md` §5 left as *"a second presence source (not read
here)"*. **It is the per-player gate mask**, and the whole of §2.3 D is built on it. [V]
`FUN_00818040(player, A, B)` (150 B) [V]:
```
if (!A || !B) return false
if (!(0.0f < player->CstR(+0x150))) return false
if (!HasGate(A, player)) return false
if ( HasGate(B, player)) return false ; B must NOT have one
return Mars_Vec3_Length(A.pos - B.pos) <= player->CstR ; non-strict; 0x004224b0
```
**`CstR` now has a reader.** `strategic-turn-internals` §4.3 records it as *"`CstR` unused here"*; it is the
**gate-projection radius** — how far past a gate a fleet can be thrown when the far end has no receiving gate.
The distance is `Mars_Vec3_Length`, i.e. two float32 narrowings (lane M §1), compared **non-strictly**. [V]
So types 4 and 5 are both Hiver gate transits and the distinction is precise:
* **type 4** = gate → gate. Both ends carry the player's gate.
* **type 5** = gate → **gateless** system within `CstR`. `MoveFleet`'s case 5 is
*"roll = rand01() × CstE; if roll > CstT the fleet scatters around the destination"* (B4's correction) —
which is exactly the risk of throwing a fleet at a system with nothing to catch it.
This **corrects** `strategic-turn-internals` §4.3's medium-confidence guess that type 5 is *"the Zuul
node-bore / Morrigi gravity casting"*. It is neither: Zuul and Morrigi can never reach step D unless they
somehow hold a gate, and the node bore lives in step F. `CstE`/`CstT`/`CstR` are one coherent trio of
**gate-projection** parameters. [V for the classification, [I] for the `CstE`/`CstT` reading, which is B4's]
The `flags &= ~0x010` at 0x7039d3 is worth stating on its own: **a gate transit ignores dead drives.** That is
the same rule `combat-retreat-pipeline.md` §2.2 found from the other end (the species-1 bypass of
`IsGroundedByDamage` on the retreat gate arm), reached independently. [V]
### 2.5 The node-route branch (`driveType == 3`: Human and Zuul only)
```
7039f6 graph = (fleet->galaxy(+0x10))->vft[1]() ; INDIRECT EDGE -- not resolved
7039fb nrf = 0 ; nrp = -1 ; errBits = 0x002
case fromPoint && toSystem:
if (SystemIsFriendly(owner, toSystem)) { nrp = -1; origin = fromPoint; goto CHECK }
else { flags |= 0x100; goto FAIL }
case toPoint:
if (!fromSystem) goto FAIL
if (!SystemIsFriendly(owner, fromSystem)) { flags |= 0x200; goto FAIL }
if (!PointUsableBy(toPoint, owner)) { flags |= 0x400; goto FAIL }
nrp = -1; origin = fromSystem; dest = toPoint; goto CHECK
default (system -> system):
if (!fromSystem || !toSystem || fromSystem == toSystem) goto FAIL
p = FindNodeLine(graph, owner, fromSystem, toSystem) ; FUN_006e4eb0
if (p != -1) { nrp = p; origin = fromSystem; goto CHECK }
if (!StarFleet_HasFlagShips(fleet, 0x20000, 0)) { flags |= 0x020; goto FAIL }
errBits = 0x040
if (!BoreNodeLine(owner, fromSystem, toSystem, 0, fleet)) ; FUN_006e4de0
{ flags |= 0x080; goto FAIL }
nrp = -1; origin = fromSystem; goto CHECK
CHECK: if (origin && dest && !InRange(fleet, origin, dest, rangeInOut)) { flags |= errBits; goto FAIL }
return 3 ; and write the route out
FAIL: return 0 ; and write an EMPTY route out
```
[V]
* **`0x20000` is the node-bore capability bit** on the fleet's cached capability mask `+0xb8`
(`StarFleet_HasFlagShips` 0x00703500, already `verified` from lane Z, delegating to
`FUN_00702d70(this, maskA, maskB, out)`). It is the same bit `AddShip` special-cases into
`(fleet->galaxy)->+0x114` (`combat-retreat-pipeline.md` §2.4). Zuul node cruisers. [V]
* **`FUN_006e4eb0`** (303 B) is the only graph structure in the subtree [V]:
```
if (!player || !A || !B || A->Idx(+0x5c) == B->Idx(+0x5c)) return -1
hi = max(idxA, idxB); lo = min(idxA, idxB)
bit = 1 << player->PlyrIdx(+0x28)
if (!(graph->+0x24[ (hi-1)*hi/2 + lo ] & bit)) return -1 ; TRIANGULAR adjacency, one dword per pair
walk the hash bucket (0x006905a0):
for each entry whose {+0xc,+0x10} pair matches {idxA,idxB} in either order
and whose +0x2c mask contains the player's bit:
score = FUN_006e2130( (graph->+0x4)->+0x8 ) ; <-- LOOP-INVARIANT
if (score > best) { best = score; result = entry->+0x8 }
return result (initialised to -1)
```
**Original defect #2, and this one is the tie-break.** `score` depends only on `graph`, so it is identical
for every candidate. `best` starts at −1, so the **first** matching entry in the bucket wins and every later
one is discarded by `score > best` being false on equality. Whatever the ranking was meant to be (line
length? a per-line discovery level?), as shipped **`FindNodeLine` is "first match in hash-bucket order"**.
A reimplementation must reproduce the bucket order to reproduce the chosen `nrp`, or accept that `nrp` is
unmodelled. I did not read `FUN_006e2130` or the hash function 0x006905a0. [V for the invariance, **not
read** for the two callees]
* **`InRange`** is `FUN_006ffa00` (170 B), and it is the only place a *distance* decides a *failure*. §4 below.
### 2.6 Intercepting a moving fleet
`FUN_00703650` (214 B, `ebx` = mover, `esi` = target, both live-in) [V]:
```
if (!mover || !target) return false
if (target->wpts empty) return false
if (!IsNodeWaypoint(target->wpts[0].Tp)) return false ; target must be on a type-3 leg
if (!CanIntercept(mover, target, rangeIn)) return false ; FUN_00703520
myLoc = (mover->LocID && mover->LocID->+0x14 == 0) ? mover->LocID : 0
if (myLoc == StarFleet_ResolveWaypoint(target)) ; I'm at the target's destination
*out = AsSystem(NodeTransitOrigin(target)); return true ; -> aim at where it came FROM
if (StarFleet_GetLocationIfNode(mover) == NodeTransitOrigin(target))
*out = AsSystem(StarFleet_ResolveWaypoint(target)); return true
return true ; *out left 0
```
`NodeTransitOrigin` is `FUN_006ffab0`: it resolves `fleet->FPlan.pnd(+0xf8)` through the entity hash at
`(fleet->galaxy(+0x10)) + 0x80` — the same `IDMap` `combat-retreat-pipeline.md` §5 found at `(S+4)+0x80`.
**`pnd` is the network id of the node transit's origin object**, and §6.1 shows where it is written. That is a
new name for a field the save format carried unexplained. [V]
`AsSystem` is `FUN_0071e340`: `return (x->+0x14 != 0) ? 0 : x`. [V]
`FUN_00703520` (292 B) [V] returns **true** when the target is not node-travelling at all (three early outs),
and otherwise demands that the mover sit at one end of the target's node line *and* that the whole line be
`InRange`. So the geometry is: **you can only cut a node-travelling fleet off at one of the two ends of the
line it is on.**
Note the third `return true` at 0x70371d with `*out` still 0: an intercept can succeed while resolving no
system, in which case `toSystem` stays whatever it was (0 for a fleet target) and the leg falls through to
step D/E with no system at either end.
---
## 3. The flag word — and `OrderFleetMove`'s three failure bits
Every bit, its site, and its meaning. All [V].
| bit | set at | meaning | fails `OrderFleetMove`? |
|---|---|---|---|
| **0x001** | 0x703897 | some ship in the fleet is performing a cancellable action | no — **warning** |
| 0x002 | 0x703b10 (errBits) | the leg's existing node line is out of fuel range | no |
| 0x004 | 0x7039c5 | **gate traffic would exceed `NGts × PrGtTrf`** | no |
| **0x008** | 0x703859 | destination is a fleet on a node route and **no intercept could be solved** | **YES** |
| **0x010** | 0x70386d | some ship's **drive is destroyed** (`StarShip_IsGroundedByDamage`) — *cleared* at 0x7039d3 on a gate leg | **YES** |
| 0x020 | 0x703b8b | no node line between the two systems and the fleet **cannot bore one** | no |
| 0x040 | 0x703b63 (errBits) | a line was bored, but the leg is still out of fuel range | no |
| 0x080 | 0x703b7d | the **node-bore attempt failed** | no |
| 0x100 | 0x703a42 | point → system, and the system is not friendly-owned | no |
| 0x200 | 0x703a6a | system → point, and the source system is not friendly-owned | no |
| **0x400** | 0x7038c5, 0x703ad3 | the destination **point** is not one this player may move to | **YES** |
| **0x800** | 0x70388a | the fleet contains a ship performing action **8** | no — **warning** |
`OrderFleetMove` tests `test DWORD PTR [ebp-0x10], 0x418` at 0x865499 — **0x400 | 0x010 | 0x008**, exactly
B5's three — and on a hit logs at level 2 with the `.rdata` string at 0x00a31e44:
```
StrategySim: %s (%s) move not permitted at this time.
```
with `%s %s` = the fleet's `FtName(+0x5c)` and the owner's name string (`owner+0x40`), both read through the
MSVC `std::string` SSO test (`capacity >= 0x10 → indirect`). [V]
**So the three "failure" bits are the three that cannot be repaired by the player pressing OK.** Every other
bit is either advisory (0x001, 0x800: "this cancels orders") or a *route-quality* complaint the engine is
willing to commit anyway (0x002/0x004/0x020/0x040/0x080/0x100/0x200). The UI's `flags != 0` test is what shows
the whole word to the player; the server's `& 0x418` is what refuses.
`0x004` **not** being a refusal is the surprising one: a Hiver player can be ordered over gate capacity, the
flag is raised, `ClassifyLeg` returns 0 for that leg, and `OrderFleetMove` **still installs the plan** with a
type-0 waypoint. That is a testable consequence — §7.
### 3.1 The two per-ship masks
`FUN_00700240(fleet)` (80 B): true if **any** ship satisfies `StarShip_IsGroundedByDamage` (0x00815090,
already `verified` from lane B5 — agreement recorded). → bit 0x010. [V]
`FUN_006ff990(fleet)` (101 B): `OR of (1 << ship->+0x4c)` over ships satisfying `FUN_0081f880(ship)`, where
```
FUN_0081f880(ship) = ship && ship->+0x4c != 0 && ship->+0x4c != 6
&& !(ship->+0x4c == 8 && (ship->+0x1c & 8) == 8)
```
`ship->+0x4c` is the ship's **current action**, and the proof is in `OrderFleetMove` itself: at 0x8655ed it
open-codes the *identical* three-way predicate and calls `FUN_00849280(S+4, ship, 0)` — the "Ship leaving %s
is still doing %s. Cancelling action." cancel that `strategic-turn-internals` §4.3 already names — on every
ship that passes it. So bit 0x001 means "this order will cancel N ship actions" and bit 0x800 singles out
action **8**. Both are prompts, not refusals. [V]
*(A shift by `ship->+0x4c` means the action enum must stay under 32; nothing bounds-checks it.)*
---
## 4. Precision profile
Ordered by how much a reimplementation would suffer from getting it wrong.
**1. `InRange` — `FUN_006ffa00`, the only float that decides a failure.** [V]
```
6ffa0f d.x = f32(B.pos.x - A.pos.x) ... d.y, d.z ; each stored to a float32 slot
6ffa2d..6ffa44 dx*dx + dy*dy + dz*dz on the x87 stack (53-bit)
6ffa46 fstp DWORD [ebp-0x4] ; sumsq -> FLOAT32
6ffa4d r = rangePtr ? *rangePtr : StarFleet_MinRange(fleet, 0.0f)
6ffa63 cap = FUN_006ff710(fleet) ; min over ships of Ship_MaxRange
6ffa6b..6ffa83 r = (r > cap) ? cap : r ; both read back from FLOAT32 slots
6ffa86 fld [ebp+0x8] ; fmul st(0),st ; r*r -- LEFT IN THE REGISTER
6ffa8b fld [ebp-0x4] ; fcompp ; compares f32(sumsq) with the 53-bit r*r
6ffa95 jp -> false ; true iff sumsq <= r*r
```
**`r*r` is never stored.** The comparison is `f32(sumsq) <= (double)r × (double)r`. A reimplementation that
writes `sumsq <= f32(r*r)` — the natural mirror of every other narrowing in this engine — will disagree
exactly at the boundary, which is precisely where a fuel check lives. This is the same class of finding as
lane M's rounded reciprocal, and it is the one number in this subsystem that flips a decision.
Also: the sense is **`<=`**, non-strict, established from `test ah,0x41` + `jp` (equal ⇒ C3 alone ⇒ odd parity
⇒ `jp` not taken ⇒ true).
**2. `FUN_006ff710`** (155 B) — the tank cap. Min over ships of `Ship_MaxRange(ship)` (0x0080c820), seeded
`FLT_MAX`, **with an early exit the moment the running min is `<= 0`** (`fldz; fld best; fcom st(1); test
ah,0x41; jnp exit`) — so it is *not* a pure min when a zero-range ship appears before a negative one. Returns
`0.0f`, not `FLT_MAX`, for a fleet with no ships. [V]
**3. The leg length** — §1.5. Three f32 deltas, one narrowing on the sum, one on the sqrt, one on the
subtraction. Bit-identical to a double accumulator with a single final narrowing.
**4. `Mars_Vec3_Length` in the gate-projection test** — lane M §1's two narrowings, compared `<=` against
`CstR` read from a float32 field.
**5. `rangeLeft = max(rangeLeft, 0.0f)`** at the top of every leg (0x7067c3), via `fcomp` of the constant 0.0
against the slot: `!(0.0 <= rangeLeft) ⇒ 0`. NaN would clamp to 0. [V]
**6. `MinRange`'s seed and update** — `FLT_MAX` (0x009e23a8 = `0x7f7fffff`), strict `best > range` update, so
on an exact tie the **earlier** ship wins and an empty fleet returns `FLT_MAX + bias`. Same shape as the
retreat destination search's tie-break (`combat-retreat-pipeline.md` §2.1). [V]
`fpu_cw = 0x127f` throughout — 53-bit precision, round-to-nearest — as lane F measured. Nothing in this
subtree changes it.
---
## 5. Why waypoint type 2 is never produced — and what it actually is
**Answer: type 2 is the Liir drive.** `FUN_0080c7d0` maps species 3 → 2 and nothing else does. Lane O's 20+
observations were taken on *"both node-drive races"* — Human (0) and Zuul (5) — and `FUN_0080c7d0` maps both
of those to **3**. Every one of those observations was forced by the table, and no amount of single- vs
multi-hop, natural vs rip-bored, planned vs in-transit variation could have produced a 2. [V]
The reachability argument, in full:
* The classifier can only return `0`, `driveType`, `3`, `4` or `5` (five `ret` sites, §2.3). [V]
* `driveType` is a **pure function of the owning player's species** — no ship data, no terrain, no tech
(§2.2; the one loop that reads ship data is the invariant no-op). [V]
* The node-route branch (§2.5) is entered **only** when `driveType == 3` and returns only `3` or `0`. [V]
* The gate branch returns only `4` or `5`, and its guard is `GFlags` — the gate mask. [V]
So a **Human or Zuul fleet can never carry a type-2 waypoint**, and a **Liir fleet's every straight leg is
type 2**. There is no bug, no unreachable path, and nothing to fix.
### 5.1 Correction: "node line" is the wrong name for type 2
`strategic-turn-internals` §4.1/§4.3 names 0x00705510 `NodeLine::Step` and 0x00702e20 `FindNodeLines`, and
`movement.h` in the engine calls kind 2 `NodeLine`. On this evidence that naming is **wrong**, and it is what
made B4's *"the type-2 node-line step is wrong by construction"* and lane O's *"type 2 never appears"* look
like the same puzzle when they are not:
* type 2 belongs to **Liir**, the one race with no node drive and no gates;
* its speed profile is built from `STUTTER_SYSTEM_INFLUENCE_RADIUS`, `STUTTER_MIN_SPEED`, `STUTTER_MAX_SPEED`
and is **slowest at a system and fastest in deep space** (`v = speed × ((MAX−MIN)×(dist/RADIUS) + MIN)`,
`formula-gaps.md`);
* the image carries `SHIP_STUTTERWARP_DEEPSPACE_MANEUVER_FACTOR` as a ship key;
* `IsNodeWaypoint` deliberately excludes 2 and accepts only 3 — which is now simply consistent rather than
anomalous.
Type 2 is the **Liir stutterwarp**. `FUN_00702e20` finds *systems whose influence spheres the leg crosses*
(`formula-gaps.md` already reads it as a ray/sphere test against systems), not node lines. Renaming is
proposed in `addresses.d/lane-p2.json`: `Stutter_Step` / `FindStutterInfluenceSystems`, with the old names
kept as aliases in the comments so the earlier findings remain searchable. [V for the species mapping and the
predicate tables; **[I]** for the *name* "stutterwarp", which rests on the constant names and the shape of the
speed curve, not on a string that says so.]
### 5.2 What this predicts for lane M and B4
Lane M could not build a type-2 workload and correctly said so. The reason is now concrete: **`ref-turn2` has
no Liir player.** Exercising type 2 does not require a node line at all — it requires a **Liir fleet moving
anywhere**. That is a far cheaper save to author than the one lane M was contemplating, and B4's untested
`NodeLineStep`/`BuildStutterSegments` become reachable the moment such a save exists.
---
## 6. What reaches saved state
`OrderFleetMove` (0x008653c0) is the writer. Read in full to the next function start. [V]
```c
bool StrategyServer::OrderFleetMove(StarFleet* f, MapObject** dests, unsigned count) {
if (!f) return false;
if (PosDiffersFromPointLocation(f)) { // FUN_0080ec50: exact fucompp on all three components,
this->vft[2](); // and only when LocID->+0x14 == 2 (a POINT)
if (PosDiffersFromPointLocation(f)) f->Pos = f->LocID->Pos;
}
vector<int> types(count); // 0x00459f70
vector<NodeRoute> routes(count); // 0x0085bf00
int flags = 0;
PathSolver(f, f, dests, count, &flags, nullptr, types.data(), routes.data());
if (flags & 0x418) { Log(2, "StrategySim: %s (%s) move not permitted at this time.\n", ...); return false; }
vector<Waypoint> wpts(count); // 0x00703e90; stride 0x1c
for (i = 0; i < count; ++i) {
wpts[i].Wpt(+0x4) = dests[i] ? dests[i]->id(+0x4) : 0;
Waypoint_Set(&wpts[i], types[i], &routes[i]); // FUN_007006e0
}
StarFleet::SetFlightPlan(f, wpts.data(), wpts.size(), f->LocID ? f->LocID->id(+0x4) : 0); // 0x00707080
for (ship in f->NShips) if (ship is acting) CancelShipAction(this, ship, 0); // 0x00849280
return true;
}
```
`FUN_007006e0` is `Waypoint::Set(int Tp, const NodeRoute* r)`: `+0x8 = Tp`, `+0x10 = r->nrp`,
`+0x14 = r->nrf`, `+0x18 = r->nrt` — which pins `Waypoint = {vptr, Wpt@4, Tp@8, NodeRoute nrt@0xc}` at
**0x1c** bytes (confirmed independently by the `0x92492493` divide-by-28 at 0x865594 and the `add edi,0x1c`
stride). Exactly `struct-recovery.md` §3.1. [V]
### 6.1 `SetFlightPlan` — `FUN_00707080`, every write
| write | field | value |
|---|---|---|
| `owner->+0x14c` −= `f->+0xc0` | **`ServerPlayer.GTraf`** | debited **before** the change if the *old* first waypoint was a gate transit |
| `f->+0xc8..0xd7` | **`FlightPlan.wpts`** | assigned from a zeroed temp (`FUN_00703cf0`), then the new list inserted (`FUN_00706c90`) |
| `f->+0xd8` | **`FPsp2`** | 0.0f from the temp, then recomputed by `FUN_00705c70` |
| `f->+0xdc` | **`FPeta2`** | 0 |
| `f->+0xe0..0xeb` | **`FPogn2`** | 0,0,0 then **`f->Pos`** — the position the order was given from |
| `f->+0xec..0xf7` | **`FPdpos`** | 0,0,0 then the **first waypoint target's `Pos`**, resolved through the IDMap at `(f->galaxy)+0x80`; left zero if it does not resolve |
| `f->+0xf8` | **`pnd`** | 0 then the **origin location's network id** (`f->LocID->+0x4`, or 0) |
| `f->+0xfc` | **`FtTrans`** | **`wpts[0].Tp`** — a second, saved copy of the first leg's waypoint type |
| `f->+0x100..0x10b` | **`FtOrig`** | `f->Pos` |
| `owner->+0x14c` += `f->+0xc0` | **`GTraf`** | re-credited if the *new* first waypoint is a gate transit |
Field names from `objects/layouts.md`; every offset above is [V] from the instruction stream and every one of
them is a **saved** field the autosave oracle sees. `FtTrans = wpts[0].Tp` is new — the save carries the first
waypoint's type twice, once inside `FPlan.wpts` and once as a top-level `StarFleet` int. [V]
Also reaching saved state on this path, but outside `SetFlightPlan`:
* **`StarFleet.Pos`** (+0x18) — the snap, but *only* when the fleet's location is a deep-space point.
`combat-retreat-pipeline.md` §2.5 records the exact-IEEE comparison correctly and misses the `+0x14 == 2`
guard; corrected here. [V]
* **every acting ship's state**, via `FUN_00849280`.
* **`ServerPlayer.GTraf`**, above.
Against B5's seven-container template: this path writes **four** of them (`FlightPlan`/`wpts`, `GTraf`,
`StarFleet.Pos`, and the new `FtTrans`), and adds the `nrt{nrp,nrf,nrt}` records, whose provenance is now
known:
* **`nrp`** = the node line's path index from `FindNodeLine` (`entry->+0x8`), or **−1**;
* **`nrf`** = the leg's **origin** object's network id (`origin->+0x4`), or 0;
* **`nrt`** = the leg's **destination** object's network id, or 0;
* and **all three are written only when the leg's type is 3.** For every other type the saved record is
`{-1, 0, 0}` — `FUN_00703730` resets it at 0x703a85 and only then fills it in (0x703aa8..0x703bb1). [V]
That last point is a save-visible invariant: **`Tp != 3 ⟹ nrt == {-1,0,0}`**, on every waypoint of every
fleet. It is checked in §7.1 — 58 waypoints, 0 failures.
---
## 7. A falsifiable prediction (rule 2)
Written before any run.
> **P1.** In every save in `verify/results/saves/`, and in every autosave any later lane takes, a waypoint
> whose `Tp` is not 3 has `nrt = {nrp: -1, nrf: 0, nrt: 0}`, and a waypoint whose `Tp` is 3 has
> `nrf` and `nrt` equal to network ids that resolve, with `nrp` either −1 (a freshly bored line, or a
> point endpoint) or a non-negative path index.
>
> **P2.** `StarFleet.FtTrans` equals `FPlan.wpts[0].Tp` for every fleet with a non-empty flight plan, and is
> unchanged from its previous value for every fleet with an empty one.
>
> **P3.** No fleet owned by a Human or Zuul player ever carries `Tp == 2`; no fleet owned by a Liir player
> ever carries `Tp == 3`; `Tp` 4 or 5 appears only for a player whose `NGts(+0x144)` is non-zero.
>
> **P4 (the cheap VM test, and the one worth doing).** Give a fleet a **multi-hop** move order whose **first
> click is the system the fleet is already parked at** — click your own system, then one or two more, then
> confirm. The resulting `FPlan.wpts` will have `n` entries, and the **types will be shifted by one**:
> `wpts[0].Tp` will be the type computed for `wpts[1]`'s destination, and **`wpts[n-1].Tp` will be 0**
> whatever the race is. For a Human or Zuul fleet the tell is unmistakable — the last waypoint of a node
> route saves as `Tp 0` with `nrt {-1,0,0}` instead of `Tp 3` with a live `nrp/nrf/nrt`. Set `n = 2` for the
> smallest case: `wpts[0].Tp` = the type of the leg to the second click, `wpts[1].Tp` = 0.
P1 and P2 are checkable **right now, offline**, against the 11 existing saves with `save_reader.py` — no VM,
no build. P3 needs a Liir save, which is the workload §5.2 argues for anyway. P4 needs the VM, one save with
a movable fleet, and about a minute; it is a **predicted bug in the original**, so a clean result falsifies
§1.2's reading of the drop rather than merely failing to confirm it.
P4's most likely way to be wrong: the UI may refuse to place a waypoint on the fleet's own system, in which
case the drop is only reachable from the AI's `OrderFleetMove` call sites (`FUN_007a4ff0`, `FUN_00865780`) and
the test must be run against an AI turn instead. The retreat pipeline cannot reach it — it passes
`count = 1` with a destination the phase-1 search explicitly excludes the battle system from, and the fleet is
at the battle system.
### 7.1 P1, P2 and a third check — **run, and green**
P1 and P2 were written above from the disassembly alone, then run against all 11 curated saves with
`verify/save-reader/save_reader.py`. A third check went in at the same time, because reading §2.5 makes it
obvious and it is free:
> **P5.** For a chain of type-3 waypoints, `nrf[0] == pnd`, `nrt[i] == Wpt[i]`, and `nrf[i+1] == nrt[i]` —
> because `ClassifyLeg` is handed `(prev, cur)` and writes `origin->id` / `dest->id`, and §1.4 sets
> `prev = cur` on the back edge.
| | plans | waypoints | pass | fail |
|---|---|---|---|---|
| **P1** `Tp != 3 ⟹ nrt == {-1,0,0}`; `Tp == 3 ⟹ nrt == Wpt && nrf != 0` | 46 | **58** | 58 | **0** |
| **P2** `FtTrans == wpts[0].Tp` | **46** | — | 46 | **0** |
| **P5** `nrf[0] == pnd` and `nrf[i+1] == nrt[i]` | **46** | — | 46 | **0** |
`Tp` histogram over all 58: **{3: 57, 1: 1}**. Zero type 2, zero type 0, zero 4, zero 5.
What that buys, and what it does not (rule 15). The coverage is thin in exactly the way lane O's was:
nine of the eleven saves are the same Zuul game at successive turns, so the 57 type-3 waypoints are far
from 57 independent observations. **But three things here could not have been guessed:**
1. **`nrt == Wpt` on all 57.** Two fields the save format carries separately, that no earlier lane connected,
and the reason they are equal is `ClassifyLeg` writing `dest->+0x4` into `nrt` while `OrderFleetMove`
writes the same `dests[i]->+0x4` into `Wpt`. Predicted from the instruction stream, confirmed.
2. **The `nrf` chain closes through `pnd`** across the three-hop plans in `zuul-turn23-fleet23.sav`
(`80→272`, `272→368`, `368→288`, with `pnd = 80`). That is §1.4's `prev = cur` back edge, visible in a
save.
3. **`nrp` splits exactly where §2.5 says it should.** `human-turn3-noderoute.sav` — Human, cannot bore —
has `nrp` = 37, 16, 9: all **non-negative**, all from `FindNodeLine`. The Zuul saves carry a **mix**:
52/53/54/56 where a line already existed, and **−1** where one did not. −1 is what 0x703adb writes after
a successful `BoreNodeLine`, and Zuul are the rip-borers. Nobody looked for this; the split falls out of
the branch structure and it is there.
**The one non-Zuul, non-Human data point is the best of all.** `turn3-state.sav`'s single flight plan is
**`Tp = 1`** with `nrt = {-1, 0, 0}`. That save's players are species **{0 Human ×2, 2 Tarkas ×2, 4 NPC ×4}**,
and `FUN_0080c7d0` maps Human→3, Tarkas→**1**, NPC→0. **Type 1 can only have come from a Tarka fleet**, and
the empty route record is P1 on the only non-node waypoint the campaign has ever recorded. That is a live
confirmation of a *second* row of the drive-type table, from a save that predates this lane, on a race nobody
was looking at. (The save does not carry a player *handle*, only `PlyrIdx`, so the fleet→player link is by
elimination over the species present, not by direct lookup.)
**Still at zero observations: types 0, 2, 4, 5** — Hiver/NPC, Liir, and both gate transits. P3 and P4 stand
unrun.
**How each could be wrong, and the symptom:**
1. `nrt` is written by more than one producer. `MoveFleet`'s multi-waypoint continuation calls
`FUN_00703730` twice (0x7da5c6, 0x7da5da) and I did **not** read those call sites. If either writes a
route into an existing waypoint, P1 breaks with a `Tp != 3` waypoint carrying a live `nrf`. That would not
falsify §2's reading of the classifier — it would mean the classifier has a second consumer that re-types
a waypoint after the fact.
2. `FtTrans` may have another writer. I found exactly one (`SetFlightPlan`), by reading, not by an
image-wide displacement scan for `+0xfc`. Symptom: P2 fails on a fleet whose plan was not installed by
`OrderFleetMove`.
3. P3's `Tp == 2` half rests on `FUN_0080c7d0` being the **only** producer of a drive type. It is the only
caller-visible one in this subtree, but `FUN_0080c7d0` has other callers I did not enumerate. Symptom: a
type-2 waypoint on a non-Liir fleet — which would be the more interesting result and should be chased,
not explained away.
4. A Hiver player with zero gates: P3's last clause predicts no type 4/5. If one appears, `GFlags` is set by
something other than gate construction and §2.4's identification of `+0xdc` is wrong.
---
## 8. Corrections to earlier findings
**8.1 To `strategic-turn-internals.md` §4.3 — waypoint type 5.** *"the identity of waypoint type 5 (a
probabilistic jump using the player's `CstE/CstT` — consistent with the Zuul node-bore / Morrigi gravity
casting; `CstR` unused here)"*. Type 5 is a **Hiver gate throw to a system with no receiving gate**, chosen at
0x7039e4 by `FUN_00818040`, whose radius **is `CstR`**. `CstR` is not unused; it is the gate-projection
radius. Zuul and Morrigi cannot reach the site.
**8.2 To `strategic-turn-internals.md` §4.1/§4.3 and the engine's `WaypointKind::NodeLine` — type 2.**
Type 2 is the **Liir** drive, not a node line. §5.1.
**8.3 To `combat-retreat-pipeline.md` §2.5's `OrderFleetMove` line.** *"It snaps the fleet's position onto its
current system when they differ"* — it snaps onto its current **point** (`FUN_006fe320` requires
`LocID->+0x14 == 2`); a fleet sitting at a system is never snapped. The exact-`fucompp` observation stands.
**8.4 To `combat-retreat-pipeline.md` §5's `ServerSystem` mask table.** `GFlags(+0xdc)`, listed as *"a second
presence source (not read here)"*, is the **per-player gate mask**, read by `FUN_00744010`.
**8.5 Agreements recorded, entries dropped (rule 14).** `StarFleet_MinRange` 0x006ff6a0,
`StarFleet_ResolveWaypoint` 0x00701390, `IsGateTransitWaypoint` 0x0056e6e0, `IsNodeWaypoint` 0x0056e720,
`StarShip_IsGroundedByDamage` 0x00815090, `StarFleet_GetLocationIfNode` 0x006fe300,
`StarFleet_HasFlagShips` 0x00703500 and `StrategyServer_OrderFleetMove` 0x008653c0 all already exist with
prototypes matching what I read. **Eight independent re-derivations agreeing; no duplicate rows filed.**
`StrategyServer_OrderFleetMove`'s existing prototype says the meaning of the three bits *"is not known"* — the
new `lane-p2.json` does not re-file the address; the meaning goes in this document and in the
`PathSolver` entry that names it.
---
## 9. What this lane did **not** read
* **`FUN_00705d60`'s fold** — past its first ~0x90 bytes. The polarity of its bool and the type of its result
are established; the tanker redistribution that turns per-ship ranges into a fleet range is not. It is the
input to every range decision in §4, so this is the largest remaining hole in the *numbers*.
* **`FUN_006e4de0`** — the node **bore**. Read only as a call shape (`(owner, from, to, 0, fleet)`, cdecl,
5 args) and by its two outcomes. It creates a node line and therefore almost certainly writes saved state
that this document does not list.
* **`FUN_006e2130`** and the bucket walk `FUN_006905a0` in `FindNodeLine`. The invariance of the ranking term
is [V]; what it computes is not read.
* **`FUN_006d2050`** — the relation scale. §1.6.
* **`FUN_0080c820`** (`Ship_MaxRange`), **`FUN_00705c70`** (the speed recompute that sets `FPsp2`),
**`FUN_00703cf0`**, **`FUN_00706c90`**, **`FUN_00849280`** past B5's first ~60 bytes.
* **`MoveFleet`'s two `FUN_00703730` call sites** (0x7da5c6, 0x7da5da) — the multi-waypoint continuation.
Prediction P1's first falsification route.
* **Indirect edges.** Three on the main line, none resolved: `(fleet->galaxy(+0x10))->vft[1]()` at 0x703a14
(the node graph getter — the single most important one, since the whole of §2.5 hangs off its result),
`(S+4)->vft[2]()` at 0x86540e in `OrderFleetMove`, and the `vft[2]`/`vft[3]` pair inside `FUN_00703bd0`
that enumerates a node's fleets. `tools/vtable_map.py` and `ghidra/vtable-owners.json` were consulted for
callers of `FUN_007066c0` (two direct, both named in §1.1; the E8/E9 sweep found no tail-call thunk into
it), but the three sites above are **outbound** edges and lane V2's inversion does not reach them.
* **The class of the kind-2 "point" object.** It has `Pos` at +0x18, a kind tag at +0x14 and two per-player
bitmasks at +0x8c / +0x90. It is not `Game::StarMapNode` (too small). Unresolved.
**The strongest and weakest sentence in this document are the same one:** the classifier's rules are read
instruction by instruction and are internally consistent with `FUN_0080c7d0`, the two waypoint jump tables,
`OrderFleetMove`'s `0x418` mask, the UI's `!= 0` mask and five verified object layouts — and **not one leg of
it has ever been observed executing under an instrument.**
---
## 10. The reimplementation
`sots-engine` branch **`wip/pathing`**, module `src/game/nav/` (a new directory; lane N owns `src/game/sim/`,
A2 `src/app`, D2 `src/game/data`). Clean-room: no addresses, no raw identifiers, no game data.
Modelled as pure functions returning a plan, not a mutation — the same split `game/combat` uses, because
installing a flight plan touches saved state the module does not own:
`DriveTypeOfSpecies`, `IsGateTransitWaypoint`, `IsNodeWaypoint`, `FleetDriveType`, `GateProjectionReaches`,
`GateTransitAllowed`, `LegInRange` (with the unstored `r*r`), `LegLength` (the five narrowings),
`ClassifyLeg`, `SolvePath`, and the flag constants with `kOrderRefusalMask`.
Gates run as separate commands; results in the lane report.

View file

@ -53,7 +53,7 @@ Format: **Screen** — evidence (textures / sprites / tokens) → key labels →
| F9 | **Game setup (custom)** | `GUI/GameSetup/GameSetupElements.tga` (icons AI/Player/Options/Timer/Money; **30 star-map shape icons**; 6 species); `GAMESETUP_*` (59), `MAPSHAPE_*` (25); `Maps/*.csv` custom maps; `UI_GAMESETUP_ROLLOVER/CONFIRM` | Star Map Setup (shape, Star Density, Number of Stars, Distance ly, Size, Resources, Suitability) · Available Species · Economic/Research Efficiency · AI Easy/Normal/Difficult · Initial Treasury/Colonies/Technologies · Options: Alliances, Random Encounters %, Lobby/Player Passwords, Teams (+Grouped), Win Ratio · Time: Strategic Turn Length, Combat Turn Length, Combat Query Time Limit (+per-additional-round) · Create Game / Cancel Game | `GameSetupPanel`, `CustomSetupPanel`, `CustomSetup_Maps`, `GameSetup_AI/LSE/MapSelect(+Impl)/MapView(+IconOverlay)/Money/Name/Option/Options/Player/Species/Time`, `CustomMapRowParser`; legacy `OldGameSetupPanel` | `StrategyGameCreateParams`, `StrategyGameParams`, `StrategyTimerParams`, `StarMapParams`, `Data/Strategy/starmap.txt`, `RealSpace.csv` |
| F10 | **Scenario setup** | `GUI/GameSetup/Scenario_Set_A…H.tga` (16 scenario images; `Set_C` is in the local gob); `SCENARIO_*` (149) | Description / Rules / Objectives pages; 14 scenarios: Jewels of the Crown, Hungry Children (HiverInvasion), Upstart Apes (key `EARTHVSTARKAS`), Holy Lands, A New Hope (CivilWar), His Master's Voice, Lords of a Broken Empire, The End of Flesh, Basic Tutorial, Tourney Space, Progression Wars, The Gathering, Land Grab (S/M/L), Antiquarians | `ScenarioSetupPanel`, `ScenarioSetup_Maps`, `GameSetup_ScenarioPages/ScenarioScene/ScenarioSelect`, `ScenarioPageBase`, `ScenarioDescPage`, `ScenarioObjPage`, `ScenarioRulesPage`, `ObjectivesPanel` | `SVScriptObject` → 33 `SVSO*` classes (one per scenario/encounter script) + `Scenarios/*.txt/.csv`, `StrategyScriptParams` |
| F11 | **Load game** | `GAMESETUP_LOAD_*`, `GAMELIST_*`, `GAMECARD_*`, `GAMEINFO_*` (12); `SOTS_GAME_{AUTOSAVE,AUTOSAVEBACKUP,REJOINSAVE,ENDTURN_AUTOSAVE,ENDTURN_AUTOSAVEBACKUP}` | Select saved game; card shows Galaxy Type, Stars, Players, Turn, Version, Economy, Research, Turn/Combat Time, Alliances, Encounters, Teams; Delete | `StrategyGameList` (+`Item`), `GameInfoPanel`, `CustomDataTable::StrategyGameLoader` | `StrategyGameLoadParams`, `.sav` |
| F12 | **Game browser** (Join MP) | `GAMEBROWSER_*`, `SERVERPAGE_*`, `FILTERDLG_*`, `MANUALJOIN_*`, `CHAT_*`, `CHATCHANNEL_*`, `MATCHINGSERVICE_*`, `NETERROR_*` (21 incl. 6 CD-key), `AUTOJOINERR_*`; `GUI/CrossFireLoad.tga` (`AUTOJOIN`) | Pages LAN / Internet / Internet Chat; server list (Game Name, Players, Ping, Password, Status, Version); Edit/Apply Filter; Join Manually (Favorites); Refresh; Join; `/join addr[:port]` command | `GameBrowserScreen`, `GameBrowserPanel` + nested `LANPage`, `InternetPage`, `ChatPage`, `ServerPage`, `ServerList`, `GameDisplay`, `GamePlayers`, `GBMOTD`, `FilterDialog`, `ManualJoinDialog`, `EnterPasswordDialog`; `AutoJoin`, `AutoJoinBackground`; `ChatMonitor`, `ChatHistoryView` | GameSpy master/chat (IRC-style), NatNeg; `StrategyJoin` (+`ProgressDialog`) |
| F12 | **Game browser** (Join MP) | `GAMEBROWSER_*`, `SERVERPAGE_*`, `FILTERDLG_*`, `MANUALJOIN_*`, `CHAT_*`, `CHATCHANNEL_*`, `MATCHINGSERVICE_*`, `NETERROR_*` (13, **none CD-key** — corrected by lane G2, see [[multiplayer-gamespy]] §1), `AUTOJOINERR_*`; `GUI/CrossFireLoad.tga` (`AUTOJOIN`) | Pages LAN / Internet / Internet Chat; server list (Game Name, Players, Ping, Password, Status, Version); Edit/Apply Filter; Join Manually (Favorites); Refresh; Join; `/join addr[:port]` command | `GameBrowserScreen`, `GameBrowserPanel` + nested `LANPage`, `InternetPage`, `ChatPage`, `ServerPage`, `ServerList`, `GameDisplay`, `GamePlayers`, `GBMOTD`, `FilterDialog`, `ManualJoinDialog`, `EnterPasswordDialog`; `AutoJoin`, `AutoJoinBackground`; `ChatMonitor`, `ChatHistoryView` | GameSpy master/chat (IRC-style), NatNeg; `StrategyJoin` (+`ProgressDialog`) |
| F13 | **Lobby** (host & join; also the MP *between-turns* room) | `GUI/Lobby/{LobbyBG,LobbyElementsA-C}.tga` (`SLOTPANEL_*`, `SLOT_BUTTON_{PASSWORD,KICK,OPEN,CUSTOMAI,AI}`, `SLOTUSER_{AI,DEAD}`, `SLOTSPECIES_*`, `LOBBY_COLOR_*`, `AVATAR_RANDOM`, `BADGE_RANDOM`); `LOBBY_*` (45), `LOBBYSTATUS_*`, `PLAYERSETUP_*` (17), `HOSTMIG*` (24), `TIMERSDLG_*`, `AIPOLICY_*`, `SYSTEMMSG_*` | Launch / Leave Game / Cancel Game / Ready; slots Open/Closed/AI/password/kick/lock; Player Setup (Name, Species, Color, Badge, Avatar, Team, Settings); Game Status (Strategic round… / Resolving combat… / Querying for combat…, "%s of %s players still playing", "%s battles in progress"); in-progress join ("will enter the game when the next turn begins"); Downloading game data; host migration | `StrategyLobbyScreen` (+`EnterPasswordDialog`, `MOTDDialog`, `PasswordStatus`, `SpeciesInfoPanel`, `TurnInfoPanel`), `SlotPanel`, `LobbySlotPanel`, `SlotDetailsPanel`, `PlayerSetupPanel`, `SpeciesSelectDialog`, `SpeciesDescPanel`, `LobbyChat`, `ChatDialog`, `AIPolicyDialog/Panel`, `SetTimersDialog`, `ServerMigrationStatusPanel` (+`ConfirmAbortDialog`), `ClientMigrationStatusPanel`, `StrategyHost` (+`ProgressDialog`, `ContinueDialog`, `SHMOTD`) | `SNMSlot*` (14 msgs), `SNMJoin*`, `SNMLobbyChat`, `SNMSetTimers`, `SNMSetAIPolicy`, `SNMMakePlayerAI`; `FNM*` chunked file transfer for migration/rejoin; `StrategySessionParams`, `StrategyHostParams` |
### 2.2 Strategy layer (the turn screen and its children)
@ -188,7 +188,7 @@ flowchart TD
2. **Debug overlays and dev toggles left in:** `DebugOverlay::StrategyMapScreen`, `DebugOverlay::BattleView`; `Lines.script` `DEBUG_UseSimpleLine / DEBUG_LockDestination / DEBUG_ForceOutOfRange / DEBUG_ReloadOnFleetChange` (hot-reload of the line script); hotkeys `UICSTR_TAC_FRAMERATE` ("Toggle Framerate") and `UICSTR_TAC_SINGLE_STEP`; `DemoApp` and `MutexScreen` classes; `ForceSingleCore` ini key.
3. **Legacy / cut screens:** `OldGameSetupPanel`; sprites `BARBUTTON_OLDTRADE` and `BARBUTTON_OLDALLIANCE` (pre-ANY trade & alliance buttons replaced by S11/S12); `ChatHistoryPanel2`, `WeaponPanel2`, `CombatReportPanelEx` (v2 rewrites kept beside v1); `Scenario_Set_D/E.tga` carry images for **`SCENARIO_KINGOFTHEHILL`** and **`SCENARIO_SLAVEMASTER`** which have no strings and no `Scenarios/*.txt` — cut scenarios; "Upstart Apes" is internally `EARTHVSTARKAS`; `SUPPORT_REPAIR_AVAIL/REQ` are empty strings.
4. **Localized gob carries UI art:** `Scenario_Set_C.tga` (Holy Lands + Tutorial images), `Tutorial/page9-11.tga` and `Data/credits.txt` are only in `sots_local_en.gob` — a loader that only mounts `sots.gob` will miss sprites the table references.
5. **GameSpy is dead and the 2017 build knows it:** `MATCHINGSERVICE_UNSUPPORTED` "Online support for Sword of the Stars is no longer available" + "players can still connect manually" — F12 degrades to LAN/manual IP; CD-key strings (`NETERROR_CDKEY_*`, `STARTUPERROR_CDKEY_*`) survive.
5. ~~**GameSpy is dead and the 2017 build knows it:**~~ **Corrected by lane G2 — see [[multiplayer-gamespy]] §0 and §1.** The `MATCHINGSERVICE_UNSUPPORTED` string ("Online support for Sword of the Stars is no longer available" + "players can still connect manually") is the *GameSpy kill-switch* message, fired only when a server answers the availability check with the unavailable bit set; the SDK **fails open** on DNS failure, so the 2017 build does not detect the shutdown on its own. F12 does degrade to LAN/manual IP, and that path is fully GameSpy-free. There are **no CD-key strings in this binary at all** — no `CDKEY` substring anywhere, no `STARTUPERROR_*` family, and all 13 `NETERROR_*` entries are connection/version/password errors.
6. **Standalone dedicated server** ships (`sots_server.exe`, `Dedicated Server Launchpad.exe`) with its own error vocabulary `SERVERERROR_*` (ini-section driven: map file, allowed species, defaults) — the strategy server is separable from the client UI, which is good news for a reimplementation split.
7. **Achievement/badge system predates Steam:** 56 `GUI/Achievements/*.tga`, `PlayerBadgeSet`, `PROFILEMAN_BADGES_*`, badge sprites `BADGE_*_GW100` — stored in the local profile.
8. **Sticky-note system:** per-system player notes (`PlayerNotePanel`, `StickyNoteElements.tga`, "Added by: %s, Turn %s") that can be *sent to other players* via `CommDeclareShareSystemNotes`.

View file

@ -0,0 +1,100 @@
{
"entries": [
{
"name": "StrategyServer_Write_ModCountFrameTags",
"addr": "0x0079fb2f",
"convention": "site",
"prototype": "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)",
"status": "verified",
"source": "findings/control-flow/alliance-mask-and-modcount.md §2 (lane A2 2026-09-08, instruction stream 0x0079fa70-0x0079fc70)"
},
{
"name": "StrategySim_off_ModCount",
"offset": "0x4",
"convention": "field",
"prototype": "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)",
"status": "verified",
"source": "findings/control-flow/alliance-mask-and-modcount.md §2 (lane A2 2026-09-08); named first by lane Z in combat-done-tail.md §7.1, independently re-derived here from StrategyServer::Write"
},
{
"name": "StrategySim_off_Frame",
"offset": "0x8",
"convention": "field",
"prototype": "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",
"status": "verified",
"source": "findings/control-flow/alliance-mask-and-modcount.md §2 (lane A2 2026-09-08)"
},
{
"name": "StrategyServer_ProcessTurn_AllianceMaskPass",
"addr": "0x007dc871",
"convention": "site",
"prototype": "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",
"status": "verified",
"source": "findings/control-flow/alliance-mask-and-modcount.md §1 (lane A2 2026-09-08). AGREES with lane T's ServerPlayer_off_TurnRecord / _off_AllianceId / _off_AllianceMask, which are kept and not duplicated here"
},
{
"name": "StrategySim_ApplyTurnCommandBatch",
"addr": "0x0088f9b0",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/control-flow/alliance-mask-and-modcount.md §2.2 (lane A2 2026-09-08)"
},
{
"name": "StrategyServer_ApplyAllTurnCommands",
"addr": "0x0078f6a0",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/control-flow/alliance-mask-and-modcount.md §2.2 (lane A2 2026-09-08)"
},
{
"name": "StrategyServer_off_TurnCommandQueue",
"offset": "0x174",
"convention": "field",
"prototype": "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",
"status": "verified",
"source": "findings/control-flow/alliance-mask-and-modcount.md §2.2 (lane A2 2026-09-08)"
},
{
"name": "TurnCommands_sizeof",
"offset": "0x1b4",
"convention": "field",
"prototype": "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",
"status": "verified",
"source": "findings/control-flow/alliance-mask-and-modcount.md §2.2 (lane A2 2026-09-08); layout from findings/objects/turncommands-block.md (lane Q)"
},
{
"name": "StrategyNetworkClient_OnMessage",
"addr": "0x00784640",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/control-flow/alliance-mask-and-modcount.md §2.2 (lane A2 2026-09-08)"
},
{
"name": "StrategyNetworkClient_off_Server",
"offset": "0x54",
"convention": "field",
"prototype": "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",
"status": "verified",
"source": "findings/control-flow/alliance-mask-and-modcount.md §2.2 (lane A2 2026-09-08)"
},
{
"name": "StrategyServer_AbandonChaosCheck",
"addr": "0x007b9df0",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/control-flow/alliance-mask-and-modcount.md §2.3 (lane A2 2026-09-08)"
},
{
"name": "StrategySim_MoveFleetCommand",
"addr": "0x00865780",
"convention": "thiscall",
"prototype": "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)",
"status": "verified",
"source": "findings/control-flow/alliance-mask-and-modcount.md §2.2 (lane A2 2026-09-08)"
}
]
}

View file

@ -0,0 +1,348 @@
{
"entries": [
{
"name": "GameSpy_GSIStartAvailableCheck",
"addr": "0x0040a060",
"convention": "cdecl",
"prototype": "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. */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "GameSpy_GSIAvailableCheckThink",
"addr": "0x0040a210",
"convention": "cdecl",
"prototype": "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. */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "GameSpy_AvailableCheckParseReply",
"addr": "0x0040a1a0",
"convention": "fastcall",
"prototype": "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. */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "GameSpy_NatNeg_Resolve",
"addr": "0x00412530",
"convention": "cdecl",
"prototype": "void (void) /* resolves natneg1.gamespy.com / natneg2.gamespy.com; UDP port 27901 (htons 0x6cfd) bound in FUN_00412200 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "GameSpy_NNBeginNegotiationWithSocket",
"addr": "0x00412590",
"convention": "cdecl",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "GameSpy_peerInitialize",
"addr": "0x00416be0",
"convention": "cdecl",
"prototype": "void* (PEERCallbacks* cbs /* 0x74 bytes */)",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "GameSpy_peerSetTitle",
"addr": "0x00416cc0",
"convention": "cdecl",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "GameSpy_peerStartReportingWithSocket",
"addr": "0x00416a50",
"convention": "cdecl",
"prototype": "int (void* peer, SOCKET s, unsigned short port)",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "GameSpy_qr2_init_socket",
"addr": "0x0041d1a0",
"convention": "cdecl",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "GameSpy_qr2_register_key",
"addr": "0x0041e730",
"convention": "cdecl",
"prototype": "void (int keyid, const char* name)",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "GameSpy_ServerBrowserNew",
"addr": "0x00420060",
"convention": "cdecl",
"prototype": "void* (const char* queryForGamename, const char* queryFromGamename, const char* queryFromKey, int queryFromVersion, int maxConcUpdates, int queryVersion, int lanBrowse, void* callback, void* instance)",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "GameSpy_ServerBrowserLANUpdate",
"addr": "0x00420160",
"convention": "cdecl",
"prototype": "int (void* sb, int startSearch, unsigned short startSearchPort, unsigned short endSearchPort)",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "GameSpy_ServerBrowserHalt",
"addr": "0x00420110",
"convention": "cdecl",
"prototype": "void (void* sb)",
"status": "mapped",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "GameSpy_SBServerListConnect",
"addr": "0x00420840",
"convention": "thiscall",
"prototype": "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. */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "GameSpy_chatConnectPeerchat",
"addr": "0x00417ab0",
"convention": "cdecl",
"prototype": "int (...) /* passes \"peerchat.gamespy.com\", TCP port 6667 (0x1a0b) to the socket-connect helper at 0x00408fe0 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_AutoJoin_ctor",
"addr": "0x0076c6a0",
"convention": "thiscall",
"prototype": "void (AutoJoin* this /*0x90 bytes*/, const void* params, const char* address) /* vftable 0x00a218f4; address std::string at +0x68 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_AutoJoin_Think",
"addr": "0x00778a90",
"convention": "thiscall",
"prototype": "bool (AutoJoin* this) /* vftable slot 2. Runs GSIStartAvailableCheck/Think, stores availability at +5, then AutoJoin_InitServerBrowser */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_AutoJoin_InitPeer",
"addr": "0x007617f0",
"convention": "thiscall",
"prototype": "bool (AutoJoin* this) /* refuses when this->available (+5) == 0; peerInitialize + peerSetTitle(\"swordots\",\"Z5gR9Z\") */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_GameBrowserPanel_InitPeer",
"addr": "0x00778c40",
"convention": "thiscall",
"prototype": "bool (GameBrowserPanel* this) /* gated on this+0x8a (availability); peerInitialize + peerSetTitle */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_GameBrowserPanel_ctor_tail",
"addr": "0x0077e210",
"convention": "thiscall",
"prototype": "GameBrowserPanel* (GameBrowserPanel* this, ...) /* calls Game_RegisterQR2Keys then GSIStartAvailableCheck(gamename) */",
"status": "mapped",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_GameBrowserPanel_LANPage_CreateBrowser",
"addr": "0x0077dde0",
"convention": "thiscall",
"prototype": "void (LANPage* this) /* vftable slot 18. ServerBrowserNew(\"swordots\",\"swordots\",\"Z5gR9Z\", 0, 30, 1, lanBrowse=1, cb, this) into this+0xb9c. NO availability gate. */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_GameBrowserPanel_LANPage_Refresh",
"addr": "0x0077de50",
"convention": "thiscall",
"prototype": "void (LANPage* this) /* ServerBrowserHalt then ServerBrowserLANUpdate(sb, 1, g_LanScanPort, g_LanScanPort + g_LanScanPortRange) */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_GameBrowserPanel_ShowManualJoinDialog",
"addr": "0x0077e590",
"convention": "thiscall",
"prototype": "void (GameBrowserPanel* this) /* news a 0x166c-byte ManualJoinDialog (ctor 0x0077c790, vftable 0x00a22604) and binds Game_ManualJoin_OnAccept as its handler */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_ManualJoin_OnAccept",
"addr": "0x0076ef90",
"convention": "cdecl",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_ParseHostAddress",
"addr": "0x008f64d0",
"convention": "cdecl",
"prototype": "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. */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "App_StartJoin",
"addr": "0x00899e30",
"convention": "thiscall",
"prototype": "bool (App* this, const void* params, const char* address, void* serverBrowser) /* news Game::StrategyJoin (0xc8 bytes) into this+0x158; serverBrowser may be NULL */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_StrategyJoin_ctor",
"addr": "0x00777b10",
"convention": "thiscall",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_StrategyJoin_Think",
"addr": "0x00765810",
"convention": "thiscall",
"prototype": "bool (StrategyJoin* this) /* vftable slot 2; jump table at 0x00765948 for states 1..5 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_StrategyJoin_QueryViaServerBrowser",
"addr": "0x00770e70",
"convention": "thiscall",
"prototype": "bool (StrategyJoin* this, void* serverBrowser) /* ServerBrowserAuxUpdateIP-style direct query then NNBeginNegotiationWithSocket */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "App_ConnectToStrategyHost",
"addr": "0x0089a7e0",
"convention": "thiscall",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_OnJoinGame_ChooseAddress",
"addr": "0x00772f60",
"convention": "thiscall",
"prototype": "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) */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_StrategyHost_InitPeer",
"addr": "0x007c1ee0",
"convention": "thiscall",
"prototype": "bool (StrategyHost* this) /* refuses when this+0x24e (availability) == 0; peerInitialize with a 0x74-byte callback table then peerSetTitle */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_StrategyHost_StartReporting",
"addr": "0x007c2dd0",
"convention": "thiscall",
"prototype": "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. */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_RegisterQR2Keys",
"addr": "0x00898bf0",
"convention": "cdecl",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_LoadNetworkConfig",
"addr": "0x005a0610",
"convention": "thiscall",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "Game_ParseJoinCommandLine",
"addr": "0x0089d280",
"convention": "thiscall",
"prototype": "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 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "g_GameSpyGameName",
"addr": "0x00a35cd4",
"convention": "data",
"prototype": "const char* /* -> \"swordots\" at 0x00a35c50 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "g_GameSpySecretKey",
"addr": "0x00a35cd8",
"convention": "data",
"prototype": "const char* /* -> \"Z5gR9Z\" at 0x00a35c48 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "g_GameVersionWord",
"addr": "0x00b2d510",
"convention": "data",
"prototype": "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. */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "g_LanScanPort",
"addr": "0x00b13c6c",
"convention": "data",
"prototype": "uint16_t /* [Network] LanScanPort, default 3369 */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "g_LanScanPortRange",
"addr": "0x00b13c70",
"convention": "data",
"prototype": "uint32_t /* [Network] LanScanPortRange, default 1; LAN sweep is [LanScanPort, LanScanPort+Range] */",
"status": "verified",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "g_GameSpyAvailableHostOverride",
"addr": "0x00b085b0",
"convention": "data",
"prototype": "char[] /* when non-empty, replaces \"<gamename>.available.gamespy.com\" in the availability check */",
"status": "mapped",
"source": "findings/subsystems/multiplayer-gamespy.md"
},
{
"name": "g_GameSpyMasterHostOverride",
"addr": "0x00b09440",
"convention": "data",
"prototype": "const char* /* when non-NULL, replaces \"<gamename>.ms<N>.gamespy.com\" in SBServerListConnect */",
"status": "mapped",
"source": "findings/subsystems/multiplayer-gamespy.md"
}
]
}

View file

@ -0,0 +1,241 @@
{ "entries": [
{ "name": "PathSolver",
"addr": "0x007066c0",
"convention": "cdecl",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "StarFleet_ClassifyLeg",
"addr": "0x00703730",
"convention": "thiscall",
"prototype": "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}",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "PathFlag_ShipActionsWillCancel",
"offset": "0x00000001",
"convention": "constant",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "PathFlag_NodeLegOutOfRange",
"offset": "0x00000002",
"convention": "constant",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "PathFlag_GateTrafficExceeded",
"offset": "0x00000004",
"convention": "constant",
"prototype": "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)",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "PathFlag_CannotInterceptFleet",
"offset": "0x00000008",
"convention": "constant",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "PathFlag_FleetGrounded",
"offset": "0x00000010",
"convention": "constant",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "PathFlag_NoNodeLineAndCannotBore",
"offset": "0x00000020",
"convention": "constant",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "PathFlag_BoredLineOutOfRange",
"offset": "0x00000040",
"convention": "constant",
"prototype": "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)",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "PathFlag_NodeBoreFailed",
"offset": "0x00000080",
"convention": "constant",
"prototype": "int // ClassifyLeg flag bit 0x080, set at 0x00703b7d when FUN_006e4de0 (bore a node line between two systems) returns false. Not a refusal bit",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "PathFlag_DestSystemNotFriendly",
"offset": "0x00000100",
"convention": "constant",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "PathFlag_SourceSystemNotFriendly",
"offset": "0x00000200",
"convention": "constant",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "PathFlag_DestPointNotPermitted",
"offset": "0x00000400",
"convention": "constant",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "PathFlag_ShipActionEight",
"offset": "0x00000800",
"convention": "constant",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "PathFlag_OrderRefusalMask",
"offset": "0x00000418",
"convention": "constant",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "DriveTypeOfSpecies",
"addr": "0x0080c7d0",
"convention": "cdecl",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "StarFleet_GetDriveType",
"addr": "0x006ff810",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "ServerSystem_HasGate",
"addr": "0x00744010",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "ServerPlayer_GateProjectionReaches",
"addr": "0x00818040",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "ServerPlayer_GateTrafficCapacity",
"addr": "0x0080dc50",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "StarFleet_LegInRange",
"addr": "0x006ffa00",
"convention": "thiscall",
"prototype": "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)",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "StarFleet_MinTankCapacity",
"addr": "0x006ff710",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "NodeGraph_FindNodeLine",
"addr": "0x006e4eb0",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "StarFleet_CanRefuelAt",
"addr": "0x00703c90",
"convention": "cdecl",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "MapObject_GetOwner",
"addr": "0x0071e280",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "MapObject_AsSystem",
"addr": "0x0071e340",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "StarFleet_SolveFleetIntercept",
"addr": "0x00703650",
"convention": "register-live-in",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "StarFleet_GetNodeTransitOrigin",
"addr": "0x006ffab0",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "StarFleet_PendingShipActionMask",
"addr": "0x006ff990",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "StarFleet_AnyShipGroundedByDamage",
"addr": "0x00700240",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "StarFleet_SetFlightPlan",
"addr": "0x00707080",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "FlightPlan_Waypoint_Set",
"addr": "0x007006e0",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "NodeRoute_Construct",
"addr": "0x006e1b20",
"convention": "thiscall",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" },
{ "name": "StarFleet_PosDiffersFromPointLocation",
"addr": "0x0080ec50",
"convention": "cdecl",
"prototype": "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",
"status": "verified",
"source": "findings/subsystems/path-solver.md" }
] }

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 @ 3c48d3a, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ 728348b, 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]