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.
189 lines
12 KiB
Markdown
189 lines
12 KiB
Markdown
# 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:
|
||
|
||
1. load (SavedGames reset to exactly one file, row 1), lobby, `Launch`;
|
||
2. `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?"* — and `OK` commits it. **This is the sublight-crawl class.**
|
||
3. `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.**
|
||
4. `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**
|
||
> `PathSolver` calls (the UI's dry run `FUN_005e6d50`, then the server's `OrderFleetMove`), and
|
||
> each `PathSolver` call on a one-destination list costs **two** `ClassifyLeg` calls (the
|
||
> pre-flight probe against `MinRange`, then the loop leg). Two orders × 2 × 2 = **8**, with
|
||
> **4** `PathSolver` records. `count` is 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`, not `0x020`.** The game's own prompt says the fleet cannot
|
||
> reach Isis **"using nodes"**, which is `errBits` seeded at `0x00703a0d` and ORed at
|
||
> `0x00703b10`: a node line **exists** and is beyond the fuel budget. So on that call
|
||
> `line_found >= 0`, the returned kind is **0**, and the flag word is `0x002`.
|
||
> *Falsified by:* `line_found == -1` on that call, which would make the flag `0x020` and 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, flags `0`.
|
||
>
|
||
> **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 is
|
||
> `DriveTypeOfSpecies` and step E of the decision order, live. The record carries the original's
|
||
> own `StarFleet_GetDriveType` next to the species, so the table is checkable rather than assumed.
|
||
>
|
||
> **P5 — `can_bore == 0` on every call, so the bore arm is reached 0 times.** No Zuul player, and
|
||
> the `0x20000` capability 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 == 0` on every call, so no call returns kind 4 or 5 and flag `0x004` never
|
||
> appears.** Every player in every corpus save has `NGts == 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 `MoveFleet`
|
||
> lane's eight live divergences were all float32 narrowings; `game/nav` already carries the five
|
||
> of `LegLength` and the unstored `r*r` of `LegInRange`. 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 `0x002` and `0x020` → `LegInRange` disagrees off a boundary, i.e. the
|
||
> range or the tank-capacity input is wrong, not the arithmetic;
|
||
> * `nrf` / `nrt` differ → the origin/destination identification in the node arm is wrong;
|
||
> * the `fleet` guard 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
|
||
|
||
* **`PathSolver` is `trace` only, and its `ours` throws if anyone sets it to `compare`.** The plan
|
||
needs the fleet's effective range, which comes from `FUN_00705d60`'s tanker-redistribution fold
|
||
— a function `path-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.
|
||
* **`nrp` on an existing line is an input, not a result.** The single-hop answer is taken from the
|
||
original's own `NodeGraph_FindNodeLine` **before 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 and `nrf`/`nrt` are.
|
||
* **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 test `FUN_00817890` on 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).
|