# `MoveFleet` position rounding — the precision sequence, read off the binary (lane M, 2026-09-08) Lane R's guarded recapture found 8 of 45 live `StrategyServer::MoveFleet` calls diverging by one ULP on a position component — the first arithmetic divergence the **behavioural** compare caught on its own. This is the mechanism, read out of the instruction stream, and the live before/after. Ghidra work is written back: `Mars_Vec3_Normalize` @ `0x00422520`, `Mars_Vec3_Length` @ `0x004224b0`, `Mars_Vec3_LengthSquared` @ `0x004224f0` are named and prototyped with plate comments, and two plate comments sit on the `MoveFleet` sites at `0x007da0f2` (the leg) and `0x007da2ac` (the position update). Six new `ghidra/addresses.json` entries; header regenerated with `tools/gen_addresses.py`. ## 1. `Mars_Vec3_Normalize` (`0x00422520`) — `float (Vec3* out, const Vec3* in)`, cdecl 123 callers. It normalises **and returns the length**, and it narrows to float32 **four** separate times. Reading the FPU widths in order: ``` fld [in+4] fld [in] fld [in+8] ; y, x, z onto the x87 stack fld st(1) fmulp st(2),st ; x*x fld st(2) fmulp st(3),st ; y*y fxch st(1) faddp st(2),st ; x*x + y*y fmul st(0),st faddp st(1),st ; + z*z <- all still 53-bit fstp DWORD PTR [ebp+0xc] ; (1) sumsq -> FLOAT32 fld DWORD PTR [ebp+0xc] call 0x924f52 ; sqrt fstp DWORD PTR [ebp+0xc] ; (2) len -> FLOAT32 fld DWORD PTR [ebp+0xc] fld DWORD PTR ds:0x9e1ef8 ; eps = 0x34000000 = 2^-23 fcom ... test ah,0x41 ... jne ; !(len > eps) -> zero fld st(0) fld1 fdivrp st(1),st ; 1.0 / len <- a RECIPROCAL fstp DWORD PTR [ebp+0xc] ; (3) inv -> FLOAT32 fld [out] fld [ebp+0xc] fld st(0) fmulp st(2),st fxch st(1) fstp DWORD PTR [out] ; (4) out.x -> FLOAT32 fld [out+4] fmul st,st(1) fstp DWORD PTR [out+4] ; out.y -> FLOAT32 fmul [out+8] fstp DWORD PTR [out+8] ; out.z -> FLOAT32 ret ; st0 = len (the float32 one) ``` so the contract is ``` sumsq = f32(x*x + y*y + z*z) products and adds in 53-bit, only the SUM stored len = f32(sqrt(sumsq)) if (!(len > 2^-23)) { out = {0,0,0}; return; } <-- see the bug below inv = f32(1.0 / len) a reciprocal, MULTIPLIED through, not three divides out.c = f32(in.c * inv) return len ``` **Original bug worth recording:** the zero branch at `0x004225a6` does `fldz`, three stores of which the last one pops, and returns with an **empty x87 stack**. There is no return value, so the caller's `fstp` for the distance underflows. Only reachable on a zero-length vector. `Mars_Vec3_Length` (`0x004224b0`) is the same first half — `f32(sqrt(f32(sumsq)))` — and `Mars_Vec3_LengthSquared` (`0x004224f0`) is `f32(sumsq)`. So **every** vector length in this engine is float32-narrowed twice. ## 2. `MoveFleet`'s straight leg (`0x007da0f2`) and position update (`0x007da2ac`) The leg: ``` edi = waypoint destination ; esi = fleet, ebx = &fleet.pos (fleet+0x18) fld [edi+0x18] fsub [esi+0x18] fstp DWORD PTR [ebp+0x8] ; delta.x -> FLOAT32 fld [edi+0x4] fsub [ebx+0x4] fstp DWORD PTR [ebp-0x20] ; delta.y -> FLOAT32 fld [edi+0x8] fsub [ebx+0x8] fstp DWORD PTR [ebp-0x1c] ; delta.z -> FLOAT32 ... copied into [ebp-0x38]/[ebp-0x34]/[ebp-0x30] ... call 0x422520 ; Normalize(&v, &v), in place fstp DWORD PTR [ebp-0x18] ; = the leg DISTANCE, never recomputed ``` so the delta a reimplementation normalises is `f32(dest.c - pos.c)`, **not** the exact difference, and the distance and the direction come from the same single call. The position update, on the `move != distance` branch: ``` fld [ebp-0x38] fmul st,st(1) fstp DWORD PTR [ebp+0x8] ; f32(dir.x * move) fld [ebp-0x34] fmul st,st(1) fstp DWORD PTR [ebp-0x2c] ; f32(dir.y * move) fmul [ebp-0x30] fstp DWORD PTR [ebp-0x30] ; f32(dir.z * move) fld [ebp+0x8] fadd [ebx] fstp DWORD PTR [ebx] ; f32(pos.x + tmp) fld [ebp-0x2c] fadd [ebx+4] fstp DWORD PTR [ebx+4] fld [ebp-0x30] fadd [ebx+8] fstp DWORD PTR [ebx+8] ``` `move` is itself reloaded from a float32 slot (`[ebp-0x20]`). The sibling branch (`move == distance`, an **exact** float compare via `fucomp` + `test ah,0x44` + `jp`) copies the destination's three words verbatim with `mov`, so an arrival never *steps* onto its destination. ## 3. What `ours` was doing, and why the error looked the way it did `sim::AdvanceAlongDirection` computed the exact double delta, a double sum of squares, a double `sqrt`, and three double **divides**. Three of the five narrowings missing plus a divide where the original multiplies by a rounded reciprocal. The position tail was already right — which is precisely why the error was a *constant absolute* ~1.2e-7 rather than a formula error: a direction wrong in its last float32 bit, scaled by a step of 2, is 2 x half-an-ULP-of-1 ~= 1.2e-7 wherever the fleet happens to be. The 64-ULP entry in lane R's table is that same 1.2e-7 landing on a result near zero. Which narrowing decides the answer is **not uniform** — worth knowing before anyone "simplifies" it back: | leg | exact delta + f32 len/inv/dir | f32 delta + f32 len, but a divide | all five | |---|---|---|---| | fleet 50, calls 42/79/116 | reproduces the **wrong** value | reproduces the original | reproduces the original | | fleet 34, call 6 | reproduces the original | **neither** value | reproduces the original | ## 4. Offline confirmation before touching the game Two of the run's fleets have a recoverable destination: an arrival copies the destination verbatim, so `pos.after` on an arriving call **is** the waypoint destination. Fleet 34 arrives on call 115 and fleet 50 on call 155, which gives exact float32 destinations for two fleets and therefore eight fully determined legs (fleet 34 calls 6/41/78/115, fleet 50 calls 42/79/116/155) — including all three of fleet 50's divergent calls and the 64-ULP one. The five-narrowing model reproduces the **original** bit-for-bit on all eight, and the old double model reproduces `ours` exactly on the three divergent ones. That settled the mechanism before a single line was rebuilt. ## 5. Live before/after Same VM, same save, same five End Turns, same `shim.cfg.recapmisc`, run twice by this lane: | build | calls | compared | diverged | `tracecmp` exit | |---|---|---|---|---| | `recap-7584bad-20260908T0615Z` (control) | 45 | 45 | **8** | 1 | | `mf-45bdf7d-dirty-20260908T0721Z` (fixed) | 45 | 45 | **0** | 0 | The control run reproduced lane R's eight divergent `call_id`s exactly (42, 79, 80, 116, 118, 154, 156, 157), so this is a controlled before/after on one lane's own runs rather than a comparison against someone else's report. Artefacts: `verify/traces/mf-{before,after}-compare.jsonl`, `verify/results/compare/mf-{before,after}.{json,md}`, `verify/results/shim/mf-{before,after}-shim.log`. ## 6. Coverage — read this before quoting the zero * **15 of the 45 calls move**; the other 30 are six waypointless fleets taking the early out. * **All 15 moving calls are the same straight-run waypoint type.** Types 2 (node line), 3 (node route), 4 (gate teleport) and 5 (probabilistic jump) **never occurred**, so this result says nothing about them and the generator never moved in this hook. * The node-line step remains **knowingly wrong**: `NodeLineStep` / `BuildStutterSegments` are written and unit-tested but not wired in; the hook steps every waypoint type as `speed x dt`. * `sim::Distance` is deliberately left in double. It is now used only by the node-line / stutter geometry, which on the evidence of `Mars_Vec3_Length` almost certainly needs the same two narrowings — but with zero behavioural coverage on those paths there is nothing to correct it against. Flagged at its declaration in the engine header. * The two arrivals still compare clean and still mean nothing; no arrival machinery is declared. ## 7. Waypoint types 2-5: attempted and NOT achieved — and why I held the VM and tried to construct a node-line move. **I could not, and it is not a matter of finding the right click.** `ref-turn2` has two players. The AI (`Fane Lao`) is the only one whose fleets move in the whole workload — all 15 moving calls are its fleets, all straight-run. The human-controlled player `re` is the one whose travel would produce a node-line waypoint, and on turn 7 of this run its home system Gamma Cephei reports **`DE 00 CR 00 DN 00`** — zero destroyers, zero cruisers, zero dreadnoughts (`verify/results/shim/mf-human-home-no-ships.png`). `Manage Fleets`, `Move` and `Special` are greyed out on every turn because there is no fleet to give an order to. The node lines themselves are drawn on the map; there is simply nothing to send along one. So exercising waypoint types 2-5 on this save requires **building a ship first** — a build-queue order, several End Turns for it to complete, then a move order — which is a multi-turn UI exercise and a different piece of work from this one. It also drifts the save further from the reference, which lane R already warned is only reproducible on its first End Turn. I stopped rather than leave the lab in a worse state. **Types 2 (node line), 3 (node route), 4 (gate teleport) and 5 (probabilistic jump) therefore remain at zero behavioural coverage**, and the node-line step is still wrong by construction (`NodeLineStep` / `BuildStutterSegments` exist and are unit-tested but are not wired into the hook, which steps every waypoint type as `speed x dt`). The next lane that wants them should plan on either building a fleet on `ref-turn2` or authoring a new save that starts with a human fleet already in orbit next to a node line — the latter is much cheaper.