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

179 lines
9.8 KiB
Markdown

# `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_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.