movefleet: reproduce the original's float32 normalise; 8 of 45 live divergences -> 0
The behavioural compare found 8 of 45 StrategyServer::MoveFleet calls diverging by one ULP on a position component. Read off the instruction stream, the cause is that the engine's vector normalise narrows to float32 four separate times and we kept everything in double: delta.c = f32(dest.c - pos.c) stored back to a float32 slot before normalising sumsq = f32(x*x + y*y + z*z) products/adds in 53-bit regs, only the SUM stored len = f32(sqrt(sumsq)) inv = f32(1.0 / len) a reciprocal, MULTIPLIED through, not three divides dir.c = f32(delta.c * inv) and the same call returns the leg distance, so it is never recomputed in a wider precision either. The position tail was already right, which is why the error was a constant absolute ~1.2e-7 (half an ULP of the inputs) rather than a formula error. Adds NormalizeVec3 / StraightLeg / StraightLegDistance / AdvanceAlongUnitDirection and rebuilds AdvanceAlongDirection on them; the movement hook now takes both the direction and the distance from one StraightLeg call, as the original does. The arrival test is an exact float compare, so the distance has to be that same float32. Tests pin float32 BIT PATTERNS, not tolerances: one case per narrowing plus four independent legs component by component. A CHECK_NEAR would pass against the old arithmetic. sim::Distance is left in double on purpose and flagged at its declaration: it now serves only the node-line/stutter geometry, which very likely needs the same treatment but has zero behavioural coverage to correct it against. Live, same VM/save/workload, run twice by this lane: control recap-7584bad-20260908T0615Z 45 calls, 45 compared, 8 diverged, exit 1 fixed mf-45bdf7d-dirty-20260908T0721Z 45 calls, 45 compared, 0 diverged, exit 0 with identical arguments, identical pos.before and identical ORIGINAL pos.after on all 45 calls. The control reproduced the eight divergent call_ids exactly. Coverage unchanged and still thin: all 15 moving calls are the same straight-run waypoint type; types 2-5 were attempted and could not be reached (the only player that would travel a node line has no ships on this save). See docs/M-movefleet.md. ctest 32/32; tools/clean_room_check.sh OK.
This commit is contained in:
parent
45bdf7d805
commit
2aa8cba614
6 changed files with 350 additions and 10 deletions
116
docs/M-movefleet.md
Normal file
116
docs/M-movefleet.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)] =
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue