Game/DesyncReport.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace BlockParty;
/// <summary>
/// A replay-desync bug report written to <c>logs/</c> in the game data folder whenever a watched
/// replay fails its determinism check (see <see cref="GameManager"/>'s CheckReplayDesync), in every
/// build — a beta tester can just send the file. Self-contained for reproduction: the full run
/// payload (seed + recorded input, importable as a local replay), the resolved level's canonical
/// JSON, the recorded-vs-reproduced scores and block states, and — when the run was played THIS
/// session — the live/replay checkpoint trails with the first divergent step window.
/// </summary>
public static class DesyncReport
{
/// <summary>Report files live in their own subfolder of the game data directory, beside
/// <see cref="LocalLevels.DirName"/> and the gif exports.</summary>
public const string DirName = "logs";
private sealed class Report
{
public string CreatedAt { get; set; }
public int SimVersion { get; set; }
public int RunDataVersion { get; set; }
public string GameIdent { get; set; }
public bool IsEditor { get; set; }
public int RecordedScore { get; set; }
public int ReproducedScore { get; set; }
public string RecordedBlocks { get; set; }
public string ReproducedBlocks { get; set; }
/// <summary>First step of the one-second window where the same-session trace saw the sim
/// diverge; -1 when the trace couldn't compare (run not from this session).</summary>
public int DivergenceStep { get; set; }
public string LiveCheckpoints { get; set; }
public string ReplayCheckpoints { get; set; }
/// <summary>Every awarded side press, in order (step/block/side) — live run vs replay. The
/// first entry where the two disagree is the divergence.</summary>
public string LivePressEvents { get; set; }
public string ReplayPressEvents { get; set; }
/// <summary>Canonical level JSON the replay resolved (what the re-sim actually played).</summary>
public string LevelJson { get; set; }
/// <summary>The complete replay payload — drop into blockparty_replays.json (or hand to the
/// developer) to re-watch this exact recording.</summary>
public RunData Run { get; set; }
}
/// <summary>Write one report file (<c>logs/desync_s{seed}_{n}.json</c>) and log its full path.
/// Never throws — diagnostics must not take down the replay end path.</summary>
public static void Save( RunData run, int recordedScore, int reproducedScore,
IReadOnlyList<BlockResult> reproducedBlocks, int divergenceStep,
IReadOnlyList<uint> liveCheckpoints, IReadOnlyList<uint> replayCheckpoints,
string livePressEvents = null, string replayPressEvents = null )
{
if ( run is null ) return;
try
{
var report = new Report
{
CreatedAt = DateTimeOffset.Now.ToString( "u" ),
SimVersion = Sim.VERSION,
RunDataVersion = RunData.CURRENT_VERSION,
GameIdent = Game.Ident,
IsEditor = Game.IsEditor,
RecordedScore = recordedScore,
ReproducedScore = reproducedScore,
RecordedBlocks = FormatBlocks( run.DecodeBlocks() ),
ReproducedBlocks = reproducedBlocks is null
? "(replay input exhausted before game-over)"
: FormatBlocks( new RunData { Blocks = RunData.PackBlocks( reproducedBlocks ) }.DecodeBlocks() ),
DivergenceStep = divergenceStep,
LiveCheckpoints = FormatCheckpoints( liveCheckpoints ),
ReplayCheckpoints = FormatCheckpoints( replayCheckpoints ),
LivePressEvents = livePressEvents,
ReplayPressEvents = replayPressEvents,
LevelJson = run.Level is not null ? EditorLevel.FromLevelDef( run.Level ).ToJsonString() : null,
Run = run,
};
FileSystem.Data.CreateDirectory( DirName );
// One file per distinct watch of a run; a low counter keeps repeat watches from piling up.
string path = null;
for ( int i = 0; i < 10; i++ )
{
string candidate = $"{DirName}/desync_s{run.Seed}_{i}.json";
if ( FileSystem.Data.FileExists( candidate ) ) continue;
path = candidate;
break;
}
path ??= $"{DirName}/desync_s{run.Seed}_9.json";
FileSystem.Data.WriteJson( path, report );
Log.Warning( $"BlockParty: desync report saved to {FileSystem.Data.GetFullPath( path )} — please send this file to the developer." );
}
catch ( Exception ex )
{
Log.Warning( $"BlockParty: couldn't save desync report: {ex.Message}" );
}
}
private static string FormatBlocks( List<BlockTallyState> blocks ) =>
blocks is { Count: > 0 }
? string.Join( " ", blocks.Select( b =>
$"{b.Type} p{b.Phase} {(b.Left ? "L" : "-")}{(b.Right ? "R" : "-")}{(b.Up ? "U" : "-")}{(b.Down ? "D" : "-")}" ) )
: "(none)";
// One hex hash per sim-second, space-separated — diffing the two strings by eye shows the first
// divergent second even without the DivergenceStep field.
private static string FormatCheckpoints( IReadOnlyList<uint> checkpoints ) =>
checkpoints is { Count: > 0 }
? string.Join( " ", checkpoints.Select( c => c.ToString( "x8" ) ) )
: "";
}