saved-state writer under combat FUN_007d5a00's six callees, read from the instruction stream to the next function start, plus 18 helpers. Lane J named this "the real per-phase combat pipeline"; the identification of it as the most tractable target was right and the characterisation was not. It is one subsystem -- destinations, group, classify, split, execute, destroy -- and it is StarFleet retreat. Seven saved fields or containers move on this path: ship->fleet membership, StarFleet objects created and destroyed in the master list, the system fleet lists, fleet position/location/flight plan/flags, the ServerSystem presence and explored masks, the per-player PlayerView, and ServerPlayer.GTraf. Closes struct-recovery.md section 7's open "OID allocation (R2's x16)": the allocator is at 0x008b8ae0, the counter lives per network node at IDMap->+0x08 + node*0x14 + 0x10, it is pre-incremented and never issues 0, and an id is (counter << 4) | (node & 0xF). R2's x16 is the shl 4. A partial retreat mints one id per group, and that counter is almost certainly the save's NMnx tag -- labelled hypothesis, with a falsifiable prediction written down before any run. New rule: retreating from a system you had not explored marks it explored for you, writing EFlags and refreshing the PlayerView. New diplomacy rule: a system whose owner captured it on the current turn loses that owner's ceasefire cover in the hostile-presence test. RNG recomputed independently from a different root and with a different tool: 327 functions in the closure once E9 tail-call thunks are followed (the first pass missed an 8-byte jmp thunk and with it the whole id-allocator chain), zero calls to the four primitives, zero inlined MT tempering immediates, and one false-positive 0x11c stride that is StarFleet.FtMS being initialised to -1. Bound, not proof: 152 indirect call sites in 91 of the 327, four of them on the main line and flagged for the vtable lane. Corrects my own first reading of the fleet-creation call: a ret-N tail-call thunk in the middle of an argument list makes three pushes look like they belong to the wrong call. Checking ret N against the push count catches it. The honest limit stands: none of this has ever executed under an instrument. combat-resolver.md section 10.3's workload now needs a second condition -- the battle must produce a retreat, or all six phases are no-ops.
606 lines
42 KiB
Markdown
606 lines
42 KiB
Markdown
# `FUN_007d5a00` and its six callees, read from the instruction stream — the post-battle **retreat** pipeline
|
||
|
||
Lane B5, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
|
||
|
||
**Method.** Every one of the six callees, the dispatcher itself and 18 further helpers were disassembled byte
|
||
for byte with `objdump -b binary -m i386 -M intel` over the raw image, **each to the next function start**, not
|
||
to Ghidra's reported size (rule 17). Call targets from `dumps/functions.json`, immediates resolved against
|
||
`.rdata`/`.data`. No claim about control flow below comes from a decompiler. Two callee subtrees
|
||
(`FUN_0088b980`, `FUN_008653c0`) and the object-id allocator chain were read by delegated sweeps working from
|
||
the same instruction stream; **every such claim is marked**.
|
||
|
||
Closes the **tier-2 #1** gap of `findings/control-flow/combat-resolver.md` (lane J §8), which named these six
|
||
as *"the real per-phase combat pipeline … the best reimplementation target in the whole combat path"*.
|
||
It **corrects** that characterisation — see §7.
|
||
|
||
---
|
||
|
||
## 0. Lead: what reaches saved state
|
||
|
||
`FUN_007d5a00` is not a general combat pipeline. It is **retreat**, and it is the single largest writer of
|
||
saved state anywhere under the combat resolver. Lane J's headline — *"the resolver's own body writes nothing
|
||
outside its stack context"* — stands, and this is where a large part of the "its callees write plenty" lives.
|
||
|
||
Seven saved fields or containers are written on the retreat path. Every one is `verified` in
|
||
`findings/objects/struct-recovery.md`, so the autosave oracle can see all of them.
|
||
|
||
| # | what is written | where, in save terms | by | how it moves |
|
||
|---|---|---|---|---|
|
||
| **1** | **`StarShip.FltID`** (`ship+0x64`) | every retreating ship's fleet handle | `FUN_007063a0` / `FUN_007062e0` | a partially-retreating fleet's ships are re-parented into a brand-new fleet |
|
||
| **2** | **`StarFleet` objects themselves** — the `NumFlts / FltID / Flt` list off `StrategyServer` | `(S+4)->+0x60` = **`S+0x64`** | `FUN_0085b340` appends; `FUN_0088b980` erases | one fleet **created** per partially-retreating group; every fleet left with zero ships **destroyed** |
|
||
| **3** | **`NMnx` — the object-id counter** | the `StrategyServer` block's 4th tag, right after `NMSz`/`NMLc` | `IDMap::AllocateID` 0x008b8ae0 | **`inc DWORD PTR [ecx+eax*4+0x10]` at 0x008b8b16**, once per created fleet. See §5 |
|
||
| **4** | **`StarFleet.NShips`** (`fleet+0xa4/+0xa8`) and **`FtFlg`** (`+0x10c`) | per fleet | `FUN_007062e0`, `FUN_006fe1a0` | membership; the new fleet is created with flag **0x400** set, and a retreating fleet gets flag **2** OR'd in |
|
||
| **5** | **`StarFleet.LocID`** (`+0xa0`), **`Pos`** (`+0x18`), **`PrvPos`** (`+0x4c`) and the inline **`FlightPlan`** (`+0xc4..0xfb`) | per fleet | `FUN_006fe2f0`, `FUN_0074f240`, `FUN_0074d1c0`, `FUN_008653c0`→`FUN_00707080` | gate retreat teleports the fleet outright; a normal retreat installs a flight plan and detaches from the system |
|
||
| **6** | **`ServerSystem` presence and knowledge masks** — `VFlags(+0xcc)`, **`EFlags(+0xd0)`**, `AFlags(+0xd4)`, `FFlags(+0xd8)` — and **`NumFlts/Flt`** (`sys+0x16c`) | per system | `FUN_0074f240` / `FUN_0074d1c0` / `FUN_007438b0` | fleets entering and leaving; and **retreating from a system you had not explored marks it explored for you** |
|
||
| **7** | **`ServerPlayer.GTraf`** (`+0x14c`) and the per-system **`NVs` / `StarSystem::PlayerView`** map | per player / per (system, player) | `FUN_007063a0`, `FUN_00707080`, `FUN_007d5080` | gate traffic is debited and re-credited around every membership and flight-plan change |
|
||
|
||
**Three of those are new to the campaign's picture of combat**, and one closes a long-open question:
|
||
|
||
* **`NMnx` moves because a battle happened.** `struct-recovery.md` §7 lists *"`OID` allocation (R2's ×16)"* as
|
||
open. R2's "×16" is the `shl esi,0x4` at 0x008b8b38: an object id is `(counter << 4) | (nodeIndex & 0xF)`.
|
||
The counter is per network node, at `IDMap->+0x08 + node*0x14 + 0x10`, pre-incremented, and value 0 is
|
||
skipped with a `"IDMap: NextID wrapping for node %d."` log. **A partial retreat mints one id per group, and
|
||
that counter is serialized.** *(Delegated read, instruction-level, of `FUN_008b8ae0` / `FUN_008b8a70` /
|
||
`FUN_008b9350` / `FUN_008b8b70`; the call site and the thunk I re-read myself — see §7.3, where I correct my
|
||
own first reading of the argument list.)*
|
||
* **Retreat grants system knowledge.** `FUN_007d5650` tests `enc->system->EFlags & (1 << PlyrIdx)` and, when
|
||
clear, runs `FUN_007d5080` → `ServerSystem::SetExplored(player, true)` **plus** a `PlayerView` refresh
|
||
through the map at `S+0x224`. Both are saved.
|
||
* **Retreat rewrites the fleet graph.** Not a stat, not an event — the actual object graph the save's
|
||
`NumFlts` / `NShips` / `FltID` chain encodes.
|
||
|
||
**Nothing here writes the turn-results accumulator.** A whole-closure scan for the `SETurnResults` stride
|
||
(`imul r32,r32,0x11c` / `add r32,0x11c` at real instruction boundaries) over all 327 reachable functions
|
||
returns exactly one hit, 0x00706290, and it is a **false positive**: `mov DWORD PTR [esi+0x11c],0xffffffff` in
|
||
the `StarFleet` constructor, initialising `FtMS`. So lane J's `+0x90` writer and lane K's `+0x24` writer remain
|
||
the only two on the combat path.
|
||
|
||
### 0.1 A falsifiable prediction (rule 2)
|
||
|
||
Written before any run, for the workload `combat-resolver.md` §10.3 specifies:
|
||
|
||
> **On the first turn a real battle produces a retreat in which a fleet only partly runs, the post-turn
|
||
> autosave's `Summary` will differ from the pre-turn one in `NMnx` by exactly the number of retreat groups
|
||
> that had at least one split fleet — one per group, not one per ship and not one per fleet.**
|
||
> Additionally `NumFlts` rises by that same count and falls by the number of source fleets emptied.
|
||
|
||
How it could be wrong, and the symptom of each way:
|
||
|
||
1. `NMnx` might not be the `IDMap` counter at all (it is identified from the tag order `NMSz, NMLc, NMnx`
|
||
plus the counter's location, **not** from reading `StrategyServer::Write`). Symptom: `NMnx` does not move
|
||
at all, or moves on turns with no fleet creation.
|
||
2. The counter may advance for other reasons in the same turn (any object creation uses the same allocator).
|
||
Symptom: the delta exceeds the group count. That does **not** falsify the mechanism; it means the
|
||
experiment needs a turn whose only creation is the retreat.
|
||
3. A group can be flagged partial and still move no ship (every candidate's source fleet already retreating
|
||
whole). The fleet is created anyway — `FUN_00790790` creates it **before** the ship loop. Symptom: a fleet
|
||
with zero ships in the save. If that never appears, my reading of the creation order is wrong.
|
||
|
||
Prediction 3 is the interesting one: it is a **predicted bug in the original**, and it is cheap to look for in
|
||
any save taken after a battle.
|
||
|
||
---
|
||
|
||
## 1. The dispatcher — `FUN_007d5a00`, and its `this`
|
||
|
||
Called unconditionally by `CombatResolver_Run` at 0x007d5be2 (lane J block A), one caller image-wide.
|
||
**Real body 0x007d5a00..0x007d5abb; Ghidra's 225 bytes is right this time** (the range to the next start at
|
||
0x007d5af0 is 15 bytes of `int3` padding plus the `_CxxThrowException` tail).
|
||
|
||
It builds a **second, much smaller stack context** from the resolver's, and runs six methods on it. Fully
|
||
instruction-verified:
|
||
|
||
```
|
||
7d5a26 mov eax,[ecx+0xc] ; resolver ctx->+0x0c = Game::EncounterResults* res
|
||
7d5a29 mov edx,[ecx+0x8] ; +0x08 = Encounter* enc
|
||
7d5a2c mov ecx,[ecx] ; +0x00 = StrategyServer* S (the S frame)
|
||
7d5a32 mov [ebp-0x48],ecx ; rc->+0x00 = S
|
||
7d5a35 mov [ebp-0x44],edx ; rc->+0x04 = enc
|
||
7d5a38 mov [ebp-0x40],eax ; rc->+0x08 = res
|
||
7d5a3b mov [ebp-0x34],esi ; rc->+0x14 = 0 (map _Mysize)
|
||
7d5a3e call operator_new(0x18) ; the map's head node
|
||
7d5a4d..7d5a66 head->+0x00 = head->+0x04 = head->+0x08 = head; head->+0x14 = head->+0x15 = 1
|
||
7d5a69..7d5a6f rc->+0x1c = rc->+0x20 = rc->+0x24 = 0 (vector _Myfirst/_Mylast/_Myend)
|
||
7d5a78 call FUN_0079bb90 ; 1. per-player retreat destinations
|
||
7d5a80 call FUN_0079bcd0 ; 2. build the retreat groups
|
||
7d5a88 call FUN_007b0320 ; 3. whole-fleet vs partial
|
||
7d5a90 call FUN_00790790 ; 4. split the partial fleets
|
||
7d5a98 call FUN_007d5650 ; 5. execute the retreat (EVENT_FLEET_RETREATED_VIA_TELEPORT)
|
||
7d5aa7 call FUN_007a7cd0 ; 6. destructor
|
||
```
|
||
|
||
**All six are unconditional and in a straight line.** The only `jcc` in the function is the
|
||
`operator new` null test at 0x007d5a48, whose false arm is the `"vector<T> too long"` throw. There is no phase
|
||
gate.
|
||
|
||
### 1.1 `RetreatContext` — enumerated, not touched-scanned
|
||
|
||
```c
|
||
// A ~0x2c-byte STACK local in FUN_007d5a00. Never heap, never serialized.
|
||
struct RetreatContext {
|
||
StrategyServer* S; // +0x00 the S frame (verified: ->+0x44 is Systems, ->+0x84 the handle map)
|
||
Encounter* enc; // +0x04 (verified: ->+0x0c the system, ->+0x1c..+0x24 the position)
|
||
Game::EncounterResults* res; // +0x08 (verified: ->+0x68/+0x6c is the retreat request list)
|
||
std::map<int, ServerSystem*> dest; // +0x0c key = PlyrIdx. _Kcompare/_Alnod empty at +0x0c,
|
||
// _Myhead +0x10, _Mysize +0x14, _Alval +0x18
|
||
std::vector<RetreatGroup*> groups; // +0x1c _Myfirst/_Mylast/_Myend, _Alval +0x28
|
||
};
|
||
```
|
||
|
||
The map's element size is settled by its **node**, not by what the code touches: `_Buynode` is
|
||
`operator new(0x18)` writing `_Left/+0x00`, `_Parent/+0x04`, `_Right/+0x08`, `pair<int,void*>` at `+0x0c`,
|
||
`_Color/+0x14` and `_Isnil/+0x15` **as one 16-bit store**. 3 pointers + 8-byte pair + 2 bytes + 2 padding =
|
||
0x18 exactly. `FUN_0076bce0` is `std::map<int,T*>::operator[]`: `_Lbound`, then either `&node->_Myval.second`
|
||
(`node+0x10`) or a default-insert of **0**. *(Node and `operator[]` are a delegated read; the head-node writes
|
||
and the 0x18 allocation I read myself in the dispatcher.)*
|
||
|
||
### 1.2 `RetreatGroup` — 0x34 bytes, enumerated from its constructor
|
||
|
||
`FUN_0078f1c0` (41 B, instruction-verified by me) writes every field, which is what settles the layout —
|
||
including the two allocator words it does **not** write (rule 5):
|
||
|
||
```c
|
||
struct RetreatGroup { // operator new(0x34), ctor FUN_0078f1c0
|
||
ServerPlayer* owner; // +0x00 = ship->+0x10
|
||
ServerSystem* dest; // +0x04 the retreat destination
|
||
std::vector<StarShip*> ships; // +0x08/+0x0c/+0x10, _Alval +0x14
|
||
std::vector<StarFleet*> fleets; // +0x18/+0x1c/+0x20, _Alval +0x24
|
||
bool partial; // +0x28 set when a source fleet only partly ran
|
||
int mode; // +0x2c = request->+0x08 (1 selects the gate path)
|
||
int variant; // +0x30 = request->+0x10 (ctor default -1)
|
||
}; // 0x34
|
||
```
|
||
|
||
Groups are keyed on **all four** of `(owner, dest, mode, variant)` — `FUN_007871c0` (79 B, `ret 0x10`,
|
||
instruction-verified) is a linear scan comparing exactly those four words.
|
||
|
||
---
|
||
|
||
## 2. The phase map
|
||
|
||
| # | addr | size (Ghidra / real) | what it does | evidence |
|
||
|---|---|---|---|---|
|
||
| **1** | `FUN_0079bb90` | 184 / 184 (`ret` at 0x0079bc47) | **Per-player retreat destination.** One loop over `enc->members` (stride 0x44, magic `0x78787879 / sar 5`): `FUN_00787210(&enc->+0x1c, enc->+0x0c, member->player, &r1, &r2, &r3)`, then `dest[player->PlyrIdx] = first non-null of (r1, r2, r3)` | verified by me |
|
||
| **2** | `FUN_0079bcd0` | 422 / 422 (`ret` at 0x0079be75) | **Build the groups.** One loop over `res->+0x68/+0x6c`, **stride 0x14** (`0x66666667 / sar 3`) — a list of retreat requests. Resolve, filter, find the destination, find-or-create the group, append the ship | verified by me |
|
||
| **3** | `FUN_007b0320` | 808 / 808 (`ret` at 0x007b063f) | **Whole fleet or partial?** Per group, tally its ships by `ship->FltID`; a fleet whose tally equals `(fleet->+0xa8 − fleet->+0xa4)/4` goes into `group->fleets`, otherwise `group->partial = 1` | verified by me |
|
||
| **4** | `FUN_00790790` | 500 / 500 (`ret` at 0x00790978) | **Split.** Only for groups with `partial != 0`: mint an id, create a fleet, append it to `group->fleets`, then move every ship whose old fleet is *not* in that list; destroy any old fleet left empty; attach the new fleet to `enc->system` | verified by me |
|
||
| **5** | `FUN_007d5650` | 927 / 927 (`ret` at 0x007d59f4) | **Execute.** Per group, per fleet: grant system knowledge if unexplored; then either **gate-teleport** it (and post `EVENT_FLEET_RETREATED_VIA_TELEPORT`) or give it a **move order** and detach it from the system | verified by me |
|
||
| **6** | `FUN_007a7cd0` | 190 / 190 (`ret` at 0x007a7da5) | **Destructor.** `FUN_00793090(first, last)` deletes every group, then `erase(begin,end)` + `operator delete` on the vector buffer, then `_Tree::erase(head->_Left, head)` + `operator delete` on the map head. Writes no game state | verified by me |
|
||
|
||
Ghidra's size was correct for all six — but each was dumped to the next function start anyway, and the padding
|
||
was inspected. `FUN_007a7cd0` is the one where it would have mattered: its body ends only 34 bytes before the
|
||
next start.
|
||
|
||
### 2.1 Phase 1 in full — the retreat destination rule
|
||
|
||
`FUN_00787210` (318 B, `ret 0x18`, thiscall, **instruction-verified by me**) is the formula. It is three
|
||
independent nearest-system searches sharing one pass over `S->Systems` (`S->+0x44/+0x48`, 4-byte stride):
|
||
|
||
```
|
||
best1 = best2 = best3 = FLT_MAX ; the .rdata float at 0x009e23a8 is 0x7f7fffff
|
||
r1 = r2 = r3 = NULL
|
||
for sys in S->Systems:
|
||
if sys == enc->+0x0c: continue ; never the battle's own system
|
||
if FUN_007469e0(sys): continue ; unusable, see below
|
||
d = |sys->Pos - enc->pos|^2 ; SQUARED, float32
|
||
if d < best1 and FUN_007437e0(sys) == player: best1 = d; r1 = sys
|
||
if d < best2 and not FUN_00744070(sys, player): best2 = d; r2 = sys
|
||
if d < best3: best3 = d; r3 = sys
|
||
*out1 = r1; *out2 = r2; *out3 = r3
|
||
```
|
||
|
||
and the caller takes the **first non-null of r1, r2, r3**. So: *retreat to the nearest system you own; failing
|
||
that to the nearest system with no hostile presence; failing that to the nearest system, full stop.*
|
||
|
||
Two details a reimplementation gets wrong by default:
|
||
|
||
* **The three best-so-fars are genuinely independent.** A nearer system that fails a predicate does not spoil
|
||
that search — the `best` store lives *inside* the predicate arm at 0x007872c6 / 0x007872ec, not before it.
|
||
A single-loop "find nearest, then filter" version produces a different system.
|
||
* **The comparison is a strict `<`** (`fld best; fcomp d; test ah,0x41; jne skip` — the jump is taken on
|
||
*less-or-equal*, so only `best > d` proceeds). On an exact tie the **earlier** system in the vector wins,
|
||
which makes the answer depend on `Systems` order.
|
||
|
||
The distance arithmetic, read instruction by instruction at 0x0078726d–0x007872a3: the three component
|
||
differences are each `fstp`-ed to **float32** first; the products and their sum are accumulated in the x87
|
||
stack; the total is narrowed to float32 once, at 0x007872a3. 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** to the original here, not an approximation.
|
||
|
||
The three predicates, all instruction-verified by me:
|
||
|
||
| addr | what it is | body |
|
||
|---|---|---|
|
||
| `FUN_007469e0` (33 B) | **`ServerSystem::IsUnusableRetreatTarget`** | `return sys->Dstyd(+0xc5) != 0 \|\| (sys->vnh(+0xc6) != 0 && sys->vnex3(+0xc8) == 0)` — a destroyed system, or one hosting a Von Neumann hive that has not been cleared. All three are **saved bools**, which is what identifies the class as `Game::ServerSystem` |
|
||
| `FUN_007437e0` (7 B) | **`ServerSystem::GetOwner`** | `return sys->PID(+0x100)` |
|
||
| `FUN_00744070` (32 B) | **`ServerSystem::HasHostilePresence(player)`** | `return (FUN_0080dff0(player, sys) & sys->AFlags(+0xd4)) != 0` |
|
||
|
||
`FUN_0080dff0` (96 B, instruction-verified by me) is the hostility mask, and it carries a diplomacy rule that
|
||
is not in `findings/subsystems/` anywhere:
|
||
|
||
```
|
||
ownerBit = (sys && sys->PID && sys->TAcq(+0x80) == S->Frame) ? (1 << sys->PID->PlyrIdx) : 0
|
||
return ~( (1 << P->PlyrIdx) | (P->CF(+0x174) & ~ownerBit) | P->NA(+0x170) | P->AL(+0x16c) )
|
||
```
|
||
|
||
**A system whose owner captured it on the current turn loses that owner's ceasefire cover.** `TAcq` is the
|
||
saved "turn acquired" int and `S->+0xc` is `Frame` (lane Z's naming), reached as `P->+0x8->+0x8` because
|
||
`ServerPlayer+0x8 == S+4` (lane K §7.3). The complement is taken over the full 32 bits; that is harmless
|
||
only because it is immediately ANDed with a presence mask.
|
||
|
||
### 2.2 Phase 2 in full — the request list and the filters
|
||
|
||
`res->+0x68/+0x6c` is a **0x14-stride vector of retreat requests**, a member of `Game::EncounterResults`
|
||
lane J did not have. Fields used: `+0x04` the ship's handle, `+0x08` the mode word, `+0x0c` a requested
|
||
destination handle, `+0x10` the second key word.
|
||
|
||
Per request (all instruction-verified by me):
|
||
|
||
```
|
||
ship = HandleMap::Resolve(S->+0x84, req->+0x04); if (!ship) skip
|
||
if (ship->Dep(+0x50) != 0) skip ; already departed
|
||
if (ship->EncID(+0x7c) == 0x15) skip
|
||
if (FUN_004f47f0(ship->EncID)) skip ; DB-driven encounter-type mask
|
||
if (ship->PlrID(+0x10) == S->Players[S->+0x1c0]) skip ; the locally-controlled player
|
||
dest = FUN_0079bc50(ctx, req, ship); if (!dest) log level 2 and skip
|
||
if (!(req->+0x08 == 1 && ship->PlrID && ship->PlrID->Species(+0x5c) == 1))
|
||
if (FUN_00815090(ship)) skip ; grounded by damage
|
||
g = FUN_007871c0(ctx, ship, dest, req->+0x08, req->+0x10)
|
||
if (!g) { g = new RetreatGroup(); g->owner = ship->PlrID; g->dest = dest;
|
||
g->mode = req->+0x08; g->variant = req->+0x10; ctx->groups.push_back(g); }
|
||
g->ships.push_back(ship)
|
||
```
|
||
|
||
* **`FUN_0079bc50` (122 B, `ret 8`)** is the destination lookup: if `req->+0x0c` resolves through the handle
|
||
map, that is the answer (an explicitly ordered destination); if it does not, it logs
|
||
`"Retreat: %s cannot retreat to specified location. Location not found.\n"` and falls through to
|
||
`ctx->dest[ship->PlrID->PlyrIdx]` — the default from phase 1. It also has a guard returning 0 when the
|
||
ship carries no handle id.
|
||
* **`FUN_00815090` (46 B)** is the damage gate: `design = ship->DesID(+0x14)`; true (⇒ cannot retreat) iff
|
||
`(design->+0x74 || design->+0x78)` **and** `design->+0x12c < 2` **and** `FLT_EPSILON > ship->+0x30`. The
|
||
`.rdata` float at 0x009e1ef8 is `0x34000000` = **1.1920929e-07, `FLT_EPSILON`**, and `ship+0x30` is the
|
||
third float of the inline `ShipHealth` at `0x24..0x33` — the one R1 guesses is the drive. So: *a ship whose
|
||
drive is dead cannot run.*
|
||
* **The species-1 bypass.** The gate is skipped entirely when `mode == 1` and the owner's `Species(+0x5c)` is
|
||
**1**. That is the same species/mode pair phase 5 requires for the teleport arm — the gate species does not
|
||
fly out, so a dead drive does not stop it.
|
||
* **`FUN_004f47f0` (67 B)** is a **DB-sourced** predicate: it lazily ORs `1 << table[i]` over a count at
|
||
0x00ae04e0 and an int array at 0x00adfe88 (both `.bss`, filled from the data files), caches the mask at
|
||
0x00b0e984 behind a flag at 0x00b0e988, and returns `(mask >> t) & 1` for `t <= 0x17`, false above. The
|
||
bound 0x17 is the same `EncounterType` range lane K found `PickDominantEncounterType` returning over. **Do
|
||
not hard-code this mask.**
|
||
|
||
### 2.3 Phase 3 in full — whole versus partial
|
||
|
||
`FUN_007b0320` (instruction-verified by me). Per group:
|
||
|
||
```
|
||
tally = {} ; a local vector<{StarFleet*, int}>, 8-byte stride
|
||
for ship in group->ships:
|
||
f = ship->FltID(+0x64)
|
||
linear-search tally for f; if absent push {f, 0}; then ++entry.count
|
||
for (f, n) in tally:
|
||
if n == (f->+0xa8 - f->+0xa4)/4: group->fleets.push_back(f) ; the whole fleet ran
|
||
else: group->partial = 1
|
||
```
|
||
|
||
The `jb`/`jae` pair around 0x007b0504 / 0x007b0570 is **not** a phase gate: it is MSVC's inlined
|
||
`vector::push_back` aliasing check (`_Inside(&_Val)`), and both arms converge on
|
||
`*_Mylast = value; _Mylast += 4` at 0x007b05ce. Rule 4, same family as the inlined string destructor.
|
||
|
||
`fleet->+0xa4/+0xa8` is `StarFleet.NShips` (`struct-recovery.md` §3), so *whole* means literally "every ship
|
||
this fleet has is in this group".
|
||
|
||
### 2.4 Phase 4 in full — the split, and a predicted bug
|
||
|
||
`FUN_00790790` (instruction-verified by me). Gated on `group->partial != 0`; groups without it are untouched.
|
||
|
||
```
|
||
newFleet = FUN_0085b340(S+4, IDMap::AllocateLocalID(), group->owner, &enc->+0x1c, NULL)
|
||
group->fleets.push_back(newFleet) ; BEFORE any ship moves
|
||
for ship in group->ships:
|
||
old = ship->FltID
|
||
if old is already in group->fleets: continue ; its whole fleet is retreating; leave it
|
||
FUN_007063a0(old, ship) ; StarFleet::RemoveShip
|
||
if ((old->+0xa8 - old->+0xa4) & ~3) == 0:
|
||
FUN_0088b980(S+4, old, 0, 0) ; StrategyServer::DestroyFleet
|
||
FUN_007062e0(newFleet, ship) ; StarFleet::AddShip
|
||
if (enc->+0x0c) FUN_0074f240(enc->+0x0c, newFleet) ; StarSystem::FleetArrives
|
||
```
|
||
|
||
The `group->fleets.push_back(newFleet)` at 0x0079083c–0x007908af happens **before** the ship loop, and the
|
||
"is my old fleet already in the list" test at 0x007908e0 is what keeps a wholly-retreating fleet's ships where
|
||
they are. **A group flagged partial therefore always creates a fleet, even in the case where every ship turns
|
||
out to belong to a wholly-retreating fleet** — which is reachable when one fleet ran whole and a second
|
||
contributed a ship that was itself filtered out before phase 3's tally. That is prediction 3 of §0.1.
|
||
|
||
`FUN_007062e0` / `FUN_007063a0` (186 / 386 B, both instruction-verified by me) are the membership pair:
|
||
|
||
* **AddShip**: `fleet->NShips.push_back(ship)`; **`ship->FltID = fleet`**; `fleet->+0xb8 |= design->+0xb8`
|
||
and `+0xbc |= design->+0xbc` (capability masks); `fleet->+0xc0 (int16) += FUN_0056f430(design->+0x12c)`;
|
||
if flag `0x20000` on `+0xb8` changed, register in `(fleet->+0x10)->+0x114`; `FUN_00705c70(fleet)`.
|
||
* **RemoveShip**: if the fleet's current waypoint is a gate transit (`IsGateTransitWaypoint`, already named in
|
||
`strategic-turn-internals.md` §4.1), **`owner->GTraf(+0x14c) -= fleet->+0xc0`** — then `ship->FltID = 0`,
|
||
erase, recompute, and **re-add** `+0xc0` afterwards. `GTraf` is a saved player field.
|
||
|
||
**`FUN_0088b980` = `StrategyServer::DestroyFleet(fleet, killShips, arg)`**, called here with `(fleet, 0, 0)`
|
||
so the ship-massacre block is dead on this path. *(Delegated read, in full, to the next function start.)* It
|
||
is a real `delete`, and it unhooks the fleet from **six** places: the owner's fleet map at `owner+0x2b4`, the
|
||
server master vector at `(S+4)+0x60`, the location's fleet vector, a second server list at `(S+4)+0x114`,
|
||
`FUN_008b92a0(S+4+0x80, fleet)` (the id map), and every player's queued events via
|
||
`FUN_00812110(player+0x29c, fleetId, -1)` — the same `EventStorage` offset the campaign already models.
|
||
Before all that it sweeps the master fleet list for any fleet whose flight plan targets the dying one and
|
||
posts **`EVENT_FLEET_INTERCEPT_ABORTED`** to that fleet's owner, inlining `player+0x29c` rather than calling
|
||
`ServerPlayer::GetEventStorage`.
|
||
|
||
### 2.5 Phase 5 in full — executing it
|
||
|
||
`FUN_007d5650` (instruction-verified by me). Two nested loops: over `ctx->groups`, then over
|
||
`group->fleets` (which by now holds the whole fleets from phase 3 *and* any new fleet from phase 4).
|
||
|
||
```
|
||
if (enc->+0x0c && !FUN_00743880(enc->+0x0c, fleet->PID(+0x58)))
|
||
FUN_007d5080(S, S->+0x44[enc->+0x0c ->Idx(+0x5c)], fleet->PID) ; grant knowledge
|
||
|
||
if (FUN_007859f0(group) && group->dest != 0) { ; --- GATE ARM ---
|
||
if ((n = FUN_006fe300(fleet))) FUN_0074d1c0(n, fleet) ; leave the old system
|
||
FUN_0074f240(group->dest, fleet) ; arrive at the new one
|
||
... build two strings from data-driven format strings ...
|
||
EventStorage::PostEvent(GetEventStorage(fleet->PID),
|
||
{ msg, summary, fleet, 0, S->Frame, "EVENT_FLEET_RETREATED_VIA_TELEPORT", 0 })
|
||
} else { ; --- MOVE ARM ---
|
||
if (!FUN_008653c0(S+4, fleet, &group->dest, 1))
|
||
Log(2, "Retreat: Unable to set destination for retreat for %s.\n", fleet->PID->+0x40)
|
||
else {
|
||
FUN_006fe1a0(fleet, 2, 1) ; FtFlg |= 2
|
||
if ((n = FUN_006fe300(fleet))) FUN_0074d1c0(n, fleet) ; detach; it is in transit
|
||
}
|
||
}
|
||
```
|
||
|
||
* **`FUN_007859f0` (48 B, `ret 4`, instruction-verified)** is the gate test, and it is three conditions:
|
||
`group->mode(+0x2c) == 1` **and** `group->owner->Species(+0x5c) == 1` **and** `group->dest(+0x04) != 0`.
|
||
Species 1 is the gate-building species; this is the Hiver gate mechanic, and `EVENT_FLEET_RETREATED_VIA_TELEPORT`
|
||
fires nowhere else.
|
||
* **`FUN_00743880` (34 B)** is `ServerSystem::IsExploredBy(player)` = `sys->EFlags(+0xd0) & (1 << PlyrIdx)`,
|
||
and **`FUN_007438b0` (82 B)** is its setter — both instruction-verified by me. `FUN_007d5080` (195 B, also
|
||
mine) calls the setter with `true`, registers with the node graph at `S->+0x154`, and refreshes the
|
||
per-(system, player) view through the map at `S+0x224` (`FUN_007d1f00` → `FUN_0075cf50` → `FUN_00753430`).
|
||
The last leg of that chain is **not** read: I have the call shape, not the body. Lane K's phase 17 says
|
||
`S+0x228` is the `PlayerView` tree's head, which puts the map object at `S+0x224` — consistent, but the
|
||
exact field written is **inferred, not verified**.
|
||
* **`FUN_006fe300` / `FUN_006fe2f0` / `FUN_006fe1a0`** (19 / 16 / 37 B, all mine) are
|
||
`StarFleet::GetLocation` (returns `+0xa0` only when `loc->+0x14 == 0`), `SetLocation` and
|
||
`SetFlag(mask, on)` on `FtFlg(+0x10c)`.
|
||
* **`FUN_0074f240` / `FUN_0074d1c0`** (162 / 273 B, mine) are `StarSystem::FleetArrives` / `FleetDeparts`.
|
||
Arrives copies the system's `Pos` into the fleet's `+0x18/+0x1c/+0x20`, sets `SetLocation`, ORs the player's
|
||
bit into `FFlags(+0xd8)`, pushes the fleet into `sys->+0x16c` (the saved `NumFlts`/`Flt` list) and then
|
||
recomputes `AFlags(+0xd4)` and `VFlags(+0xcc)` as
|
||
`if (FFlags & bit || GFlags(+0xdc) & bit || PID == player) { AFlags |= bit; VFlags |= bit; } else AFlags &= ~bit`.
|
||
Departs is the mirror, and clears the `FFlags` bit only when the departing fleet was the player's **last**
|
||
at that system (`cmp [ebp-0x4],1; jg` at 0x0074d250 — it counts same-owner fleets during the erase scan).
|
||
* **`FUN_008653c0` = `StrategyServer::OrderFleetMove(fleet, dests[], count) -> bool`** *(delegated read, in
|
||
full)*. It snaps the fleet's position onto its current system when they differ — by **exact IEEE equality**,
|
||
`fucompp` with no epsilon — solves a path, and commits a flight plan through `FUN_00707080`, which debits
|
||
and re-credits `owner->GTraf(+0x14c)` around the change and writes `fleet+0xd8..0xf8` and `+0x100..+0x108`.
|
||
It returns false when the fleet is null or when the solver sets any of bits `0x008 / 0x010 / 0x400` in its
|
||
flag word; **the meaning of those three bits is not known** — `FUN_007066c0` was not opened.
|
||
|
||
---
|
||
|
||
## 3. RNG: confirmed draw-free, computed independently
|
||
|
||
Lane J's closure scan said this subtree draws nothing. Recomputed here from a different root and with a
|
||
different tool:
|
||
|
||
* **Closure**: BFS from 0x007d5a00 over `E8` call edges **plus `E9` tail-call thunks** (the first pass missed
|
||
`FUN_0080f710`, an 8-byte `jmp` thunk, and with it the entire id-allocator chain — worth recording, because
|
||
a call-graph that ignores tail calls silently loses whole subsystems). **327 functions.**
|
||
* **Direct calls to `RNG_NextInt` 0x004271c0, `RNG_NextFloat` 0x0047d830, `RNG_Chance` 0x008e6dd0,
|
||
`RNG_Twist` 0x00426e00: zero.**
|
||
* **Inlined Mersenne draws**: an instruction-boundary scan of all 327 bodies for the tempering immediates
|
||
`0xff3a58ad`, `0xffffdf8c`, `0x9d2c5680`, `0xefc60000` — **zero hits**. This is the check lane K's sweep
|
||
did not do and lane J's rule 16 exists for.
|
||
* Both delegated sweeps ran the same scan over `FUN_0088b980` and `FUN_008653c0` and their subtrees and
|
||
reported the same.
|
||
|
||
**The fleet-name generator is clean too.** `FUN_0085b340` names a new fleet through
|
||
`FUN_00856310(owner)` → `FUN_00855f80(owner+0x298)`, which is the one plausible place a draw could hide. It
|
||
**is** inside the 327 and carries no draw. The only non-constant input to a created fleet is the monotonic
|
||
id counter.
|
||
|
||
**The honest bound.** 91 of the 327 functions contain 152 indirect call sites, and none of them is resolved
|
||
here. This is a bound, not a proof, in exactly lane J §3.3's sense. Two of those indirect edges are on the
|
||
main line and matter: `FUN_007438b0`'s `sys->vft[0x1c](player, wasSet, on)` notify and
|
||
`FUN_0074d1c0`'s `sys->vft[0x14](fleet)` — **flagged for lane V2's vtable inversion**, along with
|
||
`FUN_0085b340`'s `(S+4)->vft[0x10](fleet)` and `FUN_008653c0`'s `(S+4)->vft[0x08]()`.
|
||
|
||
---
|
||
|
||
## 4. Events
|
||
|
||
Two `EVENT_*` keys in the entire 327-function closure, found by scanning every reachable body for `.rdata`
|
||
string immediates at real instruction boundaries:
|
||
|
||
| key | posted by | depth | condition |
|
||
|---|---|---|---|
|
||
| `EVENT_FLEET_RETREATED_VIA_TELEPORT` | `FUN_007d5650` | 1 | per retreating fleet, only on the gate arm: `group->mode == 1` **and** owner species **1** **and** a destination resolved |
|
||
| `EVENT_FLEET_INTERCEPT_ABORTED` | `FUN_0088b980` | 3 | per *other* fleet whose flight plan targets a fleet being destroyed, when a split empties that fleet. Posted to the **interceptor's** owner, not the retreater's |
|
||
|
||
Against lane J's table of 23: both were already listed, at the right depths. What is new is the **condition**
|
||
for each, and the fact that these two are the **only** ones the pipeline can post — the other 21 come from the
|
||
resolver's own body or from its other callees, never from here.
|
||
|
||
Neither goes through the string-table lookup (`FUN_008c9690`) the resolver uses for its composed keys; both are
|
||
literal `push imm32` of a `.rdata` string, so there is no data-file dependency on the **key**. The two
|
||
human-readable strings that ride with the teleport event *are* data-sourced: the format strings are
|
||
`*(char**)0x00aedf0c` and `*(char**)0x00aedf14`, `.bss` pointers filled at load, rendered through
|
||
`FUN_008c8eb0` (`_snprintf` into a 0x1ff buffer) with the destination system's `Name(+0xa8)` and the fleet's
|
||
`FtName(+0x5c)`.
|
||
|
||
The post itself, read from the bytes at 0x007d5859–0x007d58c5, is the campaign's known shape:
|
||
`EventStorage::PostEvent` 0x008862b0 with `this = ServerPlayer::GetEventStorage(fleet->PID)` 0x0080db00, the
|
||
two `std::string`s passed **by value** through the `sub esp,0x1c` + `FUN_00425430` copy-construct idiom, and
|
||
`S->Frame` as the turn. Dedup per turn bucket applies as it does everywhere else.
|
||
|
||
---
|
||
|
||
## 5. Object facts recovered on the way
|
||
|
||
**`sizeof(Game::StarFleet) = 0x120` (288).** From `operator new(0x120)` in `FUN_0085b340` *(delegated read)*.
|
||
That agrees exactly with `struct-recovery.md` §3, whose last named member is `FtMS` at `+0x11c` — an
|
||
independent enumeration closing a size the table left implicit.
|
||
|
||
**The object-id allocator, and `NMnx`.** `IDMap` lives at `(S+4) + 0x80`. Its node table is a
|
||
`vector<NodeEntry>` at `+0x08/+0x0c`, **stride 0x14** — pinned twice, by `FUN_008b8a70`'s `0x66666667 / sar 3`
|
||
divide-by-20 and by the `lea eax,[edi+edi*4]` + scale-4 index arithmetic. Each `NodeEntry` is a
|
||
`std::map<int, void*>` (0x10) followed by the counter at `+0x10`. `IDMap::AllocateID`:
|
||
|
||
```
|
||
008b8b16 inc DWORD PTR [ecx+eax*4+0x10] ; pre-increment the per-node counter
|
||
008b8b20 jne 0x8b8b34 ; on wrap to 0, log and increment again (0x8b8b30)
|
||
008b8b38 shl esi,0x4
|
||
008b8b3e or esi,edx ; id = (counter << 4) | (node & 0xF)
|
||
```
|
||
|
||
so **id 0 is `INVALID_NETWORK_ID`**, the low nibble is the network node index (max 16 nodes), and the upper
|
||
28 bits are the sequence. `IDMap::Insert` 0x008b9350 writes the id into the object at **`obj+0x04`** — the
|
||
same `+0x04` handle slot `StarShip` and every `NetworkObject` uses. *(All of §5's allocator content is a
|
||
delegated instruction-level read; I re-read only the call site and the thunk.)*
|
||
|
||
`struct-recovery.md` §7's open item *"`OID` allocation (R2's ×16)"* is therefore **closed**: R2's ×16 is the
|
||
`shl 4`. What is **not** closed is where `NodeEntry.nextId` is initialised and whether the save's `NMnx` tag
|
||
is exactly this counter. The tag order `KeyPath, NMSz, NMLc, NMnx, ModCount, Frame` is highly suggestive
|
||
(size, local node, next id) but `StrategyServer::Write` 0x0079fa70 was **not** read for it. Labelled
|
||
hypothesis, and §0.1's prediction is the cheap way to test it.
|
||
|
||
**`Game::EncounterResults +0x68/+0x6c`** — a 0x14-stride vector of retreat requests, with `+0x04` a ship
|
||
handle, `+0x08` a mode word, `+0x0c` a destination handle and `+0x10` a second key word. New; lane J §8 tier 3
|
||
lists 16 of the 0x178 bytes as named, and this adds one member.
|
||
|
||
**`ServerSystem` mask semantics**, from the two writers that maintain them:
|
||
`EFlags(+0xd0)` = explored-by; `FFlags(+0xd8)` = has-a-fleet-here; `GFlags(+0xdc)` = a second presence source
|
||
(not read here); `AFlags(+0xd4)` = the derived union `FFlags | GFlags | isOwner`, recomputed on every arrival
|
||
and departure; `VFlags(+0xcc)` = the same union but **sticky** (only ever OR'd). All five are saved ints.
|
||
|
||
---
|
||
|
||
## 6. What could not be settled statically
|
||
|
||
Named with the workload that would settle each, per the brief.
|
||
|
||
| open item | why static reading cannot close it | what would |
|
||
|---|---|---|
|
||
| **Whether `NMnx` is the `IDMap` counter** | the tag is emitted by `StrategyServer::Write` 0x0079fa70, which was not read | read the 3 relevant lines of 0x0079fa70 (cheap), **or** run §0.1's prediction |
|
||
| **The three failure bits `0x008 / 0x010 / 0x400`** of `OrderFleetMove` | they are set inside `FUN_007066c0`, the 8-argument path solver, unread | a hook on `FUN_008653c0`'s return with the flag word logged; or read `FUN_007066c0` |
|
||
| **Whether `S+0x224` is the `PlayerView` map** and which field `FUN_00753430` writes | the last leg of the chain was not read | lane K's phase 17 already touches `S+0x228`; one `find-variable-accesses` on `S+0x224` would settle it |
|
||
| **The 152 indirect call sites** in 91 of the 327 functions | closure is direct-call only | lane V2's vtable inversion. Four are on the main line and named in §3 |
|
||
| **Whether any of this ever runs** | **the resolver has never executed under an instrument.** Lane Z measured `res->+0x4 != 0` on every encounter of every run, which makes `ApplyEncounterResult` a whole-function no-op | `combat-resolver.md` §10.3's workload. This lane adds a second requirement to it: the battle must produce a **retreat**, i.e. `res->+0x68 != +0x6c`, or the whole pipeline is six no-ops |
|
||
| **`FUN_0056f430`**, the per-design gate-traffic cost | not read; it is the term `AddShip` accumulates into `fleet+0xc0` | 1 function, ~small; or a hook on `GTraf` |
|
||
| **`ServerPlayer+0x298`**, the fleet-name source | delegated read stopped at `FUN_00855f80` | read 1 function; it is confirmed draw-free by the immediate scan, so this is cosmetic |
|
||
|
||
**The strongest thing this lane can say and the weakest are the same sentence:** the rules above are read from
|
||
the instruction stream and are internally consistent with five independently-verified object layouts, and
|
||
**not one of them has ever been observed executing.**
|
||
|
||
---
|
||
|
||
## 7. Corrections
|
||
|
||
### 7.1 To `combat-resolver.md` §6 and §8 — `FUN_007d5a00` is retreat, not "the combat pipeline"
|
||
|
||
Lane J: *"a six-stage sub-dispatcher … **The real per-phase combat pipeline is under here**"* and, in tier 2,
|
||
*"this is where the per-phase combat pipeline lives. The resolver is a reporting layer; the sub-dispatcher is
|
||
the machine."*
|
||
|
||
The identification of it as the most tractable target was right and this lane is the proof of it. The
|
||
**characterisation** is not: the six stages are one subsystem, not six phases of combat. They are
|
||
`destinations → group → classify → split → execute → destroy`, and the whole thing is `StarFleet` retreat.
|
||
Combat damage, kills, boarding and prize resolution are **not** here — lane J's own tier-2 list has them under
|
||
`FUN_0079c740` (prizes), `FUN_0079b980` (stats) and `FUN_007c9e00` (interception geometry).
|
||
|
||
Proposed names, filed in `ghidra/addresses.d/lane-b5.json`: `CombatResolve_Retreat` for 0x007d5a00 and
|
||
`CombatRetreat_*` for the six.
|
||
|
||
**One agreement worth recording.** `StarFleet_SetFlag` 0x006fe1a0 was already in `addresses.json` from
|
||
`handoff/b4-colony-movement.md`, with the same address, the same name and the same prototype, derived from a
|
||
completely different path (colony/movement). My draft entry was **dropped**, not merged: the fragment
|
||
generator's duplicate-name check caught it, which is the check working as intended. Two independent readings
|
||
agreeing is worth more than a second copy of the row. 33 entries filed; the merge generates 861 total, written
|
||
to a scratch path.
|
||
|
||
### 7.2 To `combat-resolver.md` §6's callee table
|
||
|
||
The row for 0x007d5a00 says it *"builds a 0x18-byte temp from `ctx->+0x00/+0x08/+0x0c`"*. The temp is
|
||
**~0x2c bytes**: the three pointers, then a `std::map` at `+0x0c` (whose *node* is 0x18 — that is where the
|
||
number came from) and a `std::vector` at `+0x1c`. Nothing depends on the figure; corrected so it stops
|
||
propagating.
|
||
|
||
### 7.3 To my own first reading of `FUN_00790790`'s fleet creation
|
||
|
||
I first read 0x007907ce–0x007907d8 as `FUN_0080f710(this = S+4, owner, &enc->pos, 0)` — a four-argument
|
||
factory — because three `push`es sit immediately before the call. They do not belong to it. `FUN_0080f710` is
|
||
`ecx += 0x80; jmp FUN_008b8b70`, and `FUN_008b8b70` is `push [ecx+0x18]; call FUN_008b8ae0` — it supplies its
|
||
own single argument and consumes no stack. The three pushes plus the `push eax` of its **return value** are
|
||
the four arguments of `FUN_0085b340` (`ret 0x10`), whose call is 9 bytes later:
|
||
`CreateFleet(S+4, newOid, owner, &enc->pos, NULL)`. The id mint is evaluated *inside* the argument list.
|
||
|
||
Recorded rather than quietly fixed, because the failure mode is a new one for the rules file: **a `ret`-N thunk
|
||
in the middle of an argument list makes the arguments look like they belong to the wrong call.** The delegated
|
||
sweep caught it by reading `ret 4` off `FUN_008b8ae0`'s epilogue and counting; I had not checked the epilogue.
|
||
Checking `ret N` against the push count is cheap and would have caught it immediately.
|
||
|
||
---
|
||
|
||
## 8. What this lane did **not** read
|
||
|
||
* **`FUN_007066c0`**, the 8-argument path solver behind `OrderFleetMove`, and with it the meaning of its
|
||
failure bits. The single biggest hole.
|
||
* **`FUN_00707080`**'s call to `FUN_00703cf0`, which produces the 0x24-byte leg record copied into
|
||
`fleet+0xdc..+0xf8`. So the *fields* a retreat's flight plan writes are known; their *values* are not.
|
||
* **`FUN_00753430` / `FUN_0075cf50` / `FUN_007d1f00`** — the last three legs of the `PlayerView` refresh.
|
||
* **`FUN_00706140`**, the `StarFleet` constructor, beyond its first 0x7a bytes (delegated, marked SKIMMED by
|
||
the sweep). The initial value of `FtFlg` is therefore unknown; only that `CreateFleet` then ORs in `0x400`.
|
||
* **`FUN_00849280`** (cancel ship action) past its first ~60 bytes, and `FUN_00702ed0` / `FUN_007065d0` /
|
||
`FUN_00705c70` / `FUN_00700090` — the flight-plan recompute helpers.
|
||
* **`FUN_0056f430`**, `FUN_00855f80`, `FUN_008b9240`, `FUN_0072b400`.
|
||
* **The 152 indirect call sites.**
|
||
* Whether `enc->+0x0c` and `S->+0x44[idx]` are the same object or two halves of a `StarSystem` /
|
||
`ServerSystem` pair. `FUN_007d5650` uses both, one as a `this` for `ServerSystem` methods and one as an
|
||
index lookup, which is exactly the ambiguity `struct-recovery.md` §7 flags as medium-confidence. **Not
|
||
resolved.**
|
||
|
||
---
|
||
|
||
## 9. The reimplementation
|
||
|
||
`sots-engine` branch **`wip/battle`**, commit `ae170ec`, module `src/game/combat/` (a new directory — lane N
|
||
owns `src/game/sim/`). Clean-room: no addresses, no raw identifiers, no game data.
|
||
|
||
It models the **decision** and stops at the mutation. `BuildRetreatPlan` is a pure function returning a plan —
|
||
groups, splits, fleets to create, fleets left empty, players who learn the system — and applying it is left to
|
||
whatever owns the object store, because creating a fleet mints an id and appends to the saved master list. The
|
||
split keeps the two apart honestly and makes the whole thing host-testable.
|
||
|
||
Modelled: `IsUsableRetreatTarget`, `HostileMask` (including the captured-this-turn ceasefire lapse),
|
||
`ChooseRetreatDestination` (three independent nearest-searches, float32 squared distance, strict `<`),
|
||
`IsGroundedByDamage` (epsilon, not zero), `ShipMayRetreat`, `GroupRetreats` (all four key words),
|
||
`ClassifyFleets`, `UsesGateRetreat`, `BuildRetreatPlan`.
|
||
|
||
**53 hand-computed checks**, including the case a single-loop destination search gets wrong, the exact-tie
|
||
case, the epsilon-boundary case, the whole-versus-partial split and the cross-group fleet emptying.
|
||
|
||
Gates, run as separate commands: `tools/clean_room_check.sh` → **OK**; host `ctest` → **42/42**. No `src/shim/`
|
||
change, so no CT111 cross-build was required. (Engine `main` has moved to `8c23f6b` since the brief's
|
||
`b48d860`; 41 tests before this lane, 42 after.)
|
||
|
||
### 9.1 What is worth writing next, ranked
|
||
|
||
1. **`FUN_0079c740` (2270 B) — prize and reward resolution**, the resolver's last act. Draw-free by lane J's
|
||
scan, and it is the *other* half of a battle's strategic consequence.
|
||
2. **`FUN_00790990` (1040 B) — the 24 elimination conditions.** Player elimination is the largest possible
|
||
change to saved state and it has one well-defined output.
|
||
3. **`FUN_007066c0` — the path solver.** Not for its own sake: it is the last unknown on the retreat path and
|
||
it is shared with normal fleet movement, which lane B4 has already compared behaviourally.
|
||
4. **`FUN_0079c270` + `FUN_0078ba10` — plague.** Lane J's point stands: this may be the only place plague
|
||
starts, and `ServerSystem::ProcessPlague` is separately known to be draw-free.
|