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.)
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/BuildStutterSegmentsare written and unit-tested but not wired in; the hook steps every waypoint type asspeed x dt. sim::Distanceis deliberately left in double. It is now used only by the node-line / stutter geometry, which on the evidence ofMars_Vec3_Lengthalmost 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.