Names the six regions (four result, two guard) that are the classifier's whole output surface, and then names every predicate the decision consumes next to which side computed it -- because the module takes those predicates as given, so reading them off the live objects is its interface rather than a shortcut. Also records that the node-graph indirect edge path-solver.md could not resolve statically is resolved DYNAMICALLY here, by making the same call from the hook.
12 KiB
The route classifier under a hook — game/nav from modelled to compared
Lane DN, 2026-09-09. Guest VM146 (sots-re-win10-146), build box CT111, own build dir
/srv/re-lab/build/sots-engine-dn, own worktree sots-engine branch wip/dn.
Hold announced: VM146 only, for the duration of this lane. VM140 is the exclusive reference guest and was not touched; VM141 (lane CZ) and VM145 (lane CR) are held by other lanes and were not touched. VM146 is released restored and hash-verified at the end of this document.
game/nav is the campaign's largest modelled module by span of behaviour and it had never
executed under an instrument. tools/displacement.py records it as
offline vs 58 waypoints / 46 flight plans across 11 saves, 0 failures — never instrumented; not one leg has executed under a hook
and path-solver.md closes with the same sentence from the other side: "the classifier's rules
are read instruction by instruction … and not one leg of it has ever been observed executing
under an instrument." This lane executes it.
0. The entry point is a real function, so the hook is on the classifier itself
The brief allowed for the classifier being inlined into its caller, in which case the honest hook
would have been on the enclosing function comparing its output. It is not inlined.
path-solver.md §0.1/§1.1/§2 read it from the instruction stream:
StarFleet_ClassifyLeg |
0x00703730, thiscall, ret 0x14, six arguments, real body 0x00703730..0x00703bc9 then 6 × int3 to the next function start |
| callers | PathSolver 0x007066c0 at two sites (the pre-flight probe and the per-leg loop), and MoveFleet's multi-waypoint continuation at 0x7da5c6 / 0x7da5da |
| output surface | the return value (the waypoint kind), *flagsOut (the flag word), and the route record's three written words at routeOut+4..+0xf. Nothing else. |
So the comparison surface is the classifier's own three outputs plus its return value, and there
is no unit boundary invented here that the binary does not have. PathSolver is hooked too, but
trace only — see §1.3.
1. Predictions (committed before the compare run, rule 2)
This section is committed in its own commit, before the instrumented build ran once.
1.1 The workload, and why it reaches two route classes
Save ar-oracle-A-pre.sav (15b99255…), turn 38, players: Human ×2 (net 16 = the human seat,
496), Tarkas ×2 (32, 512), NPC ×4 (528–576). DriveTypeOfSpecies maps Human → 3,
Tarkas → 1, NPC → 0, so three drive classes are present before the AI does anything.
Procedure, fixed and replayed identically in every run:
- load (SavedGames reset to exactly one file, row 1), lobby,
Launch; Move, order Delta Fleet → Isis. The game answers with its own prompt — "Delta Fleet does not have the range to reach Isis using nodes. Do you want to plot a sublight course?" — andOKcommits it. This is the sublight-crawl class.Move, order Delta Fleet → Venkman. Hover reads "Range: 6.0 / ETA: 2 Turns", there is no prompt, the order is committed straight away. This is the node-route class.End Turn.
The hover readout named the class before either click, exactly as the campaign's guest notes say:
Venkman ETA: 2 Turns (node), Isis ETA: 50 Turns in red (sublight); Nu Ophiuchi 4 Turns,
Kua'Taak 47, Kam'Tara 53, Kao'Lolto 50, Ko'Kotan 47. The state was built deliberately
from that readout rather than hoped for.
1.2 Falsifiable predictions
P1 — call count from the two player orders: 8. Each accepted order costs two
PathSolvercalls (the UI's dry runFUN_005e6d50, then the server'sOrderFleetMove), and eachPathSolvercall on a one-destination list costs twoClassifyLegcalls (the pre-flight probe againstMinRange, then the loop leg). Two orders × 2 × 2 = 8, with 4PathSolverrecords.countis 1 on all four and the leading-destination drop does not fire (neither destination is the fleet or the fleet's own system). Falsified by: a different multiple of 2 — most likely because hovering also runs the dry run, which would add calls in pairs and is worth knowing either way.P2 — the Isis leg raises
0x002, not0x020. The game's own prompt says the fleet cannot reach Isis "using nodes", which iserrBitsseeded at0x00703a0dand ORed at0x00703b10: a node line exists and is beyond the fuel budget. So on that callline_found >= 0, the returned kind is 0, and the flag word is0x002. Falsified by:line_found == -1on that call, which would make the flag0x020and the prompt string generic rather than diagnostic.P3 — the Venkman leg returns kind 3 with a live route.
line_found >= 0, return value 3,nrp == line_found,nrf ==Epsilon Eridani's network id,nrt ==Venkman's, flags0.P4 — no Human or Zuul call returns 1, 2 or 6; no Tarkas call returns 3; every Tarkas call leaves the route record at
{-1,0,0}and performs no range check. This isDriveTypeOfSpeciesand step E of the decision order, live. The record carries the original's ownStarFleet_GetDriveTypenext to the species, so the table is checkable rather than assumed.P5 —
can_bore == 0on every call, so the bore arm is reached 0 times. No Zuul player, and the0x20000capability is the Zuul node cruiser. This is the declared gap that makes itself inert, and the trace proves it instead of the brief asserting it (rule 28 practice 1).P6 —
gate_cap == 0on every call, so no call returns kind 4 or 5 and flag0x004never appears. Every player in every corpus save hasNGts == 0. Kinds 4 and 5 stay at zero observations and this lane says so rather than quietly counting a clean sheet as coverage.P7 — 0 divergences, and the reason is the narrowings already in the module. The
MoveFleetlane's eight live divergences were all float32 narrowings;game/navalready carries the five ofLegLengthand the unstoredr*rofLegInRange. I predict the compare comes back clean, and I predict that a boundary fuel case is the only thing in this subsystem that could break it — which this workload is not built to hit. Falsification shapes, each with a distinct symptom:
- kind differs on a Tarkas/NPC call → the drive table is wrong (visible directly against
drive_orig);- flags differ on
0x001/0x800→ the pending-action-mask split is wrong;- flags differ between
0x002and0x020→LegInRangedisagrees off a boundary, i.e. the range or the tank-capacity input is wrong, not the arithmetic;nrf/nrtdiffer → the origin/destination identification in the node arm is wrong;- the
fleetguard fires → the classifier writes the moving fleet and the module's purity claim is false.P8 — neutrality holds. The compare run's
(Autosave EndTurn)and(Autosave)are byte-identical to the control's. If they are not, every number in this lane is void (rule 19).P9 — at least 8 distinct input states. A count of calls is not coverage (rule 23); this is the number the verdict has to stand on.
1.3 What is deliberately not compared
PathSolveristraceonly, and itsoursthrows if anyone sets it tocompare. The plan needs the fleet's effective range, which comes fromFUN_00705d60's tanker-redistribution fold — a functionpath-solver.md§9 explicitly did not read. A hook that returned "no plan" there would print a green verdict over a missing input, which is rule 1's failure mode.nrpon an existing line is an input, not a result. The single-hop answer is taken from the original's ownNodeGraph_FindNodeLinebefore the original runs (afterwards it could return a line the original itself had just bored). Its agreement is therefore not evidence; the kind, the flags andnrf/nrtare.- Three input gaps, counted per call rather than argued away: the intercept solve for a
destination fleet already on a node leg (
intercept_gap), the node bore (bore_arm), and the unread friendliness testFUN_00817890on the point↔system arms (friendly_recon).
2. The instrument
Engine branch wip/dn, commit 17d635b, two new hook descriptors in
src/shim/hooks/path_classify.{h,cpp} over a pure, host-buildable adapter in
src/shim/hooks/path_inputs.{h,cpp}.
2.1 What is declared
| region | kind | what |
|---|---|---|
| return value | result | the waypoint kind, diffed by the template |
range |
result | *rangeInOut — an IN parameter despite its name (PathSolver does the draw-down itself), declared so that the original not writing it is checked rather than assumed |
flags |
result | *flagsOut |
route |
result | routeOut+4..+0xf — nrp, nrf, nrt, the only three words the original writes |
route_obj |
guard | the whole 0x10-byte NodeRoute, so a vptr write would show |
fleet |
guard | the whole 0x120-byte StarFleet. The classifier is supposed to be pure with respect to the moving fleet; this is the region that would say otherwise |
That is the function's entire output surface. It returns a kind, ORs a flag word and fills a route; there is nothing else to declare.
2.2 Where the inputs come from, one by one
The module takes its predicates as given — FleetState holds anyShipGrounded,
canBoreNodeLines, tankCapacity and the rest as plain booleans and scalars — so reading them
off the live objects is not a shortcut, it is the module's own interface. Named here so a
reader can see which side computed what:
| input | source | ours or theirs |
|---|---|---|
species, ship count, gate cost, gate traffic used/capacity, CstR |
field reads | facts |
| endpoint kind / id / position / system index | field reads | facts |
| "we have a gate here" | (GFlags >> PlyrIdx) & 1, computed in the hook |
ours |
| point visible / known, and the species-4 bypass | the two masks at +0x8c/+0x90, in the hook |
ours |
| "the destination fleet is on a node leg" | its front waypoint's type, in the hook | ours |
| tank capacity | StarFleet_MinTankCapacity |
theirs (a pure reader) |
| any ship grounded | StarFleet_AnyShipGroundedByDamage |
theirs |
| pending action mask | StarFleet_PendingShipActionMask |
theirs; the 0x100 split into 0x800 vs 0x001 is ours |
| can bore | StarFleet_HasFlagShips(fleet, 0x20000, 0) |
theirs |
| the single-hop node lookup | NodeGraph_FindNodeLine, called before the original runs |
theirs — so nrp on an existing line is an input, not a result |
| the drive type | recorded from StarFleet_GetDriveType but not fed to ours |
ours computes it from species; theirs is the check |
The node graph itself comes through (fleet->galaxy(+0x10))->vft[1]() — the indirect edge
path-solver.md §9 lists as "the single most important one" of three it could not resolve
statically. This lane resolves it dynamically instead, by making the same call from the
hook and validating the object before using it. That is recorded as
Galaxy_vft_off_GetNodeGraph in ghidra/addresses.d/dn.json.
2.3 Why BoreLine can never fire from our side
ClassifyLeg's node arm writes when it bores: FUN_006e4de0 creates a node line in the live
graph. Our NodeGraph::BoreLine therefore returns false unconditionally and sets a flag, and
the descriptor reports the call as reaching an uncompared arm. Two consequences worth stating:
a compare in which the bore arm fires is not evidence either way, and a replace mode on
this hook is refused outright (it would also have to solve an intercept it cannot).