merge lane M: MoveFleet position rounding - five float32 narrowings + reciprocal-multiply; live 8 divergences -> 0

This commit is contained in:
alex 2026-09-08 03:52:18 -04:00
commit 41547dee89
6 changed files with 350 additions and 10 deletions

116
docs/M-movefleet.md Normal file
View file

@ -0,0 +1,116 @@
# 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.

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ e0c3e64, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ 1b893e5, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
@ -767,5 +767,17 @@ constexpr uint32_t g_ptr_EVENTMSG_ADDICTION_TEMPERENCE = 0x006f0a94;
constexpr uint32_t g_dbl_ResearchUnderbudgetThreshold = 0x005e20c8;
// data double 0.0 -- the research completion draw is NextFloat()*(1.0-this)+this, so it is a plain NextFloat() [verified]
constexpr uint32_t g_dbl_ResearchRollBias = 0x005e1e68;
// cdecl float (Vec3* out, const Vec3* in) /* normalises in-place-capable (MoveFleet passes the same pointer twice) and RETURNS THE LENGTH. FOUR float32 narrowings in this order: sumsq = float32(x*x+y*y+z*z) (products/adds stay in the x87 53-bit registers, only the sum is stored to a dword and reloaded); len = float32(sqrt(sumsq)); inv = float32(1.0/len) -- a RECIPROCAL, stored to a dword and reloaded, then MULTIPLIED through, NOT three divides; out.c = float32(in.c * inv). Zero branch: !(len > 2^-23, the float at 0x009e1ef8) => out = {0,0,0} and it returns with an EMPTY x87 stack, i.e. no return value at all (original bug, only reachable on a zero-length vector). 123 callers. */ [verified]
constexpr uint32_t Mars_Vec3_Normalize = 0x00022520;
// cdecl float (const Vec3* v) /* float32(sqrt(float32(x*x+y*y+z*z))) -- the same two narrowings as the first half of Mars_Vec3_Normalize */ [verified]
constexpr uint32_t Mars_Vec3_Length = 0x000224b0;
// cdecl float (const Vec3* v) /* float32(x*x+y*y+z*z), one narrowing on the sum */ [verified]
constexpr uint32_t Mars_Vec3_LengthSquared = 0x000224f0;
// data const float = 0x34000000 = 2^-23 = 1.1920928955078125e-07 /* Mars_Vec3_Normalize zeroes the direction when !(len > this) */ [verified]
constexpr uint32_t Mars_Vec3_NormaliseEpsilon = 0x005e1ef8;
// site site inside StrategyServer::MoveFleet /* the straight-run leg. Each delta dest.c - fleet.pos.c is computed on the x87 stack and STORED BACK TO A FLOAT32 SLOT before Mars_Vec3_Normalize(&v, &v) is called on it in place; that single call returns the leg DISTANCE and leaves the float32 unit direction in the same slots. The distance is never recomputed. */ [verified]
constexpr uint32_t StrategyServer_MoveFleet_straight_leg = 0x003da0f2;
// site site inside StrategyServer::MoveFleet /* the move != distance branch: exactly two roundings per component, tmp.c = float32(dir.c * move) then pos.c = float32(pos.c + tmp.c). `move` is reloaded from a float32 slot. The sibling branch (move == distance, an EXACT float compare) copies the destination's three words verbatim with mov, so an arrival never steps onto its destination. */ [verified]
constexpr uint32_t StrategyServer_MoveFleet_position_update = 0x003da2ac;
} // namespace sots::addr

View file

@ -225,15 +225,43 @@ MoveStepResult ResolveMoveStep(double step, double minShipRange, double distance
return r;
}
Vec3 AdvanceAlongDirection(const Vec3& pos, const Vec3& dest, double move) {
const Vec3 delta = Sub(dest, pos);
const double len = std::sqrt(Dot(delta, delta));
if (!(len > kNormaliseEpsilon)) return pos; // the direction is zeroed; nothing moves
const Vec3 dir{delta.x / len, delta.y / len, delta.z / len};
NormalizeResult NormalizeVec3(const Vec3& v) {
NormalizeResult r;
// The three squares and two adds stay in the x87 registers; only the SUM is stored to a
// 4-byte slot, so exactly one narrowing happens here.
const double sumsq = F32(v.x * v.x + v.y * v.y + v.z * v.z);
const double len = F32(std::sqrt(sumsq));
if (!(len > kNormaliseEpsilon)) {
r.dir = Vec3{0, 0, 0};
r.length = 0.0;
return r;
}
r.length = len;
// A reciprocal, narrowed, then multiplied through -- not three divides.
const double inv = F32(1.0 / len);
r.dir = Vec3{F32(v.x * inv), F32(v.y * inv), F32(v.z * inv)};
return r;
}
NormalizeResult StraightLeg(const Vec3& pos, const Vec3& dest) {
// `fld dest.c; fsub pos.c; fstp DWORD PTR ...` -- the delta is a float32 before it is
// ever normalised.
return NormalizeVec3(Vec3{F32(dest.x - pos.x), F32(dest.y - pos.y), F32(dest.z - pos.z)});
}
double StraightLegDistance(const Vec3& pos, const Vec3& dest) { return StraightLeg(pos, dest).length; }
Vec3 AdvanceAlongUnitDirection(const Vec3& pos, const Vec3& dir, double move) {
return Vec3{F32(pos.x + F32(dir.x * move)), F32(pos.y + F32(dir.y * move)),
F32(pos.z + F32(dir.z * move))};
}
Vec3 AdvanceAlongDirection(const Vec3& pos, const Vec3& dest, double move) {
const NormalizeResult n = StraightLeg(pos, dest);
if (n.length == 0.0) return pos; // the direction was zeroed; nothing moves
return AdvanceAlongUnitDirection(pos, n.dir, move);
}
double ConsumeShipRange(double shipRange, double moved, bool exempt) {
if (exempt) return shipRange;
const double v = F32(shipRange - moved);

View file

@ -14,6 +14,15 @@ struct Vec3 {
double x = 0, y = 0, z = 0;
};
// A plain double-precision distance, used by the node-line / stutter geometry below.
//
// NOT the one the straight-run movement path uses -- see `StraightLegDistance`. The
// original's own vector length narrows to float32 twice (the sum of squares and the root),
// and every `Vec3` it stores is a float32 triple, so the geometry helpers below are very
// likely one ULP out in the same way `AdvanceAlongDirection` was. They are left in double
// on purpose: waypoint types 2-5 have never been exercised behaviourally, so there is no
// evidence to correct them against and no reason to churn them on a hunch. Whoever gets a
// node-line trace should read those call sites the same way and fix this alongside.
double Distance(const Vec3& a, const Vec3& b);
// Move `pos` toward `dest` by `amount`; snaps exactly onto `dest` when amount reaches
@ -164,12 +173,58 @@ struct MoveStepResult {
// and the absence of the floor.
MoveStepResult ResolveMoveStep(double step, double minShipRange, double distance);
// The engine's vector normalise, reproduced narrowing for narrowing.
//
// Every `Vec3` in the original is three float32 words, and the normalise helper narrows to
// float32 FOUR separate times. Reading the instruction stream, in order:
//
// sumsq = float32(x*x + y*y + z*z) the three products and two adds happen in the x87's
// 53-bit registers, then the sum is STORED to a
// 4-byte slot and reloaded
// len = float32(sqrt(sumsq)) stored to the same 4-byte slot and reloaded
// if (!(len > 2^-23)) -> direction zeroed, length 0
// inv = float32(1.0 / len) a RECIPROCAL, stored to a 4-byte slot and reloaded,
// then MULTIPLIED through -- not three divides
// dir.c = float32(v.c * inv) each component stored back to its float32 word
//
// Keeping any of those four in double is worth ~1 ULP of the direction, which becomes a
// constant ~1.2e-7 absolute error once it is scaled by the step length. CONFIDENCE: high --
// read off the instruction stream, and it reproduces the original bit-for-bit on every
// straight-run leg in the recapture trace whose destination could be recovered.
//
// Gotcha the original carries and this does not: on the zero-length branch it returns with
// an EMPTY x87 stack, so the caller's `fstp` for the length underflows and the distance is
// indeterminate. We return 0 there; a zero-length leg is a degenerate the workloads never
// reach, and reproducing an FPU stack underflow is not something a C++ model can do.
struct NormalizeResult {
Vec3 dir; // the unit direction, each component a float32
double length = 0; // float32(sqrt(float32(sumsq)))
};
NormalizeResult NormalizeVec3(const Vec3& v);
// The straight-run leg exactly as `MoveFleet` builds it: the three deltas are computed on
// the x87 stack and STORED BACK TO FLOAT32 SLOTS before the normalise call, so the delta a
// caller must normalise is `float32(dest.c - pos.c)`, not the exact difference. The single
// normalise call yields both the direction and the leg distance -- the original does not
// compute the distance a second time. CONFIDENCE: high.
NormalizeResult StraightLeg(const Vec3& pos, const Vec3& dest);
// The leg distance alone, for callers that only need it. Same narrowings.
double StraightLegDistance(const Vec3& pos, const Vec3& dest);
// New position after a step that did not arrive: `pos + unit(dest - pos) x move`, with
// each component rounded to float32 after the multiply and before the add. When the step
// did arrive the original copies the destination's words verbatim, which `AdvanceToward`
// each component rounded to float32 after the multiply and before the add, and the unit
// direction produced by `StraightLeg` above (so it is itself float32). When the step did
// arrive the original copies the destination's words verbatim, which `AdvanceToward`
// reproduces. CONFIDENCE: high.
Vec3 AdvanceAlongDirection(const Vec3& pos, const Vec3& dest, double move);
// The same position update given a direction that has already been normalised -- this is
// the tail the original runs once the step is known: three `float32(dir.c * move)`
// temporaries, then three `float32(pos.c + tmp)` stores. `move` is itself read back out of
// a float32 slot, so it needs no further narrowing here.
Vec3 AdvanceAlongUnitDirection(const Vec3& pos, const Vec3& dir, double move);
// Remaining strategic range of one ship after moving. Ships carrying the range-exempt
// flag (tankers and tenders, bit 0x1000) are skipped entirely -- they pay nothing and are
// topped back up by the refuelling pass. Everyone else loses `moved`, floored at 0.

View file

@ -64,14 +64,19 @@ FleetStepResult StepFleet(const FleetStepSnapshot& s, double step, sots::sim::IR
break;
}
r.distance = sots::sim::Distance(pos, dest);
// One normalise call, exactly as the original: it yields BOTH the unit direction and
// the leg distance, and both are float32 the whole way down. Calling a separate
// double-precision distance here (as this hook used to) is worth 1 ULP of position.
const sots::sim::NormalizeResult leg = sots::sim::StraightLeg(pos, dest);
r.distance = leg.length;
const double minRange = sots::sim::FleetMinShipRange(r.shipRanges, 0.0);
const MoveStepResult m = sots::sim::ResolveMoveStep(step, minRange, r.distance);
r.moved = m.moved;
r.range = m.range;
r.arrived = m.arrived;
r.stranded = m.stranded;
store(r.pos, m.arrived ? dest : sots::sim::AdvanceAlongDirection(pos, dest, m.moved));
store(r.pos, m.arrived ? dest
: sots::sim::AdvanceAlongUnitDirection(pos, leg.dir, m.moved));
for (int i = 0; i < static_cast<int>(r.shipRanges.size()); ++i) {
r.shipRanges[static_cast<std::size_t>(i)] =

View file

@ -1,5 +1,8 @@
#include "game/sim/movement.h"
#include <cstdint>
#include <cstring>
#include "check.h"
using namespace sots::sim;
@ -252,6 +255,125 @@ static void test_pass_schedule() {
CHECK_EQ(p[4].fleetId, 3); CHECK_EQ(p[4].pass, 5);
}
// ---------------------------------------------------------------------------------------
// The straight-run normalise, pinned to the bit.
//
// Every one of these expectations is a float32 BIT PATTERN, because the whole point of the
// module is which precision each intermediate is held in. A `CHECK_NEAR` here would pass
// against the wrong arithmetic -- the error this pins down is one ULP.
//
// Regression: the behavioural compare caught 8 of 45 live `MoveFleet` calls diverging by
// exactly one ULP on a position component, and the cause was this file computing the
// direction in double where the original narrows to float32 four separate times.
// ---------------------------------------------------------------------------------------
static std::uint32_t f32bits(double v) {
const float f = static_cast<float>(v);
std::uint32_t u = 0;
std::memcpy(&u, &f, sizeof u);
return u;
}
static void check_pos(const Vec3& got, std::uint32_t bx, std::uint32_t by, std::uint32_t bz,
int line) {
::simtest::report(f32bits(got.x) == bx, "pos.x bits", __FILE__, line,
"got 0x" + std::to_string(f32bits(got.x)) + ", expected 0x" + std::to_string(bx));
::simtest::report(f32bits(got.y) == by, "pos.y bits", __FILE__, line,
"got 0x" + std::to_string(f32bits(got.y)) + ", expected 0x" + std::to_string(by));
::simtest::report(f32bits(got.z) == bz, "pos.z bits", __FILE__, line,
"got 0x" + std::to_string(f32bits(got.z)) + ", expected 0x" + std::to_string(bz));
}
static void test_normalise_precision() {
// 1. The sum of squares is narrowed to float32 BEFORE the square root. 1e-4 squares to
// 1e-8, which is representable, but the sum 1 + 1e-8 is not: it rounds to 1.0f, so
// the length comes out exactly 1 rather than 1.000000005.
{
const NormalizeResult n = NormalizeVec3({1.0, 1e-4, 0.0});
CHECK_EQ(f32bits(n.length), f32bits(1.0f));
CHECK(n.length == 1.0);
}
// 2. The square root is narrowed to float32 too. sqrt(2) as a double is
// 1.4142135623730951; as a float32 it is 1.41421356201171875.
{
const NormalizeResult n = NormalizeVec3({1.0, 1.0, 0.0});
CHECK_EQ(f32bits(n.length), std::uint32_t{0x3FB504F3u});
CHECK(n.length == static_cast<double>(1.41421353816986083984375f));
}
// 3. The direction is a RECIPROCAL multiply through a float32 slot, not three divides.
// For this vector float32(1/len) x c and c / len land on different float32s.
{
const NormalizeResult n = NormalizeVec3({-12.7324999f, 11.0829000f, -16.4794998f});
// float32(1 / float32(sqrt(float32(sumsq))))
const double inv = static_cast<double>(1.0f / 23.590700149536133f);
CHECK_EQ(f32bits(n.dir.x), f32bits(static_cast<double>(-12.7324999f) * inv));
CHECK_EQ(f32bits(n.length), f32bits(23.590700149536133f));
}
// 4. Below the epsilon the direction is zeroed and the length reported as 0.
{
const NormalizeResult n = NormalizeVec3({1e-9, 0, 0});
CHECK(n.length == 0.0);
CHECK(n.dir.x == 0.0 && n.dir.y == 0.0 && n.dir.z == 0.0);
}
// 5. The leg delta is stored back to float32 before the normalise. Here the exact
// difference and its float32 rounding are different numbers, and the original uses
// the rounded one.
{
const Vec3 pos{1.8504999876022339, -2.4797000885009766, 11.430100440979004};
const Vec3 dest{-10.881999969482422, 8.60319995880127, -5.0493998527526855};
CHECK_EQ(f32bits(StraightLegDistance(pos, dest)), std::uint32_t{0x41BCB9C1u});
}
}
static void test_advance_bit_exact() {
// Four independent straight-run legs at step 2.0. Each expectation is the float32 the
// original produces; the previous double-precision direction got at least one of the
// three components one ULP wrong on every one of them.
struct Case {
Vec3 pos, dest;
std::uint32_t bx, by, bz;
};
const Case cases[] = {
{{1.8504999876022339, -2.4797000885009766, 11.430100440979004},
{-10.881999969482422, 8.60319995880127, -5.0493998527526855},
0x3F45637Au, 0xBFC52208u, 0x41208718u},
{{3.333899974822998, -3.0625, 1.145900011062622},
{-10.4931001663208, -10.569600105285645, -7.057000160217285},
0x3FE33EC5u, 0xC07A27DCu, 0x3E62996Cu},
{{4.329599857330322, -1.7378000020980835, -4.4604997634887695},
{2.053499937057495, -1.1236000061035156, -4.805600166320801},
0x401AD160u, 0xBF9C7244u, 0xC0980177u},
{{-11.458499908447266, -0.9193000197410583, -7.966800212860107},
{-9.18970012664795, -10.585100173950195, 6.437600135803223},
0xC1332FA2u, 0xC0018E2Bu, 0xC0CA3E13u},
};
for (const Case& c : cases) {
check_pos(AdvanceAlongDirection(c.pos, c.dest, 2.0), c.bx, c.by, c.bz, __LINE__);
// The two-step form the hook uses must agree with the one-shot form exactly.
const NormalizeResult n = StraightLeg(c.pos, c.dest);
check_pos(AdvanceAlongUnitDirection(c.pos, n.dir, 2.0), c.bx, c.by, c.bz, __LINE__);
}
// A leg shorter than the step arrives, and an arrival copies the destination words
// verbatim rather than stepping onto them.
{
const Vec3 pos{4.329599857330322, -1.7378000020980835, -4.4604997634887695};
const Vec3 dest{3.0, -1.5, -4.5999999046325684};
const double dist = StraightLegDistance(pos, dest);
CHECK(dist < 2.0);
const MoveStepResult m = ResolveMoveStep(2.0, 100.0, dist);
CHECK(m.arrived);
// `arrived` is an EXACT float comparison, so the distance the step is capped at has
// to be the same float32 the normalise produced -- a double-precision distance here
// would make `move == distance` a coincidence rather than an identity.
CHECK(m.moved == dist);
}
}
static void test_gate_traffic() {
std::vector<GateTrafficEntry> f = {
{0, 4, 10}, // gate transit
@ -278,5 +400,7 @@ int main() {
test_jump();
test_pass_schedule();
test_gate_traffic();
test_normalise_precision();
test_advance_bit_exact();
return simtest::finish("test_movement");
}