From 5e4798750836822d56653023866824ef2550df72 Mon Sep 17 00:00:00 2001 From: Cyd Date: Sun, 9 Aug 2026 18:19:43 -0500 Subject: [PATCH] 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 --- MUNGA/ENTITY.cpp | 97 +++++++++++++++++++++ MUNGA/MOVER.cpp | 41 ++++++++- MUNGA/MOVER.h | 8 ++ MUNGA/SIMULATE.cpp | 207 ++++++++++++++++++++++++++++++++++++++++++--- MUNGA/SIMULATE.h | 33 ++++++++ 5 files changed, 374 insertions(+), 12 deletions(-) diff --git a/MUNGA/ENTITY.cpp b/MUNGA/ENTITY.cpp index a6d977b..eb4db2c 100644 --- a/MUNGA/ENTITY.cpp +++ b/MUNGA/ENTITY.cpp @@ -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; iIsNonReplicantExecutable()) + { + 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; iIsNonReplicantExecutable()) + { + subsystemArray[i]->BeginStep(); + subsystemArray[i]->PerformTo(step_till); + } + } + Simulation::PerformTo(step_till); + step_till += fixed_step; + } + + for (int i=0; iIsNonReplicantExecutable()) + { + 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= 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; diff --git a/MUNGA/MOVER.h b/MUNGA/MOVER.h index 5dffe01..cc65298 100644 --- a/MUNGA/MOVER.h +++ b/MUNGA/MOVER.h @@ -265,6 +265,14 @@ protected: MemoryStream *update_stream ); + // + // Per-step set-up under fixed stepping: clears the force accumulator + // the thrusters add into, so each step integrates only its own + // forces. See the definition for the frame-jitter bug this closes. + // + void + BeginStep(); + int normalizeCount; Environment diff --git a/MUNGA/SIMULATE.cpp b/MUNGA/SIMULATE.cpp index 1e7747a..38aeea9 100644 --- a/MUNGA/SIMULATE.cpp +++ b/MUNGA/SIMULATE.cpp @@ -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); } //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/MUNGA/SIMULATE.h b/MUNGA/SIMULATE.h index 391ddb6..0c844b1 100644 --- a/MUNGA/SIMULATE.h +++ b/MUNGA/SIMULATE.h @@ -147,6 +147,36 @@ public: MemoryStream *update_stream ); + // + // The two halves of PerformAndWatch, so an ENTITY can interleave its + // subsystems' physics with its own, step by step, and still run the + // watchers and the update stream once per frame. PerformTo advances + // the simulation to the given time - in fixed steps when + // RP412PHYSICSHZ names a rate, in one variable slice otherwise. + // + void + PerformTo(const Time& till); + void + WatchAndWrite(MemoryStream *update_stream); + + // + // Called by the entity interleave at the TOP of every fixed step, + // before any subsystem adds its forces for that step. Per-frame set-up + // work - clearing a force accumulator, deriving local velocity from + // world state - belongs here when the fixed step is on, because "once + // per frame" is a wall-clock cadence and the whole point is that wall + // clock no longer reaches the physics. Default: nothing. + // + virtual void + BeginStep(); + + // + // The fixed step in seconds, 0 when frame-coupled. Global on purpose: + // a mixed-rate simulation would be a worse bug than either mode. + // + static Scalar + FixedStep(); + void DoNothingOnce(Scalar time_slice); void @@ -155,6 +185,9 @@ public: void SetLastPerformance(const Time& when) {Check(this); Check(&when); lastPerformance = when;} + const Time& + GetLastPerformance() const + {Check(this); return lastPerformance;} void RequestEncore(Encore encore);