diff --git a/findings/control-flow/combat-retreat-pipeline.md b/findings/control-flow/combat-retreat-pipeline.md new file mode 100644 index 0000000..7384b51 --- /dev/null +++ b/findings/control-flow/combat-retreat-pipeline.md @@ -0,0 +1,606 @@ +# `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 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 dest; // +0x0c key = PlyrIdx. _Kcompare/_Alnod empty at +0x0c, + // _Myhead +0x10, _Mysize +0x14, _Alval +0x18 + std::vector 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` 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::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 ships; // +0x08/+0x0c/+0x10, _Alval +0x14 + std::vector 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` 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` (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. diff --git a/ghidra/addresses.d/lane-b5.json b/ghidra/addresses.d/lane-b5.json new file mode 100644 index 0000000..ddbab22 --- /dev/null +++ b/ghidra/addresses.d/lane-b5.json @@ -0,0 +1,268 @@ +{ + "entries": [ + { + "name": "CombatResolve_Retreat", + "addr": "0x007d5a00", + "convention": "thiscall", + "prototype": "void (CombatResolveContext* this) // THE POST-BATTLE RETREAT PIPELINE. Exactly one caller: CombatResolver_Run 0x007d5af0, unconditionally, at 0x007d5be2. Real body 0x007d5a00..0x007d5abb; the only jcc in it is the operator-new null test whose false arm is a _CxxThrowException. It builds a ~0x2c-byte RetreatContext stack local from the resolver's ctx (rc->+0x00 = ctx->+0x00 = S; rc->+0x04 = ctx->+0x08 = enc; rc->+0x08 = ctx->+0x0c = res; a std::map at rc->+0x0c with an operator_new(0x18) head node at rc->+0x10 and _Mysize rc->+0x14; a std::vector at rc->+0x1c/+0x20/+0x24) and runs SIX unconditional this-calls in a straight line: FUN_0079bb90 (per-player destinations), FUN_0079bcd0 (build groups), FUN_007b0320 (whole vs partial), FUN_00790790 (split partial fleets), FUN_007d5650 (execute; EVENT_FLEET_RETREATED_VIA_TELEPORT), FUN_007a7cd0 (destructor). CORRECTS combat-resolver.md's characterisation of this as 'the per-phase combat pipeline': it is ONE subsystem, retreat, not six combat phases. DRAW-FREE: a 327-function closure (E8 calls plus E9 tail-call thunks) contains zero calls to the four RNG primitives and zero inlined MT tempering immediates", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "CombatRetreat_ChooseDestinations", + "addr": "0x0079bb90", + "convention": "thiscall", + "prototype": "void (RetreatContext* this) // RETREAT PHASE 1. One loop over enc->members (stride 0x44, magic 0x78787879 / sar 5). Per member: FUN_00787210(&enc->+0x1c, enc->+0x0c, member->+0x00 /*ServerPlayer*/, &r1, &r2, &r3), then this->dest[player->PlyrIdx(+0x28)] = the FIRST NON-NULL of (r1, r2, r3) via std::map::operator[] 0x0076bce0. So the per-player retreat destination is: nearest system you own, else nearest system with no hostile presence, else nearest system at all", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "CombatRetreat_FindDestination", + "addr": "0x00787210", + "convention": "thiscall", + "prototype": "void (RetreatContext* this, Mars::Vector3* battlePos, ServerSystem* excludeSys, ServerPlayer* p, ServerSystem** out1, ServerSystem** out2, ServerSystem** out3) // ret 0x18. THE RETREAT DESTINATION FORMULA. THREE INDEPENDENT nearest-system searches sharing one pass over S->Systems (S->+0x44/+0x48, 4-byte stride), each with its own best-so-far seeded to the .rdata FLT_MAX at 0x009e23a8 (0x7f7fffff): out1 = nearest sys with FUN_007437e0(sys) == p (owner); out2 = nearest sys with FUN_00744070(sys, p) FALSE (no hostile presence); out3 = nearest sys, unconditional. Both skips: sys == excludeSys, and FUN_007469e0(sys). THE INDEPENDENCE IS LOAD-BEARING -- each 'best' store is INSIDE its predicate arm (0x007872c6 / 0x007872ec), so a nearer rejected system does not spoil that search; a single-loop 'find nearest then filter' gives a different answer. Distance is SQUARED: the three component deltas are each fstp'd to float32 first, the products and sum accumulate on the x87 stack, the total narrows to float32 once at 0x007872a3 (a double accumulator with one final narrowing is BIT-IDENTICAL, since a float32 delta squares exactly in double). Comparison is a strict < (fld best; fcomp d; test ah,0x41; jne skip), so an exact tie goes to the EARLIER system in the vector", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "ServerSystem_IsUnusableRetreatTarget", + "addr": "0x007469e0", + "convention": "thiscall", + "prototype": "bool (ServerSystem* this) // 33 B. return this->Dstyd(+0xc5) != 0 || (this->vnh(+0xc6) != 0 && this->vnex3(+0xc8) == 0). A destroyed system, or one hosting a Von Neumann hive that has not been cleared out. All three are SAVED bools (struct-recovery.md section 1), which is what identifies the class as Game::ServerSystem rather than a bare StarSystem. Note CombatResolve_NodeCannon 0x007bb530 uses only the +0xc5 half for its own destination filter", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "ServerSystem_GetOwner", + "addr": "0x007437e0", + "convention": "thiscall", + "prototype": "ServerPlayer* (ServerSystem* this) // 7 B: mov eax,[ecx+0x100]; ret. this->PID, the saved owner handle", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "ServerSystem_HasHostilePresence", + "addr": "0x00744070", + "convention": "thiscall", + "prototype": "bool (ServerSystem* this, ServerPlayer* p) // ret 4. return (ServerPlayer_HostileMaskAt(p, this) & this->AFlags(+0xd4)) != 0", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "ServerPlayer_HostileMaskAt", + "addr": "0x0080dff0", + "convention": "thiscall", + "prototype": "uint32 (ServerPlayer* this, ServerSystem* sys) // ret 4. THE HOSTILITY MASK, and it carries a diplomacy rule that is nowhere in findings/subsystems/: ownerBit = (sys && sys->PID(+0x100) && sys->TAcq(+0x80) == S->Frame) ? (1 << sys->PID->PlyrIdx) : 0; return ~( (1 << this->PlyrIdx(+0x28)) | (this->CF(+0x174) & ~ownerBit) | this->NA(+0x170) | this->AL(+0x16c) ). A SYSTEM WHOSE OWNER CAPTURED IT ON THE CURRENT TURN LOSES THAT OWNER'S CEASEFIRE COVER. The current turn is reached as this->+0x8->+0x8, which is S->+0xc = Frame, because ServerPlayer+0x8 == S+4. The complement is over all 32 bits, harmless only because it is immediately ANDed with a presence mask", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "CombatRetreat_BuildGroups", + "addr": "0x0079bcd0", + "convention": "thiscall", + "prototype": "void (RetreatContext* this) // RETREAT PHASE 2. One loop over res->+0x68/+0x6c -- a 0x14-STRIDE VECTOR OF RETREAT REQUESTS (magic 0x66666667 / sar 3), a Game::EncounterResults member not previously mapped: req->+0x04 ship handle, req->+0x08 mode word, req->+0x0c requested-destination handle, req->+0x10 second key word. Per request: ship = HandleMap::Resolve(S->+0x84, req->+0x04); skip if null, if ship->Dep(+0x50) != 0, if ship->EncID(+0x7c) == 0x15, if FUN_004f47f0(ship->EncID) (a DB-driven encounter-type mask), or if ship->PlrID(+0x10) == S->Players[S->+0x1c0] (the locally-controlled player). dest = CombatRetreat_ResolveShipDestination(ctx, req, ship); on 0 it logs 'Retreat: Could not find retreat destination for %s.' at level 2 and skips. Then, UNLESS (req->+0x08 == 1 && owner->Species(+0x5c) == 1), skip the ship if StarShip_IsGroundedByDamage(ship). Finally find-or-create the group via CombatRetreat_FindGroup and push_back the ship", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "CombatRetreat_ResolveShipDestination", + "addr": "0x0079bc50", + "convention": "thiscall", + "prototype": "ServerSystem* (RetreatContext* this, void* req, StarShip* ship) // ret 8. Returns 0 when ship is null or ship->+0x04 (its handle id) is 0. Else: if req->+0x0c != 0, HandleMap::Resolve(S->+0x84, req->+0x0c) and RETURN IT if found -- the explicitly ordered destination; if not found, log 'Retreat: %s cannot retreat to specified location. Location not found.' at level 2 with ship->PlrID->+0x40 (the player's name std::string) and fall through. Fall-through: return this->dest[ship->PlrID(+0x10)->PlyrIdx(+0x28)], the phase-1 default", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "CombatRetreat_FindGroup", + "addr": "0x007871c0", + "convention": "thiscall", + "prototype": "RetreatGroup* (RetreatContext* this, StarShip* ship, ServerSystem* dest, int mode, int variant) // ret 0x10. Linear scan of this->groups (+0x1c/+0x20) for the group matching ALL FOUR key words: g->+0x00 == ship->PlrID(+0x10), g->+0x04 == dest, g->+0x2c == mode, g->+0x30 == variant. Returns 0 when absent", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "RetreatGroup_Ctor", + "addr": "0x0078f1c0", + "convention": "thiscall", + "prototype": "RetreatGroup* (RetreatGroup* this) // 41 B; ENUMERATES the 0x34-byte heap record (operator new(0x34) at 0x0079bdee). Writes: +0x00 owner ServerPlayer* = 0; +0x04 dest ServerSystem* = 0; +0x08/+0x0c/+0x10 vector = 0 (allocator +0x14 never written, per earned-rule 5); +0x18/+0x1c/+0x20 vector = 0 (allocator +0x24); +0x28 BYTE partial = 0; +0x2c int mode = 0; +0x30 int variant = -1. Total 0x34", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "StarShip_IsGroundedByDamage", + "addr": "0x00815090", + "convention": "thiscall", + "prototype": "bool (StarShip* this) // 46 B. design = this->DesID(+0x14); return (design->+0x74 != 0 || design->+0x78 != 0) && design->+0x12c < 2 && (FLT_EPSILON > this->+0x30). The .rdata float at 0x009e1ef8 is 0x34000000 = 1.1920929e-07, FLT_EPSILON -- the test is against an epsilon, NOT against zero, and it is strict, so a health exactly AT the epsilon is not grounded. this->+0x30 is the third float of the inline ShipHealth at 0x24..0x33 (R1 guesses drive). True means the ship CANNOT retreat. The species-1 / mode-1 gate path skips this test entirely", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "IsBlockedEncounterType", + "addr": "0x004f47f0", + "convention": "cdecl", + "prototype": "bool (int encounterType) // 67 B. DB-SOURCED, DO NOT HARD-CODE. Returns false for encounterType > 0x17. Otherwise lazily builds a bitmask once (flag at 0x00b0e988, cache at 0x00b0e984) by FUN_004f47b0, which ORs 1 << table[i] over a count at 0x00ae04e0 and an int array at 0x00adfe88 -- both .bss, filled from the game data files at load -- and returns (mask >> encounterType) & 1. The 0x17 bound is the same EncounterType range PickDominantEncounterType 0x004f4c40 returns over", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "CombatRetreat_ClassifyFleets", + "addr": "0x007b0320", + "convention": "thiscall", + "prototype": "void (RetreatContext* this) // RETREAT PHASE 3. Per group in this->groups: tally the group's ships by ship->FltID(+0x64) into a local 8-byte-stride vector<{StarFleet*, int}> (linear search, push {f,0} then ++count); then per distinct fleet f, if count == (f->+0xa8 - f->+0xa4)/4 (i.e. EVERY ship the fleet has is in this group) push f into g->fleets(+0x18), else set g->partial(+0x28) = 1. WARNING: the jb/jae pairs at 0x007b0504 and 0x007b0570 are MSVC's inlined vector::push_back aliasing check (_Inside(&_Val)), NOT a phase gate -- both arms converge on *_Mylast = value; _Mylast += 4 at 0x007b05ce. Draw-free, event-free, writes only the group records", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "CombatRetreat_SplitFleets", + "addr": "0x00790790", + "convention": "thiscall", + "prototype": "void (RetreatContext* this) // RETREAT PHASE 4, and THE BIGGEST SAVED-STATE WRITER ON THE COMBAT PATH. Per group, gated on g->partial(+0x28) != 0: newFleet = StrategyServer_CreateFleet(S+4, IDMap_AllocateLocalID(S+4), g->owner, &enc->+0x1c, NULL) -- note the id mint is evaluated INSIDE the argument list, see IDMap_AllocateLocalID; push newFleet into g->fleets BEFORE the ship loop; then per ship in g->ships, skip it if ship->FltID(+0x64) is ALREADY in g->fleets (its whole fleet is retreating, leave it), else StarFleet_RemoveShip(old, ship), and if ((old->+0xa8 - old->+0xa4) & ~3) == 0 call StrategyServer_DestroyFleet(S+4, old, 0, 0), then StarFleet_AddShip(newFleet, ship). Finally if (enc->+0x0c) StarSystem_FleetArrives(enc->+0x0c, newFleet). PREDICTED ORIGINAL BUG: the fleet is created before the loop, so a group flagged partial creates a fleet even when every ship turns out to belong to a wholly-retreating fleet -- a zero-ship fleet in the save", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "StarFleet_AddShip", + "addr": "0x007062e0", + "convention": "thiscall", + "prototype": "void (StarFleet* this, StarShip* ship) // ret 4. this->NShips(+0xa4).push_back(ship); SHIP->FltID(+0x64) = this (the saved handle); design = ship->DesID(+0x14); this->+0xb8 |= design->+0xb8 and this->+0xbc |= design->+0xbc (capability masks); this->+0xc0 (WORD, the fleet's gate-traffic cost) += FUN_0056f430(design->+0x12c); if bit 0x20000 of +0xb8 changed state, push_back this into (this->+0x10)->+0x114; FUN_00705c70(this) to recompute derived stats", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "StarFleet_RemoveShip", + "addr": "0x007063a0", + "convention": "thiscall", + "prototype": "void (StarFleet* this, StarShip* ship) // ret 4. Linear-search this->NShips(+0xa4/+0xa8) for ship; return if absent. If the fleet's flight plan is non-empty (+0xc8 != +0xcc) and IsGateTransitWaypoint(wpt->+0x08) 0x0056e6e0, this->PID(+0x58)->GTraf(+0x14c) -= (int16)this->+0xc0. Then SHIP->FltID(+0x64) = 0; erase the slot (memmove + _Mylast -= 4); FUN_00700090(this); FUN_00705070(this, ship); re-add the gate-traffic term under the same test; if bit 0x20000 of +0xb8 changed, find-and-erase this from (this->+0x10)->+0x114; if any ship remains, FUN_00705c70(this). GTraf is a SAVED ServerPlayer field", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "StarSystem_FleetArrives", + "addr": "0x0074f240", + "convention": "thiscall", + "prototype": "void (ServerSystem* this, StarFleet* f) // ret 4. f->Pos(+0x18/+0x1c/+0x20) = this->Pos (three raw dword copies); StarFleet_SetLocation(f, this); bit = 1 << f->PID(+0x58)->PlyrIdx(+0x28); this->FFlags(+0xd8) |= bit; this->+0x16c.push_back(f) (the SAVED NumFlts/Flt list); then recompute: if (FFlags & bit || GFlags(+0xdc) & bit || PID(+0x100) == owner) { AFlags(+0xd4) |= bit; VFlags(+0xcc) |= bit; } else AFlags &= ~bit -- on this path the else arm is unreachable because FFlags was just set", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "StarFleet_SetLocation", + "addr": "0x006fe2f0", + "convention": "thiscall", + "prototype": "void (StarFleet* this, void* loc) // 16 B: this->LocID(+0xa0) = loc. Saved handle", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "StarFleet_GetLocationIfNode", + "addr": "0x006fe300", + "convention": "thiscall", + "prototype": "void* (StarFleet* this) // 19 B: eax = this->LocID(+0xa0); return (eax && eax->+0x14 == 0) ? eax : 0. The +0x14 word is a location-class tag; the sibling FUN_006fe320 returns it only when the tag is 2. The retreat path uses this one, whose class carries the fleet list at +0x16c and the name std::string at +0xa8", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "CombatRetreat_Execute", + "addr": "0x007d5650", + "convention": "thiscall", + "prototype": "void (RetreatContext* this) // RETREAT PHASE 5. Two nested loops: over this->groups, then over g->fleets (whole fleets from phase 3 plus any new fleet from phase 4). Per fleet f: (a) if (enc->+0x0c && !ServerSystem_IsExploredBy(enc->+0x0c, f->PID(+0x58))) StrategyServer_GrantSystemIntel(S, S->+0x44[enc->+0x0c->Idx(+0x5c)], f->PID) -- RETREATING FROM AN UNEXPLORED SYSTEM REVEALS IT, writing the SAVED EFlags and PlayerView; (b) if CombatRetreat_UsesGate(g) && g->dest(+0x04) != 0, the GATE ARM: StarSystem_FleetDeparts(GetLocationIfNode(f), f), StarSystem_FleetArrives(g->dest, f), then two _snprintf'd strings from the .bss format-string pointers at 0x00aedf0c and 0x00aedf14 (destination Name(+0xa8), fleet FtName(+0x5c)) and EventStorage_PostEvent(ServerPlayer_GetEventStorage(f->PID), {msg, summary, f, 0, S->Frame, 'EVENT_FLEET_RETREATED_VIA_TELEPORT', 0}); (c) else the MOVE ARM: if (!StrategyServer_OrderFleetMove(S+4, f, &g->dest, 1)) log 'Retreat: Unable to set destination for retreat for %s.' at level 2 with f->PID->+0x40, else StarFleet_SetFlag(f, 2, true) and StarSystem_FleetDeparts(GetLocationIfNode(f), f)", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "CombatRetreat_UsesGate", + "addr": "0x007859f0", + "convention": "cdecl", + "prototype": "bool (RetreatGroup* g) // 48 B, ret 4 (ecx is set at the one call site but unused). return g != 0 && g->mode(+0x2c) == 1 && g->owner(+0x00) != 0 && g->owner->Species(+0x5c) == 1 && g->dest(+0x04) != 0. Species 1 is the gate-building species; this is the ONLY gate on EVENT_FLEET_RETREATED_VIA_TELEPORT, which is posted nowhere else in the image. The same (mode == 1 && species == 1) pair is what lets a ship with a dead drive past StarShip_IsGroundedByDamage in phase 2", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "ServerSystem_IsExploredBy", + "addr": "0x00743880", + "convention": "thiscall", + "prototype": "bool (ServerSystem* this, ServerPlayer* p) // 34 B, ret 4: return (this->EFlags(+0xd0) & (1 << p->PlyrIdx(+0x28))) != 0. EFlags is a SAVED int", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "ServerSystem_SetExploredBy", + "addr": "0x007438b0", + "convention": "thiscall", + "prototype": "void (ServerSystem* this, ServerPlayer* p, bool on) // 82 B, ret 8. bit = 1 << p->PlyrIdx(+0x28); wasSet = (this->EFlags(+0xd0) & bit) != 0; on ? EFlags |= bit : EFlags &= ~bit; then this->vft[0x1c](p, wasSet, on) -- AN INDIRECT EDGE, unresolved here, flagged for the vtable-inversion lane", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "StrategyServer_GrantSystemIntel", + "addr": "0x007d5080", + "convention": "thiscall", + "prototype": "void (StrategyServer* this /*the S frame*/, ServerSystem* sys, ServerPlayer* p) // 195 B, ret 8. Returns immediately if either pointer is null. ServerSystem_SetExploredBy(sys, p, true); FUN_006e4620(this->+0x154 /*the node-line/graph object*/, sys, p, 1); then builds a stack temp via FUN_00755ab0, fills it with FUN_007561d0(sys, &tmp, p), and applies it through FUN_007d1f00(this->+0x224, &sys->Idx(+0x5c), &p->PlyrIdx(+0x28), &tmp) -> FUN_0075cf50 -> FUN_00753430, destroying the temp with FUN_006bba00. this->+0x224 is INFERRED to be the per-(system, player) StarSystem::PlayerView map -- combat-done-tail.md phase 17 has its tree head at S+0x228 and its size word at S+0x22c, which puts the map object at S+0x224 -- but the final leg FUN_00753430 was NOT read, so the exact field written is a labelled hypothesis", + "status": "hypothesis", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "CombatRetreat_ContextDtor", + "addr": "0x007a7cd0", + "convention": "thiscall", + "prototype": "void (RetreatContext* this) // RETREAT PHASE 6, and it is only a destructor -- it writes no game state. FUN_00793090(this->+0x1c, this->+0x20) deletes every RetreatGroup; then vector::erase(begin,end) (a degenerate memmove of 0 bytes plus _Mylast = _Myfirst) and operator delete on the buffer, zeroing +0x1c/+0x20/+0x24; then _Tree::erase(head->_Left, head) via FUN_006a4e40 on the map at this->+0x0c and operator delete on its head node", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "StrategyServer_CreateFleet", + "addr": "0x0085b340", + "convention": "thiscall", + "prototype": "StarFleet* (StrategyServer* this /*the S+4 frame*/, int oid, ServerPlayer* owner, Mars::Vector3* pos, const char* nameOverride) // 347 B, ret 0x10. operator new(0x120) -- SIZEOF(Game::StarFleet) = 0x120 BY ENUMERATION, agreeing with struct-recovery.md section 3 whose last member FtMS sits at +0x11c -- then ctor FUN_00706140; IDMap_Insert(this+0x80, obj, oid) which writes the id into obj->+0x04; obj->+0x10 = this (the S+4 pointer, NOT a ServerPlayer as it is on StarShip); obj->PID(+0x58) = owner; the name from FUN_00856310(owner) (-> FUN_00855f80 on owner+0x298) assigned into obj->FtName(+0x5c) unless nameOverride is given; pos copied to BOTH +0x18/+0x1c/+0x20 (Pos) and +0x4c/+0x50/+0x54 (PrvPos); StarFleet_SetFlag(obj, 0x400, true); push_back into the vector at this->+0x60 (= S+0x64), THE SAVED NumFlts/FltID/Flt MASTER LIST; then the virtual (*this)->vft[0x10](this, obj). Draw-free, including the name generator. DELEGATED instruction-level read; the call site is mine", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "IDMap_AllocateLocalID", + "addr": "0x0080f710", + "convention": "thiscall", + "prototype": "int (StrategyServer* this /*the S+4 frame*/) // AN 8-BYTE TAIL-CALL THUNK: sub ecx,0xffffff80 (ecx += 0x80) then jmp FUN_008b8b70, which is push [ecx+0x18] (the local node index); call IDMap_AllocateID; ret. IT TAKES NO STACK ARGUMENTS. At the one retreat call site 0x007907d8 three pushes sit immediately before it and belong to the LATER call to StrategyServer_CreateFleet -- the id mint is evaluated inside that call's argument list. Checking ret N against the push count is what catches this; see combat-retreat-pipeline.md section 7.3. The IDMap subobject therefore lives at (S+4)+0x80", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "IDMap_AllocateID", + "addr": "0x008b8ae0", + "convention": "thiscall", + "prototype": "int (IDMap* this, int nodeIndex) // ret 4. THE OBJECT-ID ALLOCATOR. Closes struct-recovery.md section 7's open item 'OID allocation (R2's x16)': R2's x16 is the shl 4 below. Layout: this->+0x08/+0x0c is a vector of STRIDE 0x14 (pinned twice -- FUN_008b8a70's 0x66666667 / sar 3 divide-by-20, and the lea eax,[edi+edi*4] with scale 4 here); NodeEntry is a std::map (0x10) followed by the counter at +0x10; this->+0x18 is the local node index. Body: returns 0 if nodeIndex == -1 (logs 'IDMap: Map not initialized.') or the node does not exist; else 0x008b8b16 'inc DWORD PTR [ecx+eax*4+0x10]' PRE-INCREMENTS the per-node counter, and on wrap to 0 logs 'IDMap: NextID wrapping for node %d.' and increments AGAIN (0x008b8b30), so 0 is never issued -- 0 is INVALID_NETWORK_ID; then id = (counter << 4) | (nodeIndex & 0xF) at 0x008b8b38/0x008b8b3e, with an overflow log if the counter no longer round-trips. THE COUNTER IS ALMOST CERTAINLY THE SAVE'S 'NMnx' TAG (StrategyServer block order: KeyPath, NMSz, NMLc, NMnx, ModCount, Frame) -- LABELLED HYPOTHESIS: StrategyServer::Write 0x0079fa70 was not read for it. DELEGATED instruction-level read", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "IDMap_Insert", + "addr": "0x008b9350", + "convention": "thiscall", + "prototype": "void (IDMap* this, void* obj, int id) // ret 8. Logs 'IDMap: Object already exists with id %d.' and continues if the id is taken; refuses id 0 with 'IDMap: Object with INVALID_NETWORK_ID not inserted.'; node = id & 0xF, bounds-checked; WRITES THE ID INTO obj->+0x04 (the NetworkObject handle slot every serialized object uses); then _Buynode + _Insert into the map at _Myfirst + node*0x14. DELEGATED instruction-level read", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "StrategyServer_DestroyFleet", + "addr": "0x0088b980", + "convention": "thiscall", + "prototype": "void (StrategyServer* this /*the S+4 frame*/, StarFleet* f, bool killRemainingShips, int shipDestroyArg) // 1352 B, ret 0xc. Called from the retreat split as (f, 0, 0), so the ship-massacre block 0x0088bbf1..0x0088bc82 is dead on that path. A REAL DELETE (vtable slot 0 with flag 1, the MSVC scalar deleting destructor, at 0x0088bea6). Order: (1) sweep the master fleet vector this->+0x60/+0x64 for any fleet whose flight plan (+0xc8/+0xcc, waypoint stride 0x1c) carries a waypoint whose field_0 is f's id -- for each, re-plan it via FUN_00707290 and POST 'EVENT_FLEET_INTERCEPT_ABORTED' to THAT fleet's owner, inlining player+0x29c rather than calling ServerPlayer::GetEventStorage; (2) optionally destroy remaining ships; (3) erase from the owner's fleet map at owner+0x2b4; (4) per player, FUN_00812110(player+0x29c, fleetId, -1) to purge queued events referencing it; (5) erase from this->+0x60 or log 'StrategySim: Can't delete %s (%i), not found in master list!'; (6) detach from its location; (7) vft[0x14](f), erase from this->+0x114, FUN_008b92a0(this+0x80, f); (8) delete. No RNG, no inlined MT draw, no SETurnResults stride. DELEGATED instruction-level read, full body to the next function start", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "StrategyServer_OrderFleetMove", + "addr": "0x008653c0", + "convention": "thiscall", + "prototype": "bool (StrategyServer* this /*the S+4 frame*/, StarFleet* f, ServerSystem** dests, unsigned count) // 801 B, ret 0xc. Returns false if f is null, or if the path solver FUN_007066c0 sets any of bits 0x008 / 0x010 / 0x400 in its flag word (then logging 'StrategySim: %s (%s) move not permitted at this time.' at level 2) -- THE MEANING OF THOSE THREE BITS IS UNKNOWN, FUN_007066c0 was not opened. Before that gate it SNAPS the fleet's position onto its current system when they differ, tested by FUN_0080ec50 with fucompp EXACT IEEE EQUALITY and no epsilon; that snap is committed even on the false path. On success it builds a 0x1c-stride waypoint vector and commits it through FUN_00707080, which debits owner->GTraf(+0x14c) by (int16)f->+0xc0, writes f->+0xd8..+0xf8 and +0x100..+0x108 (cached source/destination positions and the leg record), then re-credits GTraf; finally it cancels conflicting ship actions via FUN_00849280. No RNG and no x87 in the function itself. DELEGATED instruction-level read, full body to the next function start", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + }, + { + "name": "Map_IntPtr_Subscript", + "addr": "0x0076bce0", + "convention": "thiscall", + "prototype": "void** (std::map* this, const int* key) // 125 B, ret 4. MSVC std::map::operator[]: _Lbound over the tree from this->_Myhead(+0x04)->_Parent, testing _Isnil at node+0x15 and the key at node+0x0c; if found returns &node->_Myval.second (node+0x10), else default-inserts the pair {key, 0} via _Buynode 0x008b91d0 + _Insert 0x0072b400 and returns the same. NODE IS 0x18 BY ENUMERATION from _Buynode's operator new(0x18): _Left +0x00, _Parent +0x04, _Right +0x08, pair at +0x0c/+0x10, and _Color/_Isnil written as ONE 16-bit store at +0x14/+0x15, plus 2 bytes padding. DELEGATED instruction-level read", + "status": "verified", + "source": "findings/control-flow/combat-retreat-pipeline.md" + } + ] +}