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
+40 -1
View File
@@ -658,9 +658,48 @@ Bye_Bye:
//
//-----------------------------------------------
// Make sure the position quaternion stays stable
//
// Frame-counting, so it only runs on the frame-coupled path - fixed
// steps do the same thing in BeginStep, counted in STEPS, because
// "every 20 frames" lands at a different point of the step sequence
// on every machine and rounding at different points is drift.
//-----------------------------------------------
//
if (++normalizeCount == 20)
if (Simulation::FixedStep() <= (Scalar) 0 && ++normalizeCount >= 20)
{
localOrigin.angularPosition.Normalize();
normalizeCount = 0;
}
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// The per-STEP set-up. This is the same work Mover::PerformAndWatch does
// once per frame above - and once per frame is exactly wrong under fixed
// stepping: the thrusters ADD their forces into localAcceleration every
// step, so an accumulator cleared per frame carries step one's thrust
// into step two whenever a frame holds two steps. How many steps a frame
// holds depends on wall-clock jitter, which made identical runs diverge
// by a quarter of a metre while sitting still on the pad.
//
// Idempotent on purpose: the frame-level copy still runs first on every
// path, and repeating this at each step start is a recompute from
// current state, not an accumulation.
//
void
Mover::BeginStep()
{
Check(this);
localVelocity.linearMotion.MultiplyByInverse(
worldLinearVelocity,
localToWorld
);
localAcceleration = Motion::Identity;
previousOrigin = localOrigin;
if (++normalizeCount >= 20)
{
localOrigin.angularPosition.Normalize();
normalizeCount = 0;