sots-re/findings/subsystems/movefleet-position-rounding.md
alex f5b37c2b34 lane M: MoveFleet position rounding closed; VM140 released; types 2-5 still uncovered
The mechanism, read off the instruction stream rather than fitted: Mars_Vec3_Normalize
(0x00422520, 123 callers) narrows to float32 four separate times, and MoveFleet stores
each dest.c - pos.c back to a float32 slot before calling it and takes the leg distance
from that same call's return value. Live 8/45 -> 0/45 with a control run of the unchanged
build, identical inputs and identical original outputs on all 45 calls.

Board: the two MoveFleet rows go verified; VM140 exclusivity back to FREE with two new
click-path gotchas; a new backlog row for waypoint types 2-5, which this lane attempted
and could not reach - ref-turn2 structurally cannot produce a node-line move because the
only player that would travel one has no ships.

(Most of this lane's files were swept into 9d385a7 by another lane's `git add -A` on the
shared repo; this commit carries what was left.)
2026-09-08 03:51:39 -04:00

9.8 KiB

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  <zero branch>  ; !(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_ids 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.