# M — the `MoveFleet` position rounding (2026-09-08) The first arithmetic divergence the behavioural compare found **on its own**. Everything of this kind before it (`ApplyTechEffect`'s early return, the RNG divisor) was found by reading the instruction stream and then confirmed; this one was found by widening a workload. `docs/R-recapture.md` reported 8 of 45 live `StrategyServer::MoveFleet` calls diverging by one ULP on a position component. The cause is below, read off the binary rather than fitted. ## 1. The mechanism: four float32 narrowings, not one The original does **not** compute a direction in double and round the result. Every `Vec3` in the engine is three float32 words, and its normalise helper stores to a 4-byte slot and reloads it four separate times: | # | step | held as | |---|---|---| | 0 | `delta.c = dest.c - pos.c`, computed on the x87 stack | **stored to float32** before the helper is called | | 1 | `sumsq = x*x + y*y + z*z` — three products, two adds, all in 53-bit registers | **stored to float32** | | 2 | `len = sqrt(sumsq)` | **stored to float32** | | 3 | `inv = 1.0 / len` — a **reciprocal**, then multiplied through | **stored to float32** | | 4 | `dir.c = delta.c * inv` | **stored to float32** (its own word) | and the helper *returns* `len` — the same float32 — so the leg distance is never recomputed in a wider precision either. The position tail is then two roundings per component and no more: `tmp.c = float32(dir.c * move)`, `pos.c = float32(pos.c + tmp.c)`. What we had instead: ```cpp const Vec3 delta = Sub(dest, pos); // exact difference, double const double len = std::sqrt(Dot(delta, delta)); // double sumsq, double root const Vec3 dir{delta.x / len, delta.y / len, delta.z / len}; // three double DIVIDES ``` Three of the five narrowings missing, plus a divide where the original multiplies by a rounded reciprocal. The position tail (`F32(pos + F32(dir*move))`) was already right, which is exactly 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 is. The 64-ULP case in the report is that same 1.2e-7 landing on a result near zero. Which narrowing mattered is not uniform, which is worth knowing before anyone "simplifies" this back: on the fleet-50 legs it is the **delta** narrowing that decides the answer (an exact delta with float32 length and reciprocal still reproduces the *old* wrong value), while on one fleet-34 leg it is the **reciprocal-vs-divide** that decides it. Only all five together reproduce the original. `ResolveMoveStep`'s arrival test is an exact float comparison (`move == distance`), so the distance it is capped against has to be the same float32 the normalise produced. Feeding it a double-precision distance made `arrived` a coincidence rather than an identity; that is fixed by the hook taking both the direction and the distance from one `StraightLeg` call, which is literally what the original does with one call. One original bug worth recording: on the zero-length branch the helper zeroes the output and returns with an **empty x87 stack** — no return value at all, so the caller's `fstp` for the distance underflows. Only reachable on a degenerate zero-length leg; `NormalizeVec3` returns 0 there and says so in a comment. ## 2. What changed * `sim::NormalizeVec3` — new, the helper reproduced narrowing for narrowing. * `sim::StraightLeg` / `sim::StraightLegDistance` — the delta narrowed to float32 and then normalised; one call yields direction *and* distance. * `sim::AdvanceAlongUnitDirection` — the position tail on its own. * `sim::AdvanceAlongDirection` — now built on the above; same signature, same callers. * `movement_inputs.cpp` — one `StraightLeg` call replaces `Distance` + `AdvanceAlongDirection`. * `sim::Distance` is **unchanged and still double**. It is now used only by the node-line and stutter geometry, which almost certainly needs the same treatment — but waypoint types 2-5 have no behavioural coverage at all, so there is nothing to correct it against. The header says so at the declaration. Host tests pin **float32 bit patterns**, not tolerances: `test_normalise_precision` isolates each of the four narrowings (a vector whose sum of squares rounds to 1.0f, a `sqrt(2)` that differs in float32, a reciprocal that lands on a different float32 than the divide, the epsilon branch, and a delta whose float32 rounding matters), and `test_advance_bit_exact` pins four independent legs component by component. A `CHECK_NEAR` would have passed against the broken arithmetic. ## 3. Live result Same VM, same save, same five End Turns, same `shim.cfg.recapmisc`, twice — once with the unchanged build as a control, once with the fix: | 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 reproduced lane R's eight divergent `call_id`s exactly (42, 79, 80, 116, 118, 154, 156, 157), so the before/after is a controlled comparison on this lane's own runs and not a comparison against someone else's report. ## 4. 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.** Waypoint 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 is still **knowingly wrong**: `NodeLineStep` / `BuildStutterSegments` exist and are unit-tested but are not wired into the hook, which steps every waypoint type as `speed x dt`. * The two arrivals in the run compare clean and still mean nothing — no arrival machinery is declared. * 42 undeclared writes in 15 calls, unchanged by this work. **Types 2-5 were attempted and could not be reached.** The only player whose fleets move in this workload is the AI, and every one of its moves is a straight run. The player whose travel would produce a node-line waypoint has **no ships at all** on this save — its fleet-order buttons are greyed out on every turn — so reaching a node-line move means building a ship over several turns first. That is a separate piece of work and it drifts the save away from the reference. Types 2, 3, 4 and 5 remain at zero behavioural coverage; the cheapest way to close them is a purpose-built save that starts with a fleet in orbit next to a node line. So: the straight-run position update is now bit-exact against the original on every straight leg the reference workload produces, and that is the whole claim.