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 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-09 21:42:04 -05:00
co-authored by Claude Fable 5
parent af52476603
commit c479cd48e9
4 changed files with 335 additions and 3 deletions
+128 -3
View File
@@ -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, &current_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();
}
//