From 2a679ab5719a56792134e5a81abd10bf5f4a12f7 Mon Sep 17 00:00:00 2001 From: Cyd Date: Sun, 9 Aug 2026 18:20:03 -0500 Subject: [PATCH] A race can be replayed exactly, so physics claims can be tested Three pieces of harness, all env-gated and inert in normal play, that turn "do two frame rates play the same race?" from an argument into a number: - RP412PHYSTRACE=1 samples the player vehicle's position on the SIMULATION's own clock - the vehicle's lastPerformance, which advances in whole fixed steps - so two runs sample at identical step counts and their traces compare exactly. Frame-time sampling compares different instants and calls the difference physics; an earlier version of this trace did exactly that, and its noise was chased as if it were drift. At the green light it stops the vehicle dead, because the pod simulates on its pad while the mission loads and a load is never the same length twice: two runs reached the start 776 and 599 steps in, same position, different velocity. - RP412SPAWNZONE pins which drop zone is tried first. The pick is Random(), and Random() is seeded - but a seed only repeats a run if the same NUMBER of draws precedes the pick, and that count rides on load timing. Same seed, different pad, incomparable traces. Pinned, the zone is tried first and falls back to the random walk if taken, so it cannot wedge and changes nothing unless set. - The trace prints the global step counter, which is what caught the force-accumulator bug: the position columns can look plausible while the step column says the physics ran a different number of times. With these three and RANDOM= (which already existed), a race is repeatable to the bit, and the determinism matrix - rates by frame rates by repeats, run as parallel sandboxed instances - is a regression suite: any mismatch in any cell is a real bug. Co-Authored-By: Claude Fable 5 --- MUNGA/APP.cpp | 127 +++++++++++++++++++++++++++++++++++++++++++++ MUNGA/DROPZONE.cpp | 40 +++++++++++++- 2 files changed, 166 insertions(+), 1 deletion(-) diff --git a/MUNGA/APP.cpp b/MUNGA/APP.cpp index 66c301f..ad438b5 100644 --- a/MUNGA/APP.cpp +++ b/MUNGA/APP.cpp @@ -606,6 +606,133 @@ Time startUpdate = Now(); updateManager->Execute(start_of_frame); Time endUpdate = Now(); + // + //-------------------------------------------------------------------------- + // RP412PHYSTRACE=1: the player's position, sampled on the SIMULATION's + // own clock rather than per frame. + // + // This is the acceptance test for decoupling physics from frame rate. + // Run the same egg at two frame rates and diff the traces: today they + // diverge, because the simulation advances by whatever the last frame + // happened to cost (SIMULATE.cpp, slice = till - lastPerformance), so a + // 30 fps machine integrates in 33 ms steps and a 144 fps machine in 7 ms + // ones and they are not the same race. Fixed-step them and the two + // traces have to agree. + // + // Sampled every 0.25 s of SIM time on purpose - sampling per frame would + // compare different instants and prove nothing. + //-------------------------------------------------------------------------- + // + { + static int physTrace = -1; + + if (physTrace < 0) + { + const char *setting = getenv("RP412PHYSTRACE"); + physTrace = (setting != NULL && atoi(setting) != 0) ? 1 : 0; + } + if (physTrace && GetApplicationState() == RunningMission) + { + static Logical traceStarted = False; + static Time traceOrigin; + static Scalar traceDue = (Scalar) 0; + + if (!traceStarted) + { + traceStarted = True; + traceOrigin = start_of_frame; + traceDue = (Scalar) 0; + + // + //---------------------------------------------------------- + // Start the measurement from a known state, not merely a + // known place. + // + // The pod sits on its pad simulating while the mission + // loads, and a load is not the same length twice - two runs + // of the same egg reached the green light 776 steps in and + // 599 steps in. Same pad, same position, different VELOCITY, + // and a trajectory compared from there measures the loader, + // not the physics. + // + // So: stop the vehicle dead and put its clock on the same + // mark. Every run then starts from rest at the same instant + // and any difference that follows belongs to the simulation. + // + // Test scaffolding, and it only runs with the trace asked + // for - it would be a cheat in a real race. + //---------------------------------------------------------- + // + Player *reset_player = GetMissionPlayer(); + Entity *reset_vehicle = + (reset_player != NULL) + ? reset_player->GetPlayerVehicle() : NULL; + + if (reset_vehicle != NULL && + reset_vehicle->IsDerivedFrom(*Mover::GetClassDerivations())) + { + Mover *reset_mover = (Mover *) reset_vehicle; + + reset_mover->localVelocity = Motion::Identity; + reset_mover->localAcceleration = Motion::Identity; + + // + // The clock is NOT touched. lastPerformance sits on the + // vehicle's own step grid and the trace reads that grid + // instead. The first version forced it to the frame + // timestamp, which knocked the vehicle off its grid by + // a random fraction of a step per run - and that read + // as physics drift when it was only ever measurement. + // + traceOrigin = reset_mover->GetLastPerformance(); + + DEBUG_STREAM << "PhysTrace: vehicle stopped " + << "at the green light\n" << std::flush; + } + } + + // + // Sampled on the SIMULATION's clock - the vehicle's own + // lastPerformance, which advances in whole fixed steps - so two + // runs sample at identical step counts and their traces compare + // exactly. Frame time samples mid-step at whatever phase the + // frame happened to land on, which compares different instants + // and calls the difference physics. + // + Player *clock_player = GetMissionPlayer(); + Entity *clock_vehicle = + (clock_player != NULL) ? clock_player->GetPlayerVehicle() : NULL; + + Scalar elapsed = + (clock_vehicle != NULL) + ? (Scalar)(clock_vehicle->GetLastPerformance() - traceOrigin) + : (Scalar)(start_of_frame - traceOrigin); + if (elapsed >= traceDue) + { + traceDue += (Scalar) 0.25; + + Player *trace_player = GetMissionPlayer(); + Entity *trace_vehicle = + (trace_player != NULL) ? trace_player->GetPlayerVehicle() : NULL; + + if (trace_vehicle != NULL) + { + extern long gPhysicsStepsTaken; + char buffer[160]; + + sprintf(buffer, + "PhysTrace: t=%7.3f steps=%6ld pos %12.5f %12.5f %12.5f\n", + (double) elapsed, + gPhysicsStepsTaken, + (double) trace_vehicle->localOrigin.linearPosition.x, + (double) trace_vehicle->localOrigin.linearPosition.y, + (double) trace_vehicle->localOrigin.linearPosition.z); + DEBUG_STREAM << buffer << std::flush; + } + } + } + } + CLEAR_UPDATE_MANAGER(); // diff --git a/MUNGA/DROPZONE.cpp b/MUNGA/DROPZONE.cpp index 2e9c1ff..5cdd1e0 100644 --- a/MUNGA/DROPZONE.cpp +++ b/MUNGA/DROPZONE.cpp @@ -224,9 +224,47 @@ void //----------------------------------------------------- // highest = highest - lowest + 1; + + // + //------------------------------------------------------------------------ + // RP412SPAWNZONE pins which drop zone is tried first, so a test run can + // be repeated. + // + // The pick below is Random(), and Random() is seeded - but the seed only + // makes a run repeatable if the same NUMBER of draws happens first, and + // that depends on how many frames the mission load took. So two runs of + // the same egg with the same RANDOM= still start on different pads, over + // different ground, and no two traces can be compared. That is not a + // game bug, but it makes the physics unmeasurable. + // + // Pinned, the zone is tried first and the loop falls back to the random + // walk if it is taken - so this can never wedge, and it changes nothing + // unless it is set. + //------------------------------------------------------------------------ + // + static int + pinnedZone = -2; + + if (pinnedZone == -2) + { + const char *setting = getenv("RP412SPAWNZONE"); + pinnedZone = (setting != NULL) ? atoi(setting) : -1; + } + + Logical + tryPinnedZone = (pinnedZone >= 0) ? True : False; + while (remaining) { - i = lowest + Random(highest); + if (tryPinnedZone) + { + tryPinnedZone = False; + i = lowest + (pinnedZone % highest); + } + else + { + i = lowest + Random(highest); + } Verify(i < dropZoneCount && i >= 0); if (IsAvailable(i)) {