sots-re/findings/control-flow/combat-retreat-pipeline.md
alex ab1c2296a1 B5: the combat resolver's sub-dispatcher is RETREAT, and it is the largest
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.
2026-09-08 12:00:14 -04:00

42 KiB
Raw Blame History

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

// 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):

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::strings 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 pushes 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.