{"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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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 \u0026\u0026 authority != LaunchAuthority.RetainedUnidentified )\n\t\t\tthrow new ArgumentException( \u0022Only a retained unidentified launch may have no creation identity.\u0022,\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( \u0022O\u0022, 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/// \u003Csummary\u003EExact creation FILETIME captured at launch, or null when it could not be read.\u003C/summary\u003E\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/// \u003Csummary\u003EWhether the retained private job actually contains this process.\u003C/summary\u003E\n\tinternal bool AssignedToJob { get; }\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tinternal LaunchAuthority Authority { get; private set; }\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tinternal bool FailedLaunch =\u003E Authority != LaunchAuthority.Owned;\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tinternal void MarkRetainedFailedLaunch() =\u003E Authority = LaunchAuthority.RetainedFailedLaunch;\n\n\t/// \u003Csummary\u003EPromotes hidden cleanup authority once its exact process creation identity is readable.\u003C/summary\u003E\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( \u0022O\u0022, 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\u003Cint, OwnedLocalInstance\u003E _entries = new();\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tprivate readonly Dictionary\u003C(int ProcessId, string Token), OwnedLocalInstance\u003E _displaced = new();\n\n\tprivate bool _disposed;\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tinternal Action GracefulDelayCompletedForTesting { get; set; }\n\n\tinternal OwnedInstanceSnapshot LaunchSbox( bool windowed, Func\u003Culong, bool\u003E visibleSyntheticIdentity )\n\t{\n\t\tif ( !OperatingSystem.IsWindows() )\n\t\t\tthrow new OwnedInstanceException( \u0022Local instance launching is supported only on Windows.\u0022 );\n\n\t\tvar currentExecutable = Environment.ProcessPath;\n\t\tif ( string.IsNullOrWhiteSpace( currentExecutable )\n\t\t\t|| !string.Equals( Path.GetFileName( currentExecutable ), \u0022sbox-dev.exe\u0022, StringComparison.OrdinalIgnoreCase ) )\n\t\t\tthrow new OwnedInstanceException( \u0022Local instances can only be launched by sbox-dev.exe.\u0022 );\n\n\t\tvar directory = Path.GetDirectoryName( currentExecutable );\n\t\tvar executable = directory is null ? null : Path.Combine( directory, \u0022sbox.exe\u0022 );\n\t\tif ( executable is null || !File.Exists( executable ) )\n\t\t\tthrow new OwnedInstanceException( \u0022The sibling sbox.exe executable was not found.\u0022 );\n\n\t\tvar instanceNumber = AllocateInstanceNumber( visibleSyntheticIdentity );\n\t\tvar arguments = new List\u003Cstring\u003E { \u0022-joinlocal\u0022, \u0022\u002Binstanceid\u0022, instanceNumber.ToString( CultureInfo.InvariantCulture ) };\n\t\tif ( windowed )\n\t\t{\n\t\t\targuments.Add( \u0022-sw\u0022 );\n\t\t\targuments.Add( \u0022-720\u0022 );\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\u003Cstring\u003E 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\u003C(OwnedLocalInstance Entry, OwnedInstanceSnapshot Snapshot)\u003E();\n\t\t\tforeach ( var entry in AllEntries().ToArray() )\n\t\t\t{\n\t\t\t\tif ( entry.CreationFileTime is null \u0026\u0026 !TryCaptureCreationIdentity( entry ) )\n\t\t\t\t{\n\t\t\t\t\tif ( pruneExited \u0026\u0026 IsSignaled( entry.ProcessHandle ) \u0026\u0026 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 \u0026\u0026 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 =\u003E 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\u003COwnedTerminationOutcome\u003E TerminateAsync( int processId, string launchTimestamp, bool abrupt,\n\t\tCancellationToken cancellationToken )\n\t{\n\t\tif ( processId \u003C= 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 ) \u0026\u0026 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 \u0026\u0026 !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, \u0022The owned process job exited after the graceful close request.\u0022 );\n\t\t\t\t}\n\n\t\t\t\tif ( gracefulDeadline.Elapsed \u003E= 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 \u0026\u0026 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 \u003E= ForcedBudget ) break;\n\t\t\tawait Task.Delay( PollIntervalMilliseconds, cancellationToken );\n\t\t}\n\n\t\tthrow new OwnedInstanceException( \u0022Owned instance termination timed out.\u0022, 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 ) \u0026\u0026 entry.CreationFileTime is long creation )\n\t\t\t\tentry.CreationFileTime = creation \u002B 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\u003Culong, bool\u003E visibleSyntheticIdentity )\n\t{\n\t\tfor ( var attempt = 0; attempt \u003C 256; attempt\u002B\u002B )\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 =\u003E x.InstanceNumber == candidate ) ) continue;\n\t\t\t}\n\n\t\t\tif ( visibleSyntheticIdentity?.Invoke( SyntheticSteamIdentityBase \u002B (ulong)candidate ) == true ) continue;\n\t\t\treturn candidate;\n\t\t}\n\n\t\tthrow new OwnedInstanceException( \u0022Could not allocate a unique local instance identity.\u0022 );\n\t}\n\n\tprivate OwnedInstanceSnapshot LaunchContained( string executable, IReadOnlyList\u003Cstring\u003E 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( \u0022Contained process launching is supported only on Windows.\u0022 );\n\t\tif ( !File.Exists( executable ) )\n\t\t\tthrow new OwnedInstanceException( \u0022The local instance executable was not found.\u0022 );\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( \u0022Could not create the private process job.\u0022 );\n\n\t\t\tvar startup = new NativeMethods.StartupInfo { Size = Marshal.SizeOf\u003CNativeMethods.StartupInfo\u003E() };\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( \u0022Could not create the suspended local instance.\u0022 );\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( \u0022Could not read the owned process creation identity.\u0022, 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( \u0022Injected pre-assignment launch failure.\u0022, 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( \u0022Could not contain the local instance in its private process job.\u0022,\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( \u0022Could not verify private process-job containment.\u0022, processId );\n\n\t\t\tif ( failure.HasFlag( LaunchFailureInjection.AfterAssignmentBeforeIdentity ) )\n\t\t\t\tthrow new OwnedInstanceException( \u0022Injected pre-identity launch failure.\u0022, processId );\n\n\t\t\tif ( failure.HasFlag( LaunchFailureInjection.ImagePathCaptureFails ) )\n\t\t\t\tthrow new OwnedInstanceException( \u0022Could not read the owned process image identity.\u0022, 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( \u0022The launched process image did not match the requested executable.\u0022, 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( \u0022The new process ID collided with an owned registry entry.\u0022, 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( \u0022Could not resume the contained local instance.\u0022, 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( \u0022Local instance launch cleanup failed.\u0022, 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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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 \u0026\u0026 job is not null \u0026\u0026 !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 \u0026\u0026 !process.IsInvalid \u0026\u0026 !NativeMethods.TerminateProcess( process, 1 ) )\n\t\t{\n\t\t\terror = Marshal.GetLastPInvokeError();\n\t\t}\n\n\t\tif ( error == 0 \u0026\u0026 process is not null \u0026\u0026 !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 ) \u0026\u0026 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 \u003C= 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 \u0026\u0026 job is not null \u0026\u0026 !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( \u0022Owned instance termination failed.\u0022, processId, Marshal.GetLastPInvokeError() );\n\t\t\treturn (true, \u0022The owned process job was terminated.\u0022);\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( \u0022Owned instance termination failed.\u0022, processId, Marshal.GetLastPInvokeError() );\n\t\treturn (false, \u0022The retained failed launch was terminated through its original process handle; no job termination was invoked.\u0022);\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\u0026\u0026 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 \u0026\u0026 !IsSignaled( entry.ProcessHandle )\n\t\t\t\t\u0026\u0026 !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\u0027s main thread.\n\t\tNativeMethods.EnumWindows( (window, _) =\u003E\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/// \u003Csummary\u003EEvery retained entry, whether it holds its PID key or a displaced (PID, token) pair.\u003C/summary\u003E\n\tprivate IEnumerable\u003COwnedLocalInstance\u003E AllEntries() =\u003E _entries.Values.Concat( _displaced.Values );\n\n\t/// \u003Csummary\u003EThe entry whose exact (PID, token) pair matches, in either retention map.\u003C/summary\u003E\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\u0026\u0026 primary.CreationFileTime is not null\n\t\t\t\u0026\u0026 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\u0026\u0026 displaced.CreationFileTime is not null )\n\t\t\treturn displaced;\n\n\t\treturn null;\n\t}\n\n\t/// \u003Csummary\u003EWhether either retention map still claims this PID, whatever token was supplied.\u003C/summary\u003E\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/// \u003Csummary\u003E\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\u0027s 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/// \u003C/summary\u003E\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 ) \u0026\u0026 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\u003CNativeMethods.JobBasicAccountingInformation\u003E(), IntPtr.Zero ) )\n\t\t\tthrow NativeFailure( \u0022Could not inspect the owned process job.\u0022 );\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( \u0022Could not inspect the owned process state.\u0022 );\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( \u0022Could not read the owned process creation identity.\u0022, 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( \u0022Could not read the owned process image identity.\u0022 );\n\t\treturn Path.GetFullPath( buffer.ToString() );\n\t}\n\n\tprivate static string NormalizePath( string path ) =\u003E Path.TrimEndingDirectorySeparator( Path.GetFullPath( path ));\n\n\tprivate static string ResolveLogDirectory()\n\t{\n\t\tvar root = Environment.GetEnvironmentVariable( \u0022FACEPUNCH_ENGINE\u0022, EnvironmentVariableTarget.User );\n\t\tif ( string.IsNullOrWhiteSpace( root ) ) root = AppContext.BaseDirectory;\n\t\treturn Path.GetFullPath( Path.Combine( root, \u0022logs\u0022 ) );\n\t}\n\n\tprivate static string BuildCommandLine( string executable, IReadOnlyList\u003Cstring\u003E 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( \u0027 \u0027 );\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( \u0027\u0022\u0027 );\n\t\tvar slashes = 0;\n\t\tforeach ( var character in argument )\n\t\t{\n\t\t\tif ( character == \u0027\\\\\u0027 )\n\t\t\t{\n\t\t\t\tslashes\u002B\u002B;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif ( character == \u0027\u0022\u0027 )\n\t\t\t{\n\t\t\t\tbuilder.Append( \u0027\\\\\u0027, slashes * 2 \u002B 1 );\n\t\t\t\tbuilder.Append( \u0027\u0022\u0027 );\n\t\t\t\tslashes = 0;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tbuilder.Append( \u0027\\\\\u0027, slashes );\n\t\t\tslashes = 0;\n\t\t\tbuilder.Append( character );\n\t\t}\n\t\tbuilder.Append( \u0027\\\\\u0027, slashes * 2 );\n\t\tbuilder.Append( \u0027\u0022\u0027 );\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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\ninternal enum LaunchAuthority\n{\n\t/// \u003Csummary\u003EA fully identified launch whose process was resumed.\u003C/summary\u003E\n\tOwned = 0,\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tRetainedFailedLaunch = 1,\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tRetainedUnidentified = 2\n}\n\n/// \u003Csummary\u003EFault seams used by the focused ownership harness. Production always passes None.\u003C/summary\u003E\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/// \u003Csummary\u003EFails the creation-time read, so no creation identity is ever captured.\u003C/summary\u003E\n\tCreationTimeCaptureFails = 32,\n\n\t/// \u003Csummary\u003ERetains a failed launch as if its PID key were already held by another entry.\u003C/summary\u003E\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 ) =\u003E\n\t\tnew( processId, \u0022terminated\u0022, forced, message );\n\tinternal static OwnedTerminationOutcome AlreadyExited( int processId ) =\u003E\n\t\tnew( processId, \u0022already exited\u0022, false, \u0022The owned process job had already exited.\u0022 );\n\tinternal static OwnedTerminationOutcome NotOwned( int processId ) =\u003E\n\t\tnew( processId, \u0022not owned\u0022, false, \u0022No current in-memory ownership entry matches that process.\u0022 );\n\tinternal static OwnedTerminationOutcome IdentityMismatch( int processId ) =\u003E\n\t\tnew( processId, \u0022identity mismatch\u0022, false, \u0022The supplied or retained launch identity did not match; no process was signaled.\u0022 );\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 ) =\u003E SetHandle( handle );\n\tprotected override bool ReleaseHandle() =\u003E 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() =\u003E unchecked((long)(((ulong)High \u003C\u003C 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( \u0022user32.dll\u0022, SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool EnumWindows( EnumWindowsCallback callback, IntPtr state );\n\n\t[DllImport( \u0022user32.dll\u0022, SetLastError = true )]\n\tinternal static extern uint GetWindowThreadProcessId( IntPtr window, out uint processId );\n\n\t[DllImport( \u0022user32.dll\u0022, EntryPoint = \u0022PostMessageW\u0022, 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( \u0022kernel32.dll\u0022, EntryPoint = \u0022CreateJobObjectW\u0022, CharSet = CharSet.Unicode, SetLastError = true )]\n\tinternal static extern SafeKernelHandle CreateJobObjectW( IntPtr jobAttributes, string name );\n\n\t[DllImport( \u0022kernel32.dll\u0022, EntryPoint = \u0022CreateProcessW\u0022, 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( \u0022kernel32.dll\u0022, SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool AssignProcessToJobObject( SafeKernelHandle job, SafeKernelHandle process );\n\n\t[DllImport( \u0022kernel32.dll\u0022, 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( \u0022kernel32.dll\u0022, 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( \u0022kernel32.dll\u0022, EntryPoint = \u0022QueryFullProcessImageNameW\u0022, 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( \u0022kernel32.dll\u0022, SetLastError = true )]\n\tinternal static extern uint GetProcessId( SafeKernelHandle process );\n\n\t[DllImport( \u0022kernel32.dll\u0022, SetLastError = true )]\n\tinternal static extern uint ResumeThread( IntPtr thread );\n\n\t[DllImport( \u0022kernel32.dll\u0022, SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool TerminateProcess( SafeKernelHandle process, uint exitCode );\n\n\t[DllImport( \u0022kernel32.dll\u0022, SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tinternal static extern bool TerminateJobObject( SafeKernelHandle job, uint exitCode );\n\n\t[DllImport( \u0022kernel32.dll\u0022, 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( \u0022kernel32.dll\u0022, SetLastError = true )]\n\tinternal static extern uint WaitForSingleObject( SafeKernelHandle handle, uint milliseconds );\n\n\t[DllImport( \u0022kernel32.dll\u0022, 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\u003Cobject\u003E GetReferences( object asset );\n\tIReadOnlyList\u003Cstring\u003E GetUnrecognizedReferences( object asset );\n\tIReadOnlyList\u003Cstring\u003E GetInputDependencies( object asset );\n\tIReadOnlyList\u003Cstring\u003E GetAdditionalContentFiles( object asset );\n}\n\ninternal sealed class ModelImportBackendPipeline\n{\n\tprivate readonly IModelImportBackend _backend;\n\tinternal ModelImportBackendPipeline( IModelImportBackend backend ) =\u003E _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( \u0022RegistrationFailed\u0022, $\u0022Failed to register \u0027{expectedPath}\u0027.\u0022 );\n\t\t\treturn asset;\n\t\t}\n\t\tcatch ( ImportContractException ) { throw; }\n\t\tcatch ( Exception ex ) { throw new ImportContractException( \u0022RegistrationFailed\u0022, $\u0022Registration failed for \u0027{expectedPath}\u0027: {Safe( ex.Message, 700 )}\u0022 ); }\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( \u0022RegistrationFailed\u0022, $\u0022The source was not registered at \u0027{expectedPath}\u0027.\u0022 );\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( \u0022ModelCreationFailed\u0022, \u0022The generated model could not be confirmed at its planned asset path.\u0022 );\n\t\t\treturn resolved;\n\t\t}\n\t\tcatch ( ImportContractException ) { throw; }\n\t\tcatch ( Exception ex ) { throw new ImportContractException( \u0022ModelCreationFailed\u0022, $\u0022Model creation failed: {Safe( ex.Message, 700 )}\u0022 ); }\n\t}\n\n\tinternal async ValueTask\u003CBackendCompilationEvidence\u003E ObserveCompilationAsync( object model )\n\t{\n\t\tvar result = new BackendCompilationEvidence();\n\t\tresult.UnavailableEvidence[\u0022OperationCompletion\u0022] = \u0022Installed asset-state APIs do not expose operation completion.\u0022;\n\t\tresult.UnavailableEvidence[\u0022WriterShutdown\u0022] = \u0022Installed asset-state APIs do not expose writer shutdown.\u0022;\n\t\tException helperError = null;\n\t\tif ( TryRead( () =\u003E _backend.ReadIsCompiled( model ), out var initiallyCompiled, result, \u0022IsCompiled\u0022 ) \u0026\u0026 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( () =\u003E _backend.ReadIsCompiled( model ), out var isCompiled, result, \u0022IsCompiled\u0022 );\n\t\tTryRead( () =\u003E _backend.ReadIsCompiledAndUpToDate( model ), out var isUpToDate, result, \u0022IsCompiledAndUpToDate\u0022 );\n\t\tTryRead( () =\u003E _backend.ReadIsCompileFailed( model ), out var isFailed, result, \u0022IsCompileFailed\u0022 );\n\t\tresult.IsCompiled = isCompiled;\n\t\tresult.IsCompiledAndUpToDate = isUpToDate;\n\t\tresult.IsCompileFailed = isFailed;\n\t\tvar missing = result.UnavailableEvidence.Keys.Count( x =\u003E x is \u0022IsCompiled\u0022 or \u0022IsCompiledAndUpToDate\u0022 or \u0022IsCompileFailed\u0022 );\n\t\tresult.Status = missing == 0 ? \u0022observed\u0022 : missing == 3 ? \u0022unavailable\u0022 : \u0022partial\u0022;\n\t\tif ( result.IsCompileFailed == true ) throw new BackendPipelineException( \u0022CompileFailed\u0022, \u0022The model compiler reported failure.\u0022, result );\n\t\tif ( helperError is not null ) throw new BackendPipelineException( \u0022CompilationUnconfirmed\u0022, $\u0022Compilation observation failed: {Safe( helperError.Message, 900 )}\u0022, result );\n\t\tif ( result.Status != \u0022observed\u0022 || result.IsCompiled != true || result.IsCompiledAndUpToDate != true || result.IsCompileFailed != false )\n\t\t\tthrow new BackendPipelineException( \u0022CompilationUnconfirmed\u0022, \u0022Successful compilation could not be confirmed from all installed asset-state properties.\u0022, 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[\u0022ExhaustiveSourceDependencies\u0022] = \u0022Installed APIs do not expose exhaustive source dependencies.\u0022;\n\t\tresult.UnavailableEvidence[\u0022OptionalReferenceClassification\u0022] = \u0022Unrecognized references do not expose optionality.\u0022;\n\t\tresult.UnavailableEvidence[\u0022SourceMaterialPreservation\u0022] = \u0022The model helper may substitute materials/default.vmat.\u0022;\n\t\tvar pending = new Queue\u003Cobject\u003E( [source, model] );\n\t\tvar seen = new HashSet\u003Cstring\u003E( StringComparer.OrdinalIgnoreCase );\n\t\tvar failed = false;\n\t\twhile ( pending.Count \u003E 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, $\u0022Asset#{seen.Count}\u0022, \u0022AssetPath\u0022, 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[$\u0022AssetPath:Asset#{seen.Count}\u0022] = \u0022The backend returned no asset path.\u0022;\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\u003Cstring\u003E 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( \u0022The backend returned no referenced asset path.\u0022 );\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, \u0022ReferencePath\u0022, ex ); assetFailed = true; }\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch ( Exception ex ) { RecordUnavailable( result, path, \u0022References\u0022, ex ); assetFailed = true; }\n\t\t\ttry { result.UnresolvedReferences.AddRange( _backend.GetUnrecognizedReferences( asset ) ?? [] ); }\n\t\t\tcatch ( Exception ex ) { RecordUnavailable( result, path, \u0022UnrecognizedReferences\u0022, ex ); assetFailed = true; }\n\t\t\ttry { inputs = _backend.GetInputDependencies( asset ) ?? []; result.InputDependencies.AddRange( inputs ); }\n\t\t\tcatch ( Exception ex ) { RecordUnavailable( result, path, \u0022InputDependencies\u0022, ex ); assetFailed = true; }\n\t\t\ttry { additional = _backend.GetAdditionalContentFiles( asset ) ?? []; result.AdditionalContentFiles.AddRange( additional ); }\n\t\t\tcatch ( Exception ex ) { RecordUnavailable( result, path, \u0022AdditionalContentFiles\u0022, 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 ? \u0022unavailable\u0022 : \u0022partial\u0022 : \u0022inspected\u0022;\n\t\tif ( result.Status != \u0022inspected\u0022 ) throw new BackendPipelineException( \u0022DependencyInspectionFailed\u0022, \u0022The promised dependency inspection could not be completed.\u0022, result );\n\t\tif ( result.UnresolvedReferences.Count \u003E 0 ) throw new BackendPipelineException( \u0022UnresolvedDependencies\u0022, \u0022One or more reported references could not be resolved; installed APIs do not expose optionality.\u0022, result );\n\t\treturn result;\n\t}\n\n\tprivate bool SamePath( object asset, string expected ) =\u003E string.Equals( _backend.GetAssetPath( asset )?.Replace( \u0027\\\\\u0027, \u0027/\u0027 ), expected, StringComparison.OrdinalIgnoreCase );\n\tprivate static bool TryRead( Func\u003Cbool\u003E 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 ) =\u003E\n\t\tevidence.UnavailableEvidence[$\u0022{query}:{Safe( asset, 150 )}\u0022] = Safe( ex.Message, 256 );\n\tprivate static void Normalize( List\u003Cstring\u003E paths )\n\t{\n\t\tvar values = paths.Where( x =\u003E !string.IsNullOrWhiteSpace( x ) ).Select( x =\u003E x.Replace( \u0027\\\\\u0027, \u0027/\u0027 ) )\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase ).OrderBy( x =\u003E 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 ) ? \u0022The operation failed without a message.\u0022 : value.Replace( \u0027\\r\u0027, \u0027 \u0027 ).Replace( \u0027\\n\u0027, \u0027 \u0027 );\n\t\treturn text.Length \u003C= maximum ? text : text[..maximum];\n\t}\n}\n\ninternal sealed class BackendCompilationEvidence\n{\n\tinternal string Status { get; set; } = \u0022not_started\u0022;\n\tinternal bool? IsCompiled { get; set; }\n\tinternal bool? IsCompiledAndUpToDate { get; set; }\n\tinternal bool? IsCompileFailed { get; set; }\n\tinternal Dictionary\u003Cstring, string\u003E UnavailableEvidence { get; } = new( StringComparer.Ordinal );\n}\n\ninternal sealed class BackendDependencyEvidence\n{\n\tinternal string Status { get; set; } = \u0022not_started\u0022;\n\tinternal List\u003Cstring\u003E InspectedAssets { get; } = [];\n\tinternal List\u003Cstring\u003E References { get; } = [];\n\tinternal List\u003Cstring\u003E InputDependencies { get; } = [];\n\tinternal List\u003Cstring\u003E AdditionalContentFiles { get; } = [];\n\tinternal List\u003Cstring\u003E UnresolvedReferences { get; } = [];\n\tinternal Dictionary\u003Cstring, string\u003E 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 ) =\u003E Evidence = evidence;\n}\n\ninternal sealed class ModelImportRequestGate\n{\n\tprivate StrongBox\u003Clong\u003E _active;\n\tinternal ModelImportRequestGate() : this( new StrongBox\u003Clong\u003E( 0 ) ) { }\n\tinternal ModelImportRequestGate( StrongBox\u003Clong\u003E active ) =\u003E _active = active ?? throw new ArgumentNullException( nameof( active ) );\n\tprivate StrongBox\u003Clong\u003E Active =\u003E _active ??= new StrongBox\u003Clong\u003E( 0 );\n\tinternal StrongBox\u003Clong\u003E State =\u003E Active;\n\tinternal bool IsActive =\u003E 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 ) =\u003E System.Threading.Volatile.Write( ref Active.Value, active ? 1 : 0 );\n\n\tinternal sealed class Lease : IDisposable\n\t{\n\t\tprivate StrongBox\u003Clong\u003E _state;\n\t\tinternal Lease( StrongBox\u003Clong\u003E state ) =\u003E _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\u003Cstring, object\u003E state, bool legacyInvocationFinished )\n\t{\n\t\tvar pendingValid = state.TryGetValue( \u0022ModelImportPending\u0022, out var pendingValue ) \u0026\u0026 pendingValue is IEnumerable\u003Cstring\u003E;\n\t\tvar ownedValid = state.TryGetValue( \u0022ModelImportOwnedDirectories\u0022, out var ownedValue ) \u0026\u0026 ownedValue is IEnumerable\u003Cstring\u003E;\n\t\tvar busyValid = state.TryGetValue( \u0022ModelImportBusy\u0022, out var busyValue ) \u0026\u0026 busyValue is bool;\n\t\tif ( !pendingValid || !ownedValid || !busyValid )\n\t\t\tthrow new ImportContractException( \u0022ImportBusy\u0022, \u0022Model import hotload state transfer was incomplete.\u0022 );\n\t\tvar version = state.TryGetValue( \u0022ModelImportStateVersion\u0022, out var versionValue ) \u0026\u0026 versionValue is int parsed ? parsed : 1;\n\t\tvar active = version \u003E= 4 ? (bool)busyValue : !legacyInvocationFinished \u0026\u0026 (bool)busyValue;\n\t\treturn new ModelImportHotloadSnapshot( version, active,\n\t\t\t((IEnumerable\u003Cstring\u003E)pendingValue).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray(),\n\t\t\t((IEnumerable\u003Cstring\u003E)ownedValue).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray() );\n\t}\n\n\tinternal static void Write( Dictionary\u003Cstring, object\u003E state, ModelImportRequestGate gate, IEnumerable\u003Cstring\u003E pending, IEnumerable\u003Cstring\u003E owned )\n\t{\n\t\tstate[\u0022ModelImportStateVersion\u0022] = 4;\n\t\tstate[\u0022ModelImportBusy\u0022] = gate.IsActive;\n\t\tstate[\u0022ModelImportGateState\u0022] = gate.State;\n\t\tstate[\u0022ModelImportPending\u0022] = pending.Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();\n\t\tstate[\u0022ModelImportOwnedDirectories\u0022] = 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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\t/// \u003Cparam name=\u0022model\u0022\u003EModel asset path resolvable by AssetSystem.FindByPath.\u003C/param\u003E\n\t[McpTool.ReadOnly( \u0022x_model_skeleton\u0022 )]\n\tpublic static ModelSkeletonResult GetModelSkeleton( string model )\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( model ) ) throw new Exception( \u0022Give a model asset path.\u0022 );\n\t\tvar asset = AssetSystem.FindByPath( model.Replace( \u0027\\\\\u0027, \u0027/\u0027 ) )\n\t\t\t?? throw new Exception( $\u0022Model asset \u0027{Safe( model, 512 )}\u0027 was not found.\u0022 );\n\t\tModel resource;\n\t\ttry { resource = asset.LoadResource\u003CModel\u003E(); }\n\t\tcatch ( Exception ex ) { throw new Exception( $\u0022Model asset could not be loaded: {Safe( ex.Message, 512 )}\u0022 ); }\n\t\tif ( resource is null || !resource.IsValid || resource.IsError )\n\t\t\tthrow new Exception( $\u0022Model asset \u0027{Safe( asset.Path, 512 )}\u0027 did not load as a valid non-error model resource.\u0022 );\n\n\t\tvar result = new ModelSkeletonResult { ModelAsset = asset.Path?.Replace( \u0027\\\\\u0027, \u0027/\u0027 ) };\n\t\tCapture( \u0022Bounds\u0022, () =\u003E\n\t\t{\n\t\t\tvar bounds = resource.RenderBounds;\n\t\t\tresult.Bounds = new ModelBoundsEvidence { Space = \u0022model\u0022, Mins = bounds.Mins, Maxs = bounds.Maxs };\n\t\t}, result );\n\t\tCapture( \u0022Bones\u0022, () =\u003E result.Bones = resource.Bones.AllBones.Select( x =\u003E 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( \u0022Attachments\u0022, () =\u003E result.Attachments = resource.Attachments.All.Select( x =\u003E new ModelAttachmentEvidence\n\t\t{\n\t\t\tName = x.Name, BoneIndex = x.Bone?.Index\n\t\t} ).ToList(), result );\n\t\tCapture( \u0022MaterialGroups\u0022, () =\u003E\n\t\t{\n\t\t\tvar groups = new List\u003CModelMaterialGroupEvidence\u003E();\n\t\t\tfor ( var i = 0; i \u003C resource.MaterialGroupCount; \u002B\u002Bi )\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 =\u003E x?.ResourcePath?.Replace( \u0027\\\\\u0027, \u0027/\u0027 ) ).Where( x =\u003E 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( \u0022AnimationSequences\u0022, () =\u003E\n\t\t{\n\t\t\tvar animations = new List\u003Cstring\u003E( resource.AnimationCount );\n\t\t\tfor ( var i = 0; i \u003C resource.AnimationCount; \u002B\u002Bi ) animations.Add( resource.GetAnimationName( i ) );\n\t\t\tresult.AnimationSequences = animations;\n\t\t}, result );\n\t\tresult.HasSkinningData = null;\n\t\tresult.UnavailableFields[\u0022HasSkinningData\u0022] = \u0022Installed public model APIs do not expose skinning-data presence.\u0022;\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\u003CModelBoneEvidence\u003E Bones { get; set; }\n\tpublic List\u003CModelAttachmentEvidence\u003E Attachments { get; set; }\n\tpublic List\u003CModelMaterialGroupEvidence\u003E MaterialGroups { get; set; }\n\tpublic List\u003Cstring\u003E AnimationSequences { get; set; }\n\tpublic bool? HasSkinningData { get; set; }\n\tpublic Dictionary\u003Cstring, string\u003E 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\u003Cstring\u003E 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( @\u0022STEAM_[0-5]:[01]:\\d\u002B\u0022, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds( 100 ) );\n\tprivate static readonly Regex Steam3Pattern = new( @\u0022\\[[A-Za-z]:\\d\u002B:\\d\u002B\\]\u0022, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds( 100 ) );\n\tprivate static readonly Regex Steam64Pattern = new( @\u0022(?\u003C!\\d)\\d{17}(?!\\d)\u0022, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds( 100 ) );\n\n\t/// \u003Csummary\u003EInspect the active network session without exposing account, party, chat, voice, or credential data.\u003C/summary\u003E\n\t[McpTool.ReadOnly( \u0022x_network_status\u0022 )]\n\tpublic static NetworkState GetNetworkStatus()\n\t{\n\t\treturn SnapshotNetwork();\n\t}\n\n\t/// \u003Csummary\u003EStart hosting through the editor\u0027s supported network path and return the observed state.\u003C/summary\u003E\n\t[McpTool( \u0022x_network_start_hosting\u0022 )]\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( \u0022Already connected or connecting; disconnect first.\u0022 );\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( \u0022Starting hosting failed; the editor reported an error and no other change was made.\u0022 );\n\t\t}\n\n\t\tlifetime.ThrowIfInvalid();\n\t\treturn SnapshotNetwork();\n\t}\n\n\t/// \u003Csummary\u003EDisconnect the editor from its current network session without terminating owned child instances.\u003C/summary\u003E\n\t[McpTool( \u0022x_network_disconnect\u0022 )]\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 \u0026\u0026 !Networking.IsConnecting )\n\t\t\tthrow new Exception( \u0022No network session is active.\u0022 );\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( \u0022Disconnecting failed; the editor reported an error and owned instances were left untouched.\u0022 );\n\t\t}\n\n\t\tlifetime.ThrowIfInvalid();\n\t\treturn SnapshotNetwork();\n\t}\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\t[McpTool( \u0022x_network_spawn_instance\u0022 )]\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( \u0022Local instance launch setup failed; no process was started.\u0022 );\n\t\t}\n\n\t\tlifetime.ThrowIfInvalid();\n\t\treturn MapInstance( snapshot );\n\t}\n\n\t/// \u003Csummary\u003EList only this hotload lifetime\u0027s in-memory owned process jobs; never enumerate OS processes.\u003C/summary\u003E\n\t[McpTool.ReadOnly( \u0022x_network_instances\u0022 )]\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( \u0022Owned instance inspection failed.\u0022 );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\t[McpTool( \u0022x_network_migrate_to_new_instance\u0022 )]\n\tpublic static Task\u003CNetworkState\u003E 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 \u003C 1 || timeoutSeconds \u003E 120 )\n\t\t\tthrow new Exception( \u0022timeoutSeconds must be between 1 and 120.\u0022 );\n\t\tif ( !IsStableHosting() || GetRemoteConnections().Length != 0 )\n\t\t\tthrow new Exception( \u0022Migration requires a hosting session with no remote peers.\u0022 );\n\n\t\tthrow new Exception( \u0022The installed engine cannot migrate hosting to a synthetic local client; no process was launched and the editor remains connected.\u0022 );\n\t}\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\t[McpTool( \u0022x_network_terminate_instance\u0022 )]\n\tpublic static async Task\u003CInstanceTermination\u003E 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 \u003E 0 ? $\u0022; native error {ex.NativeError}\u0022 : string.Empty;\n\t\t\tthrow new Exception( $\u0022{ex.Message} PID {processId}{native}. Ownership is retained; the instance stays listed.\u0022 );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EA privacy-limited snapshot of the active editor network session.\u003C/summary\u003E\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/// \u003Csummary\u003EA visible connection with account and party identifiers deliberately omitted.\u003C/summary\u003E\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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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/// \u003Csummary\u003EThe bounded result of an ownership-checked termination request.\u003C/summary\u003E\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( \u0022Editor must be hosting first.\u0022 );\n\t}\n\n\tprivate static bool IsStableHosting()\n\t{\n\t\treturn EditorUtility.Network.Hosting \u0026\u0026 Networking.IsActive \u0026\u0026 !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\u003CConnectionState\u003E()\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 =\u003E connection is not null )\n\t\t\t.Select( connection =\u003E 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 \u0026\u0026 connection.Id == hostId.Value,\n\t\t\t\tIsLocal = localId.HasValue \u0026\u0026 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 =\u003E connection is not null \u0026\u0026 (!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 =\u003E connection is not null \u0026\u0026 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(), \u0022[redacted]\u0022 );\n\t\tresult = Steam3Pattern.Replace( result, \u0022[redacted]\u0022 );\n\t\tresult = Steam64Pattern.Replace( result, \u0022[redacted]\u0022 );\n\t\treturn result.Length \u003C= 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 \u003E 0 ? $\u0022 PID {exception.ProcessId}.\u0022 : string.Empty;\n\t\tvar native = exception.NativeError \u003E 0 ? $\u0022 Native error {exception.NativeError}.\u0022 : string.Empty;\n\t\treturn new Exception( exception.Message \u002B process \u002B native );\n\t}\n\n}\n\ninternal sealed class NetworkToolLifetime : IHotloadManaged\n{\n\tinternal const string BusyMessage = \u0022Another network operation is in progress; wait for it to finish.\u0022;\n\tinternal const string InvalidatedMessage = \u0022The network tools were invalidated by a library hotload; retry the operation.\u0022;\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 =\u003E _cancellation.Token;\n\tinternal bool IsBusy =\u003E Volatile.Read( ref _busy ) != 0;\n\tinternal bool IsValid =\u003E 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\u003Cstring, object\u003E state ) =\u003E Invalidate();\n\n\t// Hotload state is deliberately not transferred: surviving children cease to be owned.\n\n\tpublic void Created( IReadOnlyDictionary\u003Cstring, object\u003E 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() =\u003E 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 ) =\u003E _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/// \u003Csummary\u003E\n\t/// Import a new external FBX, OBJ or DMX source into an isolated directory under the active\n\t/// project\u0027s 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/// \u003C/summary\u003E\n\t/// \u003Cparam name=\u0022sourcePath\u0022\u003EAbsolute readable path to one .fbx, .obj or .dmx source.\u003C/param\u003E\n\t/// \u003Cparam name=\u0022targetDirectory\u0022\u003ENew final directory relative to Assets; no implicit subdirectory is appended.\u003C/param\u003E\n\t/// \u003Cparam name=\u0022modelName\u0022\u003EGenerated .vmdl stem only. Null or empty defaults to the source stem.\u003C/param\u003E\n\t/// \u003Cparam name=\u0022overwrite\u0022\u003EReserved. True always returns OverwriteUnsupported before mutation.\u003C/param\u003E\n\t/// \u003Cparam name=\u0022copySiblingTextures\u0022\u003ECopy every allowlisted immediate sibling image; false does not enumerate siblings.\u003C/param\u003E\n\t[McpTool( \u0022x_import_model_source\u0022 )]\n\tpublic static async Task\u003CImportModelResult\u003E ImportModelSource( string sourcePath, string targetDirectory,\n\t\tstring modelName = \u0022\u0022, 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( \u0022OverwriteUnsupported\u0022, \u0022overwrite=true is unsupported in version 1.\u0022 );\n\t\t\tvar project = Project.Current;\n\t\t\tif ( project is null ) return result.Fail( \u0022NoActiveProject\u0022, \u0022No active project is available.\u0022 );\n\t\t\tif ( Game.IsPlaying ) return result.Fail( \u0022PlayMode\u0022, \u0022Model import is unavailable while play mode is running.\u0022 );\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( \u0022DestinationExists\u0022, \u0022The destination is nested beneath an earlier import-owned directory.\u0022 );\n\t\t\tEnsureOutputsAbsent( transaction );\n\t\t\tEnsureProjectState( project, projectIdent );\n\n\t\t\tresult.Stage = \u0022copy\u0022;\n\t\t\ttransaction.ReserveDirectory();\n\t\t\tresult.CopiedFiles.AddRange( transaction.CopyInputs() );\n\t\t\ttransaction.WriteMarker( \u0022not_started\u0022 );\n\t\t\tresult.GeneratedFiles.Add( transaction.MarkerAsset );\n\n\t\t\tresult.Stage = \u0022registration\u0022;\n\t\t\tEnsureProjectState( project, projectIdent );\n\t\t\tresult.WriterState = \u0022unconfirmed\u0022;\n\t\t\ttransaction.WriteMarker( \u0022unconfirmed\u0022 );\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 =\u003E !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 =\u003E 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 = \u0022model_creation\u0022;\n\t\t\tEnsureProjectState( project, projectIdent );\n\t\t\tvar modelAbsolute = Path.Combine( transaction.AssetsRoot, transaction.ModelAsset.Replace( \u0027/\u0027, 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 = \u0022compilation\u0022;\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 = \u0022dependency_inspection\u0022;\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 = \u0022complete\u0022;\n\t\t\tresult.Error = null;\n\t\t\tresult.RollbackStatus = \u0022not_needed\u0022;\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 \u003E 0 )\n\t\t\t\t\t{\n\t\t\t\t\t\tresult.RollbackStatus = \u0022incomplete\u0022;\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 = \u0022complete\u0022;\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 = \u0022incomplete\u0022;\n\t\t\t\tAddWarning( result, $\u0022Final import accounting was incomplete: {Safe( ex.Message, 180 )}\u0022 );\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 = \u0022unconfirmed\u0022;\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 = \u0022incomplete\u0022;\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( \u0022DestinationExists\u0022, $\u0022Planned output \u0027{copy.DestinationAsset}\u0027 already exists.\u0022 );\n\t\tif ( AssetSystem.FindByPath( transaction.ModelAsset ) is not null )\n\t\t\tthrow new ImportContractException( \u0022DestinationExists\u0022, $\u0022Planned output \u0027{transaction.ModelAsset}\u0027 is already registered.\u0022 );\n\t\tvar prefix = transaction.RelativeDirectory.TrimEnd( \u0027/\u0027 ) \u002B \u0022/\u0022;\n\t\tif ( AssetSystem.All.Any( asset =\u003E asset?.Path is string path \u0026\u0026\n\t\t\tpath.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) ) )\n\t\t\tthrow new ImportContractException( \u0022DestinationExists\u0022, \u0022The destination contains registered asset evidence.\u0022 );\n\t\tvar ancestor = transaction.RelativeDirectory;\n\t\twhile ( ancestor.Contains( \u0027/\u0027 ) )\n\t\t{\n\t\t\tancestor = ancestor[..ancestor.LastIndexOf( \u0027/\u0027 )];\n\t\t\tvar ancestorPrefix = ancestor \u002B \u0022/\u0022;\n\t\t\tvar direct = AssetSystem.All\n\t\t\t\t.Select( asset =\u003E asset?.Path?.Replace( \u0027\\\\\u0027, \u0027/\u0027 ) )\n\t\t\t\t.Where( path =\u003E path is not null \u0026\u0026 path.StartsWith( ancestorPrefix, StringComparison.OrdinalIgnoreCase ) \u0026\u0026\n\t\t\t\t\t!path[ancestorPrefix.Length..].Contains( \u0027/\u0027 ) )\n\t\t\t\t.ToArray();\n\t\t\tvar modelCount = direct.Count( path =\u003E Path.GetExtension( path ).Equals( \u0022.vmdl\u0022, StringComparison.OrdinalIgnoreCase ) );\n\t\t\tvar sourceCount = direct.Count( path =\u003E new[] { \u0022.fbx\u0022, \u0022.obj\u0022, \u0022.dmx\u0022 }.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 \u0026\u0026 sourceCount == 1 )\n\t\t\t\tthrow new ImportContractException( \u0022DestinationExists\u0022, \u0022A destination ancestor contains registered source/model import evidence.\u0022 );\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( \u0022NoActiveProject\u0022, \u0022The active project changed during import.\u0022 );\n\t\tif ( Game.IsPlaying ) throw new ImportContractException( \u0022PlayMode\u0022, \u0022Play mode started during import.\u0022 );\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, \u0022*\u0022, SearchOption.TopDirectoryOnly ) )\n\t\t\t{\n\t\t\t\tif ( (File.GetAttributes( file ) \u0026 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, $\u0022Generated output attribution was incomplete: {Safe( ex.Message, 180 )}\u0022 ); }\n\t}\n\n\tprivate static void AddWarning( ImportModelResult result, string warning )\n\t{\n\t\tif ( result.Warnings.Count \u003C 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\u003Cstring, string\u003E( 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\u003Cstring, string\u003E( 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( \u0022ImportBusy\u0022, \u0022Another model import request is currently executing.\u0022 );\n\t}\n\n\tprivate static string CodeForStage( string stage ) =\u003E stage switch\n\t{\n\t\t\u0022copy\u0022 =\u003E \u0022CopyFailed\u0022, \u0022registration\u0022 =\u003E \u0022RegistrationFailed\u0022, \u0022model_creation\u0022 =\u003E \u0022ModelCreationFailed\u0022,\n\t\t\u0022compilation\u0022 =\u003E \u0022CompilationUnconfirmed\u0022, \u0022dependency_inspection\u0022 =\u003E \u0022DependencyInspectionFailed\u0022, _ =\u003E \u0022InvalidInput\u0022\n\t};\n\tinternal static string Safe( string value, int maximum )\n\t{\n\t\tvar text = string.IsNullOrWhiteSpace( value ) ? \u0022The operation failed without a message.\u0022 : value.Replace( \u0027\\r\u0027, \u0027 \u0027 ).Replace( \u0027\\n\u0027, \u0027 \u0027 );\n\t\treturn text.Length \u003C= 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; } = \u0022validation\u0022;\n\tpublic string SourceAsset { get; set; }\n\tpublic string ModelAsset { get; set; }\n\tpublic List\u003Cstring\u003E CopiedFiles { get; set; } = [];\n\tpublic List\u003Cstring\u003E 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; } = \u0022not_started\u0022;\n\tpublic bool FurtherImportsBlocked { get; set; }\n\tpublic List\u003Cstring\u003E PendingWriterPaths { get; set; } = [];\n\tpublic List\u003Cstring\u003E UnresolvedReferences { get; set; } = [];\n\tpublic List\u003Cstring\u003E Warnings { get; set; } = [];\n\tpublic ImportModelError Error { get; set; }\n\tpublic string RollbackStatus { get; set; } = \u0022not_needed\u0022;\n\tpublic List\u003Cstring\u003E 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; } = \u0022not_started\u0022;\n\tpublic bool? IsCompiled { get; set; }\n\tpublic bool? IsCompiledAndUpToDate { get; set; }\n\tpublic bool? IsCompileFailed { get; set; }\n\tpublic Dictionary\u003Cstring, string\u003E UnavailableEvidence { get; set; } = new( StringComparer.Ordinal );\n}\n\npublic sealed class DependencyInspectionEvidence\n{\n\tpublic string Status { get; set; } = \u0022not_started\u0022;\n\tpublic List\u003Cstring\u003E InspectedAssets { get; set; } = [];\n\tpublic List\u003Cstring\u003E References { get; set; } = [];\n\tpublic List\u003Cstring\u003E InputDependencies { get; set; } = [];\n\tpublic List\u003Cstring\u003E AdditionalContentFiles { get; set; } = [];\n\tpublic Dictionary\u003Cstring, string\u003E 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 ) =\u003E AssetSystem.RegisterFile( absolutePath );\n\tpublic object FindAsset( string assetPath ) =\u003E AssetSystem.FindByPath( assetPath );\n\tpublic string GetAssetPath( object asset ) =\u003E (asset as Asset)?.Path;\n\tpublic object CreateModel( object sourceAsset, string absoluteModelPath ) =\u003E\n\t\tEditorUtility.CreateModelFromMeshFile( (Asset)sourceAsset, absoluteModelPath );\n\tpublic bool ReadIsCompiled( object asset ) =\u003E ((Asset)asset).IsCompiled;\n\tpublic bool ReadIsCompiledAndUpToDate( object asset ) =\u003E ((Asset)asset).IsCompiledAndUpToDate;\n\tpublic bool ReadIsCompileFailed( object asset ) =\u003E ((Asset)asset).IsCompileFailed;\n\tpublic async ValueTask ObserveCompilationIfNeededAsync( object asset ) =\u003E await ((Asset)asset).CompileIfNeededAsync( 30.0f );\n\tpublic IReadOnlyList\u003Cobject\u003E GetReferences( object asset ) =\u003E ((Asset)asset).GetReferences( false ).Cast\u003Cobject\u003E().ToArray();\n\tpublic IReadOnlyList\u003Cstring\u003E GetUnrecognizedReferences( object asset ) =\u003E ((Asset)asset).GetUnrecognizedReferencePaths();\n\tpublic IReadOnlyList\u003Cstring\u003E GetInputDependencies( object asset ) =\u003E ((Asset)asset).GetInputDependencies();\n\tpublic IReadOnlyList\u003Cstring\u003E GetAdditionalContentFiles( object asset ) =\u003E ((Asset)asset).GetAdditionalContentFiles();\n}\n\ninternal sealed class ModelImportLifetime : IHotloadManaged\n{\n\tprivate ModelImportRequestGate _gate = new();\n\tprivate int _valid = 1;\n\tprivate List\u003Cstring\u003E _pending = [];\n\tprivate List\u003Cstring\u003E _ownedDirectories = [];\n\tinternal IReadOnlyList\u003Cstring\u003E PendingPaths =\u003E _pending;\n\tinternal string BusyReason =\u003E \u0022Another model import request is currently executing.\u0022;\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\u003Cstring\u003E paths )\n\t{\n\t\t_pending = _pending.Concat( paths ).Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy( x =\u003E x, StringComparer.OrdinalIgnoreCase ).ToList();\n\t}\n\tinternal void ScanMarkers( string assetsRoot )\n\t{\n\t\tvar foundPending = new List\u003Cstring\u003E();\n\t\tvar foundOwned = new List\u003Cstring\u003E();\n\t\ttry\n\t\t{\n\t\t\tvar stack = new Stack\u003Cstring\u003E(); stack.Push( assetsRoot );\n\t\t\twhile ( stack.Count \u003E 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 ) \u0026 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( \u0027\\\\\u0027, \u0027/\u0027 );\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( \u0022Version\u0022, out var version ) || version.GetInt32() != 1 ||\n\t\t\t\t\t\t!root.TryGetProperty( \u0022TransactionId\u0022, out var transactionId ) || !Guid.TryParse( transactionId.GetString(), out _ ) ||\n\t\t\t\t\t\t!root.TryGetProperty( \u0022SourceAsset\u0022, out var sourceAsset ) || string.IsNullOrWhiteSpace( sourceAsset.GetString() ) ||\n\t\t\t\t\t\t!root.TryGetProperty( \u0022ModelAsset\u0022, out var modelAsset ) || string.IsNullOrWhiteSpace( modelAsset.GetString() ) ||\n\t\t\t\t\t\t!root.TryGetProperty( \u0022WriterState\u0022, out var writerState ) ||\n\t\t\t\t\t\t!string.Equals( writerState.GetString(), \u0022stopped\u0022, 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( \u0022ImportBusy\u0022, $\u0022Import ownership markers could not be scanned safely: {ExtrasTools.Safe( ex.Message, 700 )}\u0022 );\n\t\t}\n\t}\n\n\tinternal bool IsOwnedDestination( string relativeDirectory ) =\u003E _ownedDirectories.Any( owned =\u003E\n\t\trelativeDirectory.Equals( owned, StringComparison.OrdinalIgnoreCase ) ||\n\t\trelativeDirectory.StartsWith( owned.TrimEnd( \u0027/\u0027 ) \u002B \u0022/\u0022, StringComparison.OrdinalIgnoreCase ) ||\n\t\towned.StartsWith( relativeDirectory.TrimEnd( \u0027/\u0027 ) \u002B \u0022/\u0022, StringComparison.OrdinalIgnoreCase ) );\n\tpublic void Destroyed( Dictionary\u003Cstring, object\u003E 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\u003Cstring, object\u003E 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 ) =\u003E _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\u003C(string Name, Action Run)\u003E\n{\n\t(\u0022selects exact immediate allowlist\u0022, SelectsAllowlist),\n\t(\u0022false skips sibling enumeration\u0022, SkipsSiblings),\n\t(\u0022refuses 129 sibling images\u0022, RefusesImageOverflow),\n\t(\u0022accepts exact byte boundary\u0022, AcceptsExactByteBoundary),\n\t(\u0022refuses byte overflow\u0022, RefusesByteOverflow),\n\t(\u0022rejects unsafe targets and names\u0022, RejectsUnsafeInputs),\n\t(\u0022exclusive reservation preserves sentinel\u0022, PreservesSentinel),\n\t(\u0022copy race preserves foreign file\u0022, PreservesRacedCopy),\n\t(\u0022marker race preserves foreign marker\u0022, PreservesRacedMarker),\n\t(\u0022pre-engine cleanup retains owned directories safely\u0022, RetainsOwnedDirectories),\n\t(\u0022request gate serializes and releases\u0022, RequestGateSerializesAndReleases),\n\t(\u0022post-engine completion releases request gate\u0022, PostEngineCompletionReleasesGate),\n\t(\u0022registration failure is staged\u0022, RegistrationFailureIsStaged),\n\t(\u0022compilation fault preserves evidence\u0022, CompilationFaultPreservesEvidence),\n\t(\u0022dependency fault preserves earlier evidence\u0022, DependencyFaultPreservesEvidence),\n\t(\u0022legacy retained state migrates without active lock\u0022, LegacyStateMigrates),\n\t(\u0022versioned active request survives hotload\u0022, ActiveRequestSurvivesHotload),\n\t(\u0022malformed hotload state fails closed\u0022, MalformedHotloadFails),\n\t(\u0022post-engine transaction retains outputs\u0022, PostEngineTransactionRetainsOutputs),\n\t(\u0022active lease releases shared hotload gate\u0022, ActiveLeaseReleasesSharedGate),\n\t(\u0022thrown dependency path preserves evidence\u0022, ThrownDependencyPathPreservesEvidence),\n\t(\u0022missing dependency path fails inspection\u0022, MissingDependencyPathFailsInspection)\n};\nvar failures = new List\u003Cstring\u003E();\nforeach ( var check in checks )\n{\n\ttry { check.Run(); Console.WriteLine( $\u0022PASS {check.Name}\u0022 ); }\n\tcatch ( Exception ex ) { failures.Add( $\u0022FAIL {check.Name}: {ex.Message}\u0022 ); }\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, \u0022mesh.OBJ\u0022 ), \u0022v 0 0 0\\nv 1 0 0\\nv 0 1 0\\nf 1 2 3\u0022 );\n\tFile.WriteAllText( Path.Combine( root.Source, \u0022shot.PNG\u0022 ), \u0022x\u0022 );\n\tFile.WriteAllText( Path.Combine( root.Source, \u0022mesh.mtl\u0022 ), \u0022ignored\u0022 );\n\tDirectory.CreateDirectory( Path.Combine( root.Source, \u0022nested\u0022 ) );\n\tFile.WriteAllText( Path.Combine( root.Source, \u0022nested\u0022, \u0022nested.png\u0022 ), \u0022ignored\u0022 );\n\tvar plan = ModelImportTransaction.Plan( root.Assets, Path.Combine( root.Source, \u0022mesh.OBJ\u0022 ), \u0022models/test\u0022, \u0022\u0022, true );\n\tEqual( new[] { \u0022models/test/mesh.OBJ\u0022, \u0022models/test/shot.PNG\u0022 }, plan.Copies.Select( x =\u003E x.DestinationAsset ).ToArray() );\n}\n\nstatic void SkipsSiblings()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \u0022mesh.fbx\u0022 ); File.WriteAllText( source, \u0022mesh\u0022 );\n\tFile.WriteAllText( Path.Combine( root.Source, \u0022image.png\u0022 ), \u0022x\u0022 );\n\tvar plan = ModelImportTransaction.Plan( root.Assets, source, \u0022models/solo\u0022, null, false );\n\tEqual( new[] { \u0022models/solo/mesh.fbx\u0022 }, plan.Copies.Select( x =\u003E x.DestinationAsset ).ToArray() );\n}\n\nstatic void RefusesImageOverflow()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \u0022mesh.dmx\u0022 ); File.WriteAllText( source, \u0022mesh\u0022 );\n\tfor ( var i = 0; i \u003C 129; \u002B\u002Bi ) File.WriteAllText( Path.Combine( root.Source, $\u0022{i:D3}.png\u0022 ), \u0022\u0022 );\n\tThrows( \u0022LimitExceeded\u0022, () =\u003E ModelImportTransaction.Plan( root.Assets, source, \u0022models/count\u0022, \u0022\u0022, true ) );\n}\n\nstatic void AcceptsExactByteBoundary()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \u0022mesh.fbx\u0022 );\n\tusing ( var stream = File.Create( source ) ) stream.SetLength( ModelImportTransaction.MaximumBytes );\n\tvar plan = ModelImportTransaction.Plan( root.Assets, source, \u0022models/exact\u0022, \u0022\u0022, false );\n\tAssert( plan.Copies.Single().PreflightLength == ModelImportTransaction.MaximumBytes, \u0022exact byte limit was not accepted\u0022 );\n}\n\nstatic void RefusesByteOverflow()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \u0022mesh.fbx\u0022 );\n\tusing ( var stream = File.Create( source ) ) stream.SetLength( ModelImportTransaction.MaximumBytes \u002B 1 );\n\tThrows( \u0022LimitExceeded\u0022, () =\u003E ModelImportTransaction.Plan( root.Assets, source, \u0022models/over\u0022, \u0022\u0022, false ) );\n}\n\nstatic void RejectsUnsafeInputs()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \u0022mesh.fbx\u0022 ); File.WriteAllText( source, \u0022mesh\u0022 );\n\tforeach ( var target in new[] { \u0022../escape\u0022, \u0022Assets/models/x\u0022, \u0022models//x\u0022, \u0022C:\\\\outside\u0022, \u0022/outside\u0022 } )\n\t\tThrows( \u0022InvalidInput\u0022, () =\u003E ModelImportTransaction.Plan( root.Assets, source, target, \u0022\u0022, false ) );\n\tforeach ( var name in new[] { \u0022CON\u0022, \u0022bad.vmdl\u0022, \u0022 padded\u0022, \u0022trailing.\u0022 } )\n\t\tThrows( \u0022InvalidInput\u0022, () =\u003E ModelImportTransaction.Plan( root.Assets, source, \u0022models/name\u0022, name, false ) );\n}\n\nstatic void PreservesSentinel()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \u0022mesh.obj\u0022 ); File.WriteAllText( source, \u0022mesh\u0022 );\n\tvar occupied = Path.Combine( root.Assets, \u0022models\u0022, \u0022occupied\u0022 ); Directory.CreateDirectory( occupied );\n\tvar sentinel = Path.Combine( occupied, \u0022sentinel.txt\u0022 ); File.WriteAllText( sentinel, \u0022keep\u0022 );\n\tThrows( \u0022DestinationExists\u0022, () =\u003E ModelImportTransaction.Plan( root.Assets, source, \u0022models/occupied\u0022, \u0022\u0022, false ) );\n\tAssert( File.ReadAllText( sentinel ) == \u0022keep\u0022, \u0022sentinel changed\u0022 );\n}\n\nstatic void PreservesRacedCopy()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \u0022mesh.obj\u0022 ); File.WriteAllText( source, \u0022source\u0022 );\n\tvar plan = ModelImportTransaction.Plan( root.Assets, source, \u0022models/raced-copy\u0022, \u0022\u0022, false );\n\tplan.ReserveDirectory();\n\tvar destination = plan.Copies.Single().DestinationAbsolute;\n\tFile.WriteAllText( destination, \u0022foreign\u0022 );\n\tThrows( \u0022CopyFailed\u0022, () =\u003E plan.CopyInputs() );\n\tvar remaining = plan.CleanupPreEngine();\n\tAssert( File.ReadAllText( destination ) == \u0022foreign\u0022, \u0022foreign raced file was deleted\u0022 );\n\tAssert( remaining.Contains( \u0022models/raced-copy\u0022, StringComparer.OrdinalIgnoreCase ), \u0022occupied owned directory was not reported\u0022 );\n}\n\nstatic void PreservesRacedMarker()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \u0022mesh.obj\u0022 ); File.WriteAllText( source, \u0022source\u0022 );\n\tvar plan = ModelImportTransaction.Plan( root.Assets, source, \u0022models/raced-marker\u0022, \u0022\u0022, false );\n\tplan.ReserveDirectory(); plan.CopyInputs();\n\tvar marker = Path.Combine( plan.FinalDirectory, ModelImportTransaction.MarkerName );\n\tFile.WriteAllText( marker, \u0022foreign\u0022 );\n\ttry { plan.WriteMarker( \u0022not_started\u0022 ); } catch ( IOException ) { }\n\tvar remaining = plan.CleanupPreEngine();\n\tAssert( File.ReadAllText( marker ) == \u0022foreign\u0022, \u0022foreign marker was overwritten or deleted\u0022 );\n\tAssert( remaining.Contains( \u0022models/raced-marker\u0022, StringComparer.OrdinalIgnoreCase ), \u0022occupied marker directory was not reported\u0022 );\n}\n\nstatic void RetainsOwnedDirectories()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \u0022mesh.obj\u0022 ); File.WriteAllText( source, \u0022mesh\u0022 );\n\tvar plan = ModelImportTransaction.Plan( root.Assets, source, \u0022models/cleanup\u0022, \u0022\u0022, false );\n\tplan.ReserveDirectory(); plan.CopyInputs(); plan.WriteMarker( \u0022not_started\u0022 );\n\tvar remaining = plan.CleanupPreEngine();\n\tAssert( remaining.Contains( \u0022models/cleanup\u0022, StringComparer.OrdinalIgnoreCase ), \u0022retained final directory was not reported\u0022 );\n\tAssert( !File.Exists( Path.Combine( plan.FinalDirectory, \u0022mesh.obj\u0022 ) ), \u0022owned copied file remains\u0022 );\n\tAssert( !File.Exists( Path.Combine( plan.FinalDirectory, ModelImportTransaction.MarkerName ) ), \u0022owned marker remains\u0022 );\n}\n\nstatic void RequestGateSerializesAndReleases()\n{\n\tvar gate = new ModelImportRequestGate();\n\tusing var first = gate.TryEnter();\n\tAssert( first is not null \u0026\u0026 gate.IsActive, \u0022first request did not acquire the gate\u0022 );\n\tAssert( gate.TryEnter() is null, \u0022concurrent request was admitted\u0022 );\n\tfirst.Dispose();\n\tusing var second = gate.TryEnter();\n\tAssert( second is not null, \u0022gate did not release after invocation completion\u0022 );\n}\n\nstatic void PostEngineCompletionReleasesGate()\n{\n\tvar gate = new ModelImportRequestGate();\n\tvar lease = gate.TryEnter();\n\tvar writerState = \u0022unconfirmed\u0022;\n\tvar pending = new[] { \u0022models/a\u0022, \u0022models/a/a.vmdl\u0022 };\n\tlease.Dispose();\n\tAssert( !gate.IsActive, \u0022unconfirmed writer evidence retained the request gate\u0022 );\n\tAssert( writerState == \u0022unconfirmed\u0022 \u0026\u0026 pending.Length == 2, \u0022retained evidence changed when the request released\u0022 );\n}\n\nstatic void RegistrationFailureIsStaged()\n{\n\tvar backend = new FakeBackend { ThrowRegister = true };\n\tvar pipeline = new ModelImportBackendPipeline( backend );\n\tThrows( \u0022RegistrationFailed\u0022, () =\u003E pipeline.RegisterSource( \u0022source.fbx\u0022, \u0022models/a/source.fbx\u0022 ) );\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 == \u0022CompilationUnconfirmed\u0022, \u0022wrong compilation fault code\u0022 );\n\t\tAssert( evidence.Status == \u0022observed\u0022 \u0026\u0026 evidence.IsCompiled == false \u0026\u0026 evidence.IsCompileFailed == false, \u0022compile evidence was discarded\u0022 );\n\t\treturn;\n\t}\n\tthrow new Exception( \u0022expected compilation fault\u0022 );\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 == \u0022DependencyInspectionFailed\u0022, \u0022wrong dependency fault code\u0022 );\n\t\tAssert( evidence.References.Contains( \u0022materials/test.vmat\u0022 ), \u0022successful reference evidence was discarded\u0022 );\n\t\tAssert( evidence.UnavailableEvidence.Keys.Any( x =\u003E x.StartsWith( \u0022InputDependencies:\u0022, StringComparison.Ordinal ) ), \u0022failed query was not identified\u0022 );\n\t\treturn;\n\t}\n\tthrow new Exception( \u0022expected dependency inspection fault\u0022 );\n}\n\nstatic void LegacyStateMigrates()\n{\n\tvar state = new Dictionary\u003Cstring, object\u003E\n\t{\n\t\t[\u0022ModelImportBusy\u0022] = true,\n\t\t[\u0022ModelImportPending\u0022] = new[] { \u0022models/kampai/giant-lid\u0022 },\n\t\t[\u0022ModelImportOwnedDirectories\u0022] = new[] { \u0022models/kampai/giant-lid\u0022 }\n\t};\n\tvar snapshot = ModelImportHotloadTransfer.Read( state, legacyInvocationFinished: true );\n\tAssert( !snapshot.ActiveRequest, \u0022completed legacy invocation retained the revised request lock\u0022 );\n\tAssert( snapshot.PendingPaths.Single() == \u0022models/kampai/giant-lid\u0022, \u0022legacy pending evidence was lost\u0022 );\n}\n\nstatic void ActiveRequestSurvivesHotload()\n{\n\tvar gate = new ModelImportRequestGate();\n\tusing var lease = gate.TryEnter();\n\tvar state = new Dictionary\u003Cstring, object\u003E();\n\tModelImportHotloadTransfer.Write( state, gate, new[] { \u0022models/a\u0022 }, new[] { \u0022models/a\u0022 } );\n\tvar snapshot = ModelImportHotloadTransfer.Read( state, legacyInvocationFinished: false );\n\tAssert( snapshot.Version == 4 \u0026\u0026 snapshot.ActiveRequest, \u0022versioned active request was unlocked\u0022 );\n}\n\nstatic void MalformedHotloadFails()\n{\n\ttry { ModelImportHotloadTransfer.Read( new Dictionary\u003Cstring, object\u003E(), legacyInvocationFinished: false ); }\n\tcatch ( ImportContractException ex ) when ( ex.Code == \u0022ImportBusy\u0022 ) { return; }\n\tthrow new Exception( \u0022malformed hotload state did not fail closed\u0022 );\n}\n\nstatic void PostEngineTransactionRetainsOutputs()\n{\n\tusing var root = Fixture();\n\tvar source = Path.Combine( root.Source, \u0022mesh.obj\u0022 ); File.WriteAllText( source, \u0022mesh\u0022 );\n\tvar plan = ModelImportTransaction.Plan( root.Assets, source, \u0022models/retained\u0022, \u0022\u0022, false );\n\tplan.ReserveDirectory(); plan.CopyInputs(); plan.WriteMarker( \u0022unconfirmed\u0022 );\n\tplan.PrepareForEngineMutation();\n\tplan.EngineMutationAttempted = true;\n\tplan.Dispose();\n\tAssert( File.Exists( Path.Combine( plan.FinalDirectory, \u0022mesh.obj\u0022 ) ), \u0022post-engine source was deleted\u0022 );\n\tAssert( File.Exists( Path.Combine( plan.FinalDirectory, ModelImportTransaction.MarkerName ) ), \u0022post-engine marker was deleted\u0022 );\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, \u0022replacement gate did not share active state\u0022 );\n\tlease.Dispose();\n\tAssert( !newGate.IsActive \u0026\u0026 newGate.TryEnter() is not null, \u0022old invocation completion did not release replacement gate\u0022 );\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 == \u0022DependencyInspectionFailed\u0022, \u0022thrown path used wrong error\u0022 );\n\t\tAssert( evidence.InspectedAssets.Contains( \u0022models/a/model.vmdl\u0022 ), \u0022evidence from another completed asset was discarded\u0022 );\n\t\tAssert( evidence.UnavailableEvidence.Keys.Any( x =\u003E x.StartsWith( \u0022ReferencePath:\u0022, StringComparison.Ordinal ) ), \u0022thrown reference path was not identified\u0022 );\n\t\treturn;\n\t}\n\tthrow new Exception( \u0022expected thrown path inspection failure\u0022 );\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 == \u0022DependencyInspectionFailed\u0022, \u0022missing path used wrong error\u0022 );\n\t\tAssert( evidence.UnavailableEvidence.Keys.Any( x =\u003E x.StartsWith( \u0022ReferencePath:\u0022, StringComparison.Ordinal ) ), \u0022missing reference path was not identified\u0022 );\n\t\treturn;\n\t}\n\tthrow new Exception( \u0022expected missing path inspection failure\u0022 );\n}\n\nstatic Root Fixture() =\u003E new();\nstatic void Assert( bool condition, string message ) { if ( !condition ) throw new Exception( message ); }\nstatic void Equal( string[] expected, string[] actual ) =\u003E Assert( expected.SequenceEqual( actual, StringComparer.OrdinalIgnoreCase ), $\u0022expected [{string.Join(\u0022,\u0022, expected)}], got [{string.Join(\u0022,\u0022, actual)}]\u0022 );\nstatic void Throws( string code, Action action )\n{\n\ttry { action(); }\n\tcatch ( ImportContractException ex ) when ( ex.Code == code ) { return; }\n\tthrow new Exception( $\u0022expected {code}\u0022 );\n}\n\nsealed class Root : IDisposable\n{\n\tpublic string PathRoot { get; } = Path.Combine( Path.GetTempPath(), \u0022sbox-model-import-contract\u0022, Guid.NewGuid().ToString( \u0022N\u0022 ) );\n\tpublic string Assets =\u003E Path.Combine( PathRoot, \u0022Assets\u0022 );\n\tpublic string Source =\u003E Path.Combine( PathRoot, \u0022Source\u0022 );\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\u003Cobject, List\u003Cobject\u003E\u003E 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( \u0022registration fault\u0022 );\n\t\treturn Source;\n\t}\n\tpublic object FindAsset( string assetPath ) =\u003E Model;\n\tpublic string GetAssetPath( object asset )\n\t{\n\t\tif ( ReferenceEquals( asset, Material ) \u0026\u0026 ThrowMaterialPath ) throw new IOException( \u0022asset path fault\u0022 );\n\t\tif ( ReferenceEquals( asset, Material ) \u0026\u0026 MissingMaterialPath ) return null;\n\t\treturn ReferenceEquals( asset, Source ) ? \u0022models/a/source.fbx\u0022 :\n\t\t\tReferenceEquals( asset, Material ) ? \u0022materials/test.vmat\u0022 : \u0022models/a/model.vmdl\u0022;\n\t}\n\tpublic object CreateModel( object sourceAsset, string absoluteModelPath ) =\u003E Model;\n\tpublic bool ReadIsCompiled( object asset ) =\u003E IsCompiled;\n\tpublic bool ReadIsCompiledAndUpToDate( object asset ) =\u003E IsUpToDate;\n\tpublic bool ReadIsCompileFailed( object asset ) =\u003E IsFailed;\n\tpublic ValueTask ObserveCompilationIfNeededAsync( object asset ) =\u003E ThrowCompileObservation\n\t\t? ValueTask.FromException( new IOException( \u0022compile observation fault\u0022 ) ) : ValueTask.CompletedTask;\n\tpublic IReadOnlyList\u003Cobject\u003E GetReferences( object asset ) =\u003E References.TryGetValue( asset, out var values ) ? values : [];\n\tpublic IReadOnlyList\u003Cstring\u003E GetUnrecognizedReferences( object asset ) =\u003E [];\n\tpublic IReadOnlyList\u003Cstring\u003E GetInputDependencies( object asset )\n\t{\n\t\tif ( ThrowInputDependencies ) throw new IOException( \u0022input dependency fault\u0022 );\n\t\treturn [];\n\t}\n\tpublic IReadOnlyList\u003Cstring\u003E GetAdditionalContentFiles( object asset ) =\u003E [];\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 = \u0022.sbox-mcp-import.json\u0022;\n\tprivate static readonly HashSet\u003Cstring\u003E ImageExtensions = new( StringComparer.OrdinalIgnoreCase )\n\t{\n\t\t\u0022.png\u0022, \u0022.tga\u0022, \u0022.jpg\u0022, \u0022.jpeg\u0022, \u0022.bmp\u0022, \u0022.tif\u0022, \u0022.tiff\u0022, \u0022.exr\u0022, \u0022.hdr\u0022\n\t};\n\tprivate static readonly HashSet\u003Cstring\u003E SourceExtensions = new( StringComparer.OrdinalIgnoreCase )\n\t{\n\t\t\u0022.fbx\u0022, \u0022.obj\u0022, \u0022.dmx\u0022\n\t};\n\tprivate static readonly HashSet\u003Cstring\u003E ReservedNames = new( StringComparer.OrdinalIgnoreCase )\n\t{\n\t\t\u0022CON\u0022, \u0022PRN\u0022, \u0022AUX\u0022, \u0022NUL\u0022, \u0022COM1\u0022, \u0022COM2\u0022, \u0022COM3\u0022, \u0022COM4\u0022, \u0022COM5\u0022, \u0022COM6\u0022, \u0022COM7\u0022, \u0022COM8\u0022, \u0022COM9\u0022,\n\t\t\u0022LPT1\u0022, \u0022LPT2\u0022, \u0022LPT3\u0022, \u0022LPT4\u0022, \u0022LPT5\u0022, \u0022LPT6\u0022, \u0022LPT7\u0022, \u0022LPT8\u0022, \u0022LPT9\u0022\n\t};\n\n\tinternal string TransactionId { get; } = Guid.NewGuid().ToString( \u0022D\u0022 );\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 =\u003E JoinAsset( RelativeDirectory, MarkerName );\n\tinternal IReadOnlyList\u003CCopyPlan\u003E Copies { get; }\n\tinternal List\u003Cstring\u003E OwnedFiles { get; } = [];\n\tinternal List\u003Cstring\u003E CompletedCopies { get; } = [];\n\tprivate readonly List\u003C(string Path, SafeFileHandle Handle)\u003E _directoryHandles = [];\n\tprivate readonly Dictionary\u003Cstring, FileStream\u003E _ownedStreams = new( StringComparer.OrdinalIgnoreCase );\n\tprivate FileStream _markerStream;\n\tinternal List\u003Cstring\u003E 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\u003CCopyPlan\u003E 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( \u0022ApiUnavailable\u0022, \u0022Exclusive destination reservation is only supported on Windows.\u0022 );\n\t\tif ( string.IsNullOrEmpty( assetsRoot ) )\n\t\t\tthrow new ImportContractException( \u0022NoActiveProject\u0022, \u0022The active project has no Assets directory.\u0022 );\n\t\tif ( string.IsNullOrWhiteSpace( sourcePath ) || !Path.IsPathFullyQualified( sourcePath ) )\n\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, \u0022sourcePath must be an absolute file path.\u0022 );\n\n\t\tstring canonicalSource;\n\t\ttry { canonicalSource = Path.GetFullPath( sourcePath ); }\n\t\tcatch ( Exception ) { throw new ImportContractException( \u0022InvalidInput\u0022, \u0022sourcePath is not a valid absolute path.\u0022 ); }\n\t\tvar sourceInfo = new FileInfo( canonicalSource );\n\t\tif ( !sourceInfo.Exists || (sourceInfo.Attributes \u0026 FileAttributes.Directory) != 0 )\n\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, \u0022sourcePath must identify a readable regular file.\u0022 );\n\t\tif ( (sourceInfo.Attributes \u0026 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( \u0022InvalidInput\u0022, \u0022sourcePath link does not resolve to a readable regular file.\u0022 );\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( \u0022InvalidInput\u0022, \u0022sourcePath must have an .fbx, .obj, or .dmx extension.\u0022 );\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( \u0022.vmdl\u0022, StringComparison.OrdinalIgnoreCase ) )\n\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, \u0022modelName must not include a .vmdl suffix.\u0022 );\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( \u0027/\u0027, 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( \u0022DestinationExists\u0022, \u0022The final import directory already exists.\u0022 );\n\n\t\tvar candidates = new List\u003CFileInfo\u003E { sourceInfo };\n\t\tif ( copySiblingTextures )\n\t\t{\n\t\t\tIEnumerable\u003CFileInfo\u003E siblings;\n\t\t\ttry { siblings = sourceInfo.Directory!.EnumerateFiles().Where( x =\u003E ImageExtensions.Contains( x.Extension ) ); }\n\t\t\tcatch ( Exception ) { throw new ImportContractException( \u0022InvalidInput\u0022, \u0022Sibling image files could not be enumerated.\u0022 ); }\n\t\t\tcandidates.AddRange( siblings.OrderBy( x =\u003E x.Name, StringComparer.OrdinalIgnoreCase ) );\n\t\t}\n\t\tif ( candidates.Count - 1 \u003E MaximumImages )\n\t\t\tthrow new ImportContractException( \u0022LimitExceeded\u0022, \u0022The import selects more than 128 sibling images.\u0022 );\n\n\t\tvar names = new HashSet\u003Cstring\u003E( StringComparer.OrdinalIgnoreCase );\n\t\tlong total = 0;\n\t\tvar copies = new List\u003CCopyPlan\u003E( 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 \u0026 FileAttributes.ReparsePoint) != 0 )\n\t\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, $\u0022Selected file \u0027{file.Name}\u0027 is a link.\u0022 );\n\t\t\tif ( !names.Add( file.Name ) )\n\t\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, \u0022Selected filenames collide case-insensitively.\u0022 );\n\t\t\ttry { total = checked(total \u002B file.Length); }\n\t\t\tcatch ( OverflowException ) { throw new ImportContractException( \u0022LimitExceeded\u0022, \u0022Selected files exceed the byte limit.\u0022 ); }\n\t\t\tif ( total \u003E MaximumBytes )\n\t\t\t\tthrow new ImportContractException( \u0022LimitExceeded\u0022, \u0022Selected files exceed 1,073,741,824 bytes.\u0022 );\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 \u002B \u0022.vmdl\u0022;\n\t\tValidateFileName( modelFile, true );\n\t\tif ( !names.Add( modelFile ) || !names.Add( MarkerName ) )\n\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, \u0022Planned output filenames collide case-insensitively.\u0022 );\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\u003Cstring\u003E();\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 \u003E 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( \u0022CopyFailed\u0022, $\u0022A destination ancestor could not be reserved (Windows error {error}).\u0022 );\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 ? \u0022DestinationExists\u0022 : \u0022CopyFailed\u0022,\n\t\t\t\terror == 183 ? \u0022The final import directory was created by another operation.\u0022 : $\u0022The final import directory could not be reserved (Windows error {error}).\u0022 );\n\t\t}\n\t\tCreatedDirectories.Add( FinalDirectory );\n\t\tLockDirectory( FinalDirectory );\n\t}\n\n\tinternal IReadOnlyList\u003Cstring\u003E 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 \u002B read);\n\t\t\t\t\tif ( streamed \u003E MaximumBytes )\n\t\t\t\t\t\tthrow new ImportContractException( \u0022LimitExceeded\u0022, \u0022Selected files grew beyond 1,073,741,824 bytes while copying.\u0022 );\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( \u0022CopyFailed\u0022, $\u0022Failed to copy \u0027{copy.DestinationAsset}\u0027.\u0022 ); }\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\u003Cstring\u003E CleanupPreEngine()\n\t{\n\t\tvar remaining = new List\u003Cstring\u003E();\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\u003CFileDispositionInfo\u003E() ) )\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 =\u003E !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 =\u003E x, StringComparer.OrdinalIgnoreCase ).ToArray();\n\t}\n\n\tinternal string[] PendingPaths( IEnumerable\u003Cstring\u003E generated ) =\u003E 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 =\u003E x, StringComparer.OrdinalIgnoreCase ).ToArray();\n\n\tinternal string ToReportedPath( string absolute ) =\u003E IsContained( AssetsRoot, absolute )\n\t\t? Path.GetRelativePath( AssetsRoot, absolute ).Replace( \u0027\\\\\u0027, \u0027/\u0027 ) : 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( \u0022InvalidInput\u0022, \u0022targetDirectory must be a non-empty relative path without surrounding whitespace.\u0022 );\n\t\tvar normalized = target.Replace( \u0027\\\\\u0027, \u0027/\u0027 );\n\t\tif ( Path.IsPathRooted( target ) || normalized.StartsWith( \u0022//\u0022, StringComparison.Ordinal ) || normalized.Contains( \u0027:\u0027 ) )\n\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, \u0022targetDirectory must be relative to Assets.\u0022 );\n\t\tvar parts = normalized.Split( \u0027/\u0027, StringSplitOptions.None );\n\t\tif ( parts.Length == 0 || parts.Any( x =\u003E x.Length == 0 || x is \u0022.\u0022 or \u0022..\u0022 ) )\n\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, \u0022targetDirectory contains an empty, current, or parent component.\u0022 );\n\t\tif ( parts[0].Equals( \u0022Assets\u0022, StringComparison.OrdinalIgnoreCase ) )\n\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, \u0022targetDirectory must not include an Assets prefix.\u0022 );\n\t\tforeach ( var part in parts ) ValidateFileName( part, true );\n\t\treturn string.Join( \u0027/\u0027, 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( \u0022.\u0022, StringComparison.Ordinal ) )\n\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, \u0022A planned filename is empty, whitespace-padded, or ends in a dot.\u0022 );\n\t\tif ( name.IndexOfAny( Path.GetInvalidFileNameChars() ) \u003E= 0 || name.Contains( \u0027/\u0027 ) || name.Contains( \u0027\\\\\u0027 ) )\n\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, \u0022A planned filename contains invalid characters or separators.\u0022 );\n\t\tif ( !extensionAllowed \u0026\u0026 Path.GetFileName( name ) != name )\n\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, \u0022modelName must be a filename stem.\u0022 );\n\t\tvar deviceStem = name.Split( \u0027.\u0027, 2 )[0];\n\t\tif ( ReservedNames.Contains( deviceStem ) )\n\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, \u0022A planned filename is a reserved Windows device name.\u0022 );\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 ) \u0026 FileAttributes.ReparsePoint) != 0 )\n\t\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, \u0022The destination chain contains a symbolic link or junction.\u0022 );\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( \u0022CopyFailed\u0022, \u0022The destination ancestry could not be locked against replacement.\u0022 );\n\t\tif ( !GetFileInformationByHandle( handle, out var information ) || (information.FileAttributes \u0026 0x400) != 0 )\n\t\t{\n\t\t\thandle.Dispose();\n\t\t\tthrow new ImportContractException( \u0022InvalidInput\u0022, \u0022The destination chain contains or changed to a symbolic link or junction.\u0022 );\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( $\u0022Exclusive file creation failed (Windows error {error}).\u0022 );\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() =\u003E 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 ) ) \u002B 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( \u0022InvalidInput\u0022, \u0022targetDirectory escapes the active Assets root.\u0022 );\n\t}\n\n\tprivate static string JoinAsset( string directory, string name ) =\u003E $\u0022{directory.TrimEnd( \u0027/\u0027 )}/{name}\u0022;\n\n\t[DllImport( \u0022kernel32.dll\u0022, CharSet = CharSet.Unicode, SetLastError = true )]\n\t[return: MarshalAs( UnmanagedType.Bool )]\n\tprivate static extern bool CreateDirectoryW( string path, IntPtr securityAttributes );\n\t[DllImport( \u0022kernel32.dll\u0022, CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = \u0022CreateFileW\u0022 )]\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( \u0022kernel32.dll\u0022, 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( \u0022kernel32.dll\u0022, 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 ) =\u003E 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/// \u003Csummary\u003E\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 \u0027x_\u0027 so they can never\r\n/// collide with the engine\u0027s own once the upstream equivalents land.\r\n/// \u003C/summary\u003E\r\n[McpToolset( \u0022extras\u0022, \u0022Local extensions to the built-in MCP tools\u0022 )]\r\npublic static partial class ExtrasTools\r\n{\r\n\t/// \u003Csummary\u003E\r\n\t/// Make a scene the active editor tab, opening it from its asset path when it isn\u0027t 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\u0027s already open.\r\n\t/// \u003C/summary\u003E\r\n\t/// \u003Cparam name=\u0022scene\u0022\u003EScene name or resource path as list_scenes reports it, or a .scene/.prefab path from asset_search.\u003C/param\u003E\r\n\t[McpTool( \u0022x_open_scene\u0022 )]\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( \u0022Give a scene name or resource path - list_scenes shows what\u0027s open, asset_search type:scene finds scene assets on disk\u0022 );\r\n\r\n\t\tif ( Game.IsPlaying )\r\n\t\t\tthrow new Exception( \u0022Can\u0027t switch scene tabs while playing - play_stop first\u0022 );\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( \u0022That\u0027s the running game session, which has no tab to switch to - play_stop first, then open the scene you want to edit\u0022 );\r\n\r\n\t\tif ( session is not null \u0026\u0026 session == SceneEditorSession.Active )\r\n\t\t\treturn Row( session, $\u0022\u0027{session.Scene?.Name}\u0027 was already the active tab - nothing changed\u0022 );\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( $\u0022Nothing to open for \u0027{scene}\u0027 - list_scenes shows what\u0027s already open, asset_search type:scene finds scene assets on disk\u0022 );\r\n\r\n\t\tsession.MakeActive();\r\n\r\n\t\treturn Row( session, opened\r\n\t\t\t? $\u0022Opened \u0027{session.Scene?.Name}\u0027 from disk and made it the active tab\u0022\r\n\t\t\t: $\u0022Switched the active tab to the already open \u0027{session.Scene?.Name}\u0027\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\r\n\t/// \u003Cparam name=\u0022path\u0022\u003EProject-relative .scene path beneath scenes/diagnostics.\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022name\u0022\u003EOptional scene name. Defaults to the destination file name.\u003C/param\u003E\r\n\t[McpTool( \u0022x_create_scene\u0022 )]\r\n\tpublic static SceneTab CreateScene( string path, string name = \u0022\u0022 )\r\n\t{\r\n\t\tif ( Game.IsPlaying )\r\n\t\t\tthrow new Exception( \u0022Can\u0027t create a scene while playing - play_stop first\u0022 );\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( $\u0022A scene already exists at \u0027{resourcePath}\u0027 - choose a new diagnostic path\u0022 );\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( \u0022Couldn\u0027t create a new editor scene session\u0022 );\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( \u0022scene\u0022, destination.AbsolutePath )\r\n\t\t\t\t?? throw new Exception( $\u0022Couldn\u0027t create the scene resource at \u0027{resourcePath}\u0027\u0022 );\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( $\u0022Couldn\u0027t save the new scene at \u0027{resourcePath}\u0027\u0022 );\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( $\u0022The new scene was saved but couldn\u0027t be opened at \u0027{resourcePath}\u0027\u0022 );\r\n\r\n\t\t\topened.MakeActive();\r\n\t\t\treturn Row( opened, $\u0022Created \u0027{opened.Scene?.Name}\u0027 at \u0027{resourcePath}\u0027 and made it the active tab\u0022 );\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 \u0026\u0026 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/// \u003Csummary\u003E\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\u0027s active tab, which is what the scene tools edit; the built in editor_status\r\n\t/// reports the running game\u0027s 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/// \u003C/summary\u003E\r\n\t[McpTool.ReadOnly( \u0022x_editor_status\u0022 )]\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/// \u003Csummary\u003E\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\u0027s id or its game object\u0027s id, or nothing for the scene\u0027s 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\u0027s,\r\n\t/// because rendering offscreen relayouts the UI, which throws away each label\u0027s 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\u0027s 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 \u0027Camera\u0027 lists the\r\n\t/// cameras in a scene.\r\n\t/// \u003C/summary\u003E\r\n\t/// \u003Cparam name=\u0022camera\u0022\u003EA CameraComponent id or its game object\u0027s id. Empty uses the scene\u0027s main camera.\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022width\u0022\u003EImage width in pixels.\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022height\u0022\u003EImage height in pixels.\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022includeUi\u0022\u003EInclude any UI the camera renders.\u003C/param\u003E\r\n\t[McpTool.ReadOnly( \u0022x_camera_screenshot\u0022 )]\r\n\tpublic static object CameraScreenshotNative( string camera = \u0022\u0022, [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( \u0022The scene has no camera - find one with find_game_objects component \u0027Camera\u0027, or add one\u0022 );\r\n\r\n\t\t// The one size the engine bug can\u0027t bite: identical to the screen, so the offscreen\r\n\t\t// relayout changes no panel\u0027s 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\u0027s 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 \u003C= 1 || nativeHeight \u003C= 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 \u0026\u0026 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/// \u003Csummary\u003E\r\n\t/// Aim a camera game object at a world-space point using the engine\u0027s Rotation.LookAt.\r\n\t/// The camera must belong to the active editor scene. The edit is undoable.\r\n\t/// \u003C/summary\u003E\r\n\t/// \u003Cparam name=\u0022camera\u0022\u003EA CameraComponent id or its game object\u0027s id.\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022target\u0022\u003EWorld-space target as \u0027x,y,z\u0027.\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022up\u0022\u003EWorld-space up direction as \u0027x,y,z\u0027. Defaults to \u002BZ.\u003C/param\u003E\r\n\t[McpTool( \u0022x_camera_look_at\u0022 )]\r\n\tpublic static CameraLookAtResult CameraLookAt( string camera, string target, string up = \u00220,0,1\u0022 )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( camera ) )\r\n\t\t\tthrow new Exception( \u0022Give a CameraComponent or camera game object id - find_game_objects component \u0027Camera\u0027 lists them\u0022 );\r\n\r\n\t\tvar session = SceneEditorSession.Active\r\n\t\t\t?? throw new Exception( \u0022No editor scene tab is active\u0022 );\r\n\t\tvar component = ResolveCamera( camera );\r\n\t\tif ( component.Scene != session.Scene )\r\n\t\t\tthrow new Exception( \u0022The camera isn\u0027t in the active editor scene - use x_open_scene or switch_scene first\u0022 );\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 \u003C 0.000001f )\r\n\t\t\tthrow new Exception( \u0022The camera position and look-at target must differ\u0022 );\r\n\t\tif ( upDirection.LengthSquared \u003C 0.000001f )\r\n\t\t\tthrow new Exception( \u0022The look-at up direction must be non-zero\u0022 );\r\n\r\n\t\tvar gameObject = component.GameObject;\r\n\t\tusing ( session.UndoScope( \u0022Aim Camera\u0022 ).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/// \u003Csummary\u003EThe resulting camera aim.\u003C/summary\u003E\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/// \u003Csummary\u003E\r\n\t/// Set one game object\u0027s saved networking mode in the active editor scene. The edit is undoable.\r\n\t/// \u003C/summary\u003E\r\n\t/// \u003Cparam name=\u0022id\u0022\u003EGame object GUID from scene_tree or find_game_objects.\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022mode\u0022\u003ENever, Object, or Snapshot.\u003C/param\u003E\r\n\t[McpTool( \u0022x_set_network_mode\u0022 )]\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( \u0022Can\u0027t change saved network mode while playing - play_stop first\u0022 );\r\n\t\tif ( !Guid.TryParse( id, out var guid ) )\r\n\t\t\tthrow new Exception( $\u0022\u0027{id}\u0027 isn\u0027t a game object GUID\u0022 );\r\n\r\n\t\tvar session = SceneEditorSession.Active\r\n\t\t\t?? throw new Exception( \u0022No editor scene tab is active\u0022 );\r\n\t\tvar gameObject = session.Scene?.Directory?.FindByGuid( guid )\r\n\t\t\t?? throw new Exception( $\u0022No game object with id {guid} exists in the active scene\u0022 );\r\n\r\n\t\tusing ( session.UndoScope( \u0022Set Network Mode\u0022 ).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/// \u003Csummary\u003EOne scene tab open in the editor.\u003C/summary\u003E\r\n\tpublic class SceneTab\r\n\t{\r\n\t\t/// \u003Csummary\u003EWhat happened - opened, switched, or already active.\u003C/summary\u003E\r\n\t\tpublic string Message { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003EThe scene\u0027s name, as list_scenes reports it.\u003C/summary\u003E\r\n\t\tpublic string Name { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003EThe scene asset\u0027s resource path. Null for a scene that was never saved.\u003C/summary\u003E\r\n\t\tpublic string ResourcePath { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003EScene, Prefab, or Game for the running session.\u003C/summary\u003E\r\n\t\tpublic string Type { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003EWhether this is the active tab - true unless something else took focus.\u003C/summary\u003E\r\n\t\tpublic bool IsActive { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003EWhether the scene has edits that save_scene hasn\u0027t written yet.\u003C/summary\u003E\r\n\t\tpublic bool HasUnsavedChanges { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003EHow many objects sit at the scene root.\u003C/summary\u003E\r\n\t\tpublic int RootObjectCount { get; set; }\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EThe editor\u0027s current state, from the editor\u0027s point of view rather than the game\u0027s.\u003C/summary\u003E\r\n\tpublic class EditorStatusExtras\r\n\t{\r\n\t\t/// \u003Csummary\u003EThe open project\u0027s ident.\u003C/summary\u003E\r\n\t\tpublic string Project { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003EThe open project\u0027s title.\u003C/summary\u003E\r\n\t\tpublic string ProjectTitle { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003EThe active scene tab\u0027s name. Falls back to the running game\u0027s scene when no tab is active.\u003C/summary\u003E\r\n\t\tpublic string ActiveScene { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003EThe active scene\u0027s resource path. Null for a scene that was never saved.\u003C/summary\u003E\r\n\t\tpublic string ActiveScenePath { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003EWhether the active scene has edits that save_scene hasn\u0027t written yet.\u003C/summary\u003E\r\n\t\tpublic bool SceneHasUnsavedChanges { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003EHow many scene tabs are open. list_scenes names them.\u003C/summary\u003E\r\n\t\tpublic int OpenSceneCount { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003EWhether play mode is running - play_stop returns to editing.\u003C/summary\u003E\r\n\t\tpublic bool IsPlaying { get; set; }\r\n\r\n\t\t/// \u003Csummary\u003EWhether play mode is paused.\u003C/summary\u003E\r\n\t\tpublic bool IsPaused { get; set; }\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\r\n\tprivate static SceneEditorSession FindSession( string nameOrPath )\r\n\t{\r\n\t\treturn SceneEditorSession.All\r\n\t\t\t.FirstOrDefault( x =\u003E 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( \u0022Give a project-relative .scene path beneath scenes/diagnostics/\u0022 );\r\n\r\n\t\tvar normalized = path.Trim().Replace( \u0027\\\\\u0027, \u0027/\u0027 );\r\n\t\tif ( Path.IsPathRooted( normalized ) )\r\n\t\t\tthrow new Exception( \u0022Scene path must be project-relative and beneath scenes/diagnostics/\u0022 );\r\n\r\n\t\tif ( normalized.Split( \u0027/\u0027, StringSplitOptions.RemoveEmptyEntries ).Contains( \u0022..\u0022 ) )\r\n\t\t\tthrow new Exception( \u0022Scene path can\u0027t contain traversal segments\u0022 );\r\n\r\n\t\tif ( !string.Equals( Path.GetExtension( normalized ), \u0022.scene\u0022, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\tthrow new Exception( \u0022Scene path must use the .scene extension\u0022 );\r\n\r\n\t\tif ( !normalized.StartsWith( \u0022scenes/diagnostics/\u0022, StringComparison.OrdinalIgnoreCase )\r\n\t\t\t|| normalized.Length == \u0022scenes/diagnostics/\u0022.Length )\r\n\t\t\tthrow new Exception( \u0022Scene path must be beneath scenes/diagnostics/\u0022 );\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, \u0022scenes\u0022, \u0022diagnostics\u0022 ) )\r\n\t\t\t\t.TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar ) \u002B Path.DirectorySeparatorChar;\r\n\t\t\tvar absolute = Path.GetFullPath( Path.Combine( assetsRoot, normalized.Replace( \u0027/\u0027, Path.DirectorySeparatorChar ) ) );\r\n\r\n\t\t\tif ( !absolute.StartsWith( diagnosticsRoot, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t\tthrow new Exception( \u0022Scene path must be beneath scenes/diagnostics/\u0022 );\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( \u0022Scene path isn\u0027t a valid project-relative path\u0022 );\r\n\t\t}\r\n\t}\r\n\r\n\r\n\t/// \u003Csummary\u003E\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\u0027s main camera. The engine\u0027s own\r\n\t/// resolvers are private to the tools addon, so this repeats them.\r\n\t/// \u003C/summary\u003E\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( \u0022No scene is open in the editor\u0022 );\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( $\u0022\u0027{camera}\u0027 isn\u0027t a guid - find_game_objects and scene_tree show object ids, get_game_object shows component ids\u0022 );\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( \u0022That component isn\u0027t a camera - give a CameraComponent or its game object\u0022 );\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\u0027s view of a game object\u0027s components\r\n\t\t\t\treturn go.Components.Get\u003CCameraComponent\u003E( true )\r\n\t\t\t\t\t?? throw new Exception( $\u0022\u0027{go.Name}\u0027 has no camera component - find one with find_game_objects component \u0027Camera\u0027\u0022 );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tthrow new Exception( $\u0022Nothing in any open scene has id {guid} - find_game_objects and scene_tree show what\u0027s there\u0022 );\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 ? \u0022Game\u0022 : session.Scene is PrefabScene ? \u0022Prefab\u0022 : \u0022Scene\u0022,\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"}]}