diff --git a/findings/subsystems/nav-classifier-live.md b/findings/subsystems/nav-classifier-live.md new file mode 100644 index 0000000..9dd24f8 --- /dev/null +++ b/findings/subsystems/nav-classifier-live.md @@ -0,0 +1,132 @@ +# 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`). diff --git a/ghidra/addresses.d/dn.json b/ghidra/addresses.d/dn.json new file mode 100644 index 0000000..7ff63c8 --- /dev/null +++ b/ghidra/addresses.d/dn.json @@ -0,0 +1,87 @@ +{ "entries": [ + +{ "name": "MapObject_off_Id", + "offset": "0x00000004", + "convention": "offset", + "prototype": "int Id -- the network handle of a map object. This is what OrderFleetMove writes into Waypoint.Wpt(+0x4) (dests[i]->+0x4) and what ClassifyLeg writes into the route record's nrf/nrt, which is why nrt == Wpt on every type-3 waypoint in the corpus", + "status": "verified", + "source": "findings/subsystems/path-solver.md" }, + +{ "name": "MapObject_off_Kind", + "offset": "0x00000014", + "convention": "offset", + "prototype": "int Kind -- the map object's shape tag: 0 system, 1 fleet, 2 deep-space point. Read by MapObject_GetOwner 0x0071e280, MapObject_AsSystem 0x0071e340, StarFleet_GetLocationIfNode 0x006fe300, PathSolver's refuel test and ClassifyLeg's endpoint classification, so the three values are established from four independent sites", + "status": "verified", + "source": "findings/subsystems/path-solver.md" }, + +{ "name": "MapObject_off_Pos", + "offset": "0x00000018", + "convention": "offset", + "prototype": "float Pos[3] at +0x18/+0x1c/+0x20 -- the position every leg length and every range check reads. PathSolver's inlined Mars_Vec3_Length loads exactly these three slots (fld [edi+0x18] / +0x1c / +0x20 at 0x00706844)", + "status": "verified", + "source": "findings/subsystems/path-solver.md" }, + +{ "name": "MapObject_off_PointVisibleMask", + "offset": "0x0000008c", + "convention": "offset", + "prototype": "uint per-player bitmask on a kind-2 (deep-space point) object; bit PlyrIdx set = the point is visible to that player. The first of the two masks ClassifyLeg tests before raising 0x400; either mask alone permits the move, and a species-4 fleet bypasses both", + "status": "verified", + "source": "findings/subsystems/path-solver.md" }, + +{ "name": "MapObject_off_PointKnownMask", + "offset": "0x00000090", + "convention": "offset", + "prototype": "uint per-player bitmask on a kind-2 (deep-space point) object; the second of the pair at +0x8c/+0x90. Set = the point is known to that player", + "status": "verified", + "source": "findings/subsystems/path-solver.md" }, + +{ "name": "ServerSystem_off_GFlags", + "offset": "0x000000dc", + "convention": "offset", + "prototype": "uint GFlags -- the PER-PLAYER GATE MASK, read as (GFlags >> p->PlyrIdx(+0x28)) & 1 by ServerSystem_HasGate 0x00744010. combat-retreat-pipeline.md lists +0xdc only as 'a second presence source (not read here)'; the whole waypoint-type-4/5 branch of ClassifyLeg is built on it", + "status": "verified", + "source": "findings/subsystems/path-solver.md" }, + +{ "name": "ServerPlayer_off_NGts", + "offset": "0x00000144", + "convention": "offset", + "prototype": "int NGts -- the player's gate count. Multiplied by PrGtTrf(+0x148) to give the gate-traffic capacity (ServerPlayer_GateTrafficCapacity 0x0080dc50), which GTraf(+0x14c) plus the moving fleet's own int16 cost is tested against. NGts == 0 on every player of every corpus save, so waypoint kinds 4 and 5 have never been produced", + "status": "verified", + "source": "findings/subsystems/path-solver.md" }, + +{ "name": "NodeRoute_off_PathIndex", + "offset": "0x00000004", + "convention": "offset", + "prototype": "int nrp -- the node line's path index inside a NodeRoute {vptr, nrp@+4, nrf@+8, nrt@+0xc}, sizeof 0x10. Constructed to -1 by NodeRoute_Construct 0x006e1b20; ClassifyLeg leaves -1 for a freshly bored line or a point endpoint and writes FindNodeLine's entry->+0x8 otherwise. Copied to Waypoint+0x10 by FlightPlan_Waypoint_Set 0x007006e0", + "status": "verified", + "source": "findings/subsystems/path-solver.md" }, + +{ "name": "NodeRoute_off_FromId", + "offset": "0x00000008", + "convention": "offset", + "prototype": "int nrf -- the leg ORIGIN object's network id (origin->+0x4), written only when the leg's kind is 3. Copied to Waypoint+0x14. Chains across a multi-hop plan: nrf[0] == FlightPlan.pnd and nrf[i+1] == nrt[i], checked green on 46 of 46 corpus flight plans", + "status": "verified", + "source": "findings/subsystems/path-solver.md" }, + +{ "name": "NodeRoute_off_ToId", + "offset": "0x0000000c", + "convention": "offset", + "prototype": "int nrt -- the leg DESTINATION object's network id (dest->+0x4), written only when the leg's kind is 3. Copied to Waypoint+0x18, and equal to that waypoint's own Wpt(+0x4) on all 58 corpus waypoints", + "status": "verified", + "source": "findings/subsystems/path-solver.md" }, + +{ "name": "NodeRoute_sizeof", + "offset": "0x00000010", + "convention": "offset", + "prototype": "sizeof(NodeRoute) -- {vptr, int nrp, int nrf, int nrt}. Pinned by PathSolver's routesOut stride (add esi,0x10 at 0x007068b2) and by the Waypoint layout {vptr, Wpt@+4, Tp@+8, NodeRoute@+0xc} summing to the 0x1c stride", + "status": "verified", + "source": "findings/subsystems/path-solver.md" }, + +{ "name": "Galaxy_vft_off_GetNodeGraph", + "offset": "0x00000004", + "convention": "offset", + "prototype": "byte offset of vtable slot 1 on the object at StarFleet+0x10 (the 'galaxy'). ClassifyLeg's node-route branch obtains the node-line graph with `(fleet->galaxy(+0x10))->vft[1]()` at 0x00703a14 -- a thiscall through slot 1, taking no stack arguments and returning the NodeGraph* that NodeGraph_FindNodeLine 0x006e4eb0 is called on. Lane P2 left this indirect edge unresolved by static reading; lane DN resolves it dynamically instead, by making the same call from the hook", + "status": "verified", + "source": "findings/subsystems/nav-classifier-live.md" } + +] }