Editor/GameplayRecorderHandlers.cs

Editor bridge handlers to record live play-mode gameplay into Sandbox.MovieMaker .movie assets. Provides three handlers: start a recording job (RecordGameplayClipHandler), stop and save or discard the job (StopGameplayRecordingHandler), and poll status (GameplayRecordingStatusHandler). Uses an editor frame job to monitor an in-memory MovieRecorder instance and persist the compiled MovieResource to the project Assets folder.

File Access
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;

// ═══════════════════════════════════════════════════════════════════════════
// Gameplay Recording family — record live PLAY-MODE gameplay to a .movie clip.
// Closes engine-watch #2 (docs/TOOL_BACKLOG.md): Sandbox.MovieMaker.MovieRecorder
// SHIPPED in the current editor build (verified live 2026-07-12; absent 2026-07-08).
//
//   record_gameplay_clip       start an async frame-loop recording job (play mode only)
//   stop_gameplay_recording    stop → ToClip() → persist as a project .movie asset
//   gameplay_recording_status  poll the job (frames, elapsed, tracked objects)
//
// Surface verified live via reflection probes (2026-07-12):
//   MovieRecorder ctors: (Scene scene, MovieRecorderOptions options = default)
//                        (TrackBinder binder, MovieRecorderOptions options = default)
//   MovieRecorderOptions: record class (int SampleRate, MovieTime? BufferDuration),
//     fluent WithDefaultComponentCapturers() / WithDefaultCaptureActions() /
//     WithCaptureGameObject(go[, trackName]) / WithCaptureComponent(c) /
//     WithCaptureAll<T>(Func<T,bool>? = null) where T : Component.
//   Start() -> IDisposable (Sandbox.Utility.DisposeAction), Stop(), Advance(MovieTime),
//   Capture(), ToClip() -> Compiled.MovieClip, ToResource() -> IMovieResource.
//   MovieTime.FromSeconds(double) + implicit from double.
//
// BEHAVIOR PROVEN LIVE (probe results, 2026-07-12):
//   • WithDefaultComponentCapturers() ALONE captures NOTHING (0 tracks) — capturers
//     define HOW to record component types, not WHAT to record.
//   • WithCaptureAll<Component>() also captured 0 tracks on the open scene — dead end.
//   • WithCaptureGameObject(go) → 10 tracks for one player hierarchy. WORKS.
//   • WithDefaultCaptureActions() → whole-scene capture (19,969 tracks on basic_example).
//     WORKS but heavy — that's the no-ids default, and why ids are recommended.
//   • In PLAY MODE, Start() hooks the running scene and the recorder AUTO-advances +
//     AUTO-captures with game time (proven: a 5.55s wall recording that ALSO called
//     Advance(delta) per editor frame produced a 10.80s clip — exactly double-counted;
//     edit-mode manual loops produced exact manual-only durations). So the frame job
//     below only MONITORS (elapsed, maxSeconds cap) — it never calls Advance/Capture.
//   • Persistence: new MovieResource { Compiled = recorder.ToClip() } → .Serialize()
//     .ToJsonString() → write <Assets>/<folder>/<name>.movie →
//     Editor.AssetSystem.RegisterFile(abs) → asset.Compile(true). list_movies then
//     reports { loadable:true, hasCompiledClip:true } — same as a dock-authored movie.
//
// Frame-job pattern (statics + job class + [EditorEvent.Frame]) mirrors
// PlaytestHandler.cs — the house pattern for play-mode frame jobs. The recorder
// quantizes samples onto its SampleRate grid internally.
//
// Types are fully-qualified (Sandbox.MovieMaker.*) — `using Editor;` is in scope
// and Editor.MovieMaker exists, so bare names risk the FileSystem ambiguity class.
// Deliberately uses ONLY public bridge-independent APIs (no ClaudeBridge internals)
// so the file compiles in any editor assembly.
//
// Registration (MyEditorMenu.cs RegisterHandlers):
//   Register( "record_gameplay_clip",      () => new RecordGameplayClipHandler() );
//   Register( "stop_gameplay_recording",   () => new StopGameplayRecordingHandler() );
//   Register( "gameplay_recording_status", () => new GameplayRecordingStatusHandler() );
// Scene-mutating: NONE — all three must stay callable during play mode (recording
// only exists there). stop_gameplay_recording writes a DISK asset (persists), and
// its optional MoviePlayer wiring during play is runtime-only (discarded on stop),
// matching the play_movie precedent.
// ═══════════════════════════════════════════════════════════════════════════

internal static class GameplayRecorderRunner
{
	internal class Job
	{
		public string JobId;
		public Sandbox.MovieMaker.MovieRecorder Recorder;
		public IDisposable Session;               // Start()'s scoped session — dispose at stop
		public int SampleRate;
		public float MaxSeconds;
		public int FramesCaptured;                // editor frames where the recorder reported captured data
		public double ClipSeconds;                // recorder.Time at the last tick — the clip's timeline length
		public System.Diagnostics.Stopwatch Watch = new(); // wall clock (fallback + diagnostics)
		public List<string> TrackedNames = new(); // captured GameObject names ("whole scene" when empty ids)
		public string CaptureMode;                // "objects" | "scene"
		public bool Stopped;                      // recorder stopped; clip pending save/discard
		public string StopReason;
	}

	private static Job _job;
	private static readonly object _lock = new();
	private static object _lastSummary;

	internal static void Start( Job job ) { lock ( _lock ) { _job = job; _lastSummary = null; } }
	internal static Job Current() { lock ( _lock ) { return _job; } }
	internal static object LastSummary() { lock ( _lock ) { return _lastSummary; } }

	/// <summary>Detach the current job (recording already stopped) and publish its save/discard summary.</summary>
	internal static void Finish( object summary ) { lock ( _lock ) { _lastSummary = summary; _job = null; } }

	/// <summary>Stop the recorder NOW (idempotent). The job stays current so stop_gameplay_recording can persist the clip.</summary>
	internal static void StopRecorder( Job j, string reason )
	{
		if ( j.Stopped ) return;
		try { j.Recorder.Stop(); } catch { }
		try { j.Session?.Dispose(); } catch { }
		j.Watch.Stop();
		j.Stopped = true;
		j.StopReason = reason;
	}

	[EditorEvent.Frame]
	public static void OnFrame()
	{
		Job j;
		lock ( _lock ) { j = _job; }
		if ( j == null || j.Stopped ) return;

		if ( !Game.IsPlaying )
		{
			// Play mode ended under us. The recorded samples live in the recorder, not the
			// scene — stop cleanly and keep the job so stop_gameplay_recording can still save.
			StopRecorder( j, "play mode ended — clip kept in memory, call stop_gameplay_recording to save it" );
			return;
		}

		try
		{
			// MONITOR ONLY: in play mode Start() hooks the running scene, and the recorder
			// advances + captures itself with game time (calling Advance/Capture here too
			// double-counts the timeline — proven live). We track progress and enforce the cap.
			double t;
			try { t = j.Recorder.Time.TotalSeconds; }
			catch { t = j.Watch.Elapsed.TotalSeconds; }
			j.ClipSeconds = t;

			try { if ( j.Recorder.RecordedThisFrame.Any() ) j.FramesCaptured++; } catch { }

			if ( t >= j.MaxSeconds )
				StopRecorder( j, $"maxSeconds safety cap ({j.MaxSeconds}s of clip time) reached — call stop_gameplay_recording to save" );
		}
		catch ( Exception ex )
		{
			// Never let the ticker throw (it'd spam every editor frame). Stop + record why.
			StopRecorder( j, $"recorder error: {ex.Message}" );
		}
	}
}

/// <summary>
/// record_gameplay_clip — start recording live play-mode gameplay into a MovieMaker
/// clip. Requires play mode. Params:
///   ids        : optional array of GameObject GUIDs to capture (recommended — small,
///                focused clips). Omitted → whole-scene capture (heavy: every object).
///   sampleRate : optional int samples/sec (default 30, clamped 1-120)
///   maxSeconds : optional safety cap in clip-time seconds (default 60, clamped 1-600) —
///                auto-stops the job; the clip stays in memory until saved/discarded
/// Returns a job id immediately; recording runs ASYNC in the editor frame loop.
/// </summary>
public class RecordGameplayClipHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		if ( !Game.IsPlaying )
			return Task.FromResult<object>( new { error = "record_gameplay_clip requires play mode — call start_play first" } );

		var scene = Game.ActiveScene;
		if ( scene == null )
			return Task.FromResult<object>( new { error = "No active scene" } );

		var existing = GameplayRecorderRunner.Current();
		string discardedNote = null;
		if ( existing != null )
		{
			if ( !existing.Stopped )
				return Task.FromResult<object>( new { error = "a gameplay recording is already running — stop_gameplay_recording saves it (or pass discard), gameplay_recording_status polls it" } );
			// A stopped-but-unsaved job exists; starting fresh discards it.
			discardedNote = $"Discarded a previous UNSAVED recording ({System.Math.Round( existing.ClipSeconds, 2 )}s, reason: {existing.StopReason}).";
		}

		int sampleRate = 30;
		if ( p.TryGetProperty( "sampleRate", out var srEl ) && srEl.TryGetInt32( out var sr ) )
			sampleRate = System.Math.Clamp( sr, 1, 120 );

		float maxSeconds = 60f;
		if ( p.TryGetProperty( "maxSeconds", out var msEl ) && msEl.TryGetSingle( out var msF ) )
			maxSeconds = System.Math.Clamp( msF, 1f, 600f );

		try
		{
			// Capturers say HOW component types get recorded; capture targets say WHAT.
			// (Proven live: WithDefaultComponentCapturers() alone → 0 tracks.)
			var options = new Sandbox.MovieMaker.MovieRecorderOptions( sampleRate, null )
				.WithDefaultComponentCapturers();

			var job = new GameplayRecorderRunner.Job { SampleRate = sampleRate, MaxSeconds = maxSeconds };

			if ( p.TryGetProperty( "ids", out var idsEl ) && idsEl.ValueKind == JsonValueKind.Array && idsEl.GetArrayLength() > 0 )
			{
				foreach ( var idEl in idsEl.EnumerateArray() )
				{
					var idStr = idEl.ValueKind == JsonValueKind.String ? idEl.GetString() : null;
					if ( idStr == null || !Guid.TryParse( idStr, out var guid ) )
						return Task.FromResult<object>( new { error = $"ids entries must be GameObject GUIDs (got '{idStr}')" } );
					var go = scene.Directory.FindByGuid( guid );
					if ( go == null )
						return Task.FromResult<object>( new { error = $"GameObject not found in the RUNNING scene: {idStr} (play-mode ids differ from editor ids — use get_scene_hierarchy while playing)" } );
					options = options.WithCaptureGameObject( go );
					job.TrackedNames.Add( go.Name );
				}
				job.CaptureMode = "objects";
			}
			else
			{
				// Whole-scene capture. WithCaptureAll<Component>() proved inert (0 tracks);
				// WithDefaultCaptureActions() is the path that actually records everything.
				options = options.WithDefaultCaptureActions();
				job.CaptureMode = "scene";
			}

			job.JobId = Guid.NewGuid().ToString( "N" ).Substring( 0, 8 );
			job.Recorder = new Sandbox.MovieMaker.MovieRecorder( scene, options );
			// Start() hooks the RUNNING scene: the recorder advances + captures itself with
			// game time from here on (proven live) — the frame job only monitors it.
			job.Session = job.Recorder.Start();
			job.Watch.Start();

			GameplayRecorderRunner.Start( job );

			return Task.FromResult<object>( new
			{
				started = true,
				jobId = job.JobId,
				sampleRate,
				maxSeconds,
				capture = job.CaptureMode == "objects" ? $"{job.TrackedNames.Count} GameObject(s): {string.Join( ", ", job.TrackedNames )}" : "whole scene (heavy — pass ids for focused clips)",
				discarded = discardedNote,
				note = "Recording ASYNC in the editor frame loop. Drive gameplay now (playtest/drive_player make scripted runs), poll gameplay_recording_status, then stop_gameplay_recording to save the .movie asset.",
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"record_gameplay_clip failed: {ex.Message}" } );
		}
	}
}

/// <summary>
/// stop_gameplay_recording — stop the active recording job and persist it as a project
/// .movie asset (or discard it). Params:
///   name     : optional asset name (default recording_&lt;UTC timestamp&gt;; [A-Za-z0-9_-] only)
///   folder   : optional Assets subfolder (default "recordings")
///   wireToId : optional GameObject GUID — auto-wire a MoviePlayer pointed at the new clip
///   discard  : optional bool — throw the recording away instead of saving
/// Also saves a job that already auto-stopped (maxSeconds cap / play mode ended).
/// </summary>
public class StopGameplayRecordingHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		var job = GameplayRecorderRunner.Current();
		if ( job == null )
			return Task.FromResult<object>( new { error = "No gameplay recording to stop — record_gameplay_clip starts one (gameplay_recording_status shows the last saved summary)" } );

		GameplayRecorderRunner.StopRecorder( job, "stopped via stop_gameplay_recording" );

		if ( p.TryGetProperty( "discard", out var dEl ) && dEl.ValueKind == JsonValueKind.True )
		{
			var discardSummary = new
			{
				finished = true,
				saved = false,
				discarded = true,
				jobId = job.JobId,
				clipSeconds = System.Math.Round( job.ClipSeconds, 2 ),
				reason = job.StopReason,
			};
			GameplayRecorderRunner.Finish( discardSummary );
			return Task.FromResult<object>( discardSummary );
		}

		try
		{
			var clip = job.Recorder.ToClip();

			var assetsPath = Project.Current?.GetAssetsPath();
			if ( string.IsNullOrEmpty( assetsPath ) )
				return Task.FromResult<object>( new { error = "No project assets path — clip is still in memory, retry or pass discard:true" } );

			string name = null;
			if ( p.TryGetProperty( "name", out var nEl ) && nEl.ValueKind == JsonValueKind.String )
				name = Sanitize( nEl.GetString() );
			if ( string.IsNullOrEmpty( name ) )
				name = $"recording_{DateTime.UtcNow:yyyyMMdd_HHmmss}";

			string folder = "recordings";
			if ( p.TryGetProperty( "folder", out var fEl ) && fEl.ValueKind == JsonValueKind.String )
			{
				var f = Sanitize( fEl.GetString() );
				if ( !string.IsNullOrEmpty( f ) ) folder = f;
			}

			var dirFull = Path.Combine( assetsPath, folder );
			Directory.CreateDirectory( dirFull );
			var fullPath = Path.Combine( dirFull, name + ".movie" );
			var relPath = $"{folder}/{name}.movie";

			if ( File.Exists( fullPath ) )
				return Task.FromResult<object>( new { error = $"'{relPath}' already exists — pass a different name (clip is still in memory, so just retry)" } );

			// MovieResource : GameResource — Serialize() gives the on-disk .movie JSON
			// (verified live: identical loadable shape to a Movie Maker dock asset).
			var res = new Sandbox.MovieMaker.MovieResource();
			res.Compiled = clip;
			File.WriteAllText( fullPath, res.Serialize().ToJsonString() );

			var asset = Editor.AssetSystem.RegisterFile( fullPath )
				?? Editor.AssetSystem.RegisterFile( fullPath.Replace( '\\', '/' ) );
			bool compiled = false;
			try { compiled = asset?.Compile( true ) ?? false; } catch { }

			// Optional: wire a MoviePlayer on a target object at the new clip.
			object wired = null;
			if ( p.TryGetProperty( "wireToId", out var wEl ) && wEl.ValueKind == JsonValueKind.String )
			{
				if ( !Guid.TryParse( wEl.GetString(), out var wireGuid ) )
				{
					wired = new { ok = false, error = $"wireToId is not a GUID: '{wEl.GetString()}'" };
				}
				else
				{
					var scene = Game.IsPlaying ? Game.ActiveScene : SceneEditorSession.Active?.Scene;
					var go = scene?.Directory.FindByGuid( wireGuid );
					if ( go == null )
					{
						wired = new { ok = false, error = $"wireToId GameObject not found: {wEl.GetString()} (asset was still saved)" };
					}
					else
					{
						var player = go.GetOrAddComponent<Sandbox.MovieMaker.MoviePlayer>();
						// Prefer the registered asset; fall back to the in-memory resource if the
						// asset system hasn't finished loading the compiled clip yet.
						var loaded = ResourceLibrary.Get<Sandbox.MovieMaker.MovieResource>( relPath );
						bool usedAsset = loaded?.Compiled != null;
						player.Resource = usedAsset ? loaded : res;
						wired = new
						{
							ok = true,
							gameObject = go.Name,
							id = go.Id.ToString(),
							source = usedAsset ? "asset" : "in-memory resource (asset still settling — re-wire later via add_movie_player if needed)",
							runtimeOnly = Game.IsPlaying,
						};
					}
				}
			}

			double durationSeconds = 0;
			try { durationSeconds = clip.Duration.TotalSeconds; } catch { }

			var summary = new
			{
				finished = true,
				saved = true,
				assetPath = relPath,
				jobId = job.JobId,
				durationSeconds,
				trackCount = clip.Tracks.Length,
				sampleRate = job.SampleRate,
				compiled,
				stopReason = job.StopReason,
				wired,
				note = TrackNote( clip.Tracks.Length ) + " Verify with list_movies, replay via add_movie_player + play_movie (play mode).",
			};
			GameplayRecorderRunner.Finish( summary );
			return Task.FromResult<object>( summary );
		}
		catch ( Exception ex )
		{
			// Keep the job (and its in-memory clip) so a corrected retry can still save it.
			return Task.FromResult<object>( new { error = $"stop_gameplay_recording failed: {ex.Message} — the clip is still in memory; retry, or pass discard:true to drop it" } );
		}
	}

	static string TrackNote( int tracks )
		=> tracks == 0
			? "WARNING: the clip has 0 tracks (nothing captured — were the target objects present in the running scene?)."
			: $"Saved {tracks} track(s).";

	static string Sanitize( string raw )
	{
		if ( string.IsNullOrWhiteSpace( raw ) ) return null;
		var cleaned = new string( raw.Trim().Select( c => char.IsLetterOrDigit( c ) || c == '_' || c == '-' ? c : '_' ).ToArray() );
		return cleaned.Trim( '_' ).Length == 0 ? null : cleaned;
	}
}

/// <summary>
/// gameplay_recording_status — poll the recording job: live progress while recording,
/// stopped-pending-save state (maxSeconds cap / play ended), or the last saved summary.
/// No params.
/// </summary>
public class GameplayRecordingStatusHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		var job = GameplayRecorderRunner.Current();
		if ( job != null )
		{
			if ( !job.Stopped )
			{
				return Task.FromResult<object>( new
				{
					recording = true,
					jobId = job.JobId,
					elapsedSeconds = System.Math.Round( job.ClipSeconds, 2 ),   // clip-timeline seconds
					framesWithData = job.FramesCaptured,
					maxSeconds = job.MaxSeconds,
					sampleRate = job.SampleRate,
					capture = job.CaptureMode == "objects" ? string.Join( ", ", job.TrackedNames ) : "whole scene",
					trackedObjectCount = job.CaptureMode == "objects" ? job.TrackedNames.Count : -1,
					note = "Recording. stop_gameplay_recording saves the clip as a .movie asset.",
				} );
			}

			return Task.FromResult<object>( new
			{
				recording = false,
				stopped = true,
				pendingSave = true,
				jobId = job.JobId,
				reason = job.StopReason,
				elapsedSeconds = System.Math.Round( job.ClipSeconds, 2 ),
				framesWithData = job.FramesCaptured,
				capture = job.CaptureMode == "objects" ? string.Join( ", ", job.TrackedNames ) : "whole scene",
				note = "Recording stopped but NOT saved — stop_gameplay_recording persists it (or discards with discard:true).",
			} );
		}

		var summary = GameplayRecorderRunner.LastSummary();
		if ( summary != null )
			return Task.FromResult<object>( summary );

		return Task.FromResult<object>( new { recording = false, note = "No gameplay recording has run yet — record_gameplay_clip starts one (play mode required)." } );
	}
}