Editor/Import/HeadlessExporter.cs

Editor helper that runs UnrealEditor-Cmd headlessly to export selected .uasset meshes to FBX/PNG plus a manifest. It prepares a sanitized .uproject if needed, writes asset list to a temp staging folder, launches the editor process, tails the Unreal abslog to report progress, and returns staging/manifest paths and any error.

Process ExecutionFile AccessExternal Download
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;

namespace Editor.UnrealImporter;

public class ExportResult
{
	public bool Success;
	public string StagingDir;
	public string ManifestPath;
	public string Error;
}

/// <summary>A progress signal parsed out of the live Unreal log stream.</summary>
/// <param name="Done">Meshes exported so far, when the line carried a count.</param>
/// <param name="Total">Total meshes to export, when known.</param>
/// <param name="Message">Human-readable phase/state line.</param>
public record ExportEvent( int? Done, int? Total, string Message );

/// <summary>
/// Drives Tools/ue_export.py inside headless Unreal (UnrealEditor-Cmd) to turn selected
/// .uasset StaticMeshes into FBX + PNG + manifest.json in a staging folder.
/// </summary>
public static class HeadlessExporter
{
	/// <summary>Find ue_export.py shipped in this library's Tools folder.</summary>
	public static string FindExportScript()
	{
		var root = Sandbox.Project.Current?.GetRootPath();
		if ( !string.IsNullOrEmpty( root ) )
		{
			var direct = Path.Combine( root, "Libraries", "unrealimporter", "Tools", "ue_export.py" );
			if ( File.Exists( direct ) )
				return direct;

			var hit = Directory.EnumerateFiles( root, "ue_export.py", SearchOption.AllDirectories ).FirstOrDefault();
			if ( hit != null )
				return hit;
		}

		return null;
	}

	/// <summary>Convert a Content-relative .uasset file path to a /Game object path.</summary>
	/// <example>.../Content/Construction_VOL1/Meshes/SM_Boxes_01a.uasset -> /Game/Construction_VOL1/Meshes/SM_Boxes_01a</example>
	public static string ToGamePath( string uprojectFolder, string uassetAbsPath )
	{
		var content = Path.Combine( uprojectFolder, "Content" );
		var rel = Path.GetRelativePath( content, uassetAbsPath ).Replace( '\\', '/' );
		if ( rel.EndsWith( ".uasset", StringComparison.OrdinalIgnoreCase ) )
			rel = rel[..^".uasset".Length];

		return "/Game/" + rel;
	}

	/// <param name="mapGamePath">When set, scene mode: export this .umap's placements plus every mesh it uses (gameAssetPaths is ignored by the script).</param>
	/// <param name="onProgress">
	/// Live progress parsed by tailing the -abslog file. Unreal's stdout only carries
	/// Display+ severity (verified: our script's Log-verbosity lines never appear there,
	/// with or without -stdout), but the log FILE gets every line. Invoked on the calling
	/// thread's context.
	/// </param>
	public static async Task<ExportResult> Run( string editorCmd, string uprojectPath, IEnumerable<string> gameAssetPaths, string scriptPath, CancellationToken progressToken, string mapGamePath = null, Action<ExportEvent> onProgress = null )
	{
		var result = new ExportResult();

		// Marketplace packs often force-enable plugins that no longer ship with the engine
		// (NVIDIA Ansel is the classic) - Unreal hard-fatals on those at boot. Launch a
		// sanitized temp .uproject with the missing ones marked Optional instead.
		//
		// This walks the whole engine + project plugin trees looking for .uplugin files, which
		// is seconds of disk work on a big install - off the UI thread, and announced, or it
		// reads as the editor hanging before anything has even started.
		string tempUproject = null;
		onProgress?.Invoke( new ExportEvent( null, null, "Checking project plugins..." ) );
		try
		{
			var sanitized = await Task.Run( () =>
			{
				var path = SanitizeUproject( editorCmd, uprojectPath, out var temp );
				return (path, temp);
			}, progressToken );

			uprojectPath = sanitized.path;
			tempUproject = sanitized.temp;
		}
		catch ( Exception e )
		{
			Log.Warning( $"uproject plugin check failed, launching unmodified: {e.Message}" );
		}

		try
		{

		var stagingDir = Path.Combine( Path.GetTempPath(), "unrealimporter", Guid.NewGuid().ToString( "N" ) );
		Directory.CreateDirectory( stagingDir );
		result.StagingDir = stagingDir;

		// Pass the selection via a file (env-var/command-line length is limited).
		var assetsFile = Path.Combine( stagingDir, "_assets.txt" );
		await File.WriteAllLinesAsync( assetsFile, gameAssetPaths, progressToken );

		var logPath = Path.Combine( stagingDir, "ue_export.log" );

		// NOTE: -script must use forward slashes; a backslash before u/r/etc. is eaten as a python escape.
		// PCG ships with the engine since 5.2 - without it, PCG-scattered actors in World Partition
		// maps fail to deserialize ("Invalid actor native class") and their geometry is lost.
		var script = scriptPath.Replace( '\\', '/' );
		var args =
			$"\"{uprojectPath}\" -run=pythonscript -script=\"{script}\" " +
			$"-EnablePlugins=PythonScriptPlugin,PCG -unattended -nosplash -nullrhi -abslog=\"{logPath}\"";

		var psi = new ProcessStartInfo
		{
			FileName = editorCmd,
			Arguments = args,
			UseShellExecute = false,
			CreateNoWindow = true,
		};
		psi.EnvironmentVariables["UE_EXPORT_OUT"] = stagingDir;
		psi.EnvironmentVariables["UE_EXPORT_ASSETS_FILE"] = assetsFile;
		if ( !string.IsNullOrEmpty( mapGamePath ) )
			psi.EnvironmentVariables["UE_EXPORT_MAP"] = mapGamePath;

		try
		{
			using var proc = Process.Start( psi );

			// Cancelling the progress section actually stops Unreal rather than orphaning it.
			using var killOnCancel = progressToken.Register( () =>
			{
				try { proc.Kill( entireProcessTree: true ); }
				catch { }
			} );

			// TailLog owns the status line from here - it emits immediately and then keeps a
			// heartbeat going, so there's no silent gap to fill in.
			var tail = TailLog( proc, logPath, onProgress );

			try
			{
				await proc.WaitForExitAsync( progressToken );
			}
			catch ( OperationCanceledException )
			{
				// killOnCancel is stopping Unreal; fall through so the tail loop winds down.
			}

			await tail;

			result.ManifestPath = Path.Combine( stagingDir, "manifest.json" );
			if ( proc.ExitCode != 0 )
			{
				result.Error = progressToken.IsCancellationRequested
					? "Export cancelled."
					: $"UnrealEditor-Cmd exited with code {proc.ExitCode}. See log:\n{logPath}";
				return result;
			}

			if ( !File.Exists( result.ManifestPath ) )
			{
				result.Error = $"Export finished but no manifest.json was produced. See log:\n{logPath}";
				return result;
			}

			result.Success = true;
			return result;
		}
		catch ( Exception e )
		{
			result.Error = e.Message;
			return result;
		}

		}
		finally
		{
			if ( tempUproject is not null )
			{
				try { File.Delete( tempUproject ); }
				catch { }
			}
		}
	}

	static readonly Dictionary<string, HashSet<string>> pluginScanCache = new( StringComparer.OrdinalIgnoreCase );

	/// <summary>Names of every .uplugin discoverable under a directory (cached - engine trees are big).</summary>
	static HashSet<string> AvailablePlugins( string dir )
	{
		if ( pluginScanCache.TryGetValue( dir, out var cached ) )
			return cached;

		var set = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		if ( Directory.Exists( dir ) )
		{
			foreach ( var f in Directory.EnumerateFiles( dir, "*.uplugin", SearchOption.AllDirectories ) )
				set.Add( Path.GetFileNameWithoutExtension( f ) );
		}

		pluginScanCache[dir] = set;
		return set;
	}

	/// <summary>
	/// If the .uproject enables plugins that exist neither in the engine nor the project,
	/// write a sibling temp .uproject with those entries marked Optional (Unreal skips
	/// missing optional plugins instead of aborting) and return its path. Returns the
	/// original path untouched when everything resolves. Caller deletes the temp file.
	/// </summary>
	static string SanitizeUproject( string editorCmd, string uprojectPath, out string tempUproject )
	{
		tempUproject = null;

		var root = System.Text.Json.Nodes.JsonNode.Parse( File.ReadAllText( uprojectPath ) );
		if ( root?["Plugins"] is not System.Text.Json.Nodes.JsonArray plugins || plugins.Count == 0 )
			return uprojectPath;

		var enabled = plugins
			.Where( p => p?["Enabled"]?.GetValue<bool>() == true )
			.Select( p => p?["Name"]?.GetValue<string>() )
			.Where( n => !string.IsNullOrEmpty( n ) )
			.ToList();
		if ( enabled.Count == 0 )
			return uprojectPath;

		// editorCmd = <root>/Engine/Binaries/Win64/UnrealEditor-Cmd.exe
		var enginePlugins = Path.GetFullPath( Path.Combine( Path.GetDirectoryName( editorCmd ), "..", "..", "Plugins" ) );
		var projFolder = Path.GetDirectoryName( uprojectPath );

		var missing = enabled
			.Where( n => !AvailablePlugins( enginePlugins ).Contains( n )
				&& !AvailablePlugins( Path.Combine( projFolder, "Plugins" ) ).Contains( n )
				&& !AvailablePlugins( Path.Combine( projFolder, "Mods" ) ).Contains( n ) )
			.ToHashSet( StringComparer.OrdinalIgnoreCase );
		if ( missing.Count == 0 )
			return uprojectPath;

		Log.Info( $"uproject enables plugin(s) missing from this engine: {string.Join( ", ", missing )} - marking Optional for the export run." );

		foreach ( var p in plugins )
		{
			if ( p?["Name"]?.GetValue<string>() is string name && missing.Contains( name ) )
				p["Optional"] = true;
		}

		// Same folder, so Content/ and /Game paths resolve identically.
		tempUproject = Path.Combine( projFolder, Path.GetFileNameWithoutExtension( uprojectPath ) + ".sboximport.uproject" );
		File.WriteAllText( tempUproject, root.ToJsonString( new System.Text.Json.JsonSerializerOptions { WriteIndented = true } ) );
		return tempUproject;
	}

	/// <summary>How long a phase may go without an update before its elapsed time is re-emitted.</summary>
	static readonly TimeSpan Heartbeat = TimeSpan.FromSeconds( 2 );

	/// <summary>
	/// Coarse phases recognised in Unreal's OWN boot log. Booting a marketplace project
	/// headlessly is a thousand-odd log lines and a minute-plus of wall clock before our
	/// script gets a word in, and reporting nothing through it is indistinguishable from a
	/// hang. Ordered earliest-to-latest and matched monotonically (a phase never goes
	/// backwards), because the categories interleave freely.
	/// </summary>
	static readonly (string Marker, string Phase)[] BootPhases =
	{
		( "LogInit", "Unreal starting up" ),
		( "LogPluginManager: Mounting", "Unreal: mounting plugins" ),
		( "LogTargetPlatformManager", "Unreal: loading target platforms" ),
		( "LogDerivedDataCache", "Unreal: opening the derived data cache" ),
		( "LogAssetRegistry", "Unreal: reading the asset registry" ),
		( "LogPython", "Unreal: starting Python" ),
	};

	/// <summary>Index into <see cref="BootPhases"/> for a raw log line, or -1 for noise.</summary>
	static int BootPhase( string line )
	{
		for ( int i = BootPhases.Length - 1; i >= 0; i-- )
		{
			if ( line.Contains( BootPhases[i].Marker, StringComparison.Ordinal ) )
				return i;
		}

		return -1;
	}

	/// <summary>
	/// Follow the growing Unreal log file, surfacing progress lines as they land. Unreal
	/// keeps the file open with shared read access and flushes frequently; a short poll
	/// keeps this cheap. Runs on the caller's sync context (awaited reads + delays), so
	/// onProgress can touch UI directly.
	///
	/// Every emitted message carries the elapsed time, and the current one is re-emitted on a
	/// <see cref="Heartbeat"/> whenever the log goes quiet - so even the phases that log
	/// nothing at all (loading a big map, the asset registry scan) visibly tick over.
	/// </summary>
	static async Task TailLog( Process proc, string logPath, Action<ExportEvent> onProgress )
	{
		if ( onProgress is null )
		{
			return;
		}

		var clock = Stopwatch.StartNew();

		// Latest known state, re-emitted by the heartbeat with a fresh elapsed stamp.
		int? done = null, total = null;
		var message = "Starting Unreal";
		var lastEmit = TimeSpan.MinValue;

		void Emit()
		{
			// Total minutes, not the TimeSpan minutes component - an hour-long map export
			// shouldn't look like it restarted its clock.
			var elapsed = $"{(int)clock.Elapsed.TotalMinutes}:{clock.Elapsed.Seconds:00}";
			onProgress( new ExportEvent( done, total, $"{message} ({elapsed})" ) );
			lastEmit = clock.Elapsed;
		}

		async Task Tick()
		{
			if ( clock.Elapsed - lastEmit >= Heartbeat )
				Emit();

			await Task.Delay( 250 );
		}

		Emit();

		while ( !proc.HasExited && !File.Exists( logPath ) )
			await Tick();

		if ( !File.Exists( logPath ) )
			return;

		using var fs = new FileStream( logPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete );
		using var reader = new StreamReader( fs );

		// UE's own python startup chatters on LogPython too - hold script messages back until
		// our script announces itself ("=== ue_export: ... ===").
		var sawScript = false;
		var bootPhase = -1;
		var carry = "";
		while ( true )
		{
			var chunk = await reader.ReadToEndAsync();
			if ( chunk.Length > 0 )
			{
				carry += chunk;

				int nl;
				while ( (nl = carry.IndexOf( '\n' )) >= 0 )
				{
					var line = carry[..nl].TrimEnd( '\r' );
					carry = carry[(nl + 1)..];

					var ev = ParseLine( line );

					// Until our script announces itself, Unreal's own boot log is all there
					// is - track a coarse phase from EVERY line, ours or not, so the wait is
					// legible. UE's python startup chatters on LogPython as well, so a
					// LogPython line alone doesn't mean the script is talking yet.
					if ( !sawScript )
					{
						sawScript = ev?.Done is not null
							|| ev?.Message?.StartsWith( "ue_export", StringComparison.OrdinalIgnoreCase ) == true;

						if ( !sawScript )
						{
							var phase = BootPhase( line );
							if ( phase > bootPhase )
							{
								bootPhase = phase;
								message = BootPhases[phase].Phase;
								Emit();
							}

							continue;
						}
					}

					if ( ev is null )
						continue;

					done = ev.Done ?? done;
					total = ev.Total ?? total;
					if ( !string.IsNullOrEmpty( ev.Message ) )
						message = ev.Message;

					Emit();
				}
			}
			else if ( proc.HasExited )
			{
				break;
			}

			await Tick();
		}
	}

	// "...LogPython: [6/98] SM_int_ceiling_300_01" - the per-mesh export progress our script logs.
	static readonly Regex MeshProgressLine = new( @"LogPython:\s*\[(\d+)/(\d+)\]\s*(.+)$", RegexOptions.Compiled );

	/// <summary>
	/// Distil one raw Unreal log line into a progress event, or null for noise. Only our
	/// own script's output (LogPython) is surfaced; indented LogPython lines are per-slot
	/// texture detail and stay hidden.
	/// </summary>
	static ExportEvent ParseLine( string line )
	{
		if ( string.IsNullOrEmpty( line ) )
			return null;

		var match = MeshProgressLine.Match( line );
		if ( match.Success )
		{
			return new ExportEvent(
				int.Parse( match.Groups[1].Value ),
				int.Parse( match.Groups[2].Value ),
				$"Exporting {match.Groups[3].Value.Trim()}" );
		}

		var idx = line.IndexOf( "LogPython: ", StringComparison.Ordinal );
		if ( idx >= 0 )
		{
			var msg = line[(idx + "LogPython: ".Length)..];
			if ( msg.Length > 0 && !char.IsWhiteSpace( msg[0] ) )
				return new ExportEvent( null, null, msg.Trim( '=', ' ' ) );
		}

		return null;
	}
}