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.
157 lines
5.8 KiB
C++
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
|