sots-re/findings/subsystems/path-solver.md
alex e30619630e lane P2: the path solver read from the instruction stream, and OrderFleetMove's three failure bits
FUN_007066c0 is not a path finder. It performs no graph search: it walks the caller's
already-chosen destination list and classifies each consecutive pair through
FUN_00703730, accumulating flags and the index of the first failing leg. The only graph
structure in the subtree is a single-hop adjacency query. A multi-hop node route in a save
is n waypoints, one per hop.

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

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

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

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

51 KiB
Raw Blame History

FUN_007066c0 and FUN_00703730 — the "path solver", read from the instruction stream

Lane P2, 2026-09-08. Program sots / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.

Method. Every function below was disassembled with objdump -D -b binary -m i386 -M intel over the raw .text image (file offset VA - 0x400C00), each to the next function start (rule 17), and the padding inspected. Jump tables were resolved byte by byte from .text. No claim about control flow here comes from a decompiler. Everything is marked [V] instruction-verified by me or [I] inferred; there are no delegated reads in this lane.

Closes combat-retreat-pipeline.md §6's "The three failure bits 0x008 / 0x010 / 0x400 of OrderFleetMove" and §8's "the single biggest hole". It also closes the type-2 question that movefleet-position-rounding.md §6/§7, strategic-turn-internals.md §4.3 and lane O's behavioural sweep have each circled from a different side, and it corrects the name of waypoint type 2.


0. Lead: it is not a path finder

FUN_007066c0 performs no graph search over systems. There is no frontier, no visited set, no relaxation, no priority queue, and no recursion. It is a per-leg classifier and validator over a waypoint list its caller already chose:

solver(fleet, start, dests[], n) :
    drop dests[0] if it is the fleet itself or the fleet's current system
    probe leg (start -> dests[0])
    prev = start
    for i in 0 .. n-1:
        types[i]  = ClassifyLeg(fleet, prev, dests[i], &rangeLeft, &legFlags, &routes[i])
        flags    |= legFlags ;  failIndex = first i with legFlags != 0
        rangeLeft = f32(rangeLeft - f32(sqrt(f32(|prev.pos - dests[i].pos|^2))))
        if dests[i] is a system where the fleet may refuel:  rangeLeft = fullTank
        prev = dests[i]
    return true                       // ALWAYS true past the argument checks

The only graph lookup in the whole subtree is FUN_006e4eb0, and it is a single-hop adjacency query: "is there a node line between system A and system B that this player has discovered?" It answers with the line's path index or −1. It never looks at a third system. A multi-hop node route in a save is therefore n waypoints, one per hop, each classified independently — the routing decision was made by whoever built dests[] (the UI's map click chain, or the AI), never here. [V]

The function's return value is true for every call that has a non-null fleet and a non-null start object; all outcome information travels in the two out-parameters. [V]

ret (no ret N) with eight stack arguments — cdecl, not thiscall, despite ecx being loaded with the fleet at three call sites. The fleet is passed as argument 1 and used as this for the range helpers. [V]

0.1 Real boundaries (rule 17)

function Ghidra size real body verdict
FUN_007066c0 584 0x7066c0..0x706907, then 8 × int3 to 0x706910 correct
FUN_00703730 1178 0x703730..0x703bc9, then 6 × int3 to 0x703bd0 correct
FUN_008653c0 801 0x8653c0..0x8656ea, then 5 × int3 to 0x8656f0 correct
FUN_00707080 514 0x707080..0x707281, then 14 × int3 to 0x707290 correct

Four for four. Recorded because the rule exists — but this lane found the sizes right, and the disassembly to the next start is what establishes that rather than assuming it.

0.2 Two functions that read live-in registers

FUN_00703650 and FUN_00703bd0 each test a register they never write:

703650  push ebp; mov ebp,esp
703653  mov eax,[ebp+0xc] ; test eax,eax ; je ... ; mov DWORD PTR [eax],0x0
703660  test ebx,ebx      ; <-- ebx is live-in
703668  test esi,esi      ; <-- esi is live-in

At FUN_00703730's call site 0x703831, ebx = the moving fleet and esi = the target fleet; at FUN_00703c90's call site 0x703caa, esi = the system. Both have exactly one caller. Treating either as a plain cdecl function and reading only its stack arguments produces nonsense — I nearly did. This is a new failure mode for the rules file, adjacent to §7.3 of combat-retreat-pipeline.md: check whether a callee reads a callee-saved register before it writes it. [V]


1. FUN_007066c0 — the driver

1.1 Signature, from the two call sites and the frame

// __cdecl, 8 args, always returns true past the guards.
bool PathSolver(StarFleet*   fleet,      // +0x08  the moving fleet ("this" for the range helpers)
                MapObject*   start,      // +0x0c  where leg 0 begins (OrderFleetMove passes the fleet)
                MapObject**  dests,      // +0x10  the ordered destination list
                unsigned     count,      // +0x14  its length  (DECREMENTED IN PLACE, see 1.2)
                int*         flagsOut,   // +0x18  OR of every leg's flags        (optional)
                int*         failIdxOut, // +0x1c  index of the FIRST failing leg (optional, -1 = none)
                int*         typesOut,   // +0x20  one waypoint type per dest, stride 4     (optional)
                NodeRoute*   routesOut); // +0x24  one NodeRoute per dest,   stride 0x10    (optional)

typesOut and routesOut are written only when non-null, and each is guarded separately (0x706808 / 0x706814), so a caller may ask for flags alone. Both callers do exactly that or ask for everything:

caller call what it wants
FUN_005e6d50 (UI move command) (f, f, dests, n, &flags, 0, 0, 0) dry run. If flags != 0 it allocates a 0x8f8-byte dialog and hands it (f, dests, n, flags) — the move-confirmation prompt. If flags == 0 it sends the command. [V]
FUN_008653c0 OrderFleetMove (f, f, dests, n, &flags, 0, types._Myfirst, routes._Myfirst) the real thing

The UI tests flags != 0; OrderFleetMove tests flags & 0x418. That split is what separates the warning bits from the refusal bits, and it is the cleanest evidence for the reading in §3. [V]

1.2 The leading-destination drop

706722  ecx = dests[0]
706724  if (fleet == ecx)                      goto drop
706728  eax = fleet->LocID(+0xa0)
70672e  if (!eax || eax->+0x14 != 0) eax = 0   ; inlined StarFleet_GetLocationIfNode
706739  if (eax == ecx)                        goto drop
        goto keep
drop:   dests += 4 ; --count                   ; 0x70673d

So a destination list whose first entry is the fleet itself, or the system the fleet is already at, has that entry removed — dests and count are advanced/decremented in the solver's own stack copies only (count lives at [ebp+0x14], a by-value argument), so OrderFleetMove's count is untouched and it still builds count waypoints from its un-dropped list. The types[] and routes[] the solver writes are therefore shifted by one relative to the waypoints OrderFleetMove builds whenever the drop fires, and types[count-1] / routes[count-1] are never written at all — they keep the value the vector was constructed with. That is an original defect and it is testable: §7, P4. [V]

FUN_00459f70, FUN_0085bf00 and FUN_00703e90 are all MSVC vector::resize(n) — shrink-by-erase on one side, grow-and-value-initialise on the other (rep stos eax=0 for the int vector at 0x459fd8) — not reserve, so _Myfirst != _Mylast and the two out-pointers are non-null whenever count > 0, and the untouched tail element is a genuine zero rather than uninitialised memory. Checked precisely because the whole paragraph above collapses if any of the three were a reserve. [V]

The +0x14 != 0 guard means the drop only fires for a system location, never a point or a fleet. [V]

1.3 The three fuel figures

70674b  [ebp-0x24] = StarFleet_MinRange(fleet, 0.0f)        ; 0x006ff6a0
706757  [ebp-0x38] = FUN_00705d60(fleet, true)              ; full tanks
706762  [ebp-0x10] = FUN_00705d60(fleet, false)             ; fuel remaining  <- the running budget

StarFleet_MinRange is already verified in addresses.json with the identical body I read here (min over ships of ship->Range(+0x20), seeded FLT_MAX from the .rdata word at 0x009e23a8 = 0x7f7fffff, strictly-greater update, then f32(best + bias)); agreement recorded, no duplicate entry filed. [V]

FUN_00705d60(fleet, bool useMaxRange) builds a vector<float> of per-ship ranges — ship->Range(+0x20) when the flag is false, Ship_MaxRange (0x0080c820) when true — alongside a per-design list keyed on design->+0x12c, and folds them. I read only its first ~0x90 bytes and its two call sites; the fold itself (tanker redistribution) is not read. What is established is the argument's polarity and that the result is a float. [V for the polarity, not read for the fold]

The pre-flight probe at 0x706795 passes &[ebp-0x24] — the plain MinRange, not the effective range — so the first leg is evaluated twice, once against the pessimistic budget and once for real. Its flagsOut is assigned (not OR'd) and its failIdx set to 0; its return value and its route are discarded (routeOut = NULL). [V]

1.4 The per-leg loop, exactly

7067c3  rangeLeft = max(rangeLeft, 0.0f)      ; fcomp 0.0 vs rangeLeft, strict
7067df  legFlags = 0
7067e6  NodeRoute tmp;  ctor 0x006e1b20 -> {vptr=0x00a1cbdc, nrp=-1, nrf=0, nrt=0}
706803  t = ClassifyLeg(fleet, prev, cur, &rangeLeft, &legFlags, &tmp)      ; FUN_00703730
706808  if (typesOut)  typesOut[i] = t
706814  if (routesOut) { routesOut[i].nrp = tmp.nrp; .nrf = tmp.nrf; .nrt = tmp.nrt }   ; vptr NOT copied
70682e  if (legFlags) { if (failIdx == -1) failIdx = i;  flags |= legFlags }
706844  rangeLeft -= legLength(prev, cur)                                   ; see 1.5
706886  if (cur->+0x14 == 0 && CanRefuelAt(cur, fleet)) rangeLeft = fullTank
7068b2  ++i; dests += 4; routesOut += 0x10; prev = cur
7068ce  tmp.vptr = 0x009e22bc                 ; the INLINED NodeRoute destructor -- see below

Rule 4 trap, and it is a live one here. mov DWORD PTR [ebp-0x48],0x9e22bc on the back edge looks like a second object being installed. It is not: 0x009e22bc is the Mars::IStreamable vftable (rtti), and this store is the whole of the inlined ~NodeRoute() — MSVC's base-class-vptr reset with nothing left to free. The constructor runs again at 0x7067e6 at the top of the next iteration, so nrp really is reset to −1 every leg. Reading the store as a branch or as a missing re-init would have produced a false "stale node route carried between legs" bug report. [V]

The [ebp-0x4] writes (0 before the call, −1 on the back edge) are the SEH try-level for that same local, not control flow. [V]

1.5 The leg length — and its precision

706844  fld [edi+0x18] ; fsub [esi+0x18] ; fstp DWORD [ebp-0x30]     ; dx -> FLOAT32
70684d  fld [edi+0x1c] ; fsub [esi+0x1c] ; fstp DWORD [ebp-0x24]     ; dy -> FLOAT32
706856  fld [edi+0x20] ; fsub [esi+0x20] ; fstp DWORD [ebp-0x34]     ; dz -> FLOAT32
70685f..706876   dx*dx + dy*dy + dz*dz   entirely on the x87 stack (53-bit)
706878  fstp DWORD [ebp-0x34]                                        ; sumsq -> FLOAT32
70687b  fld  [ebp-0x34] ; call 0x924f52 (sqrt) ; fstp DWORD [ebp-0x34]  ; len -> FLOAT32
70688a  fld  [ebp-0x34] ; fstp DWORD [ebp-0x34]                       ; a no-op reload/store
706890  fld  [ebp-0x10] ; fsub [ebp-0x34] ; fstp DWORD [ebp-0x10]     ; rangeLeft -> FLOAT32

This is Mars_Vec3_Length (lane M §1) inlined, with the same two narrowings, plus a third on the subtraction. Lane M's rule holds verbatim: the delta a reimplementation subtracts is f32(a.c − b.c), not the exact difference, and the sum of squares is narrowed once, after a 53-bit accumulation. Because a float32 delta has 24 significand bits, each square is exact in double and so is the three-term sum, so a double accumulator with a single final narrowing is bit-identical here. fpu_cw = 0x127f. [V]

prev is the previous destination object, not the fleet's position, from leg 1 onward (edi = esi at 0x7068c5) — so the budget is drawn down along the waypoint chain, not from where the fleet actually is. On leg 0, prev is the start argument, which OrderFleetMove sets to the fleet. [V]

1.6 Refuelling

FUN_00703c90(node, fleet) — 85 B [V]:

owner = MapObject_GetOwner(fleet)                    ; FUN_0071e280, switch on +0x14:
                                                     ;   0 -> sys->PID(+0x100), 1 -> fleet->PID(+0x58), else 0
a = FUN_00703bd0(owner)      ; esi = node LIVE-IN.  For each fleet at the node (vft[2] count, vft[3] index),
                             ; skipping any whose owner differs when owner != 0:
                             ;   true if StarFleet_HasFlagShips(f, 2, 0)   -- capability mask 2, the tanker bit
b = node && owner && node->PID(+0x100) && Relation(node->PID, owner) >= 3        ; FUN_0080e050
return a || b

FUN_0080e050 is FUN_006d2050(this->PlyrIdx, &this->Team(+0x168), other->PlyrIdx). strategic-turn-internals §5.2 records that function's result as "1 ally, 2 NAP, 3 cease-fire"; if that ordering is right, >= 3 would mean "refuel at a cease-fire system but not at an ally's", which is almost certainly backwards. I did not read FUN_006d2050. The >= 3 test is [V]; the meaning of 3 is open, and §5.2's ordering should be re-checked by whoever needs it. Two independent call sites use the same scale with different thresholds (>= 3 here, > 0 in FUN_00817890), which is itself a hint that the scale is monotone in friendliness and §5.2 has it inverted. [I]

Note the refuel test is gated on cur->+0x14 == 0 — systems only, never a point or a fleet. [V]


2. FUN_00703730 — ClassifyLeg, the actual rule

// __thiscall, ret 0x14. Returns the WAYPOINT TYPE for this leg (0 = "no move possible").
int ClassifyLeg(StarFleet* this, MapObject* from, MapObject* to,
                float* rangeInOut, int* flagsOut, NodeRoute* routeOut);

flagsOut may be null — the function substitutes a stack dummy at [ebp-0x18] so the or sites never need a null test, and then skips the whole flag block at 0x703846 when the caller passed null. So a caller that wants only the type gets no flag work done at all. [V]

2.1 Endpoint classification

MapObject->+0x14 is the kind tag, already established by MoveFleet's arrival switch (strategic-turn-internals §4.3): 0 = system, 1 = fleet, 2 = deep-space point. [V]

from:  kind 2 -> fromPoint = from
       kind 0 -> fromSystem = from
       kind 1 -> loc = from->LocID(+0xa0); loc kind 0 -> fromSystem = loc; loc kind 2 -> fromPoint = loc
to:    toSystem = (kind==0) ? to : 0      (the neg/sbb/not/and idiom, 0x7037bd)
       toFleet  = (kind==1) ? to : 0
       toPoint  = (kind==2) ? to : 0

[V]

2.2 The default: species decides the waypoint type

703770  driveType = FUN_006ff810(fleet)

FUN_006ff810 (118 B) [V]:

if (fleet has no ships) return 0
t = DriveTypeOfSpecies(fleet->PID(+0x58)->Species(+0x5c))       ; FUN_0080c7d0
if (t == 0) return 0
if (fleet has exactly one ship) return t
for i in 1 .. nShips-1:
    if (DriveTypeOfSpecies(fleet->PID->Species) != t) return 0  ; <-- LOOP-INVARIANT, see below
return t

Original defect. The loop body re-reads fleet->PID->Species — the fleet's owner — on every iteration rather than indexing ship i. The compared value is therefore constant and the loop can never fail. The intent was evidently "every ship in this fleet must have the same drive"; as shipped it is dead code. It costs nothing behaviourally (a fleet's ships all belong to one player) but a reimplementation that "fixes" it to read per-ship data would diverge the moment a mixed fleet exists. Recorded as-shipped. [V]

FUN_0080c7d0(species) (50 B) is a 7-entry jump table at 0x0080c804, resolved byte by byte [V]:

species index jump target drive type
Human 0 0x0080c7e2 3
Hiver 1 0x0080c7fe 0
Tarkas 2 0x0080c7e9 1
Liir 3 0x0080c7f0 2
_NPC 4 0x0080c7fe 0
Zuul 5 0x0080c7e2 3
Morrigi 6 0x0080c7f7 6

(species enum from strategic-turn-internals §0; index out of 0..6 returns 0.)

2.3 The decision, in order

 A.  if (to is a FLEET):
         targetOnNodeRoute = to->wpts non-empty && IsNodeWaypoint(to->wpts[0].Tp)
         ok = SolveIntercept(...)              ; FUN_00703650, ebx=fleet esi=to live-in
         if (ok) toSystem = the resolved system
 B.  if (flagsOut):                            ; the whole block is skipped when null
         if (to is a fleet && !ok && targetOnNodeRoute)  flags |= 0x008
         if (AnyShipGroundedByDamage(fleet))             flags |= 0x010     ; FUN_00700240
         m = PendingShipActionMask(fleet)                                   ; FUN_006ff990
         if (m & 0x100) { m &= ~0x100; flags |= 0x800 }
         if (m)                                          flags |= 0x001
 C.  if (to is a POINT):
         if (!PointVisibleTo(to, owner) && !PointKnownTo(to, owner))        ; +0x8c / +0x90 masks
             flags |= 0x400
             return IsGateTransitWaypoint(driveType) || IsNodeWaypoint(driveType) ? 0 : driveType
 D.  fromHasGate = fromSystem && ServerSystem_HasGate(fromSystem, owner)    ; FUN_00744010, GFlags(+0xdc)
     toHasGate   = toSystem   && ServerSystem_HasGate(toSystem,   owner)
     canProject  = GateProjection(owner, fromSystem, toSystem)              ; FUN_00818040
     if ( (fromHasGate && (toHasGate || canProject ||
                           (toPoint && PointUsableBy(toPoint, owner))))     ; FUN_0080ea30
          || (toHasGate && fromPoint) ):
         capacity = owner->NGts(+0x144) * owner->PrGtTrf(+0x148)            ; FUN_0080dc50
         cost     = (int16)fleet->+0xc0
         if (fleet's CURRENT waypoint is already a gate transit) cost = 0
         if (owner->GTraf(+0x14c) + cost > capacity) { flags |= 0x004; return 0 }
         flags &= ~0x010                                                    ; <-- clears the grounded bit
         return canProject ? 5 : 4
 E.  if (driveType != 3) return driveType                                   ; every non-node race stops here
 F.  ... the node-route branch, §2.5 ...

[V] throughout.

Step E is the whole story for Hiver (0), Tarkas (1), Liir (2), NPC (0) and Morrigi (6): once the gate block declines, the leg's waypoint type is the species' drive type and nothing else is checked — no range, no node line, no route record. Type 0 for Hiver/NPC is not a failure; MoveFleet's switch treats every type it does not name as step = FPsp2 × dt.

2.4 The gate block, decoded

FUN_00744010(sys, player) (34 B) is (sys->GFlags(+0xdc) >> player->PlyrIdx(+0x28)) & 1. GFlags is the one ServerSystem presence mask combat-retreat-pipeline.md §5 left as "a second presence source (not read here)". It is the per-player gate mask, and the whole of §2.3 D is built on it. [V]

FUN_00818040(player, A, B) (150 B) [V]:

if (!A || !B) return false
if (!(0.0f < player->CstR(+0x150))) return false
if (!HasGate(A, player)) return false
if ( HasGate(B, player)) return false                 ; B must NOT have one
return Mars_Vec3_Length(A.pos - B.pos) <= player->CstR      ; non-strict; 0x004224b0

CstR now has a reader. strategic-turn-internals §4.3 records it as "CstR unused here"; it is the gate-projection radius — how far past a gate a fleet can be thrown when the far end has no receiving gate. The distance is Mars_Vec3_Length, i.e. two float32 narrowings (lane M §1), compared non-strictly. [V]

So types 4 and 5 are both Hiver gate transits and the distinction is precise:

  • type 4 = gate → gate. Both ends carry the player's gate.
  • type 5 = gate → gateless system within CstR. MoveFleet's case 5 is "roll = rand01() × CstE; if roll > CstT the fleet scatters around the destination" (B4's correction) — which is exactly the risk of throwing a fleet at a system with nothing to catch it.

This corrects strategic-turn-internals §4.3's medium-confidence guess that type 5 is "the Zuul node-bore / Morrigi gravity casting". It is neither: Zuul and Morrigi can never reach step D unless they somehow hold a gate, and the node bore lives in step F. CstE/CstT/CstR are one coherent trio of gate-projection parameters. [V for the classification, [I] for the CstE/CstT reading, which is B4's]

The flags &= ~0x010 at 0x7039d3 is worth stating on its own: a gate transit ignores dead drives. That is the same rule combat-retreat-pipeline.md §2.2 found from the other end (the species-1 bypass of IsGroundedByDamage on the retreat gate arm), reached independently. [V]

2.5 The node-route branch (driveType == 3: Human and Zuul only)

7039f6  graph = (fleet->galaxy(+0x10))->vft[1]()          ; INDIRECT EDGE -- not resolved
7039fb  nrf = 0 ; nrp = -1 ; errBits = 0x002
        case fromPoint && toSystem:
            if (SystemIsFriendly(owner, toSystem))  { nrp = -1; origin = fromPoint; goto CHECK }
            else { flags |= 0x100; goto FAIL }
        case toPoint:
            if (!fromSystem)                                             goto FAIL
            if (!SystemIsFriendly(owner, fromSystem)) { flags |= 0x200;  goto FAIL }
            if (!PointUsableBy(toPoint, owner))       { flags |= 0x400;  goto FAIL }
            nrp = -1; origin = fromSystem; dest = toPoint;               goto CHECK
        default (system -> system):
            if (!fromSystem || !toSystem || fromSystem == toSystem)      goto FAIL
            p = FindNodeLine(graph, owner, fromSystem, toSystem)         ; FUN_006e4eb0
            if (p != -1) { nrp = p; origin = fromSystem;                 goto CHECK }
            if (!StarFleet_HasFlagShips(fleet, 0x20000, 0)) { flags |= 0x020; goto FAIL }
            errBits = 0x040
            if (!BoreNodeLine(owner, fromSystem, toSystem, 0, fleet))    ; FUN_006e4de0
                { flags |= 0x080; goto FAIL }
            nrp = -1; origin = fromSystem;                               goto CHECK
CHECK:  if (origin && dest && !InRange(fleet, origin, dest, rangeInOut))  { flags |= errBits; goto FAIL }
        return 3                                        ; and write the route out
FAIL:   return 0                                        ; and write an EMPTY route out

[V]

  • 0x20000 is the node-bore capability bit on the fleet's cached capability mask +0xb8 (StarFleet_HasFlagShips 0x00703500, already verified from lane Z, delegating to FUN_00702d70(this, maskA, maskB, out)). It is the same bit AddShip special-cases into (fleet->galaxy)->+0x114 (combat-retreat-pipeline.md §2.4). Zuul node cruisers. [V]

  • FUN_006e4eb0 (303 B) is the only graph structure in the subtree [V]:

    if (!player || !A || !B || A->Idx(+0x5c) == B->Idx(+0x5c)) return -1
    hi = max(idxA, idxB); lo = min(idxA, idxB)
    bit = 1 << player->PlyrIdx(+0x28)
    if (!(graph->+0x24[ (hi-1)*hi/2 + lo ] & bit)) return -1     ; TRIANGULAR adjacency, one dword per pair
    walk the hash bucket (0x006905a0):
        for each entry whose {+0xc,+0x10} pair matches {idxA,idxB} in either order
            and whose +0x2c mask contains the player's bit:
                score = FUN_006e2130( (graph->+0x4)->+0x8 )      ; <-- LOOP-INVARIANT
                if (score > best) { best = score; result = entry->+0x8 }
    return result   (initialised to -1)
    

    Original defect #2, and this one is the tie-break. score depends only on graph, so it is identical for every candidate. best starts at −1, so the first matching entry in the bucket wins and every later one is discarded by score > best being false on equality. Whatever the ranking was meant to be (line length? a per-line discovery level?), as shipped FindNodeLine is "first match in hash-bucket order". A reimplementation must reproduce the bucket order to reproduce the chosen nrp, or accept that nrp is unmodelled. I did not read FUN_006e2130 or the hash function 0x006905a0. [V for the invariance, not read for the two callees]

  • InRange is FUN_006ffa00 (170 B), and it is the only place a distance decides a failure. §4 below.

2.6 Intercepting a moving fleet

FUN_00703650 (214 B, ebx = mover, esi = target, both live-in) [V]:

if (!mover || !target) return false
if (target->wpts empty) return false
if (!IsNodeWaypoint(target->wpts[0].Tp)) return false          ; target must be on a type-3 leg
if (!CanIntercept(mover, target, rangeIn)) return false        ; FUN_00703520
myLoc = (mover->LocID && mover->LocID->+0x14 == 0) ? mover->LocID : 0
if (myLoc == StarFleet_ResolveWaypoint(target))                ; I'm at the target's destination
     *out = AsSystem(NodeTransitOrigin(target));  return true  ; -> aim at where it came FROM
if (StarFleet_GetLocationIfNode(mover) == NodeTransitOrigin(target))
     *out = AsSystem(StarFleet_ResolveWaypoint(target));        return true
return true                                                     ; *out left 0

NodeTransitOrigin is FUN_006ffab0: it resolves fleet->FPlan.pnd(+0xf8) through the entity hash at (fleet->galaxy(+0x10)) + 0x80 — the same IDMap combat-retreat-pipeline.md §5 found at (S+4)+0x80. pnd is the network id of the node transit's origin object, and §6.1 shows where it is written. That is a new name for a field the save format carried unexplained. [V]

AsSystem is FUN_0071e340: return (x->+0x14 != 0) ? 0 : x. [V]

FUN_00703520 (292 B) [V] returns true when the target is not node-travelling at all (three early outs), and otherwise demands that the mover sit at one end of the target's node line and that the whole line be InRange. So the geometry is: you can only cut a node-travelling fleet off at one of the two ends of the line it is on.

Note the third return true at 0x70371d with *out still 0: an intercept can succeed while resolving no system, in which case toSystem stays whatever it was (0 for a fleet target) and the leg falls through to step D/E with no system at either end.


3. The flag word — and OrderFleetMove's three failure bits

Every bit, its site, and its meaning. All [V].

bit set at meaning fails OrderFleetMove?
0x001 0x703897 some ship in the fleet is performing a cancellable action no — warning
0x002 0x703b10 (errBits) the leg's existing node line is out of fuel range no
0x004 0x7039c5 gate traffic would exceed NGts × PrGtTrf no
0x008 0x703859 destination is a fleet on a node route and no intercept could be solved YES
0x010 0x70386d some ship's drive is destroyed (StarShip_IsGroundedByDamage) — cleared at 0x7039d3 on a gate leg YES
0x020 0x703b8b no node line between the two systems and the fleet cannot bore one no
0x040 0x703b63 (errBits) a line was bored, but the leg is still out of fuel range no
0x080 0x703b7d the node-bore attempt failed no
0x100 0x703a42 point → system, and the system is not friendly-owned no
0x200 0x703a6a system → point, and the source system is not friendly-owned no
0x400 0x7038c5, 0x703ad3 the destination point is not one this player may move to YES
0x800 0x70388a the fleet contains a ship performing action 8 no — warning

OrderFleetMove tests test DWORD PTR [ebp-0x10], 0x418 at 0x865499 — 0x400 | 0x010 | 0x008, exactly B5's three — and on a hit logs at level 2 with the .rdata string at 0x00a31e44:

StrategySim: %s (%s) move not permitted at this time.

with %s %s = the fleet's FtName(+0x5c) and the owner's name string (owner+0x40), both read through the MSVC std::string SSO test (capacity >= 0x10 → indirect). [V]

So the three "failure" bits are the three that cannot be repaired by the player pressing OK. Every other bit is either advisory (0x001, 0x800: "this cancels orders") or a route-quality complaint the engine is willing to commit anyway (0x002/0x004/0x020/0x040/0x080/0x100/0x200). The UI's flags != 0 test is what shows the whole word to the player; the server's & 0x418 is what refuses.

0x004 not being a refusal is the surprising one: a Hiver player can be ordered over gate capacity, the flag is raised, ClassifyLeg returns 0 for that leg, and OrderFleetMove still installs the plan with a type-0 waypoint. That is a testable consequence — §7.

3.1 The two per-ship masks

FUN_00700240(fleet) (80 B): true if any ship satisfies StarShip_IsGroundedByDamage (0x00815090, already verified from lane B5 — agreement recorded). → bit 0x010. [V]

FUN_006ff990(fleet) (101 B): OR of (1 << ship->+0x4c) over ships satisfying FUN_0081f880(ship), where

FUN_0081f880(ship) = ship && ship->+0x4c != 0 && ship->+0x4c != 6
                          && !(ship->+0x4c == 8 && (ship->+0x1c & 8) == 8)

ship->+0x4c is the ship's current action, and the proof is in OrderFleetMove itself: at 0x8655ed it open-codes the identical three-way predicate and calls FUN_00849280(S+4, ship, 0) — the "Ship leaving %s is still doing %s. Cancelling action." cancel that strategic-turn-internals §4.3 already names — on every ship that passes it. So bit 0x001 means "this order will cancel N ship actions" and bit 0x800 singles out action 8. Both are prompts, not refusals. [V]

(A shift by ship->+0x4c means the action enum must stay under 32; nothing bounds-checks it.)


4. Precision profile

Ordered by how much a reimplementation would suffer from getting it wrong.

1. InRange — FUN_006ffa00, the only float that decides a failure. [V]

6ffa0f  d.x = f32(B.pos.x - A.pos.x) ... d.y, d.z    ; each stored to a float32 slot
6ffa2d..6ffa44  dx*dx + dy*dy + dz*dz on the x87 stack (53-bit)
6ffa46  fstp DWORD [ebp-0x4]                         ; sumsq -> FLOAT32
6ffa4d  r = rangePtr ? *rangePtr : StarFleet_MinRange(fleet, 0.0f)
6ffa63  cap = FUN_006ff710(fleet)                    ; min over ships of Ship_MaxRange
6ffa6b..6ffa83  r = (r > cap) ? cap : r              ; both read back from FLOAT32 slots
6ffa86  fld [ebp+0x8] ; fmul st(0),st                ; r*r  -- LEFT IN THE REGISTER
6ffa8b  fld [ebp-0x4] ; fcompp                       ; compares f32(sumsq) with the 53-bit r*r
6ffa95  jp -> false                                  ; true iff sumsq <= r*r

r*r is never stored. The comparison is f32(sumsq) <= (double)r × (double)r. A reimplementation that writes sumsq <= f32(r*r) — the natural mirror of every other narrowing in this engine — will disagree exactly at the boundary, which is precisely where a fuel check lives. This is the same class of finding as lane M's rounded reciprocal, and it is the one number in this subsystem that flips a decision.

Also: the sense is <=, non-strict, established from test ah,0x41 + jp (equal ⇒ C3 alone ⇒ odd parity ⇒ jp not taken ⇒ true).

2. FUN_006ff710 (155 B) — the tank cap. Min over ships of Ship_MaxRange(ship) (0x0080c820), seeded FLT_MAX, with an early exit the moment the running min is <= 0 (fldz; fld best; fcom st(1); test ah,0x41; jnp exit) — so it is not a pure min when a zero-range ship appears before a negative one. Returns 0.0f, not FLT_MAX, for a fleet with no ships. [V]

3. The leg length — §1.5. Three f32 deltas, one narrowing on the sum, one on the sqrt, one on the subtraction. Bit-identical to a double accumulator with a single final narrowing.

4. Mars_Vec3_Length in the gate-projection test — lane M §1's two narrowings, compared <= against CstR read from a float32 field.

5. rangeLeft = max(rangeLeft, 0.0f) at the top of every leg (0x7067c3), via fcomp of the constant 0.0 against the slot: !(0.0 <= rangeLeft) ⇒ 0. NaN would clamp to 0. [V]

6. MinRange's seed and update — FLT_MAX (0x009e23a8 = 0x7f7fffff), strict best > range update, so on an exact tie the earlier ship wins and an empty fleet returns FLT_MAX + bias. Same shape as the retreat destination search's tie-break (combat-retreat-pipeline.md §2.1). [V]

fpu_cw = 0x127f throughout — 53-bit precision, round-to-nearest — as lane F measured. Nothing in this subtree changes it.


5. Why waypoint type 2 is never produced — and what it actually is

Answer: type 2 is the Liir drive. FUN_0080c7d0 maps species 3 → 2 and nothing else does. Lane O's 20+ observations were taken on "both node-drive races" — Human (0) and Zuul (5) — and FUN_0080c7d0 maps both of those to 3. Every one of those observations was forced by the table, and no amount of single- vs multi-hop, natural vs rip-bored, planned vs in-transit variation could have produced a 2. [V]

The reachability argument, in full:

  • The classifier can only return 0, driveType, 3, 4 or 5 (five ret sites, §2.3). [V]
  • driveType is a pure function of the owning player's species — no ship data, no terrain, no tech (§2.2; the one loop that reads ship data is the invariant no-op). [V]
  • The node-route branch (§2.5) is entered only when driveType == 3 and returns only 3 or 0. [V]
  • The gate branch returns only 4 or 5, and its guard is GFlags — the gate mask. [V]

So a Human or Zuul fleet can never carry a type-2 waypoint, and a Liir fleet's every straight leg is type 2. There is no bug, no unreachable path, and nothing to fix.

5.1 Correction: "node line" is the wrong name for type 2

strategic-turn-internals §4.1/§4.3 names 0x00705510 NodeLine::Step and 0x00702e20 FindNodeLines, and movement.h in the engine calls kind 2 NodeLine. On this evidence that naming is wrong, and it is what made B4's "the type-2 node-line step is wrong by construction" and lane O's "type 2 never appears" look like the same puzzle when they are not:

  • type 2 belongs to Liir, the one race with no node drive and no gates;
  • its speed profile is built from STUTTER_SYSTEM_INFLUENCE_RADIUS, STUTTER_MIN_SPEED, STUTTER_MAX_SPEED and is slowest at a system and fastest in deep space (v = speed × ((MAX−MIN)×(dist/RADIUS) + MIN), formula-gaps.md);
  • the image carries SHIP_STUTTERWARP_DEEPSPACE_MANEUVER_FACTOR as a ship key;
  • IsNodeWaypoint deliberately excludes 2 and accepts only 3 — which is now simply consistent rather than anomalous.

Type 2 is the Liir stutterwarp. FUN_00702e20 finds systems whose influence spheres the leg crosses (formula-gaps.md already reads it as a ray/sphere test against systems), not node lines. Renaming is proposed in addresses.d/lane-p2.json: Stutter_Step / FindStutterInfluenceSystems, with the old names kept as aliases in the comments so the earlier findings remain searchable. [V for the species mapping and the predicate tables; [I] for the name "stutterwarp", which rests on the constant names and the shape of the speed curve, not on a string that says so.]

5.2 What this predicts for lane M and B4

Lane M could not build a type-2 workload and correctly said so. The reason is now concrete: ref-turn2 has no Liir player. Exercising type 2 does not require a node line at all — it requires a Liir fleet moving anywhere. That is a far cheaper save to author than the one lane M was contemplating, and B4's untested NodeLineStep/BuildStutterSegments become reachable the moment such a save exists.


6. What reaches saved state

OrderFleetMove (0x008653c0) is the writer. Read in full to the next function start. [V]

bool StrategyServer::OrderFleetMove(StarFleet* f, MapObject** dests, unsigned count) {
    if (!f) return false;
    if (PosDiffersFromPointLocation(f)) {          // FUN_0080ec50: exact fucompp on all three components,
        this->vft[2]();                            //   and only when LocID->+0x14 == 2 (a POINT)
        if (PosDiffersFromPointLocation(f)) f->Pos = f->LocID->Pos;
    }
    vector<int>       types(count);                // 0x00459f70
    vector<NodeRoute> routes(count);               // 0x0085bf00
    int flags = 0;
    PathSolver(f, f, dests, count, &flags, nullptr, types.data(), routes.data());
    if (flags & 0x418) { Log(2, "StrategySim: %s (%s) move not permitted at this time.\n", ...); return false; }

    vector<Waypoint> wpts(count);                  // 0x00703e90; stride 0x1c
    for (i = 0; i < count; ++i) {
        wpts[i].Wpt(+0x4) = dests[i] ? dests[i]->id(+0x4) : 0;
        Waypoint_Set(&wpts[i], types[i], &routes[i]);      // FUN_007006e0
    }
    StarFleet::SetFlightPlan(f, wpts.data(), wpts.size(), f->LocID ? f->LocID->id(+0x4) : 0);   // 0x00707080
    for (ship in f->NShips) if (ship is acting) CancelShipAction(this, ship, 0);                // 0x00849280
    return true;
}

FUN_007006e0 is Waypoint::Set(int Tp, const NodeRoute* r): +0x8 = Tp, +0x10 = r->nrp, +0x14 = r->nrf, +0x18 = r->nrt — which pins Waypoint = {vptr, Wpt@4, Tp@8, NodeRoute nrt@0xc} at 0x1c bytes (confirmed independently by the 0x92492493 divide-by-28 at 0x865594 and the add edi,0x1c stride). Exactly struct-recovery.md §3.1. [V]

6.1 SetFlightPlan — FUN_00707080, every write

write field value
owner->+0x14c −= f->+0xc0 ServerPlayer.GTraf debited before the change if the old first waypoint was a gate transit
f->+0xc8..0xd7 FlightPlan.wpts assigned from a zeroed temp (FUN_00703cf0), then the new list inserted (FUN_00706c90)
f->+0xd8 FPsp2 0.0f from the temp, then recomputed by FUN_00705c70
f->+0xdc FPeta2 0
f->+0xe0..0xeb FPogn2 0,0,0 then f->Pos — the position the order was given from
f->+0xec..0xf7 FPdpos 0,0,0 then the first waypoint target's Pos, resolved through the IDMap at (f->galaxy)+0x80; left zero if it does not resolve
f->+0xf8 pnd 0 then the origin location's network id (f->LocID->+0x4, or 0)
f->+0xfc FtTrans wpts[0].Tp — a second, saved copy of the first leg's waypoint type
f->+0x100..0x10b FtOrig f->Pos
owner->+0x14c += f->+0xc0 GTraf re-credited if the new first waypoint is a gate transit

Field names from objects/layouts.md; every offset above is [V] from the instruction stream and every one of them is a saved field the autosave oracle sees. FtTrans = wpts[0].Tp is new — the save carries the first waypoint's type twice, once inside FPlan.wpts and once as a top-level StarFleet int. [V]

Also reaching saved state on this path, but outside SetFlightPlan:

  • StarFleet.Pos (+0x18) — the snap, but only when the fleet's location is a deep-space point. combat-retreat-pipeline.md §2.5 records the exact-IEEE comparison correctly and misses the +0x14 == 2 guard; corrected here. [V]
  • every acting ship's state, via FUN_00849280.
  • ServerPlayer.GTraf, above.

Against B5's seven-container template: this path writes four of them (FlightPlan/wpts, GTraf, StarFleet.Pos, and the new FtTrans), and adds the nrt{nrp,nrf,nrt} records, whose provenance is now known:

  • nrp = the node line's path index from FindNodeLine (entry->+0x8), or −1;
  • nrf = the leg's origin object's network id (origin->+0x4), or 0;
  • nrt = the leg's destination object's network id, or 0;
  • and all three are written only when the leg's type is 3. For every other type the saved record is {-1, 0, 0} — FUN_00703730 resets it at 0x703a85 and only then fills it in (0x703aa8..0x703bb1). [V]

That last point is a save-visible invariant: Tp != 3 ⟹ nrt == {-1,0,0}, on every waypoint of every fleet. It is checked in §7.1 — 58 waypoints, 0 failures.


7. A falsifiable prediction (rule 2)

Written before any run.

P1. In every save in verify/results/saves/, and in every autosave any later lane takes, a waypoint whose Tp is not 3 has nrt = {nrp: -1, nrf: 0, nrt: 0}, and a waypoint whose Tp is 3 has nrf and nrt equal to network ids that resolve, with nrp either −1 (a freshly bored line, or a point endpoint) or a non-negative path index.

P2. StarFleet.FtTrans equals FPlan.wpts[0].Tp for every fleet with a non-empty flight plan, and is unchanged from its previous value for every fleet with an empty one.

P3. No fleet owned by a Human or Zuul player ever carries Tp == 2; no fleet owned by a Liir player ever carries Tp == 3; Tp 4 or 5 appears only for a player whose NGts(+0x144) is non-zero.

P4 (the cheap VM test, and the one worth doing). Give a fleet a multi-hop move order whose first click is the system the fleet is already parked at — click your own system, then one or two more, then confirm. The resulting FPlan.wpts will have n entries, and the types will be shifted by one: wpts[0].Tp will be the type computed for wpts[1]'s destination, and wpts[n-1].Tp will be 0 whatever the race is. For a Human or Zuul fleet the tell is unmistakable — the last waypoint of a node route saves as Tp 0 with nrt {-1,0,0} instead of Tp 3 with a live nrp/nrf/nrt. Set n = 2 for the smallest case: wpts[0].Tp = the type of the leg to the second click, wpts[1].Tp = 0.

P1 and P2 are checkable right now, offline, against the 11 existing saves with save_reader.py — no VM, no build. P3 needs a Liir save, which is the workload §5.2 argues for anyway. P4 needs the VM, one save with a movable fleet, and about a minute; it is a predicted bug in the original, so a clean result falsifies §1.2's reading of the drop rather than merely failing to confirm it.

P4's most likely way to be wrong: the UI may refuse to place a waypoint on the fleet's own system, in which case the drop is only reachable from the AI's OrderFleetMove call sites (FUN_007a4ff0, FUN_00865780) and the test must be run against an AI turn instead. The retreat pipeline cannot reach it — it passes count = 1 with a destination the phase-1 search explicitly excludes the battle system from, and the fleet is at the battle system.

7.1 P1, P2 and a third check — run, and green

P1 and P2 were written above from the disassembly alone, then run against all 11 curated saves with verify/save-reader/save_reader.py. A third check went in at the same time, because reading §2.5 makes it obvious and it is free:

P5. For a chain of type-3 waypoints, nrf[0] == pnd, nrt[i] == Wpt[i], and nrf[i+1] == nrt[i] — because ClassifyLeg is handed (prev, cur) and writes origin->id / dest->id, and §1.4 sets prev = cur on the back edge.

plans waypoints pass fail
P1 Tp != 3 ⟹ nrt == {-1,0,0}; Tp == 3 ⟹ nrt == Wpt && nrf != 0 46 58 58 0
P2 FtTrans == wpts[0].Tp 46 — 46 0
P5 nrf[0] == pnd and nrf[i+1] == nrt[i] 46 — 46 0

Tp histogram over all 58: {3: 57, 1: 1}. Zero type 2, zero type 0, zero 4, zero 5.

What that buys, and what it does not (rule 15). The coverage is thin in exactly the way lane O's was: nine of the eleven saves are the same Zuul game at successive turns, so the 57 type-3 waypoints are far from 57 independent observations. But three things here could not have been guessed:

  1. nrt == Wpt on all 57. Two fields the save format carries separately, that no earlier lane connected, and the reason they are equal is ClassifyLeg writing dest->+0x4 into nrt while OrderFleetMove writes the same dests[i]->+0x4 into Wpt. Predicted from the instruction stream, confirmed.
  2. The nrf chain closes through pnd across the three-hop plans in zuul-turn23-fleet23.sav (80→272, 272→368, 368→288, with pnd = 80). That is §1.4's prev = cur back edge, visible in a save.
  3. nrp splits exactly where §2.5 says it should. human-turn3-noderoute.sav — Human, cannot bore — has nrp = 37, 16, 9: all non-negative, all from FindNodeLine. The Zuul saves carry a mix: 52/53/54/56 where a line already existed, and −1 where one did not. −1 is what 0x703adb writes after a successful BoreNodeLine, and Zuul are the rip-borers. Nobody looked for this; the split falls out of the branch structure and it is there.

The one non-Zuul, non-Human data point is the best of all. turn3-state.sav's single flight plan is Tp = 1 with nrt = {-1, 0, 0}. That save's players are species {0 Human ×2, 2 Tarkas ×2, 4 NPC ×4}, and FUN_0080c7d0 maps Human→3, Tarkas→1, NPC→0. Type 1 can only have come from a Tarka fleet, and the empty route record is P1 on the only non-node waypoint the campaign has ever recorded. That is a live confirmation of a second row of the drive-type table, from a save that predates this lane, on a race nobody was looking at. (The save does not carry a player handle, only PlyrIdx, so the fleet→player link is by elimination over the species present, not by direct lookup.)

Still at zero observations: types 0, 2, 4, 5 — Hiver/NPC, Liir, and both gate transits. P3 and P4 stand unrun.

How each could be wrong, and the symptom:

  1. nrt is written by more than one producer. MoveFleet's multi-waypoint continuation calls FUN_00703730 twice (0x7da5c6, 0x7da5da) and I did not read those call sites. If either writes a route into an existing waypoint, P1 breaks with a Tp != 3 waypoint carrying a live nrf. That would not falsify §2's reading of the classifier — it would mean the classifier has a second consumer that re-types a waypoint after the fact.
  2. FtTrans may have another writer. I found exactly one (SetFlightPlan), by reading, not by an image-wide displacement scan for +0xfc. Symptom: P2 fails on a fleet whose plan was not installed by OrderFleetMove.
  3. P3's Tp == 2 half rests on FUN_0080c7d0 being the only producer of a drive type. It is the only caller-visible one in this subtree, but FUN_0080c7d0 has other callers I did not enumerate. Symptom: a type-2 waypoint on a non-Liir fleet — which would be the more interesting result and should be chased, not explained away.
  4. A Hiver player with zero gates: P3's last clause predicts no type 4/5. If one appears, GFlags is set by something other than gate construction and §2.4's identification of +0xdc is wrong.

8. Corrections to earlier findings

8.1 To strategic-turn-internals.md §4.3 — waypoint type 5. "the identity of waypoint type 5 (a probabilistic jump using the player's CstE/CstT — consistent with the Zuul node-bore / Morrigi gravity casting; CstR unused here)". Type 5 is a Hiver gate throw to a system with no receiving gate, chosen at 0x7039e4 by FUN_00818040, whose radius is CstR. CstR is not unused; it is the gate-projection radius. Zuul and Morrigi cannot reach the site.

8.2 To strategic-turn-internals.md §4.1/§4.3 and the engine's WaypointKind::NodeLine — type 2. Type 2 is the Liir drive, not a node line. §5.1.

8.3 To combat-retreat-pipeline.md §2.5's OrderFleetMove line. "It snaps the fleet's position onto its current system when they differ" — it snaps onto its current point (FUN_006fe320 requires LocID->+0x14 == 2); a fleet sitting at a system is never snapped. The exact-fucompp observation stands.

8.4 To combat-retreat-pipeline.md §5's ServerSystem mask table. GFlags(+0xdc), listed as "a second presence source (not read here)", is the per-player gate mask, read by FUN_00744010.

8.5 Agreements recorded, entries dropped (rule 14). StarFleet_MinRange 0x006ff6a0, StarFleet_ResolveWaypoint 0x00701390, IsGateTransitWaypoint 0x0056e6e0, IsNodeWaypoint 0x0056e720, StarShip_IsGroundedByDamage 0x00815090, StarFleet_GetLocationIfNode 0x006fe300, StarFleet_HasFlagShips 0x00703500 and StrategyServer_OrderFleetMove 0x008653c0 all already exist with prototypes matching what I read. Eight independent re-derivations agreeing; no duplicate rows filed. StrategyServer_OrderFleetMove's existing prototype says the meaning of the three bits "is not known" — the new lane-p2.json does not re-file the address; the meaning goes in this document and in the PathSolver entry that names it.


9. What this lane did not read

  • FUN_00705d60's fold — past its first ~0x90 bytes. The polarity of its bool and the type of its result are established; the tanker redistribution that turns per-ship ranges into a fleet range is not. It is the input to every range decision in §4, so this is the largest remaining hole in the numbers.
  • FUN_006e4de0 — the node bore. Read only as a call shape ((owner, from, to, 0, fleet), cdecl, 5 args) and by its two outcomes. It creates a node line and therefore almost certainly writes saved state that this document does not list.
  • FUN_006e2130 and the bucket walk FUN_006905a0 in FindNodeLine. The invariance of the ranking term is [V]; what it computes is not read.
  • FUN_006d2050 — the relation scale. §1.6.
  • FUN_0080c820 (Ship_MaxRange), FUN_00705c70 (the speed recompute that sets FPsp2), FUN_00703cf0, FUN_00706c90, FUN_00849280 past B5's first ~60 bytes.
  • MoveFleet's two FUN_00703730 call sites (0x7da5c6, 0x7da5da) — the multi-waypoint continuation. Prediction P1's first falsification route.
  • Indirect edges. Three on the main line, none resolved: (fleet->galaxy(+0x10))->vft[1]() at 0x703a14 (the node graph getter — the single most important one, since the whole of §2.5 hangs off its result), (S+4)->vft[2]() at 0x86540e in OrderFleetMove, and the vft[2]/vft[3] pair inside FUN_00703bd0 that enumerates a node's fleets. tools/vtable_map.py and ghidra/vtable-owners.json were consulted for callers of FUN_007066c0 (two direct, both named in §1.1; the E8/E9 sweep found no tail-call thunk into it), but the three sites above are outbound edges and lane V2's inversion does not reach them.
  • The class of the kind-2 "point" object. It has Pos at +0x18, a kind tag at +0x14 and two per-player bitmasks at +0x8c / +0x90. It is not Game::StarMapNode (too small). Unresolved.

The strongest and weakest sentence in this document are the same one: the classifier's rules are read instruction by instruction and are internally consistent with FUN_0080c7d0, the two waypoint jump tables, OrderFleetMove's 0x418 mask, the UI's != 0 mask and five verified object layouts — and not one leg of it has ever been observed executing under an instrument.


10. The reimplementation

sots-engine branch wip/pathing, module src/game/nav/ (a new directory; lane N owns src/game/sim/, A2 src/app, D2 src/game/data). Clean-room: no addresses, no raw identifiers, no game data.

Modelled as pure functions returning a plan, not a mutation — the same split game/combat uses, because installing a flight plan touches saved state the module does not own:

DriveTypeOfSpecies, IsGateTransitWaypoint, IsNodeWaypoint, FleetDriveType, GateProjectionReaches, GateTransitAllowed, LegInRange (with the unstored r*r), LegLength (the five narrowings), ClassifyLeg, SolvePath, and the flag constants with kOrderRefusalMask.

Gates run as separate commands; results in the lane report.