Merge branch 'main' into wip/post-events
# Conflicts: # include/generated/sots_addresses.h
This commit is contained in:
commit
6293ce44ac
6 changed files with 338 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).
|
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
||||||
// Source: sots-re ghidra/addresses.json @ b0ef139, generated 2026-09-08 by tools/gen_addresses.py
|
// Source: sots-re ghidra/addresses.json @ 0cd8469, generated 2026-09-08 by tools/gen_addresses.py
|
||||||
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
|
||||||
|
|
@ -225,15 +225,43 @@ MoveStepResult ResolveMoveStep(double step, double minShipRange, double distance
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
Vec3 AdvanceAlongDirection(const Vec3& pos, const Vec3& dest, double move) {
|
NormalizeResult NormalizeVec3(const Vec3& v) {
|
||||||
const Vec3 delta = Sub(dest, pos);
|
NormalizeResult r;
|
||||||
const double len = std::sqrt(Dot(delta, delta));
|
// The three squares and two adds stay in the x87 registers; only the SUM is stored to a
|
||||||
if (!(len > kNormaliseEpsilon)) return pos; // the direction is zeroed; nothing moves
|
// 4-byte slot, so exactly one narrowing happens here.
|
||||||
const Vec3 dir{delta.x / len, delta.y / len, delta.z / len};
|
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)),
|
return Vec3{F32(pos.x + F32(dir.x * move)), F32(pos.y + F32(dir.y * move)),
|
||||||
F32(pos.z + F32(dir.z * 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) {
|
double ConsumeShipRange(double shipRange, double moved, bool exempt) {
|
||||||
if (exempt) return shipRange;
|
if (exempt) return shipRange;
|
||||||
const double v = F32(shipRange - moved);
|
const double v = F32(shipRange - moved);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,15 @@ struct Vec3 {
|
||||||
double x = 0, y = 0, z = 0;
|
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);
|
double Distance(const Vec3& a, const Vec3& b);
|
||||||
|
|
||||||
// Move `pos` toward `dest` by `amount`; snaps exactly onto `dest` when amount reaches
|
// 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.
|
// and the absence of the floor.
|
||||||
MoveStepResult ResolveMoveStep(double step, double minShipRange, double distance);
|
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
|
// 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
|
// each component rounded to float32 after the multiply and before the add, and the unit
|
||||||
// did arrive the original copies the destination's words verbatim, which `AdvanceToward`
|
// 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.
|
// reproduces. CONFIDENCE: high.
|
||||||
Vec3 AdvanceAlongDirection(const Vec3& pos, const Vec3& dest, double move);
|
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
|
// 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
|
// 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.
|
// 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;
|
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 double minRange = sots::sim::FleetMinShipRange(r.shipRanges, 0.0);
|
||||||
const MoveStepResult m = sots::sim::ResolveMoveStep(step, minRange, r.distance);
|
const MoveStepResult m = sots::sim::ResolveMoveStep(step, minRange, r.distance);
|
||||||
r.moved = m.moved;
|
r.moved = m.moved;
|
||||||
r.range = m.range;
|
r.range = m.range;
|
||||||
r.arrived = m.arrived;
|
r.arrived = m.arrived;
|
||||||
r.stranded = m.stranded;
|
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) {
|
for (int i = 0; i < static_cast<int>(r.shipRanges.size()); ++i) {
|
||||||
r.shipRanges[static_cast<std::size_t>(i)] =
|
r.shipRanges[static_cast<std::size_t>(i)] =
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
#include "game/sim/movement.h"
|
#include "game/sim/movement.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
#include "check.h"
|
#include "check.h"
|
||||||
|
|
||||||
using namespace sots::sim;
|
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);
|
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() {
|
static void test_gate_traffic() {
|
||||||
std::vector<GateTrafficEntry> f = {
|
std::vector<GateTrafficEntry> f = {
|
||||||
{0, 4, 10}, // gate transit
|
{0, 4, 10}, // gate transit
|
||||||
|
|
@ -278,5 +400,7 @@ int main() {
|
||||||
test_jump();
|
test_jump();
|
||||||
test_pass_schedule();
|
test_pass_schedule();
|
||||||
test_gate_traffic();
|
test_gate_traffic();
|
||||||
|
test_normalise_precision();
|
||||||
|
test_advance_bit_exact();
|
||||||
return simtest::finish("test_movement");
|
return simtest::finish("test_movement");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue