The simulation steps at a fixed rate

RP412PHYSICSHZ names a rate and the simulation advances in whole steps
of exactly that size on every machine, whatever the display does. 0 -
the default, and the shipped behaviour until the play testers have
spoken - is the game as it has always run: the step is however long the
last frame took, which makes the frame rate part of the physics.
Measured over two seconds of free fall, a 30 fps machine's pod fell
three times further than a 144 fps machine's. Two players on the same
track were not in the same gravity.

With a rate set, the same race is bit-identical across frame rates:
30, 60 and 144 fps produce the same trajectory to the last printed
digit, and identical runs reproduce exactly - which was never true of
this engine before, at any frame rate.

It took three pieces, and every one was found by measuring, not by
reading:

- Simulation::PerformTo turns lastPerformance into the accumulator it
  always secretly was: whole steps while time remains, the remainder
  carried to the next frame. Watchers and update records stay once per
  frame - stepping is physics, watching is I/O.

- Entity::PerformAndWatch interleaves subsystems and entity per STEP.
  The frame loop ran all subsystems to the frame boundary and then the
  entity, indistinguishable from correct at one step per frame - which
  is why thirty years of code never noticed - and wrong at two: the
  thrusters raycast twice from a vehicle that had not moved, and the
  hover spring fired twice on one stale height sample. The subsystems
  are also snapped onto their entity's step grid; each Simulation
  anchors its grid at its own creation time, a per-run phase no seed
  could pin.

- Mover::BeginStep clears the force accumulator per step. It was
  cleared once per frame while the thrusters ADD per step, so step two
  of a frame integrated step one's thrust again - and how many steps a
  frame holds rides on wall-clock jitter, which is why identical
  configs measured a quarter-metre apart. The quaternion renormalise
  counts steps now too, for the same reason.

The catch-up clamp is a quarter second of simulation whatever the rate,
so a machine that cannot keep up slows down rather than seizing, and
does so identically everywhere. The engine's clock counts milliseconds,
so rates that do not divide 1000 - 60 among them - quietly run at the
neighbouring millisecond step; the log now says so and names the exact
ones. 25, 50 and 100 are exact, and all three are verified bit-identical
across frame rates and across runs.

Verified for a single vehicle settling under gravity and hover. Driving,
collisions and the network are the next frontiers, in that order: the
collision path writes the victim's state with wall-clock stamps and a
hard-coded 0.1 s bounce, which single-player survives and lockstep will
not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-09 18:19:43 -05:00
co-authored by Claude Fable 5
parent 74aa5ae98d
commit 5e47987508
5 changed files with 374 additions and 12 deletions
+97
View File
@@ -745,6 +745,103 @@ void
//
if (GetInstance() != ReplicantInstance)
{
//
//----------------------------------------------------------------
// Fixed-step: the subsystems and the entity advance TOGETHER,
// one step at a time, because they read each other mid-flight.
// The VTV's hover spring is computed from its thrusters'
// measured heights, and each thruster measures from where the
// vehicle IS - so thrusters stepped twice against a vehicle
// that has not moved yet hand back two identical height
// samples, and the spring fires twice on stale data. Measured,
// that pod climbs at 30 fps and flies level at 144.
//
// So the step loop lives HERE, above both: everyone is walked
// to the same sub-frame instant before anyone takes the next
// step. Watchers and the update stream still run once per
// frame, after the loop - stepping is physics, watching is
// I/O, and only the first belongs inside.
//
// The interleave keys off the ENTITY's own clock so a
// subsystem created mid-flight (they are made alongside their
// owner) can never wedge the loop.
//----------------------------------------------------------------
//
Scalar fixed_step = Simulation::FixedStep();
if (fixed_step > (Scalar) 0)
{
//
// One grid for the whole vehicle. Every Simulation anchors
// its own lastPerformance at its creation time, so an
// entity and its subsystems were stepping on grids offset
// by a random fraction of a step - deterministic within a
// run, DIFFERENT between runs, because creation times ride
// on load timing. The thrusters' measurements then landed
// a different sub-step distance from the vehicle's
// integration every launch, which is physics drift no seed
// can pin. Snap the subsystems onto the entity's grid; the
// interleave below then keeps everyone in lockstep by
// construction, and once aligned this assignment is a
// no-op every frame after.
//
for (int i=0; i<subsystemCount; ++i)
{
if (subsystemArray[i] &&
subsystemArray[i]->IsNonReplicantExecutable())
{
subsystemArray[i]->SetLastPerformance(
GetLastPerformance());
}
}
Time step_till = GetLastPerformance();
step_till += fixed_step;
while (step_till <= till)
{
//
// BeginStep on the ENTITY comes before the subsystems
// perform: the Mover's force accumulator is cleared
// here, and the thrusters then ADD this step's forces
// into a clean slate. The first version left that
// clear on the per-frame path, so a two-step frame
// integrated step one's thrust twice - and since how
// many steps land in a frame rides on wall-clock
// jitter, no two runs saw the same force history.
// Identical configs measured 0.23 apart because of it.
//
BeginStep();
for (int i=0; i<subsystemCount; ++i)
{
if (subsystemArray[i] &&
subsystemArray[i]->IsNonReplicantExecutable())
{
subsystemArray[i]->BeginStep();
subsystemArray[i]->PerformTo(step_till);
}
}
Simulation::PerformTo(step_till);
step_till += fixed_step;
}
for (int i=0; i<subsystemCount; ++i)
{
if (subsystemArray[i] &&
subsystemArray[i]->IsNonReplicantExecutable())
{
subsystemArray[i]->WatchAndWrite(update_stream);
}
}
SET_PERFORM_ENTITY();
Simulation::WatchAndWrite(update_stream);
Check_Fpu();
CLEAR_PERFORM_ENTITY();
CLEAR_PERFORM_SUBSYSTEMS();
return;
}
for (int i=0; i<subsystemCount; ++i)
{
if (subsystemArray[i])