sots-engine/src/shim/hooks/movement_inputs.cpp
alex 2aa8cba614 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.
2026-09-08 03:50:04 -04:00

157 lines
5.8 KiB
C++

#include "shim/hooks/movement_inputs.h"
#include <cmath>
#include <cstring>
#include <utility>
#include "game/sim/numeric.h"
namespace shim::hooks {
using sots::sim::JumpResult;
using sots::sim::MoveStepResult;
using sots::sim::Vec3;
using trace::Tv;
namespace tv = trace::tv;
namespace {
Vec3 vec_of(const double v[3]) { return Vec3{v[0], v[1], v[2]}; }
void store(double out[3], const Vec3& v) {
out[0] = v.x;
out[1] = v.y;
out[2] = v.z;
}
} // namespace
std::vector<double> ShipRanges(const FleetStepSnapshot& s) {
std::vector<double> r;
const int n = s.shipCount < kMaxTracedShips ? s.shipCount : kMaxTracedShips;
for (int i = 0; i < n; ++i) r.push_back(s.ships[i].range);
return r;
}
FleetStepResult StepFleet(const FleetStepSnapshot& s, double step, sots::sim::IRandom& rng) {
FleetStepResult r;
const Vec3 pos = vec_of(s.pos);
const Vec3 dest = vec_of(s.dest);
store(r.pos, pos);
r.shipRanges = ShipRanges(s);
if (s.heldFlag != 0 || s.waypointType < 0) return r; // held, or no waypoints: no move
switch (s.waypointType) {
case static_cast<int>(sots::sim::WaypointKind::ProbabilisticJump): {
const JumpResult j = sots::sim::RollProbabilisticJump(s.castEfficiency,
s.castThreshold, rng);
r.rngDraws = j.draws;
r.fraction = 1.0; // a type-5 waypoint never recurses
if (j.arrived) {
store(r.pos, dest);
r.arrived = true;
} else {
// The miss scatters the fleet around the destination in a random direction.
// The direction is a second draw whose mapping this milestone does not model,
// so the position is left where it was and the scatter distance is reported.
r.moved = j.scatter;
}
return r; // no fuel is charged and no clamp applies
}
case static_cast<int>(sots::sim::WaypointKind::GateTeleport):
store(r.pos, dest);
r.arrived = true;
r.fraction = 1.0;
return r; // no fuel, no clamp
default:
break;
}
// 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::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)] =
sots::sim::ConsumeShipRange(r.shipRanges[static_cast<std::size_t>(i)], m.moved,
s.ships[i].exempt != 0);
}
r.fraction = sots::sim::PassFraction(s.waypointType, r.distance, step);
return r;
}
std::vector<int> GateTraffic(const std::vector<FleetSummary>& fleets, int playerCount) {
std::vector<sots::sim::GateTrafficEntry> e;
e.reserve(fleets.size());
for (const FleetSummary& f : fleets) {
sots::sim::GateTrafficEntry g;
g.ownerIndex = f.ownerIndex;
g.waypointType = f.waypointType;
g.traffic = f.gateTraffic;
e.push_back(g);
}
return sots::sim::GateTrafficTotals(e, playerCount);
}
std::vector<sots::sim::MovementPass> PassSchedule(const std::vector<FleetSummary>& fleets) {
std::vector<sots::sim::FleetMovementEntry> e;
e.reserve(fleets.size());
for (const FleetSummary& f : fleets) {
sots::sim::FleetMovementEntry x;
x.fleetId = f.fleetId;
x.targetFleetId = f.targetFleetId;
x.relation = f.relation;
e.push_back(x);
}
return sots::sim::PlanFleetMovement(e);
}
Tv DescribeFleetStep(const void* data, std::size_t size, unsigned) {
Tv s = tv::struct_();
if (size < sizeof(FleetStepSnapshot)) return s;
FleetStepSnapshot f;
std::memcpy(&f, data, sizeof f);
s.add("fleet", tv::i32(f.fleetId));
s.add("owner", tv::i32(f.ownerIndex));
s.add("wpt_type", tv::i32(f.waypointType));
s.add("wpt_count", tv::i32(f.waypointCount));
s.add("dest_kind", tv::i32(f.destKind));
s.add("dt", tv::f32(f.dt));
s.add("speed", tv::f32(f.speed));
std::vector<Tv> p, d;
for (int i = 0; i < 3; ++i) p.push_back(tv::f32(static_cast<float>(f.pos[i])));
for (int i = 0; i < 3; ++i) d.push_back(tv::f32(static_cast<float>(f.dest[i])));
s.add("pos", tv::list(std::move(p)));
s.add("dest", tv::list(std::move(d)));
s.add("cst_e", tv::f32(f.castEfficiency));
s.add("cst_t", tv::f32(f.castThreshold));
s.add("ships", tv::i32(f.shipCount));
s.add("held", tv::i32(f.heldFlag));
s.add("rng_left_in", tv::i32(f.rngLeftIn));
s.add("fpu_cw", tv::u32(f.fpuControlWord));
const int n = f.shipCount < kMaxTracedShips ? f.shipCount : kMaxTracedShips;
std::vector<Tv> ranges;
for (int i = 0; i < n; ++i) {
Tv one = tv::struct_();
one.add("range", tv::f32(f.ships[i].range));
one.add("exempt", tv::boolean(f.ships[i].exempt != 0));
ranges.push_back(std::move(one));
}
s.add("ship_range", tv::list(std::move(ranges)));
return s;
}
Tv DescribeGateTraffic(const std::vector<int>& totals) {
std::vector<Tv> items;
for (int v : totals) items.push_back(tv::i32(v));
return tv::list(std::move(items));
}
} // namespace shim::hooks