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.)
179 lines
9.8 KiB
Markdown
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.
|