Game/GameManager.cs
namespace BlockParty;

/// <summary>
/// Root of the game. Owns the orthographic 2D camera, the deterministic fixed-step
/// simulation loop, and the active <see cref="StageBase"/> (title / game / score ...).
///
/// The whole game is tuned for a fixed 60Hz step with a precise per-entity update order,
/// so we accumulate real time and run discrete <see cref="Arena.STEP"/> ticks here rather
/// than relying on sbox's OnUpdate/OnFixedUpdate cadence.
/// </summary>
/// <summary>Who submitted a leaderboard run, carried into a replay so the HUD can show the
/// submitter's avatar, name, country and submission date.</summary>
public readonly record struct ReplaySubmitter( long SteamId, string Name, string CountryCode, System.DateTimeOffset SubmittedAt );

/// <summary>Remembered GIF-export overlay settings (the span heads), kept while a single replay is
/// being watched so reopening the overlay restores them; forgotten when the replay ends or a
/// different one starts.</summary>
public readonly record struct GifExportConfig( int StartTick, int EndTick );

public sealed class GameManager : Component
{
	public static GameManager Instance { get; private set; }

	[Property] public int Seed { get; set; } = 1;

	/// <summary>Letterbox / pillarbox colour shown OUTSIDE the square play area. Kept much darker
	/// than both the playfield and the (blue-grey) arena walls so the 240x240 game always reads as a
	/// clean fixed square with a crisp boundary. The edge-blocker quads (StageBase) are colour-matched
	/// to this so anything poking past the arena edge is masked invisibly.</summary>
	[Property] public Color ClearColor { get; set; } = new Color( 0f, 32f / 255f, 40f / 255f );

	public CameraComponent Camera { get; private set; }
	public StageBase Stage { get; private set; }

	// Editor level-browser MAP scroll, scoped to THIS play session (the GameManager is recreated each
	// time the game is launched, but persists across stage changes — game / leaderboards / editor — within
	// a session). So the map view centres + bottoms on the FIRST view of a session and restores this
	// in-session scroll on later views. Lives here (not a C# static, which survives across editor Play
	// sessions) so a new Play session starts centred again. Scroll stored in 1080-reference px.
	public bool LevelBrowserMapShown { get; set; }
	public Vector2 LevelBrowserScroll { get; set; }

	private StageBase _pendingStage;
	private float _accumulator;
	public GameplayTimeScale GameplayTime { get; } = new();

	// --- stage-transition wipe ----------------------------------------------------------
	// Port of the original BaseStage FadeOut/FadeIn: a centred square (TransitionOverlay) grows to
	// cover the view when LEAVING a visible stage, and shrinks to reveal it when ENTERING one.
	// Each half is opt-in per stage (like the original's independent coroutines), driven by
	// StageBase.CoveredBackground: "covered" stages (score tally / leaderboard) paint their own dark
	// fade-colour backdrop, so the square appears to PERSIST across them — it grows over the game on
	// game-over, the tally/leaderboard live on the same dark fill, then it shrinks away to the menu.
	//   - leaving a NON-covered stage  -> grow the square (cover) so its content is hidden for the swap
	//   - entering a NON-covered stage -> shrink the square (reveal) to uncover it
	//   - covered<->covered            -> instant swap, no animation (both share the same dark fill)
	private enum TransitionPhase { None, Covering, Revealing }
	private TransitionPhase _transPhase = TransitionPhase.None;
	private float _transTimer;
	private float _cover;             // current square coverage, 0 (hidden) .. 1 (full)
	private StageBase _transNext;
	private bool _revealAfter;        // reveal the next stage after covering, or leave it covered?
	private bool _hideOverlayOnSwap;  // drop the overlay the frame the next (self-covered) stage applies
	private TransitionOverlay _transition;
	private GifExportOverlay _gifOverlay;
	private UnlockRevealOverlay _unlockOverlay;

	/// <summary>The shared "YOU UNLOCKED" reveal, drawn over whatever stage is up. Owned here (not by
	/// a stage) because a reveal outlives the screen that started it — see <see cref="UnlockReveal"/>
	/// and <see cref="TickUnlockReveal"/>.</summary>
	public UnlockReveal UnlockReveal { get; } = new();

	/// <summary>GIF-export overlay settings remembered across overlay open/close within the current replay
	/// session (null = use defaults). Cleared when the replay ends or a different run is launched.</summary>
	public GifExportConfig? SavedGifConfig { get; set; }

	// Duration of EACH half of the wipe (cover, then reveal). Kept shorter than the original for
	// snappier menu/stage changes while preserving the same easing and covered-stage rules.
	private const float TRANSITION_HALF = 0.32f;

	/// <summary>True while a stage-transition wipe is in progress; stages lock out input during it.</summary>
	public bool IsTransitioning => _transPhase != TransitionPhase.None;

	/// <summary>The FULL condition under which <see cref="TransitionToStage"/> refuses: a wipe is
	/// running, or a completed cover is holding the screen for the one frame until its self-covered
	/// stage applies (<see cref="_hideOverlayOnSwap"/> — IsTransitioning is already false there).
	/// Any method that mutates state and then transitions must gate on THIS, not IsTransitioning
	/// alone, or that one-frame window lands the side effects while the transition is silently
	/// swallowed (a counted daily attempt with no run, replay state with no replay stage).</summary>
	public bool StageSwapPending => IsTransitioning || _hideOverlayOnSwap;

	// Cap the number of catch-up steps per frame to avoid a spiral of death after a hitch.
	private const int MAX_STEPS_PER_FRAME = 5;

	// --- hold-to-restart (live runs only) -------------------------------------------------
	// During an ACTIVE live run a bare R tap would be an expensive mis-press, so R must be HELD for
	// the configured delay to restart (zero is instant); after game-over R stays instant. Replays and editor tests
	// keep their own instant restart paths. The sim keeps running during the hold — the HUD just
	// dims the arena and fills a progress bar off RestartHoldAlpha/RestartHoldProgress.
	// Maximum fade time; shortened to 30% of the configured hold for quick restarts.
	private const float RESTART_HOLD_FADE_SECONDS = 0.15f;

	private bool _restartHoldArmed;     // a fresh R press started this hold (a key still held across a restart can't chain another)
	private float _restartHoldTime;     // seconds R has been held this attempt
	private float _restartHoldAlpha;    // overlay fade level 0..1
	private float _restartHoldProgress; // displayed bar fill 0..1 (frozen during the release fade-out)

	/// <summary>Fade level (0 = hidden) of the hold-to-restart overlay; GameHud draws off this.</summary>
	public float RestartHoldAlpha => _restartHoldAlpha;

	/// <summary>Fill (0..1) of the hold-to-restart progress bar. Freezes at its last value while the
	/// overlay fades out after a release, so the bar doesn't snap empty mid-fade.</summary>
	public float RestartHoldProgress => _restartHoldProgress;

	// Replay transport. Playback rate is driven by a slider (0..1) mapped logarithmically so the
	// centre is exactly 1x (left = 0.1x, right = 8x). Holding Left/Right lerps the slider toward an
	// end; releasing lerps it back to centre. The fast end needs a higher per-frame step budget than
	// live play so a sped-up replay isn't throttled (and so dropped) by the live cap at low frame
	// rates: max steps/frame ≈ REPLAY_MAX_SPEED × (Time.Delta / STEP).
	private const int MAX_REPLAY_STEPS_PER_FRAME = 32;
	private const float REPLAY_MIN_SPEED = 0.1f; // slider hard-left
	private const float REPLAY_MAX_SPEED = 8f;    // slider hard-right
	private const float REPLAY_SPEED_LERP = 14f;  // key-driven slider lerp rate (per second)
	private const float REPLAY_TRIGGER_DEADZONE = 0.01f;
	private const string REPLAY_SLOW_ACTION = "ReplaySlow";
	private const string REPLAY_FAST_ACTION = "ReplayFast";
	// While paused, a held direction must persist past this before playback resumes — a shorter tap of
	// Right single-steps one tick instead.
	private const float REPLAY_PAUSE_HOLD_RESUME = 0.2f;
	// After a timeline scrub, resume playback if it was running when the scrub began (vs. staying
	// paused on the scrubbed frame). Flip to false to make scrubbing always pause-and-hold.
	private const bool REPLAY_SCRUB_AUTO_RESUME = true;
	// SFX volume multiplier for the per-step sounds a forward scrub fires, so they sit under live play.
	private const float REPLAY_SCRUB_SFX_VOLUME = 0.5f;
	private static readonly bool ShowDebugScreenText = false;

	// Reused to query the engine's modal/overlay state (the s&box escape menu).
	private readonly Game.Overlay _overlay = new();

	/// <summary>True while the sbox escape menu (pause overlay) is showing; freezes the sim.</summary>
	public bool IsPaused => _overlay.IsPauseMenuOpen;

	// Ability shortcut buttons belong only to an active, player-controlled run. Menus still sample
	// ordinary directions for navigation, and watching a replay never samples live ability input.
	private bool CanUseAbilityShortcuts => Stage is GameStage gameStage
		&& !gameStage.IsGameOver && !gameStage.BlocksSimulation && !gameStage.ReplayImportOpen
		&& !IsPaused && !StageSwapPending && !UnlockReveal.IsActive && !IsGifPreviewActive
		&& (!gameStage.IsReplay || IsHijacked) && (!IsReplaying || (IsHijacked && !_replayFinished));

	// Tracks the previous frame's pause state so we only react on transitions (e.g. ducking music).
	private bool _wasPaused;

	// --- determinism / replay -----------------------------------------------------------
	// Each fresh run gets its own random seed so the layout varies; it's recorded with the run's
	// input (RunRecorder) so the exact run can be replayed from the leaderboard. A replay reuses the
	// recorded seed and feeds the recorded input back into the sim instead of sampling live input.
	private static readonly System.Random _seedSource = new();
	private RunContext _pendingRunContext = RunContext.Normal;
	public RunContext CurrentRunContext { get; private set; } = RunContext.Normal;
	private RunData _replayData;
	private RunPlayback _replay;

	/// <summary>True while a recorded run is being played back (sim is driven by recorded input).</summary>
	public bool IsReplaying { get; private set; }

	// True once the replayed run has reached its end (death/win or recorded frames exhausted). The
	// playback freezes on the final frame instead of leaving — the viewer chooses to restart or go
	// back. Reset when a fresh replay starts or is restarted.
	private bool _replayFinished;

	/// <summary>True while a finished replay is frozen on its last frame (awaiting restart/back).</summary>
	public bool ReplayFinished => _replayFinished;

	// Set true if the finished replay's recomputed score didn't match the recorded one (a determinism
	// drift). Surfaced in the HUD so non-editor players see it too, not just the console warning.
	private bool _replayDesynced;

	/// <summary>True if the just-finished replay diverged from the original run (score mismatch).</summary>
	public bool ReplayDesynced => _replayDesynced;

	// Submitter metadata for the run being replayed (from the leaderboard entry), shown in the HUD.
	private ReplaySubmitter _replaySubmitter;

	/// <summary>Who submitted the run currently being replayed (avatar/name/country/date for the HUD).</summary>
	public ReplaySubmitter ReplaySubmitterInfo => _replaySubmitter;

	/// <summary>Global counts for this replay visit, retained through restarts and seeking.</summary>
	public ReplayViewStats CurrentReplayViews { get; private set; }
	public ReplayReactions CurrentReplayReactions { get; private set; }
	public bool IsPlacingReaction { get; private set; }
	private bool _reactionWasPaused;
	public string ReactionEffect { get; private set; } = "wow";
	public bool CanReact => IsReplaying && !_hijacked && !_replayFinished && !StageSwapPending
		&& !IsTransitioning && ReplayFrame < ReplayLength
		&& !_gifPreviewActive && !IsScrubbingTimeline && CurrentReplayReactions is not null
		&& Stage is GameStage { BlocksSimulation: false, ReplayImportOpen: false };

	public void BeginReaction()
	{
		if ( !CanReact || IsPlacingReaction ) return;
		_reactionWasPaused = _replayPaused;
		_replayPaused = true;
		_accumulator = 0f;
		IsPlacingReaction = true;
	}

	public void SelectReaction( string effect )
	{
		if ( ReplayReactionEffects.Find( effect ) is not null ) ReactionEffect = effect;
	}

	public void CancelReaction()
	{
		if ( !IsPlacingReaction ) return;
		IsPlacingReaction = false;
		_replayPaused = _reactionWasPaused;
		_accumulator = 0f;
	}

	public bool ReactionCursorPosition( out Vector2 position )
	{
		position = default;
		if ( !Camera.IsValid() ) return false;
		var ray = Camera.ScreenPixelToRay( Mouse.Position );
		position = new Vector2( MathF.Round( ray.Position.x ), MathF.Round( ray.Position.y ) );
		return position.x >= Arena.WALL_SIZE && position.x <= Arena.WIDTH - Arena.WALL_SIZE
			&& position.y >= Arena.WALL_SIZE && position.y <= Arena.HEIGHT - Arena.WALL_SIZE;
	}

	public void PlaceReaction()
	{
		if ( !IsPlacingReaction || !CanReact || !ReactionCursorPosition( out var position ) ) return;
		if ( !CurrentReplayReactions.Submit( new ReplayReaction
			{ Frame = ReplayFrame, X = position.x, Y = position.y, Effect = ReactionEffect } ) ) return;
		IsPlacingReaction = false;
		_replayPaused = false;
		_accumulator = 0f;
	}

	/// <summary>True when the current replay payload already exists in the local replay archive.</summary>
	public bool CurrentReplayIsLocal => IsReplaying && LocalReplays.Contains( _replayData, _replaySubmitter.SteamId );

	/// <summary>True when the current replay is archived locally and hearted.</summary>
	public bool CurrentReplayIsFavorite => IsReplaying && (LocalReplays.Find( _replayData, _replaySubmitter.SteamId )?.IsFavorite ?? false);

	/// <summary>Stable identity for the run currently being replayed, or null when not replaying.
	/// Level + seed + submitter + the recorded input stream (step count and hash): the stream is what
	/// makes this describe the RUN rather than the board — a daily's level id AND seed are shared by
	/// every run of that day (the seed is date-derived by design), so without it all of one player's
	/// exports of a day's daily collapsed to a single key. Two different runs always differ in their
	/// recorded inputs, and an identical stream IS the same run; the SteamId separates identical runs
	/// by different players. Used to tell "another replay" from "the same replay again" when counting
	/// GIF exports.</summary>
	public string CurrentReplayKey => IsReplaying && _replayData is not null
		? $"{_replayData.LevelId}|{_replayData.Seed}|{_replaySubmitter.SteamId}|{_replayData.StepCount}|{CurrentReplayRunHash}"
		: null;

	/// <summary>Eight hex digits identifying the run being replayed (hash of its recorded inputs), or null
	/// when not replaying. Short enough for a filename; see <see cref="CurrentReplayKey"/>.</summary>
	public string CurrentReplayRunHash => IsReplaying && _replayData is not null
		? InputStreamHash( _replayData.InputDeltas ).ToString( "x8" )
		: null;

	// FNV-1a over the encoded input stream, for the replay-identity key above.
	private static uint InputStreamHash( string inputDeltas )
	{
		uint h = 2166136261u;
		foreach ( char c in inputDeltas ?? "" )
			h = (h ^ c) * 16777619u;
		return h;
	}

	// Tracks where the replay was launched from so EndReplay returns to the right screen: the MY REPLAYS
	// archive when set, otherwise the leaderboard.
	private bool _replayFromLocalReplays;
	private string _replayReturnDailyId;
	private bool _replayReturnToLevelSelect;
	private bool _replayLeaderboardBackToLevelSelect;
	// Workshop level id whose board launched the replay, so EndReplay reopens that board (which in
	// turn Backs to the workshop screen). Null for every non-workshop launch.
	private string _replayReturnWorkshopLevelId;
	// Row that was selected on the launching list (MY REPLAYS / leaderboard), so EndReplay can reopen
	// that screen with the same row focused. -1 = none / not applicable.
	private int _replayReturnSelection = -1;
	// The score tally this replay chain hangs off (its footer replay button, or a board opened from it),
	// rebuilt already-counted-out on the way back. Null unless a tally started the chain.
	private ScoreReturn _replayReturnScore;
	// True when the replay was launched STRAIGHT from the tally, so EndReplay goes back to it rather
	// than to the leaderboard that would otherwise carry _replayReturnScore.
	private bool _replayReturnToScore;

	// Slider position in [0,1] (0.5 = 1x). Speed is derived from this via SliderToSpeed.
	private float _replaySpeedT = 0.5f;
	// True while the slider is being lerped by held keys (so releasing returns it to centre, and a
	// mouse drag — which clears this — leaves the slider where the user dropped it).
	private bool _replayKeyActive;
	private bool _replayPaused;
	// How long a direction key has been continuously held while paused (drives hold-to-resume), and
	// one-shot flags set when a direction is tapped while paused to step a single tick.
	private float _replayPausedHoldTime;
	private bool _replayStepOnce;
	private bool _replayStepBackOnce;
	// Whether playback was running when the current timeline scrub began, so EndReplayScrub can resume
	// it on release (see REPLAY_SCRUB_AUTO_RESUME).
	private bool _scrubResumePlaying;

	// --- hijack (take control mid-replay) -----------------------------------------------
	// Pressing HIJACK during a replay hands control to the live player from the current frame: live
	// input drives the (still IsReplay) GameStage instead of the recorded stream, so nothing is
	// recorded, no score/tally happens, and a win/death just freezes here. _hijackFrame remembers the
	// recorded-frame index it began at so Space (on the frozen end screen) or Restart can replay to
	// that exact point and resume control there; A/D forget it (-1).
	private bool _hijacked;
	private int _hijackFrame = -1;

	/// <summary>True while live player input is driving the replayed run (HIJACK active).</summary>
	public bool IsHijacked => _hijacked;

	/// <summary>True while a hijack restore point is armed (so Space re-enters control at that frame).</summary>
	public bool HijackArmed => _hijackFrame >= 0;

	/// <summary>True when the HIJACK button should be offered (watching a live/paused replay).</summary>
	public bool CanHijack => IsReplaying && !_replayFinished && !_hijacked;

	/// <summary>Current replay playback rate (1 = real time, below 1 is slowed, >1 is sped up).</summary>
	public float ReplaySpeed => SliderToSpeed( _replaySpeedT );

	/// <summary>Replay speed slider position in [0,1] (0.5 = 1x); bound to the HUD slider.</summary>
	public float ReplaySpeedT => _replaySpeedT;

	/// <summary>True while replay playback is paused (no steps advance).</summary>
	public bool ReplayPaused => _replayPaused;

	/// <summary>Replay completion in [0,1] (recorded steps played / total). 0 when not replaying.</summary>
	public float ReplayProgress => _replay is null || _replay.Length == 0 ? 0f : (float)_replay.Position / _replay.Length;

	/// <summary>Current replayed frame index (recorded steps played so far). 0 when not replaying.</summary>
	public int ReplayFrame => _replay?.Position ?? 0;

	/// <summary>Total recorded-step count of the current replay (the seekable timeline length). 0 when
	/// not replaying.</summary>
	public int ReplayLength => _replay?.Length ?? 0;

	/// <summary>Elapsed playback time of the current replay, in seconds.</summary>
	public float ReplayTimeSeconds => _replay is null ? 0f : _replay.Position / (float)Arena.TICK_RATE;

	/// <summary>Total length of the current replay, in seconds.</summary>
	public float ReplayTotalSeconds => _replay is null ? 0f : _replay.Length / (float)Arena.TICK_RATE;

	/// <summary>Share/import string for the replay currently being watched. Carries the run's
	/// submitter (falling back to the local player) so importers attribute it correctly.</summary>
	public string CurrentReplayCode => IsReplaying ? ReplayCodeCodec.Encode( _replayData, _replaySubmitter.SteamId ) : "";

	// Map the slider position to a playback rate with a piecewise-log curve so the centre is exactly
	// 1x: [0,0.5] interpolates 0.1x->1x, [0.5,1] interpolates 1x->8x (both geometric, so the slider
	// feels symmetric in "doublings" either side of real-time).
	private static float SliderToSpeed( float t )
	{
		t = Math.Clamp( t, 0f, 1f );
		return t <= 0.5f
			? REPLAY_MIN_SPEED * MathF.Pow( 1f / REPLAY_MIN_SPEED, t / 0.5f ) // 0.1 .. 1
			: MathF.Pow( REPLAY_MAX_SPEED, ( t - 0.5f ) / 0.5f );            // 1 .. 8
	}

	// Map a replay watch speed onto the music pitch band in LOG space, anchored at 1x = normal pitch,
	// so the band is reached only at the speed extremes (0.1x -> MusicPitchMin, 8x -> MusicPitchMax)
	// rather than clamping early. Piecewise around 1x because the speed range isn't symmetric in log.
	private static float SpeedToMusicPitch( float speed )
	{
		speed = Math.Clamp( speed, REPLAY_MIN_SPEED, REPLAY_MAX_SPEED );
		if ( speed <= 1f )
		{
			// 0.1x..1x -> MusicPitchMin..1
			float t = MathF.Log( speed / REPLAY_MIN_SPEED ) / MathF.Log( 1f / REPLAY_MIN_SPEED );
			return MathF.Pow( Audio.MusicPitchMin, 1f - t );
		}

		// 1x..8x -> 1..MusicPitchMax
		float u = MathF.Log( speed ) / MathF.Log( REPLAY_MAX_SPEED );
		return MathF.Pow( Audio.MusicPitchMax, u );
	}

	private static float ReplayTriggerAmount( InputAnalog analog )
	{
		float value = Math.Clamp( Input.GetAnalog( analog ), 0f, 1f );
		return value <= REPLAY_TRIGGER_DEADZONE ? 0f : value;
	}

	// -1 = full slow, 0 = neutral, +1 = full fast. Keyboard/dpad speed controls stay digital;
	// triggers are proportional, so a 30% pull moves 30% of the way from centre to that side.
	private static float ReplaySpeedAxisFromInput()
	{
		bool slowDigital = Input.Down( "Left" ) || Input.Down( "LeftArrow" ) || Input.Down( REPLAY_SLOW_ACTION, complainOnMissing: false );
		bool fastDigital = Input.Down( "Right" ) || Input.Down( "RightArrow" ) || Input.Down( REPLAY_FAST_ACTION, complainOnMissing: false );

		if ( slowDigital ) return -1f;
		if ( fastDigital ) return 1f;

		return ReplayTriggerAmount( InputAnalog.RightTrigger ) - ReplayTriggerAmount( InputAnalog.LeftTrigger );
	}

	/// <summary>Seed of the run currently being recorded (for diagnostics).</summary>
	public int CurrentRunSeed { get; private set; }

	/// <summary>Sim version the active GameStage runs at: the recorded version for a replay (so its
	/// version gates take the old paths), <see cref="Sim.VERSION"/> for a live run. Set in
	/// <see cref="ApplyPendingStage"/> alongside the reseed; read through <see cref="GameStage.SimVersion"/>.</summary>
	public int CurrentRunSimVersion { get; private set; } = Sim.VERSION;

	/// <summary>Start a live run on a specific level (from the level-select screen; also how a
	/// score-screen restart re-rolls the same level). <paramref name="characterId"/> overrides the
	/// picker's selected character for this run only (used by repro ConCmds that need a specific
	/// character, e.g. wrap_repro forcing WRAPPER); null = the selected character, the normal case.</summary>
	public void StartLevel( string levelId, string characterId = null )
	{
		if ( StageSwapPending ) return;

		// An unregistered workshop id would silently resolve to classic all the way down (RunContext,
		// stat names) and submit this run to classic's board — refuse instead. The workshop screen
		// installs+registers a level before starting it, so this only fires on a broken flow.
		if ( WorkshopLevels.IsWorkshopId( levelId ) && Levels.Get( levelId ) is null )
		{
			Log.Warning( $"[BlockParty] StartLevel: workshop level '{levelId}' isn't installed." );
			return;
		}

		string selectedId = characterId ?? Settings.Current.SelectedCharacterId;
		_pendingRunContext = RunContext.ForLevel( levelId, ResolveLevelCharacter( Levels.Get( levelId ), selectedId ) );
		TransitionToStage( new GameStage( this ) );
	}

	private static string ResolveLevelCharacter( LevelDef level, string selectedId )
		=> Characters.TryGet( level?.ForcedCharacterId, out var forced ) ? forced.Id : selectedId;

	// ── level-editor test play ──────────────────────────────────────────────────────────────────────
	private const string TestLevelId = "__editor_test__";
	private EditorLevel _editorSessionLevel;
	private LevelEditorStage.EditTool _editorSessionTool;
	private string _editorSessionCharacterId;

	/// <summary>Undo/redo snapshots for the level editor. Owned here (like the remembered session) so
	/// the history survives test-play round trips, stage rebuilds and editor re-opens. Session-only —
	/// never written to disk; saved level files carry no history.</summary>
	public EditorUndoHistory LevelEditorUndoHistory { get; } = new();
	private LevelDef _testLevel;
	private EditorLevel _testEditorLevel;
	private LevelEditorStage.EditTool _testEditorTool;
	// The character selected in the editor when Test Play was launched. Kept so the test run plays as that
	// character and the editor is restored to it when the run ends.
	private string _testEditorCharacterId;

	/// <summary>True while a level-editor TEST run is active (a transient, unsaved level is being played).</summary>
	public bool IsTestingLevel => _testLevel is not null;

	/// <summary>Keep the current editor model in memory while navigating other stages. This is deliberately
	/// session-only: it preserves unsaved work while the game keeps running without writing project JSON.</summary>
	public void RememberLevelEditorSession( EditorLevel level, LevelEditorStage.EditTool tool, string characterId )
	{
		if ( level is null ) return;

		_editorSessionLevel = level;
		_editorSessionTool = tool;
		_editorSessionCharacterId = characterId;
	}

	public bool TryRestoreLevelEditorSession( out EditorLevel level, out LevelEditorStage.EditTool tool, out string characterId )
	{
		level = _editorSessionLevel;
		tool = _editorSessionTool;
		characterId = _editorSessionCharacterId;
		return level is not null;
	}

	/// <summary>Test-play an unsaved editor level: build a transient <see cref="LevelDef"/> from the
	/// editor model, register it so the run resolves the in-progress edits (without writing to disk),
	/// and start a non-submitting <see cref="GameStage"/>. When the run ends (death, win, or Back) it
	/// returns to a fresh level editor restored to <paramref name="editorLevel"/> + <paramref name="tool"/>
	/// (see <see cref="ReturnFromTestPlay"/>). The editor model is kept by reference, untouched by the run.</summary>
	public void StartLevelTest( EditorLevel editorLevel, LevelEditorStage.EditTool tool, string characterId )
	{
		if ( StageSwapPending || editorLevel is null ) return;

		_testEditorLevel = editorLevel;
		_testEditorTool = tool;
		_testEditorCharacterId = characterId;

		_testLevel = editorLevel.ToLevelDef();
		// Ensure a non-empty id so Get()/the run context agree (a brand-new level may have a blank id).
		if ( string.IsNullOrEmpty( _testLevel.Id ) ) _testLevel.Id = TestLevelId;
		Levels.RegisterTransient( _testLevel );

		_pendingRunContext = RunContext.ForLevel( _testLevel.Id, ResolveLevelCharacter( _testLevel, _testEditorCharacterId ) );
		TransitionToStage( new GameStage( this ) { IsTest = true } );
	}

	/// <summary>End a test run and return to the level editor with the same authored level + tool it
	/// was launched from. Clears the transient level so the persistent registry is unshadowed again.</summary>
	public void ReturnFromTestPlay()
	{
		Levels.ClearTransient();

		var editor = new LevelEditorStage( this )
		{
			Level = _testEditorLevel ?? new EditorLevel(),
			Tool = _testEditorTool,
			SelectedCharacterId = _testEditorCharacterId ?? Settings.Current.SelectedCharacterId,
		};

		_testLevel = null;
		_testEditorLevel = null;

		TransitionToStage( editor );
	}

	/// <summary>Restart the current editor TEST run: re-run the same authored level with a fresh seed,
	/// snapping straight to a new test <see cref="GameStage"/> (no wipe, like <see cref="RestartRun"/>).
	/// Used when the game-over beat finishes and when R is pressed during a test. Falls back to returning
	/// to the editor if there is no test model to re-run.</summary>
	public void RestartTestPlay()
	{
		if ( _testEditorLevel is null ) { ReturnFromTestPlay(); return; }

		// Rebuild the transient def from the (unchanged) editor model + re-register it so the fresh run
		// resolves the same in-progress edits, then snap to a new test stage with a new seed.
		_testLevel = _testEditorLevel.ToLevelDef();
		if ( string.IsNullOrEmpty( _testLevel.Id ) ) _testLevel.Id = TestLevelId;
		Levels.RegisterTransient( _testLevel );

		_accumulator = 0f;
		_pendingRunContext = RunContext.ForLevel( _testLevel.Id, ResolveLevelCharacter( _testLevel, _testEditorCharacterId ) );
		SetStage( new GameStage( this ) { IsTest = true } );
	}

	public void StartDailyChallenge( string dailyId )
	{
		// Only TODAY's challenge can launch a counted attempt — checked live so a hub, score screen
		// or die-restart loop held across UTC midnight can't keep submitting new attempts to a day
		// whose board is closed to everyone else. (Covers future days too.)
		if ( StageSwapPending || dailyId != DailyChallenge.TodayId )
			return;

		var daily = DailyLevels.Get( dailyId );
		if ( daily is null || !DailyChallengeProgress.HasAttemptsRemaining( dailyId, daily.MaxAttempts ) )
			return;

		DailyChallengeProgress.RegisterAttemptStart( dailyId );
		_pendingRunContext = RunContext.Daily( dailyId, ResolveLevelCharacter( daily.Level, Settings.Current.SelectedCharacterId ) );
		TransitionToStage( new GameStage( this ) );
	}

	/// <summary>DEBUG: play any past/future day's generated daily to evaluate the generator (see the
	/// daily_play ConCmd). Skips the date/attempt guards, counts no attempt, and the run never
	/// submits or marks progress (<see cref="GameStage.IsDebugRun"/>).</summary>
	public void StartDailyChallengeDebug( string dailyId )
	{
		if ( StageSwapPending )
			return;

		var daily = DailyLevels.Get( dailyId );
		if ( daily is null )
			return;

		// Debug launches count into their own display-only file (never real progress/limits), so the
		// attempts UI can be exercised via daily_debug_nav.
		DailyChallengeProgress.RegisterDebugAttemptStart( dailyId );
		_pendingRunContext = RunContext.Daily( dailyId, ResolveLevelCharacter( daily.Level, Settings.Current.SelectedCharacterId ) );
		TransitionToStage( new GameStage( this ) { IsDebugRun = true } );
	}

	public void ClearRunContext()
	{
		_pendingRunContext = RunContext.Normal;
		CurrentRunContext = RunContext.Normal;
	}

	protected override void OnAwake()
	{
		Instance = this;
		Rng.Seed( Seed );

		// The dedicated Jump action (Space / gamepad A) resolves to "away from the effective floor" of
		// the controlled body — the active twin for Twins, the primary body otherwise; Up outside a run.
		// Mid charge wind-up it resolves away from the CHARGED surface instead (the cancel key).
		// Registered once here so every Sample/Prime call site (live loop, hijack, menu overlay) agrees.
		InputState.JumpDirectionResolver = () =>
			(Instance?.Stage as GameStage)?.Player?.JumpScreenDirection ?? Direction.Up;

		// While a charge jump winds, the stick's held bit toward the charge's hold direction is
		// latched (see InputState) so aim sweeps can't release the charge early.
		InputState.ChargeHoldDirectionResolver = () =>
			(Instance?.Stage as GameStage)?.Player?.ChargeHoldScreenDirection ?? Direction.None;

		// The engine's controller virtual cursor (left stick moves the mouse, gamepad A left-clicks the
		// hovered panel, right stick scrolls) is permanently off: the stick steers the player and A
		// jumps, and a cursor that comes and goes per context proved confusing. Menus are driven by
		// d-pad/Confirm and the real mouse.
		Input.EnableVirtualCursor = false;
	}

	protected override void OnDestroy()
	{
		CurrentReplayReactions?.Cancel();
		CurrentReplayViews?.Cancel();
		CurrentReplayViews = null;
		base.OnDestroy();
	}

	protected override void OnStart()
	{
		SetupCamera();
		SetupTransitionOverlay();
		SetupUnlockRevealOverlay();
		SetupGifExportOverlay();
		SetupSoftwareCursor();

		// Load persisted player settings and push the volume factors into the audio layer before the
		// music starts, so the first note already respects the saved levels.
		Settings.Load();

		// Merge cloud-mirrored beaten levels into the loaded profile (fire-and-forget best-effort;
		// see CloudProgress). Restores map progress on a fresh machine / reinstall / lost data folder.
		_ = CloudProgress.MergeAsync();

		// Grant any achievement the loaded profile already qualifies for — covers a player who met the
		// condition before the achievement existed, and re-covers one whose unlock request was lost.
		Achievements.CheckProgress();

		Audio.SetVolumes(
			Settings.Current.MasterVolume / 100f,
			Settings.Current.SfxVolume / 100f,
			Settings.Current.MusicVolume / 100f );
		Haptics.SetStrength( Settings.Current.VibrationStrength / 100f );

		Audio.PlayMusic();
		SetStage( new TitleStage( this ) );
	}

	private void SetupCamera()
	{
		var camGo = new GameObject();
		camGo.Name = "Camera2D";
		camGo.SetParent( GameObject );

		// Centre the view on the playfield, sitting along +Z and looking down -Z so the XY
		// plane faces the screen. s&box is Z-up with +Y = "left"; with forward=-Z and up=+Y
		// the camera's right works out to +X, giving screen X->right, Y->up (sprites upright,
		// gravity correct). (right = cross(forward, up) = cross(-Z,+Y) = +X.)
		camGo.WorldPosition = new Vector3( Arena.WIDTH / 2f, Arena.HEIGHT / 2f, 2000f );
		camGo.WorldRotation = Rotation.LookAt( Vector3.Down, Vector3.Left );

		Camera = camGo.Components.Create<CameraComponent>();
		Camera.Orthographic = true;
		Camera.OrthographicHeight = Arena.HEIGHT;
		Camera.BackgroundColor = ClearColor;
		// Tight near/far: entities span z ~ -40 (playfield) .. 999 (text), i.e. ~1001..2040 units in
		// front of the camera. Orthographic depth is linear, so a tighter far plane spreads the depth
		// buffer over a smaller range and cleanly separates our 1-3 unit layer gaps (no z-fighting).
		Camera.ZNear = 1f;
		Camera.ZFar = 3000f;
		Camera.IsMainCamera = true;

		// Settings live on the component itself; toggle/tune it under Camera2D in the scene tree.
		camGo.Components.Create<RetroArcadePostProcess>();
	}

	// The OS cursor is replaced by a drawn pixel arrow so the retro shader curves it together with
	// the UI it points at — see SoftwareCursor. Lives on its own persistent GameObject.
	private void SetupSoftwareCursor()
	{
		var go = new GameObject();
		go.Name = "SoftwareCursor";
		go.SetParent( GameObject );
		go.Components.Create<SoftwareCursor>();
	}

	// Persistent screen-space overlay that draws the transition square. Lives on its own GameObject
	// (NOT under a stage Root) so it survives the stage swap that happens in the middle of a wipe,
	// and sits above every stage's UI (stage panels use ZIndex 100).
	private void SetupTransitionOverlay()
	{
		var go = new GameObject();
		go.Name = "TransitionOverlay";
		go.SetParent( GameObject );
		var screen = go.Components.Create<ScreenPanel>();
		screen.ZIndex = 1000;
		_transition = go.Components.Create<TransitionOverlay>();
	}

	// Persistent screen-space overlay for the "YOU UNLOCKED" reveal. Own GameObject for the same
	// reason as the transition square: a reveal is handed the exit the score tally was about to take,
	// so it has to survive that stage swap. Sits above every stage's UI (ZIndex 100) but BELOW the
	// transition square (1000), so a wipe still covers it.
	private void SetupUnlockRevealOverlay()
	{
		var go = new GameObject();
		go.Name = "UnlockRevealOverlay";
		go.SetParent( GameObject );
		var screen = go.Components.Create<ScreenPanel>();
		screen.ZIndex = 900;
		_unlockOverlay = go.Components.Create<UnlockRevealOverlay>();
		_unlockOverlay.Reveal = UnlockReveal;
	}

	// Stages a still-owed unlock reveal may pop itself on: the menus a player lands on after
	// abandoning a won run, plus the title screen they boot into. Deliberately NOT the score tally
	// (it shows its own reveal, with the exit deferred behind it), a live run, or the editor.
	private bool CanAutoShowUnlockReveal
	{
		get
		{
			if ( IsTransitioning )
				return false;

			return Stage switch
			{
				// The map plays its own timed newly-unlocked-node presentation on entry; covering that
				// would make the player miss it, so queue behind it.
				LevelSelectStage levelSelect => !levelSelect.UnlockPresentationActive,
				TitleStage or DailyChallengeStage => true,
				_ => false,
			};
		}
	}

	// Advance the "YOU UNLOCKED" reveal once per rendered frame, after this frame's input sample.
	// While one is up it OWNS that input: the edges are consumed here so the screen behind the
	// overlay can't be driven blind by the same press that dismisses it.
	private void TickUnlockReveal( float dt )
	{
		if ( !UnlockReveal.IsActive )
		{
			// Nothing on screen: deliver a reveal the player is still owed (a won run abandoned before
			// its tally, or one queued in a previous session) as soon as they're on a menu.
			if ( !CanAutoShowUnlockReveal || !CharacterProgress.HasPendingReveal || !UnlockReveal.ShowPending() )
				return;
		}

		UnlockReveal.Tick( dt );
		// Edges AND held bits: the menus behind the overlay navigate on held input (hold-to-repeat),
		// so clearing only the edges would let a direction walk the title menu / map selection while
		// the reveal is up — including the very press that dismisses it.
		InputState.ConsumeMenuInput();
	}

	// Persistent screen-space overlay for GIF export. Like the transition overlay, it lives on its own
	// GameObject (NOT under a stage Root) so it survives the stage rebuilds that a backward replay seek
	// triggers — otherwise dragging a timeline head would tear the overlay down mid-interaction and leave
	// the camera stuck in preview framing (gif mode "half on"). It renders nothing until a preview begins.
	private void SetupGifExportOverlay()
	{
		var go = new GameObject();
		go.Name = "GifExportOverlay";
		go.SetParent( GameObject );
		var screen = go.Components.Create<ScreenPanel>();
		screen.ZIndex = 1100; // above the in-game HUD (100); the wipe (1000) doesn't run during a preview
		_gifOverlay = go.Components.Create<GifExportOverlay>();
	}

	/// <summary>Switch to a new stage immediately, tearing down the previous one (no wipe). Used at
	/// boot and internally by the transition wipe once the screen is fully covered.</summary>
	public void SetStage( StageBase stage )
	{
		_pendingStage = stage;
	}

	/// <summary>Switch to a new stage with the growing/shrinking square wipe. Covering and revealing
	/// are each opt-in based on whether the current / next stage paints its own dark backdrop
	/// (<see cref="StageBase.CoveredBackground"/>), so the square appears to persist across the
	/// score/leaderboard screens and only shrinks away when returning to a normal stage. Ignored if a
	/// wipe is already running.</summary>
	public void TransitionToStage( StageBase next )
	{
		if ( StageSwapPending ) return;

		// Stop active run-owned sounds as soon as leaving is requested; navigation and transition cues
		// are deliberately excluded by Audio.StopGameSfx and can finish over the wipe.
		if ( Stage is GameStage )
			Audio.StopGameSfx();

		bool fromCovered = Stage?.CoveredBackground ?? false;
		bool toCovered = next.CoveredBackground;

		if ( fromCovered && toCovered )
		{
			// Both stages share the same dark fill — no visible wipe, just swap the content.
			SetStage( next );
			return;
		}

		_transNext = next;
		_revealAfter = !toCovered; // only uncover the next stage if it doesn't paint its own backdrop

		if ( fromCovered )
		{
			// The current screen is already the dark fill; skip the grow and go straight to a full
			// cover, swap under it, then reveal the (non-covered) next stage.
			_cover = 1f;
			PushCover();
			SetStage( next );
			_transNext = null;
			_transPhase = TransitionPhase.Revealing;
			_transTimer = 0f;
		}
		else
		{
			// Grow the square over the current (visible) stage; the swap + reveal happen at full cover.
			_transPhase = TransitionPhase.Covering;
			_transTimer = 0f;
		}
	}

	// Advance the wipe once per rendered frame (real time, like the original coroutine). Covering
	// grows the square 0->1 (SineEaseOut); at full cover we hand off to the next stage. If the next
	// stage paints its own backdrop we drop the overlay once that stage applies (seamless, same
	// colour); otherwise we Revealing-shrink it 1->0 (SineEaseIn) to uncover the new stage.
	private void TickTransition( float dt )
	{
		if ( _transPhase == TransitionPhase.None ) return;

		_transTimer += dt;
		float t = Math.Clamp( _transTimer / TRANSITION_HALF, 0f, 1f );

		if ( _transPhase == TransitionPhase.Covering )
		{
			_cover = MathF.Sin( t * MathF.PI / 2f ); // ease-out: 0 -> 1
			if ( t >= 1f )
			{
				_cover = 1f;
				SetStage( _transNext ); // applied next frame, while the square fully covers the view
				_transNext = null;
				_transPhase = TransitionPhase.None;
				if ( _revealAfter )
				{
					_transPhase = TransitionPhase.Revealing;
					_transTimer = 0f;
				}
				else
				{
					// Keep the square full until the (self-covered) stage applies, then drop the
					// overlay so its identical backdrop takes over — no flash of the old stage.
					_hideOverlayOnSwap = true;
				}
			}
		}
		else // Revealing
		{
			_cover = MathF.Cos( t * MathF.PI / 2f ); // ease-in: 1 -> 0
			if ( t >= 1f )
			{
				_cover = 0f;
				_transPhase = TransitionPhase.None;
			}
		}

		PushCover();
	}

	private void PushCover()
	{
		if ( _transition is not null )
			_transition.Cover = _cover;
	}

	protected override void OnUpdate()
	{
		// Before anything reads input: a clicked button must not keep the keyboard in UI mode.
		KeyboardFocusGuard.Tick();

		ApplyPendingStage();
		if ( !CanUseAbilityShortcuts ) InputState.SuppressAbilityShortcuts();
		if ( Stage is null ) return;

		// A covering wipe into a self-covered stage keeps the square full until that stage applies;
		// now that it has (its identical dark backdrop is up), drop the overlay with no visible flash.
		if ( _hideOverlayOnSwap )
		{
			_hideOverlayOnSwap = false;
			_cover = 0f;
			PushCover();
		}

		// Advance the stage-transition wipe every frame, even while the sim is paused/frozen below,
		// so the cover/reveal animation always plays smoothly and the stage swap lands on schedule.
		TickTransition( Time.Delta );

		// Drive controller vibration every rendered frame, BEFORE any early-out below, so haptics run
		// identically in live play, replays, menus and while paused (the voices just decay). This is the
		// single point that mixes all active pulses and pushes one rumble command to the gamepad.
		// Continuous channels are asserted from fixed sim TICKS instead, so the mixer is also told how
		// far apart those currently sit in real time: slow motion spaces them out, and a channel waiting
		// on the next stretched step hasn't ended (see Haptics.Sustain).
		Haptics.SetSimStepInterval( Arena.STEP / MathF.Max( GameplayTime.Scale, 0.01f ) );
		Haptics.Tick( Time.Delta );
		GameplayTime.Tick( Time.Delta );
		if ( !IsReplaying || _hijacked )
			Audio.SetMusicPitch( SpeedToMusicPitch( GameplayTime.Scale ) );

		// React to pause transitions: duck the music while the escape menu is up, restore on close.
		bool paused = IsPaused;
		if ( paused != _wasPaused )
		{
			Audio.SetMusicPaused( paused );
			_wasPaused = paused;
		}

		// While the s&box escape menu is open, freeze the gameplay simulation: don't sample
		// input, accumulate time, or tick the stage. We drop any leftover accumulator so the
		// sim resumes cleanly on the next frame rather than catching up the paused interval.
		if ( paused )
		{
			_accumulator = 0f;
			return;
		}

		// An in-game menu (the options overlay) freezes the gameplay simulation but still needs to
		// process its own navigation input. Checked BEFORE the replay branch so the menu also works
		// over a replay: while it's open we pump the menu (W/S nav, slider nudge, Confirm on CLOSE)
		// and consume the input here, so nothing reaches the run/replay. Unlike the s&box escape menu
		// we deliberately do NOT duck the music — the player is adjusting volume and wants an accurate
		// level. Drop the accumulator so the sim resumes cleanly, and skip both replay and sim ticks.
		if ( Stage.BlocksSimulation )
		{
			Audio.SetLoopingSfxPaused( true );
			InputState.Sample( allowAbilityShortcuts: false );
			Stage.TickMenu( Time.Delta );
			InputState.ClearEdges();
			Stage.SyncTransforms();
			_accumulator = 0f;
			return;
		}
		Audio.SetLoopingSfxPaused( IsReplaying && _replayPaused );

		// Replay: the sim is driven by recorded input instead of live sampling. Index-driven (one
		// recorded frame per fixed step) so it reproduces regardless of the viewer's frame rate.
		if ( IsReplaying )
		{
			TickReplay();
			// The replay path returns before the normal end-of-frame debug draw below, so mirror it here —
			// the on-screen player state readout is most useful precisely when stepping through a replay.
			if ( ShowDebugScreenText )
				DrawDebug();
			return;
		}

		// Restart: after game-over (the death beat) the Restart input (R) restarts immediately with a
		// fresh random seed, as before. During an ACTIVE run R is held for the configured delay (see the
		// hold-to-restart fields) — the sim keeps running underneath while the HUD dims the arena and
		// fills a progress bar; releasing early fades it back out from wherever it was. Daily
		// eligibility is enforced by GameStage.CanRestart; replays and editor tests keep their instant
		// dedicated paths below. Snaps straight to a new GameStage.
		if ( Stage is GameStage rgs && rgs.CanRestart )
		{
			if ( rgs.IsGameOver || Settings.Current.RestartButtonDelay <= 0f )
			{
				// Down (not just Pressed) so a hold begun before the death also completes here — dying
				// mid-hold restarts without demanding a re-press.
				if ( Input.Pressed( "Restart" ) || (_restartHoldArmed && Input.Down( "Restart" )) )
				{
					ResetRestartHold();
					RestartRun();
					return;
				}
				TickRestartHold( false );
			}
			else
			{
				// Arm only on a fresh press (and not mid-wipe, where RestartRun would refuse anyway).
				if ( Input.Pressed( "Restart" ) && !IsTransitioning )
					_restartHoldArmed = true;
				bool holding = _restartHoldArmed && Input.Down( "Restart" );
				TickRestartHold( holding );
				if ( holding && _restartHoldTime >= Settings.Current.RestartButtonDelay )
				{
					ResetRestartHold();
					RestartRun();
					return;
				}
			}
		}
		else
		{
			// Not a restartable live run (menus, tests, a won game-over…): fade out any leftover overlay.
			TickRestartHold( false );
		}

		// Editor TEST run: R (Restart) restarts the test level at ANY time — before, during, or after the
		// game-over beat — re-running the same authored level with a fresh seed instead of returning to
		// the editor. Checked per rendered frame so the key edge is never dropped.
		if ( Stage is GameStage rts && rts.IsTest && Input.Pressed( "Restart" ) )
		{
			RestartTestPlay();
			return;
		}

		// Sample input once per rendered frame; editor-test menu actions and the fixed-step sim consume
		// this same snapshot. Sampling before the test Back check keeps the controller Back button / Backspace aligned
		// with every other menu and preserves a press across frames that run zero simulation steps.
		InputState.Sample( allowAbilityShortcuts: CanUseAbilityShortcuts );

		// The "YOU UNLOCKED" reveal sits over whatever stage is up and eats this frame's menu input
		// while it's visible, so the screen behind it never acts on the press that dismisses it. Also
		// where an owed reveal pops itself once the player reaches a menu.
		TickUnlockReveal( Time.Delta );

		// Editor TEST run: Back leaves the test and returns to the level editor. ExitTest self-guards
		// against re-entry and is also driven by the on-screen stop button.
		if ( Stage is GameStage tgs && tgs.IsTest && InputState.BackJust )
		{
			tgs.ExitTest();
			return;
		}

		// Live run: Back returns to the screen that launched it (daily hub / map), like the test-run
		// and replay Back paths. A limited-attempt daily asks first (see RequestBackToMenu). Consume
		// the press: nothing below runs this frame, so the latched edge would otherwise survive into
		// the prompt's first menu-pumped frame and cancel it on the spot.
		if ( Stage is GameStage && InputState.BackJust )
		{
			RequestBackToMenu();
			InputState.ConsumeMenuInput();
			return;
		}

		// Fixed-step simulation.
		_accumulator += Time.Delta * GameplayTime.Scale;
		int steps = 0;
		while ( _accumulator >= Arena.STEP && steps < MAX_STEPS_PER_FRAME )
		{
			var result = AdvanceOneSimStep( SimStepInput.Live );
			_accumulator -= Arena.STEP;
			steps++;
			if ( result == SimStepResult.Froze ) continue;
			if ( result == SimStepResult.StageEnded ) break;
		}

		// If we hit the step cap, drop the backlog rather than accumulating lag.
		if ( steps >= MAX_STEPS_PER_FRAME )
			_accumulator = 0f;

		// Push logical positions into transforms once per rendered frame.
		Stage?.SyncTransforms();

		DebugTools.TickGameStage( Stage as GameStage, Camera );
		if ( ShowDebugScreenText )
			DrawDebug();
		if ( Player.ShowCrushProbeOverlay && Stage is GameStage crushGs && crushGs.Player is not null )
			crushGs.Player.DrawCrushProbeOverlay();
		if ( Block.ShowDebugInfo && Stage is GameStage blockGs )
			foreach ( var block in blockGs.Blocks )
				block.DrawDebugInfo();
	}

	private void DrawDebug()
	{
		var info = $"BlockParty debug\nfps:{(1f / Time.Delta):0} accum:{_accumulator * 1000:0.0}ms";
		if ( Stage is GameStage gs && gs.Player is not null )
			info += "\n" + gs.Player.DebugInfo();

		Gizmo.Draw.Color = Color.White;
		Gizmo.Draw.ScreenText( info, new Vector2( 20, 120 ), "Consolas", 16, TextFlag.LeftTop );
	}

	private void ApplyPendingStage()
	{
		if ( _pendingStage is null ) return;
		InputState.SuppressAbilityShortcuts(); // never carry an unfinished gesture into another run

		// Stop run-owned sfx at the moment the outgoing GameStage actually ends. This is the single
		// choke point for every swap path (restarts, replay rebuilds, wipe handoffs), and it runs AFTER
		// any sim steps the old stage ticked last frame — so nothing fired between the stop-at-request
		// (TransitionToStage) and the real swap can leak into the next screen.
		if ( Stage is GameStage )
			Audio.StopGameSfx();

		Stage?.Exit();
		// Exit requests cancel replay work immediately, but the outgoing HUD remains visible
		// during the covering wipe. Retire its display metadata only after that HUD is gone.
		if ( !IsReplaying )
		{
			CurrentReplayReactions = null;
			CurrentReplayViews = null;
			_replaySubmitter = default;
		}
		GameplayTime.Reset();
		Stage = _pendingStage;
		_pendingStage = null;

		// Reseed the simulation immediately before the stage's OnEnter (which spawns/shuffles blocks
		// and so consumes Rng), so a run reproduces exactly: a replay reuses the recorded seed; a
		// fresh run picks a new random seed and starts recording.
		if ( Stage is GameStage )
		{
			if ( _replay is not null )
			{
				// A replay reproduces the recorded run's level and character (GameStage reads both off
				// this context when spawning), not whatever context a live run left behind. A daily
				// replay regenerates its level from the daily id instead of the registry.
				CurrentRunContext = _replayData?.IsDaily == true
					? RunContext.Daily( _replayData.DailyId, _replayData.CharacterId )
					: RunContext.ForLevel( _replayData?.LevelId, _replayData?.CharacterId );
				Rng.Seed( _replay.Seed );
				CurrentRunSimVersion = _replay.SimVersion;
				RunRecorder.Stop();

				// The live trace trails belong to ONE specific run: seed + exact input stream. Seed
				// alone is NOT identity — every attempt of a daily (by every player) shares the day's
				// seed, so a same-day daily replay would diff against an unrelated live trail and log
				// false desyncs (and pollute desync reports). Each kept trail snapshots its run's
				// stream when the run ends (FinalizeLiveTrace), so a watch still finds its match after
				// restarts / later runs — not just when it was the most recent one.
				FinalizeLiveTrace();
				_replayLiveTrace = _replayData is null ? null
					: _recentLiveTraces.LastOrDefault( t =>
						t.Seed == _replay.Seed
						&& t.StepCount == _replayData.StepCount
						&& t.Encoded == _replayData.InputDeltas );

				// Fresh replay sim (first watch, restart, or a scrub rebuild): restart its desync-trace
				// trail so checkpoints line up with the live run's from step 0.
				_replayTraceHash = ReplayRegression.FNV_OFFSET;
				_replayTraceStep = 0;
				_replayTraceDivergenceLogged = false;
				_replayTraceCheckpoints.Clear();
				_replayTraceDivergenceStep = -1;
				_replayPressEvents.Clear();
				_pressTraceMismatchLogged = false;
			}
			else
			{
				CurrentRunContext = _pendingRunContext;
				_pendingRunContext = RunContext.Normal;

				// Seal the previous run's trail BEFORE Begin wipes the recorder's stream (its identity).
				FinalizeLiveTrace();

				CurrentRunSeed = CurrentRunContext.IsDaily ? CurrentRunContext.Seed : _seedSource.Next();
				CurrentRunSimVersion = Sim.VERSION;
				Rng.Seed( CurrentRunSeed );
				RunRecorder.Begin( CurrentRunSeed );

				// Start this live run's desync-trace trail; the last few runs' trails are kept so a
				// board watch after restarts / later runs still finds its match.
				_liveTrace = new RunTrace { Seed = CurrentRunSeed };
				_recentLiveTraces.Add( _liveTrace );
				if ( _recentLiveTraces.Count > MAX_LIVE_TRACES )
					_recentLiveTraces.RemoveAt( 0 );
			}
		}

		// Reset the run-progression music pitch at every stage entry: a GameStage begins low and rises
		// as blocks phase up (see Block.NextPhase); every other screen (title, score, leaderboard, local
		// replays, daily) plays at the base start pitch. Centralised here — the single choke point for
		// Stage.Enter() — so no screen (nor a replay's exit target) is missed, and a backward-scrub re-sim
		// resets to base before ticking forward to rebuild the landed frame's pitch.
		Audio.ResetMusicProgression();

		Stage.Enter();
	}

	// ── desync trace ────────────────────────────────────────────────────────────────────────────────
	// A rolling ReplayRegression.FoldStep hash of the LIVE run's sim state, checkpointed once per
	// sim-second. When a replay of the SAME run (matched by seed) is watched later this session, the
	// replay recomputes identical checkpoints and the FIRST mismatching one is logged with its step
	// window — pinpointing where the sim first diverged instead of just flagging the final score.
	// Session-only diagnostics: nothing is persisted and payloads are unchanged. Both trails reset in
	// ApplyPendingStage (the single (re)seed choke point, which scrub rebuilds also pass through).

	/// <summary>One awarded block-side press: when, which block/face, and WHO pressed (the player, a
	/// swarm clone, or a world source like a bullet/statue). The presser kind is part of equality, so
	/// the same press attributed differently live-vs-replay reads as a divergence too.</summary>
	private readonly record struct PressEvent( int Step, int Block, BlockType Type, Direction Side, string Presser );

	// One live run's trail: rolling state hash, per-second checkpoints, every awarded press — plus,
	// once the run ends, its recorded input stream (the run's true identity; dailies share seeds).
	private sealed class RunTrace
	{
		public int Seed;
		public uint Hash = ReplayRegression.FNV_OFFSET;
		public int Step;
		public int StepCount = -1;      // sealed by FinalizeLiveTrace (-1 while the run is still live)
		public string Encoded;          // recorded input stream, sealed by FinalizeLiveTrace
		public readonly List<uint> Checkpoints = new();
		public readonly List<PressEvent> Presses = new();
	}

	// The last few live runs' trails (newest last): a board watch after restarts or later runs still
	// finds its match — a single-trail version lost the evidence to any run in between.
	private const int MAX_LIVE_TRACES = 8;
	private readonly List<RunTrace> _recentLiveTraces = new();
	private RunTrace _liveTrace;        // the live run currently recording (also in the list above)
	private RunTrace _replayLiveTrace;  // the kept trail matching the watched replay (null = none)

	private uint _replayTraceHash;
	private int _replayTraceStep;
	private bool _replayTraceDivergenceLogged;
	private readonly List<uint> _replayTraceCheckpoints = new();
	private readonly List<PressEvent> _replayPressEvents = new();
	private bool _pressTraceMismatchLogged;
	// First step of the divergent window the trace found (-1 = none) — carried into the desync report.
	private int _replayTraceDivergenceStep = -1;

	/// <summary>Seal the current live trail with its run's recorded input stream — its identity for
	/// replay matching. Called before the recorder begins a new run (Begin wipes the stream) and
	/// before a replay looks for its matching trail. No-op once sealed or with no trail.</summary>
	private void FinalizeLiveTrace()
	{
		if ( _liveTrace is null || _liveTrace.Encoded is not null )
			return;
		_liveTrace.StepCount = RunRecorder.StepCount;
		_liveTrace.Encoded = RunRecorder.Encode();
	}

	/// <summary>Record one awarded side press into the live or replay press trail; on a replay of a
	/// kept same-session run (matched by seed + input stream), log the first press that disagrees
	/// with the live run's sequence the moment it happens. Hijack presses are practice input and
	/// aren't traced.</summary>
	public void TracePressAward( Block block, Direction side, Player presser )
	{
		if ( Stage is not GameStage || block is null )
			return;

		string who = presser is null ? "world"
			: presser.IsSwarmClone ? "clone"
			: presser.IsImpostor ? "impostor"
			: "player";

		if ( !IsReplaying )
		{
			_liveTrace?.Presses.Add( new PressEvent( _liveTrace.Step + 1, block.StageIndex, block.BlockType, side, who ) );
			return;
		}

		if ( _hijacked )
			return;

		var evt = new PressEvent( _replayTraceStep + 1, block.StageIndex, block.BlockType, side, who );
		_replayPressEvents.Add( evt );

		if ( _pressTraceMismatchLogged || _replayLiveTrace is null )
			return;

		int index = _replayPressEvents.Count - 1;
		if ( index < _replayLiveTrace.Presses.Count && _replayLiveTrace.Presses[index] == evt )
			return;

		_pressTraceMismatchLogged = true;
		string expected = index < _replayLiveTrace.Presses.Count
			? DescribePress( _replayLiveTrace.Presses[index] )
			: "(the live run had no further press)";
		Log.Warning( $"BlockParty press trace: replay press #{index + 1} is {DescribePress( evt )}, but the live run's press #{index + 1} was {expected}." );
	}

	private static string DescribePress( PressEvent e ) =>
		$"step {e.Step} (t≈{e.Step / (float)Arena.TICK_RATE:0.0}s) block#{e.Block} {e.Type} {e.Side} by {e.Presser}";

	/// <summary>One line per press, for the desync report.</summary>
	private static string FormatPressTrail( List<PressEvent> events ) =>
		events.Count == 0 ? "(none)" : string.Join( "; ", events.Select( DescribePress ) );

	private void FoldDesyncTrace( SimStepInput source, GameStage stage )
	{
		if ( source == SimStepInput.Live )
		{
			if ( _liveTrace is null )
				return;
			_liveTrace.Hash = ReplayRegression.FoldStep( stage, _liveTrace.Hash );
			_liveTrace.Step++;
			if ( _liveTrace.Step % ReplayRegression.CHECKPOINT_EVERY == 0 )
				_liveTrace.Checkpoints.Add( _liveTrace.Hash );
			return;
		}

		if ( source != SimStepInput.Replay )
			return; // hijack is player-driven and meant to diverge

		_replayTraceHash = ReplayRegression.FoldStep( stage, _replayTraceHash );
		_replayTraceStep++;
		if ( _replayTraceStep % ReplayRegression.CHECKPOINT_EVERY != 0 )
			return;

		_replayTraceCheckpoints.Add( _replayTraceHash );

		int index = _replayTraceStep / ReplayRegression.CHECKPOINT_EVERY - 1;
		if ( _replayTraceDivergenceLogged
			|| _replay is null || _replayLiveTrace is null
			|| index >= _replayLiveTrace.Checkpoints.Count
			|| _replayLiveTrace.Checkpoints[index] == _replayTraceHash )
			return;

		_replayTraceDivergenceLogged = true;
		int stepA = _replayTraceStep - ReplayRegression.CHECKPOINT_EVERY + 1;
		_replayTraceDivergenceStep = stepA;
		Log.Warning( $"BlockParty desync trace: replaying THIS SESSION's run (seed {_replay.Seed}) first diverged from the live sim "
			+ $"during steps {stepA}-{_replayTraceStep} (t≈{stepA / (float)Arena.TICK_RATE:0.0}-{_replayTraceStep / (float)Arena.TICK_RATE:0.0}s)." );
	}

	// ── the one shared sim-step primitive ───────────────────────────────────────────────────────────
	// EVERY fixed-step loop in this class (live play, replay playback, single-step, hijack practice,
	// hijack rebuild, timeline scrub) advances the deterministic simulation through THIS one method, so
	// they can never drift out of lock-step. Replay determinism relies on every path feeding the sim
	// identically; previously six copies of the loop body had to be kept in sync by hand.

	// Where a single sim step's input comes from.
	private enum SimStepInput
	{
		Live,    // the frame already sampled by InputState.Sample(); recorded to the run stream
		Replay,  // the next recorded frame is fed in (InputExhausted if none remain); nothing recorded
		Hijack,  // live input like Live, but nothing recorded (practice-only take-over of a replay)
	}

	// Outcome of a single sim step, so callers can drive their catch-up loop / finish handling.
	private enum SimStepResult
	{
		Stepped,         // advanced one normal sim step
		Froze,           // consumed a hit-stop freeze step (no input fed, no recorded frame consumed)
		InputExhausted,  // replay had no recorded frame left; the sim was NOT ticked (caller decides finish)
		StageEnded,      // the step ended the stage/run (Stage swapped away, or the replay finished)
	}

	// Advance the deterministic sim by exactly one Arena.STEP (or reproduce one hit-stop freeze step),
	// taking the step's input from <paramref name="source"/>. Callers own the accumulator/step-count
	// bookkeeping and how they react to the returned result.
	private SimStepResult AdvanceOneSimStep( SimStepInput source )
	{
		// Hit-stop: consume the step as a frozen one — advance the freeze counter but DON'T feed input,
		// tick the sim, or clear edges. The freeze is reproduced from the same deterministic trigger in
		// every loop (never recorded), and preserving unread edges lets a press made mid-freeze fire on
		// resume. Checked first so it takes precedence over feeding a recorded/live frame.
		if ( Stage is GameStage hs && hs.HitStopActive )
		{
			hs.TickHitStop();
			return SimStepResult.Froze;
		}

		switch ( source )
		{
			case SimStepInput.Live:
				// Record the input this step consumes (live gameplay only) BEFORE the tick, so the stored
				// byte is exactly what the sim sees this step and the run can be replayed later.
				if ( Stage is GameStage )
					RunRecorder.RecordStep( InputState.PackByte() );
				break;

			case SimStepInput.Replay:
				// Feed the next recorded frame; if none remain, don't tick — the caller decides how to finish.
				if ( _replay is null || !_replay.HasNext )
					return SimStepResult.InputExhausted;
				InputState.ApplyReplayFrame( _replay.Next() );
				break;

			case SimStepInput.Hijack:
				// Live input already sampled by the caller drives the sim; nothing is recorded (practice only).
				break;
		}

		Stage.Tick( Arena.STEP );

		// Desync trace: fold this step's sim state into the live/replay checkpoint trail (see the
		// fields above ApplyPendingStage). BEFORE ApplyPendingStage — a stage swap resets the trails.
		if ( Stage is GameStage traceStage )
			FoldDesyncTrace( source, traceStage );

		// Live/hijack sample once per rendered frame, so clear the edge events (jump/wall-jump) this tick
		// consumed to stop later sub-steps re-firing them. A replay re-applies the exact edges every step
		// via ApplyReplayFrame, so it must NOT clear them here.
		if ( source != SimStepInput.Replay )
		{
			// This tick may have ended the run or opened an overlay. Cancel before advancing the
			// script so a later catch-up step in this rendered frame cannot fire its second press.
			if ( !CanUseAbilityShortcuts ) InputState.SuppressAbilityShortcuts();
			InputState.ClearEdges();
		}

		ApplyPendingStage();

		// The step ended the run when the stage swapped away, or (in any replay-driven path) when the
		// replay stopped / finished this step. Live play only cares about the stage swap.
		bool ended = Stage is null;
		if ( source != SimStepInput.Live )
			ended |= !IsReplaying || _replayFinished;

		return ended ? SimStepResult.StageEnded : SimStepResult.Stepped;
	}

	// Advance a replay: feed one recorded input frame per fixed step. When the recorded frames run
	// out (or the viewer presses Back) the replay ends and returns to the leaderboard.
	private void TickReplay()
	{
		if ( IsPlacingReaction && Input.Pressed( "Back" ) )
		{
			CancelReaction();
			return;
		}
		// While the GIF-export overlay is open it owns the replay: hold the current frame and ignore the
		// normal transport/back/step keys. The overlay seeks explicitly (timeline heads, and the exporter's
		// capture loop) via SeekReplayToFrame; without this guard Space/arrows/Back would un-pause, restart
		// or exit the run out from under the overlay (and corrupt an in-progress capture).
		if ( _gifPreviewActive )
		{
			// Stop the music while the export overlay is up (it's a still, paused frame), like the normal
			// paused branch does below.
			Audio.SetMusicPlaying( false );
			_accumulator = 0f;
			Stage?.SyncTransforms();
			return;
		}

		// Hold while a stage-transition wipe is running (e.g. the entry wipe): the new replay GameStage
		// hasn't entered yet, so feeding recorded input into the outgoing stage would consume frames
		// and leave the fresh run starting mid-recording (out of sync). Resume once the wipe ends.
		// Must run BEFORE the Restart/Back checks: pressing Back mid-wipe would tear down replay state
		// while EndReplay's own return transition is swallowed by the running wipe, which then still
		// applies the queued replay stage — now seeded as a live run (_replay is null) that can never
		// leave its game-over screen.
		if ( IsTransitioning )
		{
			_accumulator = 0f;
			Stage?.SyncTransforms();
			return;
		}

		// Restart returns an armed hijacked practice run to its takeover point, whether pressed before or
		// after death. Otherwise it restarts the recorded replay from frame zero.
		if ( Input.Pressed( "Restart" ) )
		{
			if ( HijackArmed )
				ResumeHijack();
			else
				RestartReplay();
			return;
		}

		if ( Input.Pressed( "Back" ) )
		{
			EndReplay();
			return;
		}

		// Reached the end of the recorded run: freeze on the final frame (the viewer restarts or goes
		// back). Don't advance the sim or bank elapsed time.
		if ( _replayFinished )
		{
			if ( HijackArmed )
			{
				// A hijacked practice run has ended (frozen). Space replays from the start to the hijack
				// frame and hands control back there; A/D restart the recorded run normally and forget
				// the hijack.
				if ( Input.Pressed( "Jump" ) )
				{
					ResumeHijack();
					return;
				}

				if ( Input.Pressed( "Left" ) || Input.Pressed( "LeftArrow" ) || Input.Pressed( "Right" ) || Input.Pressed( "RightArrow" ) )
				{
					RestartReplay();
					return;
				}
			}
			// Space / Left / A restarts the finished replay (mirrors the RESTART transport button).
			else if ( Input.Pressed( "Jump" ) || Input.Pressed( "Left" ) || Input.Pressed( "LeftArrow" ) )
			{
				RestartReplay();
				return;
			}

			// Restore normal-speed music under the end screen.
			Audio.SetMusicPlaying( true );
			Audio.SetMusicPitch( 1f );
			_accumulator = 0f;
			Stage?.SyncTransforms();
			return;
		}

		// Hijacked: the live player has taken over from this frame on. Drive the (still IsReplay) stage
		// with live input like a normal run — checked BEFORE the transport so Space/A/D act as gameplay
		// (jump/move), not pause/speed.
		if ( _hijacked )
		{
			TickHijack();
			return;
		}

		// Transport input (keys) is read every frame, even while paused, so Space can toggle pause, taps
		// of Left/Right can single-step, and holding a direction can un-pause the run.
		UpdateReplayTransport();

		if ( _replayPaused )
		{
			// Frozen: don't advance the sim and don't bank elapsed time as a backlog. A queued single
			// step advances exactly one frame, then we re-freeze. Pause the music too so it stops with
			// the picture.
			if ( _replayStepBackOnce )
				StepReplayBackOnce();
			else if ( _replayStepOnce )
				StepReplayOnce();

			Audio.SetMusicPlaying( false );
			_accumulator = 0f;
			Stage?.SyncTransforms();
			return;
		}

		// Slow/speed the music to match the watch speed (the speed range is mapped onto the music pitch
		// band in log space, so only the extremes reach the band ends). A SoundHandle pitch resamples,
		// so it pitches AND slows together — a tape/vinyl effect.
		Audio.SetMusicPlaying( true );
		Audio.SetMusicPitch( SpeedToMusicPitch( ReplaySpeed * GameplayTime.Scale ) );

		_accumulator += Time.Delta * ReplaySpeed * GameplayTime.Scale;
		int steps = 0;
		while ( _accumulator >= Arena.STEP && steps < MAX_REPLAY_STEPS_PER_FRAME )
		{
			var result = AdvanceOneSimStep( SimStepInput.Replay );
			if ( result == SimStepResult.InputExhausted )
			{
				// Recorded input ran out: finish with the locked-in score if the game-over beat was
				// skipped (un-reproducible), otherwise a genuine "ran out before the end" desync.
				FinishReplayOnInputExhausted();
				break;
			}

			_accumulator -= Arena.STEP;
			steps++;
			if ( result == SimStepResult.Froze ) continue;
			// The recorded run ended this step (death/win -> GameStage.EndGame -> FinishReplay), so stop
			// advancing and hold the final frame.
			if ( result == SimStepResult.StageEnded ) break;
		}

		if ( steps >= MAX_REPLAY_STEPS_PER_FRAME )
			_accumulator = 0f;

		Stage?.SyncTransforms();
	}

	// Replay transport keys, read every frame (even while paused):
	//  - Space (Jump) toggles pause/play.
	//  - While paused: tap Right to advance a single tick; tap Left to rebuild the replay at the
	//    previous tick. Hold either direction past REPLAY_PAUSE_HOLD_RESUME to resume play, handing
	//    speed control back to the held key.
	//  - While playing: hold Left/Right (A/D or arrows) to lerp the speed slider toward an end; release
	//    to lerp it back to centre (1x). A mouse drag clears _replayKeyActive, so it won't fight a slider
	//    the user set.
	private void UpdateReplayTransport()
	{
		_replayStepOnce = false;
		_replayStepBackOnce = false;

		// Space toggles pause/play (mirrors the HUD pause/play button).
		if ( Input.Pressed( "Jump" ) )
		{
			ToggleReplayPause();
			_replayPausedHoldTime = 0f;
		}

		float speedAxis = ReplaySpeedAxisFromInput();
		bool speedHeld = MathF.Abs( speedAxis ) > 0f;

		if ( _replayPaused )
		{
			// Direction taps queue one-tick steps; only a sustained hold resumes playback.
			if ( Input.Pressed( "Left" ) || Input.Pressed( "LeftArrow" ) || Input.Pressed( REPLAY_SLOW_ACTION ) )
				_replayStepBackOnce = true;

			if ( Input.Pressed( "Right" ) || Input.Pressed( "RightArrow" ) || Input.Pressed( REPLAY_FAST_ACTION ) )
				_replayStepOnce = true;

			if ( speedHeld )
			{
				_replayPausedHoldTime += Time.Delta;
				if ( _replayPausedHoldTime < REPLAY_PAUSE_HOLD_RESUME )
					return; // still within the tap window — stay paused

				// Held long enough: resume and let the held key drive the speed below.
				_replayPaused = false;
				if ( IsPlacingReaction ) _reactionWasPaused = false;
				_replayPausedHoldTime = 0f;
				_replayKeyActive = true;
			}
			else
			{
				_replayPausedHoldTime = 0f;
				return; // paused with no direction held
			}
		}

		float k = MathF.Min( 1f, REPLAY_SPEED_LERP * Time.Delta );

		if ( speedHeld )
		{
			float target = 0.5f + speedAxis * 0.5f;
			_replaySpeedT += (target - _replaySpeedT) * k;
			_replayKeyActive = true;
		}
		else if ( _replayKeyActive )
		{
			_replaySpeedT += (0.5f - _replaySpeedT) * k;
			if ( MathF.Abs( _replaySpeedT - 0.5f ) < 0.002f )
			{
				_replaySpeedT = 0.5f;
				_replayKeyActive = false;
			}
		}
	}

	// Advance the replay by exactly one fixed step (used while paused to single-step a frame). Mirrors
	// one iteration of the catch-up loop in TickReplay: honour an active hit-stop without consuming a
	// recorded frame, otherwise feed the next recorded input and tick the sim once.
	private void StepReplayOnce()
	{
		if ( IsPlacingReaction ) _reactionWasPaused = true;
		// Skip any active hit-stop freeze so a single frame-step tap advances one REAL recorded frame
		// rather than being swallowed by a freeze step (viewers scrub frame-by-frame, not freeze-by-
		// freeze). Backward stepping already skips it — SeekReplayToFrame targets the recorded-frame
		// index, and freeze steps don't advance that index.
		if ( Stage is GameStage gs ) gs.ClearHitStop();

		if ( AdvanceOneSimStep( SimStepInput.Replay ) == SimStepResult.InputExhausted )
			FinishReplayOnInputExhausted();
	}

	// Step backward by rebuilding the replay at the previous recorded frame. The sim has no reverse
	// integrator, so this deliberately uses the same deterministic re-sim path as timeline scrubbing.
	private void StepReplayBackOnce()
	{
		if ( _replay is null || _replay.Position <= 0 )
			return;

		SeekReplayToFrame( _replay.Position - 1, playSfx: false );
	}

	/// <summary>Take control of the replay at the current frame (HIJACK button). Live player input drives
	/// the run from here on; it is practice-only (nothing recorded, no score/tally) and the frame is
	/// remembered so <see cref="ResumeHijack"/> can return to it.</summary>
	public void HijackReplay()
	{
		if ( IsPlacingReaction ) return;
		if ( !IsReplaying || _replay is null || _replayFinished || _hijacked || IsTransitioning )
			return;

		// Refuse if the recorded run has already entered its game-over beat — there's nothing left to
		// take control of (it would just re-finish immediately).
		if ( Stage is GameStage gs && gs.IsGameOver )
			return;

		Achievements.AwardReplayHijacked(); // after the guards, so only a real take-over counts
		_hijackFrame = _replay.Position; // arm the restore point at the current frame
		_hijacked = true;
		_replayPaused = false;           // hand control over immediately
		_replayKeyActive = false;
		_accumulator = 0f;
		// Re-baseline input so a key held at the moment of hijack isn't read as a fresh press next step.
		InputState.Prime();
		// Live control drops the replay transport multiplier but retains any active gameplay slow motion.
		Audio.SetMusicPlaying( true );
		Audio.SetMusicPitch( SpeedToMusicPitch( GameplayTime.Scale ) );
	}

	// Drive a hijacked replay with LIVE input, mirroring the live fixed-step loop in OnUpdate. The stage
	// is still an IsReplay GameStage (so EndGame freezes via FinishReplay instead of going to the score
	// tally), but input comes from Sample() and NOTHING is recorded. 1x real time, live step budget.
	private void TickHijack()
	{
		InputState.Sample( allowAbilityShortcuts: CanUseAbilityShortcuts );

		_accumulator += Time.Delta * GameplayTime.Scale;
		int steps = 0;
		while ( _accumulator >= Arena.STEP && steps < MAX_STEPS_PER_FRAME )
		{
			var result = AdvanceOneSimStep( SimStepInput.Hijack );
			_accumulator -= Arena.STEP;
			steps++;
			if ( result == SimStepResult.Froze ) continue;
			// The hijacked run ended this step (death/win -> EndGame -> FinishReplay), so stop and freeze.
			if ( result == SimStepResult.StageEnded ) break;
		}

		if ( steps >= MAX_STEPS_PER_FRAME )
			_accumulator = 0f;

		Stage?.SyncTransforms();
	}

	// Rebuild a fresh replay GameStage and silently replay from step 0 up to targetFrame, reproducing the
	// exact deterministic state at that frame (the sim is forward-only, like a backward timeline scrub).
	// Shared by hijack resume (Space) and hijack stop.
	private void RebuildToFrame( int targetFrame )
	{
		// ApplyPendingStage reseeds the Rng from _replay.Seed and enters the stage synchronously.
		_replay.Reset();
		SetStage( new GameStage( this, isReplay: true ) );
		ApplyPendingStage();

		bool prevSuppress = Audio.SuppressSfx;
		bool prevHaptics = Haptics.Suppress;
		Audio.SuppressSfx = true; // a full-run re-sim would otherwise fire the whole run's SFX at once
		Haptics.Suppress = true;  // ...and the whole run's vibration
		try
		{
			while ( _replay.Position < targetFrame && IsReplaying )
			{
				var result = AdvanceOneSimStep( SimStepInput.Replay );
				if ( result == SimStepResult.Froze ) continue;
				// A silent rebuild toward a known-good pre-game-over frame: on exhaustion just stop (no
				// FinishReplayOnInputExhausted — the caller owns the finish/hijack state).
				if ( result == SimStepResult.InputExhausted || result == SimStepResult.StageEnded )
					break;
			}
		}
		finally
		{
			Audio.SuppressSfx = prevSuppress;
			Haptics.Suppress = prevHaptics;
		}

		RestoreReplayPresentation();
	}

	private void RestoreReplayPresentation()
	{
		GameplayTime.Reset();
		if ( Stage is GameStage gameStage )
			foreach ( var player in gameStage.Players )
				player.RestoreReplayPresentation();
	}

	// Replay from the start to the armed hijack frame (instant + silent), reproducing the exact pre-hijack
	// state, then hand control back to the player at that frame.
	private void ResumeHijack()
	{
		if ( !IsReplaying || _replay is null || _hijackFrame < 0 )
			return;

		_replayFinished = false;
		_replayDesynced = false;
		_replayPaused = false;
		_accumulator = 0f;

		RebuildToFrame( _hijackFrame );

		_hijacked = true; // control handed back at the hijack frame
		// Re-baseline input so the Space still held from "retry" isn't read as a jump on the first step.
		InputState.Prime();
		Stage?.SyncTransforms();
	}

	/// <summary>The HUD restart button during a hijacked practice run — identical to pressing R: replay
	/// silently to the armed takeover frame and hand control back there.</summary>
	public void RestartHijack()
	{
		// Same transition gate the key path gets from TickReplay's early-return: a click mustn't rebuild
		// the stage while a wipe is mid-flight.
		if ( HijackArmed && !IsTransitioning )
			ResumeHijack();
	}

	/// <summary>End an in-progress hijack (STOP button) and return to normal recorded playback, paused on
	/// the exact frame the hijack began. Forgets the hijack point so the run plays out normally from here.</summary>
	public void StopHijack()
	{
		if ( !IsReplaying || _replay is null || !_hijacked )
			return;

		int target = _hijackFrame;
		_hijacked = false;
		_hijackFrame = -1;          // back to normal replay; the hijack is cancelled
		_replayFinished = false;
		_replayDesynced = false;
		_accumulator = 0f;

		RebuildToFrame( target );

		_replayPaused = true;       // hold on the hijack frame, as if scrubbed there
		Audio.SetMusicPitch( 1f );  // (the paused branch in TickReplay pauses the music itself)
		Stage?.SyncTransforms();
	}

	/// <summary>Set the replay speed from the HUD slider (0..1). Leaves the value where dropped and
	/// un-pauses playback.</summary>
	public void SetReplaySpeedSlider( float t )
	{
		if ( _hijacked ) return; // transport is gameplay during hijack; ignore stray slider sets
		_replaySpeedT = Math.Clamp( t, 0f, 1f );
		_replayKeyActive = false; // a deliberate mouse set; don't auto-return to centre
		_replayPaused = false;
		if ( IsPlacingReaction ) _reactionWasPaused = false;
	}

	/// <summary>True while a timeline scrub gesture is in progress (mouse-down .. mouse-up). The HUD uses
	/// this to put up a full-screen transparent catcher so the mouse-release is still processed when it
	/// lands over a region with no interactive panel (the arena / letterbox) — otherwise the panel input
	/// system early-outs there and the drag never ends.</summary>
	public bool IsScrubbingTimeline { get; private set; }

	/// <summary>Begin a timeline scrub gesture (progress-bar mouse-down): freeze playback and remember
	/// whether it was running, so <see cref="EndReplayScrub"/> can resume it on release.</summary>
	public void BeginReplayScrub()
	{
		if ( !IsReplaying || _hijacked )
			return;

		_scrubResumePlaying = !_replayPaused && !_replayFinished;
		_replayPaused = true; // hold the picture for the duration of the scrub
		IsScrubbingTimeline = true;
	}

	/// <summary>End a timeline scrub gesture (progress-bar mouse-up): resume playback if it was running
	/// when the scrub began and auto-resume is enabled; otherwise stay paused on the scrubbed frame.</summary>
	public void EndReplayScrub()
	{
		IsScrubbingTimeline = false;

		if ( REPLAY_SCRUB_AUTO_RESUME && _scrubResumePlaying && IsReplaying && !_replayFinished && !IsPlacingReaction )
			_replayPaused = false;

		_scrubResumePlaying = false;
	}

	// ── GIF export preview framing ──────────────────────────────────────────────────────────────────
	// The GIF-export overlay shrinks the live replay into the top of the screen (leaving room for the
	// export controls below) by zooming the orthographic camera out and shifting its centre downward, so
	// the 240x240 arena sits centred in the top GIF_PREVIEW_TOP_FRACTION of the view. This only changes
	// what's shown live — the export itself re-frames tight (see GifExporter), so the GIF is unaffected.

	/// <summary>Fraction of screen height the shrunk arena occupies (from the top) during GIF preview.</summary>
	private const float GIF_PREVIEW_TOP_FRACTION = 0.6f;

	private Vector3 _gifCamPosSaved;
	private float _gifCamOrthoSaved;
	private bool _gifPreviewActive;

	/// <summary>True while the GIF-export overlay is shrinking the replay into the top of the screen.</summary>
	public bool IsGifPreviewActive => _gifPreviewActive;

	/// <summary>Enter GIF-preview framing: pause playback and shrink the arena into the top region. Saves the
	/// camera transform so <see cref="EndGifPreview"/> can restore it exactly.</summary>
	public void BeginGifPreview()
	{
		CancelReaction();
		if ( !IsReplaying || _hijacked || Camera is null || _gifPreviewActive )
			return;

		_gifCamPosSaved = Camera.WorldPosition;
		_gifCamOrthoSaved = Camera.OrthographicHeight;
		_gifPreviewActive = true;
		_replayPaused = true; // hold the picture so a head's frame stays put while the overlay is open

		// Zoom out so the arena (Arena.HEIGHT tall) fills GIF_PREVIEW_TOP_FRACTION of the view height, then
		// shift the camera centre DOWN so the arena rides above screen-centre into the top region. (Camera
		// looks -Z with up = +Y, so a smaller world-Y at screen-centre pushes the arena upward on screen.)
		float ortho = Arena.HEIGHT / GIF_PREVIEW_TOP_FRACTION;
		float offset = (0.5f - GIF_PREVIEW_TOP_FRACTION / 2f) * ortho;
		Camera.OrthographicHeight = ortho;
		Camera.WorldPosition = new Vector3( Arena.WIDTH / 2f, Arena.HEIGHT / 2f - offset, _gifCamPosSaved.z );
	}

	/// <summary>Restore the camera framing saved by <see cref="BeginGifPreview"/> (overlay closed).</summary>
	public void EndGifPreview()
	{
		if ( !_gifPreviewActive )
			return;

		_gifPreviewActive = false;
		if ( Camera is not null )
		{
			Camera.WorldPosition = _gifCamPosSaved;
			Camera.OrthographicHeight = _gifCamOrthoSaved;
		}
	}

	/// <summary>Scrub the replay to a normalised timeline position in [0,1] (progress-bar click/drag).
	/// <paramref name="playSfx"/> lets a small forward drag keep its per-step audio; a click-jump or a
	/// backward seek passes false so its burst of re-simulated SFX stays silent.</summary>
	public void SeekReplayToProgress( float t, bool playSfx = false )
	{
		if ( _replay is null )
			return;

		SeekReplayToFrame( (int)MathF.Round( Math.Clamp( t, 0f, 1f ) * _replay.Length ), playSfx );
	}

	/// <summary>Scrub the replay to an absolute recorded-frame index, reconstructing that exact frame by
	/// re-simulating the deterministic run. A FORWARD seek just keeps feeding recorded input from the
	/// current position (cheap, incremental); a BACKWARD seek can't run the sim in reverse, so it rebuilds
	/// a fresh replay <see cref="GameStage"/> (reseeded from the recording) and replays forward from step 0.
	/// Either way the intermediate steps render nothing — the sim is ticked with no per-step transform
	/// sync, and a single <see cref="StageBase.SyncTransforms"/> paints the landed frame. SFX are gated
	/// by <paramref name="playSfx"/> (a small forward drag keeps its per-step audio; a click-jump or a
	/// backward rebuild stays silent so it doesn't fire a burst of layered sounds at once). Playback is
	/// left paused on that frame (resume with the existing transport controls / auto-resume).</summary>
	public void SeekReplayToFrame( int targetFrame, bool playSfx = false )
	{
		if ( !IsReplaying || _replay is null || IsTransitioning || _hijacked )
			return;

		targetFrame = Math.Clamp( targetFrame, 0, _replay.Length );

		// Once the run has reproduced its game-over (THE END), the game-over IS the replay's real end —
		// the recording carries extra frames past it (captured during the score-screen transition), but
		// ticking into those re-sims nothing meaningful and then "runs out of input", reporting a spurious
		// desync. So ignore a forward seek while finished; only seeking BACK (to review) is allowed.
		if ( _replayFinished && targetFrame >= _replay.Position )
			return;

		// Scrubbing holds the chosen frame: clear the finished/desync freeze so the sim can advance
		// (it's re-armed below if we fast-forward into the run's game-over) and pause on arrival.
		_replayFinished = false;
		_replayDesynced = false;
		_replayPaused = true;
		_accumulator = 0f;
		// Choosing a new moment is an explicit pause. Keep the manager-owned picker and
		// selected effect through a backward stage rebuild, and stay here on Cancel too.
		if ( IsPlacingReaction ) _reactionWasPaused = true;

		// A backward seek rebuilds the run from step 0 — always silence that (it re-fires the whole run's
		// SFX). A forward drag only re-runs a few steps and may keep its audio (playSfx).
		bool backward = targetFrame < _replay.Position;
		bool prevSuppress = Audio.SuppressSfx;
		bool prevHaptics = Haptics.Suppress;
		float prevSfxScale = Audio.SfxVolumeScale;
		Audio.SuppressSfx = backward || !playSfx;
		// Vibration tracks the SFX gate: silent (backward/click-jump) re-sims mustn't buzz the pad; a
		// small forward drag that keeps its audio keeps its vibration too.
		Haptics.Suppress = backward || !playSfx;
		// A forward scrub's SFX play live but ducked, so they sit under normal-speed playback.
		Audio.SfxVolumeScale = REPLAY_SCRUB_SFX_VOLUME;
		try
		{
			// Seeking backward (the sim is forward-only): rebuild a fresh replay GameStage and replay from
			// step 0. ApplyPendingStage reseeds the Rng from _replay.Seed and enters the stage synchronously,
			// so it's ready to tick in the loop below — mirrors RestartReplay, but applied here-and-now.
			if ( backward )
			{
				_replay.Reset();
				SetStage( new GameStage( this, isReplay: true ) );
				ApplyPendingStage();
			}

			// Fast-forward to the target, mirroring one iteration of TickReplay's catch-up loop per step:
			// reproduce a hit-stop freeze without consuming a recorded frame, otherwise feed the next recorded
			// input and tick the sim once. No SyncTransforms here — this is a pure state advance.
			while ( _replay.Position < targetFrame && IsReplaying && !_replayFinished )
			{
				var result = AdvanceOneSimStep( SimStepInput.Replay );
				if ( result == SimStepResult.Froze ) continue;
				if ( result == SimStepResult.InputExhausted )
				{
					// Recorded input ran out before reaching the target: finish with the locked-in score
					// if the game-over beat was skipped, else a genuine "ran out early" desync.
					FinishReplayOnInputExhausted();
					break;
				}
				if ( result == SimStepResult.StageEnded )
					break;
			}
		}
		finally
		{
			Audio.SuppressSfx = prevSuppress;
			Haptics.Suppress = prevHaptics;
			Audio.SfxVolumeScale = prevSfxScale;
		}

		// Paint the landed frame once — the only render of the whole re-sim.
		RestoreReplayPresentation();
		Stage?.SyncTransforms();
	}

	/// <summary>Toggle replay pause (HUD pause/play button).</summary>
	public void ToggleReplayPause()
	{
		if ( !IsReplaying || _hijacked || _replayFinished || IsTransitioning ) return;
		_replayPaused = !_replayPaused;
		if ( IsPlacingReaction ) _reactionWasPaused = _replayPaused;
	}

	/// <summary>Restart the current replay from the beginning (HUD restart button).</summary>
	public void RestartReplay()
	{
		if ( !IsReplaying || _replay is null )
			return;

		_replay.Reset();
		_accumulator = 0f;
		_replayPaused = IsPlacingReaction;
		if ( IsPlacingReaction ) _reactionWasPaused = true;
		_replayFinished = false;
		_replayDesynced = false;
		// Restarting forgets any hijack (the RESTART button / A·D path): play back the recorded run.
		_hijacked = false;
		_hijackFrame = -1;
		// Restart immediately (no grow/shrink wipe) — a replay restart should snap straight back to
		// frame 0; ApplyPendingStage reseeds the fresh GameStage from _replay.Seed.
		SetStage( new GameStage( this, isReplay: true ) );
	}

	/// <summary>Begin playing back a recorded run from the leaderboard. Reseeds the sim with the
	/// recorded seed (in <see cref="ApplyPendingStage"/>) and feeds the recorded input each step.</summary>
	/// <param name="data">The recorded run payload.</param>
	/// <param name="submitter">Leaderboard metadata for the run's author (shown in the replay HUD).</param>
	/// <param name="fromLocalReplays">True when launched from the MY REPLAYS archive (so exiting returns
	/// there instead of the leaderboard); leaderboard launches leave it false.</param>
	/// <param name="returnDailyId">When set, Back exits the replay to that daily challenge page instead
	/// of the normal leaderboard.</param>
	/// <param name="returnToLevelSelect">When true, Back exits the replay to the level-select map instead
	/// of the normal leaderboard (used by the level-select footer's rows).</param>
	/// <param name="leaderboardBackToLevelSelect">When true, a replay returning to the normal leaderboard
	/// preserves that page's level-select BACK destination.</param>
	/// <param name="returnSelectionIndex">Row index selected on the launching list, restored when the
	/// replay exits back to the MY REPLAYS archive or the leaderboard (-1 = none).</param>
	/// <param name="returnWorkshopLevelId">When set, Back exits the replay to that workshop level's
	/// board (whose own BACK returns to the workshop screen) instead of the normal leaderboard.</param>
	/// <param name="returnScore">The score tally this chain started from, so exiting rebuilds it (already
	/// counted out) — either directly (<paramref name="returnToScore"/>) or via the board it opened.</param>
	/// <param name="returnToScore">True when the replay was launched straight from that tally, so Back
	/// returns to it instead of to a leaderboard.</param>
	public void StartReplay( RunData data, ReplaySubmitter submitter = default, bool fromLocalReplays = false, string returnDailyId = null, bool returnToLevelSelect = false, bool leaderboardBackToLevelSelect = false, int returnSelectionIndex = -1, string returnWorkshopLevelId = null, ScoreReturn returnScore = null, bool returnToScore = false )
	{
		if ( data is null || !data.CanReplay || StageSwapPending )
			return;

		// A different run is being launched: forget any remembered GIF-export settings and preview.
		EndGifPreview();
		SavedGifConfig = null;

		_replayData = data;
		_replay = new RunPlayback( data );
		_replaySubmitter = submitter;

		// A test-play local replay embeds its level (a transient snapshot of the editor model, not in
		// the persistent registry). Register it as a transient so it resolves by id while the replay
		// runs (and any restart re-enters); ResetReplayState clears it when the replay ends so it can't
		// shadow a real level of the same id afterwards.
		if ( data.LevelData is not null )
			Levels.RegisterTransient( data.LevelData );
		_replayFromLocalReplays = fromLocalReplays;
		_replayReturnDailyId = returnDailyId;
		_replayReturnToLevelSelect = returnToLevelSelect;
		_replayLeaderboardBackToLevelSelect = leaderboardBackToLevelSelect;
		_replayReturnWorkshopLevelId = returnWorkshopLevelId;
		_replayReturnSelection = returnSelectionIndex;
		_replayReturnScore = returnScore;
		_replayReturnToScore = returnToScore && returnScore is not null;
		IsReplaying = true;
		_replaySpeedT = 0.5f; // 1x
		_replayKeyActive = false;
		_replayPaused = false;
		_replayFinished = false;
		_replayDesynced = false;
		_hijacked = false;
		_hijackFrame = -1;
		_accumulator = 0f;
		TransitionToStage( new GameStage( this, isReplay: true ) );
		// Every user-facing source enters here. Restarts, seeks, GIF preview and headless regression
		// playback do not, so rebuilding a replay stage cannot accidentally submit another view.
		CurrentReplayViews?.Cancel();
		CurrentReplayViews = ReplayViewStats.Begin( data, submitter.SteamId );
		CurrentReplayReactions?.Cancel();
		CurrentReplayReactions = ReplayReactions.Begin( data, submitter.SteamId );
		IsPlacingReaction = false;
	}

	/// <summary>Copy the currently watched replay's share code to the system clipboard.</summary>
	public bool CopyCurrentReplayCode()
	{
		string code = CurrentReplayCode;
		if ( string.IsNullOrEmpty( code ) )
		{
			Log.Info( "BlockParty replay code: copy failed (no replay is currently being watched)." );
			return false;
		}

		Sandbox.UI.Clipboard.SetText( code );
		Log.Info( $"BlockParty replay code: copied to clipboard ({ReplayCodeCodec.Describe( _replayData )}, {code.Length} chars)." );
		return true;
	}

	/// <summary>Save the currently watched replay into the local archive. Duplicate saves are no-ops.</summary>
	public bool SaveCurrentReplay()
	{
		if ( !IsReplaying || _replayData?.CanReplay != true )
			return false;

		long steamId = _replaySubmitter.SteamId != 0 ? _replaySubmitter.SteamId : (long)Game.SteamId;
		string displayName = !string.IsNullOrWhiteSpace( _replaySubmitter.Name )
			? _replaySubmitter.Name
			: new Friend( steamId ).Name;
		LocalReplays.Add( _replayData, steamId, displayName, _replaySubmitter.CountryCode );
		return CurrentReplayIsLocal;
	}

	/// <summary>Remove the currently watched replay from the local archive. No-op if it is not saved.</summary>
	public bool DeleteCurrentReplayLocal()
	{
		var entry = LocalReplays.Find( _replayData, _replaySubmitter.SteamId );
		if ( entry is null )
			return false;

		LocalReplays.Remove( entry );
		return !CurrentReplayIsLocal;
	}

	/// <summary>Toggle the current replay's local favorite state. No-op until the replay is saved locally.</summary>
	public void ToggleCurrentReplayFavorite()
	{
		var entry = LocalReplays.Find( _replayData, _replaySubmitter.SteamId );
		if ( entry is null )
			return;

		LocalReplays.ToggleFavorite( entry );
	}

	/// <summary>Decode a pasted replay code and immediately watch it. This does not write to the local
	/// replay archive; it is a pure string round-trip.</summary>
	public bool TryStartReplayCode( string code )
	{
		if ( !ReplayCodeCodec.TryDecode( code, out var data, out long steamId ) )
		{
			Log.Info( $"BlockParty replay code: watch failed (invalid or incompatible code, {(code ?? "").Length} chars)." );
			return false;
		}

		// The code carries who did the run, so the HUD (and a save from this watch) attributes it to
		// them rather than the local player. Name/avatar resolve from the id; there's no country or
		// submission date in a code. Id 0 (encoded with no Steam session) means the importer.
		if ( steamId == 0 )
			steamId = (long)Game.SteamId;
		var submitter = new ReplaySubmitter( steamId, new Friend( steamId ).Name, "", default );
		bool returnToLocal = IsReplaying && _replayFromLocalReplays;
		StartReplay( data, submitter, fromLocalReplays: returnToLocal, returnDailyId: _replayReturnDailyId, returnToLevelSelect: _replayReturnToLevelSelect,
			leaderboardBackToLevelSelect: _replayLeaderboardBackToLevelSelect, returnSelectionIndex: _replayReturnSelection,
			returnWorkshopLevelId: _replayReturnWorkshopLevelId, returnScore: _replayReturnScore, returnToScore: _replayReturnToScore );
		bool started = IsReplaying && _replayData == data;
		if ( started )
			Achievements.AwardReplayCodeUsed();
		Log.Info( started
			? $"BlockParty replay code: watch started ({ReplayCodeCodec.Describe( data )})."
			: "BlockParty replay code: watch failed (replay could not start right now)." );
		return started;
	}

	/// <summary>Freeze a finished replay on its final frame (called when the replayed run reaches
	/// game-over, or the recorded input is exhausted). The viewer then restarts or goes back. Runs the
	/// determinism self-check once, with the score recomputed from the reproduced run.</summary>
	/// <param name="reproducedScore">The final score recomputed from the replayed run, or -1 when the
	/// recorded input ran out before a game-over (no score to verify).</param>
	public void FinishReplay( int reproducedScore, System.Collections.Generic.IReadOnlyList<BlockResult> reproducedBlocks = null )
	{
		if ( !IsReplaying || _replayFinished )
			return;

		if ( _hijacked )
		{
			// A hijacked run is player-controlled and meant to diverge from the recording — skip the
			// determinism check (it would always "DESYNC"). Stop live control but keep the hijack point
			// armed so Space can re-enter at the same frame.
			_hijacked = false;
		}
		else
		{
			CheckReplayDesync( reproducedScore, reproducedBlocks );
		}

		_replayFinished = true;
		_replayPaused = false; // the dedicated finished-freeze in TickReplay holds the frame
		_accumulator = 0f;
	}

	// The replay consumed its last recorded frame without the run ending on its own. Recording stops at
	// game-over, so a run that ended by the player SKIPPING the game-over beat (an input edge the replay
	// can't reproduce) lands here with the beat still counting down — finish it with the score already
	// locked in. Only a run that never reached game-over at all is a genuine "ran out early" desync (-1).
	private void FinishReplayOnInputExhausted()
	{
		// `Stage is GameStage gs` is a type-pattern: true (with `gs` bound) whenever the current stage is
		// a GameStage — which it always is mid-replay (a replay runs in a GameStage with IsReplay set), so
		// this is really just a typed handle to call the method below. FinishGameOverForReplay itself is
		// the one that decides whether we're mid game-over (it checks _gameOver internally) — if we are,
		// it runs EndGame, which calls back into FinishReplay with the real score and sets _replayFinished.
		if ( Stage is GameStage gs )
			gs.FinishGameOverForReplay();

		// If that didn't finish us (the run never reached game-over), it's a genuine input-exhausted desync.
		if ( !_replayFinished )
			FinishReplay( -1 );
	}

	// Determinism self-check: a correct replay reproduces the exact block states, so the score
	// recomputed from them must equal the score the original run submitted. A mismatch means the
	// sim diverged (an RNG/ordering/timestep determinism bug) — log loudly so it gets noticed.
	private void CheckReplayDesync( int reproducedScore, System.Collections.Generic.IReadOnlyList<BlockResult> reproducedBlocks = null )
	{
		if ( _replay is null )
			return;

		int expected = _replay.ExpectedFinalScore;

		// reproducedScore < 0 means the recorded input ran out before a game-over. If the original run
		// actually had a score, that's a divergence too — the replay should have reached the same death.
		if ( reproducedScore < 0 )
		{
			if ( expected > 0 )
			{
				_replayDesynced = true;
				Log.Warning( $"BlockParty replay DESYNC: input exhausted before game-over but recorded score was {expected} (seed {_replay.Seed}). The replay never reproduced the original run." );
				SaveDesyncReport( expected, reproducedScore, null );
			}
			return;
		}

		if ( reproducedScore != expected )
		{
			_replayDesynced = true;
			Log.Warning( $"BlockParty replay DESYNC: reproduced score {reproducedScore} != recorded {expected} (seed {_replay.Seed}). The replay diverged from the original run." );
			LogDesyncBlockDiff( reproducedBlocks );
			SaveDesyncReport( expected, reproducedScore, reproducedBlocks );
		}
		else
			Log.Info( $"BlockParty replay verified: score {reproducedScore} matches the recorded run (seed {_replay.Seed})." );
	}

	// Write the sendable bug-report file (logs/ in the data folder — see DesyncReport). The live
	// checkpoint/press trails only belong in it when they're actually THIS run's (same seed AND input
	// stream — dailies share seeds); trails from some other run this session would read as a wall of
	// false divergence.
	private void SaveDesyncReport( int expected, int reproduced, System.Collections.Generic.IReadOnlyList<BlockResult> reproducedBlocks )
	{
		var live = _replayLiveTrace;
		if ( live is not null && live.Presses.Count != _replayPressEvents.Count )
			Log.Warning( $"BlockParty press trace: live run awarded {live.Presses.Count} presses, replay awarded {_replayPressEvents.Count} — see the desync report for both trails." );
		DesyncReport.Save( _replayData, expected, reproduced, reproducedBlocks, _replayTraceDivergenceStep,
			live?.Checkpoints ?? (System.Collections.Generic.IReadOnlyList<uint>)System.Array.Empty<uint>(), _replayTraceCheckpoints,
			live is not null ? FormatPressTrail( live.Presses ) : "(run not from this session)",
			FormatPressTrail( _replayPressEvents ) );
	}

	// Forensics for a score desync: the payload carries the ORIGINAL run's final block states (the
	// board rows draw their tally graphic from them), so diffing against the reproduced states names
	// the block(s) whose mechanic diverged. Both sides go through the same pack/decode + type ordering
	// so the lists line up; runs recorded pre-v3 (no block data) just skip this.
	private void LogDesyncBlockDiff( System.Collections.Generic.IReadOnlyList<BlockResult> reproducedBlocks )
	{
		if ( _replayData?.Blocks is null || reproducedBlocks is null )
			return;

		var recorded = _replayData.DecodeBlocks();
		var reproduced = new RunData { Blocks = RunData.PackBlocks( reproducedBlocks ) }.DecodeBlocks();

		static string Line( System.Collections.Generic.List<BlockTallyState> blocks ) =>
			string.Join( "  ", blocks.Select( b =>
				$"{b.Type} p{b.Phase} {(b.Left ? "L" : "-")}{(b.Right ? "R" : "-")}{(b.Up ? "U" : "-")}{(b.Down ? "D" : "-")}" ) );

		Log.Warning( $"BlockParty replay DESYNC blocks — recorded:   {Line( recorded )}" );
		Log.Warning( $"BlockParty replay DESYNC blocks — reproduced: {Line( reproduced )}" );
	}

	// ── replay regression harness (see ReplayRegression) ─────────────────────────────────────────────
	// Drives whole recorded runs through the SAME AdvanceOneSimStep replay path as on-screen playback,
	// but headless: synchronous, no per-step transform sync, audio/haptics suppressed. Every saved
	// replay thereby doubles as a physics regression test — replay_bless stores golden state traces,
	// replay_verify re-runs them after a sim change and reports where behaviour drifted.

	/// <summary>Run every replayable local-archive run headless and either save the traces as the new
	/// golden baselines (<paramref name="bless"/>) or compare against the stored baselines and report
	/// divergences. Refused mid-transition, mid-replay, or during a live run (the harness swaps stages,
	/// which would abandon the run and its recording). Lands on the title screen when done.</summary>
	public void RunReplayRegression( bool bless )
	{
		if ( IsTransitioning || IsReplaying )
		{
			Log.Warning( "[replay-regression] busy (mid-transition or mid-replay) — try again from a menu." );
			return;
		}

		if ( Stage is GameStage )
		{
			Log.Warning( "[replay-regression] a live run is active — finish or leave it first (the harness would abandon it)." );
			return;
		}

		// A console command runs outside the scene tick, so Game.ActiveScene can be unset — notably after
		// the editor's Stop, which destroys the play scene but leaves this static Instance reachable.
		// Every GameObject the batch spawns takes its Scene from the active scene AT CONSTRUCTION and
		// never again: unset, they're orphans whose components never enable, and anything that touches
		// Scene synchronously (the Swapper portal's pointer) NREs mid-batch. Refuse without a live scene
		// and pin ours for the whole batch.
		if ( Scene is null )
		{
			Log.Warning( "[replay-regression] no live game scene (was the game stopped?) — press Play and run it from the game." );
			return;
		}
		using var sceneScope = Scene.Push();

		var runnable = new System.Collections.Generic.List<LocalReplayEntry>();
		var skipped = new System.Collections.Generic.List<(LocalReplayEntry Entry, ReplayUnavailableReason Why)>();
		foreach ( var e in LocalReplays.All )
		{
			if ( e.Run is null ) continue;
			var why = e.Run.UnavailableReason;
			if ( why == ReplayUnavailableReason.None ) runnable.Add( e );
			else skipped.Add( (e, why) );
		}

		if ( runnable.Count == 0 )
		{
			Log.Warning( $"[replay-regression] no replayable runs in the local archive ({ReplayRegression.DescribeSkipped( skipped )}). Save some replays first — favourites make good permanent fixtures." );
			return;
		}

		var started = System.DateTimeOffset.Now;
		var traces = new System.Collections.Generic.Dictionary<string, RegressionTrace>();
		foreach ( var entry in runnable )
		{
			string key = ReplayRegression.KeyFor( entry.Run );
			// The same deterministic payload can be archived twice (two submitters); one trace suffices.
			if ( traces.ContainsKey( key ) )
				continue;
			traces[key] = RunReplayHeadless( entry );
		}

		// The batch consumed whatever stage was up; land somewhere neutral.
		SetStage( new TitleStage( this ) );
		ApplyPendingStage();

		double seconds = (System.DateTimeOffset.Now - started).TotalSeconds;
		if ( bless )
			ReplayRegression.SaveBaselines( traces, seconds );
		else
			ReplayRegression.CompareAndReport( traces, skipped, seconds );
	}

	// Advance one whole recorded run headless, folding the sim state into a rolling hash after every
	// step and emitting a checkpoint each sim-second (see ReplayRegression.FoldStep). Mirrors the
	// silent fast-forward in SeekReplayToFrame: reseed + fresh replay GameStage, then the shared step
	// primitive until the run reproduces its end or exhausts its recorded input.
	private RegressionTrace RunReplayHeadless( LocalReplayEntry entry )
	{
		RunData data = entry.Run;
		var trace = new RegressionTrace { Label = ReplayRegression.LabelFor( entry ) };

		_replayData = data;
		_replay = new RunPlayback( data );

		// A test-play local replay embeds its (unsaved / mid-edit) level, which the persistent registry
		// can't resolve — register it as a transient exactly like StartReplay does, or the headless run
		// resolves the id to the CURRENT on-disk level and replays different geometry (a deterministic
		// false desync). ResetReplayState (finally, below) clears it between batch runs.
		if ( data.LevelData is not null )
			Levels.RegisterTransient( data.LevelData );

		_replaySubmitter = default;
		IsReplaying = true;
		_replayPaused = false;
		_replayFinished = false;
		_replayDesynced = false;
		_hijacked = false;
		_hijackFrame = -1;
		_accumulator = 0f;

		bool prevSuppress = Audio.SuppressSfx;
		bool prevHaptics = Haptics.Suppress;
		Audio.SuppressSfx = true;
		Haptics.Suppress = true;
		try
		{
			SetStage( new GameStage( this, isReplay: true ) );
			ApplyPendingStage();

			uint hash = ReplayRegression.FNV_OFFSET;
			int lastCheckpoint = 0;
			// Freeze (hit-stop) steps consume no recorded frame, so bound the loop well above the
			// recorded length rather than trusting it to terminate — hit-stop is a few frames per
			// slam, never a whole run.
			int safety = data.StepCount * 4 + 4096;
			while ( safety-- > 0 )
			{
				var result = AdvanceOneSimStep( SimStepInput.Replay );
				if ( result == SimStepResult.InputExhausted )
				{
					FinishReplayOnInputExhausted();
					break;
				}

				if ( Stage is GameStage gs )
				{
					hash = ReplayRegression.FoldStep( gs, hash );

					if ( _replay.Position != lastCheckpoint && _replay.Position % ReplayRegression.CHECKPOINT_EVERY == 0 )
					{
						lastCheckpoint = _replay.Position;
						trace.Checkpoints.Add( new RegressionCheckpoint
						{
							Step = _replay.Position,
							Hash = hash,
							PlayerX = gs.Player?.X ?? 0f,
							PlayerY = gs.Player?.Y ?? 0f,
						} );
					}
				}

				if ( result == SimStepResult.StageEnded )
					break;
			}

			trace.FinishedAtStep = _replay?.Position ?? 0;
			trace.FinalHash = hash;
			trace.DesyncedVsRecording = _replayDesynced;
		}
		finally
		{
			Audio.SuppressSfx = prevSuppress;
			Haptics.Suppress = prevHaptics;
			ResetReplayState();
		}

		return trace;
	}

	/// <summary>End the current replay and return to the leaderboard. Idempotent. Refused while a
	/// wipe is running: the return transition below would be swallowed by the active one, whose queued
	/// stage would then apply against the torn-down replay state (a replay GameStage seeded as a live
	/// run).</summary>
	public void EndReplay()
	{
		if ( !IsReplaying || StageSwapPending )
			return;

		ResetReplayState();

		// Return to whichever screen launched the replay: MY REPLAYS archive if we came from there,
		// otherwise the leaderboard. Both restore the row that was selected when the replay launched.
		if ( _replayReturnToScore )
			TransitionToStage( _replayReturnScore.Create( this ) );
		else if ( _replayFromLocalReplays )
			TransitionToStage( new LocalReplaysStage( this, _replayReturnSelection ) );
		else if ( !string.IsNullOrEmpty( _replayReturnDailyId ) )
			TransitionToStage( new DailyChallengeStage( this, _replayReturnDailyId ) );
		else if ( _replayReturnToLevelSelect )
			TransitionToStage( new LevelSelectStage( this ) );
		else if ( !string.IsNullOrEmpty( _replayReturnWorkshopLevelId ) )
			TransitionToStage( new HighscoreStage( this, levelId: _replayReturnWorkshopLevelId,
				returnSelection: _replayReturnSelection, backToWorkshop: true, returnScore: _replayReturnScore ) );
		else
			TransitionToStage( new HighscoreStage( this, returnSelection: _replayReturnSelection,
				backToLevelSelect: _replayLeaderboardBackToLevelSelect, returnScore: _replayReturnScore ) );

		_replayReturnDailyId = null;
		_replayReturnToLevelSelect = false;
		_replayLeaderboardBackToLevelSelect = false;
		_replayReturnWorkshopLevelId = null;
		_replayReturnSelection = -1;
		_replayReturnScore = null;
		_replayReturnToScore = false;
	}

	/// <summary>Advance the hold-to-restart timer and overlay fade one rendered frame. Real time (not
	/// GameplayTime-scaled), so slow-mo can't stretch the hold. The displayed bar fill freezes at its
	/// last value during the release fade-out (only the alpha animates), then clears once hidden.</summary>
	private void TickRestartHold( bool holding )
	{
		float delay = Settings.Current.RestartButtonDelay;
		if ( delay <= 0f )
		{
			ResetRestartHold();
			return;
		}
		float fadeSeconds = Math.Min( RESTART_HOLD_FADE_SECONDS, delay * 0.3f );
		if ( holding )
		{
			_restartHoldTime += Time.Delta;
			_restartHoldProgress = Math.Clamp( _restartHoldTime / delay, 0f, 1f );
			_restartHoldAlpha = Math.Min( 1f, _restartHoldAlpha + Time.Delta / fadeSeconds );
		}
		else
		{
			_restartHoldArmed = false;
			_restartHoldTime = 0f;
			_restartHoldAlpha = Math.Max( 0f, _restartHoldAlpha - Time.Delta / fadeSeconds );
			if ( _restartHoldAlpha <= 0f )
				_restartHoldProgress = 0f;
		}
	}

	/// <summary>Drop the hold-to-restart overlay instantly (a restart is firing — the stage snap is a
	/// hard visual cut anyway, and the fresh run must not start with a half-armed hold).</summary>
	private void ResetRestartHold()
	{
		_restartHoldArmed = false;
		_restartHoldTime = 0f;
		_restartHoldAlpha = 0f;
		_restartHoldProgress = 0f;
	}

	/// <summary>Restart the current run with a fresh random seed. Available during active live play and
	/// after a death (see <see cref="GameStage.CanRestart"/>); callers gate on that.
	/// Snaps straight to a new <see cref="GameStage"/> (no wipe) — ApplyPendingStage picks a new seed
	/// and starts a fresh recording because _replay is null on a live run.</summary>
	public void RestartRun()
	{
		// Refuse mid-swap: once a transition is in flight (e.g. the game-over wipe to the score
		// tally), TickTransition will SetStage(_transNext) at full cover and clobber any run we
		// start here — after RegisterAttemptStart already consumed a daily attempt for it. The
		// _hideOverlayOnSwap frame is the same hazard in reverse: our SetStage would clobber the
		// completed wipe's queued stage.
		if ( Stage is not GameStage gs || !gs.CanRestart || StageSwapPending )
			return;

		// The restarted run replays the same level and character. Captured before SubmitRunOnce,
		// which clears the current run context.
		var context = CurrentRunContext;
		string levelId = gs.Level?.Id;
		string characterId = context.CharacterId;

		// Bank the dead run's score + replay before we abandon it for a fresh one. Single-submit
		// guarded inside GameStage, so this never double-submits with the score-tally path.
		gs.SubmitRunOnce();

		_accumulator = 0f;

		// A daily restart re-runs the same day (same seed/layout) and consumes an attempt; a DEBUG
		// daily restart counts only into the display-only debug file. CanRestart already verified
		// attempts remain.
		if ( context.IsDaily )
		{
			if ( !gs.IsDebugRun )
				DailyChallengeProgress.RegisterAttemptStart( context.DailyId );
			else
				DailyChallengeProgress.RegisterDebugAttemptStart( context.DailyId );
			_pendingRunContext = RunContext.Daily( context.DailyId, characterId );
			SetStage( new GameStage( this ) { IsDebugRun = gs.IsDebugRun } );
			return;
		}

		_pendingRunContext = RunContext.ForLevel( levelId, characterId );
		SetStage( new GameStage( this ) );
	}

	/// <summary>Home button entry point: a live, limited-attempt daily run asks for confirmation first
	/// (<see cref="GameStage.TryOpenLeaveConfirm"/>, whose LEAVE then calls <see cref="QuitToMenu"/>);
	/// anything else quits straight away. Refused mid-wipe like the leave itself, so the prompt never
	/// opens for a leave that would then be swallowed.</summary>
	public void RequestQuitToMenu()
	{
		if ( StageSwapPending ) return;
		if ( Stage is GameStage gs && gs.TryOpenLeaveConfirm( GameStage.LeaveAction.Home ) ) return;
		QuitToMenu();
	}

	/// <summary>Back button / hotkey entry point; see <see cref="RequestQuitToMenu"/>.</summary>
	public void RequestBackToMenu()
	{
		if ( StageSwapPending ) return;
		if ( Stage is GameStage gs && gs.TryOpenLeaveConfirm( GameStage.LeaveAction.Back ) ) return;
		BackToMenu();
	}

	/// <summary>Abandon whatever is on screen — a live run or a replay being watched — and return
	/// all the way to the title screen. Any replay state is torn down first so the next run starts
	/// clean. Refused while a wipe is running (same rule as <see cref="EndReplay"/>): the title
	/// transition below would be swallowed by the active one, leaving the torn-down replay/test
	/// state behind a stage that still expects it.</summary>
	public void QuitToMenu()
	{
		if ( StageSwapPending )
			return;

		// If we're leaving a decided run (a death or a win) before its score tally opened, bank the
		// score + replay first so it isn't lost. No-op on a live (undecided) run, a replay, or if the
		// tally already submitted — GameStage.SubmitRunOnce guards all of those and double submits.
		if ( Stage is GameStage gs )
			gs.SubmitRunOnce();

		if ( IsReplaying )
			ResetReplayState();
		else
		{
			// A live test run registered the unsaved editor level as a transient (StartLevelTest).
			// Quitting via the home button skips ReturnFromTestPlay, so unshadow the persistent
			// registry here too — otherwise every later run/preview/replay of this level id would
			// resolve the mid-edit geometry for the rest of the session. The editor model itself is
			// safe: OnExit already banked it via RememberLevelEditorSession.
			if ( IsTestingLevel )
			{
				Levels.ClearTransient();
				_testLevel = null;
				_testEditorLevel = null;
			}

			// Restore normal-speed playing music in case a live run had altered it.
			Audio.SetMusicPlaying( true );
		}

		_replayReturnDailyId = null;
		_replayReturnToLevelSelect = false;
		_replayLeaderboardBackToLevelSelect = false;
		_replayReturnWorkshopLevelId = null;
		_replayReturnSelection = -1;
		_replayReturnScore = null;
		_replayReturnToScore = false;
		TransitionToStage( new TitleStage( this ) );
	}

	/// <summary>Return to the menu the current run/replay was launched from (the daily hub for a daily
	/// run, otherwise the level-select map) rather than all the way to the title screen like
	/// <see cref="QuitToMenu"/>. For a replay this defers to <see cref="EndReplay"/>, which already
	/// restores whichever screen opened it.</summary>
	public void BackToMenu()
	{
		// Same mid-wipe refusal as QuitToMenu: the transition below would be swallowed, leaving this
		// method's side effects (submit, return-field clears) applied with no navigation.
		if ( StageSwapPending )
			return;

		// Editor test runs launch from the level editor, not the level-select map. Reuse the same guarded
		// return path as the test HUD's stop button and the Back input.
		if ( Stage is GameStage { IsTest: true } testStage )
		{
			testStage.ExitTest();
			return;
		}

		// Watching a replay: EndReplay knows the exact origin (MY REPLAYS / daily / level-select /
		// leaderboard), so reuse it rather than duplicating that routing here.
		if ( IsReplaying )
		{
			EndReplay();
			return;
		}

		// Snapshot the context BEFORE submitting: SubmitRunOnce clears it on a decided run, and the
		// routing below reads it (same rule as ScoreStage's snapshot-before-submit). Reading after
		// sent a decided daily/workshop run to the level-select map instead of its own hub.
		var ctx = CurrentRunContext;

		// Bank a decided run's score + replay before leaving (same guard/rationale as QuitToMenu — a
		// no-op on a live, undecided run).
		if ( Stage is GameStage gs )
			gs.SubmitRunOnce();

		// Restore normal-speed playing music in case a live run had altered it.
		Audio.SetMusicPlaying( true );

		// Runs launch from the daily hub, the workshop screen or the level-select map (see
		// StartDailyChallenge / StartLevel), so route back to whichever one applies.
		_replayReturnDailyId = null;
		_replayReturnToLevelSelect = false;
		_replayLeaderboardBackToLevelSelect = false;
		_replayReturnWorkshopLevelId = null;
		_replayReturnSelection = -1;
		_replayReturnScore = null;
		_replayReturnToScore = false;
		TransitionToStage( ctx.IsDaily
			? new DailyChallengeStage( this, ctx.DailyId )
			: ctx.Level?.IsWorkshop == true
				? new WorkshopStage( this )
				: new LevelSelectStage( this ) );
	}

	// Tear down all replay-playback state so we can leave a replay cleanly. Callers decide which
	// screen to transition to afterwards.
	private void ResetReplayState()
	{
		IsPlacingReaction = false;
		CurrentReplayReactions?.Cancel();
		CurrentReplayViews?.Cancel();
		// Keep the cancelled counts and author for the outgoing HUD until ApplyPendingStage.

		// Leaving the replay: drop any GIF-export preview/state so the next replay starts fresh.
		EndGifPreview();
		SavedGifConfig = null;

		// Drop any embedded test-level transient registered for this replay (StartReplay) so it can't
		// shadow a real level of the same id once we're back on a menu. No-op for normal replays.
		Levels.ClearTransient();

		IsReplaying = false;
		_replayFinished = false;
		_hijacked = false;
		_hijackFrame = -1;
		_replayData = null;
		_replay = null;
		_accumulator = 0f;
		GameplayTime.Reset();
		// Restore normal-speed, playing music for the screen we're returning to.
		Audio.SetMusicPlaying( true );
		Audio.SetMusicPitch( 1f );
	}
}