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
+196 -11
View File
@@ -621,9 +621,203 @@ void*
}
}
//#############################################################################
// RP412PHYSICSHZ - the size of one simulation step, as a rate in hertz.
//
// The engine simulates TO a timestamp: every entity keeps a lastPerformance
// marking how far it has been simulated, and PerformAndWatch hands Perform()
// the difference. That difference used to be however long the last frame
// took, which made the frame rate part of the physics - explicitly so, since
// Mover scales its bounce and penetration thresholds by delta_t.
//
// Advancing lastPerformance in fixed steps instead makes it the accumulator
// a fixed-step loop needs, and every Perform() in the game gets an identical
// dt without one of them being touched.
//
// 0 restores the old behaviour for comparison. The RATE is a game-feel
// decision, not a technical one: thirty years of handling constants were
// tuned against the DOS build's 40 ms steps, and RP412 has been running
// ~18 ms variable ones, so the feel has already drifted. Whatever is chosen
// here becomes the canonical physics for pods and PCs alike.
//#############################################################################
static Scalar
FixedPhysicsStep()
{
static Scalar
step = (Scalar) -1;
if (step < (Scalar) 0)
{
const char
*setting = getenv("RP412PHYSICSHZ");
//
// OFF until it is proven. The accumulator below is correct in
// isolation but measured WORSE than the frame-coupled path it
// replaces - at 30 fps the pod climbs, at 144 it barely moves -
// so something else is still rate-dependent and feeding it. Not
// a default until the trace says two frame rates agree.
//
int rate = (setting != NULL) ? atoi(setting) : 0;
//
// Guard the arithmetic rather than the taste: a rate below the
// frame rate is a legitimate choice (the pods ran at 25), but a
// step of zero or a negative one is not a choice at all.
//
if (rate < 0)
{
rate = 0;
}
if (rate > 1000)
{
rate = 1000;
}
step = (rate > 0) ? ((Scalar) 1 / (Scalar) rate) : (Scalar) 0;
DEBUG_STREAM << "Physics: ";
if (rate > 0)
{
DEBUG_STREAM << "fixed step, " << rate << " Hz";
//
// The engine's clock counts MILLISECONDS, so a step is
// really round(1000/rate) ms. A rate that does not divide
// 1000 evenly therefore runs at a neighbouring rate wearing
// this one's name - 60 asks for 16.67 ms and gets 17, which
// is 58.8 Hz. Say so, and name the rates that mean what
// they say.
//
if ((1000 % rate) != 0)
{
int step_ms = (1000 + rate / 2) / rate;
DEBUG_STREAM << " - NOT millisecond-exact, steps will run "
<< step_ms << " ms (" << (1000.0f / (float) step_ms)
<< " Hz). 25, 50 and 100 are exact";
}
}
else
{
DEBUG_STREAM << "frame-coupled (RP412PHYSICSHZ=0)";
}
DEBUG_STREAM << "\n" << std::flush;
}
return step;
}
//
// How far behind one frame may catch up: a quarter second of simulation,
// whatever the rate - enough to ride out a texture load or an alt-tab,
// short of letting a stalled machine spiral. Counted in steps because the
// loop is, so 6 steps at 25 Hz, 12 at 50, 25 at 100.
//
static int
MaximumCatchUpSteps(Scalar step)
{
int steps = (int)((Scalar) 0.25 / step);
return (steps < 4) ? 4 : steps;
}
// how many fixed steps the whole simulation has taken - the trace prints it,
// so 'is the step actually fixed' is answered by measurement not by reading
long gPhysicsStepsTaken = 0;
//#############################################################################
// Simulation Support
//
Scalar
Simulation::FixedStep()
{
return FixedPhysicsStep();
}
void
Simulation::PerformTo(const Time& till)
{
Check(this);
Check(&till);
Scalar step = FixedPhysicsStep();
if (step > (Scalar) 0)
{
//
//------------------------------------------------------------------
// Fixed step. The simulation advances in whole steps of the same
// size on every machine, and whatever is left over waits for the
// next frame - lastPerformance is the accumulator, and always was.
//
// Before this, the slice was simply however long the last frame
// took, so a 30 fps machine integrated gravity in 33 ms steps and
// a 144 fps machine in 7 ms ones. Nothing in any Perform()
// changes: it is handed a dt it can rely on instead of one that
// depended on the graphics card.
//
// NOTE the caller decides the interleaving. An entity's spring
// forces are computed from its subsystems (the VTV reads its
// thrusters' measured heights), so the subsystems and the entity
// must advance TOGETHER, one step at a time -
// Entity::PerformAndWatch owns that loop and hands everyone the
// same sub-frame 'till'. Stepping a subsystem all the way to the
// frame boundary before its owner moves at all is how the first
// attempt at this produced a pod that climbed at 30 fps and flew
// level at 144: two spring impulses from one stale height sample.
//------------------------------------------------------------------
//
Scalar behind = till - lastPerformance;
int taken = 0;
int max_steps = MaximumCatchUpSteps(step);
while (behind >= step && taken < max_steps)
{
Perform(step);
++gPhysicsStepsTaken;
lastPerformance += step;
behind -= step;
++taken;
}
//
// A machine that cannot keep up must not try to buy back the whole
// backlog next frame - that costs more time, which makes a bigger
// backlog. Drop what could not be run and carry on: the game slows
// down rather than seizing, and it does so identically everywhere.
//
if (taken >= max_steps && behind >= step)
{
lastPerformance = till;
}
}
else
{
Scalar slice = till - lastPerformance;
lastPerformance = till;
Perform(slice);
}
Check_Fpu();
}
void
Simulation::BeginStep()
{
// nothing by default - see the header
}
void
Simulation::WatchAndWrite(MemoryStream *update_stream)
{
Check(this);
if (!AreWatchersDelayed())
{
ExecuteWatchers();
}
WriteSimulationUpdate(update_stream);
Check_Fpu();
}
void
Simulation::PerformAndWatch(
const Time& till,
@@ -633,17 +827,8 @@ void
Check(this);
Check(&till);
Scalar slice = till - lastPerformance;
lastPerformance = till;
Perform(slice);
if (!AreWatchersDelayed())
{
ExecuteWatchers();
}
WriteSimulationUpdate(update_stream);
Check_Fpu();
PerformTo(till);
WatchAndWrite(update_stream);
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~