From c479cd48e9a9e7a1da3ff0f6c13bf9d75f39d75a Mon Sep 17 00:00:00 2001 From: Cyd Date: Sun, 9 Aug 2026 21:42:04 -0500 Subject: [PATCH] The death cycle is deterministic A scripted lap - full throttle, a steer, a crash at speed, the burn, the tumble, death, respawn, a second crash, a second respawn - now plays out bit-identical between identical runs and across 30 and 144 fps under RP412PHYSICSHZ. Ninety of ninety samples exact in the repro pair, sixty of sixty across frame rates, max difference 0.000000. The crash was already deterministic; this makes the RECOVERY deterministic, and it took five pieces, every one found by measurement: - The respawn teleport moves onto the vehicle's own step grid. VTV::ScheduleRespawn stores it and BeginStep applies it at the first step whose clock reaches the due time, teleport and turn-toward-goal together, because the goal flip reads the POST-reset heading. The old path applied the Reset from the event queue, which runs on wall clock, and identical runs diverged on the first step after the pod stood back up. - The handler keeps its Reset for the FIRST spawn of a mission, gated by a flag rather than by mode. A Mover is born in StasisState and the first Reset is what wakes it; gating on "is fixed stepping on" - the first attempt - skipped that wake-up and parked the pod frozen at its spawn point for an entire race. The scripted-lap harness caught it in one run. - The vehicle stamps its own death clock, at the single site that sets BurningState - inside the step machinery, which is why the crash measured exact. The schedule anchors to the death, the last step-exact event in the chain. - The due time is quantized to a half-second grid ANCHORED AT THE DEATH. The instrument showed the naive anchor was four seconds stale by scheduling time: the fry chain reposts itself at wall-clock Now()+2.0 and the drop-zone reply lands about five sim-seconds after death, jittered by a few steps of queue timing. Firing "next step" inherited that jitter whole. Rounding up to the next half-second after the death puts hundredths of jitter against tenths of headroom, so every run lands in the same cell - and the felt delay stays the six-ish seconds it has always been. - The out-of-world tumble draws from a per-vehicle random stream seeded by creation order. The global Random is shared with the frame loop's consumers - particles, mostly - so its position at the moment a burning pod drew from it depended on how many frames had rendered, and the kick went straight into angular velocity. Last wall-clocked input in the whole death cycle. The respawn scheduling and firing log under RP412PHYSTRACE in run-comparable terms - pad identity, due offset, lateness - because those lines are what cracked this: "due in -4.06 sim-s" said more in one glance than three rounds of hypothesis. Still outside the claim: multi-vehicle contact (DynamicBounce writes the victim's state from the striker's step) and network play. That is the lockstep frontier, and it now has a harness waiting for it. Co-Authored-By: Claude Fable 5 --- RP/RPPLAYER.cpp | 111 ++++++++++++++++++++++++++++++++++++++++ RP/RPPLAYER.h | 12 +++++ RP/VTV.cpp | 131 ++++++++++++++++++++++++++++++++++++++++++++++-- RP/VTV.h | 84 +++++++++++++++++++++++++++++++ 4 files changed, 335 insertions(+), 3 deletions(-) diff --git a/RP/RPPLAYER.cpp b/RP/RPPLAYER.cpp index 40f13f4..45a6503 100644 --- a/RP/RPPLAYER.cpp +++ b/RP/RPPLAYER.cpp @@ -382,6 +382,96 @@ void RPPlayer::ResetAfterDeath(DropZone::ReplyMessage *message) ForceUpdate(); SetSimulationState(DropZoneAcquiredState); dropZoneLocation = message->dropZoneLocation; + + // + //------------------------------------------------------------------ + // Fixed-step: the RECOVERY goes on the vehicle's own step grid. + // + // The event queue runs on wall clock, so a Reset fired from it + // lands between different sim steps on every run - the crash was + // measured bit-identical between runs and the first divergence was + // the step after the pod stood back up. The vehicle applies the + // teleport itself at the first step one SIM second after now; the + // message still makes its round trip below, but only for the + // player-state bookkeeping - the handler leaves the physics to the + // schedule it can see is pending. + //------------------------------------------------------------------ + // + if (Simulation::FixedStep() > (Scalar) 0 && + playerVehicle != NULL && playerVehicle->GetClassID() == VTVClassID) + { + VTV *vtv = (VTV *) playerVehicle; + + // + // Anchored to the DEATH and quantized to a half-second grid. + // + // This handler runs when the drop-zone reply finally comes off + // the event queue, and the whole death-to-here chain is wall + // clock - the fry retries repost at Now()+2.0, and the measured + // arrival is about five sim-seconds after death, give or take a + // few STEPS of queue jitter. An anchor of death+1.0 is long past + // by then, so "fire at the next step" inherited the jitter + // whole. + // + // The death stamp is the last step-exact event in the chain, so: + // take the measured gap, add the second the old code waited, and + // round UP to the next half-second AFTER THE DEATH. The jitter + // is hundredths; the nearest grid boundary is tenths away; every + // run lands in the same cell and fires on the same step. The + // felt delay is the same six-ish seconds it has always been. + // + Time due; + Time death_mark; + if (vtv->ConsumeDeathClock(&death_mark)) + { + Scalar gap = vtv->GetLastPerformance() - death_mark; + const Scalar quantum = (Scalar) 0.5; + int cells = (int)((gap + (Scalar) 1.0) / quantum) + 1; + + due = death_mark; + due += quantum * (Scalar) cells; + } + else + { + due = vtv->GetLastPerformance(); + due += 1.0f; + } + vtv->ScheduleRespawn( + message->dropZoneLocation, due, + (goalEntity != NULL) + ? goalEntity->localOrigin.linearPosition + : Point3D(0.0f, 0.0f, 0.0f), + (goalEntity != NULL) ? True : False); + respawnScheduled = True; + + // + // Under the trace, say what was scheduled in run-comparable + // terms: the pad (identity by position), and how far ahead of + // the vehicle's clock the due time sits. Two runs that disagree + // here diverge before the physics gets a vote. + // + { + static int diag = -1; + if (diag < 0) + { + const char *setting = getenv("RP412PHYSTRACE"); + diag = (setting != NULL && atoi(setting) != 0) ? 1 : 0; + } + if (diag) + { + char buffer[160]; + sprintf(buffer, + "PhysTrace: respawn #%d scheduled, pad %.2f,%.2f " + "due in %.4f sim-s\n", + deathCount, + (double) message->dropZoneLocation.linearPosition.x, + (double) message->dropZoneLocation.linearPosition.z, + (double)(Scalar)(due - vtv->GetLastPerformance())); + DEBUG_STREAM << buffer << std::flush; + } + } + } + Time when = Now(); when += 1.0f; application->Post(HighEventPriority, this, message, when); @@ -422,6 +512,7 @@ void } AlwaysExecute(); deathCount = 0; + respawnScheduled = False; } // @@ -467,6 +558,26 @@ void { VTV *vtv = (VTV*)playerVehicle; Check(vtv); + + // + // A SCHEDULED respawn means the vehicle already holds - or has + // already applied - its teleport and goal-flip, on its own step + // grid. Resetting it AGAIN here, at whatever wall instant this + // message came off the queue, would re-teleport it mid-step and + // put the nondeterminism straight back. + // + // The flag, not "is fixed stepping on": this same leg also runs + // for the FIRST spawn of the mission, where the Reset below is + // what wakes a Mover out of its initial stasis. Gating on the + // mode alone skipped that wake-up and parked the pod, frozen at + // exactly its spawn point, for an entire race. + // + if (respawnScheduled) + { + respawnScheduled = False; + Check_Fpu(); + return; + } vtv->Reset(message->dropZoneLocation, VTV::RegularReset); } diff --git a/RP/RPPLAYER.h b/RP/RPPLAYER.h index dfe1eb2..f67dd76 100644 --- a/RP/RPPLAYER.h +++ b/RP/RPPLAYER.h @@ -291,6 +291,18 @@ public: Entity *goalEntity; + // + // True between ResetAfterDeath handing the recovery to the vehicle's + // step grid (fixed-step only) and the bookkeeping message coming back + // round. The message handler must NOT Reset the vehicle again in that + // window - but it MUST still Reset on the first spawn of the mission, + // which is what wakes a Mover out of its initial stasis. Gating on + // "is fixed stepping on" instead of on this flag skipped that wake-up + // and froze the pod on its pad for the whole race. + // + Logical + respawnScheduled; + private: static const IndexEntry AttributePointers[]; diff --git a/RP/VTV.cpp b/RP/VTV.cpp index d94e49f..91e0af0 100644 --- a/RP/VTV.cpp +++ b/RP/VTV.cpp @@ -803,9 +803,17 @@ void Check(*damageZones); (*damageZones)->TakeDamage(collision_damage); - localVelocity.angularMotion.x += 8.0f * Random - 4.0f; - localVelocity.angularMotion.y += 8.0f * Random - 4.0f; - localVelocity.angularMotion.z += 8.0f * Random - 4.0f; + // + // The tumble draws from the vehicle's OWN stream, not the + // global Random - the global one is shared with the frame + // loop's consumers (particles, mostly), so its position here + // depended on how many frames had rendered. This kick goes + // straight into physics state; it was the last wall-clocked + // input left in the whole death cycle. + // + localVelocity.angularMotion.x += 8.0f * TumbleRandom() - 4.0f; + localVelocity.angularMotion.y += 8.0f * TumbleRandom() - 4.0f; + localVelocity.angularMotion.z += 8.0f * TumbleRandom() - 4.0f; } } worldLinearAcceleration = zippy_accel; @@ -1273,6 +1281,18 @@ VTV::VTV( boosterSmokeDensity = 0.0f; doorHitNormal = Vector3D::Identity; lastDoorHit = Time::Null; + respawnPending = False; + respawnHaveGoal = False; + deathClockValid = False; + + // + // Creation order is deterministic, so each vehicle's tumble stream + // is too - see TumbleRandom in the header. + // + { + static unsigned long tumble_births = 0; + tumbleSeed = 0x52503431UL + 7919UL * ++tumble_births; + } heightAboveTerrain = 0.0f; forwardVelocity = 0.0f; hornBlast = -1; @@ -1685,6 +1705,101 @@ void Check_Fpu(); } +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +void + VTV::ScheduleRespawn( + const Origin &new_origin, + const Time &due, + const Point3D &face_toward, + Logical have_goal + ) +{ + Check(this); + Check(&new_origin); + + respawnOrigin = new_origin; + respawnDue = due; + respawnGoal = face_toward; + respawnHaveGoal = have_goal; + respawnPending = True; + Check_Fpu(); +} + +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +// The pending respawn lands here, at the top of the first STEP whose clock +// has reached its due time - the same step count after death on every run +// and every frame rate. The old path applied the Reset from the event +// queue, which runs on wall clock: the crash was deterministic and the +// recovery was not, measured as two identical runs diverging on the first +// step after the pod stood back up. +// +// The turn-to-face-the-scorezone flip is the same arithmetic +// RPPlayer::PointVTVTowardGoal does, done here because it reads the +// POST-reset heading - it belongs to the same step as the teleport. +// +void + VTV::BeginStep() +{ + Check(this); + + if (respawnPending && !(GetLastPerformance() < respawnDue)) + { + respawnPending = False; + + { + static int diag = -1; + if (diag < 0) + { + const char *setting = getenv("RP412PHYSTRACE"); + diag = (setting != NULL && atoi(setting) != 0) ? 1 : 0; + } + if (diag) + { + char buffer[120]; + sprintf(buffer, + "PhysTrace: respawn fired, %.4f sim-s late, pad %.2f,%.2f\n", + (double)(Scalar)(GetLastPerformance() - respawnDue), + (double) respawnOrigin.linearPosition.x, + (double) respawnOrigin.linearPosition.z); + DEBUG_STREAM << buffer << std::flush; + } + } + + Reset(respawnOrigin, RegularReset); + + if (respawnHaveGoal) + { + Vector3D to_goal; + to_goal.Subtract(respawnGoal, localOrigin.linearPosition); + + UnitVector current_heading; + localToWorld.GetFromAxis(Z_Axis, ¤t_heading); + + Scalar length_to_goal = to_goal.LengthSquared(); + if (length_to_goal > SMALL) + { + Scalar dot_prod = + (to_goal * current_heading) / Sqrt(length_to_goal); + if (dot_prod >= 0.0f) + { + Quaternion turn_around; + Quaternion y_roll(0.0f, 1.0f, 0.0f, 0.0); + + turn_around.Multiply( + localOrigin.angularPosition, y_roll); + localOrigin.angularPosition = turn_around; + localToWorld = localOrigin; + } + } + ForceUpdate(); + } + } + Mover::BeginStep(); + Check_Fpu(); +} + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // void @@ -3100,6 +3215,16 @@ void if (damageLevel >= 1.0f) { vtv->SetSimulationState(VTV::BurningState); + + // + // Stamp the death on the vehicle's own step clock, here at the + // one site that declares it dead. The respawn schedule anchors + // to this instant - the last step-exact event in the death + // chain - so the recovery lands the same number of steps after + // the crash on every run. Marked once; repeated damage while + // already burning does not move it. + // + vtv->MarkDeathClock(); } // diff --git a/RP/VTV.h b/RP/VTV.h index 67df69a..fd3a644 100644 --- a/RP/VTV.h +++ b/RP/VTV.h @@ -504,6 +504,58 @@ public: void Reset(const Origin &new_origin, int reset_command); + // + // A respawn that lands on this vehicle's own step grid. Under fixed + // stepping the player's death recovery cannot ride the event queue - + // the queue runs on wall clock, and a Reset that fires at a wall + // instant lands between different sim steps on every run. Scheduled + // here instead, BeginStep applies it at the first step whose clock + // reaches 'due': same step count after death, every run, every + // frame rate. The goal point rides along because the turn-to-face- + // the-scorezone flip depends on the POST-reset heading, so it has + // to happen in the same step as the teleport. + // + void + ScheduleRespawn( + const Origin &new_origin, + const Time &due, + const Point3D &face_toward, + Logical have_goal + ); + void + BeginStep(); + + // + // The instant this vehicle died, on its own step clock - stamped at + // the single site that sets BurningState, which runs inside the step + // machinery and is therefore already deterministic. The respawn + // schedule anchors HERE rather than at the moment the drop-zone + // reply happens to come off the event queue: the death is the last + // step-exact event in the chain, so "one second after death" is the + // same step count on every run. Marked once per death; consuming it + // re-arms it for the next one. + // + void + MarkDeathClock() + { + if (!deathClockValid) + { + deathClock = GetLastPerformance(); + deathClockValid = True; + } + } + Logical + ConsumeDeathClock(Time *when_out) + { + if (!deathClockValid) + { + return False; + } + *when_out = deathClock; + deathClockValid = False; + return True; + } + void DeathShutdown(int shutdown_command); @@ -514,6 +566,38 @@ public: //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Navigation support protected: + // the pending step-grid respawn - see ScheduleRespawn + Origin + respawnOrigin; + Time + respawnDue, + deathClock; + Point3D + respawnGoal; + Logical + respawnPending, + respawnHaveGoal, + deathClockValid; + + // + // The out-of-world tumble's own random stream. The global Random is + // shared with frame-cadence consumers - particles above all - so its + // position when a burning pod draws from it depends on how many + // frames have rendered, which is wall clock, which makes the tumble + // differ between identical runs. A per-vehicle generator seeded by + // creation order keeps the tumble looking random while drawing the + // same kicks at the same steps every run. + // + unsigned long + tumbleSeed; + + Scalar + TumbleRandom() + { + tumbleSeed = tumbleSeed * 1103515245UL + 12345UL; + return (Scalar)((tumbleSeed >> 16) & 0x7FFF) / (Scalar) 32767; + } + Scalar targetRangeExponent, currentRangeExponent;