diff --git a/docs/M-movefleet.md b/docs/M-movefleet.md new file mode 100644 index 0000000..5cee962 --- /dev/null +++ b/docs/M-movefleet.md @@ -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. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index fd9bb10..6aef2f0 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -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 @@ -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 diff --git a/src/game/sim/movement.cpp b/src/game/sim/movement.cpp index 27cfde8..2329f41 100644 --- a/src/game/sim/movement.cpp +++ b/src/game/sim/movement.cpp @@ -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); diff --git a/src/game/sim/movement.h b/src/game/sim/movement.h index 9211a06..939ca73 100644 --- a/src/game/sim/movement.h +++ b/src/game/sim/movement.h @@ -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. diff --git a/src/shim/hooks/movement_inputs.cpp b/src/shim/hooks/movement_inputs.cpp index 5edd53d..8186914 100644 --- a/src/shim/hooks/movement_inputs.cpp +++ b/src/shim/hooks/movement_inputs.cpp @@ -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(r.shipRanges.size()); ++i) { r.shipRanges[static_cast(i)] = diff --git a/tests/game_sim/test_movement.cpp b/tests/game_sim/test_movement.cpp index 0e33bcf..96dd149 100644 --- a/tests/game_sim/test_movement.cpp +++ b/tests/game_sim/test_movement.cpp @@ -1,5 +1,8 @@ #include "game/sim/movement.h" +#include +#include + #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(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(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(1.0f / 23.590700149536133f); + CHECK_EQ(f32bits(n.dir.x), f32bits(static_cast(-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 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"); }