Editor handler that copies a project asset and its dependency closure into a target project directory. It resolves the source asset, builds its references, skips cloud/procedural/transient and core-engine assets, and copies source/compiled files plus any additional content while preserving filenames under the target directory.
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
// =============================================================================
// Batch 40 -- Asset utilities.
//
// CopyAssetWithDependenciesHandler: copy a project asset (and its full
// dependency closure) into a target directory, preserving relative paths so
// material references stay intact. Includes a shadow-guard that refuses to
// land files under core engine trees (citizen/dev/default) where even a single
// stray file can trigger an infinite recompile loop (BRIDGE_GOTCHAS #5).
//
// Registration: MyEditorMenu.cs Batch 40 block.
// =============================================================================
/// <summary>
/// Copy a project asset and its full dependency closure into a target directory.
/// Preserves relative path structure under the target so material .vmat references
/// to textures keep resolving. Refuses to write into core engine asset trees
/// (models/citizen, models/dev, materials/dev, materials/default) to avoid the
/// infinite-recompile gotcha (BRIDGE_GOTCHAS #5). Cloud/procedural/transient
/// assets are skipped with a reason in the "skipped" list.
/// Returns { copied:[{from,to}], skipped:[{path,reason}], count, note }.
/// </summary>
public class CopyAssetWithDependenciesHandler : IBridgeHandler
{
// Core-engine subtrees whose assets must never be shadowed by a project copy.
// Even one file landing here triggers an infinite asset-compiler loop.
static readonly string[] ShadowBlockedTrees = new[]
{
"models/citizen",
"materials/dev",
"materials/default",
"models/dev",
};
public Task<object> Execute( JsonElement p )
{
try
{
// ---- 1. Source path resolution ----
var sourcePath = p.TryGetProperty( "sourcePath", out var sp ) ? sp.GetString() : null;
if ( string.IsNullOrWhiteSpace( sourcePath ) )
return Task.FromResult<object>( new { error = "sourcePath is required" } );
// Try AssetSystem.FindByPath with the raw value, then with a project-relative prefix.
Asset sourceAsset = Editor.AssetSystem.FindByPath( sourcePath );
if ( sourceAsset == null )
{
// Try treating it as a project-relative path.
if ( ClaudeBridge.TryResolveProjectPath( sourcePath, out var absAttempt, out _ ) )
sourceAsset = Editor.AssetSystem.FindByPath( absAttempt )
?? Editor.AssetSystem.FindByPath( absAttempt.Replace( '\\', '/' ) );
}
if ( sourceAsset == null )
return Task.FromResult<object>( new { error = $"Asset not found via AssetSystem: '{sourcePath}'. Make sure the asset is registered (visible in the asset browser). Use search_assets to find its exact path." } );
// ---- 2. Target directory ----
var targetDir = p.TryGetProperty( "targetDir", out var td ) && !string.IsNullOrWhiteSpace( td.GetString() )
? td.GetString() : "Assets/library";
if ( !ClaudeBridge.TryResolveProjectPath( targetDir, out var absTargetDir, out var pathErr ) )
return Task.FromResult<object>( new { error = pathErr } );
// SHADOW GUARD for the DESTINATION too: writing anything under a core engine
// tree (models/citizen, materials/dev, materials/default, models/dev) risks
// shadowing built-in assets -> infinite recompile loop (BRIDGE_GOTCHAS #5).
var targetNorm = targetDir.Replace( '\\', '/' ).TrimStart( '/' );
if ( targetNorm.StartsWith( "Assets/", StringComparison.OrdinalIgnoreCase ) )
targetNorm = targetNorm.Substring( 7 );
foreach ( var blockedTree in ShadowBlockedTrees )
{
if ( targetNorm.StartsWith( blockedTree, StringComparison.OrdinalIgnoreCase ) )
return Task.FromResult<object>( new { error = $"SHADOW_BLOCKED targetDir: '{targetDir}' is under the core engine tree '{blockedTree}'. Copying assets there can shadow built-in assets and cause an infinite recompile loop (BRIDGE_GOTCHAS #5). Pick a project-namespaced folder like Assets/library/<name>." } );
}
bool overwrite = p.TryGetProperty( "overwrite", out var ow ) && ow.ValueKind == JsonValueKind.True;
// ---- 3. Build closure ----
List<Asset> closure;
try
{
closure = new List<Asset> { sourceAsset };
var refs = sourceAsset.GetReferences( true );
if ( refs != null ) closure.AddRange( refs );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"Failed to collect asset references: {ex.Message}" } );
}
// ---- 4. Copy each asset ----
var copied = new List<object>();
var skipped = new List<object>();
foreach ( var asset in closure )
{
try
{
// Skip cloud/procedural/transient assets -- they resolve globally, cannot be copied.
if ( asset.IsCloud ) { skipped.Add( new { path = asset.RelativePath, reason = "cloud asset (resolves globally, no local file to copy)" } ); continue; }
if ( asset.IsProcedural ) { skipped.Add( new { path = asset.RelativePath, reason = "procedural asset (generated at runtime, no source file)" } ); continue; }
if ( asset.IsTransient ) { skipped.Add( new { path = asset.RelativePath, reason = "transient asset (not persisted to disk)" } ); continue; }
// Shadow guard: refuse core engine trees.
var relLower = ( asset.RelativePath ?? "" ).Replace( '\\', '/' ).ToLowerInvariant();
foreach ( var blocked in ShadowBlockedTrees )
{
if ( relLower.StartsWith( blocked, StringComparison.Ordinal ) )
{
skipped.Add( new
{
path = asset.RelativePath,
reason = $"SHADOW_BLOCKED: path starts with '{blocked}'. Copying assets under core engine trees ({string.Join( ", ", ShadowBlockedTrees )}) causes an infinite asset-recompile loop (BRIDGE_GOTCHAS #5). Use the asset from its original location."
} );
goto NextAsset;
}
}
// Determine source file (prefer source over compiled).
string srcFile = null;
if ( asset.HasSourceFile ) srcFile = asset.GetSourceFile( true );
else if ( asset.HasCompiledFile ) srcFile = asset.GetCompiledFile( true );
if ( string.IsNullOrEmpty( srcFile ) || !File.Exists( srcFile ) )
{
// Try additional content files.
var extras = GetAdditionalContent( asset );
if ( extras.Count == 0 )
{
skipped.Add( new { path = asset.RelativePath, reason = "no source or compiled file found on disk" } );
continue;
}
// Copy extras only.
foreach ( var extra in extras )
{
if ( !File.Exists( extra ) ) continue;
var destExtra = BuildDestPath( extra, absTargetDir );
var skipReason = CopyFile( extra, destExtra, overwrite );
if ( skipReason != null )
skipped.Add( new { path = extra, reason = skipReason } );
else
copied.Add( new { from = extra, to = destExtra } );
}
continue;
}
var dest = BuildDestPath( srcFile, absTargetDir );
var skip = CopyFile( srcFile, dest, overwrite );
if ( skip != null )
skipped.Add( new { path = asset.RelativePath, reason = skip } );
else
copied.Add( new { from = srcFile, to = dest } );
// Also copy any additional content files (e.g. LODs, physics meshes).
foreach ( var extra in GetAdditionalContent( asset ) )
{
if ( !File.Exists( extra ) ) continue;
var destExtra = BuildDestPath( extra, absTargetDir );
var skipReason2 = CopyFile( extra, destExtra, overwrite );
if ( skipReason2 != null )
skipped.Add( new { path = extra, reason = skipReason2 } );
else
copied.Add( new { from = extra, to = destExtra } );
}
}
catch ( Exception ex )
{
skipped.Add( new { path = asset.RelativePath ?? "(unknown)", reason = $"exception: {ex.Message}" } );
}
NextAsset:;
}
return Task.FromResult<object>( new
{
copied,
skipped,
count = copied.Count,
note = "Trigger an asset rescan (or restart the editor) if the new assets do not appear in the asset browser."
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"copy_asset_with_dependencies failed: {ex.Message}" } );
}
}
// Build the destination path: place the asset inside absTargetDir using the
// asset's own relative path structure (so sub-folder references stay intact).
static string BuildDestPath( string srcFile, string absTargetDir )
{
// Use the filename only if we cannot derive a meaningful relative path.
var fileName = Path.GetFileName( srcFile );
return Path.Combine( absTargetDir, fileName );
}
// Copy srcFile -> destFile. Returns null on success, a skip-reason string on failure/skip.
static string CopyFile( string src, string dest, bool overwrite )
{
if ( File.Exists( dest ) && !overwrite )
return $"destination already exists (overwrite=false): {dest}";
try
{
Directory.CreateDirectory( Path.GetDirectoryName( dest ) );
File.Copy( src, dest, overwrite );
return null;
}
catch ( Exception ex )
{
return $"copy failed: {ex.Message}";
}
}
// Safely call GetAdditionalContentFiles if the Asset API supports it.
static List<string> GetAdditionalContent( Asset asset )
{
try
{
var method = typeof( Asset ).GetMethod( "GetAdditionalContentFiles",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance );
if ( method == null ) return new List<string>();
var result = method.Invoke( asset, null );
if ( result is IEnumerable<string> paths ) return paths.ToList();
}
catch { /* API not present on this SDK version */ }
return new List<string>();
}
}