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.
42 KiB
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:
NMnxmoves because a battle happened.struct-recovery.md§7 lists "OIDallocation (R2's ×16)" as open. R2's "×16" is theshl esi,0x4at 0x008b8b38: an object id is(counter << 4) | (nodeIndex & 0xF). The counter is per network node, atIDMap->+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, ofFUN_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_007d5650testsenc->system->EFlags & (1 << PlyrIdx)and, when clear, runsFUN_007d5080→ServerSystem::SetExplored(player, true)plus aPlayerViewrefresh through the map atS+0x224. Both are saved. - Retreat rewrites the fleet graph. Not a stat, not an event — the actual object graph the save's
NumFlts/NShips/FltIDchain 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
Summarywill differ from the pre-turn one inNMnxby 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. AdditionallyNumFltsrises by that same count and falls by the number of source fleets emptied.
How it could be wrong, and the symptom of each way:
NMnxmight not be theIDMapcounter at all (it is identified from the tag orderNMSz, NMLc, NMnxplus the counter's location, not from readingStrategyServer::Write). Symptom:NMnxdoes not move at all, or moves on turns with no fleet creation.- 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.
- 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_00790790creates 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
beststore 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 onlybest > dproceeds). On an exact tie the earlier system in the vector wins, which makes the answer depend onSystemsorder.
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: ifreq->+0x0cresolves 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 toctx->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)anddesign->+0x12c < 2andFLT_EPSILON > ship->+0x30. The.rdatafloat at 0x009e1ef8 is0x34000000= 1.1920929e-07,FLT_EPSILON, andship+0x30is the third float of the inlineShipHealthat0x24..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 == 1and the owner'sSpecies(+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 ORs1 << 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) & 1fort <= 0x17, false above. The bound 0x17 is the sameEncounterTyperange lane K foundPickDominantEncounterTypereturning 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->+0xb8and+0xbc |= design->+0xbc(capability masks);fleet->+0xc0 (int16) += FUN_0056f430(design->+0x12c); if flag0x20000on+0xb8changed, register in(fleet->+0x10)->+0x114;FUN_00705c70(fleet). - RemoveShip: if the fleet's current waypoint is a gate transit (
IsGateTransitWaypoint, already named instrategic-turn-internals.md§4.1),owner->GTraf(+0x14c) -= fleet->+0xc0— thenship->FltID = 0, erase, recompute, and re-add+0xc0afterwards.GTrafis 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) == 1andgroup->owner->Species(+0x5c) == 1andgroup->dest(+0x04) != 0. Species 1 is the gate-building species; this is the Hiver gate mechanic, andEVENT_FLEET_RETREATED_VIA_TELEPORTfires nowhere else.FUN_00743880(34 B) isServerSystem::IsExploredBy(player)=sys->EFlags(+0xd0) & (1 << PlyrIdx), andFUN_007438b0(82 B) is its setter — both instruction-verified by me.FUN_007d5080(195 B, also mine) calls the setter withtrue, registers with the node graph atS->+0x154, and refreshes the per-(system, player) view through the map atS+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 saysS+0x228is thePlayerViewtree's head, which puts the map object atS+0x224— consistent, but the exact field written is inferred, not verified.FUN_006fe300/FUN_006fe2f0/FUN_006fe1a0(19 / 16 / 37 B, all mine) areStarFleet::GetLocation(returns+0xa0only whenloc->+0x14 == 0),SetLocationandSetFlag(mask, on)onFtFlg(+0x10c).FUN_0074f240/FUN_0074d1c0(162 / 273 B, mine) areStarSystem::FleetArrives/FleetDeparts. Arrives copies the system'sPosinto the fleet's+0x18/+0x1c/+0x20, setsSetLocation, ORs the player's bit intoFFlags(+0xd8), pushes the fleet intosys->+0x16c(the savedNumFlts/Fltlist) and then recomputesAFlags(+0xd4)andVFlags(+0xcc)asif (FFlags & bit || GFlags(+0xdc) & bit || PID == player) { AFlags |= bit; VFlags |= bit; } else AFlags &= ~bit. Departs is the mirror, and clears theFFlagsbit only when the departing fleet was the player's last at that system (cmp [ebp-0x4],1; jgat 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,fucomppwith no epsilon — solves a path, and commits a flight plan throughFUN_00707080, which debits and re-creditsowner->GTraf(+0x14c)around the change and writesfleet+0xd8..0xf8and+0x100..+0x108. It returns false when the fleet is null or when the solver sets any of bits0x008 / 0x010 / 0x400in its flag word; the meaning of those three bits is not known —FUN_007066c0was 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
E8call edges plusE9tail-call thunks (the first pass missedFUN_0080f710, an 8-bytejmpthunk, 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_NextInt0x004271c0,RNG_NextFloat0x0047d830,RNG_Chance0x008e6dd0,RNG_Twist0x00426e00: 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_0088b980andFUN_008653c0and 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 behindOrderFleetMove, and with it the meaning of its failure bits. The single biggest hole.FUN_00707080's call toFUN_00703cf0, which produces the 0x24-byte leg record copied intofleet+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 thePlayerViewrefresh.FUN_00706140, theStarFleetconstructor, beyond its first 0x7a bytes (delegated, marked SKIMMED by the sweep). The initial value ofFtFlgis therefore unknown; only thatCreateFleetthen ORs in0x400.FUN_00849280(cancel ship action) past its first ~60 bytes, andFUN_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->+0x0candS->+0x44[idx]are the same object or two halves of aStarSystem/ServerSystempair.FUN_007d5650uses both, one as athisforServerSystemmethods and one as an index lookup, which is exactly the ambiguitystruct-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
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.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.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.FUN_0079c270+FUN_0078ba10— plague. Lane J's point stands: this may be the only place plague starts, andServerSystem::ProcessPlagueis separately known to be draw-free.