s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (8 total matches found)
kitsupanic.sbox_mcp_plus / Editor/OwnedLocalInstance.cs
Editor library
using Microsoft.Win32.SafeHandles;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace Editor.Mcp;

internal sealed class OwnedLocalInstance : IDisposable
{
	/// <summary>
	/// A retained launch may temporarily lack a creation identity after cleanup fails. Such an entry
	/// remains internal until GetProcessTimes supplies the exact timestamp required by the public contract.
	/// </summary>

	internal OwnedLocalInstance( SafeKernelHandle processHandle, SafeKernelHandle jobHandle, int processId,
		long? creationFileTime, string executablePath, bool windowed, int instanceNumber, string logDirectory,
		bool assignedToJob, LaunchAuthority authority )
	{
		if ( creationFileTime is null && authority != LaunchAuthority.RetainedUnidentified )
			throw new ArgumentException( "Only a retained unidentified launch may have no creation identity.",
				nameof( creationFileTime ) );

		ProcessHandle = processHandle;
		JobHandle = jobHandle;
		ProcessId = processId;
		CreationFileTime = creationFileTime;
		ExecutablePath = executablePath;
		Windowed = windowed;
		InstanceNumber = instanceNumber;
		LogDirectory = logDirectory;
		AssignedToJob = assignedToJob;
		Authority = authority;

		LaunchedAt = creationFileTime is long creation
			? DateTime.FromFileTimeUtc( creation ).ToString( "O", CultureInfo.InvariantCulture )
			: null;
	}

	internal SafeKernelHandle ProcessHandle { get; }
	internal SafeKernelHandle JobHandle { get; }
	internal int ProcessId { get; }

	/// <summary>Exact creation FILETIME captured at launch, or null when it could not be read.</summary>
	internal long? CreationFileTime { get; set; }
	internal string ExecutablePath { get; }
	internal string LaunchedAt { get; set; }
	internal bool Windowed { get; }
	internal int InstanceNumber { get; }
	internal string LogDirectory { get; }

	/// <summary>Whether the retained private job actually contains this process.</summary>
	internal bool AssignedToJob { get; }

	/// <summary>
	/// The authority this entry actually holds. A launch that never resumed and whose cleanup failed
	/// is retained in one of the failed-launch states instead of being abandoned, and is never
	/// presented as an ordinary launched process.
	/// </summary>
	internal LaunchAuthority Authority { get; private set; }

	/// <summary>
	/// A launch that was never resumed, so its only authority is the original process handle or its
	/// private job - never a PID, and never a normal launch identity.
	/// </summary>
	internal bool FailedLaunch => Authority != LaunchAuthority.Owned;

	/// <summary>
	/// Marks a never-resumed launch whose pre-resume cleanup failed as the retained authority of that
	/// process. Called by the launch scope while the entry is still unreachable to any other caller.
	/// </summary>
	internal void MarkRetainedFailedLaunch() => Authority = LaunchAuthority.RetainedFailedLaunch;

	/// <summary>Promotes hidden cleanup authority once its exact process creation identity is readable.</summary>
	internal void CaptureCreationIdentity( long creationFileTime )
	{
		if ( CreationFileTime is not null ) return;
		CreationFileTime = creationFileTime;
		LaunchedAt = DateTime.FromFileTimeUtc( creationFileTime ).ToString( "O", CultureInfo.InvariantCulture );
		Authority = LaunchAuthority.RetainedFailedLaunch;
	}


	public void Dispose()
	{
		ProcessHandle.Dispose();
		JobHandle?.Dispose();
	}
}

internal sealed class OwnedInstanceRegistry : IDisposable
{
	internal const ulong SyntheticSteamIdentityBase = 90071996842377216UL;

	private static readonly TimeSpan GracefulBudget = TimeSpan.FromSeconds( 2 );
	private static readonly TimeSpan ForcedBudget = TimeSpan.FromSeconds( 3 );
	private const int PollIntervalMilliseconds = 50;

	private readonly object _sync = new();
	private readonly Dictionary<int, OwnedLocalInstance> _entries = new();

	/// <summary>
	/// Retained launches whose PID key is already held by another entry. A live process can only
	/// collide with an entry whose root has already exited, and dropping either authority would
	/// abandon a live process or its tree, so these stay reachable by their exact (PID, token) pair.
	/// </summary>
	private readonly Dictionary<(int ProcessId, string Token), OwnedLocalInstance> _displaced = new();

	private bool _disposed;

	/// <summary>
	/// Fault seam for the focused harness: fires after a graceful poll delay completes and before
	/// the following cancellation check. Inert unless a test assigns it.
	/// </summary>
	internal Action GracefulDelayCompletedForTesting { get; set; }

	internal OwnedInstanceSnapshot LaunchSbox( bool windowed, Func<ulong, bool> visibleSyntheticIdentity )
	{
		if ( !OperatingSystem.IsWindows() )
			throw new OwnedInstanceException( "Local instance launching is supported only on Windows." );

		var currentExecutable = Environment.ProcessPath;
		if ( string.IsNullOrWhiteSpace( currentExecutable )
			|| !string.Equals( Path.GetFileName( currentExecutable ), "sbox-dev.exe", StringComparison.OrdinalIgnoreCase ) )
			throw new OwnedInstanceException( "Local instances can only be launched by sbox-dev.exe." );

		var directory = Path.GetDirectoryName( currentExecutable );
		var executable = directory is null ? null : Path.Combine( directory, "sbox.exe" );
		if ( executable is null || !File.Exists( executable ) )
			throw new OwnedInstanceException( "The sibling sbox.exe executable was not found." );

		var instanceNumber = AllocateInstanceNumber( visibleSyntheticIdentity );
		var arguments = new List<string> { "-joinlocal", "+instanceid", instanceNumber.ToString( CultureInfo.InvariantCulture ) };
		if ( windowed )
		{
			arguments.Add( "-sw" );
			arguments.Add( "-720" );
		}

		return LaunchContained( executable, arguments, directory, windowed, instanceNumber,
			ResolveLogDirectory(), LaunchFailureInjection.None );
	}

	internal OwnedInstanceSnapshot LaunchContainedForTesting( string executable, IReadOnlyList<string> arguments,
		string workingDirectory, bool windowed = false, int instanceNumber = 1,
		LaunchFailureInjection failure = LaunchFailureInjection.None )
	{
		return LaunchContained( Path.GetFullPath( executable ), arguments, Path.GetFullPath( workingDirectory ),
			windowed, instanceNumber, Path.GetFullPath( workingDirectory ), failure );
	}

	internal OwnedInstanceSnapshot[] List( bool pruneExited )
	{
		lock ( _sync )
		{
			ThrowIfDisposed();
			var rows = new List<(OwnedLocalInstance Entry, OwnedInstanceSnapshot Snapshot)>();
			foreach ( var entry in AllEntries().ToArray() )
			{
				if ( entry.CreationFileTime is null && !TryCaptureCreationIdentity( entry ) )
				{
					if ( pruneExited && IsSignaled( entry.ProcessHandle ) && ActiveProcessCount( entry ) == 0 )
						RemoveAndDispose( entry );
					continue;
				}

				rows.Add( (entry, Snapshot( entry )) );
			}

			if ( pruneExited )
			{
				foreach ( var row in rows )
				{
					if ( row.Snapshot.Exited && row.Snapshot.ActiveProcessCount == 0 )
						RemoveAndDispose( row.Entry );
				}
			}

			return rows.Select( row => row.Snapshot ).ToArray();
		}
	}

	internal bool TryGet( int processId, out OwnedLocalInstance instance )
	{
		lock ( _sync )
		{
			if ( _disposed )
			{
				instance = null;
				return false;
			}

			return _entries.TryGetValue( processId, out instance );
		}
	}

	internal async Task<OwnedTerminationOutcome> TerminateAsync( int processId, string launchTimestamp, bool abrupt,
		CancellationToken cancellationToken )
	{
		if ( processId <= 0 || processId == Environment.ProcessId )
			return OwnedTerminationOutcome.NotOwned( processId );

		OwnedLocalInstance entry;
		lock ( _sync )
		{
			ThrowIfDisposed();
			entry = FindOwned( processId, launchTimestamp );
			if ( entry is null )
				return AnyEntryFor( processId )
					? OwnedTerminationOutcome.IdentityMismatch( processId )
					: OwnedTerminationOutcome.NotOwned( processId );
		}

		cancellationToken.ThrowIfCancellationRequested();
		if ( !ValidateIdentity( entry, out _ ) )
			return OwnedTerminationOutcome.IdentityMismatch( processId );

		if ( IsSignaled( entry.ProcessHandle ) && ActiveProcessCount( entry ) == 0 )
		{
			RemoveAndDispose( entry );
			return OwnedTerminationOutcome.AlreadyExited( processId );
		}

		// A failed launch was never resumed: there is no window to close and nothing to wait for,
		// so it goes straight to the retained authority below.
		if ( !abrupt && !entry.FailedLaunch )
		{
			cancellationToken.ThrowIfCancellationRequested();
			TryCloseMainWindow( entry, IsSignaled( entry.ProcessHandle ) );

			var gracefulDeadline = Stopwatch.StartNew();
			while ( true )
			{
				// Honoured after every await, including the boundary where a poll delay completed
				// and its continuation was queued just as the lifetime was invalidated.
				cancellationToken.ThrowIfCancellationRequested();

				if ( ActiveProcessCount( entry ) == 0 )
				{
					RemoveAndDispose( entry );
					return OwnedTerminationOutcome.Terminated( processId, false, "The owned process job exited after the graceful close request." );
				}

				if ( gracefulDeadline.Elapsed >= GracefulBudget ) break;
				await Task.Delay( PollIntervalMilliseconds, cancellationToken );
				GracefulDelayCompletedForTesting?.Invoke();
			}

			// The last delay may have completed with the budget already spent; nothing may be
			// signaled until ownership is revalidated against a still-valid operation.
			cancellationToken.ThrowIfCancellationRequested();
		}

		if ( !ValidateIdentity( entry, out _ ) )
			return OwnedTerminationOutcome.IdentityMismatch( processId );

		cancellationToken.ThrowIfCancellationRequested();
		var (forced, forcedMessage) = TerminateOwnedTree( entry, processId );

		var forcedDeadline = Stopwatch.StartNew();
		while ( true )
		{
			cancellationToken.ThrowIfCancellationRequested();

			if ( ActiveProcessCount( entry ) == 0 && IsSignaled( entry.ProcessHandle ) )
			{
				RemoveAndDispose( entry );
				return OwnedTerminationOutcome.Terminated( processId, forced, forcedMessage );
			}

			if ( forcedDeadline.Elapsed >= ForcedBudget ) break;
			await Task.Delay( PollIntervalMilliseconds, cancellationToken );
		}

		throw new OwnedInstanceException( "Owned instance termination timed out.", processId );
	}



	internal void CorruptCreationIdentityForTesting( int processId )
	{
		lock ( _sync )
		{
			if ( _entries.TryGetValue( processId, out var entry ) && entry.CreationFileTime is long creation )
				entry.CreationFileTime = creation + 1;
		}
	}

	internal string ReplaceLaunchTimestampForTesting( int processId, string replacement )
	{
		lock ( _sync )
		{
			var entry = _entries[processId];
			var old = entry.LaunchedAt;
			entry.LaunchedAt = replacement;
			return old;
		}
	}

	public void Dispose()
	{
		OwnedLocalInstance[] entries;
		lock ( _sync )
		{
			if ( _disposed ) return;
			_disposed = true;
			entries = AllEntries().ToArray();
			_entries.Clear();
			_displaced.Clear();
		}

		foreach ( var entry in entries ) entry.Dispose();
	}

	private int AllocateInstanceNumber( Func<ulong, bool> visibleSyntheticIdentity )
	{
		for ( var attempt = 0; attempt < 256; attempt++ )
		{
			var candidate = RandomNumberGenerator.GetInt32( 1_000_000, int.MaxValue );
			lock ( _sync )
			{
				ThrowIfDisposed();
				if ( AllEntries().Any( x => x.InstanceNumber == candidate ) ) continue;
			}

			if ( visibleSyntheticIdentity?.Invoke( SyntheticSteamIdentityBase + (ulong)candidate ) == true ) continue;
			return candidate;
		}

		throw new OwnedInstanceException( "Could not allocate a unique local instance identity." );
	}

	private OwnedInstanceSnapshot LaunchContained( string executable, IReadOnlyList<string> arguments,
		string workingDirectory, bool windowed, int instanceNumber, string logDirectory,
		LaunchFailureInjection failure )
	{
		if ( !OperatingSystem.IsWindows() )
			throw new OwnedInstanceException( "Contained process launching is supported only on Windows." );
		if ( !File.Exists( executable ) )
			throw new OwnedInstanceException( "The local instance executable was not found." );

		SafeKernelHandle jobHandle = null;
		SafeKernelHandle processHandle = null;
		IntPtr threadHandle = IntPtr.Zero;
		OwnedLocalInstance entry = null;
		var processId = 0;
		var creationFileTime = 0L;
		var resolvedExecutable = (string)null;
		var assignedToJob = false;
		var resumed = false;

		try
		{
			jobHandle = NativeMethods.CreateJobObjectW( IntPtr.Zero, null );
			if ( jobHandle.IsInvalid )
				throw NativeFailure( "Could not create the private process job." );

			var startup = new NativeMethods.StartupInfo { Size = Marshal.SizeOf<NativeMethods.StartupInfo>() };
			var commandLine = new StringBuilder( BuildCommandLine( executable, arguments ) );
			if ( !NativeMethods.CreateProcessW( executable, commandLine, IntPtr.Zero, IntPtr.Zero, false,
				NativeMethods.CreateSuspended | NativeMethods.CreateNoWindow, IntPtr.Zero, workingDirectory,
				ref startup, out var processInfo ) )
				throw NativeFailure( "Could not create the suspended local instance." );

			processHandle = new SafeKernelHandle( processInfo.ProcessHandle, true );
			threadHandle = processInfo.ThreadHandle;
			processId = checked((int)processInfo.ProcessId);

			// Captured first and unconditionally, so every later failure path - including a failed
			// cleanup - either holds the exact creation-time token this process can be terminated by
			// or knows that none was ever captured and must not be invented.
			creationFileTime = failure.HasFlag( LaunchFailureInjection.CreationTimeCaptureFails )
				? throw new OwnedInstanceException( "Could not read the owned process creation identity.", processId, 5 )
				: GetCreationFileTime( processHandle, processId );

			if ( failure.HasFlag( LaunchFailureInjection.BeforeAssignment ) )
				throw new OwnedInstanceException( "Injected pre-assignment launch failure.", processId );

			var assigned = failure.HasFlag( LaunchFailureInjection.AssignmentFails )
				? false
				: NativeMethods.AssignProcessToJobObject( jobHandle, processHandle );
			if ( !assigned )
				throw new OwnedInstanceException( "Could not contain the local instance in its private process job.",
					processId, failure.HasFlag( LaunchFailureInjection.AssignmentFails ) ? 5 : Marshal.GetLastPInvokeError() );
			assignedToJob = true;

			if ( !NativeMethods.IsProcessInJob( processHandle, jobHandle, out var inJob ) || !inJob )
				throw NativeFailure( "Could not verify private process-job containment.", processId );

			if ( failure.HasFlag( LaunchFailureInjection.AfterAssignmentBeforeIdentity ) )
				throw new OwnedInstanceException( "Injected pre-identity launch failure.", processId );

			if ( failure.HasFlag( LaunchFailureInjection.ImagePathCaptureFails ) )
				throw new OwnedInstanceException( "Could not read the owned process image identity.", processId, 31 );
			resolvedExecutable = QueryImagePath( processHandle );

			var expectedExecutable = Path.GetFullPath( executable );
			if ( !string.Equals( NormalizePath( resolvedExecutable ), NormalizePath( expectedExecutable ), StringComparison.OrdinalIgnoreCase ) )
				throw new OwnedInstanceException( "The launched process image did not match the requested executable.", processId );

			entry = new OwnedLocalInstance( processHandle, jobHandle, processId, creationFileTime,
				resolvedExecutable, windowed, instanceNumber, Path.GetFullPath( logDirectory ), true,
				LaunchAuthority.Owned );
			processHandle = null;
			jobHandle = null;

			lock ( _sync )
			{
				ThrowIfDisposed();
				if ( _entries.ContainsKey( processId ) )
					throw new OwnedInstanceException( "The new process ID collided with an owned registry entry.", processId );
				_entries.Add( processId, entry );
			}

			if ( NativeMethods.ResumeThread( threadHandle ) == uint.MaxValue )
				throw NativeFailure( "Could not resume the contained local instance.", processId );
			resumed = true;
			return Snapshot( entry );
		}
		catch ( Exception launchError ) when ( !resumed )
		{
			var cleanup = CleanupSuspendedLaunch( entry, processHandle, jobHandle, processId,
				assignedToJob, creationFileTime, resolvedExecutable, windowed, instanceNumber, logDirectory, failure );
			if ( cleanup.RetainedProcess ) processHandle = null;
			if ( cleanup.RetainedJob ) jobHandle = null;

			if ( cleanup.Error != 0 )
				throw new OwnedInstanceException( "Local instance launch cleanup failed.", processId, cleanup.Error, launchError );
			throw;
		}
		finally
		{
			if ( threadHandle != IntPtr.Zero ) NativeMethods.CloseHandle( threadHandle );
			processHandle?.Dispose();
			jobHandle?.Dispose();
		}
	}

	/// <summary>
	/// Terminates a suspended, never-resumed launch through the authority actually held for it:
	/// its private job when assignment succeeded, otherwise the original process handle. On failure
	/// the available authority is retained in a distinct failed-launch state so it is not abandoned.
	/// </summary>
	private (int Error, bool RetainedProcess, bool RetainedJob) CleanupSuspendedLaunch( OwnedLocalInstance entry,
		SafeKernelHandle processHandle, SafeKernelHandle jobHandle, int processId, bool assignedToJob,
		long creationFileTime, string resolvedExecutable, bool windowed, int instanceNumber, string logDirectory,
		LaunchFailureInjection failure )
	{
		var error = 0;
		var job = entry?.JobHandle ?? jobHandle;
		var process = entry?.ProcessHandle ?? processHandle;
		var useJob = entry?.AssignedToJob ?? assignedToJob;

		if ( failure.HasFlag( LaunchFailureInjection.CleanupTerminationFails ) )
		{
			error = 5;
		}
		else if ( useJob && job is not null && !job.IsInvalid )
		{
			if ( !NativeMethods.TerminateJobObject( job, 1 ) ) error = Marshal.GetLastPInvokeError();
		}
		else if ( process is not null && !process.IsInvalid && !NativeMethods.TerminateProcess( process, 1 ) )
		{
			error = Marshal.GetLastPInvokeError();
		}

		if ( error == 0 && process is not null && !process.IsInvalid )
		{
			var wait = NativeMethods.WaitForSingleObject( process, 3_000 );
			if ( wait != NativeMethods.WaitObject0 )
				error = wait == NativeMethods.WaitFailed ? Marshal.GetLastPInvokeError() : 1460;
		}

		if ( error == 0 )
		{
			if ( entry is not null )
			{
				lock ( _sync )
				{
					if ( _entries.TryGetValue( processId, out var current ) && ReferenceEquals( current, entry ) )
						_entries.Remove( processId );
				}
				entry.Dispose();
			}
			return (0, false, false);
		}

		if ( entry is not null )
		{
			// The entry already holds both handles, so it becomes the retained authority itself.
			entry.MarkRetainedFailedLaunch();
			RetainFailedLaunch( entry );
			return (error, true, true);
		}

		if ( process is null || process.IsInvalid || processId <= 0 )
			return (error, false, false);

		// Retain what is actually held. The image path may be unavailable after a capture failure and
		// the process may never have joined its job; neither is required to keep cleanup authority,
		// and neither is presented as a contained, fully identified launch. A job handle that is not
		// taken into the entry stays with the launch scope, which releases it.
		var takeJob = useJob && job is not null && !job.IsInvalid;
		var candidate = new OwnedLocalInstance( process, takeJob ? job : null, processId,
			creationFileTime == 0 ? null : creationFileTime, resolvedExecutable, windowed, instanceNumber,
			Path.GetFullPath( logDirectory ), takeJob,
			creationFileTime == 0 ? LaunchAuthority.RetainedUnidentified : LaunchAuthority.RetainedFailedLaunch );
		RetainFailedLaunch( candidate, pidKeyOccupied: failure.HasFlag( LaunchFailureInjection.PidKeyOccupied ) );

		return (error, true, takeJob);
	}

	private OwnedInstanceSnapshot Snapshot( OwnedLocalInstance entry )
	{
		var exited = IsSignaled( entry.ProcessHandle );
		return new OwnedInstanceSnapshot( entry.ProcessId, entry.LaunchedAt, entry.Windowed,
			entry.LogDirectory, !exited, exited, checked((int)ActiveProcessCount( entry )) );
	}

	private static (bool Forced, string Message) TerminateOwnedTree( OwnedLocalInstance entry, int processId )
	{
		if ( entry.AssignedToJob )
		{
			if ( !NativeMethods.TerminateJobObject( entry.JobHandle, 1 ) )
				throw new OwnedInstanceException( "Owned instance termination failed.", processId, Marshal.GetLastPInvokeError() );
			return (true, "The owned process job was terminated.");
		}

		// Only a failed launch reaches here: it never joined its private job, so the original
		// suspended process handle is the authority, and it cannot be confused by PID reuse. No job
		// termination was invoked, so this must never be reported as a forced termination.
		if ( !NativeMethods.TerminateProcess( entry.ProcessHandle, 1 ) )
			throw new OwnedInstanceException( "Owned instance termination failed.", processId, Marshal.GetLastPInvokeError() );
		return (false, "The retained failed launch was terminated through its original process handle; no job termination was invoked.");
	}

	private bool ValidateIdentity( OwnedLocalInstance entry, out int nativeError )
	{
		nativeError = 0;
		if ( NativeMethods.GetProcessId( entry.ProcessHandle ) != (uint)entry.ProcessId )
		{
			nativeError = Marshal.GetLastPInvokeError();
			return false;
		}

		try
		{
			// A retained launch that never captured a creation time has nothing to compare against:
			// its authority is the retained handle itself, which the kernel pins to that exact
			// process object, and the job membership check below still applies when it was assigned.
			if ( entry.CreationFileTime is long creation
				&& GetCreationFileTime( entry.ProcessHandle, entry.ProcessId ) != creation )
				return false;

			// Windows no longer provides the image path after a process exits, and a failed launch
			// may never have captured one. Either way the retained handle, exact creation time and
			// job membership continue to pin that same process.
			if ( entry.ExecutablePath is not null && !IsSignaled( entry.ProcessHandle )
				&& !string.Equals( NormalizePath( QueryImagePath( entry.ProcessHandle ) ),
					NormalizePath( entry.ExecutablePath ), StringComparison.OrdinalIgnoreCase ) )
				return false;
		}
		catch ( OwnedInstanceException ex )
		{
			nativeError = ex.NativeError;
			return false;
		}

		// Membership is only meaningful for a process this registry actually assigned.
		if ( !entry.AssignedToJob ) return true;

		if ( !NativeMethods.IsProcessInJob( entry.ProcessHandle, entry.JobHandle, out var inJob ) )
		{
			nativeError = Marshal.GetLastPInvokeError();
			return false;
		}

		return inJob;
	}

	private static void TryCloseMainWindow( OwnedLocalInstance entry, bool rootExited )
	{
		if ( rootExited || entry.CreationFileTime is null ) return;

		// Post WM_CLOSE rather than Process.CloseMainWindow: PostMessage returns without waiting for
		// the child window thread, so an unresponsive client cannot stall the editor's main thread.
		NativeMethods.EnumWindows( (window, _) =>
		{
			NativeMethods.GetWindowThreadProcessId( window, out var processId );
			if ( processId == (uint)entry.ProcessId )
				NativeMethods.PostMessageW( window, NativeMethods.WindowMessageClose, IntPtr.Zero, IntPtr.Zero );
			return true;
		}, IntPtr.Zero );
	}

	/// <summary>Every retained entry, whether it holds its PID key or a displaced (PID, token) pair.</summary>
	private IEnumerable<OwnedLocalInstance> AllEntries() => _entries.Values.Concat( _displaced.Values );

	/// <summary>The entry whose exact (PID, token) pair matches, in either retention map.</summary>
	private OwnedLocalInstance FindOwned( int processId, string launchTimestamp )
	{
		if ( launchTimestamp is null ) return null;
		if ( _entries.TryGetValue( processId, out var primary )
			&& primary.CreationFileTime is not null
			&& string.Equals( primary.LaunchedAt, launchTimestamp, StringComparison.Ordinal ) )
			return primary;

		if ( _displaced.TryGetValue( (processId, launchTimestamp), out var displaced )
			&& displaced.CreationFileTime is not null )
			return displaced;

		return null;
	}

	/// <summary>Whether either retention map still claims this PID, whatever token was supplied.</summary>
	private bool AnyEntryFor( int processId )
	{
		if ( _entries.ContainsKey( processId ) ) return true;
		foreach ( var identity in _displaced.Keys )
		{
			if ( identity.ProcessId == processId ) return true;
		}

		return false;
	}

	/// <summary>
	/// Publishes a launch that never resumed as a retained failed launch. No caller of this method
	/// disposes or closes the candidate's handles: the authority is always published, and a PID key
	/// already held by another entry is never overwritten, because dropping either authority would
	/// abandon a live process or its tree. Such an entry stays reachable by its exact token, which
	/// termination already matches together with the PID. pidKeyOccupied is the harness seam that
	/// asserts another entry already holds that PID key.
	/// </summary>
	private void RetainFailedLaunch( OwnedLocalInstance candidate, bool pidKeyOccupied = false )
	{
		lock ( _sync )
		{
			if ( _entries.TryGetValue( candidate.ProcessId, out var current ) )
			{
				// Already published under its own PID key; nothing to move or duplicate.
				if ( ReferenceEquals( current, candidate ) ) return;
			}
			else if ( !pidKeyOccupied )
			{
				_entries.Add( candidate.ProcessId, candidate );
				return;
			}

			_displaced[(candidate.ProcessId, candidate.LaunchedAt)] = candidate;
		}
	}

	private void RemoveAndDispose( OwnedLocalInstance entry )
	{
		lock ( _sync )
		{
			if ( _entries.TryGetValue( entry.ProcessId, out var current ) && ReferenceEquals( current, entry ) )
				_entries.Remove( entry.ProcessId );
			else if ( !_displaced.Remove( (entry.ProcessId, entry.LaunchedAt) ) )
				return;
		}
		entry.Dispose();
	}

	private static uint ActiveProcessCount( OwnedLocalInstance entry )
	{
		if ( entry.AssignedToJob ) return GetActiveProcessCount( entry.JobHandle );
		return IsSignaled( entry.ProcessHandle ) ? 0u : 1u;
	}

	private bool TryCaptureCreationIdentity( OwnedLocalInstance entry )
	{
		try
		{
			var creation = GetCreationFileTime( entry.ProcessHandle, entry.ProcessId );
			var wasDisplaced = _displaced.Remove( (entry.ProcessId, entry.LaunchedAt) );
			entry.CaptureCreationIdentity( creation );
			if ( wasDisplaced ) _displaced.Add( (entry.ProcessId, entry.LaunchedAt), entry );
			return true;
		}
		catch ( OwnedInstanceException )
		{
			return false;
		}
	}

	private static uint GetActiveProcessCount( SafeKernelHandle jobHandle )
	{
		if ( !NativeMethods.QueryInformationJobObject( jobHandle, NativeMethods.JobObjectBasicAccountingInformation,
			out var accounting, (uint)Marshal.SizeOf<NativeMethods.JobBasicAccountingInformation>(), IntPtr.Zero ) )
			throw NativeFailure( "Could not inspect the owned process job." );
		return accounting.ActiveProcesses;
	}

	private static bool IsSignaled( SafeKernelHandle processHandle )
	{
		var result = NativeMethods.WaitForSingleObject( processHandle, 0 );
		if ( result == NativeMethods.WaitObject0 ) return true;
		if ( result == NativeMethods.WaitTimeout ) return false;
		throw NativeFailure( "Could not inspect the owned process state." );
	}

	private static long GetCreationFileTime( SafeKernelHandle processHandle, int processId = 0 )
	{
		if ( !NativeMethods.GetProcessTimes( processHandle, out var creation, out _, out _, out _ ) )
			throw new OwnedInstanceException( "Could not read the owned process creation identity.", processId,
				Marshal.GetLastPInvokeError() );
		return creation.ToInt64();
	}

	private static string QueryImagePath( SafeKernelHandle processHandle )
	{
		var capacity = 32768u;
		var buffer = new StringBuilder( checked((int)capacity) );
		if ( !NativeMethods.QueryFullProcessImageNameW( processHandle, 0, buffer, ref capacity ) )
			throw NativeFailure( "Could not read the owned process image identity." );
		return Path.GetFullPath( buffer.ToString() );
	}

	private static string NormalizePath( string path ) => Path.TrimEndingDirectorySeparator( Path.GetFullPath( path ));

	private static string ResolveLogDirectory()
	{
		var root = Environment.GetEnvironmentVariable( "FACEPUNCH_ENGINE", EnvironmentVariableTarget.User );
		if ( string.IsNullOrWhiteSpace( root ) ) root = AppContext.BaseDirectory;
		return Path.GetFullPath( Path.Combine( root, "logs" ) );
	}

	private static string BuildCommandLine( string executable, IReadOnlyList<string> arguments )
	{
		var builder = new StringBuilder();
		AppendQuotedArgument( builder, executable );
		foreach ( var argument in arguments )
		{
			builder.Append( ' ' );
			AppendQuotedArgument( builder, argument ?? string.Empty );
		}
		return builder.ToString();
	}

	private static void AppendQuotedArgument( StringBuilder builder, string argument )
	{
		builder.Append( '"' );
		var slashes = 0;
		foreach ( var character in argument )
		{
			if ( character == '\\' )
			{
				slashes++;
				continue;
			}
			if ( character == '"' )
			{
				builder.Append( '\\', slashes * 2 + 1 );
				builder.Append( '"' );
				slashes = 0;
				continue;
			}
			builder.Append( '\\', slashes );
			slashes = 0;
			builder.Append( character );
		}
		builder.Append( '\\', slashes * 2 );
		builder.Append( '"' );
	}

	private static OwnedInstanceException NativeFailure( string message, int processId = 0 )
	{
		var error = Marshal.GetLastPInvokeError();
		return new OwnedInstanceException( message, processId, error, new Win32Exception( error ));
	}

	private void ThrowIfDisposed()
	{
		if ( _disposed ) throw new ObjectDisposedException( nameof(OwnedInstanceRegistry) );
	}
}

/// <summary>
/// The authority a registry entry holds. A launch that never resumed and whose cleanup could not
/// confirm termination is retained in one of the failed-launch states instead of being abandoned.
/// </summary>
internal enum LaunchAuthority
{
	/// <summary>A fully identified launch whose process was resumed.</summary>
	Owned = 0,

	/// <summary>
	/// A never-resumed launch retained after its cleanup failed with its exact creation time known,
	/// so its token is that creation time and job membership still constrains it when assigned.
	/// </summary>
	RetainedFailedLaunch = 1,

	/// <summary>
	/// A never-resumed launch retained after cleanup failed before its creation time could be read.
	/// It remains internal until the retained handle yields the exact creation identity; it is never
	/// exposed with a fabricated or overloaded public timestamp.
	/// </summary>
	RetainedUnidentified = 2
}

/// <summary>Fault seams used by the focused ownership harness. Production always passes None.</summary>
[Flags]
internal enum LaunchFailureInjection
{
	None = 0,
	BeforeAssignment = 1,
	AfterAssignmentBeforeIdentity = 2,
	AssignmentFails = 4,
	ImagePathCaptureFails = 8,
	CleanupTerminationFails = 16,

	/// <summary>Fails the creation-time read, so no creation identity is ever captured.</summary>
	CreationTimeCaptureFails = 32,

	/// <summary>Retains a failed launch as if its PID key were already held by another entry.</summary>
	PidKeyOccupied = 64
}

internal sealed record OwnedInstanceSnapshot( int ProcessId, string LaunchedAt, bool Windowed,
	string LogDirectory, bool Alive, bool Exited, int ActiveProcessCount );

internal sealed record OwnedTerminationOutcome( int ProcessId, string Result, bool Forced, string Message )
{
	internal static OwnedTerminationOutcome Terminated( int processId, bool forced, string message ) =>
		new( processId, "terminated", forced, message );
	internal static OwnedTerminationOutcome AlreadyExited( int processId ) =>
		new( processId, "already exited", false, "The owned process job had already exited." );
	internal static OwnedTerminationOutcome NotOwned( int processId ) =>
		new( processId, "not owned", false, "No current in-memory ownership entry matches that process." );
	internal static OwnedTerminationOutcome IdentityMismatch( int processId ) =>
		new( processId, "identity mismatch", false, "The supplied or retained launch identity did not match; no process was signaled." );
}

internal sealed class OwnedInstanceException : Exception
{
	internal OwnedInstanceException( string message, int processId = 0, int nativeError = 0, Exception inner = null )
		: base( message, inner )
	{
		ProcessId = processId;
		NativeError = nativeError;
	}
	internal int ProcessId { get; }
	internal int NativeError { get; }
}

internal sealed class SafeKernelHandle : SafeHandleZeroOrMinusOneIsInvalid
{
	internal SafeKernelHandle() : base( true ) { }
	internal SafeKernelHandle( IntPtr handle, bool ownsHandle ) : base( ownsHandle ) => SetHandle( handle );
	protected override bool ReleaseHandle() => NativeMethods.CloseHandle( handle );
}

internal static class NativeMethods
{
	internal const uint CreateSuspended = 0x00000004;
	internal const uint CreateNoWindow = 0x08000000;
	internal const uint WaitObject0 = 0x00000000;
	internal const uint WaitTimeout = 0x00000102;
	internal const uint WaitFailed = 0xFFFFFFFF;
	internal const int JobObjectBasicAccountingInformation = 1;
	internal const uint WindowMessageClose = 0x0010;

	[StructLayout( LayoutKind.Sequential, CharSet = CharSet.Unicode )]
	internal struct StartupInfo
	{
		internal int Size;
		internal string Reserved;
		internal string Desktop;
		internal string Title;
		internal uint X;
		internal uint Y;
		internal uint XSize;
		internal uint YSize;
		internal uint XCountChars;
		internal uint YCountChars;
		internal uint FillAttribute;
		internal uint Flags;
		internal ushort ShowWindow;
		internal ushort Reserved2;
		internal IntPtr Reserved2Pointer;
		internal IntPtr StandardInput;
		internal IntPtr StandardOutput;
		internal IntPtr StandardError;
	}

	[StructLayout( LayoutKind.Sequential )]
	internal struct ProcessInformation
	{
		internal IntPtr ProcessHandle;
		internal IntPtr ThreadHandle;
		internal uint ProcessId;
		internal uint ThreadId;
	}

	[StructLayout( LayoutKind.Sequential )]
	internal struct FileTime
	{
		internal uint Low;
		internal uint High;
		internal long ToInt64() => unchecked((long)(((ulong)High << 32) | Low));
	}

	[StructLayout( LayoutKind.Sequential )]
	internal struct JobBasicAccountingInformation
	{
		internal long TotalUserTime;
		internal long TotalKernelTime;
		internal long ThisPeriodTotalUserTime;
		internal long ThisPeriodTotalKernelTime;
		internal uint TotalPageFaultCount;
		internal uint TotalProcesses;
		internal uint ActiveProcesses;
		internal uint TotalTerminatedProcesses;
	}

	internal delegate bool EnumWindowsCallback( IntPtr window, IntPtr state );

	[DllImport( "user32.dll", SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	internal static extern bool EnumWindows( EnumWindowsCallback callback, IntPtr state );

	[DllImport( "user32.dll", SetLastError = true )]
	internal static extern uint GetWindowThreadProcessId( IntPtr window, out uint processId );

	[DllImport( "user32.dll", EntryPoint = "PostMessageW", SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	internal static extern bool PostMessageW( IntPtr window, uint message, IntPtr wParam, IntPtr lParam );

	[DllImport( "kernel32.dll", EntryPoint = "CreateJobObjectW", CharSet = CharSet.Unicode, SetLastError = true )]
	internal static extern SafeKernelHandle CreateJobObjectW( IntPtr jobAttributes, string name );

	[DllImport( "kernel32.dll", EntryPoint = "CreateProcessW", CharSet = CharSet.Unicode, SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	internal static extern bool CreateProcessW( string applicationName, StringBuilder commandLine,
		IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs( UnmanagedType.Bool )] bool inheritHandles,
		uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startupInfo,
		out ProcessInformation processInformation );

	[DllImport( "kernel32.dll", SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	internal static extern bool AssignProcessToJobObject( SafeKernelHandle job, SafeKernelHandle process );

	[DllImport( "kernel32.dll", SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	internal static extern bool IsProcessInJob( SafeKernelHandle process, SafeKernelHandle job,
		[MarshalAs( UnmanagedType.Bool )] out bool result );

	[DllImport( "kernel32.dll", SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	internal static extern bool GetProcessTimes( SafeKernelHandle process, out FileTime creation,
		out FileTime exit, out FileTime kernel, out FileTime user );

	[DllImport( "kernel32.dll", EntryPoint = "QueryFullProcessImageNameW", CharSet = CharSet.Unicode, SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	internal static extern bool QueryFullProcessImageNameW( SafeKernelHandle process, uint flags,
		StringBuilder imagePath, ref uint size );

	[DllImport( "kernel32.dll", SetLastError = true )]
	internal static extern uint GetProcessId( SafeKernelHandle process );

	[DllImport( "kernel32.dll", SetLastError = true )]
	internal static extern uint ResumeThread( IntPtr thread );

	[DllImport( "kernel32.dll", SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	internal static extern bool TerminateProcess( SafeKernelHandle process, uint exitCode );

	[DllImport( "kernel32.dll", SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	internal static extern bool TerminateJobObject( SafeKernelHandle job, uint exitCode );

	[DllImport( "kernel32.dll", SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	internal static extern bool QueryInformationJobObject( SafeKernelHandle job, int informationClass,
		out JobBasicAccountingInformation information, uint informationLength, IntPtr returnLength );

	[DllImport( "kernel32.dll", SetLastError = true )]
	internal static extern uint WaitForSingleObject( SafeKernelHandle handle, uint milliseconds );

	[DllImport( "kernel32.dll", SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	internal static extern bool CloseHandle( IntPtr handle );
}
kitsupanic.sbox_mcp_plus / Editor/ModelImportBackend.cs
Editor library
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;

namespace Editor.Mcp;

internal interface IModelImportBackend
{
	object RegisterFile( string absolutePath );
	object FindAsset( string assetPath );
	string GetAssetPath( object asset );
	object CreateModel( object sourceAsset, string absoluteModelPath );
	bool ReadIsCompiled( object asset );
	bool ReadIsCompiledAndUpToDate( object asset );
	bool ReadIsCompileFailed( object asset );
	ValueTask ObserveCompilationIfNeededAsync( object asset );
	IReadOnlyList<object> GetReferences( object asset );
	IReadOnlyList<string> GetUnrecognizedReferences( object asset );
	IReadOnlyList<string> GetInputDependencies( object asset );
	IReadOnlyList<string> GetAdditionalContentFiles( object asset );
}

internal sealed class ModelImportBackendPipeline
{
	private readonly IModelImportBackend _backend;
	internal ModelImportBackendPipeline( IModelImportBackend backend ) => _backend = backend;

	internal object RegisterDependency( string absolutePath, string expectedPath )
	{
		try
		{
			var asset = _backend.RegisterFile( absolutePath );
			if ( asset is null )
				throw new ImportContractException( "RegistrationFailed", $"Failed to register '{expectedPath}'." );
			return asset;
		}
		catch ( ImportContractException ) { throw; }
		catch ( Exception ex ) { throw new ImportContractException( "RegistrationFailed", $"Registration failed for '{expectedPath}': {Safe( ex.Message, 700 )}" ); }
	}

	internal object RegisterSource( string absolutePath, string expectedPath )
	{
		var asset = RegisterDependency( absolutePath, expectedPath );
		if ( !SamePath( asset, expectedPath ) )
			throw new ImportContractException( "RegistrationFailed", $"The source was not registered at '{expectedPath}'." );
		return asset;
	}

	internal object CreateModel( object source, string absolutePath, string expectedPath )
	{
		try
		{
			var created = _backend.CreateModel( source, absolutePath );
			var resolved = _backend.FindAsset( expectedPath );
			if ( created is null || resolved is null || !SamePath( resolved, expectedPath ) )
				throw new ImportContractException( "ModelCreationFailed", "The generated model could not be confirmed at its planned asset path." );
			return resolved;
		}
		catch ( ImportContractException ) { throw; }
		catch ( Exception ex ) { throw new ImportContractException( "ModelCreationFailed", $"Model creation failed: {Safe( ex.Message, 700 )}" ); }
	}

	internal async ValueTask<BackendCompilationEvidence> ObserveCompilationAsync( object model )
	{
		var result = new BackendCompilationEvidence();
		result.UnavailableEvidence["OperationCompletion"] = "Installed asset-state APIs do not expose operation completion.";
		result.UnavailableEvidence["WriterShutdown"] = "Installed asset-state APIs do not expose writer shutdown.";
		Exception helperError = null;
		if ( TryRead( () => _backend.ReadIsCompiled( model ), out var initiallyCompiled, result, "IsCompiled" ) && initiallyCompiled == false )
		{
			try { await _backend.ObserveCompilationIfNeededAsync( model ); }
			catch ( Exception ex ) { helperError = ex; }
		}
		TryRead( () => _backend.ReadIsCompiled( model ), out var isCompiled, result, "IsCompiled" );
		TryRead( () => _backend.ReadIsCompiledAndUpToDate( model ), out var isUpToDate, result, "IsCompiledAndUpToDate" );
		TryRead( () => _backend.ReadIsCompileFailed( model ), out var isFailed, result, "IsCompileFailed" );
		result.IsCompiled = isCompiled;
		result.IsCompiledAndUpToDate = isUpToDate;
		result.IsCompileFailed = isFailed;
		var missing = result.UnavailableEvidence.Keys.Count( x => x is "IsCompiled" or "IsCompiledAndUpToDate" or "IsCompileFailed" );
		result.Status = missing == 0 ? "observed" : missing == 3 ? "unavailable" : "partial";
		if ( result.IsCompileFailed == true ) throw new BackendPipelineException( "CompileFailed", "The model compiler reported failure.", result );
		if ( helperError is not null ) throw new BackendPipelineException( "CompilationUnconfirmed", $"Compilation observation failed: {Safe( helperError.Message, 900 )}", result );
		if ( result.Status != "observed" || result.IsCompiled != true || result.IsCompiledAndUpToDate != true || result.IsCompileFailed != false )
			throw new BackendPipelineException( "CompilationUnconfirmed", "Successful compilation could not be confirmed from all installed asset-state properties.", result );
		return result;
	}

	internal BackendDependencyEvidence InspectDependencies( object source, object model )
	{
		var result = new BackendDependencyEvidence();
		result.UnavailableEvidence["ExhaustiveSourceDependencies"] = "Installed APIs do not expose exhaustive source dependencies.";
		result.UnavailableEvidence["OptionalReferenceClassification"] = "Unrecognized references do not expose optionality.";
		result.UnavailableEvidence["SourceMaterialPreservation"] = "The model helper may substitute materials/default.vmat.";
		var pending = new Queue<object>( [source, model] );
		var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		var failed = false;
		while ( pending.Count > 0 )
		{
			var asset = pending.Dequeue();
			string path;
			try { path = _backend.GetAssetPath( asset ); }
			catch ( Exception ex )
			{
				RecordUnavailable( result, $"Asset#{seen.Count}", "AssetPath", ex );
				failed = true;
				continue;
			}
			if ( string.IsNullOrWhiteSpace( path ) )
			{
				result.UnavailableEvidence[$"AssetPath:Asset#{seen.Count}"] = "The backend returned no asset path.";
				failed = true;
				continue;
			}
			if ( !seen.Add( path ) ) continue;
			var assetFailed = false;
			IReadOnlyList<string> inputs = [], additional = [];
			try
			{
				foreach ( var reference in _backend.GetReferences( asset ) ?? [] )
				{
					pending.Enqueue( reference );
					try
					{
						var referencePath = _backend.GetAssetPath( reference );
						if ( string.IsNullOrWhiteSpace( referencePath ) ) throw new Exception( "The backend returned no referenced asset path." );
						result.References.Add( referencePath );
					}
					catch ( Exception ex ) { RecordUnavailable( result, path, "ReferencePath", ex ); assetFailed = true; }
				}
			}
			catch ( Exception ex ) { RecordUnavailable( result, path, "References", ex ); assetFailed = true; }
			try { result.UnresolvedReferences.AddRange( _backend.GetUnrecognizedReferences( asset ) ?? [] ); }
			catch ( Exception ex ) { RecordUnavailable( result, path, "UnrecognizedReferences", ex ); assetFailed = true; }
			try { inputs = _backend.GetInputDependencies( asset ) ?? []; result.InputDependencies.AddRange( inputs ); }
			catch ( Exception ex ) { RecordUnavailable( result, path, "InputDependencies", ex ); assetFailed = true; }
			try { additional = _backend.GetAdditionalContentFiles( asset ) ?? []; result.AdditionalContentFiles.AddRange( additional ); }
			catch ( Exception ex ) { RecordUnavailable( result, path, "AdditionalContentFiles", ex ); assetFailed = true; }
			if ( assetFailed ) failed = true;
			else result.InspectedAssets.Add( path );
		}
		Normalize( result.InspectedAssets ); Normalize( result.References ); Normalize( result.InputDependencies );
		Normalize( result.AdditionalContentFiles ); Normalize( result.UnresolvedReferences );
		result.Status = failed ? result.InspectedAssets.Count == 0 ? "unavailable" : "partial" : "inspected";
		if ( result.Status != "inspected" ) throw new BackendPipelineException( "DependencyInspectionFailed", "The promised dependency inspection could not be completed.", result );
		if ( result.UnresolvedReferences.Count > 0 ) throw new BackendPipelineException( "UnresolvedDependencies", "One or more reported references could not be resolved; installed APIs do not expose optionality.", result );
		return result;
	}

	private bool SamePath( object asset, string expected ) => string.Equals( _backend.GetAssetPath( asset )?.Replace( '\\', '/' ), expected, StringComparison.OrdinalIgnoreCase );
	private static bool TryRead( Func<bool> read, out bool? value, BackendCompilationEvidence evidence, string key )
	{
		try { value = read(); evidence.UnavailableEvidence.Remove( key ); return true; }
		catch ( Exception ex ) { value = null; evidence.UnavailableEvidence[key] = Safe( ex.Message, 256 ); return false; }
	}
	private static void RecordUnavailable( BackendDependencyEvidence evidence, string asset, string query, Exception ex ) =>
		evidence.UnavailableEvidence[$"{query}:{Safe( asset, 150 )}"] = Safe( ex.Message, 256 );
	private static void Normalize( List<string> paths )
	{
		var values = paths.Where( x => !string.IsNullOrWhiteSpace( x ) ).Select( x => x.Replace( '\\', '/' ) )
			.Distinct( StringComparer.OrdinalIgnoreCase ).OrderBy( x => x, StringComparer.OrdinalIgnoreCase ).ToArray();
		paths.Clear(); paths.AddRange( values );
	}
	private static string Safe( string value, int maximum )
	{
		var text = string.IsNullOrWhiteSpace( value ) ? "The operation failed without a message." : value.Replace( '\r', ' ' ).Replace( '\n', ' ' );
		return text.Length <= maximum ? text : text[..maximum];
	}
}

internal sealed class BackendCompilationEvidence
{
	internal string Status { get; set; } = "not_started";
	internal bool? IsCompiled { get; set; }
	internal bool? IsCompiledAndUpToDate { get; set; }
	internal bool? IsCompileFailed { get; set; }
	internal Dictionary<string, string> UnavailableEvidence { get; } = new( StringComparer.Ordinal );
}

internal sealed class BackendDependencyEvidence
{
	internal string Status { get; set; } = "not_started";
	internal List<string> InspectedAssets { get; } = [];
	internal List<string> References { get; } = [];
	internal List<string> InputDependencies { get; } = [];
	internal List<string> AdditionalContentFiles { get; } = [];
	internal List<string> UnresolvedReferences { get; } = [];
	internal Dictionary<string, string> UnavailableEvidence { get; } = new( StringComparer.Ordinal );
}

internal sealed class BackendPipelineException : ImportContractException
{
	internal object Evidence { get; }
	internal BackendPipelineException( string code, string message, object evidence ) : base( code, message ) => Evidence = evidence;
}

internal sealed class ModelImportRequestGate
{
	private StrongBox<long> _active;
	internal ModelImportRequestGate() : this( new StrongBox<long>( 0 ) ) { }
	internal ModelImportRequestGate( StrongBox<long> active ) => _active = active ?? throw new ArgumentNullException( nameof( active ) );
	private StrongBox<long> Active => _active ??= new StrongBox<long>( 0 );
	internal StrongBox<long> State => Active;
	internal bool IsActive => System.Threading.Volatile.Read( ref Active.Value ) != 0;
	internal Lease TryEnter()
	{
		var state = Active;
		return System.Threading.Interlocked.CompareExchange( ref state.Value, 1, 0 ) == 0 ? new Lease( state ) : null;
	}
	internal void Restore( bool active ) => System.Threading.Volatile.Write( ref Active.Value, active ? 1 : 0 );

	internal sealed class Lease : IDisposable
	{
		private StrongBox<long> _state;
		internal Lease( StrongBox<long> state ) => _state = state;
		public void Dispose()
		{
			var state = System.Threading.Interlocked.Exchange( ref _state, null );
			if ( state is not null ) System.Threading.Volatile.Write( ref state.Value, 0 );
		}
	}
}
internal sealed record ModelImportHotloadSnapshot(
	int Version, bool ActiveRequest, string[] PendingPaths, string[] OwnedDirectories );

internal static class ModelImportHotloadTransfer
{
	internal static ModelImportHotloadSnapshot Read( IReadOnlyDictionary<string, object> state, bool legacyInvocationFinished )
	{
		var pendingValid = state.TryGetValue( "ModelImportPending", out var pendingValue ) && pendingValue is IEnumerable<string>;
		var ownedValid = state.TryGetValue( "ModelImportOwnedDirectories", out var ownedValue ) && ownedValue is IEnumerable<string>;
		var busyValid = state.TryGetValue( "ModelImportBusy", out var busyValue ) && busyValue is bool;
		if ( !pendingValid || !ownedValid || !busyValid )
			throw new ImportContractException( "ImportBusy", "Model import hotload state transfer was incomplete." );
		var version = state.TryGetValue( "ModelImportStateVersion", out var versionValue ) && versionValue is int parsed ? parsed : 1;
		var active = version >= 4 ? (bool)busyValue : !legacyInvocationFinished && (bool)busyValue;
		return new ModelImportHotloadSnapshot( version, active,
			((IEnumerable<string>)pendingValue).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray(),
			((IEnumerable<string>)ownedValue).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray() );
	}

	internal static void Write( Dictionary<string, object> state, ModelImportRequestGate gate, IEnumerable<string> pending, IEnumerable<string> owned )
	{
		state["ModelImportStateVersion"] = 4;
		state["ModelImportBusy"] = gate.IsActive;
		state["ModelImportGateState"] = gate.State;
		state["ModelImportPending"] = pending.Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();
		state["ModelImportOwnedDirectories"] = owned.Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();
	}
}
kitsupanic.sbox_mcp_plus / Editor/McpExtras.ModelSkeleton.cs
Editor library
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace Editor.Mcp;

public static partial class ExtrasTools
{
	/// <summary>
	/// Return read-only model-space bounds, bones, attachment ownership, material groups and animation
	/// names from an installed model resource. Unavailable complete fields are null with a reason;
	/// inspected empty collections remain empty. Skinning presence is unavailable in installed public APIs.
	/// </summary>
	/// <param name="model">Model asset path resolvable by AssetSystem.FindByPath.</param>
	[McpTool.ReadOnly( "x_model_skeleton" )]
	public static ModelSkeletonResult GetModelSkeleton( string model )
	{
		if ( string.IsNullOrWhiteSpace( model ) ) throw new Exception( "Give a model asset path." );
		var asset = AssetSystem.FindByPath( model.Replace( '\\', '/' ) )
			?? throw new Exception( $"Model asset '{Safe( model, 512 )}' was not found." );
		Model resource;
		try { resource = asset.LoadResource<Model>(); }
		catch ( Exception ex ) { throw new Exception( $"Model asset could not be loaded: {Safe( ex.Message, 512 )}" ); }
		if ( resource is null || !resource.IsValid || resource.IsError )
			throw new Exception( $"Model asset '{Safe( asset.Path, 512 )}' did not load as a valid non-error model resource." );

		var result = new ModelSkeletonResult { ModelAsset = asset.Path?.Replace( '\\', '/' ) };
		Capture( "Bounds", () =>
		{
			var bounds = resource.RenderBounds;
			result.Bounds = new ModelBoundsEvidence { Space = "model", Mins = bounds.Mins, Maxs = bounds.Maxs };
		}, result );
		Capture( "Bones", () => result.Bones = resource.Bones.AllBones.Select( x => new ModelBoneEvidence
		{
			Index = x.Index, Name = x.Name, ParentIndex = x.Parent?.Index ?? -1
		} ).ToList(), result );
		Capture( "Attachments", () => result.Attachments = resource.Attachments.All.Select( x => new ModelAttachmentEvidence
		{
			Name = x.Name, BoneIndex = x.Bone?.Index
		} ).ToList(), result );
		Capture( "MaterialGroups", () =>
		{
			var groups = new List<ModelMaterialGroupEvidence>();
			for ( var i = 0; i < resource.MaterialGroupCount; ++i )
			{
				groups.Add( new ModelMaterialGroupEvidence
				{
					Index = i,
					Name = resource.GetMaterialGroupName( i ),
					Materials = resource.GetMaterials( i ).Select( x => x?.ResourcePath?.Replace( '\\', '/' ) ).Where( x => x is not null ).ToList()
				} );
			}
			result.MaterialGroups = groups;
		}, result );
		Capture( "AnimationSequences", () =>
		{
			var animations = new List<string>( resource.AnimationCount );
			for ( var i = 0; i < resource.AnimationCount; ++i ) animations.Add( resource.GetAnimationName( i ) );
			result.AnimationSequences = animations;
		}, result );
		result.HasSkinningData = null;
		result.UnavailableFields["HasSkinningData"] = "Installed public model APIs do not expose skinning-data presence.";
		return result;
	}

	private static void Capture( string field, Action inspect, ModelSkeletonResult result )
	{
		try { inspect(); }
		catch ( Exception ex ) { result.UnavailableFields[field] = Safe( ex.Message, 256 ); }
	}
}

public sealed class ModelSkeletonResult
{
	public string ModelAsset { get; set; }
	public ModelBoundsEvidence Bounds { get; set; }
	public List<ModelBoneEvidence> Bones { get; set; }
	public List<ModelAttachmentEvidence> Attachments { get; set; }
	public List<ModelMaterialGroupEvidence> MaterialGroups { get; set; }
	public List<string> AnimationSequences { get; set; }
	public bool? HasSkinningData { get; set; }
	public Dictionary<string, string> UnavailableFields { get; set; } = new( StringComparer.Ordinal );
}

public sealed class ModelBoundsEvidence
{
	public string Space { get; set; }
	public Vector3 Mins { get; set; }
	public Vector3 Maxs { get; set; }
}

public sealed class ModelBoneEvidence
{
	public int Index { get; set; }
	public string Name { get; set; }
	public int ParentIndex { get; set; }
}

public sealed class ModelAttachmentEvidence
{
	public string Name { get; set; }
	public int? BoneIndex { get; set; }
}

public sealed class ModelMaterialGroupEvidence
{
	public int Index { get; set; }
	public string Name { get; set; }
	public List<string> Materials { get; set; } = [];
}
kitsupanic.sbox_mcp_plus / Editor/McpExtras.Network.cs
Editor library
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;

namespace Editor.Mcp;

public static partial class ExtrasTools
{
	private static NetworkToolLifetime NetworkLifetime = new();
	private static readonly Regex Steam2Pattern = new( @"STEAM_[0-5]:[01]:\d+", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds( 100 ) );
	private static readonly Regex Steam3Pattern = new( @"\[[A-Za-z]:\d+:\d+\]", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds( 100 ) );
	private static readonly Regex Steam64Pattern = new( @"(?<!\d)\d{17}(?!\d)", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds( 100 ) );

	/// <summary>Inspect the active network session without exposing account, party, chat, voice, or credential data.</summary>
	[McpTool.ReadOnly( "x_network_status" )]
	public static NetworkState GetNetworkStatus()
	{
		return SnapshotNetwork();
	}

	/// <summary>Start hosting through the editor's supported network path and return the observed state.</summary>
	[McpTool( "x_network_start_hosting" )]
	public static NetworkState StartNetworkHosting()
	{
		var lifetime = CurrentLifetime();
		using var operation = lifetime.EnterMutation();
		if ( EditorUtility.Network.Active || Networking.IsConnecting )
			throw new Exception( "Already connected or connecting; disconnect first." );

		try
		{
			EditorUtility.Network.StartHosting();
		}
		catch ( Exception )
		{
			throw new Exception( "Starting hosting failed; the editor reported an error and no other change was made." );
		}

		lifetime.ThrowIfInvalid();
		return SnapshotNetwork();
	}

	/// <summary>Disconnect the editor from its current network session without terminating owned child instances.</summary>
	[McpTool( "x_network_disconnect" )]
	public static NetworkState DisconnectNetwork()
	{
		var lifetime = CurrentLifetime();
		using var operation = lifetime.EnterMutation();
		if ( !EditorUtility.Network.Active && !Networking.IsConnecting )
			throw new Exception( "No network session is active." );

		try
		{
			EditorUtility.Network.Disconnect();
		}
		catch ( Exception )
		{
			throw new Exception( "Disconnecting failed; the editor reported an error and owned instances were left untouched." );
		}

		lifetime.ThrowIfInvalid();
		return SnapshotNetwork();
	}

	/// <summary>
	/// Launch a fixed local sbox client contained in a private process job. The argument list is fixed
	/// and deliberately does not copy arbitrary editor preference command-line arguments.
	/// </summary>
	[McpTool( "x_network_spawn_instance" )]
	public static InstanceState SpawnNetworkInstance( bool? windowed = null )
	{
		var lifetime = CurrentLifetime();
		using var operation = lifetime.EnterMutation();
		RequireStableHosting();
		var effectiveWindowed = windowed ?? EditorPreferences.WindowedLocalInstances;

		OwnedInstanceSnapshot snapshot;
		try
		{
			snapshot = lifetime.Registry.LaunchSbox( effectiveWindowed, IsSyntheticIdentityVisible );
		}
		catch ( OwnedInstanceException ex )
		{
			throw TranslateLaunchFailure( ex );
		}
		catch ( Exception )
		{
			throw new Exception( "Local instance launch setup failed; no process was started." );
		}

		lifetime.ThrowIfInvalid();
		return MapInstance( snapshot );
	}

	/// <summary>List only this hotload lifetime's in-memory owned process jobs; never enumerate OS processes.</summary>
	[McpTool.ReadOnly( "x_network_instances" )]
	public static InstanceState[] GetNetworkInstances()
	{
		var lifetime = CurrentLifetime();
		try
		{
			return lifetime.Registry.List( !lifetime.IsBusy ).Select( MapInstance ).ToArray();
		}
		catch ( OwnedInstanceException )
		{
			throw new Exception( "Owned instance inspection failed." );
		}
	}

	/// <summary>
	/// Refuse local-client host migration before launching anything. Installed engine 26.09.08e
	/// chooses successors from Steam lobby membership, which synthetic loopback clients cannot join,
	/// and exposes no supported API that can target or certify a local successor.
	/// </summary>
	[McpTool( "x_network_migrate_to_new_instance" )]
	public static Task<NetworkState> MigrateNetworkToNewInstance( bool? windowed = null, int timeoutSeconds = 120 )
	{
		var lifetime = CurrentLifetime();
		using var operation = lifetime.EnterMutation();
		if ( timeoutSeconds < 1 || timeoutSeconds > 120 )
			throw new Exception( "timeoutSeconds must be between 1 and 120." );
		if ( !IsStableHosting() || GetRemoteConnections().Length != 0 )
			throw new Exception( "Migration requires a hosting session with no remote peers." );

		throw new Exception( "The installed engine cannot migrate hosting to a synthetic local client; no process was launched and the editor remains connected." );
	}

	/// <summary>
	/// Terminate exactly one currently owned process job. Both the PID and the exact creation-time
	/// LaunchedAt token returned by spawn or listing are required. Retained cleanup authority stays
	/// internal until that exact identity is readable. Abrupt skips the two-second graceful close budget.
	/// </summary>
	[McpTool( "x_network_terminate_instance" )]
	public static async Task<InstanceTermination> TerminateNetworkInstance( int processId, string launchTimestamp, bool abrupt = false )
	{
		var lifetime = CurrentLifetime();
		using var operation = lifetime.EnterMutation();
		try
		{
			var outcome = await lifetime.Registry.TerminateAsync( processId, launchTimestamp, abrupt, lifetime.CancellationToken );
			lifetime.ThrowIfInvalid();
			return new InstanceTermination
			{
				ProcessId = outcome.ProcessId,
				Result = outcome.Result,
				Forced = outcome.Forced,
				Message = outcome.Message
			};
		}
		catch ( OperationCanceledException ) when ( !lifetime.IsValid )
		{
			throw new Exception( NetworkToolLifetime.InvalidatedMessage );
		}
		catch ( OwnedInstanceException ex )
		{
			var native = ex.NativeError > 0 ? $"; native error {ex.NativeError}" : string.Empty;
			throw new Exception( $"{ex.Message} PID {processId}{native}. Ownership is retained; the instance stays listed." );
		}
	}

	/// <summary>A privacy-limited snapshot of the active editor network session.</summary>
	public class NetworkState
	{
		public bool IsActive { get; set; }
		public bool IsHost { get; set; }
		public bool IsClient { get; set; }
		public bool IsConnecting { get; set; }
		public Guid? LocalConnectionId { get; set; }
		public Guid? HostConnectionId { get; set; }
		public ConnectionState[] Connections { get; set; }
	}

	/// <summary>A visible connection with account and party identifiers deliberately omitted.</summary>
	public class ConnectionState
	{
		public Guid Id { get; set; }
		public string DisplayName { get; set; }
		public bool IsHost { get; set; }
		public bool IsLocal { get; set; }
		public bool IsActive { get; set; }
		public bool IsConnecting { get; set; }
		public float Ping { get; set; }
	}

	/// <summary>
	/// An in-memory owned local process descriptor. LaunchedAt is the exact UTC process creation time
	/// and its termination token; unidentified cleanup authority is not exposed until this is readable.
	/// </summary>
	public class InstanceState
	{
		public int ProcessId { get; set; }
		public string LaunchedAt { get; set; }
		public bool Windowed { get; set; }
		public string LogDirectory { get; set; }
		public bool Alive { get; set; }
		public bool Exited { get; set; }
		public int ActiveProcessCount { get; set; }
	}

	/// <summary>The bounded result of an ownership-checked termination request.</summary>
	public class InstanceTermination
	{
		public int ProcessId { get; set; }
		public string Result { get; set; }
		public bool Forced { get; set; }
		public string Message { get; set; }
	}

	private static NetworkToolLifetime CurrentLifetime()
	{
		var lifetime = NetworkLifetime;
		lifetime.ThrowIfInvalid();
		return lifetime;
	}

	private static void RequireStableHosting()
	{
		if ( !IsStableHosting() ) throw new Exception( "Editor must be hosting first." );
	}

	private static bool IsStableHosting()
	{
		return EditorUtility.Network.Hosting && Networking.IsActive && !Networking.IsConnecting;
	}

	private static NetworkState SnapshotNetwork()
	{
		var active = Networking.IsActive;
		if ( !active )
		{
			return new NetworkState
			{
				IsActive = false,
				IsHost = false,
				IsClient = false,
				IsConnecting = Networking.IsConnecting,
				LocalConnectionId = null,
				HostConnectionId = null,
				Connections = Array.Empty<ConnectionState>()
			};
		}

		var local = Connection.Local;
		var host = Connection.Host;
		var localId = local?.Id;
		var hostId = host?.Id;
		var connections = Connection.All
			.Where( connection => connection is not null )
			.Select( connection => new ConnectionState
			{
				Id = connection.Id,
				DisplayName = SanitizeDisplayName( connection.DisplayName ),
				IsHost = hostId.HasValue && connection.Id == hostId.Value,
				IsLocal = localId.HasValue && connection.Id == localId.Value,
				IsActive = connection.IsActive,
				IsConnecting = connection.IsConnecting,
				Ping = connection.Ping
			})
			.ToArray();

		return new NetworkState
		{
			IsActive = true,
			IsHost = Networking.IsHost,
			IsClient = Networking.IsClient,
			IsConnecting = Networking.IsConnecting,
			LocalConnectionId = localId,
			HostConnectionId = hostId,
			Connections = connections
		};
	}

	private static Connection[] GetRemoteConnections()
	{
		var localId = Connection.Local?.Id;
		return Connection.All
			.Where( connection => connection is not null && (!localId.HasValue || connection.Id != localId.Value) )
			.ToArray();
	}

	private static bool IsSyntheticIdentityVisible( ulong identity )
	{
		return Connection.All.Any( connection => connection is not null && connection.SteamId.ValueUnsigned == identity );
	}

	private static string SanitizeDisplayName( string value )
	{
		if ( string.IsNullOrEmpty( value ) ) return string.Empty;
		var clean = new StringBuilder( Math.Min( value.Length, 512 ) );
		foreach ( var character in value )
		{
			if ( clean.Length == 512 ) break;
			if ( !char.IsControl( character ) ) clean.Append( character );
		}

		var result = Steam2Pattern.Replace( clean.ToString(), "[redacted]" );
		result = Steam3Pattern.Replace( result, "[redacted]" );
		result = Steam64Pattern.Replace( result, "[redacted]" );
		return result.Length <= 128 ? result : result[..128];
	}

	private static InstanceState MapInstance( OwnedInstanceSnapshot snapshot )
	{
		return new InstanceState
		{
			ProcessId = snapshot.ProcessId,
			LaunchedAt = snapshot.LaunchedAt,
			Windowed = snapshot.Windowed,
			LogDirectory = snapshot.LogDirectory,
			Alive = snapshot.Alive,
			Exited = snapshot.Exited,
			ActiveProcessCount = snapshot.ActiveProcessCount
		};
	}

	private static Exception TranslateLaunchFailure( OwnedInstanceException exception )
	{
		var process = exception.ProcessId > 0 ? $" PID {exception.ProcessId}." : string.Empty;
		var native = exception.NativeError > 0 ? $" Native error {exception.NativeError}." : string.Empty;
		return new Exception( exception.Message + process + native );
	}

}

internal sealed class NetworkToolLifetime : IHotloadManaged
{
	internal const string BusyMessage = "Another network operation is in progress; wait for it to finish.";
	internal const string InvalidatedMessage = "The network tools were invalidated by a library hotload; retry the operation.";

	private OwnedInstanceRegistry _registry = new();
	private CancellationTokenSource _cancellation = new();
	private long _busy;
	private int _valid = 1;

	// Null only after invalidation released the handles; callers report the fixed invalidation error.
	internal OwnedInstanceRegistry Registry
	{
		get
		{
			var registry = Volatile.Read( ref _registry );
			if ( registry is null ) throw new Exception( NetworkToolLifetime.InvalidatedMessage );
			return registry;
		}
	}

	internal CancellationToken CancellationToken => _cancellation.Token;
	internal bool IsBusy => Volatile.Read( ref _busy ) != 0;
	internal bool IsValid => Volatile.Read( ref _valid ) != 0;

	internal IDisposable EnterMutation()
	{
		ThrowIfInvalid();
		if ( Interlocked.CompareExchange( ref _busy, 1, 0 ) != 0 )
			throw new Exception( BusyMessage );
		if ( !IsValid )
		{
			Volatile.Write( ref _busy, 0 );
			throw new Exception( NetworkToolLifetime.InvalidatedMessage );
		}
		return new MutationLease( this );
	}

	internal void ThrowIfInvalid()
	{
		if ( !IsValid ) throw new Exception( NetworkToolLifetime.InvalidatedMessage );
	}

	public void Destroyed( Dictionary<string, object> state ) => Invalidate();

	// Hotload state is deliberately not transferred: surviving children cease to be owned.

	public void Created( IReadOnlyDictionary<string, object> state )
	{
		_registry = new OwnedInstanceRegistry();
		_cancellation = new CancellationTokenSource();
		Volatile.Write( ref _busy, 0 );
		Volatile.Write( ref _valid, 1 );
	}

	public void Persisted() { }
	public void Failed() => Invalidate();

	private void Invalidate()
	{
		Volatile.Write( ref _valid, 0 );
		_cancellation.Cancel();

		// No new mutation can start once invalid, so the only reason to wait is an operation
		// still holding retained handles. Closing them never terminates a process.
		ReleaseHandlesWhenIdle();
	}

	private void ReleaseHandlesWhenIdle()
	{
		if ( Volatile.Read( ref _busy ) != 0 ) return;
		Interlocked.Exchange( ref _registry, null )?.Dispose();
	}

	private sealed class MutationLease : IDisposable
	{
		private NetworkToolLifetime _owner;
		internal MutationLease( NetworkToolLifetime owner ) => _owner = owner;
		public void Dispose()
		{
			var owner = Interlocked.Exchange( ref _owner, null );
			if ( owner is null ) return;
			Volatile.Write( ref owner._busy, 0 );
			if ( !owner.IsValid ) owner.ReleaseHandlesWhenIdle();
		}
	}
}
kitsupanic.sbox_mcp_plus / Editor/McpExtras.ModelImport.cs
Editor library
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Text.Json;
using System.Threading.Tasks;

namespace Editor.Mcp;

public static partial class ExtrasTools
{
	private static ModelImportLifetime ModelImportLifetime = new();
	private static readonly IModelImportBackend ImportBackend = new SandboxModelImportBackend();
	private static readonly ModelImportBackendPipeline ImportPipeline = new( ImportBackend );

	/// <summary>
	/// Import a new external FBX, OBJ or DMX source into an isolated directory under the active
	/// project's Assets root. Native model creation and compilation are synchronous and can block
	/// the editor without a hard deadline. Returned evidence distinguishes observed asset state
	/// from unavailable operation-completion, dependency-coverage and writer-shutdown evidence.
	/// </summary>
	/// <param name="sourcePath">Absolute readable path to one .fbx, .obj or .dmx source.</param>
	/// <param name="targetDirectory">New final directory relative to Assets; no implicit subdirectory is appended.</param>
	/// <param name="modelName">Generated .vmdl stem only. Null or empty defaults to the source stem.</param>
	/// <param name="overwrite">Reserved. True always returns OverwriteUnsupported before mutation.</param>
	/// <param name="copySiblingTextures">Copy every allowlisted immediate sibling image; false does not enumerate siblings.</param>
	[McpTool( "x_import_model_source" )]
	public static async Task<ImportModelResult> ImportModelSource( string sourcePath, string targetDirectory,
		string modelName = "", bool overwrite = false, bool copySiblingTextures = true )
	{
		var result = new ImportModelResult();
		ModelImportTransaction transaction = null;
		var lease = ModelImportLifetime.TryEnter();
		if ( lease is null ) return BusyResult( result );

		try
		{
			if ( overwrite ) return result.Fail( "OverwriteUnsupported", "overwrite=true is unsupported in version 1." );
			var project = Project.Current;
			if ( project is null ) return result.Fail( "NoActiveProject", "No active project is available." );
			if ( Game.IsPlaying ) return result.Fail( "PlayMode", "Model import is unavailable while play mode is running." );
			var projectIdent = project.Config?.Ident;
			var assetsRoot = project.GetAssetsPath();
			ModelImportLifetime.ScanMarkers( assetsRoot );

			transaction = ModelImportTransaction.Plan( assetsRoot, sourcePath, targetDirectory, modelName, copySiblingTextures );
			result.SourceAsset = transaction.SourceAsset;
			result.ModelAsset = transaction.ModelAsset;
			if ( ModelImportLifetime.IsOwnedDestination( transaction.RelativeDirectory ) )
				throw new ImportContractException( "DestinationExists", "The destination is nested beneath an earlier import-owned directory." );
			EnsureOutputsAbsent( transaction );
			EnsureProjectState( project, projectIdent );

			result.Stage = "copy";
			transaction.ReserveDirectory();
			result.CopiedFiles.AddRange( transaction.CopyInputs() );
			transaction.WriteMarker( "not_started" );
			result.GeneratedFiles.Add( transaction.MarkerAsset );

			result.Stage = "registration";
			EnsureProjectState( project, projectIdent );
			result.WriterState = "unconfirmed";
			transaction.WriteMarker( "unconfirmed" );
			transaction.PrepareForEngineMutation();
			transaction.EngineMutationAttempted = true;
			ModelImportLifetime.RecordHistorical( transaction.PendingPaths( result.GeneratedFiles ) );

			foreach ( var copy in transaction.Copies.Where( x => !string.Equals( x.DestinationAsset, transaction.SourceAsset, StringComparison.OrdinalIgnoreCase ) ) )
				ImportPipeline.RegisterDependency( copy.DestinationAbsolute, copy.DestinationAsset );
			var sourceCopy = transaction.Copies.Single( x => string.Equals( x.DestinationAsset, transaction.SourceAsset, StringComparison.OrdinalIgnoreCase ) );
			var sourceAsset = ImportPipeline.RegisterSource( sourceCopy.DestinationAbsolute, transaction.SourceAsset );
			result.SourceRegistered = true;

			result.Stage = "model_creation";
			EnsureProjectState( project, projectIdent );
			var modelAbsolute = Path.Combine( transaction.AssetsRoot, transaction.ModelAsset.Replace( '/', Path.DirectorySeparatorChar ) );
			object modelAsset;
			try { modelAsset = ImportPipeline.CreateModel( sourceAsset, modelAbsolute, transaction.ModelAsset ); }
			finally { TryObserveGeneratedFiles( transaction, result ); }
			result.ModelRegistered = true;

			result.Stage = "compilation";
			var compilation = await ImportPipeline.ObserveCompilationAsync( modelAsset );
			CopyCompilationEvidence( compilation, result.Compilation );
			EnsureProjectState( project, projectIdent );

			result.Stage = "dependency_inspection";
			var dependencies = ImportPipeline.InspectDependencies( sourceAsset, modelAsset );
			CopyDependencyEvidence( dependencies, result );

			result.Succeeded = true;
			result.Stage = "complete";
			result.Error = null;
			result.RollbackStatus = "not_needed";
			ApplyPendingState( transaction, result );
			return result;
		}
		catch ( BackendPipelineException ex )
		{
			if ( ex.Evidence is BackendCompilationEvidence compilation ) CopyCompilationEvidence( compilation, result.Compilation );
			if ( ex.Evidence is BackendDependencyEvidence dependencies ) CopyDependencyEvidence( dependencies, result );
			result.Fail( ex.Code, ex.Message );
		}
		catch ( ImportContractException ex )
		{
			result.Fail( ex.Code, ex.Message );
		}
		catch ( Exception ex )
		{
			result.Fail( CodeForStage( result.Stage ), Safe( ex.Message, 1024 ) );
		}
		finally
		{
			try
			{
				if ( transaction is not null ) result.CopiedFiles = transaction.CompletedCopies.ToList();
				if ( transaction?.EngineMutationAttempted == true )
				{
					ApplyPendingState( transaction, result );
					transaction.Dispose();
				}
				else
				{
					var remaining = transaction?.CleanupPreEngine() ?? [];
					if ( remaining.Count > 0 )
					{
						result.RollbackStatus = "incomplete";
						result.PartialPaths.AddRange( remaining );
					}
					else if ( transaction is not null ) result.RollbackStatus = "complete";
				}
			}
			catch ( Exception ex )
			{
				result.RollbackStatus = "incomplete";
				AddWarning( result, $"Final import accounting was incomplete: {Safe( ex.Message, 180 )}" );
			}
			finally
			{
				result.FurtherImportsBlocked = false;
				lease.Dispose();
			}
		}
		return result;
	}

	private static void ApplyPendingState( ModelImportTransaction transaction, ImportModelResult result )
	{
		result.WriterState = "unconfirmed";
		result.FurtherImportsBlocked = false;
		result.PendingWriterPaths = transaction.PendingPaths( result.GeneratedFiles ).ToList();
		ModelImportLifetime.RecordHistorical( result.PendingWriterPaths );
		TryObserveGeneratedFiles( transaction, result );
		result.PendingWriterPaths = transaction.PendingPaths( result.GeneratedFiles ).ToList();
		ModelImportLifetime.RecordHistorical( result.PendingWriterPaths );
		if ( !result.Succeeded )
		{
			result.RollbackStatus = "incomplete";
			result.PartialPaths = result.PendingWriterPaths.ToList();
		}
	}

	private static void EnsureOutputsAbsent( ModelImportTransaction transaction )
	{
		foreach ( var copy in transaction.Copies )
			if ( File.Exists( copy.DestinationAbsolute ) || AssetSystem.FindByPath( copy.DestinationAsset ) is not null )
				throw new ImportContractException( "DestinationExists", $"Planned output '{copy.DestinationAsset}' already exists." );
		if ( AssetSystem.FindByPath( transaction.ModelAsset ) is not null )
			throw new ImportContractException( "DestinationExists", $"Planned output '{transaction.ModelAsset}' is already registered." );
		var prefix = transaction.RelativeDirectory.TrimEnd( '/' ) + "/";
		if ( AssetSystem.All.Any( asset => asset?.Path is string path &&
			path.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) ) )
			throw new ImportContractException( "DestinationExists", "The destination contains registered asset evidence." );
		var ancestor = transaction.RelativeDirectory;
		while ( ancestor.Contains( '/' ) )
		{
			ancestor = ancestor[..ancestor.LastIndexOf( '/' )];
			var ancestorPrefix = ancestor + "/";
			var direct = AssetSystem.All
				.Select( asset => asset?.Path?.Replace( '\\', '/' ) )
				.Where( path => path is not null && path.StartsWith( ancestorPrefix, StringComparison.OrdinalIgnoreCase ) &&
					!path[ancestorPrefix.Length..].Contains( '/' ) )
				.ToArray();
			var modelCount = direct.Count( path => Path.GetExtension( path ).Equals( ".vmdl", StringComparison.OrdinalIgnoreCase ) );
			var sourceCount = direct.Count( path => new[] { ".fbx", ".obj", ".dmx" }.Contains( Path.GetExtension( path ), StringComparer.OrdinalIgnoreCase ) );
			// A v1 import boundary owns exactly one source and one generated model. Directories
			// containing several independent pairs are collection roots, not isolated imports.
			if ( modelCount == 1 && sourceCount == 1 )
				throw new ImportContractException( "DestinationExists", "A destination ancestor contains registered source/model import evidence." );
		}
	}
	private static void EnsureProjectState( Project project, string ident )
	{
		if ( Project.Current is null || !ReferenceEquals( Project.Current, project ) || !string.Equals( Project.Current.Config?.Ident, ident, StringComparison.Ordinal ) )
			throw new ImportContractException( "NoActiveProject", "The active project changed during import." );
		if ( Game.IsPlaying ) throw new ImportContractException( "PlayMode", "Play mode started during import." );
	}

	private static void TryObserveGeneratedFiles( ModelImportTransaction transaction, ImportModelResult result )
	{
		try
		{
			if ( !Directory.Exists( transaction.FinalDirectory ) ) return;
			foreach ( var file in Directory.EnumerateFiles( transaction.FinalDirectory, "*", SearchOption.TopDirectoryOnly ) )
			{
				if ( (File.GetAttributes( file ) & FileAttributes.ReparsePoint) != 0 ) continue;
				var path = transaction.ToReportedPath( file );
				if ( result.CopiedFiles.Contains( path, StringComparer.OrdinalIgnoreCase ) ) continue;
				if ( !result.GeneratedFiles.Contains( path, StringComparer.OrdinalIgnoreCase ) ) result.GeneratedFiles.Add( path );
				if ( !transaction.OwnedFiles.Contains( file, StringComparer.OrdinalIgnoreCase ) ) transaction.OwnedFiles.Add( file );
			}
			result.GeneratedFiles.Sort( StringComparer.OrdinalIgnoreCase );
		}
		catch ( Exception ex ) { AddWarning( result, $"Generated output attribution was incomplete: {Safe( ex.Message, 180 )}" ); }
	}

	private static void AddWarning( ImportModelResult result, string warning )
	{
		if ( result.Warnings.Count < 16 ) result.Warnings.Add( Safe( warning, 256 ) );
	}


	private static void CopyCompilationEvidence( BackendCompilationEvidence source, CompilationEvidence target )
	{
		target.Status = source.Status;
		target.IsCompiled = source.IsCompiled;
		target.IsCompiledAndUpToDate = source.IsCompiledAndUpToDate;
		target.IsCompileFailed = source.IsCompileFailed;
		target.UnavailableEvidence = new Dictionary<string, string>( source.UnavailableEvidence, StringComparer.Ordinal );
	}

	private static void CopyDependencyEvidence( BackendDependencyEvidence source, ImportModelResult target )
	{
		target.DependencyInspection.Status = source.Status;
		target.DependencyInspection.InspectedAssets = source.InspectedAssets.ToList();
		target.DependencyInspection.References = source.References.ToList();
		target.DependencyInspection.InputDependencies = source.InputDependencies.ToList();
		target.DependencyInspection.AdditionalContentFiles = source.AdditionalContentFiles.ToList();
		target.DependencyInspection.UnavailableEvidence = new Dictionary<string, string>( source.UnavailableEvidence, StringComparer.Ordinal );
		target.UnresolvedReferences = source.UnresolvedReferences.ToList();
	}

	private static ImportModelResult BusyResult( ImportModelResult result )
	{
		result.FurtherImportsBlocked = true;
		return result.Fail( "ImportBusy", "Another model import request is currently executing." );
	}

	private static string CodeForStage( string stage ) => stage switch
	{
		"copy" => "CopyFailed", "registration" => "RegistrationFailed", "model_creation" => "ModelCreationFailed",
		"compilation" => "CompilationUnconfirmed", "dependency_inspection" => "DependencyInspectionFailed", _ => "InvalidInput"
	};
	internal static string Safe( string value, int maximum )
	{
		var text = string.IsNullOrWhiteSpace( value ) ? "The operation failed without a message." : value.Replace( '\r', ' ' ).Replace( '\n', ' ' );
		return text.Length <= maximum ? text : text[..maximum];
	}
}

public sealed class ImportModelResult
{
	public bool Succeeded { get; set; }
	public string Stage { get; set; } = "validation";
	public string SourceAsset { get; set; }
	public string ModelAsset { get; set; }
	public List<string> CopiedFiles { get; set; } = [];
	public List<string> GeneratedFiles { get; set; } = [];
	public bool SourceRegistered { get; set; }
	public bool ModelRegistered { get; set; }
	public CompilationEvidence Compilation { get; set; } = new();
	public DependencyInspectionEvidence DependencyInspection { get; set; } = new();
	public string WriterState { get; set; } = "not_started";
	public bool FurtherImportsBlocked { get; set; }
	public List<string> PendingWriterPaths { get; set; } = [];
	public List<string> UnresolvedReferences { get; set; } = [];
	public List<string> Warnings { get; set; } = [];
	public ImportModelError Error { get; set; }
	public string RollbackStatus { get; set; } = "not_needed";
	public List<string> PartialPaths { get; set; } = [];
	internal ImportModelResult Fail( string code, string message )
	{
		Succeeded = false; Error = new() { Code = code, Message = ExtrasTools.Safe( message, 1024 ) }; return this;
	}
}

public sealed class CompilationEvidence
{
	public string Status { get; set; } = "not_started";
	public bool? IsCompiled { get; set; }
	public bool? IsCompiledAndUpToDate { get; set; }
	public bool? IsCompileFailed { get; set; }
	public Dictionary<string, string> UnavailableEvidence { get; set; } = new( StringComparer.Ordinal );
}

public sealed class DependencyInspectionEvidence
{
	public string Status { get; set; } = "not_started";
	public List<string> InspectedAssets { get; set; } = [];
	public List<string> References { get; set; } = [];
	public List<string> InputDependencies { get; set; } = [];
	public List<string> AdditionalContentFiles { get; set; } = [];
	public Dictionary<string, string> UnavailableEvidence { get; set; } = new( StringComparer.Ordinal );
}

public sealed class ImportModelError
{
	public string Code { get; set; }
	public string Message { get; set; }
}

internal sealed class SandboxModelImportBackend : IModelImportBackend
{
	public object RegisterFile( string absolutePath ) => AssetSystem.RegisterFile( absolutePath );
	public object FindAsset( string assetPath ) => AssetSystem.FindByPath( assetPath );
	public string GetAssetPath( object asset ) => (asset as Asset)?.Path;
	public object CreateModel( object sourceAsset, string absoluteModelPath ) =>
		EditorUtility.CreateModelFromMeshFile( (Asset)sourceAsset, absoluteModelPath );
	public bool ReadIsCompiled( object asset ) => ((Asset)asset).IsCompiled;
	public bool ReadIsCompiledAndUpToDate( object asset ) => ((Asset)asset).IsCompiledAndUpToDate;
	public bool ReadIsCompileFailed( object asset ) => ((Asset)asset).IsCompileFailed;
	public async ValueTask ObserveCompilationIfNeededAsync( object asset ) => await ((Asset)asset).CompileIfNeededAsync( 30.0f );
	public IReadOnlyList<object> GetReferences( object asset ) => ((Asset)asset).GetReferences( false ).Cast<object>().ToArray();
	public IReadOnlyList<string> GetUnrecognizedReferences( object asset ) => ((Asset)asset).GetUnrecognizedReferencePaths();
	public IReadOnlyList<string> GetInputDependencies( object asset ) => ((Asset)asset).GetInputDependencies();
	public IReadOnlyList<string> GetAdditionalContentFiles( object asset ) => ((Asset)asset).GetAdditionalContentFiles();
}

internal sealed class ModelImportLifetime : IHotloadManaged
{
	private ModelImportRequestGate _gate = new();
	private int _valid = 1;
	private List<string> _pending = [];
	private List<string> _ownedDirectories = [];
	internal IReadOnlyList<string> PendingPaths => _pending;
	internal string BusyReason => "Another model import request is currently executing.";

	internal Lease TryEnter()
	{
		if ( Volatile.Read( ref _valid ) == 0 ) return null;
		var lease = _gate.TryEnter();
		return lease is null ? null : new Lease( lease );
	}
	internal void RecordHistorical( IEnumerable<string> paths )
	{
		_pending = _pending.Concat( paths ).Distinct( StringComparer.OrdinalIgnoreCase )
			.OrderBy( x => x, StringComparer.OrdinalIgnoreCase ).ToList();
	}
	internal void ScanMarkers( string assetsRoot )
	{
		var foundPending = new List<string>();
		var foundOwned = new List<string>();
		try
		{
			var stack = new Stack<string>(); stack.Push( assetsRoot );
			while ( stack.Count > 0 )
			{
				var directory = stack.Pop();
				foreach ( var child in Directory.EnumerateDirectories( directory ) )
					if ( (File.GetAttributes( child ) & FileAttributes.ReparsePoint) == 0 ) stack.Push( child );
				var marker = Path.Combine( directory, ModelImportTransaction.MarkerName );
				if ( !File.Exists( marker ) ) continue;
				var relative = Path.GetRelativePath( assetsRoot, directory ).Replace( '\\', '/' );
				foundOwned.Add( relative );
				var blocks = true;
				try
				{
					using var document = JsonDocument.Parse( File.ReadAllText( marker ) );
					var root = document.RootElement;
					blocks = !root.TryGetProperty( "Version", out var version ) || version.GetInt32() != 1 ||
						!root.TryGetProperty( "TransactionId", out var transactionId ) || !Guid.TryParse( transactionId.GetString(), out _ ) ||
						!root.TryGetProperty( "SourceAsset", out var sourceAsset ) || string.IsNullOrWhiteSpace( sourceAsset.GetString() ) ||
						!root.TryGetProperty( "ModelAsset", out var modelAsset ) || string.IsNullOrWhiteSpace( modelAsset.GetString() ) ||
						!root.TryGetProperty( "WriterState", out var writerState ) ||
						!string.Equals( writerState.GetString(), "stopped", StringComparison.Ordinal );
				}
				catch { blocks = true; }
				if ( blocks ) foundPending.Add( relative );
			}
			_ownedDirectories = foundOwned;
			_pending = foundPending;
		}
		catch ( Exception ex )
		{
			_ownedDirectories = [];
			_pending = [];
			throw new ImportContractException( "ImportBusy", $"Import ownership markers could not be scanned safely: {ExtrasTools.Safe( ex.Message, 700 )}" );
		}
	}

	internal bool IsOwnedDestination( string relativeDirectory ) => _ownedDirectories.Any( owned =>
		relativeDirectory.Equals( owned, StringComparison.OrdinalIgnoreCase ) ||
		relativeDirectory.StartsWith( owned.TrimEnd( '/' ) + "/", StringComparison.OrdinalIgnoreCase ) ||
		owned.StartsWith( relativeDirectory.TrimEnd( '/' ) + "/", StringComparison.OrdinalIgnoreCase ) );
	public void Destroyed( Dictionary<string, object> state )
	{
		ModelImportHotloadTransfer.Write( state, _gate, _pending, _ownedDirectories );
		Volatile.Write( ref _valid, 0 );
	}
	public void Created( IReadOnlyDictionary<string, object> state )
	{
		try
		{
			// The only legacy retained gate in this session belongs to the giant-lid invocation,
			// whose MCP call returned before this contract migration began.
			var snapshot = ModelImportHotloadTransfer.Read( state, legacyInvocationFinished: true );
			_pending = snapshot.PendingPaths.ToList();
			_ownedDirectories = snapshot.OwnedDirectories.ToList();
			_gate ??= new ModelImportRequestGate();
			_gate.Restore( snapshot.ActiveRequest );
			Volatile.Write( ref _valid, 1 );
		}
		catch
		{
			_gate.Restore( true );
			Volatile.Write( ref _valid, 0 );
		}
	}

	public void Persisted() { }
	public void Failed()
	{
		_gate.Restore( true );
		Volatile.Write( ref _valid, 0 );
	}

	internal sealed class Lease : IDisposable
	{
		private ModelImportRequestGate.Lease _lease;
		internal Lease( ModelImportRequestGate.Lease lease ) => _lease = lease;
		public void Dispose()
		{
			var lease = Interlocked.Exchange( ref _lease, null );
			lease?.Dispose();
		}
	}
}
kitsupanic.sbox_mcp_plus / Tests/Program.cs
UnitTest library
using Editor.Mcp;

var checks = new List<(string Name, Action Run)>
{
	("selects exact immediate allowlist", SelectsAllowlist),
	("false skips sibling enumeration", SkipsSiblings),
	("refuses 129 sibling images", RefusesImageOverflow),
	("accepts exact byte boundary", AcceptsExactByteBoundary),
	("refuses byte overflow", RefusesByteOverflow),
	("rejects unsafe targets and names", RejectsUnsafeInputs),
	("exclusive reservation preserves sentinel", PreservesSentinel),
	("copy race preserves foreign file", PreservesRacedCopy),
	("marker race preserves foreign marker", PreservesRacedMarker),
	("pre-engine cleanup retains owned directories safely", RetainsOwnedDirectories),
	("request gate serializes and releases", RequestGateSerializesAndReleases),
	("post-engine completion releases request gate", PostEngineCompletionReleasesGate),
	("registration failure is staged", RegistrationFailureIsStaged),
	("compilation fault preserves evidence", CompilationFaultPreservesEvidence),
	("dependency fault preserves earlier evidence", DependencyFaultPreservesEvidence),
	("legacy retained state migrates without active lock", LegacyStateMigrates),
	("versioned active request survives hotload", ActiveRequestSurvivesHotload),
	("malformed hotload state fails closed", MalformedHotloadFails),
	("post-engine transaction retains outputs", PostEngineTransactionRetainsOutputs),
	("active lease releases shared hotload gate", ActiveLeaseReleasesSharedGate),
	("thrown dependency path preserves evidence", ThrownDependencyPathPreservesEvidence),
	("missing dependency path fails inspection", MissingDependencyPathFailsInspection)
};
var failures = new List<string>();
foreach ( var check in checks )
{
	try { check.Run(); Console.WriteLine( $"PASS {check.Name}" ); }
	catch ( Exception ex ) { failures.Add( $"FAIL {check.Name}: {ex.Message}" ); }
}
foreach ( var failure in failures ) Console.Error.WriteLine( failure );
return failures.Count == 0 ? 0 : 1;

static void SelectsAllowlist()
{
	using var root = Fixture();
	File.WriteAllText( Path.Combine( root.Source, "mesh.OBJ" ), "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3" );
	File.WriteAllText( Path.Combine( root.Source, "shot.PNG" ), "x" );
	File.WriteAllText( Path.Combine( root.Source, "mesh.mtl" ), "ignored" );
	Directory.CreateDirectory( Path.Combine( root.Source, "nested" ) );
	File.WriteAllText( Path.Combine( root.Source, "nested", "nested.png" ), "ignored" );
	var plan = ModelImportTransaction.Plan( root.Assets, Path.Combine( root.Source, "mesh.OBJ" ), "models/test", "", true );
	Equal( new[] { "models/test/mesh.OBJ", "models/test/shot.PNG" }, plan.Copies.Select( x => x.DestinationAsset ).ToArray() );
}

static void SkipsSiblings()
{
	using var root = Fixture();
	var source = Path.Combine( root.Source, "mesh.fbx" ); File.WriteAllText( source, "mesh" );
	File.WriteAllText( Path.Combine( root.Source, "image.png" ), "x" );
	var plan = ModelImportTransaction.Plan( root.Assets, source, "models/solo", null, false );
	Equal( new[] { "models/solo/mesh.fbx" }, plan.Copies.Select( x => x.DestinationAsset ).ToArray() );
}

static void RefusesImageOverflow()
{
	using var root = Fixture();
	var source = Path.Combine( root.Source, "mesh.dmx" ); File.WriteAllText( source, "mesh" );
	for ( var i = 0; i < 129; ++i ) File.WriteAllText( Path.Combine( root.Source, $"{i:D3}.png" ), "" );
	Throws( "LimitExceeded", () => ModelImportTransaction.Plan( root.Assets, source, "models/count", "", true ) );
}

static void AcceptsExactByteBoundary()
{
	using var root = Fixture();
	var source = Path.Combine( root.Source, "mesh.fbx" );
	using ( var stream = File.Create( source ) ) stream.SetLength( ModelImportTransaction.MaximumBytes );
	var plan = ModelImportTransaction.Plan( root.Assets, source, "models/exact", "", false );
	Assert( plan.Copies.Single().PreflightLength == ModelImportTransaction.MaximumBytes, "exact byte limit was not accepted" );
}

static void RefusesByteOverflow()
{
	using var root = Fixture();
	var source = Path.Combine( root.Source, "mesh.fbx" );
	using ( var stream = File.Create( source ) ) stream.SetLength( ModelImportTransaction.MaximumBytes + 1 );
	Throws( "LimitExceeded", () => ModelImportTransaction.Plan( root.Assets, source, "models/over", "", false ) );
}

static void RejectsUnsafeInputs()
{
	using var root = Fixture();
	var source = Path.Combine( root.Source, "mesh.fbx" ); File.WriteAllText( source, "mesh" );
	foreach ( var target in new[] { "../escape", "Assets/models/x", "models//x", "C:\\outside", "/outside" } )
		Throws( "InvalidInput", () => ModelImportTransaction.Plan( root.Assets, source, target, "", false ) );
	foreach ( var name in new[] { "CON", "bad.vmdl", " padded", "trailing." } )
		Throws( "InvalidInput", () => ModelImportTransaction.Plan( root.Assets, source, "models/name", name, false ) );
}

static void PreservesSentinel()
{
	using var root = Fixture();
	var source = Path.Combine( root.Source, "mesh.obj" ); File.WriteAllText( source, "mesh" );
	var occupied = Path.Combine( root.Assets, "models", "occupied" ); Directory.CreateDirectory( occupied );
	var sentinel = Path.Combine( occupied, "sentinel.txt" ); File.WriteAllText( sentinel, "keep" );
	Throws( "DestinationExists", () => ModelImportTransaction.Plan( root.Assets, source, "models/occupied", "", false ) );
	Assert( File.ReadAllText( sentinel ) == "keep", "sentinel changed" );
}

static void PreservesRacedCopy()
{
	using var root = Fixture();
	var source = Path.Combine( root.Source, "mesh.obj" ); File.WriteAllText( source, "source" );
	var plan = ModelImportTransaction.Plan( root.Assets, source, "models/raced-copy", "", false );
	plan.ReserveDirectory();
	var destination = plan.Copies.Single().DestinationAbsolute;
	File.WriteAllText( destination, "foreign" );
	Throws( "CopyFailed", () => plan.CopyInputs() );
	var remaining = plan.CleanupPreEngine();
	Assert( File.ReadAllText( destination ) == "foreign", "foreign raced file was deleted" );
	Assert( remaining.Contains( "models/raced-copy", StringComparer.OrdinalIgnoreCase ), "occupied owned directory was not reported" );
}

static void PreservesRacedMarker()
{
	using var root = Fixture();
	var source = Path.Combine( root.Source, "mesh.obj" ); File.WriteAllText( source, "source" );
	var plan = ModelImportTransaction.Plan( root.Assets, source, "models/raced-marker", "", false );
	plan.ReserveDirectory(); plan.CopyInputs();
	var marker = Path.Combine( plan.FinalDirectory, ModelImportTransaction.MarkerName );
	File.WriteAllText( marker, "foreign" );
	try { plan.WriteMarker( "not_started" ); } catch ( IOException ) { }
	var remaining = plan.CleanupPreEngine();
	Assert( File.ReadAllText( marker ) == "foreign", "foreign marker was overwritten or deleted" );
	Assert( remaining.Contains( "models/raced-marker", StringComparer.OrdinalIgnoreCase ), "occupied marker directory was not reported" );
}

static void RetainsOwnedDirectories()
{
	using var root = Fixture();
	var source = Path.Combine( root.Source, "mesh.obj" ); File.WriteAllText( source, "mesh" );
	var plan = ModelImportTransaction.Plan( root.Assets, source, "models/cleanup", "", false );
	plan.ReserveDirectory(); plan.CopyInputs(); plan.WriteMarker( "not_started" );
	var remaining = plan.CleanupPreEngine();
	Assert( remaining.Contains( "models/cleanup", StringComparer.OrdinalIgnoreCase ), "retained final directory was not reported" );
	Assert( !File.Exists( Path.Combine( plan.FinalDirectory, "mesh.obj" ) ), "owned copied file remains" );
	Assert( !File.Exists( Path.Combine( plan.FinalDirectory, ModelImportTransaction.MarkerName ) ), "owned marker remains" );
}

static void RequestGateSerializesAndReleases()
{
	var gate = new ModelImportRequestGate();
	using var first = gate.TryEnter();
	Assert( first is not null && gate.IsActive, "first request did not acquire the gate" );
	Assert( gate.TryEnter() is null, "concurrent request was admitted" );
	first.Dispose();
	using var second = gate.TryEnter();
	Assert( second is not null, "gate did not release after invocation completion" );
}

static void PostEngineCompletionReleasesGate()
{
	var gate = new ModelImportRequestGate();
	var lease = gate.TryEnter();
	var writerState = "unconfirmed";
	var pending = new[] { "models/a", "models/a/a.vmdl" };
	lease.Dispose();
	Assert( !gate.IsActive, "unconfirmed writer evidence retained the request gate" );
	Assert( writerState == "unconfirmed" && pending.Length == 2, "retained evidence changed when the request released" );
}

static void RegistrationFailureIsStaged()
{
	var backend = new FakeBackend { ThrowRegister = true };
	var pipeline = new ModelImportBackendPipeline( backend );
	Throws( "RegistrationFailed", () => pipeline.RegisterSource( "source.fbx", "models/a/source.fbx" ) );
}

static void CompilationFaultPreservesEvidence()
{
	var backend = new FakeBackend { IsCompiled = false, IsUpToDate = false, IsFailed = false, ThrowCompileObservation = true };
	var pipeline = new ModelImportBackendPipeline( backend );
	try { pipeline.ObserveCompilationAsync( backend.Model ).AsTask().GetAwaiter().GetResult(); }
	catch ( BackendPipelineException ex )
	{
		var evidence = (BackendCompilationEvidence)ex.Evidence;
		Assert( ex.Code == "CompilationUnconfirmed", "wrong compilation fault code" );
		Assert( evidence.Status == "observed" && evidence.IsCompiled == false && evidence.IsCompileFailed == false, "compile evidence was discarded" );
		return;
	}
	throw new Exception( "expected compilation fault" );
}

static void DependencyFaultPreservesEvidence()
{
	var backend = new FakeBackend { ThrowInputDependencies = true };
	backend.References[backend.Source] = [backend.Material];
	var pipeline = new ModelImportBackendPipeline( backend );
	try { pipeline.InspectDependencies( backend.Source, backend.Model ); }
	catch ( BackendPipelineException ex )
	{
		var evidence = (BackendDependencyEvidence)ex.Evidence;
		Assert( ex.Code == "DependencyInspectionFailed", "wrong dependency fault code" );
		Assert( evidence.References.Contains( "materials/test.vmat" ), "successful reference evidence was discarded" );
		Assert( evidence.UnavailableEvidence.Keys.Any( x => x.StartsWith( "InputDependencies:", StringComparison.Ordinal ) ), "failed query was not identified" );
		return;
	}
	throw new Exception( "expected dependency inspection fault" );
}

static void LegacyStateMigrates()
{
	var state = new Dictionary<string, object>
	{
		["ModelImportBusy"] = true,
		["ModelImportPending"] = new[] { "models/kampai/giant-lid" },
		["ModelImportOwnedDirectories"] = new[] { "models/kampai/giant-lid" }
	};
	var snapshot = ModelImportHotloadTransfer.Read( state, legacyInvocationFinished: true );
	Assert( !snapshot.ActiveRequest, "completed legacy invocation retained the revised request lock" );
	Assert( snapshot.PendingPaths.Single() == "models/kampai/giant-lid", "legacy pending evidence was lost" );
}

static void ActiveRequestSurvivesHotload()
{
	var gate = new ModelImportRequestGate();
	using var lease = gate.TryEnter();
	var state = new Dictionary<string, object>();
	ModelImportHotloadTransfer.Write( state, gate, new[] { "models/a" }, new[] { "models/a" } );
	var snapshot = ModelImportHotloadTransfer.Read( state, legacyInvocationFinished: false );
	Assert( snapshot.Version == 4 && snapshot.ActiveRequest, "versioned active request was unlocked" );
}

static void MalformedHotloadFails()
{
	try { ModelImportHotloadTransfer.Read( new Dictionary<string, object>(), legacyInvocationFinished: false ); }
	catch ( ImportContractException ex ) when ( ex.Code == "ImportBusy" ) { return; }
	throw new Exception( "malformed hotload state did not fail closed" );
}

static void PostEngineTransactionRetainsOutputs()
{
	using var root = Fixture();
	var source = Path.Combine( root.Source, "mesh.obj" ); File.WriteAllText( source, "mesh" );
	var plan = ModelImportTransaction.Plan( root.Assets, source, "models/retained", "", false );
	plan.ReserveDirectory(); plan.CopyInputs(); plan.WriteMarker( "unconfirmed" );
	plan.PrepareForEngineMutation();
	plan.EngineMutationAttempted = true;
	plan.Dispose();
	Assert( File.Exists( Path.Combine( plan.FinalDirectory, "mesh.obj" ) ), "post-engine source was deleted" );
	Assert( File.Exists( Path.Combine( plan.FinalDirectory, ModelImportTransaction.MarkerName ) ), "post-engine marker was deleted" );
}

static void ActiveLeaseReleasesSharedGate()
{
	var oldGate = new ModelImportRequestGate();
	var lease = oldGate.TryEnter();
	var newGate = new ModelImportRequestGate( oldGate.State );
	Assert( newGate.IsActive, "replacement gate did not share active state" );
	lease.Dispose();
	Assert( !newGate.IsActive && newGate.TryEnter() is not null, "old invocation completion did not release replacement gate" );
}

static void ThrownDependencyPathPreservesEvidence()
{
	var backend = new FakeBackend { ThrowMaterialPath = true };
	backend.References[backend.Source] = [backend.Material];
	var pipeline = new ModelImportBackendPipeline( backend );
	try { pipeline.InspectDependencies( backend.Source, backend.Model ); }
	catch ( BackendPipelineException ex )
	{
		var evidence = (BackendDependencyEvidence)ex.Evidence;
		Assert( ex.Code == "DependencyInspectionFailed", "thrown path used wrong error" );
		Assert( evidence.InspectedAssets.Contains( "models/a/model.vmdl" ), "evidence from another completed asset was discarded" );
		Assert( evidence.UnavailableEvidence.Keys.Any( x => x.StartsWith( "ReferencePath:", StringComparison.Ordinal ) ), "thrown reference path was not identified" );
		return;
	}
	throw new Exception( "expected thrown path inspection failure" );
}

static void MissingDependencyPathFailsInspection()
{
	var backend = new FakeBackend { MissingMaterialPath = true };
	backend.References[backend.Source] = [backend.Material];
	var pipeline = new ModelImportBackendPipeline( backend );
	try { pipeline.InspectDependencies( backend.Source, backend.Model ); }
	catch ( BackendPipelineException ex )
	{
		var evidence = (BackendDependencyEvidence)ex.Evidence;
		Assert( ex.Code == "DependencyInspectionFailed", "missing path used wrong error" );
		Assert( evidence.UnavailableEvidence.Keys.Any( x => x.StartsWith( "ReferencePath:", StringComparison.Ordinal ) ), "missing reference path was not identified" );
		return;
	}
	throw new Exception( "expected missing path inspection failure" );
}

static Root Fixture() => new();
static void Assert( bool condition, string message ) { if ( !condition ) throw new Exception( message ); }
static void Equal( string[] expected, string[] actual ) => Assert( expected.SequenceEqual( actual, StringComparer.OrdinalIgnoreCase ), $"expected [{string.Join(",", expected)}], got [{string.Join(",", actual)}]" );
static void Throws( string code, Action action )
{
	try { action(); }
	catch ( ImportContractException ex ) when ( ex.Code == code ) { return; }
	throw new Exception( $"expected {code}" );
}

sealed class Root : IDisposable
{
	public string PathRoot { get; } = Path.Combine( Path.GetTempPath(), "sbox-model-import-contract", Guid.NewGuid().ToString( "N" ) );
	public string Assets => Path.Combine( PathRoot, "Assets" );
	public string Source => Path.Combine( PathRoot, "Source" );
	public Root() { Directory.CreateDirectory( Assets ); Directory.CreateDirectory( Source ); }
	public void Dispose() { try { Directory.Delete( PathRoot, true ); } catch { } }
}

sealed class FakeBackend : IModelImportBackend
{
	internal object Source { get; } = new();
	internal object Model { get; } = new();
	internal object Material { get; } = new();
	internal Dictionary<object, List<object>> References { get; } = [];
	internal bool ThrowRegister { get; set; }
	internal bool ThrowCompileObservation { get; set; }
	internal bool ThrowInputDependencies { get; set; }
	internal bool ThrowMaterialPath { get; set; }
	internal bool MissingMaterialPath { get; set; }
	internal bool IsCompiled { get; set; } = true;
	internal bool IsUpToDate { get; set; } = true;
	internal bool IsFailed { get; set; }
	public object RegisterFile( string absolutePath )
	{
		if ( ThrowRegister ) throw new IOException( "registration fault" );
		return Source;
	}
	public object FindAsset( string assetPath ) => Model;
	public string GetAssetPath( object asset )
	{
		if ( ReferenceEquals( asset, Material ) && ThrowMaterialPath ) throw new IOException( "asset path fault" );
		if ( ReferenceEquals( asset, Material ) && MissingMaterialPath ) return null;
		return ReferenceEquals( asset, Source ) ? "models/a/source.fbx" :
			ReferenceEquals( asset, Material ) ? "materials/test.vmat" : "models/a/model.vmdl";
	}
	public object CreateModel( object sourceAsset, string absoluteModelPath ) => Model;
	public bool ReadIsCompiled( object asset ) => IsCompiled;
	public bool ReadIsCompiledAndUpToDate( object asset ) => IsUpToDate;
	public bool ReadIsCompileFailed( object asset ) => IsFailed;
	public ValueTask ObserveCompilationIfNeededAsync( object asset ) => ThrowCompileObservation
		? ValueTask.FromException( new IOException( "compile observation fault" ) ) : ValueTask.CompletedTask;
	public IReadOnlyList<object> GetReferences( object asset ) => References.TryGetValue( asset, out var values ) ? values : [];
	public IReadOnlyList<string> GetUnrecognizedReferences( object asset ) => [];
	public IReadOnlyList<string> GetInputDependencies( object asset )
	{
		if ( ThrowInputDependencies ) throw new IOException( "input dependency fault" );
		return [];
	}
	public IReadOnlyList<string> GetAdditionalContentFiles( object asset ) => [];
}
kitsupanic.sbox_mcp_plus / Editor/ModelImportTransaction.cs
Editor library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
using System.Text.Json;

namespace Editor.Mcp;

internal sealed class ModelImportTransaction
{
	internal const long MaximumBytes = 1_073_741_824;
	internal const int MaximumImages = 128;
	internal const string MarkerName = ".sbox-mcp-import.json";
	private static readonly HashSet<string> ImageExtensions = new( StringComparer.OrdinalIgnoreCase )
	{
		".png", ".tga", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".exr", ".hdr"
	};
	private static readonly HashSet<string> SourceExtensions = new( StringComparer.OrdinalIgnoreCase )
	{
		".fbx", ".obj", ".dmx"
	};
	private static readonly HashSet<string> ReservedNames = new( StringComparer.OrdinalIgnoreCase )
	{
		"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
		"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
	};

	internal string TransactionId { get; } = Guid.NewGuid().ToString( "D" );
	internal string AssetsRoot { get; }
	internal string FinalDirectory { get; }
	internal string RelativeDirectory { get; }
	internal string SourceAsset { get; }
	internal string ModelAsset { get; }
	internal string MarkerAsset => JoinAsset( RelativeDirectory, MarkerName );
	internal IReadOnlyList<CopyPlan> Copies { get; }
	internal List<string> OwnedFiles { get; } = [];
	internal List<string> CompletedCopies { get; } = [];
	private readonly List<(string Path, SafeFileHandle Handle)> _directoryHandles = [];
	private readonly Dictionary<string, FileStream> _ownedStreams = new( StringComparer.OrdinalIgnoreCase );
	private FileStream _markerStream;
	internal List<string> CreatedDirectories { get; } = [];
	internal bool EngineMutationAttempted { get; set; }

	private ModelImportTransaction( string assetsRoot, string relativeDirectory, string finalDirectory,
		string sourceAsset, string modelAsset, IReadOnlyList<CopyPlan> copies )
	{
		AssetsRoot = assetsRoot;
		RelativeDirectory = relativeDirectory;
		FinalDirectory = finalDirectory;
		SourceAsset = sourceAsset;
		ModelAsset = modelAsset;
		Copies = copies;
	}

	internal static ModelImportTransaction Plan( string assetsRoot, string sourcePath, string targetDirectory,
		string modelName, bool copySiblingTextures )
	{
		if ( !OperatingSystem.IsWindows() )
			throw new ImportContractException( "ApiUnavailable", "Exclusive destination reservation is only supported on Windows." );
		if ( string.IsNullOrEmpty( assetsRoot ) )
			throw new ImportContractException( "NoActiveProject", "The active project has no Assets directory." );
		if ( string.IsNullOrWhiteSpace( sourcePath ) || !Path.IsPathFullyQualified( sourcePath ) )
			throw new ImportContractException( "InvalidInput", "sourcePath must be an absolute file path." );

		string canonicalSource;
		try { canonicalSource = Path.GetFullPath( sourcePath ); }
		catch ( Exception ) { throw new ImportContractException( "InvalidInput", "sourcePath is not a valid absolute path." ); }
		var sourceInfo = new FileInfo( canonicalSource );
		if ( !sourceInfo.Exists || (sourceInfo.Attributes & FileAttributes.Directory) != 0 )
			throw new ImportContractException( "InvalidInput", "sourcePath must identify a readable regular file." );
		if ( (sourceInfo.Attributes & FileAttributes.ReparsePoint) != 0 )
		{
			var target = sourceInfo.ResolveLinkTarget( true );
			if ( target is not FileInfo targetFile || !targetFile.Exists )
				throw new ImportContractException( "InvalidInput", "sourcePath link does not resolve to a readable regular file." );
			sourceInfo = targetFile;
			canonicalSource = targetFile.FullName;
		}
		if ( !SourceExtensions.Contains( sourceInfo.Extension ) )
			throw new ImportContractException( "InvalidInput", "sourcePath must have an .fbx, .obj, or .dmx extension." );
		using ( File.Open( canonicalSource, FileMode.Open, FileAccess.Read, FileShare.Read ) ) { }

		var effectiveName = string.IsNullOrEmpty( modelName ) ? Path.GetFileNameWithoutExtension( sourceInfo.Name ) : modelName;
		ValidateFileName( effectiveName, false );
		if ( effectiveName.EndsWith( ".vmdl", StringComparison.OrdinalIgnoreCase ) )
			throw new ImportContractException( "InvalidInput", "modelName must not include a .vmdl suffix." );
		ValidateFileName( sourceInfo.Name, true );

		var relative = NormalizeTarget( targetDirectory );
		var canonicalRoot = Path.TrimEndingDirectorySeparator( Path.GetFullPath( assetsRoot ) );
		var final = Path.GetFullPath( Path.Combine( canonicalRoot, relative.Replace( '/', Path.DirectorySeparatorChar ) ) );
		EnsureContained( canonicalRoot, final );
		EnsureNoReparsePoints( canonicalRoot, final );
		if ( Directory.Exists( final ) || File.Exists( final ) )
			throw new ImportContractException( "DestinationExists", "The final import directory already exists." );

		var candidates = new List<FileInfo> { sourceInfo };
		if ( copySiblingTextures )
		{
			IEnumerable<FileInfo> siblings;
			try { siblings = sourceInfo.Directory!.EnumerateFiles().Where( x => ImageExtensions.Contains( x.Extension ) ); }
			catch ( Exception ) { throw new ImportContractException( "InvalidInput", "Sibling image files could not be enumerated." ); }
			candidates.AddRange( siblings.OrderBy( x => x.Name, StringComparer.OrdinalIgnoreCase ) );
		}
		if ( candidates.Count - 1 > MaximumImages )
			throw new ImportContractException( "LimitExceeded", "The import selects more than 128 sibling images." );

		var names = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		long total = 0;
		var copies = new List<CopyPlan>( candidates.Count );
		foreach ( var file in candidates )
		{
			ValidateFileName( file.Name, true );
			if ( (file.Attributes & FileAttributes.ReparsePoint) != 0 )
				throw new ImportContractException( "InvalidInput", $"Selected file '{file.Name}' is a link." );
			if ( !names.Add( file.Name ) )
				throw new ImportContractException( "InvalidInput", "Selected filenames collide case-insensitively." );
			try { total = checked(total + file.Length); }
			catch ( OverflowException ) { throw new ImportContractException( "LimitExceeded", "Selected files exceed the byte limit." ); }
			if ( total > MaximumBytes )
				throw new ImportContractException( "LimitExceeded", "Selected files exceed 1,073,741,824 bytes." );
			copies.Add( new CopyPlan( file.FullName, Path.Combine( final, file.Name ), JoinAsset( relative, file.Name ), file.Length ) );
		}

		var modelFile = effectiveName + ".vmdl";
		ValidateFileName( modelFile, true );
		if ( !names.Add( modelFile ) || !names.Add( MarkerName ) )
			throw new ImportContractException( "InvalidInput", "Planned output filenames collide case-insensitively." );
		return new ModelImportTransaction( canonicalRoot, relative, final,
			JoinAsset( relative, sourceInfo.Name ), JoinAsset( relative, modelFile ), copies );
	}

	internal void ReserveDirectory()
	{
		var parent = Path.GetDirectoryName( FinalDirectory )!;
		var chain = new Stack<string>();
		for ( var cursor = parent; ; cursor = Path.GetDirectoryName( cursor )! )
		{
			chain.Push( cursor );
			if ( string.Equals( cursor, AssetsRoot, StringComparison.OrdinalIgnoreCase ) ) break;
		}
		while ( chain.Count > 0 )
		{
			var directory = chain.Pop();
			if ( !Directory.Exists( directory ) )
			{
				if ( CreateDirectoryW( directory, IntPtr.Zero ) ) CreatedDirectories.Add( directory );
				else
				{
					var error = Marshal.GetLastWin32Error();
					if ( error != 183 || !Directory.Exists( directory ) )
						throw new ImportContractException( "CopyFailed", $"A destination ancestor could not be reserved (Windows error {error})." );
				}
			}
			LockDirectory( directory );
		}
		if ( !CreateDirectoryW( FinalDirectory, IntPtr.Zero ) )
		{
			var error = Marshal.GetLastWin32Error();
			throw new ImportContractException( error == 183 ? "DestinationExists" : "CopyFailed",
				error == 183 ? "The final import directory was created by another operation." : $"The final import directory could not be reserved (Windows error {error})." );
		}
		CreatedDirectories.Add( FinalDirectory );
		LockDirectory( FinalDirectory );
	}

	internal IReadOnlyList<string> CopyInputs()
	{
		long streamed = 0;
		var buffer = new byte[128 * 1024];
		foreach ( var copy in Copies )
		{
			try
			{
				using var input = new FileStream( copy.SourceAbsolute, FileMode.Open, FileAccess.Read, FileShare.Read, buffer.Length, FileOptions.SequentialScan );
				var output = OpenOwnedFile( copy.DestinationAbsolute );
				OwnedFiles.Add( copy.DestinationAbsolute );
				_ownedStreams.Add( copy.DestinationAbsolute, output );
				int read;
				while ( (read = input.Read( buffer, 0, buffer.Length )) != 0 )
				{
					streamed = checked(streamed + read);
					if ( streamed > MaximumBytes )
						throw new ImportContractException( "LimitExceeded", "Selected files grew beyond 1,073,741,824 bytes while copying." );
					output.Write( buffer, 0, read );
				}
				output.Flush( true );
				CompletedCopies.Add( copy.DestinationAsset );
			}
			catch ( ImportContractException ) { throw; }
			catch ( Exception ) { throw new ImportContractException( "CopyFailed", $"Failed to copy '{copy.DestinationAsset}'." ); }
		}
		return CompletedCopies;
	}

	internal void WriteMarker( string writerState )
	{
		var marker = Path.Combine( FinalDirectory, MarkerName );
		var json = JsonSerializer.Serialize( new Marker( 1, TransactionId, SourceAsset, ModelAsset, writerState ) );
		if ( _markerStream is null )
		{
			_markerStream = OpenOwnedFile( marker );
			OwnedFiles.Add( marker );
			_ownedStreams.Add( marker, _markerStream );
		}
		_markerStream.Position = 0;
		_markerStream.SetLength( 0 );
		using ( var writer = new StreamWriter( _markerStream, System.Text.Encoding.UTF8, 1024, true ) )
		{
			writer.Write( json );
			writer.Flush();
		}
		_markerStream.Flush( true );
	}

	internal IReadOnlyList<string> CleanupPreEngine()
	{
		var remaining = new List<string>();
		var handleOwned = _ownedStreams.Keys.ToHashSet( StringComparer.OrdinalIgnoreCase );
		foreach ( var owned in _ownedStreams.ToArray() )
		{
			var disposition = new FileDispositionInfo { DeleteFile = true };
			try
			{
				if ( !SetFileInformationByHandle( owned.Value.SafeFileHandle, 4, ref disposition, (uint)Marshal.SizeOf<FileDispositionInfo>() ) )
					remaining.Add( ToReportedPath( owned.Key ) );
			}
			catch { remaining.Add( ToReportedPath( owned.Key ) ); }
			finally
			{
				try { owned.Value.Dispose(); }
				catch { remaining.Add( ToReportedPath( owned.Key ) ); }
			}
		}
		_ownedStreams.Clear();
		_markerStream = null;
		foreach ( var file in OwnedFiles.Where( x => !handleOwned.Contains( x ) ) )
			if ( File.Exists( file ) ) remaining.Add( ToReportedPath( file ) );
		foreach ( var directory in CreatedDirectories )
			if ( Directory.Exists( directory ) ) remaining.Add( ToReportedPath( directory ) );
		foreach ( var entry in _directoryHandles.ToArray() )
		{
			try { entry.Handle.Dispose(); }
			catch { remaining.Add( ToReportedPath( entry.Path ) ); }
		}
		_directoryHandles.Clear();
		return remaining.Distinct( StringComparer.OrdinalIgnoreCase ).OrderBy( x => x, StringComparer.OrdinalIgnoreCase ).ToArray();
	}

	internal string[] PendingPaths( IEnumerable<string> generated ) => new[] { RelativeDirectory }
		.Concat( OwnedFiles.Select( ToReportedPath ) ).Concat( generated )
		.Append( SourceAsset ).Append( ModelAsset )
		.Distinct( StringComparer.OrdinalIgnoreCase ).OrderBy( x => x, StringComparer.OrdinalIgnoreCase ).ToArray();

	internal string ToReportedPath( string absolute ) => IsContained( AssetsRoot, absolute )
		? Path.GetRelativePath( AssetsRoot, absolute ).Replace( '\\', '/' ) : absolute;

	internal static string NormalizeTarget( string target )
	{
		if ( string.IsNullOrWhiteSpace( target ) || target != target.Trim() )
			throw new ImportContractException( "InvalidInput", "targetDirectory must be a non-empty relative path without surrounding whitespace." );
		var normalized = target.Replace( '\\', '/' );
		if ( Path.IsPathRooted( target ) || normalized.StartsWith( "//", StringComparison.Ordinal ) || normalized.Contains( ':' ) )
			throw new ImportContractException( "InvalidInput", "targetDirectory must be relative to Assets." );
		var parts = normalized.Split( '/', StringSplitOptions.None );
		if ( parts.Length == 0 || parts.Any( x => x.Length == 0 || x is "." or ".." ) )
			throw new ImportContractException( "InvalidInput", "targetDirectory contains an empty, current, or parent component." );
		if ( parts[0].Equals( "Assets", StringComparison.OrdinalIgnoreCase ) )
			throw new ImportContractException( "InvalidInput", "targetDirectory must not include an Assets prefix." );
		foreach ( var part in parts ) ValidateFileName( part, true );
		return string.Join( '/', parts );
	}

	internal static void ValidateFileName( string name, bool extensionAllowed )
	{
		if ( string.IsNullOrWhiteSpace( name ) || name != name.Trim() || name.EndsWith( ".", StringComparison.Ordinal ) )
			throw new ImportContractException( "InvalidInput", "A planned filename is empty, whitespace-padded, or ends in a dot." );
		if ( name.IndexOfAny( Path.GetInvalidFileNameChars() ) >= 0 || name.Contains( '/' ) || name.Contains( '\\' ) )
			throw new ImportContractException( "InvalidInput", "A planned filename contains invalid characters or separators." );
		if ( !extensionAllowed && Path.GetFileName( name ) != name )
			throw new ImportContractException( "InvalidInput", "modelName must be a filename stem." );
		var deviceStem = name.Split( '.', 2 )[0];
		if ( ReservedNames.Contains( deviceStem ) )
			throw new ImportContractException( "InvalidInput", "A planned filename is a reserved Windows device name." );
	}

	internal static void EnsureNoReparsePoints( string root, string destination )
	{
		for ( var cursor = destination; !string.Equals( cursor, root, StringComparison.OrdinalIgnoreCase ); cursor = Path.GetDirectoryName( cursor )! )
		{
			if ( !Directory.Exists( cursor ) ) continue;
			if ( (File.GetAttributes( cursor ) & FileAttributes.ReparsePoint) != 0 )
				throw new ImportContractException( "InvalidInput", "The destination chain contains a symbolic link or junction." );
		}
	}
	private void LockDirectory( string directory )
	{
		var handle = CreateFileHandle( directory, 0x80000000, 0x00000001 | 0x00000002, IntPtr.Zero, 3,
			0x02000000 | 0x00200000, IntPtr.Zero );
		if ( handle.IsInvalid ) throw new ImportContractException( "CopyFailed", "The destination ancestry could not be locked against replacement." );
		if ( !GetFileInformationByHandle( handle, out var information ) || (information.FileAttributes & 0x400) != 0 )
		{
			handle.Dispose();
			throw new ImportContractException( "InvalidInput", "The destination chain contains or changed to a symbolic link or junction." );
		}
		_directoryHandles.Add( (directory, handle) );
	}

	private static FileStream OpenOwnedFile( string path )
	{
		var handle = CreateFileHandle( path, 0x80000000 | 0x40000000 | 0x00010000, 0x00000001,
			IntPtr.Zero, 1, 0x08000000, IntPtr.Zero );
		if ( handle.IsInvalid )
		{
			var error = Marshal.GetLastWin32Error();
			handle.Dispose();
			throw new IOException( $"Exclusive file creation failed (Windows error {error})." );
		}
		return new FileStream( handle, FileAccess.ReadWrite, 128 * 1024, false );
	}

	private void DisposeHandles()
	{
		foreach ( var stream in _ownedStreams.Values ) stream.Dispose();
		_ownedStreams.Clear();
		_markerStream = null;
		foreach ( var entry in _directoryHandles ) entry.Handle.Dispose();
		_directoryHandles.Clear();
	}

	internal void Dispose() => DisposeHandles();
	internal void PrepareForEngineMutation()
	{
		foreach ( var stream in _ownedStreams.Values ) stream.Dispose();
		_ownedStreams.Clear();
		_markerStream = null;
	}


	internal static bool IsContained( string root, string path )
	{
		var prefix = Path.TrimEndingDirectorySeparator( Path.GetFullPath( root ) ) + Path.DirectorySeparatorChar;
		var full = Path.GetFullPath( path );
		return full.StartsWith( prefix, StringComparison.OrdinalIgnoreCase );
	}

	private static void EnsureContained( string root, string path )
	{
		if ( !IsContained( root, path ) ) throw new ImportContractException( "InvalidInput", "targetDirectory escapes the active Assets root." );
	}

	private static string JoinAsset( string directory, string name ) => $"{directory.TrimEnd( '/' )}/{name}";

	[DllImport( "kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	private static extern bool CreateDirectoryW( string path, IntPtr securityAttributes );
	[DllImport( "kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CreateFileW" )]
	private static extern SafeFileHandle CreateFileHandle( string fileName, uint desiredAccess, uint shareMode,
		IntPtr securityAttributes, uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile );
	[DllImport( "kernel32.dll", SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	private static extern bool GetFileInformationByHandle( SafeFileHandle handle, out ByHandleFileInformation information );

	[StructLayout( LayoutKind.Sequential )]
	private struct ByHandleFileInformation
	{
		public uint FileAttributes;
		public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
		public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
		public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
		public uint VolumeSerialNumber;
		public uint FileSizeHigh;
		public uint FileSizeLow;
		public uint NumberOfLinks;
		public uint FileIndexHigh;
		public uint FileIndexLow;
	}
	[StructLayout( LayoutKind.Sequential )]
	private struct FileDispositionInfo
	{
		[MarshalAs( UnmanagedType.Bool )]
		public bool DeleteFile;
	}

	[DllImport( "kernel32.dll", SetLastError = true )]
	[return: MarshalAs( UnmanagedType.Bool )]
	private static extern bool SetFileInformationByHandle( SafeFileHandle handle, int fileInformationClass,
		ref FileDispositionInfo fileInformation, uint bufferSize );




	internal sealed record CopyPlan( string SourceAbsolute, string DestinationAbsolute, string DestinationAsset, long PreflightLength );
	internal sealed record Marker( int Version, string TransactionId, string SourceAsset, string ModelAsset, string WriterState );
}

internal class ImportContractException : Exception
{
	internal string Code { get; }
	internal ImportContractException( string code, string message ) : base( message ) => Code = code;
}
kitsupanic.sbox_mcp_plus / Editor/McpExtras.cs
Editor library
using Sandbox;
using System;
using System.IO;
using System.Linq;

namespace Editor.Mcp;

/// <summary>
/// Local extensions to the built in MCP tools, living in a shared library so every project here
/// gets them without waiting on an engine release. Tool names are prefixed 'x_' so they can never
/// collide with the engine's own once the upstream equivalents land.
/// </summary>
[McpToolset( "extras", "Local extensions to the built-in MCP tools" )]
public static partial class ExtrasTools
{
	/// <summary>
	/// Make a scene the active editor tab, opening it from its asset path when it isn't open yet.
	/// Scene edits always target the active scene, so switch before editing a background scene.
	/// Returns the tab it settled on - name, resource path, type, unsaved changes and root object
	/// count - plus a message saying what happened. list_scenes shows what's already open.
	/// </summary>
	/// <param name="scene">Scene name or resource path as list_scenes reports it, or a .scene/.prefab path from asset_search.</param>
	[McpTool( "x_open_scene" )]
	public static SceneTab OpenSceneTab( string scene )
	{
		if ( string.IsNullOrWhiteSpace( scene ) )
			throw new Exception( "Give a scene name or resource path - list_scenes shows what's open, asset_search type:scene finds scene assets on disk" );

		if ( Game.IsPlaying )
			throw new Exception( "Can't switch scene tabs while playing - play_stop first" );

		var session = FindSession( scene );

		if ( session is GameEditorSession )
			throw new Exception( "That's the running game session, which has no tab to switch to - play_stop first, then open the scene you want to edit" );

		if ( session is not null && session == SceneEditorSession.Active )
			return Row( session, $"'{session.Scene?.Name}' was already the active tab - nothing changed" );

		var opened = session is null;

		session ??= SceneEditorSession.CreateFromPath( scene )
			?? throw new Exception( $"Nothing to open for '{scene}' - list_scenes shows what's already open, asset_search type:scene finds scene assets on disk" );

		session.MakeActive();

		return Row( session, opened
			? $"Opened '{session.Scene?.Name}' from disk and made it the active tab"
			: $"Switched the active tab to the already open '{session.Scene?.Name}'" );
	}

	/// <summary>
	/// Create a new empty scene beneath scenes/diagnostics, save it without prompting, and make
	/// its tab active. Existing tabs, including dirty tabs, are left untouched.
	/// </summary>
	/// <param name="path">Project-relative .scene path beneath scenes/diagnostics.</param>
	/// <param name="name">Optional scene name. Defaults to the destination file name.</param>
	[McpTool( "x_create_scene" )]
	public static SceneTab CreateScene( string path, string name = "" )
	{
		if ( Game.IsPlaying )
			throw new Exception( "Can't create a scene while playing - play_stop first" );

		var destination = ValidateDiagnosticScenePath( path );
		var resourcePath = destination.RelativePath;
		var sceneName = string.IsNullOrWhiteSpace( name )
			? Path.GetFileNameWithoutExtension( resourcePath )
			: name.Trim();

		if ( File.Exists( destination.AbsolutePath ) || AssetSystem.FindByPath( resourcePath ) is not null )
			throw new Exception( $"A scene already exists at '{resourcePath}' - choose a new diagnostic path" );

		var previous = SceneEditorSession.Active;
		SceneEditorSession created = null;
		Asset asset = null;
		var saved = false;

		try
		{
			created = SceneEditorSession.CreateDefault()
				?? throw new Exception( "Couldn't create a new editor scene session" );

			var scene = created.Scene;
			foreach ( var child in scene.Children.ToArray() )
				child.Destroy();
			scene.ProcessDeletes();
			scene.Name = sceneName;

			Directory.CreateDirectory( Path.GetDirectoryName( destination.AbsolutePath ) );

			asset = AssetSystem.CreateResource( "scene", destination.AbsolutePath )
				?? throw new Exception( $"Couldn't create the scene resource at '{resourcePath}'" );

			var sceneFile = new SceneFile
			{
				Id = Guid.NewGuid(),
				GameObjects = [],
				SceneProperties = scene.SerializeProperties()
			};

			saved = asset.SaveToDisk( sceneFile );
			if ( !saved )
				throw new Exception( $"Couldn't save the new scene at '{resourcePath}'" );

			created.Destroy();
			created = null;

			var opened = SceneEditorSession.CreateFromPath( resourcePath )
				?? throw new Exception( $"The new scene was saved but couldn't be opened at '{resourcePath}'" );

			opened.MakeActive();
			return Row( opened, $"Created '{opened.Scene?.Name}' at '{resourcePath}' and made it the active tab" );
		}
		catch
		{
			created?.Destroy();

			if ( asset is not null )
				asset.Delete();

			if ( previous is not null && previous != SceneEditorSession.Active )
				previous.MakeActive();

			throw;
		}
	}



	/// <summary>
	/// What the editor is doing right now - which project is open, which scene tab is active and
	/// whether it has unsaved changes, and whether play mode is running or paused. ActiveScene here
	/// is the editor's active tab, which is what the scene tools edit; the built in editor_status
	/// reports the running game's scene instead and can disagree while playing. Follow up with
	/// scene_tree for the hierarchy, or x_open_scene to switch tabs.
	/// </summary>
	[McpTool.ReadOnly( "x_editor_status" )]
	public static EditorStatusExtras GetEditorStatus()
	{
		var session = SceneEditorSession.Active;
		var scene = session?.Scene ?? Game.ActiveScene;

		return new EditorStatusExtras
		{
			Project = Project.Current?.Config?.Ident,
			ProjectTitle = Project.Current?.Config?.Title,
			ActiveScene = scene?.Name,
			ActiveScenePath = scene?.Source?.ResourcePath,
			SceneHasUnsavedChanges = session?.HasUnsavedChanges ?? false,
			OpenSceneCount = SceneEditorSession.All.Count,
			IsPlaying = Game.IsPlaying,
			IsPaused = Game.IsPaused
		};
	}

	/// <summary>
	/// Render a camera in the scene and return it as an image, with UI text intact. Give any
	/// CameraComponent's id or its game object's id, or nothing for the scene's main camera.
	/// Use this instead of camera_screenshot whenever the shot includes Razor UI: camera_screenshot
	/// renders every text label as a flat gray rectangle at any size other than the live viewport's,
	/// because rendering offscreen relayouts the UI, which throws away each label's text texture
	/// without rebuilding the render descriptors that point at it. In play mode this tool always
	/// renders at the native screen resolution - where that relayout is a no-op, so the descriptors
	/// stay valid - and downscales the result to the size you asked for: the output matches the
	/// requested width and height exactly, but its detail is capped at the viewport's resolution,
	/// so asking for more pixels than the viewport has gets you an upscale, not more detail. In
	/// edit mode there is no game viewport, so no live screen-size UI exists to corrupt and it
	/// renders directly at the requested size, at full detail - making this a drop in replacement
	/// for camera_screenshot in both modes. find_game_objects with component 'Camera' lists the
	/// cameras in a scene.
	/// </summary>
	/// <param name="camera">A CameraComponent id or its game object's id. Empty uses the scene's main camera.</param>
	/// <param name="width">Image width in pixels.</param>
	/// <param name="height">Image height in pixels.</param>
	/// <param name="includeUi">Include any UI the camera renders.</param>
	[McpTool.ReadOnly( "x_camera_screenshot" )]
	public static object CameraScreenshotNative( string camera = "", [Sandbox.Range( 16, 4096 )] int width = 1280,
		[Sandbox.Range( 16, 4096 )] int height = 720, bool includeUi = true )
	{
		var target = ResolveCamera( camera );

		if ( !target.IsValid() )
			throw new Exception( "The scene has no camera - find one with find_game_objects component 'Camera', or add one" );

		// The one size the engine bug can't bite: identical to the screen, so the offscreen
		// relayout changes no panel's size and no text texture gets released underneath its
		// descriptor. Everything else is a downscale we do ourselves.
		var nativeWidth = Screen.Width.CeilToInt();
		var nativeHeight = Screen.Height.CeilToInt();

		// No screen size means no game viewport - edit mode. Nothing live is laid out at the
		// screen's size, so there are no text textures a relayout can destroy, and we can render
		// straight at the size asked for, exactly as the built in camera_screenshot does.
		if ( nativeWidth <= 1 || nativeHeight <= 1 )
		{
			var direct = new Bitmap( width, height );
			target.RenderToBitmap( direct, includeUi );
			return direct;
		}

		var bitmap = new Bitmap( nativeWidth, nativeHeight );
		target.RenderToBitmap( bitmap, includeUi );

		if ( nativeWidth == width && nativeHeight == height )
			return bitmap;

		// Resize hands back a new bitmap, so the native capture is ours to release
		using ( bitmap )
		{
			return bitmap.Resize( width, height );
		}
	}

	/// <summary>
	/// Aim a camera game object at a world-space point using the engine's Rotation.LookAt.
	/// The camera must belong to the active editor scene. The edit is undoable.
	/// </summary>
	/// <param name="camera">A CameraComponent id or its game object's id.</param>
	/// <param name="target">World-space target as 'x,y,z'.</param>
	/// <param name="up">World-space up direction as 'x,y,z'. Defaults to +Z.</param>
	[McpTool( "x_camera_look_at" )]
	public static CameraLookAtResult CameraLookAt( string camera, string target, string up = "0,0,1" )
	{
		if ( string.IsNullOrWhiteSpace( camera ) )
			throw new Exception( "Give a CameraComponent or camera game object id - find_game_objects component 'Camera' lists them" );

		var session = SceneEditorSession.Active
			?? throw new Exception( "No editor scene tab is active" );
		var component = ResolveCamera( camera );
		if ( component.Scene != session.Scene )
			throw new Exception( "The camera isn't in the active editor scene - use x_open_scene or switch_scene first" );

		var targetPosition = Vector3.Parse( target );
		var upDirection = Vector3.Parse( up );
		var direction = targetPosition - component.WorldPosition;
		if ( direction.LengthSquared < 0.000001f )
			throw new Exception( "The camera position and look-at target must differ" );
		if ( upDirection.LengthSquared < 0.000001f )
			throw new Exception( "The look-at up direction must be non-zero" );

		var gameObject = component.GameObject;
		using ( session.UndoScope( "Aim Camera" ).WithGameObjectChanges( gameObject, GameObjectUndoFlags.All ).Push() )
		{
			gameObject.WorldRotation = Rotation.LookAt( direction.Normal, upDirection.Normal );
		}

		return new CameraLookAtResult
		{
			Id = gameObject.Id,
			Name = gameObject.Name,
			Position = gameObject.WorldPosition,
			Target = targetPosition,
			Angles = gameObject.WorldRotation.Angles()
		};
	}

	/// <summary>The resulting camera aim.</summary>
	public class CameraLookAtResult
	{
		public Guid Id { get; set; }
		public string Name { get; set; }
		public Vector3 Position { get; set; }
		public Vector3 Target { get; set; }
		public Angles Angles { get; set; }
	}

	/// <summary>
	/// Set one game object's saved networking mode in the active editor scene. The edit is undoable.
	/// </summary>
	/// <param name="id">Game object GUID from scene_tree or find_game_objects.</param>
	/// <param name="mode">Never, Object, or Snapshot.</param>
	[McpTool( "x_set_network_mode" )]
	public static NetworkModeResult SetNetworkMode( string id, NetworkMode mode )
	{
		if ( Game.IsPlaying )
			throw new Exception( "Can't change saved network mode while playing - play_stop first" );
		if ( !Guid.TryParse( id, out var guid ) )
			throw new Exception( $"'{id}' isn't a game object GUID" );

		var session = SceneEditorSession.Active
			?? throw new Exception( "No editor scene tab is active" );
		var gameObject = session.Scene?.Directory?.FindByGuid( guid )
			?? throw new Exception( $"No game object with id {guid} exists in the active scene" );

		using ( session.UndoScope( "Set Network Mode" ).WithGameObjectChanges( gameObject, GameObjectUndoFlags.All ).Push() )
		{
			gameObject.NetworkMode = mode;
		}

		return new NetworkModeResult { Id = gameObject.Id, Name = gameObject.Name, Mode = gameObject.NetworkMode };
	}

	public class NetworkModeResult
	{
		public Guid Id { get; set; }
		public string Name { get; set; }
		public NetworkMode Mode { get; set; }
	}

	/// <summary>One scene tab open in the editor.</summary>
	public class SceneTab
	{
		/// <summary>What happened - opened, switched, or already active.</summary>
		public string Message { get; set; }

		/// <summary>The scene's name, as list_scenes reports it.</summary>
		public string Name { get; set; }

		/// <summary>The scene asset's resource path. Null for a scene that was never saved.</summary>
		public string ResourcePath { get; set; }

		/// <summary>Scene, Prefab, or Game for the running session.</summary>
		public string Type { get; set; }

		/// <summary>Whether this is the active tab - true unless something else took focus.</summary>
		public bool IsActive { get; set; }

		/// <summary>Whether the scene has edits that save_scene hasn't written yet.</summary>
		public bool HasUnsavedChanges { get; set; }

		/// <summary>How many objects sit at the scene root.</summary>
		public int RootObjectCount { get; set; }
	}

	/// <summary>The editor's current state, from the editor's point of view rather than the game's.</summary>
	public class EditorStatusExtras
	{
		/// <summary>The open project's ident.</summary>
		public string Project { get; set; }

		/// <summary>The open project's title.</summary>
		public string ProjectTitle { get; set; }

		/// <summary>The active scene tab's name. Falls back to the running game's scene when no tab is active.</summary>
		public string ActiveScene { get; set; }

		/// <summary>The active scene's resource path. Null for a scene that was never saved.</summary>
		public string ActiveScenePath { get; set; }

		/// <summary>Whether the active scene has edits that save_scene hasn't written yet.</summary>
		public bool SceneHasUnsavedChanges { get; set; }

		/// <summary>How many scene tabs are open. list_scenes names them.</summary>
		public int OpenSceneCount { get; set; }

		/// <summary>Whether play mode is running - play_stop returns to editing.</summary>
		public bool IsPlaying { get; set; }

		/// <summary>Whether play mode is paused.</summary>
		public bool IsPaused { get; set; }
	}

	/// <summary>
	/// The open session whose scene matches a name or resource path, case insensitive. Null when
	/// nothing open matches - the caller decides whether to open it from disk.
	/// </summary>
	private static SceneEditorSession FindSession( string nameOrPath )
	{
		return SceneEditorSession.All
			.FirstOrDefault( x => string.Equals( x.Scene?.Name, nameOrPath, StringComparison.OrdinalIgnoreCase )
				|| string.Equals( x.Scene?.Source?.ResourcePath, nameOrPath, StringComparison.OrdinalIgnoreCase ) );
	}

	private static (string RelativePath, string AbsolutePath) ValidateDiagnosticScenePath( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) )
			throw new Exception( "Give a project-relative .scene path beneath scenes/diagnostics/" );

		var normalized = path.Trim().Replace( '\\', '/' );
		if ( Path.IsPathRooted( normalized ) )
			throw new Exception( "Scene path must be project-relative and beneath scenes/diagnostics/" );

		if ( normalized.Split( '/', StringSplitOptions.RemoveEmptyEntries ).Contains( ".." ) )
			throw new Exception( "Scene path can't contain traversal segments" );

		if ( !string.Equals( Path.GetExtension( normalized ), ".scene", StringComparison.OrdinalIgnoreCase ) )
			throw new Exception( "Scene path must use the .scene extension" );

		if ( !normalized.StartsWith( "scenes/diagnostics/", StringComparison.OrdinalIgnoreCase )
			|| normalized.Length == "scenes/diagnostics/".Length )
			throw new Exception( "Scene path must be beneath scenes/diagnostics/" );

		try
		{
			var assetsRoot = Path.GetFullPath( Project.Current.GetAssetsPath() );
			var diagnosticsRoot = Path.GetFullPath( Path.Combine( assetsRoot, "scenes", "diagnostics" ) )
				.TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar ) + Path.DirectorySeparatorChar;
			var absolute = Path.GetFullPath( Path.Combine( assetsRoot, normalized.Replace( '/', Path.DirectorySeparatorChar ) ) );

			if ( !absolute.StartsWith( diagnosticsRoot, StringComparison.OrdinalIgnoreCase ) )
				throw new Exception( "Scene path must be beneath scenes/diagnostics/" );

			return (normalized, absolute);
		}
		catch ( Exception exception ) when ( exception is ArgumentException or NotSupportedException or PathTooLongException )
		{
			throw new Exception( "Scene path isn't a valid project-relative path" );
		}
	}


	/// <summary>
	/// The camera a tool argument names - a CameraComponent id, or a game object id whose
	/// CameraComponent we take. Empty means the active scene's main camera. The engine's own
	/// resolvers are private to the tools addon, so this repeats them.
	/// </summary>
	private static CameraComponent ResolveCamera( string camera )
	{
		if ( string.IsNullOrWhiteSpace( camera ) )
		{
			var scene = SceneEditorSession.Active?.Scene ?? Game.ActiveScene
				?? throw new Exception( "No scene is open in the editor" );

			return scene.Camera;
		}

		if ( !Guid.TryParse( camera, out var guid ) )
			throw new Exception( $"'{camera}' isn't a guid - find_game_objects and scene_tree show object ids, get_game_object shows component ids" );

		foreach ( var session in SceneEditorSession.All )
		{
			if ( session.Scene?.Directory?.FindComponentByGuid( guid ) is Component component )
			{
				return component as CameraComponent
					?? throw new Exception( "That component isn't a camera - give a CameraComponent or its game object" );
			}

			if ( session.Scene?.Directory?.FindByGuid( guid ) is GameObject go )
			{
				// includeDisabled, matching the built-in resolver's view of a game object's components
				return go.Components.Get<CameraComponent>( true )
					?? throw new Exception( $"'{go.Name}' has no camera component - find one with find_game_objects component 'Camera'" );
			}
		}

		throw new Exception( $"Nothing in any open scene has id {guid} - find_game_objects and scene_tree show what's there" );
	}

	private static SceneTab Row( SceneEditorSession session, string message )
	{
		return new SceneTab
		{
			Message = message,
			Name = session.Scene?.Name,
			ResourcePath = session.Scene?.Source?.ResourcePath,
			Type = session is GameEditorSession ? "Game" : session.Scene is PrefabScene ? "Prefab" : "Scene",
			IsActive = session == SceneEditorSession.Active,
			HasUnsavedChanges = session.HasUnsavedChanges,
			RootObjectCount = session.Scene?.Children.Count ?? 0
		};
	}
}
Debug: View Raw JSON Response
{
    "TotalCount": 8,
    "Files": [
        {
            "Ident": "kitsupanic.sbox_mcp_plus",
            "Path": "Editor/OwnedLocalInstance.cs",
            "FileName": "OwnedLocalInstance.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381524,
            "IsPrivate": false,
            "Code": "using Microsoft.Win32.SafeHandles;\nusing System;\nusing System.Collections.Generic;\nusing System.ComponentModel;\nusing System.Diagnostics;\nusing System.Globalization;\nusing System.IO;\nusing System.Linq;\nusing System.Runtime.InteropServices;\nusing System.Security.Cryptography;\nusing System.Text;\nusing System.Threading;\nusing System.Threading.Tasks;\n\nnamespace Editor.Mcp;\n\ninternal sealed class OwnedLocalInstance : IDisposable\n{\n\t/// <summary>\n\t/// A retained launch may temporarily lack a creation identity after cleanup fails. Such an entry\n\t/// remains internal until GetProcessTimes supplies the exact timestamp required by the public contract.\n\t/// </summary>\n\n\tinternal OwnedLocalInstance( SafeKernelHandle processHandle, SafeKernelHandle jobHandle, int processId,\n\t\tlong? creationFileTime, string executablePath, bool windowed, int instanceNumber, string logDirectory,\n\t\tbool assignedToJob, LaunchAuthority authority )\n\t{\n\t\tif ( creationFileTime is null && authority != LaunchAuthority.RetainedUnidentified )\n\t\t\tthrow new ArgumentException( \"Only a retained unidentified launch may have no creation identity.\",\n\t\t\t\tnameof( creationFileTime ) );\n\n\t\tProcessHandle = processHandle;\n\t\tJobHandle = jobHandle;\n\t\tProcessId = processId;\n\t\tCreationFileTime = creationFileTime;\n\t\tExecutablePath = executablePath;\n\t\tWindowed = windowed;\n\t\tInstanceNumber = instanceNumber;\n\t\tLogDirectory = logDirectory;\n\t\tAssignedToJob = assignedToJob;\n\t\tAuthority = authority;\n\n\t\tLaunchedAt = creationFileTime is long creation\n\t\t\t? DateTime.FromFileTimeUtc( creation ).ToString( \"O\", CultureInfo.InvariantCulture )\n\t\t\t: null;\n\t}\n\n\tinternal SafeKernelHandle ProcessHandle { get; }\n\tinternal SafeKernelHandle JobHandle { get; }\n\tinternal int ProcessId { get; }\n\n\t/// <summary>Exact creation FILETIME captured at launch, or null when it could not be read.</summary>\n\tinternal long? CreationFileTime { get; set; }\n\tinternal string ExecutablePath { get; }\n\tinternal string LaunchedAt { get; set; }\n\tinternal bool Windowed { get; }\n\tinternal int InstanceNumber { get; }\n\tinternal string LogDirectory { get; }\n\n\t/// <summary>Whether the retained private job actually contains this process.</summary>\n\tinternal bool AssignedToJob { get; }\n\n\t/// <summary>\n\t/// The authority this entry actually holds. A launch that never resumed and whose cleanup failed\n\t/// is retained in one of the failed-launch states instead of being abandoned, and is never\n\t/// presented as an ordinary launched process.\n\t/// </summary>\n\tinternal LaunchAuthority Authority { get; private set; }\n\n\t/// <summary>\n\t/// A launch that was never resumed, so its only authority is the original process handle or its\n\t/// private job - never a PID, and never a normal launch identity.\n\t/// </summary>\n\tinternal bool FailedLaunch => Authority != LaunchAuthority.Owned;\n\n\t/// <summary>\n\t/// Marks a never-resumed launch whose pre-resume cleanup failed as the retained authority of that\n\t/// process. Called by the launch scope while the entry is still unreachable to any other caller.\n\t/// </summary>\n\tinternal void MarkRetainedFailedLaunch() => Authority = LaunchAuthority.RetainedFailedLaunch;\n\n\t/// <summary>Promotes hidden cleanup authority once its exact process creation identity is readable.</summary>\n\tinternal void CaptureCreationIdentity( long creationFileTime )\n\t{\n\t\tif ( CreationFileTime is not null ) return;\n\t\tCreationFileTime = creationFileTime;\n\t\tLaunchedAt = DateTime.FromFileTimeUtc( creationFileTime ).ToString( \"O\", CultureInfo.InvariantCulture );\n\t\tAuthority = LaunchAuthority.RetainedFailedLaunch;\n\t}\n\n\n\tpublic void Dispose()\n\t{\n\t\tProcessHandle.Dispose();\n\t\tJobHandle?.Dispose();\n\t}\n}\n\ninternal sealed class OwnedInstanceRegistry : IDisposable\n{\n\tinternal const ulong SyntheticSteamIdentityBase = 90071996842377216UL;\n\n\tprivate static readonly TimeSpan GracefulBudget = TimeSpan.FromSeconds( 2 );\n\tprivate static readonly TimeSpan ForcedBudget = TimeSpan.FromSeconds( 3 );\n\tprivate const int PollIntervalMilliseconds = 50;\n\n\tprivate readonly object _sync = new();\n\tprivate readonly Dictionary<int, OwnedLocalInstance> _entries = new();\n\n\t/// <summary>\n\t/// Retained launches whose PID key is already held by another entry. A live process can only\n\t/// collide with an entry whose root has already exited, and dropping either authority would\n\t/// abandon a live process or its tree, so these stay reachable by their exact (PID, token) pair.\n\t/// </summary>\n\tprivate readonly Dictionary<(int ProcessId, string Token), OwnedLocalInstance> _displaced = new();\n\n\tprivate bool _disposed;\n\n\t/// <summary>\n\t/// Fault seam for the focused harness: fires after a graceful poll delay completes and before\n\t/// the following cancellation check. Inert unless a test assigns it.\n\t/// </summary>\n\tinternal Action GracefulDelayCompletedForTesting { get; set; }\n\n\tinternal OwnedInstanceSnapshot LaunchSbox( bool windowed, Func<ulong, bool> visibleSyntheticIdentity )\n\t{\n\t\tif ( !OperatingSystem.IsWindows() )\n\t\t\tthrow new OwnedInstanceException( \"Local instance launching is supported only on Windows.\" );\n\n\t\tvar currentExecutable = Environment.ProcessPath;\n\t\tif ( string.IsNullOrWhiteSpace( currentExecutable )\n\t\t\t|| !string.Equals( Path.GetFileName( currentExecutable ), \"sbox-dev.exe\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\tthrow new OwnedInstanceException( \"Local instances can only be launched by sbox-dev.exe.\" );\n\n\t\tvar directory = Path.GetDirectoryName( currentExecutable );\n\t\tvar executable = directory is null ? null : Path.Combine( directory, \"sbox.exe\" );\n\t\tif ( executable is null || !File.Exists( executable ) )\n\t\t\tthrow new OwnedInstanceException( \"The sibling sbox.exe executable was not found.\" );\n\n\t\tvar instanceNumber = AllocateInstanceNumber( visibleSyntheticIdentity );\n\t\tvar arguments = new List<string> { \"-joinlocal\", \"+instanceid\", instanceNumber.ToString( CultureInfo.InvariantCulture ) };\n\t\tif ( windowed )\n\t\t{\n\t\t\targuments.Add( \"-sw\" );\n\t\t\targuments.Add( \"-720\" );\n\t\t}\n\n\t\treturn LaunchContained( executable, arguments, directory, windowed, instanceNumber,\n\t\t\tResolveLogDirectory(), LaunchFailureInjection.None );\n\t}\n\n\tinternal OwnedInstanceSnapshot LaunchContainedForTesting( string executable, IReadOnlyList<string> arguments,\n\t\tstring workingDirectory, bool windowed = false, int instanceNumber = 1,\n\t\tLaunchFailureInjection failure = LaunchFailureInjection.None )\n\t{\n\t\treturn LaunchContained( Path.GetFullPath( executable ), arguments, Path.GetFullPath( workingDirectory ),\n\t\t\twindowed, instanceNumber, Path.GetFullPath( workingDirectory ), failure );\n\t}\n\n\tinternal OwnedInstanceSnapshot[] List( bool pruneExited )\n\t{\n\t\tlock ( _sync )\n\t\t{\n\t\t\tThrowIfDisposed();\n\t\t\tvar rows = new List<(OwnedLocalInstance Entry, OwnedInstanceSnapshot Snapshot)>();\n\t\t\tforeach ( var entry in AllEntries().ToArray() )\n\t\t\t{\n\t\t\t\tif ( entry.CreationFileTime is null && !TryCaptureCreationIdentity( entry ) )\n\t\t\t\t{\n\t\t\t\t\tif ( pruneExited && IsSignaled( entry.ProcessHandle ) && ActiveProcessCount( entry ) == 0 )\n\t\t\t\t\t\tRemoveAndDispose( entry );\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\trows.Add( (entry, Snapshot( entry )) );\n\t\t\t}\n\n\t\t\tif ( pruneExited )\n\t\t\t{\n\t\t\t\tforeach ( var row in rows )\n\t\t\t\t{\n\t\t\t\t\tif ( row.Snapshot.Exited && row.Snapshot.ActiveProcessCount == 0 )\n\t\t\t\t\t\tRemoveAndDispose( row.Entry );\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn rows.Select( row => row.Snapshot ).ToArray();\n\t\t}\n\t}\n\n\tinternal bool TryGet( int processId, out OwnedLocalInstance instance )\n\t{\n\t\tlock ( _sync )\n\t\t{\n\t\t\tif ( _disposed )\n\t\t\t{\n\t\t\t\tinstance = null;\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\treturn _entries.TryGetValue( processId, out instance );\n\t\t}\n\t}\n\n\tinternal async Task<OwnedTerminationOutcome> TerminateAsync( int processId, string launchTimestamp, bool abrupt,\n\t\tCancellationToken cancellationToken )\n\t{\n\t\tif ( processId <= 0 || processId == Environment.ProcessId )\n\t\t\treturn OwnedTerminationOutcome.NotOwned( processId );\n\n\t\tOwnedLocalInstance entry;\n\t\tlock ( _sync )\n\t\t{\n\t\t\tThrowIfDisposed();\n\t\t\tentry = FindOwned( processId, launchTimestamp );\n\t\t\tif ( entry is null )\n\t\t\t\treturn AnyEntryFor( processId )\n\t\t\t\t\t? OwnedTerminationOutcome.IdentityMismatch( processId )\n\t\t\t\t\t: OwnedTerminationOutcome.NotOwned( processId );\n\t\t}\n\n\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\tif ( !ValidateIdentity( entry, out _ ) )\n\t\t\treturn OwnedTerminationOutcome.IdentityMismatch( processId );\n\n\t\tif ( IsSignaled( entry.ProcessHandle ) && ActiveProcessCount( entry ) == 0 )\n\t\t{\n\t\t\tRemoveAndDispose( entry );\n\t\t\treturn OwnedTerminationOutcome.AlreadyExited( processId );\n\t\t}\n\n\t\t// A failed launch was never resumed: there is no window to close and nothing to wait for,\n\t\t// so it goes straight to the retained authority below.\n\t\tif ( !abrupt && !entry.FailedLaunch )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tTryCloseMainWindow( entry, IsSignaled( entry.ProcessHandle ) );\n\n\t\t\tvar gracefulDeadline = Stopwatch.StartNew();\n\t\t\twhile ( true )\n\t\t\t{\n\t\t\t\t// Honoured after every await, including the boundary where a poll delay completed\n\t\t\t\t// and its continuation was queued just as the lifetime was invalidated.\n\t\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\n\t\t\t\tif ( ActiveProcessCount( entry ) == 0 )\n\t\t\t\t{\n\t\t\t\t\tRemoveAndDispose( entry );\n\t\t\t\t\treturn OwnedTerminationOutcome.Terminated( processId, false, \"The owned process job exited after the graceful close request.\" );\n\t\t\t\t}\n\n\t\t\t\tif ( gracefulDeadline.Elapsed >= GracefulBudget ) break;\n\t\t\t\tawait Task.Delay( PollIntervalMilliseconds, cancellationToken );\n\t\t\t\tGracefulDelayCompletedForTesting?.Invoke();\n\t\t\t}\n\n\t\t\t// The last delay may have completed with the budget already spent; nothing may be\n\t\t\t// signaled until ownership is revalidated against a still-valid operation.\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t}\n\n\t\tif ( !ValidateIdentity( entry, out _ ) )\n\t\t\treturn OwnedTerminationOutcome.IdentityMismatch( processId );\n\n\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\tvar (forced, forcedMessage) = TerminateOwnedTree( entry, processId );\n\n\t\tvar forcedDeadline = Stopwatch.StartNew();\n\t\twhile ( true )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\n\t\t\tif ( ActiveProcessCount( entry ) == 0 && IsSignaled( entry.ProcessHandle ) )\n\t\t\t{\n\t\t\t\tRemoveAndDispose( entry );\n\t\t\t\treturn OwnedTerminationOutcome.Terminated( processId, forced, forcedMessage );\n\t\t\t}\n\n\t\t\tif ( forcedDeadline.Elapsed >= ForcedBudget ) break;\n\t\t\tawait Task.Delay( PollIntervalMilliseconds, cancellationToken );\n\t\t}\n\n\t\tthrow new OwnedInstanceException( \"Owned instance termination timed out.\", processId );\n\t}\n\n\n\n\tinternal void CorruptCreationIdentityForTesting( int processId )\n\t{\n\t\tlock ( _sync )\n\t\t{\n\t\t\tif ( _entries.TryGetValue( processId, out var entry ) && entry.CreationFileTime is long creation )\n\t\t\t\tentry.CreationFileTime = creation + 1;\n\t\t}\n\t}\n\n\tinternal string ReplaceLaunchTimestampForTesting( int processId, string replacement )\n\t{\n\t\tlock ( _sync )\n\t\t{\n\t\t\tvar entry = _entries[processId];\n\t\t\tvar old = entry.LaunchedAt;\n\t\t\tentry.LaunchedAt = replacement;\n\t\t\treturn old;\n\t\t}\n\t}\n\n\tpublic void Dispose()\n\t{\n\t\tOwnedLocalInstance[] entries;\n\t\tlock ( _sync )\n\t\t{\n\t\t\tif ( _disposed ) return;\n\t\t\t_disposed = true;\n\t\t\tentries = AllEntries().ToArray();\n\t\t\t_entries.Clear();\n\t\t\t_displaced.Clear();\n\t\t}\n\n\t\tforeach ( var entry in entries ) entry.Dispose();\n\t}\n\n\tprivate int AllocateInstanceNumber( Func<ulong, bool> visibleSyntheticIdentity )\n\t{\n\t\tfor ( var attempt = 0; attempt < 256; attempt++ )\n\t\t{\n\t\t\tvar candidate = RandomNumberGenerator.GetInt32( 1_000_000, int.MaxValue );\n\t\t\tlock ( _sync )\n\t\t\t{\n\t\t\t\tThrowIfDisposed();\n\t\t\t\tif ( AllEntries().Any( x => x.InstanceNumber == candidate ) ) continue;\n\t\t\t}\n\n\t\t\tif ( visibleSyntheticIdentity?.Invoke( SyntheticSteamIdentityBase + (ulong)candidate ) == true ) continue;\n\t\t\treturn candidate;\n\t\t}\n\n\t\tthrow new OwnedInstanceException( \"Could not allocate a unique local instance identity.\" );\n\t}\n\n\tprivate OwnedInstanceSnapshot LaunchContained( string executable, IReadOnlyList<string> arguments,\n\t\tstring workingDirectory, bool windowed, int instanceNumber, string logDirectory,\n\t\tLaunchFailureInjection failure )\n\t{\n\t\tif ( !OperatingSystem.IsWindows() )\n\t\t\tthrow new OwnedInstanceException( \"Contained process launching is supported only on Windows.\" );\n\t\tif ( !File.Exists( executable ) )\n\t\t\tthrow new OwnedInstanceException( \"The local instance executable was not found.\" );\n\n\t\tSafeKernelHandle jobHandle = null;\n\t\tSafeKernelHandle processHandle = null;\n\t\tIntPtr threadHandle = IntPtr.Zero;\n\t\tOwnedLocalInstance entry = null;\n\t\tvar processId = 0;\n\t\tvar creationFileTime = 0L;\n\t\tvar resolvedExecutable = (string)null;\n\t\tvar assignedToJob = false;\n\t\tvar resumed = false;\n\n\t\ttry\n\t\t{\n\t\t\tjobHandle = NativeMethods.CreateJobObjectW( IntPtr.Zero, null );\n\t\t\tif ( jobHandle.IsInvalid )\n\t\t\t\tthrow NativeFailure( \"Could not create the private process job.\" );\n\n\t\t\tvar startup = new NativeMethods.StartupInfo { Size = Marshal.SizeOf<NativeMethods.StartupInfo>() };\n\t\t\tvar commandLine = new StringBuilder( BuildCommandLine( executable, arguments ) );\n\t\t\tif ( !NativeMethods.CreateProcessW( executable, commandLine, IntPtr.Zero, IntPtr.Zero, false,\n\t\t\t\tNativeMethods.CreateSuspended | NativeMethods.CreateNoWindow, IntPtr.Zero, workingDirectory,\n\t\t\t\tref startup, out var processInfo ) )\n\t\t\t\tthrow NativeFailure( \"Could not create the suspended local instance.\" );\n\n\t\t\tprocessHandle = new SafeKernelHandle( processInfo.ProcessHandle, true );\n\t\t\tthreadHandle = processInfo.ThreadHandle;\n\t\t\tprocessId = checked((int)processInfo.ProcessId);\n\n\t\t\t// Captured first and unconditionally, so every later failure path - including a failed\n\t\t\t// cleanup - either holds the exact creation-time token this process can be terminated by\n\t\t\t// or knows that none was ever captured and must not be invented.\n\t\t\tcreationFileTime = failure.HasFlag( LaunchFailureInjection.CreationTimeCaptureFails )\n\t\t\t\t? throw new OwnedInstanceException( \"Could not read the owned process creation identity.\", processId, 5 )\n\t\t\t\t: GetCreationFileTime( processHandle, processId );\n\n\t\t\tif ( failure.HasFlag( LaunchFailureInjection.BeforeAssignment ) )\n\t\t\t\tthrow new OwnedInstanceException( \"Injected pre-assignment launch failure.\", processId );\n\n\t\t\tvar assigned = failure.HasFlag( LaunchFailureInjection.AssignmentFails )\n\t\t\t\t? false\n\t\t\t\t: NativeMethods.AssignProcessToJobObject( jobHandle, processHandle );\n\t\t\tif ( !assigned )\n\t\t\t\tthrow new OwnedInstanceException( \"Could not contain the local instance in its private process job.\",\n\t\t\t\t\tprocessId, failure.HasFlag( LaunchFailureInjection.AssignmentFails ) ? 5 : Marshal.GetLastPInvokeError() );\n\t\t\tassignedToJob = true;\n\n\t\t\tif ( !NativeMethods.IsProcessInJob( processHandle, jobHandle, out var inJob ) || !inJob )\n\t\t\t\tthrow NativeFailure( \"Could not verify private process-job containment.\", processId );\n\n\t\t\tif ( failure.HasFlag( LaunchFailureInjection.AfterAssignmentBeforeIdentity ) )\n\t\t\t\tthrow new OwnedInstanceException( \"Injected pre-identity launch failure.\", processId );\n\n\t\t\tif ( failure.HasFlag( LaunchFailureInjection.ImagePathCaptureFails ) )\n\t\t\t\tthrow new OwnedInstanceException( \"Could not read the owned process image identity.\", processId, 31 );\n\t\t\tresolvedExecutable = QueryImagePath( processHandle );\n\n\t\t\tvar expectedExecutable = Path.GetFullPath( executable );\n\t\t\tif ( !string.Equals( NormalizePath( resolvedExecutable ), NormalizePath( expectedExecutable ), StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\tthrow new OwnedInstanceException( \"The launched process image did not match the requested executable.\", processId );\n\n\t\t\tentry = new OwnedLocalInstance( processHandle, jobHandle, processId, creationFileTime,\n\t\t\t\tresolvedExecutable, windowed, instanceNumber, Path.GetFullPath( logDirectory ), true,\n\t\t\t\tLaunchAuthority.Owned );\n\t\t\tprocessHandle = null;\n\t\t\tjobHandle = null;\n\n\t\t\tlock ( _sync )\n\t\t\t{\n\t\t\t\tThrowIfDisposed();\n\t\t\t\tif ( _entries.ContainsKey( processId ) )\n\t\t\t\t\tthrow new OwnedInstanceException( \"The new process ID collided with an owned registry entry.\", processId );\n\t\t\t\t_entries.Add( processId, entry );\n\t\t\t}\n\n\t\t\tif ( NativeMethods.ResumeThread( threadHandle ) == uint.MaxValue )\n\t\t\t\tthrow NativeFailure( \"Could not resume the contained local instance.\", processId );\n\t\t\tresumed = true;\n\t\t\treturn Snapshot( entry );\n\t\t}\n\t\tcatch ( Exception launchError ) when ( !resumed )\n\t\t{\n\t\t\tvar cleanup = CleanupSuspendedLaunch( entry, processHandle, jobHandle, processId,\n\t\t\t\tassignedToJob, creationFileTime, resolvedExecutable, windowed, instanceNumber, logDirectory, failure );\n\t\t\tif ( cleanup.RetainedProcess ) processHandle = null;\n\t\t\tif ( cleanup.RetainedJob ) jobHandle = null;\n\n\t\t\tif ( cleanup.Error != 0 )\n\t\t\t\tthrow new OwnedInstanceException( \"Local instance launch cleanup failed.\", processId, cleanup.Error, launchError );\n\t\t\tthrow;\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tif ( threadHandle != IntPtr.Zero ) NativeMethods.CloseHandle( threadHandle );\n\t\t\tprocessHandle?.Dispose();\n\t\t\tjobHandle?.Dispose();\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Terminates a suspended, never-resumed launch through the authority actually held for it:\n\t/// its private job when assignment succeeded, otherwise the original process handle. On failure\n\t/// the available authority is retained in a distinct failed-launch state so it is not abandoned.\n\t/// </summary>\n\tprivate (int Error, bool RetainedProcess, bool RetainedJob) CleanupSuspendedLaunch( OwnedLocalInstance entry,\n\t\tSafeKernelHandle processHandle, SafeKernelHandle jobHandle, int processId, bool assignedToJob,\n\t\tlong creationFileTime, string resolvedExecutable, bool windowed, int instanceNumber, string logDirectory,\n\t\tLaunchFailureInjection failure )\n\t{\n\t\tvar error = 0;\n\t\tvar job = entry?.JobHandle ?? jobHandle;\n\t\tvar process = entry?.ProcessHandle ?? processHandle;\n\t\tvar useJob = entry?.AssignedToJob ?? assignedToJob;\n\n\t\tif ( failure.HasFlag( LaunchFailureInjection.CleanupTerminationFails ) )\n\t\t{\n\t\t\terror = 5;\n\t\t}\n\t\telse if ( useJob && job is not null && !job.IsInvalid )\n\t\t{\n\t\t\tif ( !NativeMethods.TerminateJobObject( job, 1 ) ) error = Marshal.GetLastPInvokeError();\n\t\t}\n\t\telse if ( process is not null && !process.IsInvalid && !NativeMethods.TerminateProcess( process, 1 ) )\n\t\t{\n\t\t\terror = Marshal.GetLastPInvokeError();\n\t\t}\n\n\t\tif ( error == 0 && process is not null && !process.IsInvalid )\n\t\t{\n\t\t\tvar wait = NativeMethods.WaitForSingleObject( process, 3_000 );\n\t\t\tif ( wait != NativeMethods.WaitObject0 )\n\t\t\t\terror = wait == NativeMethods.WaitFailed ? Marshal.GetLastPInvokeError() : 1460;\n\t\t}\n\n\t\tif ( error == 0 )\n\t\t{\n\t\t\tif ( entry is not null )\n\t\t\t{\n\t\t\t\tlock ( _sync )\n\t\t\t\t{\n\t\t\t\t\tif ( _entries.TryGetValue( processId, out var current ) && ReferenceEquals( current, entry ) )\n\t\t\t\t\t\t_entries.Remove( processId );\n\t\t\t\t}\n\t\t\t\tentry.Dispose();\n\t\t\t}\n\t\t\treturn (0, false, false);\n\t\t}\n\n\t\tif ( entry is not null )\n\t\t{\n\t\t\t// The entry already holds both handles, so it becomes the retained authority itself.\n\t\t\tentry.MarkRetainedFailedLaunch();\n\t\t\tRetainFailedLaunch( entry );\n\t\t\treturn (error, true, true);\n\t\t}\n\n\t\tif ( process is null || process.IsInvalid || processId <= 0 )\n\t\t\treturn (error, false, false);\n\n\t\t// Retain what is actually held. The image path may be unavailable after a capture failure and\n\t\t// the process may never have joined its job; neither is required to keep cleanup authority,\n\t\t// and neither is presented as a contained, fully identified launch. A job handle that is not\n\t\t// taken into the entry stays with the launch scope, which releases it.\n\t\tvar takeJob = useJob && job is not null && !job.IsInvalid;\n\t\tvar candidate = new OwnedLocalInstance( process, takeJob ? job : null, processId,\n\t\t\tcreationFileTime == 0 ? null : creationFileTime, resolvedExecutable, windowed, instanceNumber,\n\t\t\tPath.GetFullPath( logDirectory ), takeJob,\n\t\t\tcreationFileTime == 0 ? LaunchAuthority.RetainedUnidentified : LaunchAuthority.RetainedFailedLaunch );\n\t\tRetainFailedLaunch( candidate, pidKeyOccupied: failure.HasFlag( LaunchFailureInjection.PidKeyOccupied ) );\n\n\t\treturn (error, true, takeJob);\n\t}\n\n\tprivate OwnedInstanceSnapshot Snapshot( OwnedLocalInstance entry )\n\t{\n\t\tvar exited = IsSignaled( entry.ProcessHandle );\n\t\treturn new OwnedInstanceSnapshot( entry.ProcessId, entry.LaunchedAt, entry.Windowed,\n\t\t\tentry.LogDirectory, !exited, exited, checked((int)ActiveProcessCount( entry )) );\n\t}\n\n\tprivate static (bool Forced, string Message) TerminateOwnedTree( OwnedLocalInstance entry, int processId )\n\t{\n\t\tif ( entry.AssignedToJob )\n\t\t{\n\t\t\tif ( !NativeMethods.TerminateJobObject( entry.JobHandle, 1 ) )\n\t\t\t\tthrow new OwnedInstanceException( \"Owned instance termination failed.\", processId, Marshal.GetLastPInvokeError() );\n\t\t\treturn (true, \"The owned process job was terminated.\");\n\t\t}\n\n\t\t// Only a failed launch reaches here: it never joined its private job, so the original\n\t\t// suspended process handle is the authority, and it cannot be confused by PID reuse. No job\n\t\t// termination was invoked, so this must never be reported as a forced termination.\n\t\tif ( !NativeMethods.TerminateProcess( entry.ProcessHandle, 1 ) )\n\t\t\tthrow new OwnedInstanceException( \"Owned instance termination failed.\", processId, Marshal.GetLastPInvokeError() );\n\t\treturn (false, \"The retained failed launch was terminated through its original process handle; no job termination was invoked.\");\n\t}\n\n\tprivate bool ValidateIdentity( OwnedLocalInstance entry, out int nativeError )\n\t{\n\t\tnativeError = 0;\n\t\tif ( NativeMethods.GetProcessId( entry.ProcessHandle ) != (uint)entry.ProcessId )\n\t\t{\n\t\t\tnativeError = Marshal.GetLastPInvokeError();\n\t\t\treturn false;\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\t// A retained launch that never captured a creation time has nothing to compare against:\n\t\t\t// its authority is the retained handle itself, which the kernel pins to that exact\n\t\t\t// process object, and the job membership check below still applies when it was assigned.\n\t\t\tif ( entry.CreationFileTime is long creation\n\t\t\t\t&& GetCreationFileTime( entry.ProcessHandle, entry.ProcessId ) != creation )\n\t\t\t\treturn false;\n\n\t\t\t// Windows no longer provides the image path after a process exits, and a failed launch\n\t\t\t// may never have captured one. Either way the retained handle, exact creation time and\n\t\t\t// job membership continue to pin that same process.\n\t\t\tif ( entry.ExecutablePath is not null && !IsSignaled( entry.ProcessHandle )\n\t\t\t\t&& !string.Equals( NormalizePath( QueryImagePath( entry.ProcessHandle ) ),\n\t\t\t\t\tNormalizePath( entry.ExecutablePath ), StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\treturn false;\n\t\t}\n\t\tcatch ( OwnedInstanceException ex )\n\t\t{\n\t\t\tnativeError = ex.NativeError;\n\t\t\treturn false;\n\t\t}\n\n\t\t// Membership is only meaningful for a process this registry actually assigned.\n\t\tif ( !entry.AssignedToJob ) return true;\n\n\t\tif ( !NativeMethods.IsProcessInJob( entry.ProcessHandle, entry.JobHandle, out var inJob ) )\n\t\t{\n\t\t\tnativeError = Marshal.GetLastPInvokeError();\n\t\t\treturn false;\n\t\t}\n\n\t\treturn inJob;\n\t}\n\n\tprivate static void TryCloseMainWindow( OwnedLocalInstance entry, bool rootExited )\n\t{\n\t\tif ( rootExited || entry.CreationFileTime is null ) return;\n\n\t\t// Post WM_CLOSE rather than Process.CloseMainWindow: PostMessage returns without waiting for\n\t\t// the child window thread, so an unresponsive client cannot stall the editor's main thread.\n\t\tNativeMethods.EnumWindows( (window, _) =>\n\t\t{\n\t\t\tNativeMethods.GetWindowThreadProcessId( window, out var processId );\n\t\t\tif ( processId == (uint)entry.ProcessId )\n\t\t\t\tNativeMethods.PostMessageW( window, NativeMethods.WindowMessageClose, IntPtr.Zero, IntPtr.Zero );\n\t\t\treturn true;\n\t\t}, IntPtr.Zero );\n\t}\n\n\t/// <summary>Every retained entry, whether it holds its PID key or a displaced (PID, token) pair.</summary>\n\tprivate IEnumerable<OwnedLocalInstance> AllEntries() => _entries.Values.Concat( _displaced.Values );\n\n\t/// <summary>The entry whose exact (PID, token) pair matches, in either retention map.</summary>\n\tprivate OwnedLocalInstance FindOwned( int processId, string launchTimestamp )\n\t{\n\t\tif ( launchTimestamp is null ) return null;\n\t\tif ( _entries.TryGetValue( processId, out var primary )\n\t\t\t&& primary.CreationFileTime is not null\n\t\t\t&& string.Equals( primary.LaunchedAt, launchTimestamp, StringComparison.Ordinal ) )\n\t\t\treturn primary;\n\n\t\tif ( _displaced.TryGetValue( (processId, launchTimestamp), out var displaced )\n\t\t\t&& displaced.CreationFileTime is not null )\n\t\t\treturn displaced;\n\n\t\treturn null;\n\t}\n\n\t/// <summary>Whether either retention map still claims this PID, whatever token was supplied.</summary>\n\tprivate bool AnyEntryFor( int processId )\n\t{\n\t\tif ( _entries.ContainsKey( processId ) ) return true;\n\t\tforeach ( var identity in _displaced.Keys )\n\t\t{\n\t\t\tif ( identity.ProcessId == processId ) return true;\n\t\t}\n\n\t\treturn false;\n\t}\n\n\t/// <summary>\n\t/// Publishes a launch that never resumed as a retained failed launch. No caller of this method\n\t/// disposes or closes the candidate's handles: the authority is always published, and a PID key\n\t/// already held by another entry is never overwritten, because dropping either authority would\n\t/// abandon a live process or its tree. Such an entry stays reachable by its exact token, which\n\t/// termination already matches together with the PID. pidKeyOccupied is the harness seam that\n\t/// asserts another entry already holds that PID key.\n\t/// </summary>\n\tprivate void RetainFailedLaunch( OwnedLocalInstance candidate, bool pidKeyOccupied = false )\n\t{\n\t\tlock ( _sync )\n\t\t{\n\t\t\tif ( _entries.TryGetValue( candidate.ProcessId, out var current ) )\n\t\t\t{\n\t\t\t\t// Already published under its own PID key; nothing to move or duplicate.\n\t\t\t\tif ( ReferenceEquals( current, candidate ) ) return;\n\t\t\t}\n\t\t\telse if ( !pidKeyOccupied )\n\t\t\t{\n\t\t\t\t_entries.Add( candidate.ProcessId, candidate );\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t_displaced[(candidate.ProcessId, candidate.LaunchedAt)] = candidate;\n\t\t}\n\t}\n\n\tprivate void RemoveAndDispose( OwnedLocalInstance entry )\n\t{\n\t\tlock ( _sync )\n\t\t{\n\t\t\tif ( _entries.TryGetValue( entry.ProcessId, out var current ) && ReferenceEquals( current, entry ) )\n\t\t\t\t_entries.Remove( entry.ProcessId );\n\t\t\telse if ( !_displaced.Remove( (entry.ProcessId, entry.LaunchedAt) ) )\n\t\t\t\treturn;\n\t\t}\n\t\tentry.Dispose();\n\t}\n\n\tprivate static uint ActiveProcessCount( OwnedLocalInstance entry )\n\t{\n\t\tif ( entry.AssignedToJob ) return GetActiveProcessCount( entry.JobHandle );\n\t\treturn IsSignaled( entry.ProcessHandle ) ? 0u : 1u;\n\t}\n\n\tprivate bool TryCaptureCreationIdentity( OwnedLocalInstance entry )\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar creation = GetCreationFileTime( entry.ProcessHandle, entry.ProcessId );\n\t\t\tvar wasDisplaced = _displaced.Remove( (entry.ProcessId, entry.LaunchedAt) );\n\t\t\tentry.CaptureCreationIdentity( creation );\n\t\t\tif ( wasDisplaced ) _displaced.Add( (entry.ProcessId, entry.LaunchedAt), entry );\n\t\t\treturn true;\n\t\t}\n\t\tcatch ( OwnedInstanceException )\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate static uint GetActiveProcessCount( SafeKernelHandle jobHandle )\n\t{\n\t\tif ( !NativeMethods.QueryInformationJobObject( jobHandle, NativeMethods.JobObjectBasicAccountingInformation,\n\t\t\tout var accounting, (uint)Marshal.SizeOf<NativeMethods.JobBasicAccountingInformation>(), IntPtr.Zero ) )\n\t\t\tthrow NativeFailure( \"Could not inspect the owned process job.\" );\n\t\treturn accounting.ActiveProcesses;\n\t}\n\n\tprivate static bool IsSignaled( SafeKernelHandle processHandle )\n\t{\n\t\tvar result = NativeMethods.WaitForSingleObject( processHandle, 0 );\n\t\tif ( result == NativeMethods.WaitObject0 ) return true;\n\t\tif ( result == NativeMethods.WaitTimeout ) return false;\n\t\tthrow NativeFailure( \"Could not inspect the owned process state.\" );\n\t}\n\n\tprivate static long GetCreationFileTime( SafeKernelHandle processHandle, int processId = 0 )\n\t{\n\t\tif ( !NativeMethods.GetProcessTimes( processHandle, out var creation, out _, out _, out _ ) )\n\t\t\tthrow new OwnedInstanceException( \"Could not read the owned process creation identity.\", processId,\n\t\t\t\tMarshal.GetLastPInvokeError() );\n\t\treturn creation.ToInt64();\n\t}\n\n\tprivate static string QueryImagePath( SafeKernelHandle processHandle )\n\t{\n\t\tvar capacity = 32768u;\n\t\tvar buffer = new StringBuilder( checked((int)capacity) );\n\t\tif ( !NativeMethods.QueryFullProcessImageNameW( processHandle, 0, buffer, ref capacity ) )\n\t\t\tthrow NativeFailure( \"Could not read the owned process image identity.\" );\n\t\treturn Path.GetFullPath( buffer.ToString() );\n\t}\n\n\tprivate static string NormalizePath( string path ) => Path.TrimEndingDirectorySeparator( Path.GetFullPath( path ));\n\n\tprivate static string ResolveLogDirectory()\n\t{\n\t\tvar root = Environment.GetEnvironmentVariable( \"FACEPUNCH_ENGINE\", EnvironmentVariableTarget.User );\n\t\tif ( string.IsNullOrWhiteSpace( root ) ) root = AppContext.BaseDirectory;\n\t\treturn Path.GetFullPath( Path.Combine( root, \"logs\" ) );\n\t}\n\n\tprivate static string BuildCommandLine( string executable, IReadOnlyList<string> arguments )\n\t{\n\t\tvar builder = new StringBuilder();\n\t\tAppendQuotedArgument( builder, executable );\n\t\tforeach ( var argument in arguments )\n\t\t{\n\t\t\tbuilder.Append( ' ' );\n\t\t\tAppendQuotedArgument( builder, argument ?? string.Empty );\n\t\t}\n\t\treturn builder.ToString();\n\t}\n\n\tprivate static void AppendQuotedArgument( StringBuilder builder, string argument )\n\t{\n\t\tbuilder.Append( '\"' );\n\t\tvar slashes = 0;\n\t\tforeach ( var character in argument )\n\t\t{\n\t\t\tif ( character == '\\\\' )\n\t\t\t{\n\t\t\t\tslashes++;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif ( character == '\"' )\n\t\t\t{\n\t\t\t\tbuilder.Append( '\\\\', slashes * 2 + 1 );\n\t\t\t\tbuilder.Append( '\"' );\n\t\t\t\tslashes = 0;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tbuilder.Append( '\\\\', slashes );\n\t\t\tslashes = 0;\n\t\t\tbuilder.Append( character );\n\t\t}\n\t\tbuilder.Append( '\\\\', slashes * 2 );\n\t\tbuilder.Append( '\"' );\n\t}\n\n\tprivate static OwnedInstanceException NativeFailure( string message, int processId = 0 )\n\t{\n\t\tvar error = Marshal.GetLastPInvokeError();\n\t\treturn new OwnedInstanceException( message, processId, error, new Win32Exception( error ));\n\t}\n\n\tprivate void ThrowIfDisposed()\n\t{\n\t\tif ( _disposed ) throw new ObjectDisposedException( nameof(OwnedInstanceRegistry) );\n\t}\n}\n\n/// <summary>\n/// The authority a registry entry holds. A launch that never resumed and whose cleanup could not\n/// confirm termination is retained in one of the failed-launch states instead of being abandoned.\n/// </summary>\ninternal enum LaunchAuthority\n{\n\t/// <summary>A fully identified launch whose process was resumed.</summary>\n\tOwned = 0,\n\n\t/// <summary>\n\t/// A never-resumed launch retained after its cleanup failed with its exact creation time known,\n\t/// so its token is that creation time and job membership still constrains it when assigned.\n\t/// </summary>\n\tRetainedFailedLaunch = 1,\n\n\t/// <summary>\n\t/// A never-resumed launch retained after cleanup failed before its creation time could be read.\n\t/// It remains internal until the retained handle yields the exact creation identity; it is never\n\t/// exposed with a fabricated or overloaded public timestamp.\n\t/// </summary>\n\tRetainedUnidentified = 2\n}\n\n/// <summary>Fault seams used by the focused ownership harness. Production always passes None.</summary>\n[Flags]\ninternal enum LaunchFailureInjection\n{\n\tNone = 0,\n\tBeforeAssignment = 1,\n\tAfterAssignmentBeforeIdentity = 2,\n\tAssignmentFails = 4,\n\tImagePathCaptureFails = 8,\n\tCleanupTerminationFails = 16,\n\n\t/// <summary>Fails the creation-time read, so no creation identity is ever captured.</summary>\n\tCreationTimeCaptureFails = 32,\n\n\t/// <summary>Retains a failed launch as if its PID key were already held by another entry.</summary>\n\tPidKeyOccupied = 64\n}\n\ninternal sealed record OwnedInstanceSnapshot( int ProcessId, string LaunchedAt, bool Windowed,\n\tstring LogDirectory, bool Alive, bool Exited, int ActiveProcessCount );\n\ninternal sealed record OwnedTerminationOutcome( int ProcessId, string Result, bool Forced, string Message )\n{\n\tinternal static OwnedTerminationOutcome Terminated( int processId, bool forced, string message ) =>\n\t\tnew( processId, \"terminated\", forced, message );\n\tinternal static OwnedTerminationOutcome AlreadyExited( int processId ) =>\n\t\tnew( processId, \"already exited\", false, \"The owned process job had already exited.\" );\n\tinternal static OwnedTerminationOutcome NotOwned( int processId ) =>\n\t\tnew( processId, \"not owned\", false, \"No current in-memory ownership entry matches that process.\" );\n\tinternal static OwnedTerminationOutcome IdentityMismatch( int processId ) =>\n\t\tnew( processId, \"identity mismatch\", false, \"The supplied or retained launch identity did not match; no process was signaled.\" );\n}\n\ninternal sealed class OwnedInstanceException : Exception\n{\n\tinternal OwnedInstanceException( string message, int processId = 0, int nativeError = 0, Exception inner = null )\n\t\t: base( message, inner )\n\t{\n\t\tProcessId = processId;\n\t\tNativeError = nativeError;\n\t}\n\tinternal int ProcessId { get; }\n\tinternal int NativeError { get; }\n}\n\ninternal sealed class SafeKernelHandle : SafeHandleZeroOrMinusOneIsInvalid\n{\n\tinternal SafeKernelHandle() : base( true ) { }\n\tinternal SafeKernelHandle( IntPtr handle, bool ownsHandle ) : base( ownsHandle ) => SetHandle( handle );\n\tprotected override bool ReleaseHandle() => NativeMethods.CloseHandle( handle );\n}\n\ninternal static class NativeMethods\n{\n\tinternal const uint CreateSuspended = 0x00000004;\n\tinternal const uint CreateNoWindow = 0x08000000;\n\tinternal const uint WaitObject0 = 0x00000000;\n\tinternal const uint WaitTimeout = 0x00000102;\n\tinternal const uint WaitFailed = 0xFFFFFFFF;\n\tinternal const int JobObjectBasicAccountingInformation = 1;\n\tinternal const uint WindowMessageClose = 0x0010;\n\n\t[StructLayout( LayoutKind.Sequential, CharSet = CharSet.Unicode )]\n\tinternal struct StartupInfo\n\t{\n\t\tinternal int Size;\n\t\tinternal string Reserved;\n\t\tinternal string Desktop;\n\t\tinternal string Title;\n\t\tinternal uint X;\n\t\tinternal uint Y;\n\t\tinternal uint XSize;\n\t\tinternal uint YSize;\n\t\tinternal uint XCountChars;\n\t\tinternal uint YCountChars;\n\t\tinternal uint FillAttribute;\n\t\tinternal uint Flags;\n\t\tinternal ushort ShowWindow;\n\t\tinternal ushort Reserved2;\n\t\tinternal IntPtr Reserved2Pointer;\n\t\tinternal IntPtr StandardInput;\n\t\tinternal IntPtr StandardOutput;\n\t\tinternal IntPtr StandardError;\n\t}\n\n\t[StructLayout( LayoutKind.Sequential )]\n\tinternal struct ProcessInformation\n\t{\n\t\tinternal IntPtr ProcessHandle;\n\t\tinternal IntPtr ThreadHandle;\n\t\tinternal uint ProcessId;\n\t\tinternal uint ThreadId;\n\t}\n\n\t[StructLayout( LayoutKind.Sequential )]\n\tinternal struct FileTime\n\t{\n\t\tinternal uint Low;\n\t\tinternal uint High;\n\t\tinternal long ToInt64() => unchecked((long)(((ulong)High << 32) | Low));\n\t}\n\n\t[StructLayout( LayoutKind.Sequential )]\n\tinternal struct JobBasicAccountingInformation\n\t{\n\t\tinternal long TotalUserTime;\n\t\tinternal long TotalKernelTime;\n\t\tinternal long ThisPeriodTotalUserTime;\n\t\tinternal long ThisPeriodTotalKernelTime;\n\t\tinternal uint TotalPageFaultCount;\n\t\tinternal uint TotalProcesses;\n\t\tinternal uint ActiveProcesses;\n\t\tinternal uint TotalTerminatedProcesses;\n\t}\n\n\tinternal delegate bool EnumWindowsCallback( IntPtr window, IntPtr state );\n\n\t[DllImport( \"user32.dll\", SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool EnumWindows( EnumWindowsCallback callback, IntPtr state );\n\n\t[DllImport( \"user32.dll\", SetLastError = true )]\n\tinternal static extern uint GetWindowThreadProcessId( IntPtr window, out uint processId );\n\n\t[DllImport( \"user32.dll\", EntryPoint = \"PostMessageW\", SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool PostMessageW( IntPtr window, uint message, IntPtr wParam, IntPtr lParam );\n\n\t[DllImport( \"kernel32.dll\", EntryPoint = \"CreateJobObjectW\", CharSet = CharSet.Unicode, SetLastError = true )]\n\tinternal static extern SafeKernelHandle CreateJobObjectW( IntPtr jobAttributes, string name );\n\n\t[DllImport( \"kernel32.dll\", EntryPoint = \"CreateProcessW\", CharSet = CharSet.Unicode, SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool CreateProcessW( string applicationName, StringBuilder commandLine,\n\t\tIntPtr processAttributes, IntPtr threadAttributes, [MarshalAs( UnmanagedType.Bool )] bool inheritHandles,\n\t\tuint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startupInfo,\n\t\tout ProcessInformation processInformation );\n\n\t[DllImport( \"kernel32.dll\", SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool AssignProcessToJobObject( SafeKernelHandle job, SafeKernelHandle process );\n\n\t[DllImport( \"kernel32.dll\", SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool IsProcessInJob( SafeKernelHandle process, SafeKernelHandle job,\n\t\t[MarshalAs( UnmanagedType.Bool )] out bool result );\n\n\t[DllImport( \"kernel32.dll\", SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool GetProcessTimes( SafeKernelHandle process, out FileTime creation,\n\t\tout FileTime exit, out FileTime kernel, out FileTime user );\n\n\t[DllImport( \"kernel32.dll\", EntryPoint = \"QueryFullProcessImageNameW\", CharSet = CharSet.Unicode, SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool QueryFullProcessImageNameW( SafeKernelHandle process, uint flags,\n\t\tStringBuilder imagePath, ref uint size );\n\n\t[DllImport( \"kernel32.dll\", SetLastError = true )]\n\tinternal static extern uint GetProcessId( SafeKernelHandle process );\n\n\t[DllImport( \"kernel32.dll\", SetLastError = true )]\n\tinternal static extern uint ResumeThread( IntPtr thread );\n\n\t[DllImport( \"kernel32.dll\", SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool TerminateProcess( SafeKernelHandle process, uint exitCode );\n\n\t[DllImport( \"kernel32.dll\", SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool TerminateJobObject( SafeKernelHandle job, uint exitCode );\n\n\t[DllImport( \"kernel32.dll\", SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool QueryInformationJobObject( SafeKernelHandle job, int informationClass,\n\t\tout JobBasicAccountingInformation information, uint informationLength, IntPtr returnLength );\n\n\t[DllImport( \"kernel32.dll\", SetLastError = true )]\n\tinternal static extern uint WaitForSingleObject( SafeKernelHandle handle, uint milliseconds );\n\n\t[DllImport( \"kernel32.dll\", SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool CloseHandle( IntPtr handle );\n}\n"
        },
        {
            "Ident": "kitsupanic.sbox_mcp_plus",
            "Path": "Editor/ModelImportBackend.cs",
            "FileName": "ModelImportBackend.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381524,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Runtime.CompilerServices;\nusing System.Threading.Tasks;\n\nnamespace Editor.Mcp;\n\ninternal interface IModelImportBackend\n{\n\tobject RegisterFile( string absolutePath );\n\tobject FindAsset( string assetPath );\n\tstring GetAssetPath( object asset );\n\tobject CreateModel( object sourceAsset, string absoluteModelPath );\n\tbool ReadIsCompiled( object asset );\n\tbool ReadIsCompiledAndUpToDate( object asset );\n\tbool ReadIsCompileFailed( object asset );\n\tValueTask ObserveCompilationIfNeededAsync( object asset );\n\tIReadOnlyList<object> GetReferences( object asset );\n\tIReadOnlyList<string> GetUnrecognizedReferences( object asset );\n\tIReadOnlyList<string> GetInputDependencies( object asset );\n\tIReadOnlyList<string> GetAdditionalContentFiles( object asset );\n}\n\ninternal sealed class ModelImportBackendPipeline\n{\n\tprivate readonly IModelImportBackend _backend;\n\tinternal ModelImportBackendPipeline( IModelImportBackend backend ) => _backend = backend;\n\n\tinternal object RegisterDependency( string absolutePath, string expectedPath )\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar asset = _backend.RegisterFile( absolutePath );\n\t\t\tif ( asset is null )\n\t\t\t\tthrow new ImportContractException( \"RegistrationFailed\", $\"Failed to register '{expectedPath}'.\" );\n\t\t\treturn asset;\n\t\t}\n\t\tcatch ( ImportContractException ) { throw; }\n\t\tcatch ( Exception ex ) { throw new ImportContractException( \"RegistrationFailed\", $\"Registration failed for '{expectedPath}': {Safe( ex.Message, 700 )}\" ); }\n\t}\n\n\tinternal object RegisterSource( string absolutePath, string expectedPath )\n\t{\n\t\tvar asset = RegisterDependency( absolutePath, expectedPath );\n\t\tif ( !SamePath( asset, expectedPath ) )\n\t\t\tthrow new ImportContractException( \"RegistrationFailed\", $\"The source was not registered at '{expectedPath}'.\" );\n\t\treturn asset;\n\t}\n\n\tinternal object CreateModel( object source, string absolutePath, string expectedPath )\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar created = _backend.CreateModel( source, absolutePath );\n\t\t\tvar resolved = _backend.FindAsset( expectedPath );\n\t\t\tif ( created is null || resolved is null || !SamePath( resolved, expectedPath ) )\n\t\t\t\tthrow new ImportContractException( \"ModelCreationFailed\", \"The generated model could not be confirmed at its planned asset path.\" );\n\t\t\treturn resolved;\n\t\t}\n\t\tcatch ( ImportContractException ) { throw; }\n\t\tcatch ( Exception ex ) { throw new ImportContractException( \"ModelCreationFailed\", $\"Model creation failed: {Safe( ex.Message, 700 )}\" ); }\n\t}\n\n\tinternal async ValueTask<BackendCompilationEvidence> ObserveCompilationAsync( object model )\n\t{\n\t\tvar result = new BackendCompilationEvidence();\n\t\tresult.UnavailableEvidence[\"OperationCompletion\"] = \"Installed asset-state APIs do not expose operation completion.\";\n\t\tresult.UnavailableEvidence[\"WriterShutdown\"] = \"Installed asset-state APIs do not expose writer shutdown.\";\n\t\tException helperError = null;\n\t\tif ( TryRead( () => _backend.ReadIsCompiled( model ), out var initiallyCompiled, result, \"IsCompiled\" ) && initiallyCompiled == false )\n\t\t{\n\t\t\ttry { await _backend.ObserveCompilationIfNeededAsync( model ); }\n\t\t\tcatch ( Exception ex ) { helperError = ex; }\n\t\t}\n\t\tTryRead( () => _backend.ReadIsCompiled( model ), out var isCompiled, result, \"IsCompiled\" );\n\t\tTryRead( () => _backend.ReadIsCompiledAndUpToDate( model ), out var isUpToDate, result, \"IsCompiledAndUpToDate\" );\n\t\tTryRead( () => _backend.ReadIsCompileFailed( model ), out var isFailed, result, \"IsCompileFailed\" );\n\t\tresult.IsCompiled = isCompiled;\n\t\tresult.IsCompiledAndUpToDate = isUpToDate;\n\t\tresult.IsCompileFailed = isFailed;\n\t\tvar missing = result.UnavailableEvidence.Keys.Count( x => x is \"IsCompiled\" or \"IsCompiledAndUpToDate\" or \"IsCompileFailed\" );\n\t\tresult.Status = missing == 0 ? \"observed\" : missing == 3 ? \"unavailable\" : \"partial\";\n\t\tif ( result.IsCompileFailed == true ) throw new BackendPipelineException( \"CompileFailed\", \"The model compiler reported failure.\", result );\n\t\tif ( helperError is not null ) throw new BackendPipelineException( \"CompilationUnconfirmed\", $\"Compilation observation failed: {Safe( helperError.Message, 900 )}\", result );\n\t\tif ( result.Status != \"observed\" || result.IsCompiled != true || result.IsCompiledAndUpToDate != true || result.IsCompileFailed != false )\n\t\t\tthrow new BackendPipelineException( \"CompilationUnconfirmed\", \"Successful compilation could not be confirmed from all installed asset-state properties.\", result );\n\t\treturn result;\n\t}\n\n\tinternal BackendDependencyEvidence InspectDependencies( object source, object model )\n\t{\n\t\tvar result = new BackendDependencyEvidence();\n\t\tresult.UnavailableEvidence[\"ExhaustiveSourceDependencies\"] = \"Installed APIs do not expose exhaustive source dependencies.\";\n\t\tresult.UnavailableEvidence[\"OptionalReferenceClassification\"] = \"Unrecognized references do not expose optionality.\";\n\t\tresult.UnavailableEvidence[\"SourceMaterialPreservation\"] = \"The model helper may substitute materials/default.vmat.\";\n\t\tvar pending = new Queue<object>( [source, model] );\n\t\tvar seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\t\tvar failed = false;\n\t\twhile ( pending.Count > 0 )\n\t\t{\n\t\t\tvar asset = pending.Dequeue();\n\t\t\tstring path;\n\t\t\ttry { path = _backend.GetAssetPath( asset ); }\n\t\t\tcatch ( Exception ex )\n\t\t\t{\n\t\t\t\tRecordUnavailable( result, $\"Asset#{seen.Count}\", \"AssetPath\", ex );\n\t\t\t\tfailed = true;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif ( string.IsNullOrWhiteSpace( path ) )\n\t\t\t{\n\t\t\t\tresult.UnavailableEvidence[$\"AssetPath:Asset#{seen.Count}\"] = \"The backend returned no asset path.\";\n\t\t\t\tfailed = true;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif ( !seen.Add( path ) ) continue;\n\t\t\tvar assetFailed = false;\n\t\t\tIReadOnlyList<string> inputs = [], additional = [];\n\t\t\ttry\n\t\t\t{\n\t\t\t\tforeach ( var reference in _backend.GetReferences( asset ) ?? [] )\n\t\t\t\t{\n\t\t\t\t\tpending.Enqueue( reference );\n\t\t\t\t\ttry\n\t\t\t\t\t{\n\t\t\t\t\t\tvar referencePath = _backend.GetAssetPath( reference );\n\t\t\t\t\t\tif ( string.IsNullOrWhiteSpace( referencePath ) ) throw new Exception( \"The backend returned no referenced asset path.\" );\n\t\t\t\t\t\tresult.References.Add( referencePath );\n\t\t\t\t\t}\n\t\t\t\t\tcatch ( Exception ex ) { RecordUnavailable( result, path, \"ReferencePath\", ex ); assetFailed = true; }\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch ( Exception ex ) { RecordUnavailable( result, path, \"References\", ex ); assetFailed = true; }\n\t\t\ttry { result.UnresolvedReferences.AddRange( _backend.GetUnrecognizedReferences( asset ) ?? [] ); }\n\t\t\tcatch ( Exception ex ) { RecordUnavailable( result, path, \"UnrecognizedReferences\", ex ); assetFailed = true; }\n\t\t\ttry { inputs = _backend.GetInputDependencies( asset ) ?? []; result.InputDependencies.AddRange( inputs ); }\n\t\t\tcatch ( Exception ex ) { RecordUnavailable( result, path, \"InputDependencies\", ex ); assetFailed = true; }\n\t\t\ttry { additional = _backend.GetAdditionalContentFiles( asset ) ?? []; result.AdditionalContentFiles.AddRange( additional ); }\n\t\t\tcatch ( Exception ex ) { RecordUnavailable( result, path, \"AdditionalContentFiles\", ex ); assetFailed = true; }\n\t\t\tif ( assetFailed ) failed = true;\n\t\t\telse result.InspectedAssets.Add( path );\n\t\t}\n\t\tNormalize( result.InspectedAssets ); Normalize( result.References ); Normalize( result.InputDependencies );\n\t\tNormalize( result.AdditionalContentFiles ); Normalize( result.UnresolvedReferences );\n\t\tresult.Status = failed ? result.InspectedAssets.Count == 0 ? \"unavailable\" : \"partial\" : \"inspected\";\n\t\tif ( result.Status != \"inspected\" ) throw new BackendPipelineException( \"DependencyInspectionFailed\", \"The promised dependency inspection could not be completed.\", result );\n\t\tif ( result.UnresolvedReferences.Count > 0 ) throw new BackendPipelineException( \"UnresolvedDependencies\", \"One or more reported references could not be resolved; installed APIs do not expose optionality.\", result );\n\t\treturn result;\n\t}\n\n\tprivate bool SamePath( object asset, string expected ) => string.Equals( _backend.GetAssetPath( asset )?.Replace( '\\\\', '/' ), expected, StringComparison.OrdinalIgnoreCase );\n\tprivate static bool TryRead( Func<bool> read, out bool? value, BackendCompilationEvidence evidence, string key )\n\t{\n\t\ttry { value = read(); evidence.UnavailableEvidence.Remove( key ); return true; }\n\t\tcatch ( Exception ex ) { value = null; evidence.UnavailableEvidence[key] = Safe( ex.Message, 256 ); return false; }\n\t}\n\tprivate static void RecordUnavailable( BackendDependencyEvidence evidence, string asset, string query, Exception ex ) =>\n\t\tevidence.UnavailableEvidence[$\"{query}:{Safe( asset, 150 )}\"] = Safe( ex.Message, 256 );\n\tprivate static void Normalize( List<string> paths )\n\t{\n\t\tvar values = paths.Where( x => !string.IsNullOrWhiteSpace( x ) ).Select( x => x.Replace( '\\\\', '/' ) )\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase ).OrderBy( x => x, StringComparer.OrdinalIgnoreCase ).ToArray();\n\t\tpaths.Clear(); paths.AddRange( values );\n\t}\n\tprivate static string Safe( string value, int maximum )\n\t{\n\t\tvar text = string.IsNullOrWhiteSpace( value ) ? \"The operation failed without a message.\" : value.Replace( '\\r', ' ' ).Replace( '\\n', ' ' );\n\t\treturn text.Length <= maximum ? text : text[..maximum];\n\t}\n}\n\ninternal sealed class BackendCompilationEvidence\n{\n\tinternal string Status { get; set; } = \"not_started\";\n\tinternal bool? IsCompiled { get; set; }\n\tinternal bool? IsCompiledAndUpToDate { get; set; }\n\tinternal bool? IsCompileFailed { get; set; }\n\tinternal Dictionary<string, string> UnavailableEvidence { get; } = new( StringComparer.Ordinal );\n}\n\ninternal sealed class BackendDependencyEvidence\n{\n\tinternal string Status { get; set; } = \"not_started\";\n\tinternal List<string> InspectedAssets { get; } = [];\n\tinternal List<string> References { get; } = [];\n\tinternal List<string> InputDependencies { get; } = [];\n\tinternal List<string> AdditionalContentFiles { get; } = [];\n\tinternal List<string> UnresolvedReferences { get; } = [];\n\tinternal Dictionary<string, string> UnavailableEvidence { get; } = new( StringComparer.Ordinal );\n}\n\ninternal sealed class BackendPipelineException : ImportContractException\n{\n\tinternal object Evidence { get; }\n\tinternal BackendPipelineException( string code, string message, object evidence ) : base( code, message ) => Evidence = evidence;\n}\n\ninternal sealed class ModelImportRequestGate\n{\n\tprivate StrongBox<long> _active;\n\tinternal ModelImportRequestGate() : this( new StrongBox<long>( 0 ) ) { }\n\tinternal ModelImportRequestGate( StrongBox<long> active ) => _active = active ?? throw new ArgumentNullException( nameof( active ) );\n\tprivate StrongBox<long> Active => _active ??= new StrongBox<long>( 0 );\n\tinternal StrongBox<long> State => Active;\n\tinternal bool IsActive => System.Threading.Volatile.Read( ref Active.Value ) != 0;\n\tinternal Lease TryEnter()\n\t{\n\t\tvar state = Active;\n\t\treturn System.Threading.Interlocked.CompareExchange( ref state.Value, 1, 0 ) == 0 ? new Lease( state ) : null;\n\t}\n\tinternal void Restore( bool active ) => System.Threading.Volatile.Write( ref Active.Value, active ? 1 : 0 );\n\n\tinternal sealed class Lease : IDisposable\n\t{\n\t\tprivate StrongBox<long> _state;\n\t\tinternal Lease( StrongBox<long> state ) => _state = state;\n\t\tpublic void Dispose()\n\t\t{\n\t\t\tvar state = System.Threading.Interlocked.Exchange( ref _state, null );\n\t\t\tif ( state is not null ) System.Threading.Volatile.Write( ref state.Value, 0 );\n\t\t}\n\t}\n}\ninternal sealed record ModelImportHotloadSnapshot(\n\tint Version, bool ActiveRequest, string[] PendingPaths, string[] OwnedDirectories );\n\ninternal static class ModelImportHotloadTransfer\n{\n\tinternal static ModelImportHotloadSnapshot Read( IReadOnlyDictionary<string, object> state, bool legacyInvocationFinished )\n\t{\n\t\tvar pendingValid = state.TryGetValue( \"ModelImportPending\", out var pendingValue ) && pendingValue is IEnumerable<string>;\n\t\tvar ownedValid = state.TryGetValue( \"ModelImportOwnedDirectories\", out var ownedValue ) && ownedValue is IEnumerable<string>;\n\t\tvar busyValid = state.TryGetValue( \"ModelImportBusy\", out var busyValue ) && busyValue is bool;\n\t\tif ( !pendingValid || !ownedValid || !busyValid )\n\t\t\tthrow new ImportContractException( \"ImportBusy\", \"Model import hotload state transfer was incomplete.\" );\n\t\tvar version = state.TryGetValue( \"ModelImportStateVersion\", out var versionValue ) && versionValue is int parsed ? parsed : 1;\n\t\tvar active = version >= 4 ? (bool)busyValue : !legacyInvocationFinished && (bool)busyValue;\n\t\treturn new ModelImportHotloadSnapshot( version, active,\n\t\t\t((IEnumerable<string>)pendingValue).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray(),\n\t\t\t((IEnumerable<string>)ownedValue).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray() );\n\t}\n\n\tinternal static void Write( Dictionary<string, object> state, ModelImportRequestGate gate, IEnumerable<string> pending, IEnumerable<string> owned )\n\t{\n\t\tstate[\"ModelImportStateVersion\"] = 4;\n\t\tstate[\"ModelImportBusy\"] = gate.IsActive;\n\t\tstate[\"ModelImportGateState\"] = gate.State;\n\t\tstate[\"ModelImportPending\"] = pending.Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();\n\t\tstate[\"ModelImportOwnedDirectories\"] = owned.Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();\n\t}\n}"
        },
        {
            "Ident": "kitsupanic.sbox_mcp_plus",
            "Path": "Editor/McpExtras.ModelSkeleton.cs",
            "FileName": "McpExtras.ModelSkeleton.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381524,
            "IsPrivate": false,
            "Code": "using Sandbox;\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace Editor.Mcp;\n\npublic static partial class ExtrasTools\n{\n\t/// <summary>\n\t/// Return read-only model-space bounds, bones, attachment ownership, material groups and animation\n\t/// names from an installed model resource. Unavailable complete fields are null with a reason;\n\t/// inspected empty collections remain empty. Skinning presence is unavailable in installed public APIs.\n\t/// </summary>\n\t/// <param name=\"model\">Model asset path resolvable by AssetSystem.FindByPath.</param>\n\t[McpTool.ReadOnly( \"x_model_skeleton\" )]\n\tpublic static ModelSkeletonResult GetModelSkeleton( string model )\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( model ) ) throw new Exception( \"Give a model asset path.\" );\n\t\tvar asset = AssetSystem.FindByPath( model.Replace( '\\\\', '/' ) )\n\t\t\t?? throw new Exception( $\"Model asset '{Safe( model, 512 )}' was not found.\" );\n\t\tModel resource;\n\t\ttry { resource = asset.LoadResource<Model>(); }\n\t\tcatch ( Exception ex ) { throw new Exception( $\"Model asset could not be loaded: {Safe( ex.Message, 512 )}\" ); }\n\t\tif ( resource is null || !resource.IsValid || resource.IsError )\n\t\t\tthrow new Exception( $\"Model asset '{Safe( asset.Path, 512 )}' did not load as a valid non-error model resource.\" );\n\n\t\tvar result = new ModelSkeletonResult { ModelAsset = asset.Path?.Replace( '\\\\', '/' ) };\n\t\tCapture( \"Bounds\", () =>\n\t\t{\n\t\t\tvar bounds = resource.RenderBounds;\n\t\t\tresult.Bounds = new ModelBoundsEvidence { Space = \"model\", Mins = bounds.Mins, Maxs = bounds.Maxs };\n\t\t}, result );\n\t\tCapture( \"Bones\", () => result.Bones = resource.Bones.AllBones.Select( x => new ModelBoneEvidence\n\t\t{\n\t\t\tIndex = x.Index, Name = x.Name, ParentIndex = x.Parent?.Index ?? -1\n\t\t} ).ToList(), result );\n\t\tCapture( \"Attachments\", () => result.Attachments = resource.Attachments.All.Select( x => new ModelAttachmentEvidence\n\t\t{\n\t\t\tName = x.Name, BoneIndex = x.Bone?.Index\n\t\t} ).ToList(), result );\n\t\tCapture( \"MaterialGroups\", () =>\n\t\t{\n\t\t\tvar groups = new List<ModelMaterialGroupEvidence>();\n\t\t\tfor ( var i = 0; i < resource.MaterialGroupCount; ++i )\n\t\t\t{\n\t\t\t\tgroups.Add( new ModelMaterialGroupEvidence\n\t\t\t\t{\n\t\t\t\t\tIndex = i,\n\t\t\t\t\tName = resource.GetMaterialGroupName( i ),\n\t\t\t\t\tMaterials = resource.GetMaterials( i ).Select( x => x?.ResourcePath?.Replace( '\\\\', '/' ) ).Where( x => x is not null ).ToList()\n\t\t\t\t} );\n\t\t\t}\n\t\t\tresult.MaterialGroups = groups;\n\t\t}, result );\n\t\tCapture( \"AnimationSequences\", () =>\n\t\t{\n\t\t\tvar animations = new List<string>( resource.AnimationCount );\n\t\t\tfor ( var i = 0; i < resource.AnimationCount; ++i ) animations.Add( resource.GetAnimationName( i ) );\n\t\t\tresult.AnimationSequences = animations;\n\t\t}, result );\n\t\tresult.HasSkinningData = null;\n\t\tresult.UnavailableFields[\"HasSkinningData\"] = \"Installed public model APIs do not expose skinning-data presence.\";\n\t\treturn result;\n\t}\n\n\tprivate static void Capture( string field, Action inspect, ModelSkeletonResult result )\n\t{\n\t\ttry { inspect(); }\n\t\tcatch ( Exception ex ) { result.UnavailableFields[field] = Safe( ex.Message, 256 ); }\n\t}\n}\n\npublic sealed class ModelSkeletonResult\n{\n\tpublic string ModelAsset { get; set; }\n\tpublic ModelBoundsEvidence Bounds { get; set; }\n\tpublic List<ModelBoneEvidence> Bones { get; set; }\n\tpublic List<ModelAttachmentEvidence> Attachments { get; set; }\n\tpublic List<ModelMaterialGroupEvidence> MaterialGroups { get; set; }\n\tpublic List<string> AnimationSequences { get; set; }\n\tpublic bool? HasSkinningData { get; set; }\n\tpublic Dictionary<string, string> UnavailableFields { get; set; } = new( StringComparer.Ordinal );\n}\n\npublic sealed class ModelBoundsEvidence\n{\n\tpublic string Space { get; set; }\n\tpublic Vector3 Mins { get; set; }\n\tpublic Vector3 Maxs { get; set; }\n}\n\npublic sealed class ModelBoneEvidence\n{\n\tpublic int Index { get; set; }\n\tpublic string Name { get; set; }\n\tpublic int ParentIndex { get; set; }\n}\n\npublic sealed class ModelAttachmentEvidence\n{\n\tpublic string Name { get; set; }\n\tpublic int? BoneIndex { get; set; }\n}\n\npublic sealed class ModelMaterialGroupEvidence\n{\n\tpublic int Index { get; set; }\n\tpublic string Name { get; set; }\n\tpublic List<string> Materials { get; set; } = [];\n}\n"
        },
        {
            "Ident": "kitsupanic.sbox_mcp_plus",
            "Path": "Editor/McpExtras.Network.cs",
            "FileName": "McpExtras.Network.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381524,
            "IsPrivate": false,
            "Code": "using Sandbox;\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text;\nusing System.Text.RegularExpressions;\nusing System.Threading;\nusing System.Threading.Tasks;\n\nnamespace Editor.Mcp;\n\npublic static partial class ExtrasTools\n{\n\tprivate static NetworkToolLifetime NetworkLifetime = new();\n\tprivate static readonly Regex Steam2Pattern = new( @\"STEAM_[0-5]:[01]:\\d+\", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds( 100 ) );\n\tprivate static readonly Regex Steam3Pattern = new( @\"\\[[A-Za-z]:\\d+:\\d+\\]\", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds( 100 ) );\n\tprivate static readonly Regex Steam64Pattern = new( @\"(?<!\\d)\\d{17}(?!\\d)\", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds( 100 ) );\n\n\t/// <summary>Inspect the active network session without exposing account, party, chat, voice, or credential data.</summary>\n\t[McpTool.ReadOnly( \"x_network_status\" )]\n\tpublic static NetworkState GetNetworkStatus()\n\t{\n\t\treturn SnapshotNetwork();\n\t}\n\n\t/// <summary>Start hosting through the editor's supported network path and return the observed state.</summary>\n\t[McpTool( \"x_network_start_hosting\" )]\n\tpublic static NetworkState StartNetworkHosting()\n\t{\n\t\tvar lifetime = CurrentLifetime();\n\t\tusing var operation = lifetime.EnterMutation();\n\t\tif ( EditorUtility.Network.Active || Networking.IsConnecting )\n\t\t\tthrow new Exception( \"Already connected or connecting; disconnect first.\" );\n\n\t\ttry\n\t\t{\n\t\t\tEditorUtility.Network.StartHosting();\n\t\t}\n\t\tcatch ( Exception )\n\t\t{\n\t\t\tthrow new Exception( \"Starting hosting failed; the editor reported an error and no other change was made.\" );\n\t\t}\n\n\t\tlifetime.ThrowIfInvalid();\n\t\treturn SnapshotNetwork();\n\t}\n\n\t/// <summary>Disconnect the editor from its current network session without terminating owned child instances.</summary>\n\t[McpTool( \"x_network_disconnect\" )]\n\tpublic static NetworkState DisconnectNetwork()\n\t{\n\t\tvar lifetime = CurrentLifetime();\n\t\tusing var operation = lifetime.EnterMutation();\n\t\tif ( !EditorUtility.Network.Active && !Networking.IsConnecting )\n\t\t\tthrow new Exception( \"No network session is active.\" );\n\n\t\ttry\n\t\t{\n\t\t\tEditorUtility.Network.Disconnect();\n\t\t}\n\t\tcatch ( Exception )\n\t\t{\n\t\t\tthrow new Exception( \"Disconnecting failed; the editor reported an error and owned instances were left untouched.\" );\n\t\t}\n\n\t\tlifetime.ThrowIfInvalid();\n\t\treturn SnapshotNetwork();\n\t}\n\n\t/// <summary>\n\t/// Launch a fixed local sbox client contained in a private process job. The argument list is fixed\n\t/// and deliberately does not copy arbitrary editor preference command-line arguments.\n\t/// </summary>\n\t[McpTool( \"x_network_spawn_instance\" )]\n\tpublic static InstanceState SpawnNetworkInstance( bool? windowed = null )\n\t{\n\t\tvar lifetime = CurrentLifetime();\n\t\tusing var operation = lifetime.EnterMutation();\n\t\tRequireStableHosting();\n\t\tvar effectiveWindowed = windowed ?? EditorPreferences.WindowedLocalInstances;\n\n\t\tOwnedInstanceSnapshot snapshot;\n\t\ttry\n\t\t{\n\t\t\tsnapshot = lifetime.Registry.LaunchSbox( effectiveWindowed, IsSyntheticIdentityVisible );\n\t\t}\n\t\tcatch ( OwnedInstanceException ex )\n\t\t{\n\t\t\tthrow TranslateLaunchFailure( ex );\n\t\t}\n\t\tcatch ( Exception )\n\t\t{\n\t\t\tthrow new Exception( \"Local instance launch setup failed; no process was started.\" );\n\t\t}\n\n\t\tlifetime.ThrowIfInvalid();\n\t\treturn MapInstance( snapshot );\n\t}\n\n\t/// <summary>List only this hotload lifetime's in-memory owned process jobs; never enumerate OS processes.</summary>\n\t[McpTool.ReadOnly( \"x_network_instances\" )]\n\tpublic static InstanceState[] GetNetworkInstances()\n\t{\n\t\tvar lifetime = CurrentLifetime();\n\t\ttry\n\t\t{\n\t\t\treturn lifetime.Registry.List( !lifetime.IsBusy ).Select( MapInstance ).ToArray();\n\t\t}\n\t\tcatch ( OwnedInstanceException )\n\t\t{\n\t\t\tthrow new Exception( \"Owned instance inspection failed.\" );\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Refuse local-client host migration before launching anything. Installed engine 26.09.08e\n\t/// chooses successors from Steam lobby membership, which synthetic loopback clients cannot join,\n\t/// and exposes no supported API that can target or certify a local successor.\n\t/// </summary>\n\t[McpTool( \"x_network_migrate_to_new_instance\" )]\n\tpublic static Task<NetworkState> MigrateNetworkToNewInstance( bool? windowed = null, int timeoutSeconds = 120 )\n\t{\n\t\tvar lifetime = CurrentLifetime();\n\t\tusing var operation = lifetime.EnterMutation();\n\t\tif ( timeoutSeconds < 1 || timeoutSeconds > 120 )\n\t\t\tthrow new Exception( \"timeoutSeconds must be between 1 and 120.\" );\n\t\tif ( !IsStableHosting() || GetRemoteConnections().Length != 0 )\n\t\t\tthrow new Exception( \"Migration requires a hosting session with no remote peers.\" );\n\n\t\tthrow new Exception( \"The installed engine cannot migrate hosting to a synthetic local client; no process was launched and the editor remains connected.\" );\n\t}\n\n\t/// <summary>\n\t/// Terminate exactly one currently owned process job. Both the PID and the exact creation-time\n\t/// LaunchedAt token returned by spawn or listing are required. Retained cleanup authority stays\n\t/// internal until that exact identity is readable. Abrupt skips the two-second graceful close budget.\n\t/// </summary>\n\t[McpTool( \"x_network_terminate_instance\" )]\n\tpublic static async Task<InstanceTermination> TerminateNetworkInstance( int processId, string launchTimestamp, bool abrupt = false )\n\t{\n\t\tvar lifetime = CurrentLifetime();\n\t\tusing var operation = lifetime.EnterMutation();\n\t\ttry\n\t\t{\n\t\t\tvar outcome = await lifetime.Registry.TerminateAsync( processId, launchTimestamp, abrupt, lifetime.CancellationToken );\n\t\t\tlifetime.ThrowIfInvalid();\n\t\t\treturn new InstanceTermination\n\t\t\t{\n\t\t\t\tProcessId = outcome.ProcessId,\n\t\t\t\tResult = outcome.Result,\n\t\t\t\tForced = outcome.Forced,\n\t\t\t\tMessage = outcome.Message\n\t\t\t};\n\t\t}\n\t\tcatch ( OperationCanceledException ) when ( !lifetime.IsValid )\n\t\t{\n\t\t\tthrow new Exception( NetworkToolLifetime.InvalidatedMessage );\n\t\t}\n\t\tcatch ( OwnedInstanceException ex )\n\t\t{\n\t\t\tvar native = ex.NativeError > 0 ? $\"; native error {ex.NativeError}\" : string.Empty;\n\t\t\tthrow new Exception( $\"{ex.Message} PID {processId}{native}. Ownership is retained; the instance stays listed.\" );\n\t\t}\n\t}\n\n\t/// <summary>A privacy-limited snapshot of the active editor network session.</summary>\n\tpublic class NetworkState\n\t{\n\t\tpublic bool IsActive { get; set; }\n\t\tpublic bool IsHost { get; set; }\n\t\tpublic bool IsClient { get; set; }\n\t\tpublic bool IsConnecting { get; set; }\n\t\tpublic Guid? LocalConnectionId { get; set; }\n\t\tpublic Guid? HostConnectionId { get; set; }\n\t\tpublic ConnectionState[] Connections { get; set; }\n\t}\n\n\t/// <summary>A visible connection with account and party identifiers deliberately omitted.</summary>\n\tpublic class ConnectionState\n\t{\n\t\tpublic Guid Id { get; set; }\n\t\tpublic string DisplayName { get; set; }\n\t\tpublic bool IsHost { get; set; }\n\t\tpublic bool IsLocal { get; set; }\n\t\tpublic bool IsActive { get; set; }\n\t\tpublic bool IsConnecting { get; set; }\n\t\tpublic float Ping { get; set; }\n\t}\n\n\t/// <summary>\n\t/// An in-memory owned local process descriptor. LaunchedAt is the exact UTC process creation time\n\t/// and its termination token; unidentified cleanup authority is not exposed until this is readable.\n\t/// </summary>\n\tpublic class InstanceState\n\t{\n\t\tpublic int ProcessId { get; set; }\n\t\tpublic string LaunchedAt { get; set; }\n\t\tpublic bool Windowed { get; set; }\n\t\tpublic string LogDirectory { get; set; }\n\t\tpublic bool Alive { get; set; }\n\t\tpublic bool Exited { get; set; }\n\t\tpublic int ActiveProcessCount { get; set; }\n\t}\n\n\t/// <summary>The bounded result of an ownership-checked termination request.</summary>\n\tpublic class InstanceTermination\n\t{\n\t\tpublic int ProcessId { get; set; }\n\t\tpublic string Result { get; set; }\n\t\tpublic bool Forced { get; set; }\n\t\tpublic string Message { get; set; }\n\t}\n\n\tprivate static NetworkToolLifetime CurrentLifetime()\n\t{\n\t\tvar lifetime = NetworkLifetime;\n\t\tlifetime.ThrowIfInvalid();\n\t\treturn lifetime;\n\t}\n\n\tprivate static void RequireStableHosting()\n\t{\n\t\tif ( !IsStableHosting() ) throw new Exception( \"Editor must be hosting first.\" );\n\t}\n\n\tprivate static bool IsStableHosting()\n\t{\n\t\treturn EditorUtility.Network.Hosting && Networking.IsActive && !Networking.IsConnecting;\n\t}\n\n\tprivate static NetworkState SnapshotNetwork()\n\t{\n\t\tvar active = Networking.IsActive;\n\t\tif ( !active )\n\t\t{\n\t\t\treturn new NetworkState\n\t\t\t{\n\t\t\t\tIsActive = false,\n\t\t\t\tIsHost = false,\n\t\t\t\tIsClient = false,\n\t\t\t\tIsConnecting = Networking.IsConnecting,\n\t\t\t\tLocalConnectionId = null,\n\t\t\t\tHostConnectionId = null,\n\t\t\t\tConnections = Array.Empty<ConnectionState>()\n\t\t\t};\n\t\t}\n\n\t\tvar local = Connection.Local;\n\t\tvar host = Connection.Host;\n\t\tvar localId = local?.Id;\n\t\tvar hostId = host?.Id;\n\t\tvar connections = Connection.All\n\t\t\t.Where( connection => connection is not null )\n\t\t\t.Select( connection => new ConnectionState\n\t\t\t{\n\t\t\t\tId = connection.Id,\n\t\t\t\tDisplayName = SanitizeDisplayName( connection.DisplayName ),\n\t\t\t\tIsHost = hostId.HasValue && connection.Id == hostId.Value,\n\t\t\t\tIsLocal = localId.HasValue && connection.Id == localId.Value,\n\t\t\t\tIsActive = connection.IsActive,\n\t\t\t\tIsConnecting = connection.IsConnecting,\n\t\t\t\tPing = connection.Ping\n\t\t\t})\n\t\t\t.ToArray();\n\n\t\treturn new NetworkState\n\t\t{\n\t\t\tIsActive = true,\n\t\t\tIsHost = Networking.IsHost,\n\t\t\tIsClient = Networking.IsClient,\n\t\t\tIsConnecting = Networking.IsConnecting,\n\t\t\tLocalConnectionId = localId,\n\t\t\tHostConnectionId = hostId,\n\t\t\tConnections = connections\n\t\t};\n\t}\n\n\tprivate static Connection[] GetRemoteConnections()\n\t{\n\t\tvar localId = Connection.Local?.Id;\n\t\treturn Connection.All\n\t\t\t.Where( connection => connection is not null && (!localId.HasValue || connection.Id != localId.Value) )\n\t\t\t.ToArray();\n\t}\n\n\tprivate static bool IsSyntheticIdentityVisible( ulong identity )\n\t{\n\t\treturn Connection.All.Any( connection => connection is not null && connection.SteamId.ValueUnsigned == identity );\n\t}\n\n\tprivate static string SanitizeDisplayName( string value )\n\t{\n\t\tif ( string.IsNullOrEmpty( value ) ) return string.Empty;\n\t\tvar clean = new StringBuilder( Math.Min( value.Length, 512 ) );\n\t\tforeach ( var character in value )\n\t\t{\n\t\t\tif ( clean.Length == 512 ) break;\n\t\t\tif ( !char.IsControl( character ) ) clean.Append( character );\n\t\t}\n\n\t\tvar result = Steam2Pattern.Replace( clean.ToString(), \"[redacted]\" );\n\t\tresult = Steam3Pattern.Replace( result, \"[redacted]\" );\n\t\tresult = Steam64Pattern.Replace( result, \"[redacted]\" );\n\t\treturn result.Length <= 128 ? result : result[..128];\n\t}\n\n\tprivate static InstanceState MapInstance( OwnedInstanceSnapshot snapshot )\n\t{\n\t\treturn new InstanceState\n\t\t{\n\t\t\tProcessId = snapshot.ProcessId,\n\t\t\tLaunchedAt = snapshot.LaunchedAt,\n\t\t\tWindowed = snapshot.Windowed,\n\t\t\tLogDirectory = snapshot.LogDirectory,\n\t\t\tAlive = snapshot.Alive,\n\t\t\tExited = snapshot.Exited,\n\t\t\tActiveProcessCount = snapshot.ActiveProcessCount\n\t\t};\n\t}\n\n\tprivate static Exception TranslateLaunchFailure( OwnedInstanceException exception )\n\t{\n\t\tvar process = exception.ProcessId > 0 ? $\" PID {exception.ProcessId}.\" : string.Empty;\n\t\tvar native = exception.NativeError > 0 ? $\" Native error {exception.NativeError}.\" : string.Empty;\n\t\treturn new Exception( exception.Message + process + native );\n\t}\n\n}\n\ninternal sealed class NetworkToolLifetime : IHotloadManaged\n{\n\tinternal const string BusyMessage = \"Another network operation is in progress; wait for it to finish.\";\n\tinternal const string InvalidatedMessage = \"The network tools were invalidated by a library hotload; retry the operation.\";\n\n\tprivate OwnedInstanceRegistry _registry = new();\n\tprivate CancellationTokenSource _cancellation = new();\n\tprivate long _busy;\n\tprivate int _valid = 1;\n\n\t// Null only after invalidation released the handles; callers report the fixed invalidation error.\n\tinternal OwnedInstanceRegistry Registry\n\t{\n\t\tget\n\t\t{\n\t\t\tvar registry = Volatile.Read( ref _registry );\n\t\t\tif ( registry is null ) throw new Exception( NetworkToolLifetime.InvalidatedMessage );\n\t\t\treturn registry;\n\t\t}\n\t}\n\n\tinternal CancellationToken CancellationToken => _cancellation.Token;\n\tinternal bool IsBusy => Volatile.Read( ref _busy ) != 0;\n\tinternal bool IsValid => Volatile.Read( ref _valid ) != 0;\n\n\tinternal IDisposable EnterMutation()\n\t{\n\t\tThrowIfInvalid();\n\t\tif ( Interlocked.CompareExchange( ref _busy, 1, 0 ) != 0 )\n\t\t\tthrow new Exception( BusyMessage );\n\t\tif ( !IsValid )\n\t\t{\n\t\t\tVolatile.Write( ref _busy, 0 );\n\t\t\tthrow new Exception( NetworkToolLifetime.InvalidatedMessage );\n\t\t}\n\t\treturn new MutationLease( this );\n\t}\n\n\tinternal void ThrowIfInvalid()\n\t{\n\t\tif ( !IsValid ) throw new Exception( NetworkToolLifetime.InvalidatedMessage );\n\t}\n\n\tpublic void Destroyed( Dictionary<string, object> state ) => Invalidate();\n\n\t// Hotload state is deliberately not transferred: surviving children cease to be owned.\n\n\tpublic void Created( IReadOnlyDictionary<string, object> state )\n\t{\n\t\t_registry = new OwnedInstanceRegistry();\n\t\t_cancellation = new CancellationTokenSource();\n\t\tVolatile.Write( ref _busy, 0 );\n\t\tVolatile.Write( ref _valid, 1 );\n\t}\n\n\tpublic void Persisted() { }\n\tpublic void Failed() => Invalidate();\n\n\tprivate void Invalidate()\n\t{\n\t\tVolatile.Write( ref _valid, 0 );\n\t\t_cancellation.Cancel();\n\n\t\t// No new mutation can start once invalid, so the only reason to wait is an operation\n\t\t// still holding retained handles. Closing them never terminates a process.\n\t\tReleaseHandlesWhenIdle();\n\t}\n\n\tprivate void ReleaseHandlesWhenIdle()\n\t{\n\t\tif ( Volatile.Read( ref _busy ) != 0 ) return;\n\t\tInterlocked.Exchange( ref _registry, null )?.Dispose();\n\t}\n\n\tprivate sealed class MutationLease : IDisposable\n\t{\n\t\tprivate NetworkToolLifetime _owner;\n\t\tinternal MutationLease( NetworkToolLifetime owner ) => _owner = owner;\n\t\tpublic void Dispose()\n\t\t{\n\t\t\tvar owner = Interlocked.Exchange( ref _owner, null );\n\t\t\tif ( owner is null ) return;\n\t\t\tVolatile.Write( ref owner._busy, 0 );\n\t\t\tif ( !owner.IsValid ) owner.ReleaseHandlesWhenIdle();\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "kitsupanic.sbox_mcp_plus",
            "Path": "Editor/McpExtras.ModelImport.cs",
            "FileName": "McpExtras.ModelImport.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381524,
            "IsPrivate": false,
            "Code": "using Sandbox;\nusing System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Threading;\nusing System.Text.Json;\nusing System.Threading.Tasks;\n\nnamespace Editor.Mcp;\n\npublic static partial class ExtrasTools\n{\n\tprivate static ModelImportLifetime ModelImportLifetime = new();\n\tprivate static readonly IModelImportBackend ImportBackend = new SandboxModelImportBackend();\n\tprivate static readonly ModelImportBackendPipeline ImportPipeline = new( ImportBackend );\n\n\t/// <summary>\n\t/// Import a new external FBX, OBJ or DMX source into an isolated directory under the active\n\t/// project's Assets root. Native model creation and compilation are synchronous and can block\n\t/// the editor without a hard deadline. Returned evidence distinguishes observed asset state\n\t/// from unavailable operation-completion, dependency-coverage and writer-shutdown evidence.\n\t/// </summary>\n\t/// <param name=\"sourcePath\">Absolute readable path to one .fbx, .obj or .dmx source.</param>\n\t/// <param name=\"targetDirectory\">New final directory relative to Assets; no implicit subdirectory is appended.</param>\n\t/// <param name=\"modelName\">Generated .vmdl stem only. Null or empty defaults to the source stem.</param>\n\t/// <param name=\"overwrite\">Reserved. True always returns OverwriteUnsupported before mutation.</param>\n\t/// <param name=\"copySiblingTextures\">Copy every allowlisted immediate sibling image; false does not enumerate siblings.</param>\n\t[McpTool( \"x_import_model_source\" )]\n\tpublic static async Task<ImportModelResult> ImportModelSource( string sourcePath, string targetDirectory,\n\t\tstring modelName = \"\", bool overwrite = false, bool copySiblingTextures = true )\n\t{\n\t\tvar result = new ImportModelResult();\n\t\tModelImportTransaction transaction = null;\n\t\tvar lease = ModelImportLifetime.TryEnter();\n\t\tif ( lease is null ) return BusyResult( result );\n\n\t\ttry\n\t\t{\n\t\t\tif ( overwrite ) return result.Fail( \"OverwriteUnsupported\", \"overwrite=true is unsupported in version 1.\" );\n\t\t\tvar project = Project.Current;\n\t\t\tif ( project is null ) return result.Fail( \"NoActiveProject\", \"No active project is available.\" );\n\t\t\tif ( Game.IsPlaying ) return result.Fail( \"PlayMode\", \"Model import is unavailable while play mode is running.\" );\n\t\t\tvar projectIdent = project.Config?.Ident;\n\t\t\tvar assetsRoot = project.GetAssetsPath();\n\t\t\tModelImportLifetime.ScanMarkers( assetsRoot );\n\n\t\t\ttransaction = ModelImportTransaction.Plan( assetsRoot, sourcePath, targetDirectory, modelName, copySiblingTextures );\n\t\t\tresult.SourceAsset = transaction.SourceAsset;\n\t\t\tresult.ModelAsset = transaction.ModelAsset;\n\t\t\tif ( ModelImportLifetime.IsOwnedDestination( transaction.RelativeDirectory ) )\n\t\t\t\tthrow new ImportContractException( \"DestinationExists\", \"The destination is nested beneath an earlier import-owned directory.\" );\n\t\t\tEnsureOutputsAbsent( transaction );\n\t\t\tEnsureProjectState( project, projectIdent );\n\n\t\t\tresult.Stage = \"copy\";\n\t\t\ttransaction.ReserveDirectory();\n\t\t\tresult.CopiedFiles.AddRange( transaction.CopyInputs() );\n\t\t\ttransaction.WriteMarker( \"not_started\" );\n\t\t\tresult.GeneratedFiles.Add( transaction.MarkerAsset );\n\n\t\t\tresult.Stage = \"registration\";\n\t\t\tEnsureProjectState( project, projectIdent );\n\t\t\tresult.WriterState = \"unconfirmed\";\n\t\t\ttransaction.WriteMarker( \"unconfirmed\" );\n\t\t\ttransaction.PrepareForEngineMutation();\n\t\t\ttransaction.EngineMutationAttempted = true;\n\t\t\tModelImportLifetime.RecordHistorical( transaction.PendingPaths( result.GeneratedFiles ) );\n\n\t\t\tforeach ( var copy in transaction.Copies.Where( x => !string.Equals( x.DestinationAsset, transaction.SourceAsset, StringComparison.OrdinalIgnoreCase ) ) )\n\t\t\t\tImportPipeline.RegisterDependency( copy.DestinationAbsolute, copy.DestinationAsset );\n\t\t\tvar sourceCopy = transaction.Copies.Single( x => string.Equals( x.DestinationAsset, transaction.SourceAsset, StringComparison.OrdinalIgnoreCase ) );\n\t\t\tvar sourceAsset = ImportPipeline.RegisterSource( sourceCopy.DestinationAbsolute, transaction.SourceAsset );\n\t\t\tresult.SourceRegistered = true;\n\n\t\t\tresult.Stage = \"model_creation\";\n\t\t\tEnsureProjectState( project, projectIdent );\n\t\t\tvar modelAbsolute = Path.Combine( transaction.AssetsRoot, transaction.ModelAsset.Replace( '/', Path.DirectorySeparatorChar ) );\n\t\t\tobject modelAsset;\n\t\t\ttry { modelAsset = ImportPipeline.CreateModel( sourceAsset, modelAbsolute, transaction.ModelAsset ); }\n\t\t\tfinally { TryObserveGeneratedFiles( transaction, result ); }\n\t\t\tresult.ModelRegistered = true;\n\n\t\t\tresult.Stage = \"compilation\";\n\t\t\tvar compilation = await ImportPipeline.ObserveCompilationAsync( modelAsset );\n\t\t\tCopyCompilationEvidence( compilation, result.Compilation );\n\t\t\tEnsureProjectState( project, projectIdent );\n\n\t\t\tresult.Stage = \"dependency_inspection\";\n\t\t\tvar dependencies = ImportPipeline.InspectDependencies( sourceAsset, modelAsset );\n\t\t\tCopyDependencyEvidence( dependencies, result );\n\n\t\t\tresult.Succeeded = true;\n\t\t\tresult.Stage = \"complete\";\n\t\t\tresult.Error = null;\n\t\t\tresult.RollbackStatus = \"not_needed\";\n\t\t\tApplyPendingState( transaction, result );\n\t\t\treturn result;\n\t\t}\n\t\tcatch ( BackendPipelineException ex )\n\t\t{\n\t\t\tif ( ex.Evidence is BackendCompilationEvidence compilation ) CopyCompilationEvidence( compilation, result.Compilation );\n\t\t\tif ( ex.Evidence is BackendDependencyEvidence dependencies ) CopyDependencyEvidence( dependencies, result );\n\t\t\tresult.Fail( ex.Code, ex.Message );\n\t\t}\n\t\tcatch ( ImportContractException ex )\n\t\t{\n\t\t\tresult.Fail( ex.Code, ex.Message );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tresult.Fail( CodeForStage( result.Stage ), Safe( ex.Message, 1024 ) );\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tif ( transaction is not null ) result.CopiedFiles = transaction.CompletedCopies.ToList();\n\t\t\t\tif ( transaction?.EngineMutationAttempted == true )\n\t\t\t\t{\n\t\t\t\t\tApplyPendingState( transaction, result );\n\t\t\t\t\ttransaction.Dispose();\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tvar remaining = transaction?.CleanupPreEngine() ?? [];\n\t\t\t\t\tif ( remaining.Count > 0 )\n\t\t\t\t\t{\n\t\t\t\t\t\tresult.RollbackStatus = \"incomplete\";\n\t\t\t\t\t\tresult.PartialPaths.AddRange( remaining );\n\t\t\t\t\t}\n\t\t\t\t\telse if ( transaction is not null ) result.RollbackStatus = \"complete\";\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch ( Exception ex )\n\t\t\t{\n\t\t\t\tresult.RollbackStatus = \"incomplete\";\n\t\t\t\tAddWarning( result, $\"Final import accounting was incomplete: {Safe( ex.Message, 180 )}\" );\n\t\t\t}\n\t\t\tfinally\n\t\t\t{\n\t\t\t\tresult.FurtherImportsBlocked = false;\n\t\t\t\tlease.Dispose();\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n\n\tprivate static void ApplyPendingState( ModelImportTransaction transaction, ImportModelResult result )\n\t{\n\t\tresult.WriterState = \"unconfirmed\";\n\t\tresult.FurtherImportsBlocked = false;\n\t\tresult.PendingWriterPaths = transaction.PendingPaths( result.GeneratedFiles ).ToList();\n\t\tModelImportLifetime.RecordHistorical( result.PendingWriterPaths );\n\t\tTryObserveGeneratedFiles( transaction, result );\n\t\tresult.PendingWriterPaths = transaction.PendingPaths( result.GeneratedFiles ).ToList();\n\t\tModelImportLifetime.RecordHistorical( result.PendingWriterPaths );\n\t\tif ( !result.Succeeded )\n\t\t{\n\t\t\tresult.RollbackStatus = \"incomplete\";\n\t\t\tresult.PartialPaths = result.PendingWriterPaths.ToList();\n\t\t}\n\t}\n\n\tprivate static void EnsureOutputsAbsent( ModelImportTransaction transaction )\n\t{\n\t\tforeach ( var copy in transaction.Copies )\n\t\t\tif ( File.Exists( copy.DestinationAbsolute ) || AssetSystem.FindByPath( copy.DestinationAsset ) is not null )\n\t\t\t\tthrow new ImportContractException( \"DestinationExists\", $\"Planned output '{copy.DestinationAsset}' already exists.\" );\n\t\tif ( AssetSystem.FindByPath( transaction.ModelAsset ) is not null )\n\t\t\tthrow new ImportContractException( \"DestinationExists\", $\"Planned output '{transaction.ModelAsset}' is already registered.\" );\n\t\tvar prefix = transaction.RelativeDirectory.TrimEnd( '/' ) + \"/\";\n\t\tif ( AssetSystem.All.Any( asset => asset?.Path is string path &&\n\t\t\tpath.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) ) )\n\t\t\tthrow new ImportContractException( \"DestinationExists\", \"The destination contains registered asset evidence.\" );\n\t\tvar ancestor = transaction.RelativeDirectory;\n\t\twhile ( ancestor.Contains( '/' ) )\n\t\t{\n\t\t\tancestor = ancestor[..ancestor.LastIndexOf( '/' )];\n\t\t\tvar ancestorPrefix = ancestor + \"/\";\n\t\t\tvar direct = AssetSystem.All\n\t\t\t\t.Select( asset => asset?.Path?.Replace( '\\\\', '/' ) )\n\t\t\t\t.Where( path => path is not null && path.StartsWith( ancestorPrefix, StringComparison.OrdinalIgnoreCase ) &&\n\t\t\t\t\t!path[ancestorPrefix.Length..].Contains( '/' ) )\n\t\t\t\t.ToArray();\n\t\t\tvar modelCount = direct.Count( path => Path.GetExtension( path ).Equals( \".vmdl\", StringComparison.OrdinalIgnoreCase ) );\n\t\t\tvar sourceCount = direct.Count( path => new[] { \".fbx\", \".obj\", \".dmx\" }.Contains( Path.GetExtension( path ), StringComparer.OrdinalIgnoreCase ) );\n\t\t\t// A v1 import boundary owns exactly one source and one generated model. Directories\n\t\t\t// containing several independent pairs are collection roots, not isolated imports.\n\t\t\tif ( modelCount == 1 && sourceCount == 1 )\n\t\t\t\tthrow new ImportContractException( \"DestinationExists\", \"A destination ancestor contains registered source/model import evidence.\" );\n\t\t}\n\t}\n\tprivate static void EnsureProjectState( Project project, string ident )\n\t{\n\t\tif ( Project.Current is null || !ReferenceEquals( Project.Current, project ) || !string.Equals( Project.Current.Config?.Ident, ident, StringComparison.Ordinal ) )\n\t\t\tthrow new ImportContractException( \"NoActiveProject\", \"The active project changed during import.\" );\n\t\tif ( Game.IsPlaying ) throw new ImportContractException( \"PlayMode\", \"Play mode started during import.\" );\n\t}\n\n\tprivate static void TryObserveGeneratedFiles( ModelImportTransaction transaction, ImportModelResult result )\n\t{\n\t\ttry\n\t\t{\n\t\t\tif ( !Directory.Exists( transaction.FinalDirectory ) ) return;\n\t\t\tforeach ( var file in Directory.EnumerateFiles( transaction.FinalDirectory, \"*\", SearchOption.TopDirectoryOnly ) )\n\t\t\t{\n\t\t\t\tif ( (File.GetAttributes( file ) & FileAttributes.ReparsePoint) != 0 ) continue;\n\t\t\t\tvar path = transaction.ToReportedPath( file );\n\t\t\t\tif ( result.CopiedFiles.Contains( path, StringComparer.OrdinalIgnoreCase ) ) continue;\n\t\t\t\tif ( !result.GeneratedFiles.Contains( path, StringComparer.OrdinalIgnoreCase ) ) result.GeneratedFiles.Add( path );\n\t\t\t\tif ( !transaction.OwnedFiles.Contains( file, StringComparer.OrdinalIgnoreCase ) ) transaction.OwnedFiles.Add( file );\n\t\t\t}\n\t\t\tresult.GeneratedFiles.Sort( StringComparer.OrdinalIgnoreCase );\n\t\t}\n\t\tcatch ( Exception ex ) { AddWarning( result, $\"Generated output attribution was incomplete: {Safe( ex.Message, 180 )}\" ); }\n\t}\n\n\tprivate static void AddWarning( ImportModelResult result, string warning )\n\t{\n\t\tif ( result.Warnings.Count < 16 ) result.Warnings.Add( Safe( warning, 256 ) );\n\t}\n\n\n\tprivate static void CopyCompilationEvidence( BackendCompilationEvidence source, CompilationEvidence target )\n\t{\n\t\ttarget.Status = source.Status;\n\t\ttarget.IsCompiled = source.IsCompiled;\n\t\ttarget.IsCompiledAndUpToDate = source.IsCompiledAndUpToDate;\n\t\ttarget.IsCompileFailed = source.IsCompileFailed;\n\t\ttarget.UnavailableEvidence = new Dictionary<string, string>( source.UnavailableEvidence, StringComparer.Ordinal );\n\t}\n\n\tprivate static void CopyDependencyEvidence( BackendDependencyEvidence source, ImportModelResult target )\n\t{\n\t\ttarget.DependencyInspection.Status = source.Status;\n\t\ttarget.DependencyInspection.InspectedAssets = source.InspectedAssets.ToList();\n\t\ttarget.DependencyInspection.References = source.References.ToList();\n\t\ttarget.DependencyInspection.InputDependencies = source.InputDependencies.ToList();\n\t\ttarget.DependencyInspection.AdditionalContentFiles = source.AdditionalContentFiles.ToList();\n\t\ttarget.DependencyInspection.UnavailableEvidence = new Dictionary<string, string>( source.UnavailableEvidence, StringComparer.Ordinal );\n\t\ttarget.UnresolvedReferences = source.UnresolvedReferences.ToList();\n\t}\n\n\tprivate static ImportModelResult BusyResult( ImportModelResult result )\n\t{\n\t\tresult.FurtherImportsBlocked = true;\n\t\treturn result.Fail( \"ImportBusy\", \"Another model import request is currently executing.\" );\n\t}\n\n\tprivate static string CodeForStage( string stage ) => stage switch\n\t{\n\t\t\"copy\" => \"CopyFailed\", \"registration\" => \"RegistrationFailed\", \"model_creation\" => \"ModelCreationFailed\",\n\t\t\"compilation\" => \"CompilationUnconfirmed\", \"dependency_inspection\" => \"DependencyInspectionFailed\", _ => \"InvalidInput\"\n\t};\n\tinternal static string Safe( string value, int maximum )\n\t{\n\t\tvar text = string.IsNullOrWhiteSpace( value ) ? \"The operation failed without a message.\" : value.Replace( '\\r', ' ' ).Replace( '\\n', ' ' );\n\t\treturn text.Length <= maximum ? text : text[..maximum];\n\t}\n}\n\npublic sealed class ImportModelResult\n{\n\tpublic bool Succeeded { get; set; }\n\tpublic string Stage { get; set; } = \"validation\";\n\tpublic string SourceAsset { get; set; }\n\tpublic string ModelAsset { get; set; }\n\tpublic List<string> CopiedFiles { get; set; } = [];\n\tpublic List<string> GeneratedFiles { get; set; } = [];\n\tpublic bool SourceRegistered { get; set; }\n\tpublic bool ModelRegistered { get; set; }\n\tpublic CompilationEvidence Compilation { get; set; } = new();\n\tpublic DependencyInspectionEvidence DependencyInspection { get; set; } = new();\n\tpublic string WriterState { get; set; } = \"not_started\";\n\tpublic bool FurtherImportsBlocked { get; set; }\n\tpublic List<string> PendingWriterPaths { get; set; } = [];\n\tpublic List<string> UnresolvedReferences { get; set; } = [];\n\tpublic List<string> Warnings { get; set; } = [];\n\tpublic ImportModelError Error { get; set; }\n\tpublic string RollbackStatus { get; set; } = \"not_needed\";\n\tpublic List<string> PartialPaths { get; set; } = [];\n\tinternal ImportModelResult Fail( string code, string message )\n\t{\n\t\tSucceeded = false; Error = new() { Code = code, Message = ExtrasTools.Safe( message, 1024 ) }; return this;\n\t}\n}\n\npublic sealed class CompilationEvidence\n{\n\tpublic string Status { get; set; } = \"not_started\";\n\tpublic bool? IsCompiled { get; set; }\n\tpublic bool? IsCompiledAndUpToDate { get; set; }\n\tpublic bool? IsCompileFailed { get; set; }\n\tpublic Dictionary<string, string> UnavailableEvidence { get; set; } = new( StringComparer.Ordinal );\n}\n\npublic sealed class DependencyInspectionEvidence\n{\n\tpublic string Status { get; set; } = \"not_started\";\n\tpublic List<string> InspectedAssets { get; set; } = [];\n\tpublic List<string> References { get; set; } = [];\n\tpublic List<string> InputDependencies { get; set; } = [];\n\tpublic List<string> AdditionalContentFiles { get; set; } = [];\n\tpublic Dictionary<string, string> UnavailableEvidence { get; set; } = new( StringComparer.Ordinal );\n}\n\npublic sealed class ImportModelError\n{\n\tpublic string Code { get; set; }\n\tpublic string Message { get; set; }\n}\n\ninternal sealed class SandboxModelImportBackend : IModelImportBackend\n{\n\tpublic object RegisterFile( string absolutePath ) => AssetSystem.RegisterFile( absolutePath );\n\tpublic object FindAsset( string assetPath ) => AssetSystem.FindByPath( assetPath );\n\tpublic string GetAssetPath( object asset ) => (asset as Asset)?.Path;\n\tpublic object CreateModel( object sourceAsset, string absoluteModelPath ) =>\n\t\tEditorUtility.CreateModelFromMeshFile( (Asset)sourceAsset, absoluteModelPath );\n\tpublic bool ReadIsCompiled( object asset ) => ((Asset)asset).IsCompiled;\n\tpublic bool ReadIsCompiledAndUpToDate( object asset ) => ((Asset)asset).IsCompiledAndUpToDate;\n\tpublic bool ReadIsCompileFailed( object asset ) => ((Asset)asset).IsCompileFailed;\n\tpublic async ValueTask ObserveCompilationIfNeededAsync( object asset ) => await ((Asset)asset).CompileIfNeededAsync( 30.0f );\n\tpublic IReadOnlyList<object> GetReferences( object asset ) => ((Asset)asset).GetReferences( false ).Cast<object>().ToArray();\n\tpublic IReadOnlyList<string> GetUnrecognizedReferences( object asset ) => ((Asset)asset).GetUnrecognizedReferencePaths();\n\tpublic IReadOnlyList<string> GetInputDependencies( object asset ) => ((Asset)asset).GetInputDependencies();\n\tpublic IReadOnlyList<string> GetAdditionalContentFiles( object asset ) => ((Asset)asset).GetAdditionalContentFiles();\n}\n\ninternal sealed class ModelImportLifetime : IHotloadManaged\n{\n\tprivate ModelImportRequestGate _gate = new();\n\tprivate int _valid = 1;\n\tprivate List<string> _pending = [];\n\tprivate List<string> _ownedDirectories = [];\n\tinternal IReadOnlyList<string> PendingPaths => _pending;\n\tinternal string BusyReason => \"Another model import request is currently executing.\";\n\n\tinternal Lease TryEnter()\n\t{\n\t\tif ( Volatile.Read( ref _valid ) == 0 ) return null;\n\t\tvar lease = _gate.TryEnter();\n\t\treturn lease is null ? null : new Lease( lease );\n\t}\n\tinternal void RecordHistorical( IEnumerable<string> paths )\n\t{\n\t\t_pending = _pending.Concat( paths ).Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy( x => x, StringComparer.OrdinalIgnoreCase ).ToList();\n\t}\n\tinternal void ScanMarkers( string assetsRoot )\n\t{\n\t\tvar foundPending = new List<string>();\n\t\tvar foundOwned = new List<string>();\n\t\ttry\n\t\t{\n\t\t\tvar stack = new Stack<string>(); stack.Push( assetsRoot );\n\t\t\twhile ( stack.Count > 0 )\n\t\t\t{\n\t\t\t\tvar directory = stack.Pop();\n\t\t\t\tforeach ( var child in Directory.EnumerateDirectories( directory ) )\n\t\t\t\t\tif ( (File.GetAttributes( child ) & FileAttributes.ReparsePoint) == 0 ) stack.Push( child );\n\t\t\t\tvar marker = Path.Combine( directory, ModelImportTransaction.MarkerName );\n\t\t\t\tif ( !File.Exists( marker ) ) continue;\n\t\t\t\tvar relative = Path.GetRelativePath( assetsRoot, directory ).Replace( '\\\\', '/' );\n\t\t\t\tfoundOwned.Add( relative );\n\t\t\t\tvar blocks = true;\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tusing var document = JsonDocument.Parse( File.ReadAllText( marker ) );\n\t\t\t\t\tvar root = document.RootElement;\n\t\t\t\t\tblocks = !root.TryGetProperty( \"Version\", out var version ) || version.GetInt32() != 1 ||\n\t\t\t\t\t\t!root.TryGetProperty( \"TransactionId\", out var transactionId ) || !Guid.TryParse( transactionId.GetString(), out _ ) ||\n\t\t\t\t\t\t!root.TryGetProperty( \"SourceAsset\", out var sourceAsset ) || string.IsNullOrWhiteSpace( sourceAsset.GetString() ) ||\n\t\t\t\t\t\t!root.TryGetProperty( \"ModelAsset\", out var modelAsset ) || string.IsNullOrWhiteSpace( modelAsset.GetString() ) ||\n\t\t\t\t\t\t!root.TryGetProperty( \"WriterState\", out var writerState ) ||\n\t\t\t\t\t\t!string.Equals( writerState.GetString(), \"stopped\", StringComparison.Ordinal );\n\t\t\t\t}\n\t\t\t\tcatch { blocks = true; }\n\t\t\t\tif ( blocks ) foundPending.Add( relative );\n\t\t\t}\n\t\t\t_ownedDirectories = foundOwned;\n\t\t\t_pending = foundPending;\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\t_ownedDirectories = [];\n\t\t\t_pending = [];\n\t\t\tthrow new ImportContractException( \"ImportBusy\", $\"Import ownership markers could not be scanned safely: {ExtrasTools.Safe( ex.Message, 700 )}\" );\n\t\t}\n\t}\n\n\tinternal bool IsOwnedDestination( string relativeDirectory ) => _ownedDirectories.Any( owned =>\n\t\trelativeDirectory.Equals( owned, StringComparison.OrdinalIgnoreCase ) ||\n\t\trelativeDirectory.StartsWith( owned.TrimEnd( '/' ) + \"/\", StringComparison.OrdinalIgnoreCase ) ||\n\t\towned.StartsWith( relativeDirectory.TrimEnd( '/' ) + \"/\", StringComparison.OrdinalIgnoreCase ) );\n\tpublic void Destroyed( Dictionary<string, object> state )\n\t{\n\t\tModelImportHotloadTransfer.Write( state, _gate, _pending, _ownedDirectories );\n\t\tVolatile.Write( ref _valid, 0 );\n\t}\n\tpublic void Created( IReadOnlyDictionary<string, object> state )\n\t{\n\t\ttry\n\t\t{\n\t\t\t// The only legacy retained gate in this session belongs to the giant-lid invocation,\n\t\t\t// whose MCP call returned before this contract migration began.\n\t\t\tvar snapshot = ModelImportHotloadTransfer.Read( state, legacyInvocationFinished: true );\n\t\t\t_pending = snapshot.PendingPaths.ToList();\n\t\t\t_ownedDirectories = snapshot.OwnedDirectories.ToList();\n\t\t\t_gate ??= new ModelImportRequestGate();\n\t\t\t_gate.Restore( snapshot.ActiveRequest );\n\t\t\tVolatile.Write( ref _valid, 1 );\n\t\t}\n\t\tcatch\n\t\t{\n\t\t\t_gate.Restore( true );\n\t\t\tVolatile.Write( ref _valid, 0 );\n\t\t}\n\t}\n\n\tpublic void Persisted() { }\n\tpublic void Failed()\n\t{\n\t\t_gate.Restore( true );\n\t\tVolatile.Write( ref _valid, 0 );\n\t}\n\n\tinternal sealed class Lease : IDisposable\n\t{\n\t\tprivate ModelImportRequestGate.Lease _lease;\n\t\tinternal Lease( ModelImportRequestGate.Lease lease ) => _lease = lease;\n\t\tpublic void Dispose()\n\t\t{\n\t\t\tvar lease = Interlocked.Exchange( ref _lease, null );\n\t\t\tlease?.Dispose();\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "kitsupanic.sbox_mcp_plus",
            "Path": "Tests/Program.cs",
            "FileName": "Program.cs",
            "PackageType": "library",
            "CodeKind": "UnitTest",
            "AssetVersionId": 381524,
            "IsPrivate": false,
            "Code": "using Editor.Mcp;\n\nvar checks = new List<(string Name, Action Run)>\n{\n\t(\"selects exact immediate allowlist\", SelectsAllowlist),\n\t(\"false skips sibling enumeration\", SkipsSiblings),\n\t(\"refuses 129 sibling images\", RefusesImageOverflow),\n\t(\"accepts exact byte boundary\", AcceptsExactByteBoundary),\n\t(\"refuses byte overflow\", RefusesByteOverflow),\n\t(\"rejects unsafe targets and names\", RejectsUnsafeInputs),\n\t(\"exclusive reservation preserves sentinel\", PreservesSentinel),\n\t(\"copy race preserves foreign file\", PreservesRacedCopy),\n\t(\"marker race preserves foreign marker\", PreservesRacedMarker),\n\t(\"pre-engine cleanup retains owned directories safely\", RetainsOwnedDirectories),\n\t(\"request gate serializes and releases\", RequestGateSerializesAndReleases),\n\t(\"post-engine completion releases request gate\", PostEngineCompletionReleasesGate),\n\t(\"registration failure is staged\", RegistrationFailureIsStaged),\n\t(\"compilation fault preserves evidence\", CompilationFaultPreservesEvidence),\n\t(\"dependency fault preserves earlier evidence\", DependencyFaultPreservesEvidence),\n\t(\"legacy retained state migrates without active lock\", LegacyStateMigrates),\n\t(\"versioned active request survives hotload\", ActiveRequestSurvivesHotload),\n\t(\"malformed hotload state fails closed\", MalformedHotloadFails),\n\t(\"post-engine transaction retains outputs\", PostEngineTransactionRetainsOutputs),\n\t(\"active lease releases shared hotload gate\", ActiveLeaseReleasesSharedGate),\n\t(\"thrown dependency path preserves evidence\", ThrownDependencyPathPreservesEvidence),\n\t(\"missing dependency path fails inspection\", MissingDependencyPathFailsInspection)\n};\nvar failures = new List<string>();\nforeach ( var check in checks )\n{\n\ttry { check.Run(); Console.WriteLine( $\"PASS {check.Name}\" ); }\n\tcatch ( Exception ex ) { failures.Add( $\"FAIL {check.Name}: {ex.Message}\" ); }\n}\nforeach ( var failure in failures ) Console.Error.WriteLine( failure );\nreturn failures.Count == 0 ? 0 : 1;\n\nstatic void SelectsAllowlist()\n{\n\tusing var root = Fixture();\n\tFile.WriteAllText( Path.Combine( root.Source, \"mesh.OBJ\" ), \"v 0 0 0\\nv 1 0 0\\nv 0 1 0\\nf 1 2 3\" );\n\tFile.WriteAllText( Path.Combine( root.Source, \"shot.PNG\" ), \"x\" );\n\tFile.WriteAllText( Path.Combine( root.Source, \"mesh.mtl\" ), \"ignored\" );\n\tDirectory.CreateDirectory( Path.Combine( root.Source, \"nested\" ) );\n\tFile.WriteAllText( Path.Combine( root.Source, \"nested\", \"nested.png\" ), \"ignored\" );\n\tvar plan = ModelImportTransaction.Plan( root.Assets, Path.Combine( root.Source, \"mesh.OBJ\" ), \"models/test\", \"\", true );\n\tEqual( new[] { \"models/test/mesh.OBJ\", \"models/test/shot.PNG\" }, plan.Copies.Select( x => x.DestinationAsset ).ToArray() );\n}\n\nstatic void SkipsSiblings()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \"mesh.fbx\" ); File.WriteAllText( source, \"mesh\" );\n\tFile.WriteAllText( Path.Combine( root.Source, \"image.png\" ), \"x\" );\n\tvar plan = ModelImportTransaction.Plan( root.Assets, source, \"models/solo\", null, false );\n\tEqual( new[] { \"models/solo/mesh.fbx\" }, plan.Copies.Select( x => x.DestinationAsset ).ToArray() );\n}\n\nstatic void RefusesImageOverflow()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \"mesh.dmx\" ); File.WriteAllText( source, \"mesh\" );\n\tfor ( var i = 0; i < 129; ++i ) File.WriteAllText( Path.Combine( root.Source, $\"{i:D3}.png\" ), \"\" );\n\tThrows( \"LimitExceeded\", () => ModelImportTransaction.Plan( root.Assets, source, \"models/count\", \"\", true ) );\n}\n\nstatic void AcceptsExactByteBoundary()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \"mesh.fbx\" );\n\tusing ( var stream = File.Create( source ) ) stream.SetLength( ModelImportTransaction.MaximumBytes );\n\tvar plan = ModelImportTransaction.Plan( root.Assets, source, \"models/exact\", \"\", false );\n\tAssert( plan.Copies.Single().PreflightLength == ModelImportTransaction.MaximumBytes, \"exact byte limit was not accepted\" );\n}\n\nstatic void RefusesByteOverflow()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \"mesh.fbx\" );\n\tusing ( var stream = File.Create( source ) ) stream.SetLength( ModelImportTransaction.MaximumBytes + 1 );\n\tThrows( \"LimitExceeded\", () => ModelImportTransaction.Plan( root.Assets, source, \"models/over\", \"\", false ) );\n}\n\nstatic void RejectsUnsafeInputs()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \"mesh.fbx\" ); File.WriteAllText( source, \"mesh\" );\n\tforeach ( var target in new[] { \"../escape\", \"Assets/models/x\", \"models//x\", \"C:\\\\outside\", \"/outside\" } )\n\t\tThrows( \"InvalidInput\", () => ModelImportTransaction.Plan( root.Assets, source, target, \"\", false ) );\n\tforeach ( var name in new[] { \"CON\", \"bad.vmdl\", \" padded\", \"trailing.\" } )\n\t\tThrows( \"InvalidInput\", () => ModelImportTransaction.Plan( root.Assets, source, \"models/name\", name, false ) );\n}\n\nstatic void PreservesSentinel()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \"mesh.obj\" ); File.WriteAllText( source, \"mesh\" );\n\tvar occupied = Path.Combine( root.Assets, \"models\", \"occupied\" ); Directory.CreateDirectory( occupied );\n\tvar sentinel = Path.Combine( occupied, \"sentinel.txt\" ); File.WriteAllText( sentinel, \"keep\" );\n\tThrows( \"DestinationExists\", () => ModelImportTransaction.Plan( root.Assets, source, \"models/occupied\", \"\", false ) );\n\tAssert( File.ReadAllText( sentinel ) == \"keep\", \"sentinel changed\" );\n}\n\nstatic void PreservesRacedCopy()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \"mesh.obj\" ); File.WriteAllText( source, \"source\" );\n\tvar plan = ModelImportTransaction.Plan( root.Assets, source, \"models/raced-copy\", \"\", false );\n\tplan.ReserveDirectory();\n\tvar destination = plan.Copies.Single().DestinationAbsolute;\n\tFile.WriteAllText( destination, \"foreign\" );\n\tThrows( \"CopyFailed\", () => plan.CopyInputs() );\n\tvar remaining = plan.CleanupPreEngine();\n\tAssert( File.ReadAllText( destination ) == \"foreign\", \"foreign raced file was deleted\" );\n\tAssert( remaining.Contains( \"models/raced-copy\", StringComparer.OrdinalIgnoreCase ), \"occupied owned directory was not reported\" );\n}\n\nstatic void PreservesRacedMarker()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \"mesh.obj\" ); File.WriteAllText( source, \"source\" );\n\tvar plan = ModelImportTransaction.Plan( root.Assets, source, \"models/raced-marker\", \"\", false );\n\tplan.ReserveDirectory(); plan.CopyInputs();\n\tvar marker = Path.Combine( plan.FinalDirectory, ModelImportTransaction.MarkerName );\n\tFile.WriteAllText( marker, \"foreign\" );\n\ttry { plan.WriteMarker( \"not_started\" ); } catch ( IOException ) { }\n\tvar remaining = plan.CleanupPreEngine();\n\tAssert( File.ReadAllText( marker ) == \"foreign\", \"foreign marker was overwritten or deleted\" );\n\tAssert( remaining.Contains( \"models/raced-marker\", StringComparer.OrdinalIgnoreCase ), \"occupied marker directory was not reported\" );\n}\n\nstatic void RetainsOwnedDirectories()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \"mesh.obj\" ); File.WriteAllText( source, \"mesh\" );\n\tvar plan = ModelImportTransaction.Plan( root.Assets, source, \"models/cleanup\", \"\", false );\n\tplan.ReserveDirectory(); plan.CopyInputs(); plan.WriteMarker( \"not_started\" );\n\tvar remaining = plan.CleanupPreEngine();\n\tAssert( remaining.Contains( \"models/cleanup\", StringComparer.OrdinalIgnoreCase ), \"retained final directory was not reported\" );\n\tAssert( !File.Exists( Path.Combine( plan.FinalDirectory, \"mesh.obj\" ) ), \"owned copied file remains\" );\n\tAssert( !File.Exists( Path.Combine( plan.FinalDirectory, ModelImportTransaction.MarkerName ) ), \"owned marker remains\" );\n}\n\nstatic void RequestGateSerializesAndReleases()\n{\n\tvar gate = new ModelImportRequestGate();\n\tusing var first = gate.TryEnter();\n\tAssert( first is not null && gate.IsActive, \"first request did not acquire the gate\" );\n\tAssert( gate.TryEnter() is null, \"concurrent request was admitted\" );\n\tfirst.Dispose();\n\tusing var second = gate.TryEnter();\n\tAssert( second is not null, \"gate did not release after invocation completion\" );\n}\n\nstatic void PostEngineCompletionReleasesGate()\n{\n\tvar gate = new ModelImportRequestGate();\n\tvar lease = gate.TryEnter();\n\tvar writerState = \"unconfirmed\";\n\tvar pending = new[] { \"models/a\", \"models/a/a.vmdl\" };\n\tlease.Dispose();\n\tAssert( !gate.IsActive, \"unconfirmed writer evidence retained the request gate\" );\n\tAssert( writerState == \"unconfirmed\" && pending.Length == 2, \"retained evidence changed when the request released\" );\n}\n\nstatic void RegistrationFailureIsStaged()\n{\n\tvar backend = new FakeBackend { ThrowRegister = true };\n\tvar pipeline = new ModelImportBackendPipeline( backend );\n\tThrows( \"RegistrationFailed\", () => pipeline.RegisterSource( \"source.fbx\", \"models/a/source.fbx\" ) );\n}\n\nstatic void CompilationFaultPreservesEvidence()\n{\n\tvar backend = new FakeBackend { IsCompiled = false, IsUpToDate = false, IsFailed = false, ThrowCompileObservation = true };\n\tvar pipeline = new ModelImportBackendPipeline( backend );\n\ttry { pipeline.ObserveCompilationAsync( backend.Model ).AsTask().GetAwaiter().GetResult(); }\n\tcatch ( BackendPipelineException ex )\n\t{\n\t\tvar evidence = (BackendCompilationEvidence)ex.Evidence;\n\t\tAssert( ex.Code == \"CompilationUnconfirmed\", \"wrong compilation fault code\" );\n\t\tAssert( evidence.Status == \"observed\" && evidence.IsCompiled == false && evidence.IsCompileFailed == false, \"compile evidence was discarded\" );\n\t\treturn;\n\t}\n\tthrow new Exception( \"expected compilation fault\" );\n}\n\nstatic void DependencyFaultPreservesEvidence()\n{\n\tvar backend = new FakeBackend { ThrowInputDependencies = true };\n\tbackend.References[backend.Source] = [backend.Material];\n\tvar pipeline = new ModelImportBackendPipeline( backend );\n\ttry { pipeline.InspectDependencies( backend.Source, backend.Model ); }\n\tcatch ( BackendPipelineException ex )\n\t{\n\t\tvar evidence = (BackendDependencyEvidence)ex.Evidence;\n\t\tAssert( ex.Code == \"DependencyInspectionFailed\", \"wrong dependency fault code\" );\n\t\tAssert( evidence.References.Contains( \"materials/test.vmat\" ), \"successful reference evidence was discarded\" );\n\t\tAssert( evidence.UnavailableEvidence.Keys.Any( x => x.StartsWith( \"InputDependencies:\", StringComparison.Ordinal ) ), \"failed query was not identified\" );\n\t\treturn;\n\t}\n\tthrow new Exception( \"expected dependency inspection fault\" );\n}\n\nstatic void LegacyStateMigrates()\n{\n\tvar state = new Dictionary<string, object>\n\t{\n\t\t[\"ModelImportBusy\"] = true,\n\t\t[\"ModelImportPending\"] = new[] { \"models/kampai/giant-lid\" },\n\t\t[\"ModelImportOwnedDirectories\"] = new[] { \"models/kampai/giant-lid\" }\n\t};\n\tvar snapshot = ModelImportHotloadTransfer.Read( state, legacyInvocationFinished: true );\n\tAssert( !snapshot.ActiveRequest, \"completed legacy invocation retained the revised request lock\" );\n\tAssert( snapshot.PendingPaths.Single() == \"models/kampai/giant-lid\", \"legacy pending evidence was lost\" );\n}\n\nstatic void ActiveRequestSurvivesHotload()\n{\n\tvar gate = new ModelImportRequestGate();\n\tusing var lease = gate.TryEnter();\n\tvar state = new Dictionary<string, object>();\n\tModelImportHotloadTransfer.Write( state, gate, new[] { \"models/a\" }, new[] { \"models/a\" } );\n\tvar snapshot = ModelImportHotloadTransfer.Read( state, legacyInvocationFinished: false );\n\tAssert( snapshot.Version == 4 && snapshot.ActiveRequest, \"versioned active request was unlocked\" );\n}\n\nstatic void MalformedHotloadFails()\n{\n\ttry { ModelImportHotloadTransfer.Read( new Dictionary<string, object>(), legacyInvocationFinished: false ); }\n\tcatch ( ImportContractException ex ) when ( ex.Code == \"ImportBusy\" ) { return; }\n\tthrow new Exception( \"malformed hotload state did not fail closed\" );\n}\n\nstatic void PostEngineTransactionRetainsOutputs()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \"mesh.obj\" ); File.WriteAllText( source, \"mesh\" );\n\tvar plan = ModelImportTransaction.Plan( root.Assets, source, \"models/retained\", \"\", false );\n\tplan.ReserveDirectory(); plan.CopyInputs(); plan.WriteMarker( \"unconfirmed\" );\n\tplan.PrepareForEngineMutation();\n\tplan.EngineMutationAttempted = true;\n\tplan.Dispose();\n\tAssert( File.Exists( Path.Combine( plan.FinalDirectory, \"mesh.obj\" ) ), \"post-engine source was deleted\" );\n\tAssert( File.Exists( Path.Combine( plan.FinalDirectory, ModelImportTransaction.MarkerName ) ), \"post-engine marker was deleted\" );\n}\n\nstatic void ActiveLeaseReleasesSharedGate()\n{\n\tvar oldGate = new ModelImportRequestGate();\n\tvar lease = oldGate.TryEnter();\n\tvar newGate = new ModelImportRequestGate( oldGate.State );\n\tAssert( newGate.IsActive, \"replacement gate did not share active state\" );\n\tlease.Dispose();\n\tAssert( !newGate.IsActive && newGate.TryEnter() is not null, \"old invocation completion did not release replacement gate\" );\n}\n\nstatic void ThrownDependencyPathPreservesEvidence()\n{\n\tvar backend = new FakeBackend { ThrowMaterialPath = true };\n\tbackend.References[backend.Source] = [backend.Material];\n\tvar pipeline = new ModelImportBackendPipeline( backend );\n\ttry { pipeline.InspectDependencies( backend.Source, backend.Model ); }\n\tcatch ( BackendPipelineException ex )\n\t{\n\t\tvar evidence = (BackendDependencyEvidence)ex.Evidence;\n\t\tAssert( ex.Code == \"DependencyInspectionFailed\", \"thrown path used wrong error\" );\n\t\tAssert( evidence.InspectedAssets.Contains( \"models/a/model.vmdl\" ), \"evidence from another completed asset was discarded\" );\n\t\tAssert( evidence.UnavailableEvidence.Keys.Any( x => x.StartsWith( \"ReferencePath:\", StringComparison.Ordinal ) ), \"thrown reference path was not identified\" );\n\t\treturn;\n\t}\n\tthrow new Exception( \"expected thrown path inspection failure\" );\n}\n\nstatic void MissingDependencyPathFailsInspection()\n{\n\tvar backend = new FakeBackend { MissingMaterialPath = true };\n\tbackend.References[backend.Source] = [backend.Material];\n\tvar pipeline = new ModelImportBackendPipeline( backend );\n\ttry { pipeline.InspectDependencies( backend.Source, backend.Model ); }\n\tcatch ( BackendPipelineException ex )\n\t{\n\t\tvar evidence = (BackendDependencyEvidence)ex.Evidence;\n\t\tAssert( ex.Code == \"DependencyInspectionFailed\", \"missing path used wrong error\" );\n\t\tAssert( evidence.UnavailableEvidence.Keys.Any( x => x.StartsWith( \"ReferencePath:\", StringComparison.Ordinal ) ), \"missing reference path was not identified\" );\n\t\treturn;\n\t}\n\tthrow new Exception( \"expected missing path inspection failure\" );\n}\n\nstatic Root Fixture() => new();\nstatic void Assert( bool condition, string message ) { if ( !condition ) throw new Exception( message ); }\nstatic void Equal( string[] expected, string[] actual ) => Assert( expected.SequenceEqual( actual, StringComparer.OrdinalIgnoreCase ), $\"expected [{string.Join(\",\", expected)}], got [{string.Join(\",\", actual)}]\" );\nstatic void Throws( string code, Action action )\n{\n\ttry { action(); }\n\tcatch ( ImportContractException ex ) when ( ex.Code == code ) { return; }\n\tthrow new Exception( $\"expected {code}\" );\n}\n\nsealed class Root : IDisposable\n{\n\tpublic string PathRoot { get; } = Path.Combine( Path.GetTempPath(), \"sbox-model-import-contract\", Guid.NewGuid().ToString( \"N\" ) );\n\tpublic string Assets => Path.Combine( PathRoot, \"Assets\" );\n\tpublic string Source => Path.Combine( PathRoot, \"Source\" );\n\tpublic Root() { Directory.CreateDirectory( Assets ); Directory.CreateDirectory( Source ); }\n\tpublic void Dispose() { try { Directory.Delete( PathRoot, true ); } catch { } }\n}\n\nsealed class FakeBackend : IModelImportBackend\n{\n\tinternal object Source { get; } = new();\n\tinternal object Model { get; } = new();\n\tinternal object Material { get; } = new();\n\tinternal Dictionary<object, List<object>> References { get; } = [];\n\tinternal bool ThrowRegister { get; set; }\n\tinternal bool ThrowCompileObservation { get; set; }\n\tinternal bool ThrowInputDependencies { get; set; }\n\tinternal bool ThrowMaterialPath { get; set; }\n\tinternal bool MissingMaterialPath { get; set; }\n\tinternal bool IsCompiled { get; set; } = true;\n\tinternal bool IsUpToDate { get; set; } = true;\n\tinternal bool IsFailed { get; set; }\n\tpublic object RegisterFile( string absolutePath )\n\t{\n\t\tif ( ThrowRegister ) throw new IOException( \"registration fault\" );\n\t\treturn Source;\n\t}\n\tpublic object FindAsset( string assetPath ) => Model;\n\tpublic string GetAssetPath( object asset )\n\t{\n\t\tif ( ReferenceEquals( asset, Material ) && ThrowMaterialPath ) throw new IOException( \"asset path fault\" );\n\t\tif ( ReferenceEquals( asset, Material ) && MissingMaterialPath ) return null;\n\t\treturn ReferenceEquals( asset, Source ) ? \"models/a/source.fbx\" :\n\t\t\tReferenceEquals( asset, Material ) ? \"materials/test.vmat\" : \"models/a/model.vmdl\";\n\t}\n\tpublic object CreateModel( object sourceAsset, string absoluteModelPath ) => Model;\n\tpublic bool ReadIsCompiled( object asset ) => IsCompiled;\n\tpublic bool ReadIsCompiledAndUpToDate( object asset ) => IsUpToDate;\n\tpublic bool ReadIsCompileFailed( object asset ) => IsFailed;\n\tpublic ValueTask ObserveCompilationIfNeededAsync( object asset ) => ThrowCompileObservation\n\t\t? ValueTask.FromException( new IOException( \"compile observation fault\" ) ) : ValueTask.CompletedTask;\n\tpublic IReadOnlyList<object> GetReferences( object asset ) => References.TryGetValue( asset, out var values ) ? values : [];\n\tpublic IReadOnlyList<string> GetUnrecognizedReferences( object asset ) => [];\n\tpublic IReadOnlyList<string> GetInputDependencies( object asset )\n\t{\n\t\tif ( ThrowInputDependencies ) throw new IOException( \"input dependency fault\" );\n\t\treturn [];\n\t}\n\tpublic IReadOnlyList<string> GetAdditionalContentFiles( object asset ) => [];\n}\n"
        },
        {
            "Ident": "kitsupanic.sbox_mcp_plus",
            "Path": "Editor/ModelImportTransaction.cs",
            "FileName": "ModelImportTransaction.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381524,
            "IsPrivate": false,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Runtime.InteropServices;\nusing Microsoft.Win32.SafeHandles;\nusing System.Text.Json;\n\nnamespace Editor.Mcp;\n\ninternal sealed class ModelImportTransaction\n{\n\tinternal const long MaximumBytes = 1_073_741_824;\n\tinternal const int MaximumImages = 128;\n\tinternal const string MarkerName = \".sbox-mcp-import.json\";\n\tprivate static readonly HashSet<string> ImageExtensions = new( StringComparer.OrdinalIgnoreCase )\n\t{\n\t\t\".png\", \".tga\", \".jpg\", \".jpeg\", \".bmp\", \".tif\", \".tiff\", \".exr\", \".hdr\"\n\t};\n\tprivate static readonly HashSet<string> SourceExtensions = new( StringComparer.OrdinalIgnoreCase )\n\t{\n\t\t\".fbx\", \".obj\", \".dmx\"\n\t};\n\tprivate static readonly HashSet<string> ReservedNames = new( StringComparer.OrdinalIgnoreCase )\n\t{\n\t\t\"CON\", \"PRN\", \"AUX\", \"NUL\", \"COM1\", \"COM2\", \"COM3\", \"COM4\", \"COM5\", \"COM6\", \"COM7\", \"COM8\", \"COM9\",\n\t\t\"LPT1\", \"LPT2\", \"LPT3\", \"LPT4\", \"LPT5\", \"LPT6\", \"LPT7\", \"LPT8\", \"LPT9\"\n\t};\n\n\tinternal string TransactionId { get; } = Guid.NewGuid().ToString( \"D\" );\n\tinternal string AssetsRoot { get; }\n\tinternal string FinalDirectory { get; }\n\tinternal string RelativeDirectory { get; }\n\tinternal string SourceAsset { get; }\n\tinternal string ModelAsset { get; }\n\tinternal string MarkerAsset => JoinAsset( RelativeDirectory, MarkerName );\n\tinternal IReadOnlyList<CopyPlan> Copies { get; }\n\tinternal List<string> OwnedFiles { get; } = [];\n\tinternal List<string> CompletedCopies { get; } = [];\n\tprivate readonly List<(string Path, SafeFileHandle Handle)> _directoryHandles = [];\n\tprivate readonly Dictionary<string, FileStream> _ownedStreams = new( StringComparer.OrdinalIgnoreCase );\n\tprivate FileStream _markerStream;\n\tinternal List<string> CreatedDirectories { get; } = [];\n\tinternal bool EngineMutationAttempted { get; set; }\n\n\tprivate ModelImportTransaction( string assetsRoot, string relativeDirectory, string finalDirectory,\n\t\tstring sourceAsset, string modelAsset, IReadOnlyList<CopyPlan> copies )\n\t{\n\t\tAssetsRoot = assetsRoot;\n\t\tRelativeDirectory = relativeDirectory;\n\t\tFinalDirectory = finalDirectory;\n\t\tSourceAsset = sourceAsset;\n\t\tModelAsset = modelAsset;\n\t\tCopies = copies;\n\t}\n\n\tinternal static ModelImportTransaction Plan( string assetsRoot, string sourcePath, string targetDirectory,\n\t\tstring modelName, bool copySiblingTextures )\n\t{\n\t\tif ( !OperatingSystem.IsWindows() )\n\t\t\tthrow new ImportContractException( \"ApiUnavailable\", \"Exclusive destination reservation is only supported on Windows.\" );\n\t\tif ( string.IsNullOrEmpty( assetsRoot ) )\n\t\t\tthrow new ImportContractException( \"NoActiveProject\", \"The active project has no Assets directory.\" );\n\t\tif ( string.IsNullOrWhiteSpace( sourcePath ) || !Path.IsPathFullyQualified( sourcePath ) )\n\t\t\tthrow new ImportContractException( \"InvalidInput\", \"sourcePath must be an absolute file path.\" );\n\n\t\tstring canonicalSource;\n\t\ttry { canonicalSource = Path.GetFullPath( sourcePath ); }\n\t\tcatch ( Exception ) { throw new ImportContractException( \"InvalidInput\", \"sourcePath is not a valid absolute path.\" ); }\n\t\tvar sourceInfo = new FileInfo( canonicalSource );\n\t\tif ( !sourceInfo.Exists || (sourceInfo.Attributes & FileAttributes.Directory) != 0 )\n\t\t\tthrow new ImportContractException( \"InvalidInput\", \"sourcePath must identify a readable regular file.\" );\n\t\tif ( (sourceInfo.Attributes & FileAttributes.ReparsePoint) != 0 )\n\t\t{\n\t\t\tvar target = sourceInfo.ResolveLinkTarget( true );\n\t\t\tif ( target is not FileInfo targetFile || !targetFile.Exists )\n\t\t\t\tthrow new ImportContractException( \"InvalidInput\", \"sourcePath link does not resolve to a readable regular file.\" );\n\t\t\tsourceInfo = targetFile;\n\t\t\tcanonicalSource = targetFile.FullName;\n\t\t}\n\t\tif ( !SourceExtensions.Contains( sourceInfo.Extension ) )\n\t\t\tthrow new ImportContractException( \"InvalidInput\", \"sourcePath must have an .fbx, .obj, or .dmx extension.\" );\n\t\tusing ( File.Open( canonicalSource, FileMode.Open, FileAccess.Read, FileShare.Read ) ) { }\n\n\t\tvar effectiveName = string.IsNullOrEmpty( modelName ) ? Path.GetFileNameWithoutExtension( sourceInfo.Name ) : modelName;\n\t\tValidateFileName( effectiveName, false );\n\t\tif ( effectiveName.EndsWith( \".vmdl\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\tthrow new ImportContractException( \"InvalidInput\", \"modelName must not include a .vmdl suffix.\" );\n\t\tValidateFileName( sourceInfo.Name, true );\n\n\t\tvar relative = NormalizeTarget( targetDirectory );\n\t\tvar canonicalRoot = Path.TrimEndingDirectorySeparator( Path.GetFullPath( assetsRoot ) );\n\t\tvar final = Path.GetFullPath( Path.Combine( canonicalRoot, relative.Replace( '/', Path.DirectorySeparatorChar ) ) );\n\t\tEnsureContained( canonicalRoot, final );\n\t\tEnsureNoReparsePoints( canonicalRoot, final );\n\t\tif ( Directory.Exists( final ) || File.Exists( final ) )\n\t\t\tthrow new ImportContractException( \"DestinationExists\", \"The final import directory already exists.\" );\n\n\t\tvar candidates = new List<FileInfo> { sourceInfo };\n\t\tif ( copySiblingTextures )\n\t\t{\n\t\t\tIEnumerable<FileInfo> siblings;\n\t\t\ttry { siblings = sourceInfo.Directory!.EnumerateFiles().Where( x => ImageExtensions.Contains( x.Extension ) ); }\n\t\t\tcatch ( Exception ) { throw new ImportContractException( \"InvalidInput\", \"Sibling image files could not be enumerated.\" ); }\n\t\t\tcandidates.AddRange( siblings.OrderBy( x => x.Name, StringComparer.OrdinalIgnoreCase ) );\n\t\t}\n\t\tif ( candidates.Count - 1 > MaximumImages )\n\t\t\tthrow new ImportContractException( \"LimitExceeded\", \"The import selects more than 128 sibling images.\" );\n\n\t\tvar names = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\t\tlong total = 0;\n\t\tvar copies = new List<CopyPlan>( candidates.Count );\n\t\tforeach ( var file in candidates )\n\t\t{\n\t\t\tValidateFileName( file.Name, true );\n\t\t\tif ( (file.Attributes & FileAttributes.ReparsePoint) != 0 )\n\t\t\t\tthrow new ImportContractException( \"InvalidInput\", $\"Selected file '{file.Name}' is a link.\" );\n\t\t\tif ( !names.Add( file.Name ) )\n\t\t\t\tthrow new ImportContractException( \"InvalidInput\", \"Selected filenames collide case-insensitively.\" );\n\t\t\ttry { total = checked(total + file.Length); }\n\t\t\tcatch ( OverflowException ) { throw new ImportContractException( \"LimitExceeded\", \"Selected files exceed the byte limit.\" ); }\n\t\t\tif ( total > MaximumBytes )\n\t\t\t\tthrow new ImportContractException( \"LimitExceeded\", \"Selected files exceed 1,073,741,824 bytes.\" );\n\t\t\tcopies.Add( new CopyPlan( file.FullName, Path.Combine( final, file.Name ), JoinAsset( relative, file.Name ), file.Length ) );\n\t\t}\n\n\t\tvar modelFile = effectiveName + \".vmdl\";\n\t\tValidateFileName( modelFile, true );\n\t\tif ( !names.Add( modelFile ) || !names.Add( MarkerName ) )\n\t\t\tthrow new ImportContractException( \"InvalidInput\", \"Planned output filenames collide case-insensitively.\" );\n\t\treturn new ModelImportTransaction( canonicalRoot, relative, final,\n\t\t\tJoinAsset( relative, sourceInfo.Name ), JoinAsset( relative, modelFile ), copies );\n\t}\n\n\tinternal void ReserveDirectory()\n\t{\n\t\tvar parent = Path.GetDirectoryName( FinalDirectory )!;\n\t\tvar chain = new Stack<string>();\n\t\tfor ( var cursor = parent; ; cursor = Path.GetDirectoryName( cursor )! )\n\t\t{\n\t\t\tchain.Push( cursor );\n\t\t\tif ( string.Equals( cursor, AssetsRoot, StringComparison.OrdinalIgnoreCase ) ) break;\n\t\t}\n\t\twhile ( chain.Count > 0 )\n\t\t{\n\t\t\tvar directory = chain.Pop();\n\t\t\tif ( !Directory.Exists( directory ) )\n\t\t\t{\n\t\t\t\tif ( CreateDirectoryW( directory, IntPtr.Zero ) ) CreatedDirectories.Add( directory );\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tvar error = Marshal.GetLastWin32Error();\n\t\t\t\t\tif ( error != 183 || !Directory.Exists( directory ) )\n\t\t\t\t\t\tthrow new ImportContractException( \"CopyFailed\", $\"A destination ancestor could not be reserved (Windows error {error}).\" );\n\t\t\t\t}\n\t\t\t}\n\t\t\tLockDirectory( directory );\n\t\t}\n\t\tif ( !CreateDirectoryW( FinalDirectory, IntPtr.Zero ) )\n\t\t{\n\t\t\tvar error = Marshal.GetLastWin32Error();\n\t\t\tthrow new ImportContractException( error == 183 ? \"DestinationExists\" : \"CopyFailed\",\n\t\t\t\terror == 183 ? \"The final import directory was created by another operation.\" : $\"The final import directory could not be reserved (Windows error {error}).\" );\n\t\t}\n\t\tCreatedDirectories.Add( FinalDirectory );\n\t\tLockDirectory( FinalDirectory );\n\t}\n\n\tinternal IReadOnlyList<string> CopyInputs()\n\t{\n\t\tlong streamed = 0;\n\t\tvar buffer = new byte[128 * 1024];\n\t\tforeach ( var copy in Copies )\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tusing var input = new FileStream( copy.SourceAbsolute, FileMode.Open, FileAccess.Read, FileShare.Read, buffer.Length, FileOptions.SequentialScan );\n\t\t\t\tvar output = OpenOwnedFile( copy.DestinationAbsolute );\n\t\t\t\tOwnedFiles.Add( copy.DestinationAbsolute );\n\t\t\t\t_ownedStreams.Add( copy.DestinationAbsolute, output );\n\t\t\t\tint read;\n\t\t\t\twhile ( (read = input.Read( buffer, 0, buffer.Length )) != 0 )\n\t\t\t\t{\n\t\t\t\t\tstreamed = checked(streamed + read);\n\t\t\t\t\tif ( streamed > MaximumBytes )\n\t\t\t\t\t\tthrow new ImportContractException( \"LimitExceeded\", \"Selected files grew beyond 1,073,741,824 bytes while copying.\" );\n\t\t\t\t\toutput.Write( buffer, 0, read );\n\t\t\t\t}\n\t\t\t\toutput.Flush( true );\n\t\t\t\tCompletedCopies.Add( copy.DestinationAsset );\n\t\t\t}\n\t\t\tcatch ( ImportContractException ) { throw; }\n\t\t\tcatch ( Exception ) { throw new ImportContractException( \"CopyFailed\", $\"Failed to copy '{copy.DestinationAsset}'.\" ); }\n\t\t}\n\t\treturn CompletedCopies;\n\t}\n\n\tinternal void WriteMarker( string writerState )\n\t{\n\t\tvar marker = Path.Combine( FinalDirectory, MarkerName );\n\t\tvar json = JsonSerializer.Serialize( new Marker( 1, TransactionId, SourceAsset, ModelAsset, writerState ) );\n\t\tif ( _markerStream is null )\n\t\t{\n\t\t\t_markerStream = OpenOwnedFile( marker );\n\t\t\tOwnedFiles.Add( marker );\n\t\t\t_ownedStreams.Add( marker, _markerStream );\n\t\t}\n\t\t_markerStream.Position = 0;\n\t\t_markerStream.SetLength( 0 );\n\t\tusing ( var writer = new StreamWriter( _markerStream, System.Text.Encoding.UTF8, 1024, true ) )\n\t\t{\n\t\t\twriter.Write( json );\n\t\t\twriter.Flush();\n\t\t}\n\t\t_markerStream.Flush( true );\n\t}\n\n\tinternal IReadOnlyList<string> CleanupPreEngine()\n\t{\n\t\tvar remaining = new List<string>();\n\t\tvar handleOwned = _ownedStreams.Keys.ToHashSet( StringComparer.OrdinalIgnoreCase );\n\t\tforeach ( var owned in _ownedStreams.ToArray() )\n\t\t{\n\t\t\tvar disposition = new FileDispositionInfo { DeleteFile = true };\n\t\t\ttry\n\t\t\t{\n\t\t\t\tif ( !SetFileInformationByHandle( owned.Value.SafeFileHandle, 4, ref disposition, (uint)Marshal.SizeOf<FileDispositionInfo>() ) )\n\t\t\t\t\tremaining.Add( ToReportedPath( owned.Key ) );\n\t\t\t}\n\t\t\tcatch { remaining.Add( ToReportedPath( owned.Key ) ); }\n\t\t\tfinally\n\t\t\t{\n\t\t\t\ttry { owned.Value.Dispose(); }\n\t\t\t\tcatch { remaining.Add( ToReportedPath( owned.Key ) ); }\n\t\t\t}\n\t\t}\n\t\t_ownedStreams.Clear();\n\t\t_markerStream = null;\n\t\tforeach ( var file in OwnedFiles.Where( x => !handleOwned.Contains( x ) ) )\n\t\t\tif ( File.Exists( file ) ) remaining.Add( ToReportedPath( file ) );\n\t\tforeach ( var directory in CreatedDirectories )\n\t\t\tif ( Directory.Exists( directory ) ) remaining.Add( ToReportedPath( directory ) );\n\t\tforeach ( var entry in _directoryHandles.ToArray() )\n\t\t{\n\t\t\ttry { entry.Handle.Dispose(); }\n\t\t\tcatch { remaining.Add( ToReportedPath( entry.Path ) ); }\n\t\t}\n\t\t_directoryHandles.Clear();\n\t\treturn remaining.Distinct( StringComparer.OrdinalIgnoreCase ).OrderBy( x => x, StringComparer.OrdinalIgnoreCase ).ToArray();\n\t}\n\n\tinternal string[] PendingPaths( IEnumerable<string> generated ) => new[] { RelativeDirectory }\n\t\t.Concat( OwnedFiles.Select( ToReportedPath ) ).Concat( generated )\n\t\t.Append( SourceAsset ).Append( ModelAsset )\n\t\t.Distinct( StringComparer.OrdinalIgnoreCase ).OrderBy( x => x, StringComparer.OrdinalIgnoreCase ).ToArray();\n\n\tinternal string ToReportedPath( string absolute ) => IsContained( AssetsRoot, absolute )\n\t\t? Path.GetRelativePath( AssetsRoot, absolute ).Replace( '\\\\', '/' ) : absolute;\n\n\tinternal static string NormalizeTarget( string target )\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( target ) || target != target.Trim() )\n\t\t\tthrow new ImportContractException( \"InvalidInput\", \"targetDirectory must be a non-empty relative path without surrounding whitespace.\" );\n\t\tvar normalized = target.Replace( '\\\\', '/' );\n\t\tif ( Path.IsPathRooted( target ) || normalized.StartsWith( \"//\", StringComparison.Ordinal ) || normalized.Contains( ':' ) )\n\t\t\tthrow new ImportContractException( \"InvalidInput\", \"targetDirectory must be relative to Assets.\" );\n\t\tvar parts = normalized.Split( '/', StringSplitOptions.None );\n\t\tif ( parts.Length == 0 || parts.Any( x => x.Length == 0 || x is \".\" or \"..\" ) )\n\t\t\tthrow new ImportContractException( \"InvalidInput\", \"targetDirectory contains an empty, current, or parent component.\" );\n\t\tif ( parts[0].Equals( \"Assets\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\tthrow new ImportContractException( \"InvalidInput\", \"targetDirectory must not include an Assets prefix.\" );\n\t\tforeach ( var part in parts ) ValidateFileName( part, true );\n\t\treturn string.Join( '/', parts );\n\t}\n\n\tinternal static void ValidateFileName( string name, bool extensionAllowed )\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( name ) || name != name.Trim() || name.EndsWith( \".\", StringComparison.Ordinal ) )\n\t\t\tthrow new ImportContractException( \"InvalidInput\", \"A planned filename is empty, whitespace-padded, or ends in a dot.\" );\n\t\tif ( name.IndexOfAny( Path.GetInvalidFileNameChars() ) >= 0 || name.Contains( '/' ) || name.Contains( '\\\\' ) )\n\t\t\tthrow new ImportContractException( \"InvalidInput\", \"A planned filename contains invalid characters or separators.\" );\n\t\tif ( !extensionAllowed && Path.GetFileName( name ) != name )\n\t\t\tthrow new ImportContractException( \"InvalidInput\", \"modelName must be a filename stem.\" );\n\t\tvar deviceStem = name.Split( '.', 2 )[0];\n\t\tif ( ReservedNames.Contains( deviceStem ) )\n\t\t\tthrow new ImportContractException( \"InvalidInput\", \"A planned filename is a reserved Windows device name.\" );\n\t}\n\n\tinternal static void EnsureNoReparsePoints( string root, string destination )\n\t{\n\t\tfor ( var cursor = destination; !string.Equals( cursor, root, StringComparison.OrdinalIgnoreCase ); cursor = Path.GetDirectoryName( cursor )! )\n\t\t{\n\t\t\tif ( !Directory.Exists( cursor ) ) continue;\n\t\t\tif ( (File.GetAttributes( cursor ) & FileAttributes.ReparsePoint) != 0 )\n\t\t\t\tthrow new ImportContractException( \"InvalidInput\", \"The destination chain contains a symbolic link or junction.\" );\n\t\t}\n\t}\n\tprivate void LockDirectory( string directory )\n\t{\n\t\tvar handle = CreateFileHandle( directory, 0x80000000, 0x00000001 | 0x00000002, IntPtr.Zero, 3,\n\t\t\t0x02000000 | 0x00200000, IntPtr.Zero );\n\t\tif ( handle.IsInvalid ) throw new ImportContractException( \"CopyFailed\", \"The destination ancestry could not be locked against replacement.\" );\n\t\tif ( !GetFileInformationByHandle( handle, out var information ) || (information.FileAttributes & 0x400) != 0 )\n\t\t{\n\t\t\thandle.Dispose();\n\t\t\tthrow new ImportContractException( \"InvalidInput\", \"The destination chain contains or changed to a symbolic link or junction.\" );\n\t\t}\n\t\t_directoryHandles.Add( (directory, handle) );\n\t}\n\n\tprivate static FileStream OpenOwnedFile( string path )\n\t{\n\t\tvar handle = CreateFileHandle( path, 0x80000000 | 0x40000000 | 0x00010000, 0x00000001,\n\t\t\tIntPtr.Zero, 1, 0x08000000, IntPtr.Zero );\n\t\tif ( handle.IsInvalid )\n\t\t{\n\t\t\tvar error = Marshal.GetLastWin32Error();\n\t\t\thandle.Dispose();\n\t\t\tthrow new IOException( $\"Exclusive file creation failed (Windows error {error}).\" );\n\t\t}\n\t\treturn new FileStream( handle, FileAccess.ReadWrite, 128 * 1024, false );\n\t}\n\n\tprivate void DisposeHandles()\n\t{\n\t\tforeach ( var stream in _ownedStreams.Values ) stream.Dispose();\n\t\t_ownedStreams.Clear();\n\t\t_markerStream = null;\n\t\tforeach ( var entry in _directoryHandles ) entry.Handle.Dispose();\n\t\t_directoryHandles.Clear();\n\t}\n\n\tinternal void Dispose() => DisposeHandles();\n\tinternal void PrepareForEngineMutation()\n\t{\n\t\tforeach ( var stream in _ownedStreams.Values ) stream.Dispose();\n\t\t_ownedStreams.Clear();\n\t\t_markerStream = null;\n\t}\n\n\n\tinternal static bool IsContained( string root, string path )\n\t{\n\t\tvar prefix = Path.TrimEndingDirectorySeparator( Path.GetFullPath( root ) ) + Path.DirectorySeparatorChar;\n\t\tvar full = Path.GetFullPath( path );\n\t\treturn full.StartsWith( prefix, StringComparison.OrdinalIgnoreCase );\n\t}\n\n\tprivate static void EnsureContained( string root, string path )\n\t{\n\t\tif ( !IsContained( root, path ) ) throw new ImportContractException( \"InvalidInput\", \"targetDirectory escapes the active Assets root.\" );\n\t}\n\n\tprivate static string JoinAsset( string directory, string name ) => $\"{directory.TrimEnd( '/' )}/{name}\";\n\n\t[DllImport( \"kernel32.dll\", CharSet = CharSet.Unicode, SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tprivate static extern bool CreateDirectoryW( string path, IntPtr securityAttributes );\n\t[DllImport( \"kernel32.dll\", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = \"CreateFileW\" )]\n\tprivate static extern SafeFileHandle CreateFileHandle( string fileName, uint desiredAccess, uint shareMode,\n\t\tIntPtr securityAttributes, uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile );\n\t[DllImport( \"kernel32.dll\", SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tprivate static extern bool GetFileInformationByHandle( SafeFileHandle handle, out ByHandleFileInformation information );\n\n\t[StructLayout( LayoutKind.Sequential )]\n\tprivate struct ByHandleFileInformation\n\t{\n\t\tpublic uint FileAttributes;\n\t\tpublic System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;\n\t\tpublic System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;\n\t\tpublic System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;\n\t\tpublic uint VolumeSerialNumber;\n\t\tpublic uint FileSizeHigh;\n\t\tpublic uint FileSizeLow;\n\t\tpublic uint NumberOfLinks;\n\t\tpublic uint FileIndexHigh;\n\t\tpublic uint FileIndexLow;\n\t}\n\t[StructLayout( LayoutKind.Sequential )]\n\tprivate struct FileDispositionInfo\n\t{\n\t\t[MarshalAs( UnmanagedType.Bool )]\n\t\tpublic bool DeleteFile;\n\t}\n\n\t[DllImport( \"kernel32.dll\", SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tprivate static extern bool SetFileInformationByHandle( SafeFileHandle handle, int fileInformationClass,\n\t\tref FileDispositionInfo fileInformation, uint bufferSize );\n\n\n\n\n\tinternal sealed record CopyPlan( string SourceAbsolute, string DestinationAbsolute, string DestinationAsset, long PreflightLength );\n\tinternal sealed record Marker( int Version, string TransactionId, string SourceAsset, string ModelAsset, string WriterState );\n}\n\ninternal class ImportContractException : Exception\n{\n\tinternal string Code { get; }\n\tinternal ImportContractException( string code, string message ) : base( message ) => Code = code;\n}\n"
        },
        {
            "Ident": "kitsupanic.sbox_mcp_plus",
            "Path": "Editor/McpExtras.cs",
            "FileName": "McpExtras.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381524,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing System;\r\nusing System.IO;\r\nusing System.Linq;\r\n\r\nnamespace Editor.Mcp;\r\n\r\n/// <summary>\r\n/// Local extensions to the built in MCP tools, living in a shared library so every project here\r\n/// gets them without waiting on an engine release. Tool names are prefixed 'x_' so they can never\r\n/// collide with the engine's own once the upstream equivalents land.\r\n/// </summary>\r\n[McpToolset( \"extras\", \"Local extensions to the built-in MCP tools\" )]\r\npublic static partial class ExtrasTools\r\n{\r\n\t/// <summary>\r\n\t/// Make a scene the active editor tab, opening it from its asset path when it isn't open yet.\r\n\t/// Scene edits always target the active scene, so switch before editing a background scene.\r\n\t/// Returns the tab it settled on - name, resource path, type, unsaved changes and root object\r\n\t/// count - plus a message saying what happened. list_scenes shows what's already open.\r\n\t/// </summary>\r\n\t/// <param name=\"scene\">Scene name or resource path as list_scenes reports it, or a .scene/.prefab path from asset_search.</param>\r\n\t[McpTool( \"x_open_scene\" )]\r\n\tpublic static SceneTab OpenSceneTab( string scene )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( scene ) )\r\n\t\t\tthrow new Exception( \"Give a scene name or resource path - list_scenes shows what's open, asset_search type:scene finds scene assets on disk\" );\r\n\r\n\t\tif ( Game.IsPlaying )\r\n\t\t\tthrow new Exception( \"Can't switch scene tabs while playing - play_stop first\" );\r\n\r\n\t\tvar session = FindSession( scene );\r\n\r\n\t\tif ( session is GameEditorSession )\r\n\t\t\tthrow new Exception( \"That's the running game session, which has no tab to switch to - play_stop first, then open the scene you want to edit\" );\r\n\r\n\t\tif ( session is not null && session == SceneEditorSession.Active )\r\n\t\t\treturn Row( session, $\"'{session.Scene?.Name}' was already the active tab - nothing changed\" );\r\n\r\n\t\tvar opened = session is null;\r\n\r\n\t\tsession ??= SceneEditorSession.CreateFromPath( scene )\r\n\t\t\t?? throw new Exception( $\"Nothing to open for '{scene}' - list_scenes shows what's already open, asset_search type:scene finds scene assets on disk\" );\r\n\r\n\t\tsession.MakeActive();\r\n\r\n\t\treturn Row( session, opened\r\n\t\t\t? $\"Opened '{session.Scene?.Name}' from disk and made it the active tab\"\r\n\t\t\t: $\"Switched the active tab to the already open '{session.Scene?.Name}'\" );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Create a new empty scene beneath scenes/diagnostics, save it without prompting, and make\r\n\t/// its tab active. Existing tabs, including dirty tabs, are left untouched.\r\n\t/// </summary>\r\n\t/// <param name=\"path\">Project-relative .scene path beneath scenes/diagnostics.</param>\r\n\t/// <param name=\"name\">Optional scene name. Defaults to the destination file name.</param>\r\n\t[McpTool( \"x_create_scene\" )]\r\n\tpublic static SceneTab CreateScene( string path, string name = \"\" )\r\n\t{\r\n\t\tif ( Game.IsPlaying )\r\n\t\t\tthrow new Exception( \"Can't create a scene while playing - play_stop first\" );\r\n\r\n\t\tvar destination = ValidateDiagnosticScenePath( path );\r\n\t\tvar resourcePath = destination.RelativePath;\r\n\t\tvar sceneName = string.IsNullOrWhiteSpace( name )\r\n\t\t\t? Path.GetFileNameWithoutExtension( resourcePath )\r\n\t\t\t: name.Trim();\r\n\r\n\t\tif ( File.Exists( destination.AbsolutePath ) || AssetSystem.FindByPath( resourcePath ) is not null )\r\n\t\t\tthrow new Exception( $\"A scene already exists at '{resourcePath}' - choose a new diagnostic path\" );\r\n\r\n\t\tvar previous = SceneEditorSession.Active;\r\n\t\tSceneEditorSession created = null;\r\n\t\tAsset asset = null;\r\n\t\tvar saved = false;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tcreated = SceneEditorSession.CreateDefault()\r\n\t\t\t\t?? throw new Exception( \"Couldn't create a new editor scene session\" );\r\n\r\n\t\t\tvar scene = created.Scene;\r\n\t\t\tforeach ( var child in scene.Children.ToArray() )\r\n\t\t\t\tchild.Destroy();\r\n\t\t\tscene.ProcessDeletes();\r\n\t\t\tscene.Name = sceneName;\r\n\r\n\t\t\tDirectory.CreateDirectory( Path.GetDirectoryName( destination.AbsolutePath ) );\r\n\r\n\t\t\tasset = AssetSystem.CreateResource( \"scene\", destination.AbsolutePath )\r\n\t\t\t\t?? throw new Exception( $\"Couldn't create the scene resource at '{resourcePath}'\" );\r\n\r\n\t\t\tvar sceneFile = new SceneFile\r\n\t\t\t{\r\n\t\t\t\tId = Guid.NewGuid(),\r\n\t\t\t\tGameObjects = [],\r\n\t\t\t\tSceneProperties = scene.SerializeProperties()\r\n\t\t\t};\r\n\r\n\t\t\tsaved = asset.SaveToDisk( sceneFile );\r\n\t\t\tif ( !saved )\r\n\t\t\t\tthrow new Exception( $\"Couldn't save the new scene at '{resourcePath}'\" );\r\n\r\n\t\t\tcreated.Destroy();\r\n\t\t\tcreated = null;\r\n\r\n\t\t\tvar opened = SceneEditorSession.CreateFromPath( resourcePath )\r\n\t\t\t\t?? throw new Exception( $\"The new scene was saved but couldn't be opened at '{resourcePath}'\" );\r\n\r\n\t\t\topened.MakeActive();\r\n\t\t\treturn Row( opened, $\"Created '{opened.Scene?.Name}' at '{resourcePath}' and made it the active tab\" );\r\n\t\t}\r\n\t\tcatch\r\n\t\t{\r\n\t\t\tcreated?.Destroy();\r\n\r\n\t\t\tif ( asset is not null )\r\n\t\t\t\tasset.Delete();\r\n\r\n\t\t\tif ( previous is not null && previous != SceneEditorSession.Active )\r\n\t\t\t\tprevious.MakeActive();\r\n\r\n\t\t\tthrow;\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\t/// <summary>\r\n\t/// What the editor is doing right now - which project is open, which scene tab is active and\r\n\t/// whether it has unsaved changes, and whether play mode is running or paused. ActiveScene here\r\n\t/// is the editor's active tab, which is what the scene tools edit; the built in editor_status\r\n\t/// reports the running game's scene instead and can disagree while playing. Follow up with\r\n\t/// scene_tree for the hierarchy, or x_open_scene to switch tabs.\r\n\t/// </summary>\r\n\t[McpTool.ReadOnly( \"x_editor_status\" )]\r\n\tpublic static EditorStatusExtras GetEditorStatus()\r\n\t{\r\n\t\tvar session = SceneEditorSession.Active;\r\n\t\tvar scene = session?.Scene ?? Game.ActiveScene;\r\n\r\n\t\treturn new EditorStatusExtras\r\n\t\t{\r\n\t\t\tProject = Project.Current?.Config?.Ident,\r\n\t\t\tProjectTitle = Project.Current?.Config?.Title,\r\n\t\t\tActiveScene = scene?.Name,\r\n\t\t\tActiveScenePath = scene?.Source?.ResourcePath,\r\n\t\t\tSceneHasUnsavedChanges = session?.HasUnsavedChanges ?? false,\r\n\t\t\tOpenSceneCount = SceneEditorSession.All.Count,\r\n\t\t\tIsPlaying = Game.IsPlaying,\r\n\t\t\tIsPaused = Game.IsPaused\r\n\t\t};\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Render a camera in the scene and return it as an image, with UI text intact. Give any\r\n\t/// CameraComponent's id or its game object's id, or nothing for the scene's main camera.\r\n\t/// Use this instead of camera_screenshot whenever the shot includes Razor UI: camera_screenshot\r\n\t/// renders every text label as a flat gray rectangle at any size other than the live viewport's,\r\n\t/// because rendering offscreen relayouts the UI, which throws away each label's text texture\r\n\t/// without rebuilding the render descriptors that point at it. In play mode this tool always\r\n\t/// renders at the native screen resolution - where that relayout is a no-op, so the descriptors\r\n\t/// stay valid - and downscales the result to the size you asked for: the output matches the\r\n\t/// requested width and height exactly, but its detail is capped at the viewport's resolution,\r\n\t/// so asking for more pixels than the viewport has gets you an upscale, not more detail. In\r\n\t/// edit mode there is no game viewport, so no live screen-size UI exists to corrupt and it\r\n\t/// renders directly at the requested size, at full detail - making this a drop in replacement\r\n\t/// for camera_screenshot in both modes. find_game_objects with component 'Camera' lists the\r\n\t/// cameras in a scene.\r\n\t/// </summary>\r\n\t/// <param name=\"camera\">A CameraComponent id or its game object's id. Empty uses the scene's main camera.</param>\r\n\t/// <param name=\"width\">Image width in pixels.</param>\r\n\t/// <param name=\"height\">Image height in pixels.</param>\r\n\t/// <param name=\"includeUi\">Include any UI the camera renders.</param>\r\n\t[McpTool.ReadOnly( \"x_camera_screenshot\" )]\r\n\tpublic static object CameraScreenshotNative( string camera = \"\", [Sandbox.Range( 16, 4096 )] int width = 1280,\r\n\t\t[Sandbox.Range( 16, 4096 )] int height = 720, bool includeUi = true )\r\n\t{\r\n\t\tvar target = ResolveCamera( camera );\r\n\r\n\t\tif ( !target.IsValid() )\r\n\t\t\tthrow new Exception( \"The scene has no camera - find one with find_game_objects component 'Camera', or add one\" );\r\n\r\n\t\t// The one size the engine bug can't bite: identical to the screen, so the offscreen\r\n\t\t// relayout changes no panel's size and no text texture gets released underneath its\r\n\t\t// descriptor. Everything else is a downscale we do ourselves.\r\n\t\tvar nativeWidth = Screen.Width.CeilToInt();\r\n\t\tvar nativeHeight = Screen.Height.CeilToInt();\r\n\r\n\t\t// No screen size means no game viewport - edit mode. Nothing live is laid out at the\r\n\t\t// screen's size, so there are no text textures a relayout can destroy, and we can render\r\n\t\t// straight at the size asked for, exactly as the built in camera_screenshot does.\r\n\t\tif ( nativeWidth <= 1 || nativeHeight <= 1 )\r\n\t\t{\r\n\t\t\tvar direct = new Bitmap( width, height );\r\n\t\t\ttarget.RenderToBitmap( direct, includeUi );\r\n\t\t\treturn direct;\r\n\t\t}\r\n\r\n\t\tvar bitmap = new Bitmap( nativeWidth, nativeHeight );\r\n\t\ttarget.RenderToBitmap( bitmap, includeUi );\r\n\r\n\t\tif ( nativeWidth == width && nativeHeight == height )\r\n\t\t\treturn bitmap;\r\n\r\n\t\t// Resize hands back a new bitmap, so the native capture is ours to release\r\n\t\tusing ( bitmap )\r\n\t\t{\r\n\t\t\treturn bitmap.Resize( width, height );\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Aim a camera game object at a world-space point using the engine's Rotation.LookAt.\r\n\t/// The camera must belong to the active editor scene. The edit is undoable.\r\n\t/// </summary>\r\n\t/// <param name=\"camera\">A CameraComponent id or its game object's id.</param>\r\n\t/// <param name=\"target\">World-space target as 'x,y,z'.</param>\r\n\t/// <param name=\"up\">World-space up direction as 'x,y,z'. Defaults to +Z.</param>\r\n\t[McpTool( \"x_camera_look_at\" )]\r\n\tpublic static CameraLookAtResult CameraLookAt( string camera, string target, string up = \"0,0,1\" )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( camera ) )\r\n\t\t\tthrow new Exception( \"Give a CameraComponent or camera game object id - find_game_objects component 'Camera' lists them\" );\r\n\r\n\t\tvar session = SceneEditorSession.Active\r\n\t\t\t?? throw new Exception( \"No editor scene tab is active\" );\r\n\t\tvar component = ResolveCamera( camera );\r\n\t\tif ( component.Scene != session.Scene )\r\n\t\t\tthrow new Exception( \"The camera isn't in the active editor scene - use x_open_scene or switch_scene first\" );\r\n\r\n\t\tvar targetPosition = Vector3.Parse( target );\r\n\t\tvar upDirection = Vector3.Parse( up );\r\n\t\tvar direction = targetPosition - component.WorldPosition;\r\n\t\tif ( direction.LengthSquared < 0.000001f )\r\n\t\t\tthrow new Exception( \"The camera position and look-at target must differ\" );\r\n\t\tif ( upDirection.LengthSquared < 0.000001f )\r\n\t\t\tthrow new Exception( \"The look-at up direction must be non-zero\" );\r\n\r\n\t\tvar gameObject = component.GameObject;\r\n\t\tusing ( session.UndoScope( \"Aim Camera\" ).WithGameObjectChanges( gameObject, GameObjectUndoFlags.All ).Push() )\r\n\t\t{\r\n\t\t\tgameObject.WorldRotation = Rotation.LookAt( direction.Normal, upDirection.Normal );\r\n\t\t}\r\n\r\n\t\treturn new CameraLookAtResult\r\n\t\t{\r\n\t\t\tId = gameObject.Id,\r\n\t\t\tName = gameObject.Name,\r\n\t\t\tPosition = gameObject.WorldPosition,\r\n\t\t\tTarget = targetPosition,\r\n\t\t\tAngles = gameObject.WorldRotation.Angles()\r\n\t\t};\r\n\t}\r\n\r\n\t/// <summary>The resulting camera aim.</summary>\r\n\tpublic class CameraLookAtResult\r\n\t{\r\n\t\tpublic Guid Id { get; set; }\r\n\t\tpublic string Name { get; set; }\r\n\t\tpublic Vector3 Position { get; set; }\r\n\t\tpublic Vector3 Target { get; set; }\r\n\t\tpublic Angles Angles { get; set; }\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Set one game object's saved networking mode in the active editor scene. The edit is undoable.\r\n\t/// </summary>\r\n\t/// <param name=\"id\">Game object GUID from scene_tree or find_game_objects.</param>\r\n\t/// <param name=\"mode\">Never, Object, or Snapshot.</param>\r\n\t[McpTool( \"x_set_network_mode\" )]\r\n\tpublic static NetworkModeResult SetNetworkMode( string id, NetworkMode mode )\r\n\t{\r\n\t\tif ( Game.IsPlaying )\r\n\t\t\tthrow new Exception( \"Can't change saved network mode while playing - play_stop first\" );\r\n\t\tif ( !Guid.TryParse( id, out var guid ) )\r\n\t\t\tthrow new Exception( $\"'{id}' isn't a game object GUID\" );\r\n\r\n\t\tvar session = SceneEditorSession.Active\r\n\t\t\t?? throw new Exception( \"No editor scene tab is active\" );\r\n\t\tvar gameObject = session.Scene?.Directory?.FindByGuid( guid )\r\n\t\t\t?? throw new Exception( $\"No game object with id {guid} exists in the active scene\" );\r\n\r\n\t\tusing ( session.UndoScope( \"Set Network Mode\" ).WithGameObjectChanges( gameObject, GameObjectUndoFlags.All ).Push() )\r\n\t\t{\r\n\t\t\tgameObject.NetworkMode = mode;\r\n\t\t}\r\n\r\n\t\treturn new NetworkModeResult { Id = gameObject.Id, Name = gameObject.Name, Mode = gameObject.NetworkMode };\r\n\t}\r\n\r\n\tpublic class NetworkModeResult\r\n\t{\r\n\t\tpublic Guid Id { get; set; }\r\n\t\tpublic string Name { get; set; }\r\n\t\tpublic NetworkMode Mode { get; set; }\r\n\t}\r\n\r\n\t/// <summary>One scene tab open in the editor.</summary>\r\n\tpublic class SceneTab\r\n\t{\r\n\t\t/// <summary>What happened - opened, switched, or already active.</summary>\r\n\t\tpublic string Message { get; set; }\r\n\r\n\t\t/// <summary>The scene's name, as list_scenes reports it.</summary>\r\n\t\tpublic string Name { get; set; }\r\n\r\n\t\t/// <summary>The scene asset's resource path. Null for a scene that was never saved.</summary>\r\n\t\tpublic string ResourcePath { get; set; }\r\n\r\n\t\t/// <summary>Scene, Prefab, or Game for the running session.</summary>\r\n\t\tpublic string Type { get; set; }\r\n\r\n\t\t/// <summary>Whether this is the active tab - true unless something else took focus.</summary>\r\n\t\tpublic bool IsActive { get; set; }\r\n\r\n\t\t/// <summary>Whether the scene has edits that save_scene hasn't written yet.</summary>\r\n\t\tpublic bool HasUnsavedChanges { get; set; }\r\n\r\n\t\t/// <summary>How many objects sit at the scene root.</summary>\r\n\t\tpublic int RootObjectCount { get; set; }\r\n\t}\r\n\r\n\t/// <summary>The editor's current state, from the editor's point of view rather than the game's.</summary>\r\n\tpublic class EditorStatusExtras\r\n\t{\r\n\t\t/// <summary>The open project's ident.</summary>\r\n\t\tpublic string Project { get; set; }\r\n\r\n\t\t/// <summary>The open project's title.</summary>\r\n\t\tpublic string ProjectTitle { get; set; }\r\n\r\n\t\t/// <summary>The active scene tab's name. Falls back to the running game's scene when no tab is active.</summary>\r\n\t\tpublic string ActiveScene { get; set; }\r\n\r\n\t\t/// <summary>The active scene's resource path. Null for a scene that was never saved.</summary>\r\n\t\tpublic string ActiveScenePath { get; set; }\r\n\r\n\t\t/// <summary>Whether the active scene has edits that save_scene hasn't written yet.</summary>\r\n\t\tpublic bool SceneHasUnsavedChanges { get; set; }\r\n\r\n\t\t/// <summary>How many scene tabs are open. list_scenes names them.</summary>\r\n\t\tpublic int OpenSceneCount { get; set; }\r\n\r\n\t\t/// <summary>Whether play mode is running - play_stop returns to editing.</summary>\r\n\t\tpublic bool IsPlaying { get; set; }\r\n\r\n\t\t/// <summary>Whether play mode is paused.</summary>\r\n\t\tpublic bool IsPaused { get; set; }\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The open session whose scene matches a name or resource path, case insensitive. Null when\r\n\t/// nothing open matches - the caller decides whether to open it from disk.\r\n\t/// </summary>\r\n\tprivate static SceneEditorSession FindSession( string nameOrPath )\r\n\t{\r\n\t\treturn SceneEditorSession.All\r\n\t\t\t.FirstOrDefault( x => string.Equals( x.Scene?.Name, nameOrPath, StringComparison.OrdinalIgnoreCase )\r\n\t\t\t\t|| string.Equals( x.Scene?.Source?.ResourcePath, nameOrPath, StringComparison.OrdinalIgnoreCase ) );\r\n\t}\r\n\r\n\tprivate static (string RelativePath, string AbsolutePath) ValidateDiagnosticScenePath( string path )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( path ) )\r\n\t\t\tthrow new Exception( \"Give a project-relative .scene path beneath scenes/diagnostics/\" );\r\n\r\n\t\tvar normalized = path.Trim().Replace( '\\\\', '/' );\r\n\t\tif ( Path.IsPathRooted( normalized ) )\r\n\t\t\tthrow new Exception( \"Scene path must be project-relative and beneath scenes/diagnostics/\" );\r\n\r\n\t\tif ( normalized.Split( '/', StringSplitOptions.RemoveEmptyEntries ).Contains( \"..\" ) )\r\n\t\t\tthrow new Exception( \"Scene path can't contain traversal segments\" );\r\n\r\n\t\tif ( !string.Equals( Path.GetExtension( normalized ), \".scene\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\tthrow new Exception( \"Scene path must use the .scene extension\" );\r\n\r\n\t\tif ( !normalized.StartsWith( \"scenes/diagnostics/\", StringComparison.OrdinalIgnoreCase )\r\n\t\t\t|| normalized.Length == \"scenes/diagnostics/\".Length )\r\n\t\t\tthrow new Exception( \"Scene path must be beneath scenes/diagnostics/\" );\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar assetsRoot = Path.GetFullPath( Project.Current.GetAssetsPath() );\r\n\t\t\tvar diagnosticsRoot = Path.GetFullPath( Path.Combine( assetsRoot, \"scenes\", \"diagnostics\" ) )\r\n\t\t\t\t.TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar ) + Path.DirectorySeparatorChar;\r\n\t\t\tvar absolute = Path.GetFullPath( Path.Combine( assetsRoot, normalized.Replace( '/', Path.DirectorySeparatorChar ) ) );\r\n\r\n\t\t\tif ( !absolute.StartsWith( diagnosticsRoot, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t\tthrow new Exception( \"Scene path must be beneath scenes/diagnostics/\" );\r\n\r\n\t\t\treturn (normalized, absolute);\r\n\t\t}\r\n\t\tcatch ( Exception exception ) when ( exception is ArgumentException or NotSupportedException or PathTooLongException )\r\n\t\t{\r\n\t\t\tthrow new Exception( \"Scene path isn't a valid project-relative path\" );\r\n\t\t}\r\n\t}\r\n\r\n\r\n\t/// <summary>\r\n\t/// The camera a tool argument names - a CameraComponent id, or a game object id whose\r\n\t/// CameraComponent we take. Empty means the active scene's main camera. The engine's own\r\n\t/// resolvers are private to the tools addon, so this repeats them.\r\n\t/// </summary>\r\n\tprivate static CameraComponent ResolveCamera( string camera )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( camera ) )\r\n\t\t{\r\n\t\t\tvar scene = SceneEditorSession.Active?.Scene ?? Game.ActiveScene\r\n\t\t\t\t?? throw new Exception( \"No scene is open in the editor\" );\r\n\r\n\t\t\treturn scene.Camera;\r\n\t\t}\r\n\r\n\t\tif ( !Guid.TryParse( camera, out var guid ) )\r\n\t\t\tthrow new Exception( $\"'{camera}' isn't a guid - find_game_objects and scene_tree show object ids, get_game_object shows component ids\" );\r\n\r\n\t\tforeach ( var session in SceneEditorSession.All )\r\n\t\t{\r\n\t\t\tif ( session.Scene?.Directory?.FindComponentByGuid( guid ) is Component component )\r\n\t\t\t{\r\n\t\t\t\treturn component as CameraComponent\r\n\t\t\t\t\t?? throw new Exception( \"That component isn't a camera - give a CameraComponent or its game object\" );\r\n\t\t\t}\r\n\r\n\t\t\tif ( session.Scene?.Directory?.FindByGuid( guid ) is GameObject go )\r\n\t\t\t{\r\n\t\t\t\t// includeDisabled, matching the built-in resolver's view of a game object's components\r\n\t\t\t\treturn go.Components.Get<CameraComponent>( true )\r\n\t\t\t\t\t?? throw new Exception( $\"'{go.Name}' has no camera component - find one with find_game_objects component 'Camera'\" );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tthrow new Exception( $\"Nothing in any open scene has id {guid} - find_game_objects and scene_tree show what's there\" );\r\n\t}\r\n\r\n\tprivate static SceneTab Row( SceneEditorSession session, string message )\r\n\t{\r\n\t\treturn new SceneTab\r\n\t\t{\r\n\t\t\tMessage = message,\r\n\t\t\tName = session.Scene?.Name,\r\n\t\t\tResourcePath = session.Scene?.Source?.ResourcePath,\r\n\t\t\tType = session is GameEditorSession ? \"Game\" : session.Scene is PrefabScene ? \"Prefab\" : \"Scene\",\r\n\t\t\tIsActive = session == SceneEditorSession.Active,\r\n\t\t\tHasUnsavedChanges = session.HasUnsavedChanges,\r\n\t\t\tRootObjectCount = session.Scene?.Children.Count ?? 0\r\n\t\t};\r\n\t}\r\n}\r\n"
        }
    ]
}