Game/ReplayRegression.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace BlockParty;
/// <summary>One per-sim-second checkpoint of a regression trace: the cumulative state hash at that
/// recorded-input step, plus the player position so a divergence report reads as "player was HERE,
/// is now THERE" instead of just a hash mismatch.</summary>
public sealed class RegressionCheckpoint
{
public int Step { get; set; }
public uint Hash { get; set; }
public float PlayerX { get; set; }
public float PlayerY { get; set; }
}
/// <summary>Golden trace of one replay simulated headless: per-second checkpoints plus how and where
/// the run ended. Two traces of the same replay on the same sim behaviour are bit-identical, so any
/// difference IS a behaviour change.</summary>
public sealed class RegressionTrace
{
/// <summary>Human-readable identity for reports (submitter, score, level, length).</summary>
public string Label { get; set; }
/// <summary>Recorded-frame index the run ended at (game-over reproduced or input exhausted).</summary>
public int FinishedAtStep { get; set; }
/// <summary>Cumulative state hash over the whole run (covers the tail past the last checkpoint).</summary>
public uint FinalHash { get; set; }
/// <summary>Whether playback flagged a desync against the RECORDING itself (score mismatch /
/// input exhausted early). A baseline recorded with this true is suspect — the replay didn't
/// reproduce its original run even when blessed.</summary>
public bool DesyncedVsRecording { get; set; }
public List<RegressionCheckpoint> Checkpoints { get; set; } = new();
}
/// <summary>The persisted baseline file: every blessed trace keyed by replay identity.</summary>
public sealed class RegressionBaselines
{
public int SimVersion { get; set; }
public string BlessedAt { get; set; }
public Dictionary<string, RegressionTrace> Traces { get; set; } = new();
}
/// <summary>
/// Replay-based physics regression harness. The sim is deterministic, so every saved replay doubles
/// as a physics test: <c>replay_bless</c> runs each replayable run in the local archive headless
/// (no rendering, no audio) and stores a golden trace of the sim state; after changing anything that
/// touches the simulation, <c>replay_verify</c> re-runs them and reports the first second at which
/// each run's state drifts from its blessed trace. An intentional behaviour change is accepted by
/// re-blessing. Favourited repro replays make ideal permanent fixtures (favourites are never trimmed
/// from the archive). The heavy lifting (driving the sim) lives in
/// <see cref="GameManager.RunReplayRegression"/> so it reuses the exact playback step path.
/// </summary>
public static class ReplayRegression
{
public const string BaselineFilePath = "blockparty_replay_baselines.json";
/// <summary>Checkpoint cadence in recorded steps: once per sim-second localises a divergence to
/// the second it first appears while keeping a 10-minute run's trace to ≤600 checkpoints.</summary>
public const int CHECKPOINT_EVERY = Arena.TICK_RATE;
// FNV-1a, folded 32 bits at a time — we need cheap and deterministic, not cryptographic.
public const uint FNV_OFFSET = 2166136261u;
private const uint FNV_PRIME = 16777619u;
/// <summary>Record the current sim behaviour of every replayable local run as the golden baselines.</summary>
[ConCmd( "replay_bless" )]
public static void BlessCmd()
{
if ( !Game.IsEditor ) return;
GameManager.Instance?.RunReplayRegression( bless: true );
}
/// <summary>Re-run every replayable local run and report drift against the blessed baselines.</summary>
[ConCmd( "replay_verify" )]
public static void VerifyCmd()
{
if ( !Game.IsEditor ) return;
GameManager.Instance?.RunReplayRegression( bless: false );
}
/// <summary>Delete every archived run recorded before <see cref="Sim.MIN_REPLAY_VERSION"/>. Those
/// can never replay again (no gate reaches below the release version), yet the archive keeps them
/// and the My Replays screen hides them, so they'd otherwise pad the harness's skip count forever.
/// Favourites go too — a heart can't keep a run the sim no longer understands.</summary>
[ConCmd( "replay_purge_stale" )]
public static void PurgeStaleCmd()
{
if ( !Game.IsEditor ) return;
int removed = LocalReplays.PurgeBelowMinVersion( out int favourites );
string favNote = favourites > 0 ? $" ({favourites} favourite(s) among them)" : "";
Log.Info( $"[replay-regression] purged {removed} archived run(s) recorded before sim v{Sim.MIN_REPLAY_VERSION}{favNote}; {LocalReplays.All.Count} remain." );
}
/// <summary>Stable identity of a replay payload: level + seed + a hash of the full deterministic
/// context (input stream, length, character, daily, post-release sim version, and — for test-play
/// replays — the embedded level itself). Survives archive re-ordering and renames.</summary>
public static string KeyFor( RunData run )
{
uint h = FNV_OFFSET;
h = FoldString( h, run.InputDeltas );
h = Fold( h, run.Seed );
h = Fold( h, run.StepCount );
h = FoldString( h, run.LevelId );
h = FoldString( h, run.DailyId );
h = FoldString( h, run.CharacterId );
// A test-play replay's geometry is the embedded (mid-edit) level, not the on-disk one its id
// names — fold its canonical JSON so two replays sharing an id but different embedded geometry
// can't collide on one baseline. Folded only when present, so normal replays' keys are unchanged.
if ( run.LevelData is not null )
h = FoldString( h, EditorLevel.FromLevelDef( run.LevelData ).ToJsonString() );
// The recorded sim version picks the physics path, so it's identity too: the same payload on two
// versions must not share a trace. Folded only past the release version so every v40 key — and
// every baseline blessed at release — stays valid.
if ( run.SimVersion > Sim.MIN_REPLAY_VERSION )
h = Fold( h, run.SimVersion );
return $"{run.LevelId ?? "classic"}-s{run.Seed}-{h:X8}";
}
public static string LabelFor( LocalReplayEntry entry )
{
var run = entry.Run;
string who = string.IsNullOrWhiteSpace( entry.DisplayName ) ? "?" : entry.DisplayName;
string level = string.IsNullOrEmpty( run.LevelId ) ? "classic" : run.LevelId;
return $"{who}: {run.FinalScore} on {level}, {run.StepCount} steps, seed {run.Seed}";
}
/// <summary>Fold one just-ticked sim step's gameplay state into the rolling hash: player and
/// impostor kinematics plus every block's position and phase. Deliberately excludes cosmetic
/// state (particles, shakes, sprites) — the trace tracks the SIMULATION.</summary>
public static uint FoldStep( GameStage gs, uint h )
{
if ( gs.Players.Count <= 1 )
{
var p = gs.Player;
if ( p is not null )
{
h = Fold( h, BitConverter.SingleToInt32Bits( p.X ) );
h = Fold( h, BitConverter.SingleToInt32Bits( p.Y ) );
h = Fold( h, BitConverter.SingleToInt32Bits( p.VelX ) );
h = Fold( h, BitConverter.SingleToInt32Bits( p.VelY ) );
// Death timing matters to the trace like it already does in the multi-player branch.
// NOTE: changed every baseline hash — re-bless.
h = Fold( h, p.IsDead ? 1 : 0 );
}
else
{
h = Fold( h, unchecked((int)0xDEADDEAD) );
}
}
else
{
foreach ( var p in gs.Players )
{
h = Fold( h, BitConverter.SingleToInt32Bits( p.X ) );
h = Fold( h, BitConverter.SingleToInt32Bits( p.Y ) );
h = Fold( h, BitConverter.SingleToInt32Bits( p.VelX ) );
h = Fold( h, BitConverter.SingleToInt32Bits( p.VelY ) );
h = Fold( h, p.IsDead ? 1 : 0 );
}
h = Fold( h, gs.ActivePlayerIndex );
}
foreach ( var imp in gs.Impostors )
{
h = Fold( h, BitConverter.SingleToInt32Bits( imp.X ) );
h = Fold( h, BitConverter.SingleToInt32Bits( imp.Y ) );
}
foreach ( var b in gs.GetBlocks() )
{
h = Fold( h, BitConverter.SingleToInt32Bits( b.X ) );
h = Fold( h, BitConverter.SingleToInt32Bits( b.Y ) );
h = Fold( h, b.Phase );
// Side state is score-bearing sim state that phase alone misses: the ws3790638117 desyncs were a
// single press differing with IDENTICAL positions/phases, invisible to the checkpoints until the
// pressed sides were folded. Folds each side's full mode + suspended-press flag. NOTE: changes
// every baseline hash — re-bless.
h = Fold( h, b.SideStateCode() );
}
// Coins are sim state too (pickup order/timing feeds the score): fold the running counter and
// each surviving coin's position. NOTE: adding this changed every baseline hash — re-bless
// (replay_bless) after this change.
h = Fold( h, gs.CoinsCollected );
foreach ( var c in gs.Coins )
{
if ( c.Dead ) continue;
h = Fold( h, BitConverter.SingleToInt32Bits( c.X ) );
h = Fold( h, BitConverter.SingleToInt32Bits( c.Y ) );
}
return h;
}
public static void SaveBaselines( Dictionary<string, RegressionTrace> traces, double seconds )
{
var data = new RegressionBaselines
{
SimVersion = Sim.VERSION,
BlessedAt = DateTimeOffset.Now.ToString( "u" ),
Traces = traces,
};
FileSystem.Data.WriteJson( BaselineFilePath, data );
int suspect = 0;
foreach ( var t in traces.Values )
if ( t.DesyncedVsRecording )
suspect++;
string suspectNote = suspect > 0
? $" WARNING: {suspect} run(s) desynced against their own recording while blessing — those baselines capture already-drifted behaviour."
: "";
Log.Info( $"[replay-regression] BLESSED {traces.Count} run(s) as golden baselines (sim v{data.SimVersion}, {seconds:F1}s).{suspectNote} After a sim change, run replay_verify." );
}
/// <summary>"61 skipped: 60 below sim v40, 1 unusable" — one clause per non-zero reason, in enum
/// order. Empty when nothing was skipped.</summary>
public static string DescribeSkipped( List<(LocalReplayEntry Entry, ReplayUnavailableReason Why)> skipped )
{
if ( skipped.Count == 0 ) return "";
var parts = skipped
.GroupBy( s => s.Why )
.OrderBy( g => g.Key )
.Select( g => g.Key switch
{
ReplayUnavailableReason.TooOld => $"{g.Count()} below sim v{Sim.MIN_REPLAY_VERSION} (replay_purge_stale removes these)",
ReplayUnavailableReason.TooNew => $"{g.Count()} newer than sim v{Sim.VERSION}",
ReplayUnavailableReason.LevelChanged => $"{g.Count()} level changed since recording",
ReplayUnavailableReason.Unusable => $"{g.Count()} unusable (no input, corrupt, or removed level)",
_ => $"{g.Count()} {g.Key}",
} );
return $"{skipped.Count} skipped: {string.Join( ", ", parts )}";
}
/// <summary>One line per skipped run that ISN'T merely pre-release — those are bulk noise the
/// purge handles; the rest are individually worth a look (a level edit, an empty recording).</summary>
private static void LogSkippedDetails( List<(LocalReplayEntry Entry, ReplayUnavailableReason Why)> skipped )
{
foreach ( var (entry, why) in skipped )
{
if ( why == ReplayUnavailableReason.TooOld ) continue;
string reason = why switch
{
ReplayUnavailableReason.LevelChanged => entry.Run.ReplayOutdatedReason,
ReplayUnavailableReason.TooNew => entry.Run.ReplayOutdatedReason,
ReplayUnavailableReason.Unusable when string.IsNullOrEmpty( entry.Run.InputDeltas ) => "no recorded input",
ReplayUnavailableReason.Unusable => "corrupt payload or removed level",
_ => why.ToString(),
};
Log.Info( $"[replay-regression] skipped '{LabelFor( entry )}' — {reason}" );
}
}
public static void CompareAndReport( Dictionary<string, RegressionTrace> current, List<(LocalReplayEntry Entry, ReplayUnavailableReason Why)> skipped, double seconds )
{
var baselines = FileSystem.Data.ReadJsonOrDefault<RegressionBaselines>( BaselineFilePath, null );
if ( baselines?.Traces is null || baselines.Traces.Count == 0 )
{
Log.Warning( "[replay-regression] no baselines found — run replay_bless first (on a known-good build)." );
return;
}
// Each run re-sims at its RECORDED version (its gates take the old paths), so baselines blessed
// on an older build must still match — a divergence here is a broken gate, not an expected drift.
if ( baselines.SimVersion != Sim.VERSION )
Log.Info( $"[replay-regression] baselines were blessed on sim v{baselines.SimVersion}; this build is v{Sim.VERSION}. Runs recorded on older versions replay through their version gates and must still match — any divergence below means a gate leaks the new behaviour into old replays." );
int ok = 0;
var fresh = new List<string>();
var diverged = new List<string>();
foreach ( var (key, now) in current )
{
if ( !baselines.Traces.TryGetValue( key, out var was ) )
{
fresh.Add( now.Label );
continue;
}
string drift = DescribeDivergence( was, now );
if ( drift is null ) ok++;
else diverged.Add( $"'{now.Label}' — {drift}" );
}
int missing = 0;
foreach ( var key in baselines.Traces.Keys )
if ( !current.ContainsKey( key ) )
missing++;
string skippedDesc = DescribeSkipped( skipped );
string staleNote = skippedDesc.Length > 0 ? $", {skippedDesc}" : "";
string missingNote = missing > 0 ? $", {missing} baseline(s) have no matching archived replay any more" : "";
string freshNote = fresh.Count > 0 ? $", {fresh.Count} new (no baseline — bless to include)" : "";
if ( diverged.Count == 0 )
Log.Info( $"[replay-regression] VERIFY OK: {ok}/{current.Count} run(s) match their baselines ({seconds:F1}s{staleNote}{freshNote}{missingNote})." );
else
{
Log.Warning( $"[replay-regression] VERIFY: {diverged.Count} of {current.Count} run(s) DIVERGED from baseline, {ok} OK ({seconds:F1}s{staleNote}{freshNote}{missingNote}):" );
foreach ( var line in diverged )
Log.Warning( $"[replay-regression] {line}" );
}
LogSkippedDetails( skipped );
}
/// <summary>Null when the traces match; otherwise a one-line description of the earliest drift.</summary>
private static string DescribeDivergence( RegressionTrace was, RegressionTrace now )
{
int common = Math.Min( was.Checkpoints.Count, now.Checkpoints.Count );
for ( int i = 0; i < common; i++ )
{
var b = was.Checkpoints[i];
var c = now.Checkpoints[i];
if ( b.Hash == c.Hash )
continue;
float t = b.Step / (float)Arena.TICK_RATE;
return $"first drift by step {b.Step} (t={t:F1}s): player was ({b.PlayerX:F2},{b.PlayerY:F2}), now ({c.PlayerX:F2},{c.PlayerY:F2})";
}
if ( was.Checkpoints.Count != now.Checkpoints.Count )
return $"run length changed after the last matching checkpoint: {now.Checkpoints.Count} checkpoint(s) vs baseline {was.Checkpoints.Count} (finished at step {now.FinishedAtStep} vs {was.FinishedAtStep})";
if ( now.FinishedAtStep != was.FinishedAtStep )
return $"finished at step {now.FinishedAtStep} vs baseline {was.FinishedAtStep} (checkpoints all match — drift is in the final partial second)";
if ( now.FinalHash != was.FinalHash )
return "tail state diverged after the last checkpoint (final hash mismatch)";
if ( now.DesyncedVsRecording != was.DesyncedVsRecording )
return $"recording desync flag changed: now {now.DesyncedVsRecording}, baseline {was.DesyncedVsRecording}";
return null;
}
private static uint Fold( uint h, int v )
{
unchecked
{
h ^= (uint)v;
h *= FNV_PRIME;
}
return h;
}
private static uint FoldString( uint h, string s )
{
if ( string.IsNullOrEmpty( s ) )
return Fold( h, -1 );
foreach ( char c in s )
h = Fold( h, c );
return h;
}
}