🔍 s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
🔗 Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=brax.unrealimporter&take=20
Showing code results for query:
*
(14 total matches found)
Editor
library
using Sandbox;
using Sandbox.Resources;
namespace Editor.UnrealImporter;
/// <summary>
/// Creates s&box GameResources - .tmat (Terrain Material) and .decal (Decal Definition).
///
/// These are built through the editor's own asset API rather than by writing json: create the
/// asset, set properties on the real resource object, save. The resource classes' own defaults
/// then apply to everything we don't set, and SaveToDisk serialises, compiles and registers.
/// Only .vmat is hand-written, because it's kv3 with no GameResource behind it.
///
/// NOTE on creating vs editing: Asset.LoadResource needs an up-to-date COMPILED file, which a
/// just-created asset doesn't have yet - it returns null there. So a new resource is
/// constructed with `new T()` (giving us the class defaults) and only an existing one is
/// loaded, which lets a re-import keep whatever the user hand-tuned on it.
/// </summary>
public static class GameResourceWriter
{
/// <summary>
/// A Terrain Material. Terrain takes separate grayscale roughness/AO/height maps and a
/// scalar metalness (there's no metal texture slot), so a metallic map has nowhere to go.
/// Null paths are simply left at the resource's default image.
/// </summary>
public static Asset CreateTerrainMaterial( string absolutePath, string albedo, string roughness, string normal, string height, string ao, float uvScale = 1f )
{
var asset = global::Editor.AssetSystem.CreateResource( "tmat", absolutePath );
if ( asset is null )
return null;
// Existing asset -> update it in place; new one -> start from the class defaults.
var isNew = !asset.TryLoadResource<TerrainMaterial>( out var mat );
mat ??= new TerrainMaterial();
if ( !string.IsNullOrEmpty( albedo ) ) mat.AlbedoImage = albedo;
if ( !string.IsNullOrEmpty( roughness ) ) mat.RoughnessImage = roughness;
if ( !string.IsNullOrEmpty( normal ) ) mat.NormalImage = normal;
if ( !string.IsNullOrEmpty( height ) ) mat.HeightImage = height;
if ( !string.IsNullOrEmpty( ao ) ) mat.AOImage = ao;
// Tiling and displacement are the two things a user is most likely to tune by hand
// (we can't read Unreal's tiling), so only seed them on a fresh resource.
if ( isNew )
{
mat.UVScale = uvScale;
// Displacement does nothing without a height map, and the resource hides the
// field while HeightImage is still its "no height" default.
if ( mat.HasHeightTexture )
mat.DisplacementScale = 1f;
}
return asset.SaveToDisk( mat ) ? asset : null;
}
/// <summary>
/// A Decal Definition. Its rough/metal/occlusion is ONE packed map (RGB in that order),
/// not three, and the colour texture's alpha is what masks the decal.
/// </summary>
public static Asset CreateDecal( string absolutePath, string color, string normal, string rmo, string emissive, string height, float size = 32f )
{
var asset = global::Editor.AssetSystem.CreateResource( "decal", absolutePath );
if ( asset is null )
return null;
var isNew = !asset.TryLoadResource<DecalDefinition>( out var decal );
decal ??= new DecalDefinition();
decal.ColorTexture = ImageTexture( color );
decal.NormalTexture = ImageTexture( normal );
decal.RoughMetalOcclusionTexture = ImageTexture( rmo );
decal.EmissiveTexture = ImageTexture( emissive );
decal.HeightTexture = ImageTexture( height );
// Size is a pure guess on our part - don't stomp it on re-import.
if ( isNew )
{
decal.Width = size;
decal.Height = size;
// Parallax needs a height map; leave it inert when there isn't one.
decal.ParallaxStrength = decal.HeightTexture is null ? 0f : 1f;
}
return asset.SaveToDisk( decal ) ? asset : null;
}
/// <summary>
/// A Texture backed by an image file on disk. Going through ImageFileGenerator (rather
/// than Texture.Load) is what gives the texture its EmbeddedResource, which is how the
/// image path survives serialisation into the resource's json.
/// </summary>
static Texture ImageTexture( string contentPath )
{
if ( string.IsNullOrEmpty( contentPath ) )
return null;
var generator = new ImageFileGenerator { FilePath = contentPath };
return generator.FindOrCreate( ResourceGenerator.Options.Default );
}
}
Editor
library
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Editor.UnrealImporter;
/// <summary>
/// Mirrors the manifest.json produced by Tools/ue_export.py (the headless Unreal export).
/// </summary>
public class ImportManifest
{
[JsonPropertyName( "version" )] public int Version { get; set; }
[JsonPropertyName( "assets" )] public List<ManifestAsset> Assets { get; set; } = new();
/// <summary>
/// Materials selected on their own (no mesh) - each becomes a standalone .vmat.
/// Surface packs (Megascans Surfaces etc.) are nothing but these.
/// </summary>
[JsonPropertyName( "materials" )] public List<ManifestMaterial> Materials { get; set; } = new();
/// <summary>Present only for scene-mode exports (UE_EXPORT_MAP): the level's placements + lights.</summary>
[JsonPropertyName( "scene" )] public ManifestScene Scene { get; set; }
public static ImportManifest Load( string path )
{
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
return JsonSerializer.Deserialize<ImportManifest>( File.ReadAllText( path ), opts );
}
}
public class ManifestAsset
{
[JsonPropertyName( "asset" )] public string Asset { get; set; }
/// <summary>/Game package path - scene placements reference meshes by this.</summary>
[JsonPropertyName( "game_path" )] public string GamePath { get; set; }
[JsonPropertyName( "fbx" )] public string Fbx { get; set; }
[JsonPropertyName( "import_scale" )] public float ImportScale { get; set; } = 0.3937f;
[JsonPropertyName( "materials" )] public List<ManifestMaterial> Materials { get; set; } = new();
}
public class ManifestMaterial
{
/// <summary>FBX material slot name (e.g. "lambert2"). Null for standalone material imports.</summary>
[JsonPropertyName( "slot" )] public string Slot { get; set; }
/// <summary>Standalone imports only: the picked asset's name, for progress/logging.</summary>
[JsonPropertyName( "asset" )] public string Asset { get; set; }
/// <summary>Standalone imports only: the /Game package path it came from.</summary>
[JsonPropertyName( "game_path" )] public string GamePath { get; set; }
/// <summary>Source Material Instance name (e.g. "MI_CardboardBoxes_01a") - used for vmat/texture naming + dedup.</summary>
[JsonPropertyName( "material" )] public string Material { get; set; }
/// <summary>Unreal blend mode name (BLEND_OPAQUE / BLEND_MASKED / BLEND_TRANSLUCENT...). Null on old manifests.</summary>
[JsonPropertyName( "blend_mode" )] public string BlendMode { get; set; }
// Texture role -> staging-relative png path. Null when the material doesn't use that role.
[JsonPropertyName( "alb" )] public string Alb { get; set; }
[JsonPropertyName( "nrm" )] public string Nrm { get; set; }
[JsonPropertyName( "rma" )] public string Rma { get; set; }
[JsonPropertyName( "rough" )] public string Rough { get; set; }
[JsonPropertyName( "metal" )] public string Metal { get; set; }
[JsonPropertyName( "ao" )] public string Ao { get; set; }
[JsonPropertyName( "emissive" )] public string Emissive { get; set; }
[JsonPropertyName( "opacity" )] public string Opacity { get; set; }
/// <summary>Displacement/height map. complex.shader has no slot for it - recorded so we can warn.</summary>
[JsonPropertyName( "height" )] public string Height { get; set; }
/// <summary>
/// Channel layout of <see cref="Rma"/>: "rma" (R=rough G=metal B=ao, the Fab convention),
/// "orm"/"arm" (R=ao G=rough B=metal, Megascans) or "mra". Null on old manifests -> "rma".
/// </summary>
[JsonPropertyName( "rma_order" )] public string RmaOrder { get; set; }
/// <summary>Grayscale tint mask (white = full tint). Packed into the normal's alpha by the complex shader.</summary>
[JsonPropertyName( "tintmask" )] public string TintMask { get; set; }
/// <summary>Best-guess single tint color [r,g,b,a] in Unreal LINEAR space (sRGB-encode for g_vColorTint).</summary>
[JsonPropertyName( "tint_color" )] public float[] TintColor { get; set; }
/// <summary>Multi-zone tint: mask channel ("r"/"g"/"b"/"a") -> LINEAR tint [r,g,b,a]. Baked into the albedo.</summary>
[JsonPropertyName( "tint_zones" )] public Dictionary<string, float[]> TintZones { get; set; }
/// <summary>Best-guess tint amount/strength (0..1) -> g_flModelTintAmount.</summary>
[JsonPropertyName( "tint_amount" )] public float? TintAmount { get; set; }
/// <summary>All scalar parameter overrides on the Material Instance (kept for fidelity).</summary>
[JsonPropertyName( "scalar_params" )] public Dictionary<string, float> ScalarParams { get; set; }
/// <summary>All vector (color) parameter overrides [r,g,b,a] on the Material Instance.</summary>
[JsonPropertyName( "vector_params" )] public Dictionary<string, float[]> VectorParams { get; set; }
}
public class ManifestScene
{
[JsonPropertyName( "name" )] public string Name { get; set; }
[JsonPropertyName( "map" )] public string Map { get; set; }
[JsonPropertyName( "placements" )] public List<ManifestPlacement> Placements { get; set; } = new();
[JsonPropertyName( "lights" )] public List<ManifestLight> Lights { get; set; } = new();
/// <summary>Things the exporter skipped (capped scatter ISMs, landscapes...) - surfaced in the import summary.</summary>
[JsonPropertyName( "warnings" )] public List<string> Warnings { get; set; } = new();
}
/// <summary>
/// One static-mesh placement in the level. Transform is raw Unreal: centimetres,
/// left-handed X-fwd/Y-right/Z-up, quaternion xyzw. Conversion happens in ScenePrefabBuilder.
/// </summary>
public class ManifestPlacement
{
[JsonPropertyName( "mesh" )] public string Mesh { get; set; }
[JsonPropertyName( "name" )] public string Name { get; set; }
[JsonPropertyName( "pos" )] public float[] Pos { get; set; }
[JsonPropertyName( "rot" )] public float[] Rot { get; set; }
[JsonPropertyName( "scale" )] public float[] Scale { get; set; }
}
public class ManifestLight
{
/// <summary>"point", "spot" or "directional".</summary>
[JsonPropertyName( "type" )] public string Type { get; set; }
[JsonPropertyName( "name" )] public string Name { get; set; }
[JsonPropertyName( "pos" )] public float[] Pos { get; set; }
[JsonPropertyName( "rot" )] public float[] Rot { get; set; }
[JsonPropertyName( "scale" )] public float[] Scale { get; set; }
[JsonPropertyName( "color" )] public float[] Color { get; set; }
/// <summary>Raw Unreal intensity - unit depends on <see cref="Units"/> (lux for directional).</summary>
[JsonPropertyName( "intensity" )] public float? Intensity { get; set; }
/// <summary>Unreal ELightUnits name: CANDELAS / LUMENS / UNITLESS / EV. Debug info - use Candela.</summary>
[JsonPropertyName( "units" )] public string Units { get; set; }
/// <summary>Luminous intensity in candela, converted from Intensity+Units by the exporter. Point/spot only.</summary>
[JsonPropertyName( "candela" )] public float? Candela { get; set; }
/// <summary>Attenuation radius in centimetres.</summary>
[JsonPropertyName( "radius" )] public float? Radius { get; set; }
[JsonPropertyName( "inner_cone" )] public float? InnerCone { get; set; }
[JsonPropertyName( "outer_cone" )] public float? OuterCone { get; set; }
}
Editor
library
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Editor.UnrealImporter;
/// <summary>
/// Turns a manifest "scene" section (raw Unreal placements + lights) into an s&box
/// .prefab: one child GameObject per placement with a ModelRenderer pointing at the
/// imported vmdl, plus Point/Spot/Directional lights.
///
/// Coordinate conversion (Unreal cm left-handed X-fwd/Y-right/Z-up -> Source inch
/// right-handed X-fwd/Y-left/Z-up) mirrors across the XZ plane:
/// position (x, -y, z) / 2.54 quaternion mirror: (-x, y, -z, w)
///
/// The FBX mesh path adds a twist: UE's exporter negates Y and Source 2's importer
/// rotates 90°, so an imported mesh's local axes are UE's with X and Y SWAPPED
/// (verified against UE bounding boxes: sbox (x,y,z) = ue (y,x,z)/2.54). A model
/// placement must compensate: R' = S·R·M, i.e. the mirrored quaternion post-multiplied
/// by yaw -90, and non-uniform scale swaps x/y. Lights carry no mesh, so they use the
/// plain mirror.
/// </summary>
public static class ScenePrefabBuilder
{
const float UeToInch = 1f / 2.54f;
/// <summary>
/// Write <scene name>.prefab under outputRoot. modelsByGamePath maps the manifest's
/// /Game mesh paths to imported vmdl content paths; mirroredByGamePath the variants for
/// mirrored (odd-negative-scale) placements. Returns the prefab's absolute path.
/// </summary>
public static string Build( ManifestScene scene, IReadOnlyDictionary<string, string> modelsByGamePath, string outputRoot, List<string> warnings,
IReadOnlyDictionary<string, string> mirroredByGamePath = null, float lightScale = 1f )
{
var children = new JsonArray();
var missingMeshes = new HashSet<string>();
var missingMirrors = new HashSet<string>();
foreach ( var p in scene.Placements ?? new() )
{
if ( string.IsNullOrEmpty( p.Mesh ) || !modelsByGamePath.TryGetValue( p.Mesh, out var vmdl ) )
{
if ( p.Mesh is not null )
missingMeshes.Add( p.Mesh );
continue;
}
// True mirrors (odd negative axes) swap to the mirrored model variant.
if ( p.Scale is { Length: >= 3 } && NegativeCount( p.Scale ) % 2 == 1 )
{
if ( mirroredByGamePath is not null && mirroredByGamePath.TryGetValue( p.Mesh, out var mirrored ) )
vmdl = mirrored;
else
missingMirrors.Add( p.Mesh );
}
var go = GameObjectNode( p.Name, p.Pos, p.Rot, p.Scale, isMesh: true );
go["Components"] = new JsonArray( ComponentNode( "Sandbox.ModelRenderer", new()
{
["Model"] = vmdl,
["Tint"] = "1,1,1,1",
["RenderType"] = "On",
} ) );
children.Add( go );
}
foreach ( var m in missingMirrors )
warnings.Add( $"scene: no mirrored model for {m}, its flipped placements will render inside-out." );
foreach ( var l in scene.Lights ?? new() )
{
var node = LightNode( l, lightScale );
if ( node is not null )
children.Add( node );
}
foreach ( var m in missingMeshes )
warnings.Add( $"scene: no imported model for {m}, its placements were skipped." );
var root = GameObjectNode( Sanitize( scene.Name ), null, null, null );
root["Children"] = children;
var prefab = new JsonObject
{
["RootObject"] = root,
["ResourceVersion"] = 2,
["ShowInMenu"] = false,
["MenuPath"] = null,
["MenuIcon"] = null,
["DontBreakAsTemplate"] = false,
["__references"] = new JsonArray(),
["__version"] = 2,
};
var path = Path.Combine( outputRoot, Sanitize( scene.Name ) + ".prefab" );
File.WriteAllText( path, prefab.ToJsonString( new JsonSerializerOptions { WriteIndented = true } ) );
return path;
}
static JsonObject GameObjectNode( string name, float[] uePos, float[] ueRot, float[] ueScale, bool isMesh = false )
{
var scale = ueScale ?? new float[] { 1, 1, 1 };
if ( isMesh && scale.Length >= 3 )
scale = new[] { scale[1], scale[0], scale[2] }; // mesh local axes are swapped
var rot = ConvertRotation( ueRot, isMesh );
(rot, scale) = ResolveNegativeScale( rot, scale );
return new JsonObject
{
["__guid"] = Guid.NewGuid().ToString(),
["__version"] = 2,
["Flags"] = 0,
["Name"] = string.IsNullOrEmpty( name ) ? "unnamed" : name,
["Position"] = Vec3( ConvertPosition( uePos ) ),
["Rotation"] = Quat( rot ),
["Scale"] = Vec3( scale ),
["Enabled"] = true,
};
}
static int NegativeCount( float[] s ) => (s[0] < 0 ? 1 : 0) + (s[1] < 0 ? 1 : 0) + (s[2] < 0 ? 1 : 0);
// 180° rotations about local X / Y / Z, quaternion xyzw.
static readonly float[][] Rot180 = { new float[] { 1, 0, 0, 0 }, new float[] { 0, 1, 0, 0 }, new float[] { 0, 0, 1, 0 } };
/// <summary>
/// s&box doesn't flip triangle winding for negative GameObject scale, so negative axes
/// must not reach the prefab. diag(-a,-b,c) == rot180_z * diag(a,b,c): an EVEN number of
/// negative axes folds into a 180° local rotation (about the remaining positive axis).
/// An ODD count is a true mirror, served by the model's mirrored variant M = diag(-1,1,1)
/// (ScaleAndMirror across local X). The sign pattern factors as sign = M * Q with Q a
/// 180° rotation (sign matrices commute): negative x -> Q = identity; negative y ->
/// rot180_z; negative z -> rot180_y; all three -> rot180_x. Callers pick the mirrored
/// model for odd counts.
/// </summary>
static (float[] rot, float[] scale) ResolveNegativeScale( float[] rot, float[] scale )
{
if ( scale.Length < 3 || NegativeCount( scale ) == 0 )
return (rot, scale);
int negatives = NegativeCount( scale );
int axis = -1;
if ( negatives == 2 )
{
axis = Array.FindIndex( scale, v => v >= 0 ); // rotate about the positive axis
}
else if ( negatives == 1 )
{
// X-mirrored model: sign pattern (-,+,+) is the model itself; (+,-,+) needs
// rot180 about Z on top of it; (+,+,-) rot180 about Y.
int neg = Array.FindIndex( scale, v => v < 0 );
axis = neg switch { 1 => 2, 2 => 1, _ => -1 };
}
else if ( negatives == 3 )
{
axis = 0; // (-,-,-) = M * rot180_x
}
if ( axis >= 0 )
rot = MulQuat( rot, Rot180[axis] );
return (rot, new[] { Math.Abs( scale[0] ), Math.Abs( scale[1] ), Math.Abs( scale[2] ) });
}
static JsonObject ComponentNode( string type, JsonObject properties )
{
var node = new JsonObject
{
["__type"] = type,
["__guid"] = Guid.NewGuid().ToString(),
["__enabled"] = true,
["Flags"] = 0,
};
foreach ( var kv in properties )
node[kv.Key] = kv.Value?.DeepClone();
return node;
}
static JsonObject LightNode( ManifestLight l, float lightScale )
{
var (type, props) = l.Type switch
{
"point" => ("Sandbox.PointLight", new JsonObject
{
["LightColor"] = ColorStr( l, lightScale ),
["Radius"] = Round( (l.Radius ?? 1000f) * UeToInch ),
}),
"spot" => ("Sandbox.SpotLight", new JsonObject
{
["LightColor"] = ColorStr( l, lightScale ),
["Radius"] = Round( (l.Radius ?? 1000f) * UeToInch ),
["ConeInner"] = Round( l.InnerCone ?? 30f ),
["ConeOuter"] = Round( l.OuterCone ?? 45f ),
}),
"directional" => ("Sandbox.DirectionalLight", new JsonObject
{
["LightColor"] = ColorStr( l, lightScale ),
["Shadows"] = true,
}),
_ => (null, null),
};
if ( type is null )
return null;
var go = GameObjectNode( l.Name ?? l.Type, l.Pos, l.Rot, null );
go["Components"] = new JsonArray( ComponentNode( type, props ) );
return go;
}
static float[] ConvertPosition( float[] p )
{
if ( p is null || p.Length < 3 )
return new float[] { 0, 0, 0 };
return new[] { p[0] * UeToInch, -p[1] * UeToInch, p[2] * UeToInch };
}
// Yaw -90: compensates the X<->Y swap the FBX mesh pipeline bakes into mesh space.
static readonly float[] MeshAxisFix = { 0, 0, -0.70710678f, 0.70710678f };
static float[] ConvertRotation( float[] q, bool isMesh )
{
if ( q is null || q.Length < 4 )
q = new float[] { 0, 0, 0, 1 };
var mirrored = new[] { -q[0], q[1], -q[2], q[3] };
return isMesh ? MulQuat( mirrored, MeshAxisFix ) : mirrored;
}
/// <summary>Hamilton product a*b (xyzw): rotation b in local space followed by a.</summary>
static float[] MulQuat( float[] a, float[] b )
{
return new[]
{
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
};
}
/// <summary>
/// A ~1000 cd source (strong ceiling fixture) maps to HDR magnitude 1.0. Calibrated
/// visually against the CCA subway terminal: UE relies on auto-exposure to pull
/// physically-lit interiors (dozens of overlapping 500-1000 cd lights) down to
/// comfortable levels, s&box doesn't - mapping generously blows every surface to
/// white. Hand-placed lights in this project sit at ~0.35-2 HDR magnitude.
/// </summary>
const float RefCandela = 1000f;
/// <summary>
/// Legacy UNITLESS lights aren't physical: UE4-scale authoring puts a strong lamp
/// around 1000-5000. Converting them through UE's official unitless->candela factor
/// (16/10000) lands at fractions of a candela and everything goes black, so they get
/// their own perceptual reference instead (calibrated with the same 0.4 factor as
/// the candela path).
/// </summary>
const float RefUnitless = 5000f;
/// <summary>
/// s&box lights carry brightness in LightColor's HDR magnitude. Scale the Unreal
/// chroma by intensity: candela for physically-united point/spot lights, a UE4-scale
/// heuristic for UNITLESS ones, lux for directional. Raw UE intensities are
/// unit-dependent - comparing them without units is what made imports blinding.
/// sqrt compresses the huge dynamic range of authored UE values.
/// </summary>
static string ColorStr( ManifestLight l, float lightScale )
{
var c = l.Color is { Length: >= 3 } ? l.Color : new float[] { 1, 1, 1 };
float brightness;
if ( l.Type == "directional" && l.Intensity is > 0 )
brightness = Math.Clamp( MathF.Sqrt( l.Intensity.Value / 5f ), 0.5f, 2.5f ); // lux; UE legacy suns sit ~2-15
else if ( l.Units?.StartsWith( "UNITLESS" ) == true && l.Intensity is > 0 )
brightness = Math.Clamp( MathF.Sqrt( l.Intensity.Value / RefUnitless ), 0.05f, 2f );
else if ( l.Candela is > 0 )
brightness = Math.Clamp( MathF.Sqrt( l.Candela.Value / RefCandela ), 0.05f, 2f );
else if ( l.Intensity is > 0 )
brightness = Math.Clamp( MathF.Sqrt( l.Intensity.Value / 8f ), 0.25f, 4f ); // old manifests: unit unknown
else
brightness = 1f;
brightness = Math.Clamp( brightness * lightScale, 0.02f, 4f );
return $"{F( c[0] * brightness )},{F( c[1] * brightness )},{F( c[2] * brightness )},1";
}
static string Vec3( float[] v ) => $"{F( v[0] )},{F( v[1] )},{F( v[2] )}";
static string Quat( float[] q ) => $"{F( q[0] )},{F( q[1] )},{F( q[2] )},{F( q[3] )}";
static float Round( float v ) => (float)Math.Round( v, 3 );
static string F( float v ) => v.ToString( "0.######", CultureInfo.InvariantCulture );
static string Sanitize( string s )
{
if ( string.IsNullOrEmpty( s ) )
return "unnamed_scene";
var chars = s.ToLowerInvariant().ToCharArray();
for ( int i = 0; i < chars.Length; i++ )
{
var ch = chars[i];
if ( ch is not ((>= 'a' and <= 'z') or (>= '0' and <= '9') or '_') )
chars[i] = '_';
}
return new string( chars );
}
}
Editor
library
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Editor.UnrealImporter;
public class MeshStats
{
public long Triangles { get; set; } = -1;
public long Vertices { get; set; } = -1;
public long Materials { get; set; } = -1;
public long LODs { get; set; } = -1;
/// <summary>Source file stamp this was read from - stale entries re-parse.</summary>
public long Mtime { get; set; }
public long Size { get; set; }
}
/// <summary>
/// Reads triangle/vertex counts straight out of an uncooked StaticMesh .uasset, no Unreal
/// involved. The Unreal editor bakes asset-registry tags ("Triangles", "Vertices",
/// "Materials", "LODs", ...) into every saved package as serialized FString key/value
/// pairs: int32 length (incl. NUL), ascii chars, NUL. Rather than parsing the
/// version-dependent FPackageFileSummary to find the block, we scan for that
/// self-contained byte pattern - same magic-scan approach the thumbnail extractor uses,
/// verified against UE 5.x Fab packs.
///
/// Cached in memory and on disk (.sbox/unrealimporter/meshstats.json, keyed mtime+size)
/// because a full pack means gigabytes of .uasset reads otherwise.
/// </summary>
public static class UassetMeshStats
{
// Registry tags live in the package header tables, which sit well before the bulk
// mesh data - reading the head of the file is nearly always enough.
const int HeaderReadBytes = 4 * 1024 * 1024;
static readonly ConcurrentDictionary<string, MeshStats> cache = new( StringComparer.OrdinalIgnoreCase );
static readonly ConcurrentDictionary<string, Task<MeshStats>> inFlight = new( StringComparer.OrdinalIgnoreCase );
static readonly SemaphoreSlim ioGate = new( 2 ); // don't hammer the disk when a folder expands
static bool diskCacheLoaded;
static int saveScheduled;
static string CacheFile => Sandbox.Project.Current is not null
? Path.Combine( Sandbox.Project.Current.GetRootPath(), ".sbox", "unrealimporter", "meshstats.json" )
: Path.Combine( Path.GetTempPath(), "unrealimporter", "meshstats.json" );
/// <summary>Memory/disk cache lookup, no file IO on the asset itself.</summary>
public static bool TryGetCached( string absPath, out MeshStats stats )
{
LoadDiskCache();
if ( cache.TryGetValue( absPath, out stats ) )
{
var fi = new FileInfo( absPath );
if ( fi.Exists && fi.LastWriteTimeUtc.Ticks == stats.Mtime && fi.Length == stats.Size )
return true;
cache.TryRemove( absPath, out _ );
stats = null;
}
return false;
}
/// <summary>Parse (or fetch cached) stats for one .uasset. Null when nothing was found.</summary>
public static Task<MeshStats> LoadAsync( string absPath )
{
if ( TryGetCached( absPath, out var cached ) )
return Task.FromResult( cached );
return inFlight.GetOrAdd( absPath, p => Task.Run( async () =>
{
try
{
await ioGate.WaitAsync();
try
{
var stats = Parse( p );
if ( stats is not null )
{
cache[p] = stats;
ScheduleSave();
}
return stats;
}
finally
{
ioGate.Release();
}
}
catch
{
return null;
}
finally
{
inFlight.TryRemove( p, out _ );
}
} ) );
}
static MeshStats Parse( string absPath )
{
var fi = new FileInfo( absPath );
if ( !fi.Exists )
return null;
var data = ReadHead( absPath, HeaderReadBytes );
var tris = TagValue( data, "Triangles" );
// Rare: huge header tables push the tag block past our head read.
if ( tris < 0 && fi.Length > data.Length )
{
data = File.ReadAllBytes( absPath );
tris = TagValue( data, "Triangles" );
}
if ( tris < 0 )
return null;
return new MeshStats
{
Triangles = tris,
Vertices = TagValue( data, "Vertices" ),
Materials = TagValue( data, "Materials" ),
LODs = TagValue( data, "LODs" ),
Mtime = fi.LastWriteTimeUtc.Ticks,
Size = fi.Length,
};
}
static byte[] ReadHead( string path, int maxBytes )
{
using var fs = File.OpenRead( path );
var len = (int)Math.Min( fs.Length, maxBytes );
var buf = new byte[len];
fs.ReadExactly( buf, 0, len );
return buf;
}
/// <summary>
/// Find asset-registry tag <paramref name="key"/> and return its numeric value, -1 when
/// absent. Matches the FString serialization (length prefix + NUL) so plain-text
/// occurrences of the word elsewhere can't false-positive.
/// </summary>
static long TagValue( ReadOnlySpan<byte> data, string key )
{
Span<byte> pattern = stackalloc byte[4 + key.Length + 1];
BitConverter.TryWriteBytes( pattern, key.Length + 1 );
Encoding.ASCII.GetBytes( key, pattern[4..] );
pattern[^1] = 0;
var at = data.IndexOf( pattern );
if ( at < 0 )
return -1;
var vpos = at + pattern.Length;
if ( vpos + 4 > data.Length )
return -1;
int vlen = BitConverter.ToInt32( data[vpos..] );
if ( vlen <= 1 || vlen > 64 || vpos + 4 + vlen > data.Length )
return -1;
var s = Encoding.ASCII.GetString( data.Slice( vpos + 4, vlen - 1 ) );
return long.TryParse( s, out var v ) ? v : -1;
}
// ---- disk cache ----
static void LoadDiskCache()
{
if ( diskCacheLoaded )
return;
diskCacheLoaded = true;
try
{
if ( !File.Exists( CacheFile ) )
return;
var loaded = JsonSerializer.Deserialize<ConcurrentDictionary<string, MeshStats>>( File.ReadAllText( CacheFile ) );
if ( loaded is null )
return;
foreach ( var kv in loaded )
cache.TryAdd( kv.Key, kv.Value );
}
catch
{
// cache is disposable - a corrupt file just means re-parsing
}
}
static void ScheduleSave()
{
if ( Interlocked.Exchange( ref saveScheduled, 1 ) == 1 )
return;
_ = Task.Run( async () =>
{
await Task.Delay( 3000 );
Interlocked.Exchange( ref saveScheduled, 0 );
try
{
Directory.CreateDirectory( Path.GetDirectoryName( CacheFile ) );
File.WriteAllText( CacheFile, JsonSerializer.Serialize( cache ) );
}
catch
{
}
} );
}
}
Editor
library
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
namespace Editor.UnrealImporter;
/// <summary>
/// Locates a .uproject's engine and the UnrealEditor-Cmd.exe used to run the headless export.
/// </summary>
public static class UnrealLocator
{
public static string FindUprojectInFolder( string folder )
{
if ( string.IsNullOrEmpty( folder ) || !Directory.Exists( folder ) )
return null;
return Directory.GetFiles( folder, "*.uproject", SearchOption.TopDirectoryOnly ).FirstOrDefault();
}
/// <summary>Reads "EngineAssociation" (e.g. "5.5") from a .uproject. May be null.</summary>
public static string ReadEngineAssociation( string uprojectPath )
{
try
{
using var doc = JsonDocument.Parse( File.ReadAllText( uprojectPath ) );
if ( doc.RootElement.TryGetProperty( "EngineAssociation", out var e ) )
return e.GetString();
}
catch { }
return null;
}
/// <summary>
/// Find UnrealEditor-Cmd.exe, preferring the version the project targets.
/// Tries the registry first, then scans the standard Epic Games install root.
/// When the exact version isn't installed, prefers the CLOSEST NEWER engine
/// (a newer engine opens older assets; an older one can't read newer assets),
/// falling back to the highest older install.
/// </summary>
public static string FindEditorCmd( string engineVersion )
{
var fromReg = FromRegistry( engineVersion );
if ( fromReg != null )
return fromReg;
var roots = new[]
{
Environment.GetEnvironmentVariable( "ProgramW6432" ),
Environment.GetEnvironmentVariable( "ProgramFiles" ),
}.Where( x => !string.IsNullOrEmpty( x ) ).Distinct();
Version.TryParse( engineVersion ?? "", out var wanted );
foreach ( var pf in roots )
{
var epic = Path.Combine( pf, "Epic Games" );
if ( !Directory.Exists( epic ) )
continue;
if ( !string.IsNullOrEmpty( engineVersion ) )
{
var exact = CmdPath( Path.Combine( epic, $"UE_{engineVersion}" ) );
if ( File.Exists( exact ) )
return exact;
}
var installed = Directory.GetDirectories( epic, "UE_*" )
.Where( d => File.Exists( CmdPath( d ) ) )
.Select( d => (dir: d, ver: Version.TryParse( Path.GetFileName( d )["UE_".Length..], out var v ) ? v : null) )
.Where( x => x.ver is not null )
.ToList();
if ( installed.Count == 0 )
continue;
var pick = wanted is not null
? installed.Where( x => x.ver >= wanted ).OrderBy( x => x.ver ).FirstOrDefault().dir
?? installed.OrderByDescending( x => x.ver ).First().dir
: installed.OrderByDescending( x => x.ver ).First().dir;
return CmdPath( pick );
}
return null;
}
static string CmdPath( string engineRoot )
=> Path.Combine( engineRoot, "Engine", "Binaries", "Win64", "UnrealEditor-Cmd.exe" );
static string FromRegistry( string engineVersion )
{
if ( string.IsNullOrEmpty( engineVersion ) )
return null;
try
{
using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(
$@"SOFTWARE\EpicGames\Unreal Engine\{engineVersion}" );
if ( key?.GetValue( "InstalledDirectory" ) is string dir && !string.IsNullOrEmpty( dir ) )
{
var cmd = CmdPath( dir );
if ( File.Exists( cmd ) )
return cmd;
}
}
catch { }
return null;
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;
namespace Editor.UnrealImporter;
public class ImportSummary
{
public int Models;
public int Materials;
public int Textures;
public string OutputDir;
public List<string> Warnings = new();
/// <summary>Scene mode: placements in the generated prefab, and where it was written.</summary>
public int Placements;
public string PrefabPath;
}
/// <summary>How generated assets are laid out on disk.</summary>
public enum ImportLayout
{
/// <summary><output>/models, /materials, /textures.</summary>
Grouped,
/// <summary>Everything directly in <output>.</summary>
Flat,
/// <summary>
/// One self-contained folder per imported asset: <output>/<asset>/ holds its
/// model, materials and textures together. Shared materials are duplicated into each
/// asset's folder - that's the point, each folder can be moved or deleted on its own.
/// </summary>
PerAsset,
/// <summary>
/// Classic Source style: Assets/models/<sub> for fbx+vmdl, Assets/materials/<sub> for
/// vmat+textures, Assets/prefabs/<sub> for map prefabs. Ignores the picked output folder.
/// </summary>
ClassicSource,
}
/// <summary>
/// What a material picked on its own turns into. Materials on a MESH are always .vmat -
/// a model's material slots can't reference a terrain or decal resource.
/// </summary>
public enum MaterialOutput
{
/// <summary>A complex.shader .vmat (the default).</summary>
Material,
/// <summary>A .tmat Terrain Material - for tiling ground surfaces.</summary>
Terrain,
/// <summary>A .decal Decal Definition - projected decals.</summary>
Decal,
}
/// <summary>Where each kind of generated file goes.</summary>
public class ImportPaths
{
public string ModelsDir;
public string MaterialsDir;
public string TexturesDir;
public string PrefabDir;
/// <summary>What to show the user as "where it went".</summary>
public string Display;
}
/// <summary>
/// Consumes a staging folder (FBX + PNG + manifest.json from the headless export) and writes
/// sbox assets (.fbx + .vmat + .vmdl) into the project, ready for the engine to compile.
/// </summary>
public static class AssetImporter
{
/// <summary>
/// Resolve the destination folders for a layout. Classic Source hangs off the Assets root
/// (type first, then subfolder) rather than off the picked output folder.
/// </summary>
public static ImportPaths ResolvePaths( string outputRoot, string assetsDir, ImportLayout layout, string subfolder )
{
switch ( layout )
{
case ImportLayout.Flat:
return new ImportPaths
{
ModelsDir = outputRoot,
MaterialsDir = outputRoot,
TexturesDir = outputRoot,
PrefabDir = outputRoot,
Display = outputRoot,
};
case ImportLayout.PerAsset:
// These are the ROOT - Import() appends the per-asset folder as it goes.
return new ImportPaths
{
ModelsDir = outputRoot,
MaterialsDir = outputRoot,
TexturesDir = outputRoot,
PrefabDir = outputRoot,
Display = Path.Combine( outputRoot, "<asset>" ),
};
case ImportLayout.ClassicSource:
{
// Empty subfolder is legal - assets land straight in Assets/models, Assets/materials.
var sub = SanitizeSubfolder( subfolder );
string Under( string type ) => string.IsNullOrEmpty( sub )
? Path.Combine( assetsDir, type )
: Path.Combine( assetsDir, type, sub );
var models = Under( "models" );
var materials = Under( "materials" );
return new ImportPaths
{
ModelsDir = models,
// Textures live beside the vmats that reference them.
MaterialsDir = materials,
TexturesDir = materials,
PrefabDir = Under( "prefabs" ),
Display = $"{models}\n{materials}",
};
}
default:
return new ImportPaths
{
ModelsDir = Path.Combine( outputRoot, "models" ),
MaterialsDir = Path.Combine( outputRoot, "materials" ),
TexturesDir = Path.Combine( outputRoot, "textures" ),
PrefabDir = outputRoot,
Display = outputRoot,
};
}
}
/// <summary>Trim a user-typed subfolder to a safe relative path ("Props/Barrels" stays nested).</summary>
static string SanitizeSubfolder( string subfolder )
{
if ( string.IsNullOrWhiteSpace( subfolder ) )
return "";
var parts = subfolder.Split( new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries )
.Select( p => p.Trim() )
.Where( p => p.Length > 0 && p != "." && p != ".." )
.Select( Sanitize );
return string.Join( Path.DirectorySeparatorChar, parts );
}
/// <param name="manifest"></param>
/// <param name="stagingDir"></param>
/// <param name="outputRoot"></param>
/// <param name="progressToken"></param>
/// <param name="layout">How the generated files are foldered - see <see cref="ImportLayout"/>.</param>
/// <param name="subfolder">Subfolder under Assets/models + Assets/materials, ClassicSource layout only.</param>
/// <param name="onProgress">(done, total, current asset name) per imported model.</param>
/// <param name="generateLods">When false, models get no auto-LOD chain (full detail always).</param>
/// <param name="lightScale">Extra multiplier on converted scene-light brightness (1 = calibrated default).</param>
/// <param name="materialOutput">What standalone materials become - vmat, tmat or decal. Mesh slots are always vmat.</param>
/// <param name="perAssetFolderDepth">
/// PerAsset layout only: how many folders up the /Game path to name each asset's folder after.
/// 0 = the asset's own name (e.g. mi_sjfnbeaa). Fab/Megascans bury the real name a couple of
/// folders up (.../Fine_American_Road_sjfnbeaa/Medium/MI_sjfnbeaa), so 2 gives a readable folder.
/// </param>
/// <param name="maxTextureSize">Cap every written texture's longest edge, downscaling bigger sources (0 = keep as-is).</param>
public static async Task<ImportSummary> Import( ImportManifest manifest, string stagingDir, string outputRoot, CancellationToken progressToken, ImportLayout layout = ImportLayout.Grouped, string subfolder = null, Action<int, int, string> onProgress = null, bool generateLods = true, float lightScale = 1f, MaterialOutput materialOutput = MaterialOutput.Material, int perAssetFolderDepth = 0, int maxTextureSize = 0 )
{
var assetsDir = FindAssetsDir( outputRoot ) ?? Sandbox.Project.Current?.GetAssetsPath();
if ( string.IsNullOrEmpty( assetsDir ) )
throw new Exception( "Could not resolve the project's Assets folder. Pick an output folder inside Assets/." );
var paths = ResolvePaths( outputRoot, assetsDir, layout, subfolder );
var summary = new ImportSummary { OutputDir = paths.Display };
Directory.CreateDirectory( paths.ModelsDir );
Directory.CreateDirectory( paths.MaterialsDir );
Directory.CreateDirectory( paths.TexturesDir );
// PerAsset puts every asset in its own self-contained folder; every other layout
// shares one set of directories for the whole import. The folder is named after a
// parent of the /Game path (perAssetFolderDepth up) when the asset's own name is
// unhelpful - Fab MIs are named like "mi_sjfnbeaa".
(string models, string materials, string textures) DirsFor( string ownName, string gamePath )
{
if ( layout != ImportLayout.PerAsset )
return (paths.ModelsDir, paths.MaterialsDir, paths.TexturesDir);
var dir = Path.Combine( paths.ModelsDir, PerAssetFolder( gamePath, ownName, perAssetFolderDepth ) );
Directory.CreateDirectory( dir );
return (dir, dir, dir);
}
// Track materials we've already written so shared ones are processed once. Keyed by
// folder too: under PerAsset the same material is deliberately written into each
// asset's folder, so the name alone would wrongly dedupe it away.
var writtenVmats = new Dictionary<string, string>(); // "<dir>|<base>" -> vmat content path
var modelsByGamePath = new Dictionary<string, string>(); // /Game path -> vmdl content path
var mirroredByGamePath = new Dictionary<string, string>(); // /Game path -> mirrored vmdl content path
// Scene placements with an odd number of negative scale axes are true mirrors -
// s&box doesn't flip winding for negative GameObject scale, so those need a
// mirrored model variant (negative vmdl import_scale bakes the mirror + winding).
// Progress spans meshes then standalone materials as one run.
var totalAssets = manifest.Assets.Count + (manifest.Materials?.Count ?? 0);
var needsMirror = new HashSet<string>();
foreach ( var p in manifest.Scene?.Placements ?? new() )
{
if ( p.Mesh is not null && p.Scale is { Length: >= 3 } && p.Scale.Count( v => v < 0 ) % 2 == 1 )
needsMirror.Add( p.Mesh );
}
for ( int i = 0; i < manifest.Assets.Count; i++ )
{
var asset = manifest.Assets[i];
progressToken.ThrowIfCancellationRequested();
// Everything below is synchronous and slow (per-pixel texture passes, model
// compiles), so hand the editor's event loop a chance to repaint between assets -
// without this the whole import is one frozen window with a stale progress bar.
await Task.Delay( 1, progressToken );
onProgress?.Invoke( i + 1, totalAssets, asset.Asset );
if ( string.IsNullOrEmpty( asset.Fbx ) )
{
summary.Warnings.Add( $"{asset.Asset}: no fbx in manifest, skipped." );
continue;
}
// Copy the mesh.
var fbxSrc = Path.Combine( stagingDir, asset.Fbx.Replace( '/', Path.DirectorySeparatorChar ) );
if ( !File.Exists( fbxSrc ) )
{
summary.Warnings.Add( $"{asset.Asset}: fbx missing at {fbxSrc}, skipped." );
continue;
}
var modelName = Sanitize( asset.Asset );
var (modelsDir, materialsDir, texturesDir) = DirsFor( modelName, asset.GamePath );
var fbxDst = Path.Combine( modelsDir, modelName + ".fbx" );
File.Copy( fbxSrc, fbxDst, overwrite: true );
// Build per-slot remaps, writing vmats + textures as needed.
var remaps = new List<(string slot, string vmat)>();
foreach ( var mat in asset.Materials )
{
var baseName = MaterialBaseName( mat );
// A single material is seconds of texture work at 4K, and a mesh can have a
// dozen - report each one, or the asset line alone looks stalled.
onProgress?.Invoke( i + 1, totalAssets, $"{asset.Asset} - {baseName}" );
await Task.Delay( 1, progressToken );
// s&box reads the FBX material *node* name, which Unreal writes as the assigned
// material (the MI, e.g. "MI_OilBarrel_01a") - NOT the DCC slot label ("lambert2",
// which ends up unused). So the remap must key off the material name.
var remapKey = !string.IsNullOrEmpty( mat.Material ) ? mat.Material : mat.Slot;
var vmatContent = await WriteVmat( mat, baseName, stagingDir, assetsDir, materialsDir, texturesDir, writtenVmats, summary, progressToken, maxTextureSize );
remaps.Add( (remapKey, vmatContent) );
}
// UE's FBX exporter names material nodes after the assigned material - when two
// slots share one material, the FBX SDK uniquifies the duplicates with numeric
// suffixes (MI_Escalator_01a + MI_Escalator_01a_3). Those suffixed nodes need
// remaps too, or the engine hunts for a literal "mi_escalator_01a_3.vmat".
remaps.AddRange( SuffixedRemaps( fbxDst, remaps ) );
onProgress?.Invoke( i + 1, totalAssets, $"{asset.Asset} - compiling model" );
await Task.Delay( 1, progressToken );
// Write the model, then verify it compiles. Hull-from-render chokes on some
// geometry (dense foliage cards -> "Inconsistent hull geometry"), so fall back
// to a single hull, then to no collision, until the model compiles.
var fbxContent = ToContentPath( assetsDir, fbxDst );
var vmdlPath = Path.Combine( modelsDir, modelName + ".vmdl" );
var scale = asset.ImportScale <= 0 ? 0.3937f : asset.ImportScale;
string usedHullMode = null;
foreach ( var hullMode in new[] { "HullPerElement", "SingleHull", null } )
{
await File.WriteAllTextAsync( vmdlPath, Kv3Writer.VmdlText( fbxContent, scale, remaps, hullMode, lods: generateLods ), progressToken );
var vmdlAsset = global::Editor.AssetSystem.RegisterFile( vmdlPath );
if ( vmdlAsset is null )
break; // can't verify here - leave the default and let the engine compile later
if ( vmdlAsset.Compile( full: false ) && !vmdlAsset.IsCompileFailed )
{
usedHullMode = hullMode;
break;
}
if ( hullMode is null )
summary.Warnings.Add( $"{asset.Asset}: model failed to compile even without collision - see console." );
else
summary.Warnings.Add( $"{asset.Asset}: collision '{hullMode}' failed to compile, falling back to {(hullMode == "HullPerElement" ? "SingleHull" : "no collision")}." );
}
summary.Models++;
if ( !string.IsNullOrEmpty( asset.GamePath ) )
modelsByGamePath[asset.GamePath] = ToContentPath( assetsDir, vmdlPath );
// Mirrored variant for placements that flip this mesh. Uses the ScaleAndMirror
// model modifier (flip across local X, winding corrected) - NOT a negative
// import_scale, which mirrors the verts but leaves faces wound inside-out.
// The prefab builder composes a 180° rotation to turn the X-flip into whatever
// mirror the placement actually wants.
if ( asset.GamePath is not null && needsMirror.Contains( asset.GamePath ) )
{
var mirrorPath = Path.Combine( modelsDir, modelName + "_mirror.vmdl" );
await File.WriteAllTextAsync( mirrorPath, Kv3Writer.VmdlText( fbxContent, scale, remaps, usedHullMode ?? "HullPerElement", mirror: true, lods: generateLods ), progressToken );
var mirrorAsset = global::Editor.AssetSystem.RegisterFile( mirrorPath );
if ( mirrorAsset is not null && (!mirrorAsset.Compile( full: false ) || mirrorAsset.IsCompileFailed) )
summary.Warnings.Add( $"{asset.Asset}: mirrored variant failed to compile - mirrored placements will use the unmirrored model." );
else
mirroredByGamePath[asset.GamePath] = ToContentPath( assetsDir, mirrorPath );
}
Log.Info( $"[{i + 1}/{manifest.Assets.Count}] Imported {asset.Asset} -> {vmdlPath}" +
(usedHullMode != "HullPerElement" ? $" (collision: {usedHullMode ?? "none"})" : "") );
}
// A model's material slots have to be vmats, so a terrain/decal choice only applies to
// the standalone materials - say so rather than leaving the user to wonder.
if ( materialOutput != MaterialOutput.Material && manifest.Assets.Count > 0 )
summary.Warnings.Add( $"Material output '{materialOutput}' applies to materials imported on their own; the {manifest.Assets.Count} mesh(es) still got .vmat materials." );
// Materials picked on their own: no mesh, just a vmat + its textures. Surface packs
// (Megascans Surfaces) consist of nothing else.
foreach ( var mat in manifest.Materials ?? new() )
{
progressToken.ThrowIfCancellationRequested();
var name = mat.Asset ?? mat.Material ?? "material";
onProgress?.Invoke( manifest.Assets.Count + manifest.Materials.IndexOf( mat ) + 1, totalAssets, name );
await Task.Delay( 1, progressToken );
var baseName = MaterialBaseName( mat );
// A standalone material is its own asset, so PerAsset gives it its own folder.
var (_, matDir, texDir) = DirsFor( baseName, mat.GamePath );
string written;
if ( materialOutput == MaterialOutput.Terrain )
{
written = WriteTerrainMaterial( mat, baseName, stagingDir, assetsDir, matDir, texDir, summary, maxTextureSize );
}
else if ( materialOutput == MaterialOutput.Decal )
{
written = WriteDecal( mat, baseName, stagingDir, assetsDir, matDir, texDir, summary, maxTextureSize );
}
else
{
written = await WriteVmat( mat, baseName, stagingDir, assetsDir, matDir, texDir, writtenVmats, summary, progressToken, maxTextureSize );
// A vmat is just a file we wrote - nothing compiles it for us here (a model
// would have pulled it in), so register it or it won't show in the asset
// browser until a rescan. The GameResource paths are saved through the asset
// system already, which registers and compiles them.
global::Editor.AssetSystem.RegisterFile( Path.Combine( matDir, baseName + ".vmat" ) );
}
Log.Info( $"Imported material {name} -> {written}" );
}
// Scene mode: turn the level's placements + lights into a prefab next to the models.
if ( manifest.Scene is not null )
{
if ( manifest.Scene.Warnings is { Count: > 0 } )
summary.Warnings.AddRange( manifest.Scene.Warnings );
Directory.CreateDirectory( paths.PrefabDir );
summary.PrefabPath = ScenePrefabBuilder.Build( manifest.Scene, modelsByGamePath, paths.PrefabDir, summary.Warnings, mirroredByGamePath );
summary.Placements = manifest.Scene.Placements?.Count ?? 0;
}
return summary;
}
/// <summary>
/// Write (or reuse) the .vmat for one material, processing its textures on the way.
/// Returns the vmat's content path. Shared by mesh slots and standalone material imports.
/// </summary>
static async Task<string> WriteVmat( ManifestMaterial mat, string baseName, string stagingDir, string assetsDir,
string materialsDir, string texturesDir, Dictionary<string, string> writtenVmats, ImportSummary summary, CancellationToken token, int maxTextureSize = 0 )
{
var cacheKey = $"{materialsDir}|{baseName}";
if ( writtenVmats.TryGetValue( cacheKey, out var existing ) )
return existing;
var emissive = EmissiveParams( mat );
var alphaRole = AlphaRoleFor( mat, emissive is not null );
var tex = TextureProcessor.Process( mat, stagingDir, texturesDir, baseName, alphaRole, maxTextureSize: maxTextureSize );
summary.Textures += CountTextures( tex );
// Self-illum source: dedicated emissive texture wins, else the albedo-alpha mask.
var selfIllumMask = tex.Emissive ?? tex.SelfIllumMask;
var vmatText = Kv3Writer.VmatText(
color: TexContent( assetsDir, texturesDir, tex.Color ),
normal: TexContent( assetsDir, texturesDir, tex.Normal ),
roughness: TexContent( assetsDir, texturesDir, tex.Roughness ),
metallic: TexContent( assetsDir, texturesDir, tex.Metallic ),
ao: TexContent( assetsDir, texturesDir, tex.Ao ),
alpha: TexContent( assetsDir, texturesDir, tex.Alpha ),
// Tint stays INERT by default (white) so the albedo's own colours show through.
// The mask + captured tint colours are emitted for optional manual recolouring.
tintMask: TexContent( assetsDir, texturesDir, tex.TintMask ),
tintColor: null,
tintAmount: null,
tintComment: TintComment( mat ),
alphaTest: mat.BlendMode?.Contains( "MASKED" ) == true,
selfIllumMask: TexContent( assetsDir, texturesDir, selfIllumMask ),
selfIllumTint: emissive?.tint,
selfIllumBrightness: emissive?.magnitude ?? 1f,
selfIllumFromAlbedoAlpha: tex.Emissive is null && tex.SelfIllumMask is not null );
var vmatPath = Path.Combine( materialsDir, baseName + ".vmat" );
await File.WriteAllTextAsync( vmatPath, vmatText, token );
summary.Materials++;
// complex.shader has no displacement input - say so rather than silently dropping it.
if ( !string.IsNullOrEmpty( mat.Height ) )
summary.Warnings.Add( $"{baseName}: has a displacement/height map, which complex.shader can't use - ignored." );
var content = ToContentPath( assetsDir, vmatPath );
writtenVmats[cacheKey] = content;
return content;
}
/// <summary>
/// Write a .tmat Terrain Material. Terrain wants separate grayscale maps plus the height
/// map (which the vmat path has no slot for), and carries metalness as a scalar - so a
/// metallic texture has nowhere to go and is reported rather than silently dropped.
/// </summary>
static string WriteTerrainMaterial( ManifestMaterial mat, string baseName, string stagingDir, string assetsDir,
string materialsDir, string texturesDir, ImportSummary summary, int maxTextureSize = 0 )
{
var tex = TextureProcessor.Process( mat, stagingDir, texturesDir, baseName, AlphaRole.Ignore, packRmo: false, wantHeight: true, maxTextureSize: maxTextureSize );
summary.Textures += CountTextures( tex );
var path = Path.Combine( materialsDir, baseName + ".tmat" );
var asset = GameResourceWriter.CreateTerrainMaterial( path,
albedo: TexContent( assetsDir, texturesDir, tex.Color ),
roughness: TexContent( assetsDir, texturesDir, tex.Roughness ),
normal: TexContent( assetsDir, texturesDir, tex.Normal ),
height: TexContent( assetsDir, texturesDir, tex.Height ),
ao: TexContent( assetsDir, texturesDir, tex.Ao ) );
if ( asset is null )
{
summary.Warnings.Add( $"{baseName}: failed to create the terrain material - see console." );
return ToContentPath( assetsDir, path );
}
summary.Materials++;
if ( tex.Metallic is not null )
summary.Warnings.Add( $"{baseName}: terrain materials carry metalness as a single value, not a texture - the metallic map was not used." );
if ( tex.Height is null )
summary.Warnings.Add( $"{baseName}: no height/displacement map found - terrain height blending will be flat." );
return asset.Path;
}
/// <summary>
/// Write a .decal Decal Definition. Decals take ONE packed rough/metal/occlusion map
/// rather than three, and are masked by the colour texture's alpha - so an opaque source
/// material makes a decal that covers its whole quad.
/// </summary>
static string WriteDecal( ManifestMaterial mat, string baseName, string stagingDir, string assetsDir,
string materialsDir, string texturesDir, ImportSummary summary, int maxTextureSize = 0 )
{
// Keep the albedo's alpha as the decal mask whatever the Unreal blend mode says.
var tex = TextureProcessor.Process( mat, stagingDir, texturesDir, baseName, AlphaRole.Ignore, packRmo: true, wantHeight: true, maxTextureSize: maxTextureSize );
summary.Textures += CountTextures( tex );
var path = Path.Combine( materialsDir, baseName + ".decal" );
var asset = GameResourceWriter.CreateDecal( path,
color: TexContent( assetsDir, texturesDir, tex.Color ),
normal: TexContent( assetsDir, texturesDir, tex.Normal ),
rmo: TexContent( assetsDir, texturesDir, tex.RoughMetalOcclusion ),
emissive: TexContent( assetsDir, texturesDir, tex.Emissive ),
height: TexContent( assetsDir, texturesDir, tex.Height ) );
if ( asset is null )
{
summary.Warnings.Add( $"{baseName}: failed to create the decal - see console." );
return ToContentPath( assetsDir, path );
}
summary.Materials++;
if ( tex.Color is null )
summary.Warnings.Add( $"{baseName}: decal has no colour texture - its alpha is what masks a decal, so this one won't show." );
return asset.Path;
}
/// <summary>
/// Scan the FBX for numeric-suffixed variants of known material node names
/// (duplicate-material slots uniquified by the FBX SDK) and remap them to the same
/// vmat as their base name. False positives from unrelated strings just produce
/// unused remap entries, which are harmless.
/// </summary>
static List<(string slot, string vmat)> SuffixedRemaps( string fbxPath, IReadOnlyList<(string slot, string vmat)> remaps )
{
var extra = new List<(string, string)>();
string text;
try
{
text = Encoding.ASCII.GetString( File.ReadAllBytes( fbxPath ) );
}
catch
{
return extra;
}
var known = remaps.Select( r => r.slot ).ToHashSet( StringComparer.OrdinalIgnoreCase );
foreach ( var (slot, vmat) in remaps.ToList() )
{
if ( string.IsNullOrEmpty( slot ) )
continue;
foreach ( System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches( text, System.Text.RegularExpressions.Regex.Escape( slot ) + @"_\d+" ) )
{
if ( known.Add( m.Value ) )
extra.Add( (m.Value, vmat) );
}
}
return extra;
}
/// <summary>
/// What the albedo's alpha channel means, from the Unreal blend mode. Opaque materials'
/// alpha is NOT opacity - with emissive params present it's a self-illum mask (lamp
/// housings etc.), otherwise it packs something we can't interpret and is ignored.
/// Old manifests without blend_mode keep the legacy translucency behaviour.
/// </summary>
static AlphaRole AlphaRoleFor( ManifestMaterial mat, bool hasEmissiveParams )
{
var blend = mat.BlendMode ?? "";
if ( blend.Length == 0 || blend.Contains( "TRANSLUCENT" ) || blend.Contains( "MASKED" )
|| blend.Contains( "ADDITIVE" ) || blend.Contains( "MODULATE" ) )
return AlphaRole.Translucency;
return hasEmissiveParams ? AlphaRole.SelfIllum : AlphaRole.Ignore;
}
/// <summary>
/// Emissive tint (Unreal LINEAR) + linear brightness multiplier from the Material
/// Instance's parameter overrides ("Emissive Multiply", "Emissive Color Multi", ...).
/// Null when the material has no emissive-ish parameter.
/// </summary>
static (float[] tint, float magnitude)? EmissiveParams( ManifestMaterial mat )
{
if ( mat.VectorParams is not null )
{
foreach ( var kv in mat.VectorParams )
{
if ( !kv.Key.Contains( "emissiv", StringComparison.OrdinalIgnoreCase ) || kv.Value is not { Length: >= 3 } )
continue;
float mag = Math.Max( kv.Value[0], Math.Max( kv.Value[1], kv.Value[2] ) );
if ( mag > 0 )
return (kv.Value, mag);
}
}
if ( mat.ScalarParams is not null )
{
foreach ( var kv in mat.ScalarParams )
{
if ( kv.Key.Contains( "emissiv", StringComparison.OrdinalIgnoreCase ) && kv.Value > 0 )
return (null, kv.Value);
}
}
return null;
}
/// <summary>Human-readable note of the tint colours Unreal had, so they can be wired up by hand.</summary>
static string TintComment( ManifestMaterial mat )
{
var parts = new List<string>();
if ( mat.TintColor is not null )
parts.Add( $"tint=[{FmtColor( mat.TintColor )}]" );
if ( mat.TintZones is not null )
foreach ( var kv in mat.TintZones )
parts.Add( $"{kv.Key}=[{FmtColor( kv.Value )}]" );
return parts.Count == 0 ? null : "Captured Unreal tint (NOT auto-applied; set g_vColorTint to use): " + string.Join( ", ", parts );
}
static string FmtColor( float[] c )
{
if ( c is null )
return "";
var sb = new StringBuilder();
for ( int i = 0; i < c.Length; i++ )
{
if ( i > 0 ) sb.Append( ' ' );
sb.Append( c[i].ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture ) );
}
return sb.ToString();
}
static int CountTextures( ProcessedTextures t )
{
int n = 0;
if ( t.Color != null ) n++;
if ( t.Alpha != null ) n++;
if ( t.Normal != null ) n++;
if ( t.Roughness != null ) n++;
if ( t.Metallic != null ) n++;
if ( t.Ao != null ) n++;
if ( t.Emissive != null ) n++;
if ( t.TintMask != null ) n++;
if ( t.SelfIllumMask != null ) n++;
if ( t.Height != null ) n++;
if ( t.RoughMetalOcclusion != null ) n++;
return n;
}
static string TexContent( string assetsDir, string texturesDir, string fileName )
{
if ( string.IsNullOrEmpty( fileName ) )
return null;
return ToContentPath( assetsDir, Path.Combine( texturesDir, fileName ) );
}
/// <summary>Path relative to the Assets folder, forward slashes, lowercase.</summary>
static string ToContentPath( string assetsDir, string absPath )
=> Path.GetRelativePath( assetsDir, absPath ).Replace( '\\', '/' ).ToLowerInvariant();
static string FindAssetsDir( string path )
{
if ( string.IsNullOrEmpty( path ) )
return null;
var d = new DirectoryInfo( path );
while ( d != null )
{
if ( string.Equals( d.Name, "Assets", StringComparison.OrdinalIgnoreCase ) )
return d.FullName;
d = d.Parent;
}
return null;
}
/// <summary>Base name (lowercase, dot-free) for a material's textures + vmat, from the MI name when available.</summary>
static string MaterialBaseName( ManifestMaterial mat )
{
if ( !string.IsNullOrEmpty( mat.Material ) )
return Sanitize( mat.Material );
// Fall back to a texture filename minus its role suffix.
var any = mat.Alb ?? mat.Nrm ?? mat.Rma ?? mat.Rough ?? mat.Metal ?? mat.Ao;
if ( !string.IsNullOrEmpty( any ) )
{
var name = Path.GetFileNameWithoutExtension( any );
foreach ( var suffix in new[] { "_ALB", "_ALBEDO", "_BASECOLOR", "_COLOR", "_NRM", "_NORMAL", "_RMA", "_ORM" } )
{
if ( name.EndsWith( suffix, StringComparison.OrdinalIgnoreCase ) )
{
name = name[..^suffix.Length];
break;
}
}
return Sanitize( name );
}
return Sanitize( mat.Slot ?? "material" );
}
/// <summary>
/// Folder name for an asset under the PerAsset layout: its own sanitized name at depth 0,
/// or an ancestor of its /Game path further up. Fab MIs carry meaningless names
/// (mi_sjfnbeaa) while the human-readable pack name sits a couple of folders above
/// (.../Fine_American_Road_sjfnbeaa/Medium/MI_sjfnbeaa), so depth 2 names the folder for it.
/// Never climbs into the "/Game" mount root, and falls back to the own name if the path
/// is too shallow for the requested depth.
/// </summary>
static string PerAssetFolder( string gamePath, string ownName, int depth )
{
if ( depth <= 0 || string.IsNullOrEmpty( gamePath ) )
return Sanitize( ownName );
var parts = gamePath.Split( '/', StringSplitOptions.RemoveEmptyEntries );
// Last segment is the asset itself; walk `depth` folders up from it.
int idx = parts.Length - 1 - depth;
// parts[0] is normally the "Game" mount - don't name a folder after it.
int floor = parts.Length > 1 && parts[0].Equals( "Game", StringComparison.OrdinalIgnoreCase ) ? 1 : 0;
if ( idx < floor || idx >= parts.Length - 1 )
return Sanitize( ownName );
return Sanitize( parts[idx] );
}
/// <summary>Lowercase; non [a-z0-9_] -> '_'. Guarantees no dots in generated filenames.</summary>
static string Sanitize( string s )
{
if ( string.IsNullOrEmpty( s ) )
return "unnamed";
var sb = new StringBuilder( s.Length );
foreach ( var ch in s.ToLowerInvariant() )
sb.Append( (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' ? ch : '_' );
return sb.ToString();
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Sandbox;
namespace Editor.UnrealImporter;
/// <summary>
/// Extracts the editor thumbnail Unreal embeds in every saved (uncooked) .uasset.
///
/// The package stores an FObjectThumbnail: 12 bytes of header (int32 width, height,
/// compressedSize) followed by the compressed image - PNG normally, JPEG in newer
/// packs (flagged by a negative height). Rather than parsing the version-dependent
/// package summary to find the thumbnail table, we scan for the PNG/JPEG magic and
/// validate the header that precedes it - verified against UE 5.x Fab packs.
///
/// Extracted images are cached on disk under the project's .sbox/ folder (keyed on
/// file mtime+size, so re-saved assets re-extract), plus an in-memory Pixmap cache
/// because the import window rebuilds its list on every keystroke.
/// </summary>
public static class UassetThumbnail
{
// path -> pixmap (null = scanned, no thumbnail found)
static readonly Dictionary<string, Pixmap> memoryCache = new();
static readonly Dictionary<string, Task<Pixmap>> inFlight = new();
static string CacheDir => Sandbox.Project.Current is not null
? Path.Combine( Sandbox.Project.Current.GetRootPath(), ".sbox", "unrealimporter", "thumbnails" )
: Path.Combine( Path.GetTempPath(), "unrealimporter", "thumbnails" );
/// <summary>Memory-cache lookup. True if this path has been resolved (pixmap may still be null).</summary>
public static bool TryGetCached( string absPath, out Pixmap pixmap )
=> memoryCache.TryGetValue( absPath, out pixmap );
/// <summary>
/// Resolve the thumbnail for a .uasset: memory cache, then disk cache, then a scan of the
/// file itself. Returns null if the asset has no embedded thumbnail. Safe to call
/// repeatedly - concurrent requests for the same path share one task.
/// </summary>
public static Task<Pixmap> LoadAsync( string absPath )
{
if ( memoryCache.TryGetValue( absPath, out var cached ) )
return Task.FromResult( cached );
if ( inFlight.TryGetValue( absPath, out var running ) )
return running;
var task = Load( absPath );
inFlight[absPath] = task;
return task;
}
static async Task<Pixmap> Load( string absPath )
{
string imagePath = null;
try
{
// File IO + scanning off the main thread; only the Pixmap itself is created back on it.
imagePath = await Task.Run( () => ResolveCacheFile( absPath ) );
}
catch ( Exception e )
{
Log.Warning( $"Thumbnail extraction failed for {absPath}: {e.Message}" );
}
var pixmap = imagePath is not null ? Pixmap.FromFile( imagePath ) : null;
memoryCache[absPath] = pixmap;
inFlight.Remove( absPath );
return pixmap;
}
/// <summary>
/// Path to a cached thumbnail image for this uasset, extracting it if needed.
/// Null if the asset has no embedded thumbnail (recorded with a .none marker).
/// </summary>
static string ResolveCacheFile( string absPath )
{
var fi = new FileInfo( absPath );
if ( !fi.Exists )
return null;
var pathHash = ShortHash( absPath.ToLowerInvariant() );
var statHash = ShortHash( $"{fi.LastWriteTimeUtc.Ticks}|{fi.Length}" );
var dir = CacheDir;
var baseName = Path.Combine( dir, $"{pathHash}_{statHash}" );
if ( File.Exists( baseName + ".png" ) ) return baseName + ".png";
if ( File.Exists( baseName + ".jpg" ) ) return baseName + ".jpg";
if ( File.Exists( baseName + ".none" ) ) return null;
Directory.CreateDirectory( dir );
// The asset changed since it was last cached - drop the stale entries for this path.
foreach ( var stale in Directory.EnumerateFiles( dir, pathHash + "_*" ) )
File.Delete( stale );
var (image, ext) = Extract( File.ReadAllBytes( absPath ) );
if ( image is null )
{
File.WriteAllBytes( baseName + ".none", Array.Empty<byte>() );
return null;
}
var target = baseName + ext;
File.WriteAllBytes( target, image );
return target;
}
static string ShortHash( string input )
=> Convert.ToHexString( SHA256.HashData( Encoding.UTF8.GetBytes( input ) ) )[..16].ToLowerInvariant();
static readonly byte[] PngMagic = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
/// <summary>Find the embedded thumbnail in raw .uasset bytes, or (null, null).</summary>
internal static (byte[] Image, string Extension) Extract( byte[] data )
{
if ( TryFindImage( data, PngMagic, out var png ) )
return (png, ".png");
// JPEG: SOI (FF D8 FF) followed by an APP0/APP1/DQT segment.
if ( TryFindJpeg( data, out var jpg ) )
return (jpg, ".jpg");
return (null, null);
}
static bool TryFindImage( byte[] data, byte[] magic, out byte[] image )
{
int pos = 12;
while ( (pos = IndexOf( data, magic, pos )) >= 0 )
{
if ( TrySlice( data, pos, out image ) )
return true;
pos += 1;
}
image = null;
return false;
}
static bool TryFindJpeg( byte[] data, out byte[] image )
{
for ( int pos = 12; pos < data.Length - 4; pos++ )
{
if ( data[pos] != 0xFF || data[pos + 1] != 0xD8 || data[pos + 2] != 0xFF )
continue;
var seg = data[pos + 3];
if ( seg != 0xE0 && seg != 0xE1 && seg != 0xDB )
continue;
if ( TrySlice( data, pos, out image ) )
return true;
}
image = null;
return false;
}
/// <summary>
/// Validate the FObjectThumbnail header in the 12 bytes before the image magic and
/// slice out the image. Rejects magic hits that aren't preceded by a sane header.
/// </summary>
static bool TrySlice( byte[] data, int magicPos, out byte[] image )
{
image = null;
if ( magicPos < 12 )
return false;
int width = BitConverter.ToInt32( data, magicPos - 12 );
int height = Math.Abs( BitConverter.ToInt32( data, magicPos - 8 ) ); // negative = JPEG flag
int size = BitConverter.ToInt32( data, magicPos - 4 );
if ( width < 4 || width > 8192 || height < 4 || height > 8192 )
return false;
if ( size < 16 || (long)magicPos + size > data.Length )
return false;
image = data[magicPos..(magicPos + size)];
return true;
}
static int IndexOf( byte[] haystack, byte[] needle, int start )
{
var idx = haystack.AsSpan( start ).IndexOf( needle );
return idx < 0 ? -1 : start + idx;
}
}
Editor
library
using Sandbox;
namespace Editor.UnrealImporter;
/// <summary>
/// A titled section box: a rounded border with its title notched into the top-left edge,
/// like an HTML fieldset/legend. Add content to <see cref="Widget.Layout"/> as usual - the
/// margins already leave room for the title and border.
/// </summary>
public class Fieldset : Widget
{
/// <summary>Height reserved for the title row; the border runs through its middle.</summary>
const float TitleHeight = 16;
const float TitleInset = 10;
const float TitlePad = 5;
public string Title { get; set; }
public Fieldset( string title, Widget parent ) : base( parent )
{
Title = title;
Layout = Layout.Column();
Layout.Spacing = 8;
Layout.Margin = new Sandbox.UI.Margin( 12, TitleHeight + 10, 12, 12 );
}
protected override void OnPaint()
{
// The border starts halfway down the title so the text can sit on the line.
var border = LocalRect.Shrink( 0.5f );
border.Top += TitleHeight * 0.5f;
// Fill first: the section needs to read as a raised panel, not just an outline.
Paint.ClearPen();
Paint.SetBrush( ImportStyle.Panel );
Paint.DrawRect( border, 4 );
Paint.ClearBrush();
Paint.SetPen( Theme.Border, 1 );
Paint.DrawRect( border, 4 );
if ( string.IsNullOrEmpty( Title ) )
return;
Paint.SetDefaultFont( 8, 400 );
var text = Paint.MeasureText( Title );
// Punch a gap in the border so the title reads as part of the frame, not on top of it.
// The gap straddles the border line, so each half takes the fill it sits against -
// window background above, panel fill below.
var gap = new Rect( TitleInset - TitlePad, border.Top - TitleHeight * 0.5f,
text.x + TitlePad * 2, TitleHeight );
Paint.ClearPen();
var above = gap;
above.Bottom = border.Top;
Paint.SetBrush( Theme.WindowBackground );
Paint.DrawRect( above );
var below = gap;
below.Top = border.Top;
Paint.SetBrush( ImportStyle.Panel );
Paint.DrawRect( below );
Paint.ClearBrush();
Paint.SetPen( Theme.Text.WithAlpha( 0.9f ) );
Paint.DrawText( gap, Title, TextFlag.Center );
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
namespace Editor.UnrealImporter;
/// <summary>
/// Generates sbox .vmat and .vmdl (kv3 text) from processed import data.
/// Structure mirrors Assets/prefabs/capture_point/sm_flagpole_tall_01a.vmdl + .vmat.
/// </summary>
public static class Kv3Writer
{
static string F( float v ) => v.ToString( "0.0######", CultureInfo.InvariantCulture );
/// <summary>
/// Format an Unreal LINEAR tint color as g_vColorTint's "[r g b a]" string.
/// g_vColorTint is sRGB-gamma in the shader (it does SrgbGammaToLinear), so we sRGB-encode
/// Unreal's linear value. Null/missing -> white (no tint).
/// </summary>
static string ColorTint( float[] c )
{
if ( c is null || c.Length < 3 )
return "[1.000000 1.000000 1.000000 0.000000]";
float r = LinearToSrgb( c[0] ), g = LinearToSrgb( c[1] ), b = LinearToSrgb( c[2] );
return $"[{r.ToString( "0.000000", CultureInfo.InvariantCulture )} " +
$"{g.ToString( "0.000000", CultureInfo.InvariantCulture )} " +
$"{b.ToString( "0.000000", CultureInfo.InvariantCulture )} 0.000000]";
}
static float LinearToSrgb( float c )
{
c = System.Math.Clamp( c, 0f, 1f );
return c <= 0.0031308f ? c * 12.92f : 1.055f * System.MathF.Pow( c, 1f / 2.4f ) - 0.055f;
}
/// <summary>Chroma of an HDR color: components divided by the max (null/black -> white).</summary>
static float[] Normalized( float[] c )
{
if ( c is null || c.Length < 3 )
return new float[] { 1f, 1f, 1f, 1f };
float max = System.MathF.Max( c[0], System.MathF.Max( c[1], c[2] ) );
if ( max <= 0f )
return new float[] { 1f, 1f, 1f, 1f };
return new[] { c[0] / max, c[1] / max, c[2] / max, 1f };
}
/// <summary>
/// A complex.shader material. Texture arguments are Content-relative paths (forward slashes),
/// or null to omit that slot. alphaTest picks F_ALPHA_TEST over F_TRANSLUCENT for the alpha
/// map (UE Masked materials). selfIllumMask enables F_SELF_ILLUM: a grayscale albedo-alpha
/// mask (selfIllumFromAlbedoAlpha=true, glow tinted by the albedo) or a dedicated RGB
/// emissive texture. selfIllumBrightness is a LINEAR multiplier (converted to the shader's
/// pow2 exponent), selfIllumTint an Unreal LINEAR color.
/// </summary>
public static string VmatText( string color, string normal, string roughness, string metallic, string ao, string alpha = null,
string tintMask = null, float[] tintColor = null, float? tintAmount = null, string tintComment = null,
bool alphaTest = false, string selfIllumMask = null, float[] selfIllumTint = null, float selfIllumBrightness = 1f,
bool selfIllumFromAlbedoAlpha = false )
{
var sb = new StringBuilder();
sb.AppendLine( "// THIS FILE IS AUTO-GENERATED (unreal_importer)" );
if ( !string.IsNullOrEmpty( tintComment ) )
sb.AppendLine( $"// {tintComment}" );
sb.AppendLine();
sb.AppendLine( "Layer0" );
sb.AppendLine( "{" );
sb.AppendLine( "\tshader \"shaders/complex.shader\"" );
sb.AppendLine();
sb.AppendLine( "\t//---- PBR ----" );
if ( !string.IsNullOrEmpty( metallic ) )
{
sb.AppendLine( "\tF_METALNESS_TEXTURE 1" );
}
sb.AppendLine( "\tF_SPECULAR 1" );
if ( !string.IsNullOrEmpty( tintMask ) )
{
sb.AppendLine( "\tF_TINT_MASK 1" );
}
if ( !string.IsNullOrEmpty( alpha ) )
{
sb.AppendLine();
sb.AppendLine( "\t//---- Alpha ----" );
if ( alphaTest )
{
sb.AppendLine( "\tF_ALPHA_TEST 1" );
sb.AppendLine( "\tg_flAlphaTestReference \"0.500\"" );
}
else
{
sb.AppendLine( "\tF_TRANSLUCENT 1" );
}
sb.AppendLine( $"\tTextureTranslucency \"{alpha}\"" );
}
if ( !string.IsNullOrEmpty( selfIllumMask ) )
{
float mag = MathF.Max( selfIllumBrightness, 0.001f );
var tint = Normalized( selfIllumTint );
sb.AppendLine();
sb.AppendLine( "\t//---- Self Illum ----" );
sb.AppendLine( "\tF_SELF_ILLUM 1" );
sb.AppendLine( $"\tTextureSelfIllumMask \"{selfIllumMask}\"" );
sb.AppendLine( $"\tg_vSelfIllumTint \"{ColorTint( tint )}\"" );
sb.AppendLine( $"\tg_flSelfIllumBrightness \"{F( Math.Clamp( MathF.Log2( mag ), -10f, 10f ) )}\"" );
sb.AppendLine( "\tg_flSelfIllumScale \"1.000\"" );
// Grayscale alpha masks carry no colour - let the albedo tint the glow.
sb.AppendLine( $"\tg_flSelfIllumAlbedoFactor \"{(selfIllumFromAlbedoAlpha ? "1.000" : "0.000")}\"" );
}
if ( !string.IsNullOrEmpty( ao ) )
{
sb.AppendLine();
sb.AppendLine( "\t//---- Ambient Occlusion ----" );
sb.AppendLine( "\tg_flAmbientOcclusionDirectDiffuse \"0.000\"" );
sb.AppendLine( "\tg_flAmbientOcclusionDirectSpecular \"0.000\"" );
sb.AppendLine( $"\tTextureAmbientOcclusion \"{ao}\"" );
}
sb.AppendLine();
sb.AppendLine( "\t//---- Color ----" );
sb.AppendLine( $"\tg_flModelTintAmount \"{F( tintAmount ?? 1.0f )}\"" );
sb.AppendLine( $"\tg_vColorTint \"{ColorTint( tintColor )}\"" );
if ( !string.IsNullOrEmpty( color ) )
sb.AppendLine( $"\tTextureColor \"{color}\"" );
if ( !string.IsNullOrEmpty( tintMask ) )
{
sb.AppendLine();
sb.AppendLine( "\t//---- Tint Mask ----" );
sb.AppendLine( $"\tTextureTintMask \"{tintMask}\"" );
}
sb.AppendLine();
sb.AppendLine( "\t//---- Fog ----" );
sb.AppendLine( "\tg_bFogEnabled \"1\"" );
if ( !string.IsNullOrEmpty( metallic ) )
{
sb.AppendLine();
sb.AppendLine( "\t//---- Metalness ----" );
sb.AppendLine( $"\tTextureMetalness \"{metallic}\"" );
}
if ( !string.IsNullOrEmpty( normal ) )
{
sb.AppendLine();
sb.AppendLine( "\t//---- Normal ----" );
sb.AppendLine( $"\tTextureNormal \"{normal}\"" );
}
if ( !string.IsNullOrEmpty( roughness ) )
{
sb.AppendLine();
sb.AppendLine( "\t//---- Roughness ----" );
sb.AppendLine( "\tg_flRoughnessScaleFactor \"1.000\"" );
sb.AppendLine( $"\tTextureRoughness \"{roughness}\"" );
}
sb.AppendLine();
sb.AppendLine( "\t//---- Texture Coordinates ----" );
sb.AppendLine( "\tg_vTexCoordOffset \"[0.000 0.000]\"" );
sb.AppendLine( "\tg_vTexCoordScale \"[1.000 1.000]\"" );
sb.AppendLine( "\tg_vTexCoordScrollSpeed \"[0.000 0.000]\"" );
sb.AppendLine( "}" );
return sb.ToString();
}
/// <summary>
/// A static model referencing an FBX, with per-slot material remaps, a hull-from-render
/// collision shape, and a 5-level auto-LOD chain (matches the flagpole reference).
/// hullMode: "HullPerElement" (default), "SingleHull", "HullPerMesh", or null for no
/// collision at all - dense foliage geometry can fail hull generation entirely.
/// mirror emits a ModelModifier_ScaleAndMirror flipping local X - unlike a negative
/// import_scale (which mirrors but leaves the triangle winding inverted, so faces
/// get culled from the wrong side), the modifier corrects winding properly.
/// lods=false skips the auto-LOD chain entirely (full detail at every distance).
/// </summary>
public static string VmdlText( string fbxContentPath, float importScale, IReadOnlyList<(string slot, string vmat)> remaps, string hullMode = "HullPerElement", bool mirror = false, bool lods = true )
{
var sb = new StringBuilder();
sb.AppendLine( "<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->" );
sb.AppendLine( "{" );
sb.AppendLine( "\trootNode =" );
sb.AppendLine( "\t{" );
sb.AppendLine( "\t\t_class = \"RootNode\"" );
sb.AppendLine( "\t\tchildren =" );
sb.AppendLine( "\t\t[" );
// --- Material groups (remaps) ---
sb.AppendLine( "\t\t\t{" );
sb.AppendLine( "\t\t\t\t_class = \"MaterialGroupList\"" );
sb.AppendLine( "\t\t\t\tchildren =" );
sb.AppendLine( "\t\t\t\t[" );
sb.AppendLine( "\t\t\t\t\t{" );
sb.AppendLine( "\t\t\t\t\t\t_class = \"DefaultMaterialGroup\"" );
sb.AppendLine( "\t\t\t\t\t\tremaps =" );
sb.AppendLine( "\t\t\t\t\t\t[" );
foreach ( var (slot, vmat) in remaps )
{
sb.AppendLine( "\t\t\t\t\t\t\t{" );
sb.AppendLine( $"\t\t\t\t\t\t\t\tfrom = \"{slot}\"" );
sb.AppendLine( $"\t\t\t\t\t\t\t\tto = \"{vmat}\"" );
sb.AppendLine( "\t\t\t\t\t\t\t}," );
}
sb.AppendLine( "\t\t\t\t\t\t]" );
sb.AppendLine( "\t\t\t\t\t\tuse_global_default = false" );
sb.AppendLine( "\t\t\t\t\t\tglobal_default_material = \"\"" );
sb.AppendLine( "\t\t\t\t\t}," );
sb.AppendLine( "\t\t\t\t]" );
sb.AppendLine( "\t\t\t}," );
// --- Mirror (proper winding-corrected flip across local X) ---
if ( mirror )
{
sb.AppendLine( "\t\t\t{" );
sb.AppendLine( "\t\t\t\t_class = \"ModelModifierList\"" );
sb.AppendLine( "\t\t\t\tchildren =" );
sb.AppendLine( "\t\t\t\t[" );
sb.AppendLine( "\t\t\t\t\t{" );
sb.AppendLine( "\t\t\t\t\t\t_class = \"ModelModifier_ScaleAndMirror\"" );
sb.AppendLine( "\t\t\t\t\t\tscale = 1.0" );
sb.AppendLine( "\t\t\t\t\t\tmirror_x = true" );
sb.AppendLine( "\t\t\t\t\t\tmirror_y = false" );
sb.AppendLine( "\t\t\t\t\t\tmirror_z = false" );
sb.AppendLine( "\t\t\t\t\t\tflip_bone_forward = false" );
sb.AppendLine( "\t\t\t\t\t\tswap_left_and_right_bones = false" );
sb.AppendLine( "\t\t\t\t\t}," );
sb.AppendLine( "\t\t\t\t]" );
sb.AppendLine( "\t\t\t}," );
}
// --- Collision (hull from render mesh) ---
if ( hullMode is not null )
{
sb.AppendLine( "\t\t\t{" );
sb.AppendLine( "\t\t\t\t_class = \"PhysicsShapeList\"" );
sb.AppendLine( "\t\t\t\tchildren =" );
sb.AppendLine( "\t\t\t\t[" );
sb.AppendLine( "\t\t\t\t\t{" );
sb.AppendLine( "\t\t\t\t\t\t_class = \"PhysicsHullFromRender\"" );
sb.AppendLine( "\t\t\t\t\t\tparent_bone = \"\"" );
sb.AppendLine( "\t\t\t\t\t\tsurface_prop = \"default\"" );
sb.AppendLine( "\t\t\t\t\t\tcollision_tags = \"solid\"" );
sb.AppendLine( "\t\t\t\t\t\tfaceMergeAngle = 20.0" );
sb.AppendLine( "\t\t\t\t\t\tmaxHullVertices = 32" );
sb.AppendLine( $"\t\t\t\t\t\thull_mode = \"{hullMode}\"" );
sb.AppendLine( "\t\t\t\t\t}," );
sb.AppendLine( "\t\t\t\t]" );
sb.AppendLine( "\t\t\t}," );
}
// --- Render mesh (FBX) ---
sb.AppendLine( "\t\t\t{" );
sb.AppendLine( "\t\t\t\t_class = \"RenderMeshList\"" );
sb.AppendLine( "\t\t\t\tchildren =" );
sb.AppendLine( "\t\t\t\t[" );
sb.AppendLine( "\t\t\t\t\t{" );
sb.AppendLine( "\t\t\t\t\t\t_class = \"RenderMeshFile\"" );
sb.AppendLine( $"\t\t\t\t\t\tfilename = \"{fbxContentPath}\"" );
sb.AppendLine( "\t\t\t\t\t\timport_translation = [ 0.0, 0.0, 0.0 ]" );
sb.AppendLine( "\t\t\t\t\t\timport_rotation = [ 0.0, 0.0, 0.0 ]" );
sb.AppendLine( $"\t\t\t\t\t\timport_scale = {F( importScale )}" );
sb.AppendLine( "\t\t\t\t\t\talign_origin_x_type = \"None\"" );
sb.AppendLine( "\t\t\t\t\t\talign_origin_y_type = \"None\"" );
sb.AppendLine( "\t\t\t\t\t\talign_origin_z_type = \"None\"" );
sb.AppendLine( "\t\t\t\t\t\tparent_bone = \"\"" );
sb.AppendLine( "\t\t\t\t\t\timport_filter =" );
sb.AppendLine( "\t\t\t\t\t\t{" );
sb.AppendLine( "\t\t\t\t\t\t\texclude_by_default = false" );
sb.AppendLine( "\t\t\t\t\t\t\texception_list = [ ]" );
sb.AppendLine( "\t\t\t\t\t\t}" );
sb.AppendLine( "\t\t\t\t\t}," );
sb.AppendLine( "\t\t\t\t]" );
sb.AppendLine( "\t\t\t}," );
// --- Auto LODs ---
if ( lods )
AppendLodGroupList( sb );
sb.AppendLine( "\t\t]" );
sb.AppendLine( "\t\tmodel_archetype = \"\"" );
sb.AppendLine( "\t\tprimary_associated_entity = \"\"" );
sb.AppendLine( "\t\tanim_graph_name = \"\"" );
sb.AppendLine( "\t\tbase_model_name = \"\"" );
sb.AppendLine( "\t}" );
sb.AppendLine( "}" );
return sb.ToString();
}
static void AppendLodGroupList( StringBuilder sb )
{
// (switch_threshold, simplify_mode, reduction, lock_border, permissive, protect_uv, meshes-on-lod0)
// Reductions compound down the chain; keep the cumulative ratio (~0.17) gentle enough
// that low-poly meshes never simplify to 0 triangles - a LOD with no geometry fails
// the whole model compile (seen with 12-triangle drywall sheets at cumulative 0.04).
var lods = new (float thr, int mode, float red, bool lockBorder, bool permissive, bool protectUv, bool hasMesh)[]
{
( 0.0f, 0, 0.5f, true, false, true, true ),
( 25.0f, 1, 0.5f, true, false, true, false ),
( 40.0f, 1, 0.6f, false, true, true, false ),
( 60.0f, 1, 0.7f, false, true, false, false ),
( 80.0f, 1, 0.8f, false, true, false, false ),
};
sb.AppendLine( "\t\t\t{" );
sb.AppendLine( "\t\t\t\t_class = \"LODGroupList\"" );
sb.AppendLine( "\t\t\t\tchildren =" );
sb.AppendLine( "\t\t\t\t[" );
foreach ( var l in lods )
{
sb.AppendLine( "\t\t\t\t\t{" );
sb.AppendLine( "\t\t\t\t\t\t_class = \"LODGroup\"" );
sb.AppendLine( $"\t\t\t\t\t\tswitch_threshold = {F( l.thr )}" );
sb.AppendLine( $"\t\t\t\t\t\tauto_simplify_mode = {l.mode}" );
sb.AppendLine( $"\t\t\t\t\t\tauto_reduction = {F( l.red )}" );
sb.AppendLine( "\t\t\t\t\t\tauto_max_error = 0.0" );
sb.AppendLine( $"\t\t\t\t\t\tauto_lock_border_vertices = {B( l.lockBorder )}" );
sb.AppendLine( $"\t\t\t\t\t\tauto_permissive_simplification = {B( l.permissive )}" );
sb.AppendLine( $"\t\t\t\t\t\tauto_protect_uv_seams = {B( l.protectUv )}" );
sb.AppendLine( "\t\t\t\t\t\tauto_regularize = 1" );
sb.AppendLine( "\t\t\t\t\t\tauto_prune_isolated_components = false" );
sb.AppendLine( "\t\t\t\t\t\tauto_strip_vertex_color = false" );
sb.AppendLine( "\t\t\t\t\t\tauto_material_culling_enabled = false" );
sb.AppendLine( "\t\t\t\t\t\tmeshes =" );
if ( l.hasMesh )
{
sb.AppendLine( "\t\t\t\t\t\t[" );
sb.AppendLine( "\t\t\t\t\t\t\t\"unnamed_1\"," );
sb.AppendLine( "\t\t\t\t\t\t]" );
}
else
{
sb.AppendLine( "\t\t\t\t\t\t[ ]" );
}
sb.AppendLine( "\t\t\t\t\t\tmaterial_culls = [ ]" );
sb.AppendLine( "\t\t\t\t\t}," );
}
sb.AppendLine( "\t\t\t\t]" );
sb.AppendLine( "\t\t\t}," );
}
static string B( bool v ) => v ? "true" : "false";
}
Editor
library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Sandbox;
namespace Editor.UnrealImporter;
/// <summary>
/// Editor tool: pick an Unreal project folder, tick the static meshes and materials to bring
/// over, and export them to sbox (FBX + vmat + vmdl) via a headless Unreal pass + kv3 generation.
///
/// Materials can be picked on their own - they import as a standalone vmat, which is the whole
/// point of surface packs (Megascans Surfaces are a material plus its textures, no mesh).
///
/// The browser is a folder tree mirroring /Game. Folder checkboxes (tri-state) tick whole
/// subtrees, maps sit inline in their folders (double-click to import), and each mesh row
/// shows the triangle count read straight from the .uasset's embedded asset-registry tags.
/// Searching flattens the tree to matches.
///
/// TODO: max texture resolution selection
/// TODO: make async with progress bar
/// </summary>
[EditorApp( "Unreal Importer", "move_to_inbox", "Import Unreal / Fab meshes and materials into s&box" )]
public class UnrealImportWindow : Widget
{
/// <summary>What an entry turns into once imported.</summary>
enum AssetKind
{
/// <summary>StaticMesh -> fbx + vmdl (+ the vmats of its slots).</summary>
Mesh,
/// <summary>Material / Material Instance -> a standalone vmat.</summary>
Material,
}
class AssetEntry
{
public AssetKind Kind;
public string GamePath; // /Game/.../SM_X
public string AbsPath; // ...\Content\...\SM_X.uasset
public string Display; // GamePath without the /Game/ prefix
public long SizeBytes; // .uasset on disk (uncooked, so this is the whole asset)
public long Triangles = -1; // meshes only: from the uasset's asset-registry tags; -1 until read
public bool Selected; // opt-in: nothing ticked until the user picks
public bool IsMesh => Kind == AssetKind.Mesh;
}
class MapEntry
{
public string GamePath; // /Game/.../Maps/Demonstration
public string AbsPath; // ...\Content\...\Demonstration.umap
public string Display;
}
class FolderBucket
{
public readonly SortedSet<string> Subfolders = new( StringComparer.OrdinalIgnoreCase );
public readonly List<AssetEntry> Assets = new();
public readonly List<MapEntry> Maps = new();
/// <summary>Every asset anywhere below this folder - drives the tri-state checkbox.</summary>
public readonly List<AssetEntry> Subtree = new();
}
const float CheckWidth = 26;
const float ThumbSize = 34;
interface ICheckRow
{
void OnCheckClicked();
}
/// <summary>A row that can supply a large hover preview.</summary>
interface IPreviewRow
{
Pixmap PreviewPixmap { get; }
string PreviewCaption { get; }
}
/// <summary>
/// Frameless tooltip window showing a row's embedded thumbnail at full size
/// (Unreal stores them at 256x256; the list shrinks them to 34px).
/// </summary>
class ThumbPreview : Widget
{
const float ImageSize = 256;
const float CaptionHeight = 20;
const float Pad = 8;
readonly Pixmap pixmap;
readonly string caption;
public object Key;
public ThumbPreview( Pixmap pixmap, string caption, Vector2 screenPos ) : base( null )
{
this.pixmap = pixmap;
this.caption = caption;
WindowFlags = WindowFlags.ToolTip | WindowFlags.FramelessWindowHint | WindowFlags.WindowDoesNotAcceptFocus;
FocusMode = FocusMode.None;
TransparentForMouseEvents = true;
ShowWithoutActivating = true;
NoSystemBackground = true;
Size = new Vector2( ImageSize + Pad * 2, ImageSize + CaptionHeight + Pad * 2 );
Position = screenPos;
Show();
}
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrushAndPen( Theme.ControlBackground, Theme.Border );
Paint.DrawRect( LocalRect );
var img = LocalRect.Shrink( Pad );
img.Height = ImageSize;
Paint.Draw( img, pixmap );
var text = LocalRect.Shrink( Pad );
text.Top += ImageSize;
Paint.SetPen( Theme.TextControl.WithAlpha( 0.8f ) );
Paint.SetDefaultFont( 7 );
Paint.DrawText( text, caption, TextFlag.Center );
}
}
/// <summary>
/// TreeView that routes clicks on the leading checkbox column to the row, and pops a
/// large thumbnail preview after hovering a mesh/map row briefly.
/// </summary>
class ImportTreeView : TreeView
{
ThumbPreview preview;
object hoverNode;
RealTimeSince hoverSince;
public ImportTreeView( Widget parent ) : base( parent )
{
MouseTracking = true;
}
protected override bool OnItemPressed( VirtualWidget pressedItem, MouseEvent e )
{
if ( e.LeftMouseButton && pressedItem.Object is ICheckRow row )
{
var box = pressedItem.Rect;
box.Left += IndentWidth * pressedItem.Column + ExpandWidth;
box.Width = CheckWidth;
if ( box.IsInside( e.LocalPosition ) )
{
row.OnCheckClicked();
Update();
return false;
}
}
return base.OnItemPressed( pressedItem, e );
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
var node = GetItemAt( e.LocalPosition )?.Object;
if ( node == hoverNode )
return;
hoverNode = node;
hoverSince = 0;
if ( preview.IsValid() && preview.Key != node )
{
preview.Destroy();
preview = null;
}
}
protected override void OnMouseLeave()
{
base.OnMouseLeave();
ClearPreview();
}
public override void OnDestroyed()
{
base.OnDestroyed();
ClearPreview();
}
void ClearPreview()
{
hoverNode = null;
preview?.Destroy();
preview = null;
}
[EditorEvent.Frame]
public void ShowPreviewWhenSettled()
{
if ( preview.IsValid() || hoverNode is not IPreviewRow row || hoverSince < 0.35f )
return;
if ( row.PreviewPixmap is null )
return;
// To the right of the cursor, nudged up so the image is centred on the row.
var pos = Application.CursorPosition + new Vector2( 28, -140 );
preview = new ThumbPreview( row.PreviewPixmap, row.PreviewCaption, pos ) { Key = hoverNode };
}
}
class FolderNode : TreeNode, ICheckRow
{
readonly UnrealImportWindow win;
readonly string path; // folder path relative to /Game ("" only for the virtual root)
public FolderNode( UnrealImportWindow win, string path )
{
this.win = win;
this.path = path;
Value = "folder:" + path;
Height = 26;
}
public override bool HasChildren => win.FolderHasChildren( path );
protected override void BuildChildren()
{
Clear();
AddItems( win.BuildFolderChildNodes( path ) );
}
public override void OnPaint( VirtualWidget item )
{
ImportStyle.PaintRow( item, TreeView );
var r = item.Rect;
var (sel, total) = win.SubtreeSelection( path );
var check = r;
check.Width = CheckWidth;
Paint.SetPen( sel > 0 ? Theme.Primary : Theme.TextControl.WithAlpha( 0.5f ) );
Paint.DrawIcon( check, sel == 0 ? "check_box_outline_blank" : sel == total ? "check_box" : "indeterminate_check_box", 16, TextFlag.Center );
var icon = r;
icon.Left += CheckWidth;
icon.Width = 22;
Paint.SetPen( Theme.Yellow.WithAlpha( 0.8f ) );
Paint.DrawIcon( icon, item.IsOpen ? "folder_open" : "folder", 16, TextFlag.Center );
var meta = r;
meta.Right -= 6;
Paint.SetPen( Theme.TextControl.WithAlpha( 0.4f ) );
Paint.SetDefaultFont( 7 );
Paint.DrawText( meta, win.SubtreeSummary( path ), TextFlag.RightCenter );
var text = r;
text.Left += CheckWidth + 26;
text.Right -= 80;
Paint.SetPen( Theme.Text );
Paint.SetDefaultFont();
var name = path[(path.LastIndexOf( '/' ) + 1)..];
Paint.DrawText( text, name, TextFlag.LeftCenter );
}
public void OnCheckClicked()
{
var (sel, total) = win.SubtreeSelection( path );
win.SetFolderSelected( path, sel < total );
}
}
class AssetNode : TreeNode, ICheckRow, IPreviewRow
{
readonly UnrealImportWindow win;
readonly AssetEntry entry;
readonly bool fullPath;
Pixmap pixmap;
bool thumbResolved;
/// <summary>Placeholder + material-row icon: a mesh reads as a solid, a material as a swatch.</summary>
string Icon => entry.IsMesh ? "view_in_ar" : "palette";
public Pixmap PreviewPixmap => pixmap;
public string PreviewCaption => entry.Triangles >= 0
? $"{entry.Display} · {FormatCount( entry.Triangles )} tris"
: entry.IsMesh ? entry.Display : $"{entry.Display} · material";
public AssetNode( UnrealImportWindow win, AssetEntry entry, bool fullPath )
{
this.win = win;
this.entry = entry;
this.fullPath = fullPath;
Value = entry;
Height = 40;
if ( UassetThumbnail.TryGetCached( entry.AbsPath, out pixmap ) )
thumbResolved = true;
else
_ = ResolveThumb();
// Triangle counts are a mesh-only asset-registry tag.
if ( entry.IsMesh && entry.Triangles < 0 )
_ = ResolveStats();
}
async Task ResolveThumb()
{
pixmap = await UassetThumbnail.LoadAsync( entry.AbsPath );
thumbResolved = true;
TreeView?.Update();
}
async Task ResolveStats()
{
var stats = await UassetMeshStats.LoadAsync( entry.AbsPath );
if ( stats is not null )
{
entry.Triangles = stats.Triangles;
win.UpdateStatus();
}
TreeView?.Update();
}
public override void OnPaint( VirtualWidget item )
{
ImportStyle.PaintRow( item, TreeView );
var r = item.Rect;
var check = r;
check.Width = CheckWidth;
Paint.SetPen( entry.Selected ? Theme.Primary : Theme.TextControl.WithAlpha( 0.5f ) );
Paint.DrawIcon( check, entry.Selected ? "check_box" : "check_box_outline_blank", 16, TextFlag.Center );
var thumb = r;
thumb.Left += CheckWidth;
thumb.Width = ThumbSize;
thumb.Top += (r.Height - ThumbSize) / 2;
thumb.Height = ThumbSize;
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground );
Paint.DrawRect( thumb, 3 );
if ( pixmap is not null )
{
Paint.Draw( thumb, pixmap, 1, 3 );
}
else
{
Paint.SetPen( Theme.TextControl.WithAlpha( thumbResolved ? 0.25f : 0.1f ) );
Paint.DrawIcon( thumb, Icon, 18 );
}
var meta = r;
meta.Right -= 6;
Paint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );
Paint.SetDefaultFont( 7 );
var label = entry.Triangles >= 0
? $"{FormatCount( entry.Triangles )} tris · {FormatSize( entry.SizeBytes )}"
: entry.IsMesh
? FormatSize( entry.SizeBytes )
: $"material · {FormatSize( entry.SizeBytes )}";
Paint.DrawText( meta, label, TextFlag.RightCenter );
var text = r;
text.Left += CheckWidth + ThumbSize + 8;
text.Right -= 120;
Paint.SetPen( Theme.Text );
Paint.SetDefaultFont();
var name = fullPath ? entry.Display : entry.Display[(entry.Display.LastIndexOf( '/' ) + 1)..];
Paint.DrawText( text, name, TextFlag.LeftCenter );
}
public void OnCheckClicked()
{
entry.Selected = !entry.Selected;
win.UpdateStatus();
}
public override void OnActivated()
{
OnCheckClicked();
TreeView?.Update();
}
}
class MapNode : TreeNode, IPreviewRow
{
readonly UnrealImportWindow win;
readonly MapEntry entry;
readonly bool fullPath;
Pixmap pixmap;
bool thumbResolved;
public Pixmap PreviewPixmap => pixmap;
public string PreviewCaption => $"{entry.Display} · map";
public MapNode( UnrealImportWindow win, MapEntry entry, bool fullPath )
{
this.win = win;
this.entry = entry;
this.fullPath = fullPath;
Value = entry;
Height = 40;
if ( UassetThumbnail.TryGetCached( entry.AbsPath, out pixmap ) )
thumbResolved = true;
else
_ = ResolveThumb();
}
async Task ResolveThumb()
{
pixmap = await UassetThumbnail.LoadAsync( entry.AbsPath );
thumbResolved = true;
TreeView?.Update();
}
public override void OnPaint( VirtualWidget item )
{
ImportStyle.PaintRow( item, TreeView );
var r = item.Rect;
var icon = r;
icon.Width = CheckWidth;
Paint.SetPen( Theme.Green.WithAlpha( 0.8f ) );
Paint.DrawIcon( icon, "public", 16, TextFlag.Center );
var thumb = r;
thumb.Left += CheckWidth;
thumb.Width = ThumbSize;
thumb.Top += (r.Height - ThumbSize) / 2;
thumb.Height = ThumbSize;
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground );
Paint.DrawRect( thumb, 3 );
if ( pixmap is not null )
{
Paint.Draw( thumb, pixmap, 1, 3 );
}
else
{
Paint.SetPen( Theme.TextControl.WithAlpha( thumbResolved ? 0.25f : 0.1f ) );
Paint.DrawIcon( thumb, "public", 18 );
}
var meta = r;
meta.Right -= 6;
Paint.SetPen( Theme.TextControl.WithAlpha( 0.4f ) );
Paint.SetDefaultFont( 7 );
Paint.DrawText( meta, "map · double-click to import", TextFlag.RightCenter );
var text = r;
text.Left += CheckWidth + ThumbSize + 8;
text.Right -= 160;
Paint.SetPen( Theme.Text );
Paint.SetDefaultFont();
var name = fullPath ? entry.Display : entry.Display[(entry.Display.LastIndexOf( '/' ) + 1)..];
Paint.DrawText( text, name, TextFlag.LeftCenter );
}
public override void OnActivated()
{
win.DoImportMap( entry );
}
}
string uprojectPath;
string uprojectFolder;
string outputFolder;
string searchFilter = "";
bool flatView;
readonly List<AssetEntry> entries = new();
readonly List<MapEntry> mapEntries = new();
readonly Dictionary<string, FolderBucket> folders = new( StringComparer.OrdinalIgnoreCase );
List<TreeNode> rootNodes = new();
/// <summary>Width of the left-hand label column, so the settings rows line up.</summary>
const float LabelWidth = 110;
LineEdit projectLabel;
LineEdit outputLabel;
Label statusLabel;
LineEdit searchEdit;
ImportTreeView tree;
Button exportButton;
ComboBox layoutCombo;
LineEdit subfolderEdit;
Label subfolderLabel;
Checkbox lodCheckbox;
LineEdit lightScaleEdit;
ComboBox materialOutputCombo;
ComboBox perAssetFolderCombo;
Label perAssetFolderLabel;
ComboBox maxTextureSizeCombo;
/// <summary>Combo item order - the layout row adds items in exactly this order.</summary>
static readonly ImportLayout[] LayoutOrder = { ImportLayout.Grouped, ImportLayout.Flat, ImportLayout.ClassicSource, ImportLayout.PerAsset };
ImportLayout SelectedLayout => layoutCombo is null ? ImportLayout.Grouped : LayoutOrder[Math.Clamp( layoutCombo.CurrentIndex, 0, LayoutOrder.Length - 1 )];
/// <summary>Combo item order - the material output row adds items in exactly this order.</summary>
static readonly MaterialOutput[] MaterialOutputOrder = { MaterialOutput.Material, MaterialOutput.Terrain, MaterialOutput.Decal };
MaterialOutput SelectedMaterialOutput => materialOutputCombo is null
? MaterialOutput.Material
: MaterialOutputOrder[Math.Clamp( materialOutputCombo.CurrentIndex, 0, MaterialOutputOrder.Length - 1 )];
/// <summary>Per-asset folder-name depth: the combo index IS the depth (0 = asset's own name).</summary>
int PerAssetFolderDepth => perAssetFolderCombo?.CurrentIndex ?? 0;
/// <summary>Combo item order - the texture size row adds items in exactly this order. 0 = no cap.</summary>
static readonly int[] MaxTextureSizeOrder = { 0, 4096, 2048, 1024, 512 };
int MaxTextureSize => maxTextureSizeCombo is null
? 0
: MaxTextureSizeOrder[Math.Clamp( maxTextureSizeCombo.CurrentIndex, 0, MaxTextureSizeOrder.Length - 1 )];
string Subfolder() => subfolderEdit?.Text ?? "";
/// <summary>The light-brightness multiplier from the UI, defensively parsed.</summary>
float LightScale()
{
if ( lightScaleEdit is null )
return 1f;
return float.TryParse( lightScaleEdit.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v ) && v > 0
? Math.Clamp( v, 0.01f, 20f )
: 1f;
}
public UnrealImportWindow() : this( null ) { }
public UnrealImportWindow( Widget parent ) : base( parent )
{
WindowFlags = WindowFlags.Dialog | WindowFlags.Customized | WindowFlags.WindowTitle | WindowFlags.CloseButton | WindowFlags.WindowSystemMenuHint;
DeleteOnClose = true;
WindowTitle = "Unreal Importer";
SetWindowIcon( "move_to_inbox" );
outputFolder = Sandbox.Project.Current is not null
? Path.Combine( Sandbox.Project.Current.GetAssetsPath(), "unrealimport" )
: null;
Layout = Layout.Column();
Layout.Spacing = 8;
Layout.Margin = 16;
Layout.Add( new WarningBox(
"Select an Unreal project folder, tick the meshes and materials you want, and export.\n" +
"This runs a headless Unreal pass to extract FBX + textures, then generates vmdl/vmat.\n" +
"A big pack takes minutes - the status line below tracks every phase.", this ) );
// Project row
{
var row = Layout.Row();
row.Spacing = 8;
row.Add( new Label( "Unreal Project", this ) { FixedWidth = LabelWidth } );
projectLabel = new LineEdit( this )
{
ReadOnly = true,
PlaceholderText = "No Unreal project selected",
ToolTip = "The .uproject the assets are read from",
}.StyleInput();
row.Add( projectLabel, 1 );
row.Add( new Button( "Browse Project...", "folder_open", this ) { Clicked = PickProject } );
Layout.Add( row );
}
// ---- Asset Selection ----
{
var section = new Fieldset( "Asset Selection", this );
var toolRow = Layout.Row();
toolRow.Spacing = 8;
searchEdit = new LineEdit( this ) { PlaceholderText = "⌕ Search meshes and materials", ToolTip = "Filter the list by name or path" };
searchEdit.StyleInput();
searchEdit.TextEdited += t =>
{
searchFilter = t ?? "";
RefreshTree();
UpdateStatus();
};
toolRow.Add( searchEdit, 1 );
toolRow.Add( new Button( "Select All", "done_all", this ) { Clicked = () => SetAll( true ) } );
toolRow.Add( new Button( "Select None", "remove_done", this ) { Clicked = () => SetAll( false ) } );
flatView = EditorCookie.Get( "unreal_import_flat_view", false );
var flatToggle = new Checkbox( "Flat list", this )
{
Value = flatView,
ToolTip = "Show every asset as one flat list instead of the folder tree",
};
flatToggle.Toggled = () =>
{
flatView = flatToggle.Value;
EditorCookie.Set( "unreal_import_flat_view", flatView );
RefreshTree();
};
toolRow.Add( flatToggle );
section.Layout.Add( toolRow );
tree = new ImportTreeView( this );
tree.MultiSelect = false;
// Sunk into the section: darker than the panel so the row stripes read against it.
tree.SetStyles(
$"background-color: {Theme.WindowBackground.Hex};" +
$"border: 1px solid {Theme.Border.WithAlpha( 0.5f ).Hex};" +
$"border-radius: {Theme.ControlRadius}px;" );
section.Layout.Add( tree, 1 );
// The section (and the tree inside it) takes all the leftover height.
Layout.Add( section, 1 );
}
// ---- Export Settings ----
{
var section = new Fieldset( "Export Settings", this );
var grid = Layout.Grid();
grid.Spacing = 8;
section.Layout.Add( grid );
// Row 0: output directory, spanning the full width.
grid.AddCell( 0, 0, new Label( "Output Directory", this ) { FixedWidth = LabelWidth } );
outputLabel = new LineEdit( this )
{
ReadOnly = true,
PlaceholderText = "No output folder selected",
ToolTip = "Where generated assets are written",
}.StyleInput();
grid.AddCell( 1, 0, outputLabel, xSpan: 3 );
grid.AddCell( 4, 0, new Button( "Output...", "drive_file_move", this ) { Clicked = PickOutput } );
// Row 1: layout | map light brightness.
grid.AddCell( 0, 1, new Label( "Layout", this ) { FixedWidth = LabelWidth } );
layoutCombo = new ComboBox( this ) { MinimumWidth = 180 };
layoutCombo.AddItem( "Grouped", icon: "folder",
description: "<output>/models, /materials, /textures" );
layoutCombo.AddItem( "Flat", icon: "folder_open",
description: "Everything directly in the output folder" );
layoutCombo.AddItem( "Classic Source", icon: "account_tree",
description: "Assets/models/<subdir> for fbx+vmdl, Assets/materials/<subdir> for vmat+textures" );
layoutCombo.AddItem( "Per Asset", icon: "inventory_2",
description: "<output>/<asset>/ - each asset's model, materials and textures together" );
var savedLayout = EditorCookie.Get( "unreal_import_layout", 0 );
layoutCombo.CurrentIndex = Math.Clamp( savedLayout, 0, LayoutOrder.Length - 1 );
layoutCombo.ItemChanged += () =>
{
EditorCookie.Set( "unreal_import_layout", layoutCombo.CurrentIndex );
UpdateLayoutRow();
};
grid.AddCell( 1, 1, layoutCombo.StyleInput() );
// Scene-light brightness: the conversion is calibrated, but UE maps lean on
// auto-exposure that s&box doesn't have - taste (and pack) varies, so expose a knob.
grid.AddCell( 2, 1, new Label( "Map light brightness", this ), alignment: TextFlag.RightCenter );
lightScaleEdit = new LineEdit( this )
{
Text = EditorCookie.Get( "unreal_import_light_scale", 1f ).ToString( System.Globalization.CultureInfo.InvariantCulture ),
ToolTip = "Multiplier on converted map light intensity. 1 = calibrated default; lower for moodier interiors, higher if too dark. Applies on (re)import.",
};
lightScaleEdit.TextEdited += _ => EditorCookie.Set( "unreal_import_light_scale", LightScale() );
grid.AddCell( 3, 1, lightScaleEdit.StyleInput(), xSpan: 2 );
// Row 2: subfolder | generate LODs.
subfolderLabel = new Label( "Subfolder", this ) { FixedWidth = LabelWidth };
grid.AddCell( 0, 2, subfolderLabel );
subfolderEdit = new LineEdit( this )
{
Text = EditorCookie.Get( "unreal_import_subfolder", "unrealimport" ),
PlaceholderText = "(none)",
ToolTip = "Subfolder under Assets/models and Assets/materials. Leave empty to write straight into them.",
};
subfolderEdit.TextEdited += t =>
{
EditorCookie.Set( "unreal_import_subfolder", t ?? "" );
if ( outputLabel is not null )
outputLabel.Text = OutputDisplay();
};
grid.AddCell( 1, 2, subfolderEdit.StyleInput() );
lodCheckbox = new Checkbox( "Generate LODs", this )
{
Value = EditorCookie.Get( "unreal_import_lods", true ),
ToolTip = "5-level auto chain; untick for full detail at every distance",
};
grid.AddCell( 2, 2, lodCheckbox, xSpan: 3 );
lodCheckbox.Toggled = () => EditorCookie.Set( "unreal_import_lods", lodCheckbox.Value );
// Row 3: what a material picked on its own becomes.
grid.AddCell( 0, 3, new Label( "Material Output", this ) { FixedWidth = LabelWidth } );
materialOutputCombo = new ComboBox( this ) { MinimumWidth = 180 };
materialOutputCombo.AddItem( "Material (.vmat)", icon: "palette",
description: "Standard complex.shader material" );
materialOutputCombo.AddItem( "Terrain (.tmat)", icon: "landscape",
description: "Terrain Material - tiling ground surface with height blending" );
materialOutputCombo.AddItem( "Decal (.decal)", icon: "approval",
description: "Decal Definition - projected decal masked by the colour alpha" );
materialOutputCombo.CurrentIndex = Math.Clamp(
EditorCookie.Get( "unreal_import_material_output", 0 ), 0, MaterialOutputOrder.Length - 1 );
materialOutputCombo.ItemChanged += () =>
{
EditorCookie.Set( "unreal_import_material_output", materialOutputCombo.CurrentIndex );
UpdateStatus();
};
grid.AddCell( 1, 3, materialOutputCombo.StyleInput() );
grid.AddCell( 2, 3, new Label( "Meshes always use .vmat", this )
{
Color = Theme.TextControl.WithAlpha( 0.5f ),
ToolTip = "A model's material slots can't reference a terrain or decal resource, so this only applies to materials imported on their own.",
}, xSpan: 3 );
// Row 4: Per Asset only - which folder to name each asset's subfolder after.
// Fab/Megascans MIs are named like "mi_sjfnbeaa"; the readable name is a couple
// folders up (.../Fine_American_Road_sjfnbeaa/Medium/MI_sjfnbeaa).
perAssetFolderLabel = new Label( "Folder name", this ) { FixedWidth = LabelWidth };
grid.AddCell( 0, 4, perAssetFolderLabel );
perAssetFolderCombo = new ComboBox( this ) { MinimumWidth = 180 };
perAssetFolderCombo.AddItem( "Asset name", icon: "description",
description: "Name each folder after the asset itself (e.g. mi_sjfnbeaa)" );
perAssetFolderCombo.AddItem( "1 folder up", icon: "north",
description: "Name it after the asset's parent folder" );
perAssetFolderCombo.AddItem( "2 folders up", icon: "north",
description: "Grandparent folder - the readable pack name for Fab/Megascans" );
perAssetFolderCombo.AddItem( "3 folders up", icon: "north",
description: "Great-grandparent folder" );
perAssetFolderCombo.CurrentIndex = Math.Clamp( EditorCookie.Get( "unreal_import_perasset_depth", 0 ), 0, 3 );
perAssetFolderCombo.ItemChanged += () => EditorCookie.Set( "unreal_import_perasset_depth", perAssetFolderCombo.CurrentIndex );
grid.AddCell( 1, 4, perAssetFolderCombo.StyleInput() );
grid.AddCell( 2, 4, new Label( "Per Asset layout only", this )
{
Color = Theme.TextControl.WithAlpha( 0.5f ),
ToolTip = "Which folder each asset's subfolder is named after, when using the Per Asset layout.",
}, xSpan: 3 );
// Row 5: texture size ceiling. Fab/Megascans ship 4K (sometimes 8K) maps that a
// prop the size of a crate has no use for - capping them here cuts the import time
// as well as the disk, since every per-pixel pass runs on the smaller bitmap.
grid.AddCell( 0, 5, new Label( "Max texture size", this ) { FixedWidth = LabelWidth } );
maxTextureSizeCombo = new ComboBox( this ) { MinimumWidth = 180 };
maxTextureSizeCombo.AddItem( "Original", icon: "photo_size_select_actual",
description: "Keep whatever the pack ships - no resizing" );
maxTextureSizeCombo.AddItem( "4096", icon: "photo_size_select_large",
description: "Downscale anything larger than 4K" );
maxTextureSizeCombo.AddItem( "2048", icon: "photo_size_select_large",
description: "Downscale anything larger than 2K - a good default for props" );
maxTextureSizeCombo.AddItem( "1024", icon: "photo_size_select_small",
description: "Downscale anything larger than 1K" );
maxTextureSizeCombo.AddItem( "512", icon: "photo_size_select_small",
description: "Downscale anything larger than 512 - small props and blockout" );
maxTextureSizeCombo.CurrentIndex = Math.Clamp(
EditorCookie.Get( "unreal_import_max_texture_size", 0 ), 0, MaxTextureSizeOrder.Length - 1 );
maxTextureSizeCombo.ItemChanged += () =>
EditorCookie.Set( "unreal_import_max_texture_size", maxTextureSizeCombo.CurrentIndex );
grid.AddCell( 1, 5, maxTextureSizeCombo.StyleInput() );
grid.AddCell( 2, 5, new Label( "Smaller = faster import", this )
{
Color = Theme.TextControl.WithAlpha( 0.5f ),
ToolTip = "Textures bigger than this are resampled down on the longest edge, keeping their aspect ratio. Never upscales.",
}, xSpan: 3 );
// Only the field columns absorb extra width; the label columns stay tight.
grid.SetColumnStretch( 0, 3, 0, 2, 0 );
Layout.Add( section );
UpdateLayoutRow();
}
statusLabel = new Label( "", this );
statusLabel.Color = Theme.TextControl.WithAlpha( 0.6f );
Layout.Add( statusLabel );
// Bottom bar
{
var row = Layout.Row();
row.Margin = new Sandbox.UI.Margin( 0, 8, 0, 0 );
row.AddStretchCell();
exportButton = new Button.Primary( "Export to s&box", "move_to_inbox", this ) { Clicked = () => _ = DoExport() };
exportButton.Enabled = false;
row.Add( exportButton );
Layout.Add( row );
}
Width = 640;
MinimumWidth = 480;
Height = 680;
Show();
Focus();
var outputPath = EditorCookie.Get( "unreal_import_project_path", "" );
if ( !string.IsNullOrEmpty( outputPath ) )
{
Log.Info( $"UnrealImportWindow: restoring last project path: {outputPath}" );
uprojectPath = outputPath;
uprojectFolder = Path.GetDirectoryName( outputPath );
projectLabel.Text = $"{Path.GetFileName( outputPath )} ({Path.GetFileName( uprojectFolder )})";
ScanAssets();
}
}
string OutputDisplay()
{
// Classic Source ignores the picked folder entirely - it writes off the Assets root.
if ( SelectedLayout == ImportLayout.ClassicSource )
{
var assets = Sandbox.Project.Current?.GetAssetsPath();
if ( string.IsNullOrEmpty( assets ) )
return "Assets/models + Assets/materials";
var paths = AssetImporter.ResolvePaths( outputFolder, assets, ImportLayout.ClassicSource, Subfolder() );
return $"{paths.ModelsDir} + {paths.MaterialsDir}";
}
if ( string.IsNullOrEmpty( outputFolder ) )
return "";
// Per Asset fans out into a folder per asset - show that rather than implying one folder.
return SelectedLayout == ImportLayout.PerAsset
? Path.Combine( outputFolder, "<asset>" )
: outputFolder;
}
/// <summary>
/// The subfolder field only means anything in Classic Source; grey it out elsewhere.
/// (Per Asset names its folders after the assets themselves, so there's nothing to type.)
/// </summary>
void UpdateLayoutRow()
{
var classic = SelectedLayout == ImportLayout.ClassicSource;
var perAsset = SelectedLayout == ImportLayout.PerAsset;
if ( subfolderEdit is not null )
subfolderEdit.Enabled = classic;
if ( subfolderLabel is not null )
subfolderLabel.Enabled = classic;
// The folder-name depth only matters when each asset gets its own folder.
if ( perAssetFolderCombo is not null )
perAssetFolderCombo.Enabled = perAsset;
if ( perAssetFolderLabel is not null )
perAssetFolderLabel.Enabled = perAsset;
if ( outputLabel is not null )
outputLabel.Text = OutputDisplay();
UpdateExportEnabled();
}
void PickProject()
{
var fd = new FileDialog( null ) { Title = "Select Unreal Project Folder" };
fd.SetFindDirectory();
fd.SetModeOpen();
if ( !fd.Execute() )
return;
var folder = fd.SelectedFile;
var uproject = UnrealLocator.FindUprojectInFolder( folder );
if ( uproject is null )
{
EditorUtility.DisplayDialog( "Not an Unreal project", $"No .uproject found in:\n{folder}" );
return;
}
uprojectFolder = folder;
uprojectPath = uproject;
projectLabel.Text = $"{Path.GetFileName( uproject )} ({Path.GetFileName( folder )})";
EditorCookie.Set( "unreal_import_project_path", uprojectPath );
Log.Info( $"UnrealImportWindow: storing last project path: {uprojectPath}" );
ScanAssets();
}
void PickOutput()
{
var fd = new FileDialog( null ) { Title = "Select Output Folder (inside Assets/)", Directory = outputFolder };
fd.SetFindDirectory();
fd.SetModeOpen();
if ( !string.IsNullOrEmpty( outputFolder ) )
fd.Directory = outputFolder;
if ( !fd.Execute() )
return;
outputFolder = fd.SelectedFile;
outputLabel.Text = OutputDisplay();
UpdateExportEnabled();
}
void ScanAssets()
{
entries.Clear();
mapEntries.Clear();
var content = Path.Combine( uprojectFolder, "Content" );
if ( Directory.Exists( content ) )
{
foreach ( var file in new DirectoryInfo( content ).EnumerateFiles( "*.umap", SearchOption.AllDirectories ) )
{
var gamePath = HeadlessExporter.ToGamePath( uprojectFolder, file.FullName );
if ( gamePath.EndsWith( ".umap", StringComparison.OrdinalIgnoreCase ) )
gamePath = gamePath[..^".umap".Length];
mapEntries.Add( new MapEntry
{
AbsPath = file.FullName,
GamePath = gamePath,
Display = gamePath.StartsWith( "/Game/" ) ? gamePath["/Game/".Length..] : gamePath,
} );
}
mapEntries.Sort( ( a, b ) => string.CompareOrdinal( a.GamePath, b.GamePath ) );
// FileInfo rather than plain paths so we get the size without a second stat per file.
foreach ( var file in new DirectoryInfo( content ).EnumerateFiles( "*.uasset", SearchOption.AllDirectories ) )
{
var kind = ClassifyUasset( file );
if ( kind is null )
continue;
var gamePath = HeadlessExporter.ToGamePath( uprojectFolder, file.FullName );
entries.Add( new AssetEntry
{
Kind = kind.Value,
AbsPath = file.FullName,
GamePath = gamePath,
// Show the path relative to /Game for readability.
Display = gamePath.StartsWith( "/Game/" ) ? gamePath["/Game/".Length..] : gamePath,
SizeBytes = file.Length,
} );
}
}
if ( entries.Count == 0 )
{
Log.Warning( $"No static meshes or materials found in {uprojectFolder}/Content." );
}
entries.Sort( ( a, b ) => string.CompareOrdinal( a.GamePath, b.GamePath ) );
BuildFolderIndex();
RefreshTree();
UpdateExportEnabled();
UpdateStatus();
_ = WarmStats( entries.ToList() );
}
/// <summary>
/// What a .uasset is, from its name and folder - null for anything we can't import.
///
/// Reading the real class out of the package would need version-dependent header parsing;
/// Unreal/Fab naming is conventional enough that prefixes plus the type folder do the job.
/// The exporter re-checks the actual type when it loads the asset, so a wrong guess here
/// costs a warning, not a broken import.
/// </summary>
static AssetKind? ClassifyUasset( FileInfo file )
{
var dir = (file.DirectoryName ?? "").Replace( '\\', '/' );
var name = Path.GetFileNameWithoutExtension( file.Name );
// Name prefixes are stronger evidence than the folder - a material parked in a
// Meshes/ folder is still a material.
if ( name.StartsWith( "SM_", StringComparison.OrdinalIgnoreCase ) )
return AssetKind.Mesh;
// MI_ = Material Instance, M_/MM_ = Material (master). Textures are T_/TX_, so the
// single-letter M_ prefix doesn't collide with anything else we'd want to list.
if ( name.StartsWith( "MI_", StringComparison.OrdinalIgnoreCase )
|| name.StartsWith( "M_", StringComparison.OrdinalIgnoreCase )
|| name.StartsWith( "MM_", StringComparison.OrdinalIgnoreCase ) )
return AssetKind.Material;
if ( dir.Contains( "/Meshes", StringComparison.OrdinalIgnoreCase ) )
return AssetKind.Mesh;
if ( dir.Contains( "/Materials", StringComparison.OrdinalIgnoreCase ) )
return AssetKind.Material;
return null;
}
/// <summary>
/// Background pass reading tri counts for everything, so folder rows and the status
/// total become accurate without expanding every folder. Throttled inside
/// UassetMeshStats; cached on disk so later opens are instant.
/// </summary>
async Task WarmStats( List<AssetEntry> list )
{
int done = 0;
foreach ( var e in list )
{
if ( !IsValid || !entries.Contains( e ) )
return;
if ( e.Triangles < 0 )
{
var stats = await UassetMeshStats.LoadAsync( e.AbsPath );
if ( stats is not null )
e.Triangles = stats.Triangles;
}
if ( ++done % 64 == 0 )
{
UpdateStatus();
tree?.Update();
}
}
if ( IsValid )
{
UpdateStatus();
tree?.Update();
}
}
// ---- folder index ----
static string ParentOf( string path ) => path.Contains( '/' ) ? path[..path.LastIndexOf( '/' )] : "";
static string DirOf( string display ) => display.Contains( '/' ) ? display[..display.LastIndexOf( '/' )] : "";
FolderBucket Bucket( string path )
{
if ( !folders.TryGetValue( path, out var b ) )
folders[path] = b = new FolderBucket();
return b;
}
void BuildFolderIndex()
{
folders.Clear();
Bucket( "" );
void RegisterChain( string dir )
{
while ( dir.Length > 0 )
{
var parent = ParentOf( dir );
Bucket( parent ).Subfolders.Add( dir );
Bucket( dir );
dir = parent;
}
}
foreach ( var e in entries )
{
var dir = DirOf( e.Display );
RegisterChain( dir );
Bucket( dir ).Assets.Add( e );
for ( var p = dir; ; p = ParentOf( p ) )
{
Bucket( p ).Subtree.Add( e );
if ( p.Length == 0 )
break;
}
}
foreach ( var m in mapEntries )
{
var dir = DirOf( m.Display );
RegisterChain( dir );
Bucket( dir ).Maps.Add( m );
}
rootNodes = BuildFolderChildNodes( "" ).ToList();
}
bool FolderHasChildren( string path )
=> folders.TryGetValue( path, out var b ) && (b.Subfolders.Count > 0 || b.Assets.Count > 0 || b.Maps.Count > 0);
IEnumerable<TreeNode> BuildFolderChildNodes( string path )
{
if ( !folders.TryGetValue( path, out var b ) )
yield break;
foreach ( var sub in b.Subfolders )
yield return new FolderNode( this, sub );
foreach ( var m in b.Maps )
yield return new MapNode( this, m, fullPath: false );
foreach ( var e in b.Assets )
yield return new AssetNode( this, e, fullPath: false );
}
(int selected, int total) SubtreeSelection( string path )
{
if ( !folders.TryGetValue( path, out var b ) )
return (0, 0);
int sel = 0;
foreach ( var e in b.Subtree )
if ( e.Selected )
sel++;
return (sel, b.Subtree.Count);
}
/// <summary>Right-hand folder label: "12 meshes · 3 materials", omitting whichever is zero.</summary>
string SubtreeSummary( string path )
{
if ( !folders.TryGetValue( path, out var b ) )
return "";
int meshes = b.Subtree.Count( e => e.IsMesh );
int mats = b.Subtree.Count - meshes;
var parts = new List<string>();
if ( meshes > 0 )
parts.Add( meshes == 1 ? "1 mesh" : $"{meshes} meshes" );
if ( mats > 0 )
parts.Add( mats == 1 ? "1 material" : $"{mats} materials" );
return string.Join( " · ", parts );
}
void SetFolderSelected( string path, bool on )
{
if ( !folders.TryGetValue( path, out var b ) )
return;
foreach ( var e in b.Subtree )
e.Selected = on;
UpdateStatus();
tree?.Update();
}
// ---- filtering / tree ----
/// <summary>Entries matching the current search box, in list order.</summary>
IEnumerable<AssetEntry> Filtered()
{
if ( string.IsNullOrWhiteSpace( searchFilter ) )
return entries;
var term = searchFilter.Trim();
return entries.Where( e => e.Display.Contains( term, StringComparison.OrdinalIgnoreCase ) );
}
/// <summary>Maps matching the current search box.</summary>
IEnumerable<MapEntry> FilteredMaps()
{
if ( string.IsNullOrWhiteSpace( searchFilter ) )
return mapEntries;
var term = searchFilter.Trim();
return mapEntries.Where( e => e.Display.Contains( term, StringComparison.OrdinalIgnoreCase ) );
}
/// <summary>Tree of folders normally; a flat list while searching or when toggled flat.</summary>
void RefreshTree()
{
if ( tree is null )
return;
bool searching = !string.IsNullOrWhiteSpace( searchFilter );
if ( !searching && !flatView )
{
// Persistent nodes, so folder expansion survives search/flat round-trips.
tree.SetItems( rootNodes );
if ( rootNodes.Count == 1 )
tree.Open( rootNodes[0] );
}
else
{
// Filtered()/FilteredMaps() return everything when the search box is empty,
// so this doubles as the plain flat view.
var flat = new List<TreeNode>();
flat.AddRange( FilteredMaps().Select( m => (TreeNode)new MapNode( this, m, fullPath: true ) ) );
flat.AddRange( Filtered().Select( e => (TreeNode)new AssetNode( this, e, fullPath: true ) ) );
if ( flat.Count == 0 && searching )
flat.Add( new TreeNode( $"No matches for \"{searchFilter.Trim()}\"" ) );
tree.SetItems( flat );
}
}
static string FormatSize( long bytes )
{
if ( bytes >= 1024L * 1024 * 1024 ) return $"{bytes / (1024f * 1024 * 1024):0.##} GB";
if ( bytes >= 1024 * 1024 ) return $"{bytes / (1024f * 1024):0.#} MB";
if ( bytes >= 1024 ) return $"{bytes / 1024f:0} KB";
return $"{bytes} B";
}
static string FormatCount( long n )
{
if ( n >= 1_000_000 ) return $"{n / 1_000_000f:0.##}M";
if ( n >= 1_000 ) return $"{n / 1_000f:0.#}k";
return $"{n}";
}
/// <summary>Ticks or unticks everything currently shown - the search filter narrows this.</summary>
void SetAll( bool on )
{
foreach ( var e in Filtered() )
e.Selected = on;
tree?.Update();
UpdateStatus();
}
void UpdateStatus()
{
if ( statusLabel is null )
return;
if ( entries.Count == 0 )
{
statusLabel.Text = "";
return;
}
var selected = entries.Where( e => e.Selected ).ToList();
var shown = Filtered().Count();
int meshCount = entries.Count( e => e.IsMesh );
var found = $"{meshCount} static mesh(es), {entries.Count - meshCount} material(s)";
var text = shown == entries.Count ? $"{found} found." : $"{shown} of {found} shown.";
if ( selected.Count > 0 )
{
text += $" {selected.Count} selected ({FormatSize( selected.Sum( e => e.SizeBytes ) )}";
// Tri counts only exist for meshes - "+" means some are still being read.
long tris = selected.Sum( e => Math.Max( 0, e.Triangles ) );
bool partial = selected.Any( e => e.IsMesh && e.Triangles < 0 );
if ( tris > 0 )
text += $", {FormatCount( tris )}{(partial ? "+" : "")} tris";
text += ").";
}
else
{
text += " Nothing selected.";
}
statusLabel.Text = text;
}
void UpdateExportEnabled()
{
if ( exportButton is null )
return;
// Classic Source writes off the Assets root, so it doesn't need a picked output folder.
var haveOutput = SelectedLayout == ImportLayout.ClassicSource || !string.IsNullOrEmpty( outputFolder );
exportButton.Enabled = entries.Count > 0 && haveOutput && !string.IsNullOrEmpty( uprojectPath );
}
/// <summary>Push a live export/import event into the progress toast + status line.</summary>
void ApplyProgress( IProgressSection progress, ExportEvent ev )
{
if ( ev.Total is > 0 )
progress.TotalCount = ev.Total.Value;
if ( ev.Done is > 0 )
progress.Current = ev.Done.Value;
if ( !string.IsNullOrEmpty( ev.Message ) )
{
progress.Subtitle = ev.Message;
statusLabel.Text = ev.Done is > 0 && ev.Total is > 0 ? $"[{ev.Done}/{ev.Total}] {ev.Message}" : ev.Message;
}
}
/// <summary>Locate ue_export.py + the right UnrealEditor-Cmd, dialoging on failure.</summary>
bool TryResolveTools( out string script, out string editorCmd )
{
editorCmd = null;
script = HeadlessExporter.FindExportScript();
if ( script is null )
{
EditorUtility.DisplayDialog( "Export script missing", "Could not find Tools/ue_export.py in this library." );
return false;
}
var engineVersion = UnrealLocator.ReadEngineAssociation( uprojectPath );
editorCmd = UnrealLocator.FindEditorCmd( engineVersion );
if ( editorCmd is null )
{
EditorUtility.DisplayDialog( "Unreal not found",
$"Couldn't locate UnrealEditor-Cmd.exe for engine '{engineVersion}'.\nIs Unreal installed under Epic Games?" );
return false;
}
return true;
}
/// <summary>Double-clicking a map row lands here - confirm before kicking a long export.</summary>
void DoImportMap( MapEntry map )
{
EditorUtility.DisplayDialog( "Import map?",
$"Import {map.Display}?\n\nThis exports every mesh the level uses and builds a prefab of its layout. It can take a while.",
"Cancel", "Import", () => _ = RunImportMap( map ), "🌍" );
}
async Task RunImportMap( MapEntry map )
{
if ( string.IsNullOrEmpty( outputFolder ) && SelectedLayout != ImportLayout.ClassicSource )
{
EditorUtility.DisplayDialog( "No output folder", "Pick an output folder (inside Assets/) first." );
return;
}
if ( !TryResolveTools( out var script, out var editorCmd ) )
return;
await Task.Delay( 100 );
statusLabel.Text = $"Importing map {map.Display}... this exports every mesh the level uses and can take a while.";
using var progress = Application.Editor.ProgressSection();
progress.Title = $"Exporting map {map.Display}";
var progressToken = progress.GetCancel();
try
{
var export = await HeadlessExporter.Run( editorCmd, uprojectPath, Enumerable.Empty<string>(), script, progressToken, mapGamePath: map.GamePath,
onProgress: ev => ApplyProgress( progress, ev ) );
if ( !export.Success )
{
EditorUtility.DisplayDialog( "Map export failed", export.Error ?? "Unknown error.", icon: "⚠️" );
statusLabel.Text = "Map export failed.";
return;
}
progress.Title = $"Importing map {map.Display}";
var manifest = ImportManifest.Load( export.ManifestPath );
var summary = await AssetImporter.Import( manifest, export.StagingDir, outputFolder, progressToken, SelectedLayout, Subfolder(),
generateLods: lodCheckbox is null || lodCheckbox.Value,
lightScale: LightScale(),
materialOutput: SelectedMaterialOutput,
perAssetFolderDepth: PerAssetFolderDepth,
maxTextureSize: MaxTextureSize,
onProgress: ( done, total, name ) => ApplyProgress( progress, new ExportEvent( done, total, $"Importing {name}" ) ) );
var msg = $"Imported {summary.Models} model(s), {summary.Materials} material(s), {summary.Textures} texture(s).\n" +
$"{summary.Placements} placement(s) written to:\n{summary.PrefabPath}";
if ( summary.Warnings.Count > 0 )
msg += "\n\nWarnings:\n - " + string.Join( "\n - ", summary.Warnings.Take( 10 ) );
EditorUtility.DisplayDialog( "Map import complete", msg, icon: "✅" );
statusLabel.Text = $"Done: {summary.Placements} placements, {summary.Models} models.";
}
catch ( Exception e )
{
EditorUtility.DisplayDialog( "Map import error", e.ToString(), icon: "⚠️" );
statusLabel.Text = "Map import error.";
}
}
async Task DoExport()
{
// Deliberately ignores the search filter - ticks persist across filtering, so everything
// the user has selected gets exported whether or not it's on screen right now.
var selectedEntries = entries.Where( e => e.Selected ).ToList();
var selected = selectedEntries.Select( e => e.GamePath ).ToList();
if ( selected.Count == 0 )
{
EditorUtility.DisplayDialog( "Nothing selected", "Tick at least one mesh or material to export." );
return;
}
if ( !TryResolveTools( out var script, out var editorCmd ) )
return;
await Task.Delay( 100 );
// Meshes and materials go over in one selection - the export script routes by asset type.
int meshCount = selectedEntries.Count( e => e.IsMesh );
var what = meshCount == selected.Count ? $"{meshCount} mesh(es)"
: meshCount == 0 ? $"{selected.Count} material(s)"
: $"{meshCount} mesh(es) + {selected.Count - meshCount} material(s)";
statusLabel.Text = $"Exporting {what} via headless Unreal... this can take a minute.";
using var progress = Application.Editor.ProgressSection();
progress.Title = "Exporting from Unreal";
progress.TotalCount = selected.Count;
var progressToken = progress.GetCancel();
try
{
var export = await HeadlessExporter.Run( editorCmd, uprojectPath, selected, script, progressToken,
onProgress: ev => ApplyProgress( progress, ev ) );
if ( !export.Success )
{
EditorUtility.DisplayDialog( "Export failed", export.Error ?? "Unknown error.", icon: "⚠️" );
statusLabel.Text = "Export failed.";
return;
}
progress.Title = "Importing into s&box";
var manifest = ImportManifest.Load( export.ManifestPath );
var summary = await AssetImporter.Import( manifest, export.StagingDir, outputFolder, progressToken, SelectedLayout, Subfolder(),
generateLods: lodCheckbox is null || lodCheckbox.Value,
lightScale: LightScale(),
materialOutput: SelectedMaterialOutput,
perAssetFolderDepth: PerAssetFolderDepth,
maxTextureSize: MaxTextureSize,
onProgress: ( done, total, name ) => ApplyProgress( progress, new ExportEvent( done, total, $"Importing {name}" ) ) );
var msg = $"Imported {summary.Models} model(s), {summary.Materials} material(s), {summary.Textures} texture(s).\n\n" +
$"Output:\n{summary.OutputDir}";
if ( summary.Warnings.Count > 0 )
msg += "\n\nWarnings:\n - " + string.Join( "\n - ", summary.Warnings.Take( 10 ) );
EditorUtility.DisplayDialog( "Import complete", msg, icon: "✅" );
statusLabel.Text = $"Done: {summary.Models} models, {summary.Materials} materials, {summary.Textures} textures.";
}
catch ( Exception e )
{
EditorUtility.DisplayDialog( "Import error", e.ToString(), icon: "⚠️" );
statusLabel.Text = "Import error.";
}
}
}
Editor
library
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;
}
}
Editor
library
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Editor.Mcp;
using Sandbox;
namespace Editor.UnrealImporter;
// TEMPORARY verification tool - delete once scene import scale is confirmed.
[McpToolset( "unrealimporter", "Unreal importer debug tools" )]
public static class SceneDebugTools
{
/// <summary>Run AssetImporter.Import on a staging folder (manifest.json + FBX + PNG).</summary>
/// <param name="stagingDir">Staging folder containing manifest.json.</param>
/// <param name="outputFolder">Output folder inside the project's Assets/.</param>
/// <param name="layout">Grouped (default), Flat, ClassicSource or PerAsset.</param>
/// <param name="materialOutput">Material (default), Terrain or Decal.</param>
/// <param name="perAssetFolderDepth">PerAsset layout: folders up the /Game path to name each folder after (0 = own name).</param>
/// <param name="maxTextureSize">Cap every written texture's longest edge, downscaling bigger sources (0 = keep as-is).</param>
[McpTool( "unreal_scene_import_test" )]
public static async Task<string> SceneImportTest( string stagingDir, string outputFolder, string layout = null, string materialOutput = null, int perAssetFolderDepth = 0, int maxTextureSize = 0 )
{
var manifestPath = Path.Combine( stagingDir, "manifest.json" );
if ( !File.Exists( manifestPath ) )
return $"no manifest.json in {stagingDir}";
if ( !System.Enum.TryParse<ImportLayout>( layout ?? "Grouped", ignoreCase: true, out var importLayout ) )
return $"unknown layout '{layout}'";
if ( !System.Enum.TryParse<MaterialOutput>( materialOutput ?? "Material", ignoreCase: true, out var matOutput ) )
return $"unknown material output '{materialOutput}'";
var manifest = ImportManifest.Load( manifestPath );
var summary = await AssetImporter.Import( manifest, stagingDir, outputFolder, CancellationToken.None, importLayout, materialOutput: matOutput, perAssetFolderDepth: perAssetFolderDepth, maxTextureSize: maxTextureSize );
var result = $"models={summary.Models} materials={summary.Materials} textures={summary.Textures} " +
$"placements={summary.Placements} prefab={summary.PrefabPath ?? "(none)"}";
if ( summary.Warnings.Count > 0 )
result += "\nwarnings:\n - " + string.Join( "\n - ", summary.Warnings.Take( 10 ) );
return result;
}
/// <summary>TEMP: read tri/vert counts from a .uasset via UassetMeshStats.</summary>
/// <param name="uassetPath">Absolute path to a .uasset.</param>
[McpTool( "unreal_meshstats_test" )]
public static async Task<string> MeshStatsTest( string uassetPath )
{
var stats = await UassetMeshStats.LoadAsync( uassetPath );
if ( stats is null )
return "no stats found";
return $"tris={stats.Triangles} verts={stats.Vertices} mats={stats.Materials} lods={stats.LODs}";
}
/// <summary>TEMP: run a small headless export and log the live progress events (verifies log tailing).</summary>
/// <param name="uprojectPath">Absolute path to the .uproject.</param>
/// <param name="assets">';'-separated /Game asset paths to export.</param>
[McpTool( "unreal_export_progress_test" )]
public static async Task<string> ExportProgressTest( string uprojectPath, string assets )
{
var script = HeadlessExporter.FindExportScript();
var editorCmd = UnrealLocator.FindEditorCmd( UnrealLocator.ReadEngineAssociation( uprojectPath ) );
if ( script is null || editorCmd is null )
return "tools not found";
var events = new List<string>();
var result = await HeadlessExporter.Run( editorCmd, uprojectPath, assets.Split( ';' ), script, System.Threading.CancellationToken.None,
onProgress: ev =>
{
var line = $"{ev.Done}/{ev.Total} {ev.Message}";
events.Add( line );
Log.Info( $"UEPROG {line}" );
} );
return $"success={result.Success} events={events.Count}\n" + string.Join( "\n", events.TakeLast( 12 ) );
}
/// <summary>TEMP: open the Unreal Importer window for UI verification.</summary>
[McpTool( "unreal_open_import_window" )]
public static string OpenImportWindow()
{
_ = new UnrealImportWindow();
return "opened";
}
/// <summary>Load a model and report its bounds in inches.</summary>
/// <param name="modelPath">Model content path, e.g. "unrealimport/models/x.vmdl".</param>
[McpTool( "unreal_model_bounds" )]
public static string ModelBounds( string modelPath )
{
var model = Model.Load( modelPath );
if ( model is null || model.IsError )
return $"failed to load {modelPath}";
var b = model.Bounds;
return $"size=({b.Size.x:0.##}, {b.Size.y:0.##}, {b.Size.z:0.##}) in mins=({b.Mins.x:0.##},{b.Mins.y:0.##},{b.Mins.z:0.##}) maxs=({b.Maxs.x:0.##},{b.Maxs.y:0.##},{b.Maxs.z:0.##})";
}
/// <summary>Bounds of every .vmdl in a folder, one json object per line.</summary>
/// <param name="folder">Absolute folder containing .vmdl files.</param>
[McpTool( "unreal_all_model_bounds" )]
public static string AllModelBounds( string folder )
{
var sb = new System.Text.StringBuilder();
foreach ( var f in Directory.EnumerateFiles( folder, "*.vmdl" ) )
{
var rel = Path.GetRelativePath( Sandbox.Project.Current.GetAssetsPath(), f ).Replace( '\\', '/' );
var model = Model.Load( rel );
if ( model is null || model.IsError )
{
sb.AppendLine( $"{{\"model\":\"{Path.GetFileName( f )}\",\"error\":true}}" );
continue;
}
var b = model.Bounds;
sb.AppendLine( System.FormattableString.Invariant(
$"{{\"model\":\"{Path.GetFileName( f )}\",\"min\":[{b.Mins.x:0.###},{b.Mins.y:0.###},{b.Mins.z:0.###}],\"max\":[{b.Maxs.x:0.###},{b.Maxs.y:0.###},{b.Maxs.z:0.###}]}}" ) );
}
return sb.ToString();
}
}
Editor
library
using Sandbox;
namespace Editor.UnrealImporter;
/// <summary>
/// Shared colours + painting helpers for the importer window.
///
/// The editor's dark theme sets ControlBackground and WindowBackground to the SAME value
/// (#181818), so a stock LineEdit or ComboBox is painted exactly the colour of the window
/// behind it and reads as loose text rather than a field. These helpers derive contrasting
/// tones by lerping towards the theme's surface colours, so they still track a custom theme
/// instead of hardcoding greys.
/// </summary>
public static class ImportStyle
{
/// <summary>Section/panel fill - a step up from the window background.</summary>
public static Color Panel => Color.Lerp( Theme.WindowBackground, Theme.SurfaceBackground, 0.22f );
/// <summary>Input field fill - a further step up, so fields read as sunken boxes.</summary>
public static Color Input => Color.Lerp( Theme.WindowBackground, Theme.SurfaceBackground, 0.45f );
/// <summary>Alternating tree row tint (matches the editor's own scene tree).</summary>
public static Color RowStripe => Theme.SurfaceLightBackground.WithAlpha( 0.06f );
/// <summary>Give a text field / combo a visible box, since the theme's default is invisible.</summary>
public static T StyleInput<T>( this T widget ) where T : Widget
{
widget.SetStyles(
$"background-color: {Input.Hex};" +
$"border: 1px solid {Theme.Border.WithAlpha( 0.5f ).Hex};" +
$"border-radius: {Theme.ControlRadius}px;" );
return widget;
}
/// <summary>
/// Row background for a tree item: selection, then hover, then a zebra stripe. Spans the
/// full width of the view rather than the (indented) item rect, so nested rows still
/// stripe in line with their parents.
/// </summary>
public static void PaintRow( VirtualWidget item, TreeView tree )
{
var full = item.Rect;
full.Left = 0;
if ( tree.IsValid() )
full.Right = tree.Width;
Paint.ClearPen();
if ( item.Selected || item.Pressed )
Paint.SetBrush( Theme.SelectedBackground.WithAlpha( 0.9f ) );
else if ( item.Hovered )
Paint.SetBrush( Theme.SelectedBackground.WithAlpha( 0.25f ) );
else if ( item.Row % 2 == 0 )
Paint.SetBrush( RowStripe );
else
return;
Paint.DrawRect( full );
}
}
Editor
library
using System;
using System.IO;
using Sandbox;
namespace Editor.UnrealImporter;
/// <summary>
/// Output texture filenames (no path) for a processed material, or null where absent.
/// </summary>
public class ProcessedTextures
{
public string Color;
public string Alpha;
public string Normal;
public string Roughness;
public string Metallic;
public string Ao;
public string Emissive;
public string TintMask;
/// <summary>Displacement/height map. Unused by complex.shader; terrain + decal resources want it.</summary>
public string Height;
/// <summary>Packed R=Roughness G=Metal B=Occlusion, for decal resources (which take one RMO map).</summary>
public string RoughMetalOcclusion;
/// <summary>Grayscale emissive mask extracted from the albedo's alpha (opaque materials with emissive params).</summary>
public string SelfIllumMask;
}
/// <summary>What the albedo's alpha channel means for this material - decided from the Unreal blend mode.</summary>
public enum AlphaRole
{
/// <summary>UE blends/masks with it - extract as a translucency/alpha-test map.</summary>
Translucency,
/// <summary>Opaque material with emissive params - the alpha is a self-illum mask.</summary>
SelfIllum,
/// <summary>Opaque, no emissive - the alpha packs something we can't interpret; ignore it.</summary>
Ignore,
}
/// <summary>
/// Turns Unreal's raw exported textures into sbox-ready ones using sbox's Bitmap:
/// - splits RMA (R=roughness, G=metallic, B=ao) into separate grayscale maps
/// - flips the normal's green channel (Unreal DirectX -> sbox OpenGL)
/// - extracts the albedo's alpha to a separate map
/// - writes everything as <base>_<role>.png (lowercase, no dots)
/// </summary>
public static class TextureProcessor
{
/// <param name="packRmo">
/// Emit one packed R=Rough G=Metal B=AO map instead of three grayscale ones. Decal
/// resources take a single RMO texture, so splitting and re-packing would be lossy churn.
/// </param>
/// <param name="wantHeight">Also process the displacement map (terrain + decal resources use it).</param>
/// <param name="maxTextureSize">
/// Downscale anything larger than this on the longest edge (0 = keep the source size).
/// Applied at LOAD time, so the channel splits and per-pixel passes below run on the
/// smaller bitmap too - a 4K pack imports several times faster at 1K.
/// </param>
public static ProcessedTextures Process( ManifestMaterial mat, string stagingDir, string outputTextureDir, string baseName, AlphaRole alphaRole = AlphaRole.Translucency, bool packRmo = false, bool wantHeight = false, int maxTextureSize = 0 )
{
Directory.CreateDirectory( outputTextureDir );
var result = new ProcessedTextures();
// --- Opacity (dedicated map) ---
// Cutout foliage and thatch ship their mask as its OWN texture and leave the albedo
// fully opaque, so there is no alpha for the colour block below to extract. A texture
// Unreal explicitly bound to an opacity parameter wins over the albedo's alpha; done
// first so that alpha still serves as the fallback when this map is missing.
if ( alphaRole == AlphaRole.Translucency && !string.IsNullOrEmpty( mat.Opacity ) )
{
using var opacity = Load( stagingDir, mat.Opacity, maxTextureSize );
if ( opacity is not null )
result.Alpha = Save( ExtractChannel( opacity, DominantChannel( opacity, includeAlpha: true ) ), outputTextureDir, baseName, "alpha", dispose: true );
}
// --- Color (+ alpha) ---
if ( !string.IsNullOrEmpty( mat.Alb ) )
{
using var alb = Load( stagingDir, mat.Alb, maxTextureSize );
if ( alb is not null )
{
// Export the albedo UNTOUCHED. Fab/Megascans albedos already contain the final
// colours; the material's tint mask + tint colours are an OPTIONAL runtime-recolour
// system (team colours / variants). Baking them here double-colours and corrupts
// the result, so we keep the albedo pristine and leave tint inert in the vmat.
result.Color = Save( alb, outputTextureDir, baseName, "color" );
if ( result.Alpha is null && !alb.IsOpaque() && alphaRole != AlphaRole.Ignore )
{
if ( alphaRole == AlphaRole.SelfIllum )
result.SelfIllumMask = Save( ExtractAlpha( alb ), outputTextureDir, baseName, "selfillum", dispose: true );
else
result.Alpha = Save( ExtractAlpha( alb ), outputTextureDir, baseName, "alpha", dispose: true );
}
}
}
// --- Normal (flip green) ---
if ( !string.IsNullOrEmpty( mat.Nrm ) )
{
using var nrm = Load( stagingDir, mat.Nrm, maxTextureSize );
if ( nrm is not null )
result.Normal = Save( FlipGreen( nrm ), outputTextureDir, baseName, "normal", dispose: true );
}
// --- Packed RMA/ORM -> roughness / metallic / ao (or one repacked RMO) ---
if ( !string.IsNullOrEmpty( mat.Rma ) )
{
using var rma = Load( stagingDir, mat.Rma, maxTextureSize );
if ( rma is not null )
{
var (rough, metal, ao) = RmaChannels( mat.RmaOrder );
if ( packRmo )
result.RoughMetalOcclusion = Save( Reorder( rma, rough, metal, ao ), outputTextureDir, baseName, "rmo", dispose: true );
else
{
result.Roughness = Save( ExtractChannel( rma, rough ), outputTextureDir, baseName, "roughness", dispose: true );
result.Metallic = Save( ExtractChannel( rma, metal ), outputTextureDir, baseName, "metallic", dispose: true );
result.Ao = Save( ExtractChannel( rma, ao ), outputTextureDir, baseName, "ao", dispose: true );
}
}
}
// --- Explicit single-channel maps (override RMA-derived if both somehow present) ---
ProcessSingle( mat.Rough, stagingDir, outputTextureDir, baseName, "roughness", ref result.Roughness, maxTextureSize );
ProcessSingle( mat.Metal, stagingDir, outputTextureDir, baseName, "metallic", ref result.Metallic, maxTextureSize );
ProcessSingle( mat.Ao, stagingDir, outputTextureDir, baseName, "ao", ref result.Ao, maxTextureSize );
ProcessSingle( mat.Emissive, stagingDir, outputTextureDir, baseName, "emissive", ref result.Emissive, maxTextureSize );
if ( wantHeight )
ProcessSingle( mat.Height, stagingDir, outputTextureDir, baseName, "height", ref result.Height, maxTextureSize );
// A material with separate maps still owes a decal one packed RMO - build it from
// whichever of the three exist (missing channels stay black).
if ( packRmo && result.RoughMetalOcclusion is null && (result.Roughness ?? result.Metallic ?? result.Ao) is not null )
{
var packed = Combine( outputTextureDir, result.Roughness, result.Metallic, result.Ao );
if ( packed is not null )
result.RoughMetalOcclusion = Save( packed, outputTextureDir, baseName, "rmo", dispose: true );
}
// --- Tint mask (grayscale) - export the populated channel so it can drive optional
// runtime tinting. Masks are single-channel but the data isn't always in R (this ATV
// mask lives in B), so pick whichever channel actually carries data.
if ( !string.IsNullOrEmpty( mat.TintMask ) )
{
using var mask = Load( stagingDir, mat.TintMask, maxTextureSize );
if ( mask is not null )
result.TintMask = Save( ExtractChannel( mask, DominantChannel( mask ) ), outputTextureDir, baseName, "tintmask", dispose: true );
}
return result;
}
/// <summary>
/// Which channel index (0=R, 1=G, 2=B) holds roughness / metalness / AO for a packed
/// mask, from the manifest's layout name. Fab ships _RMA, Megascans ships _ORM with the
/// exact same look but a different order - splitting one as the other swaps roughness
/// and AO, which reads as a flat, wrongly-shiny surface rather than an obvious error.
/// </summary>
static (int rough, int metal, int ao) RmaChannels( string order ) => (order ?? "rma").ToLowerInvariant() switch
{
// "aorm" is "orm" spelled out - the leading A is the occlusion the O already names.
// Read as plain RMA it binds the ROUGHNESS map as metalness (a near-white metal mask)
// and the empty metal channel as AO (fully black), which wrecks the lighting.
"orm" or "arm" or "aorm" => (1, 2, 0),
"mra" => (1, 0, 2),
_ => (0, 1, 2),
};
static void ProcessSingle( string rel, string stagingDir, string outDir, string baseName, string role, ref string slot, int maxSize = 0 )
{
if ( string.IsNullOrEmpty( rel ) )
return;
using var bmp = Load( stagingDir, rel, maxSize );
if ( bmp is not null )
slot = Save( bmp, outDir, baseName, role );
}
static Bitmap Load( string stagingDir, string relPath, int maxSize = 0 )
{
var abs = Path.Combine( stagingDir, relPath.Replace( '/', Path.DirectorySeparatorChar ) );
if ( !File.Exists( abs ) )
return null;
var bmp = Bitmap.CreateFromBytes( File.ReadAllBytes( abs ) );
if ( bmp is null || !bmp.IsValid )
return null;
return Downscale( bmp, maxSize );
}
/// <summary>
/// Shrink a bitmap so neither edge exceeds maxSize, keeping its aspect ratio. Returns the
/// original when it already fits (or when maxSize is 0), so the caller always owns exactly
/// one bitmap. Never upscales - the cap is a ceiling, not a target.
/// </summary>
static Bitmap Downscale( Bitmap bmp, int maxSize )
{
if ( maxSize <= 0 || (bmp.Width <= maxSize && bmp.Height <= maxSize) )
return bmp;
float scale = maxSize / (float)Math.Max( bmp.Width, bmp.Height );
int w = Math.Max( 1, (int)MathF.Round( bmp.Width * scale ) );
int h = Math.Max( 1, (int)MathF.Round( bmp.Height * scale ) );
var resized = bmp.Resize( w, h );
bmp.Dispose();
return resized;
}
static string Save( Bitmap bmp, string outDir, string baseName, string role, bool dispose = false )
{
var fileName = $"{baseName}_{role}.png";
File.WriteAllBytes( Path.Combine( outDir, fileName ), bmp.ToPng() );
if ( dispose )
bmp.Dispose();
return fileName;
}
/// <summary>
/// Index (0=R,1=G,2=B,3=A) of the channel carrying the mask data (widest value range).
/// includeAlpha lets alpha win - opacity maps are sometimes white RGB with the cutout in
/// alpha, whereas a tint mask never lives there and would only be spoiled by considering it.
/// </summary>
static int DominantChannel( Bitmap src, bool includeAlpha = false )
{
var px = src.GetPixels();
var min = new[] { 1f, 1f, 1f, 1f };
var max = new[] { 0f, 0f, 0f, 0f };
// Sample sparsely - masks are large and uniform enough that this is plenty.
int step = Math.Max( 1, px.Length / 100000 );
for ( int i = 0; i < px.Length; i += step )
{
var c = px[i];
var v = new[] { c.r, c.g, c.b, c.a };
for ( int n = 0; n < 4; n++ )
{
if ( v[n] < min[n] ) min[n] = v[n];
if ( v[n] > max[n] ) max[n] = v[n];
}
}
int count = includeAlpha ? 4 : 3;
int best = 0;
for ( int n = 1; n < count; n++ )
{
if ( max[n] - min[n] > max[best] - min[best] )
best = n;
}
return best;
}
/// <summary>
/// New bitmap with the source channels moved into R=Rough, G=Metal, B=AO order.
/// A source that's already RMA comes out unchanged.
/// </summary>
static Bitmap Reorder( Bitmap src, int rough, int metal, int ao )
{
var pixels = src.GetPixels();
for ( int i = 0; i < pixels.Length; i++ )
{
var c = pixels[i];
float Ch( int n ) => n == 0 ? c.r : n == 1 ? c.g : c.b;
pixels[i] = new Color( Ch( rough ), Ch( metal ), Ch( ao ), 1f );
}
var bmp = new Bitmap( src.Width, src.Height );
bmp.SetPixels( pixels );
return bmp;
}
/// <summary>
/// Pack three already-written grayscale maps into one RMO bitmap. Null slots stay black.
/// Returns null unless every supplied map shares the same dimensions - rescaling here
/// would be guesswork, and a mismatched pack is worse than none.
/// </summary>
static Bitmap Combine( string dir, string roughFile, string metalFile, string aoFile )
{
Bitmap Read( string f ) => string.IsNullOrEmpty( f ) ? null : Load( dir, f );
using var r = Read( roughFile );
using var m = Read( metalFile );
using var a = Read( aoFile );
var any = r ?? m ?? a;
if ( any is null )
return null;
foreach ( var b in new[] { r, m, a } )
{
if ( b is not null && (b.Width != any.Width || b.Height != any.Height) )
return null;
}
var rp = r?.GetPixels();
var mp = m?.GetPixels();
var ap = a?.GetPixels();
var outPixels = new Color[any.Width * any.Height];
for ( int i = 0; i < outPixels.Length; i++ )
outPixels[i] = new Color( rp?[i].r ?? 0f, mp?[i].r ?? 0f, ap?[i].r ?? 0f, 1f );
var bmp = new Bitmap( any.Width, any.Height );
bmp.SetPixels( outPixels );
return bmp;
}
/// <summary>New grayscale bitmap from one channel (0=R, 1=G, 2=B, 3=A).</summary>
static Bitmap ExtractChannel( Bitmap src, int channel )
{
var pixels = src.GetPixels();
for ( int i = 0; i < pixels.Length; i++ )
{
var c = pixels[i];
float v = channel == 0 ? c.r : channel == 1 ? c.g : channel == 2 ? c.b : c.a;
pixels[i] = new Color( v, v, v, 1f );
}
var bmp = new Bitmap( src.Width, src.Height );
bmp.SetPixels( pixels );
return bmp;
}
/// <summary>New bitmap with the green channel inverted (DirectX -> OpenGL normals).</summary>
static Bitmap FlipGreen( Bitmap src )
{
var pixels = src.GetPixels();
for ( int i = 0; i < pixels.Length; i++ )
{
var c = pixels[i];
pixels[i] = new Color( c.r, 1f - c.g, c.b, c.a );
}
var bmp = new Bitmap( src.Width, src.Height );
bmp.SetPixels( pixels );
return bmp;
}
/// <summary>New grayscale bitmap holding the source alpha.</summary>
static Bitmap ExtractAlpha( Bitmap src )
{
var pixels = src.GetPixels();
for ( int i = 0; i < pixels.Length; i++ )
{
float a = pixels[i].a;
pixels[i] = new Color( a, a, a, 1f );
}
var bmp = new Bitmap( src.Width, src.Height );
bmp.SetPixels( pixels );
return bmp;
}
}
Debug: View Raw JSON Response
{
"TotalCount": 14,
"Files": [
{
"Ident": "brax.unrealimporter",
"Path": "Editor/Import/GameResourceWriter.cs",
"FileName": "GameResourceWriter.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 337301,
"Code": "using Sandbox;\nusing Sandbox.Resources;\n\nnamespace Editor.UnrealImporter;\n\n/// <summary>\n/// Creates s&box GameResources - .tmat (Terrain Material) and .decal (Decal Definition).\n///\n/// These are built through the editor's own asset API rather than by writing json: create the\n/// asset, set properties on the real resource object, save. The resource classes' own defaults\n/// then apply to everything we don't set, and SaveToDisk serialises, compiles and registers.\n/// Only .vmat is hand-written, because it's kv3 with no GameResource behind it.\n///\n/// NOTE on creating vs editing: Asset.LoadResource needs an up-to-date COMPILED file, which a\n/// just-created asset doesn't have yet - it returns null there. So a new resource is\n/// constructed with `new T()` (giving us the class defaults) and only an existing one is\n/// loaded, which lets a re-import keep whatever the user hand-tuned on it.\n/// </summary>\npublic static class GameResourceWriter\n{\n\t/// <summary>\n\t/// A Terrain Material. Terrain takes separate grayscale roughness/AO/height maps and a\n\t/// scalar metalness (there's no metal texture slot), so a metallic map has nowhere to go.\n\t/// Null paths are simply left at the resource's default image.\n\t/// </summary>\n\tpublic static Asset CreateTerrainMaterial( string absolutePath, string albedo, string roughness, string normal, string height, string ao, float uvScale = 1f )\n\t{\n\t\tvar asset = global::Editor.AssetSystem.CreateResource( \"tmat\", absolutePath );\n\t\tif ( asset is null )\n\t\t\treturn null;\n\n\t\t// Existing asset -> update it in place; new one -> start from the class defaults.\n\t\tvar isNew = !asset.TryLoadResource<TerrainMaterial>( out var mat );\n\t\tmat ??= new TerrainMaterial();\n\n\t\tif ( !string.IsNullOrEmpty( albedo ) ) mat.AlbedoImage = albedo;\n\t\tif ( !string.IsNullOrEmpty( roughness ) ) mat.RoughnessImage = roughness;\n\t\tif ( !string.IsNullOrEmpty( normal ) ) mat.NormalImage = normal;\n\t\tif ( !string.IsNullOrEmpty( height ) ) mat.HeightImage = height;\n\t\tif ( !string.IsNullOrEmpty( ao ) ) mat.AOImage = ao;\n\n\t\t// Tiling and displacement are the two things a user is most likely to tune by hand\n\t\t// (we can't read Unreal's tiling), so only seed them on a fresh resource.\n\t\tif ( isNew )\n\t\t{\n\t\t\tmat.UVScale = uvScale;\n\n\t\t\t// Displacement does nothing without a height map, and the resource hides the\n\t\t\t// field while HeightImage is still its \"no height\" default.\n\t\t\tif ( mat.HasHeightTexture )\n\t\t\t\tmat.DisplacementScale = 1f;\n\t\t}\n\n\t\treturn asset.SaveToDisk( mat ) ? asset : null;\n\t}\n\n\t/// <summary>\n\t/// A Decal Definition. Its rough/metal/occlusion is ONE packed map (RGB in that order),\n\t/// not three, and the colour texture's alpha is what masks the decal.\n\t/// </summary>\n\tpublic static Asset CreateDecal( string absolutePath, string color, string normal, string rmo, string emissive, string height, float size = 32f )\n\t{\n\t\tvar asset = global::Editor.AssetSystem.CreateResource( \"decal\", absolutePath );\n\t\tif ( asset is null )\n\t\t\treturn null;\n\n\t\tvar isNew = !asset.TryLoadResource<DecalDefinition>( out var decal );\n\t\tdecal ??= new DecalDefinition();\n\n\t\tdecal.ColorTexture = ImageTexture( color );\n\t\tdecal.NormalTexture = ImageTexture( normal );\n\t\tdecal.RoughMetalOcclusionTexture = ImageTexture( rmo );\n\t\tdecal.EmissiveTexture = ImageTexture( emissive );\n\t\tdecal.HeightTexture = ImageTexture( height );\n\n\t\t// Size is a pure guess on our part - don't stomp it on re-import.\n\t\tif ( isNew )\n\t\t{\n\t\t\tdecal.Width = size;\n\t\t\tdecal.Height = size;\n\t\t\t// Parallax needs a height map; leave it inert when there isn't one.\n\t\t\tdecal.ParallaxStrength = decal.HeightTexture is null ? 0f : 1f;\n\t\t}\n\n\t\treturn asset.SaveToDisk( decal ) ? asset : null;\n\t}\n\n\t/// <summary>\n\t/// A Texture backed by an image file on disk. Going through ImageFileGenerator (rather\n\t/// than Texture.Load) is what gives the texture its EmbeddedResource, which is how the\n\t/// image path survives serialisation into the resource's json.\n\t/// </summary>\n\tstatic Texture ImageTexture( string contentPath )\n\t{\n\t\tif ( string.IsNullOrEmpty( contentPath ) )\n\t\t\treturn null;\n\n\t\tvar generator = new ImageFileGenerator { FilePath = contentPath };\n\t\treturn generator.FindOrCreate( ResourceGenerator.Options.Default );\n\t}\n}\n"
},
{
"Ident": "brax.unrealimporter",
"Path": "Editor/Import/ImportManifest.cs",
"FileName": "ImportManifest.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 337301,
"Code": "using System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Text.Json;\r\nusing System.Text.Json.Serialization;\r\n\r\nnamespace Editor.UnrealImporter;\r\n\r\n/// <summary>\r\n/// Mirrors the manifest.json produced by Tools/ue_export.py (the headless Unreal export).\r\n/// </summary>\r\npublic class ImportManifest\r\n{\r\n\t[JsonPropertyName( \"version\" )] public int Version { get; set; }\r\n\t[JsonPropertyName( \"assets\" )] public List<ManifestAsset> Assets { get; set; } = new();\r\n\r\n\t/// <summary>\r\n\t/// Materials selected on their own (no mesh) - each becomes a standalone .vmat.\r\n\t/// Surface packs (Megascans Surfaces etc.) are nothing but these.\r\n\t/// </summary>\r\n\t[JsonPropertyName( \"materials\" )] public List<ManifestMaterial> Materials { get; set; } = new();\r\n\r\n\t/// <summary>Present only for scene-mode exports (UE_EXPORT_MAP): the level's placements + lights.</summary>\r\n\t[JsonPropertyName( \"scene\" )] public ManifestScene Scene { get; set; }\r\n\r\n\tpublic static ImportManifest Load( string path )\r\n\t{\r\n\t\tvar opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };\r\n\t\treturn JsonSerializer.Deserialize<ImportManifest>( File.ReadAllText( path ), opts );\r\n\t}\r\n}\r\n\r\npublic class ManifestAsset\r\n{\r\n\t[JsonPropertyName( \"asset\" )] public string Asset { get; set; }\r\n\r\n\t/// <summary>/Game package path - scene placements reference meshes by this.</summary>\r\n\t[JsonPropertyName( \"game_path\" )] public string GamePath { get; set; }\r\n\t[JsonPropertyName( \"fbx\" )] public string Fbx { get; set; }\r\n\t[JsonPropertyName( \"import_scale\" )] public float ImportScale { get; set; } = 0.3937f;\r\n\t[JsonPropertyName( \"materials\" )] public List<ManifestMaterial> Materials { get; set; } = new();\r\n}\r\n\r\npublic class ManifestMaterial\r\n{\r\n\t/// <summary>FBX material slot name (e.g. \"lambert2\"). Null for standalone material imports.</summary>\r\n\t[JsonPropertyName( \"slot\" )] public string Slot { get; set; }\r\n\r\n\t/// <summary>Standalone imports only: the picked asset's name, for progress/logging.</summary>\r\n\t[JsonPropertyName( \"asset\" )] public string Asset { get; set; }\r\n\r\n\t/// <summary>Standalone imports only: the /Game package path it came from.</summary>\r\n\t[JsonPropertyName( \"game_path\" )] public string GamePath { get; set; }\r\n\r\n\t/// <summary>Source Material Instance name (e.g. \"MI_CardboardBoxes_01a\") - used for vmat/texture naming + dedup.</summary>\r\n\t[JsonPropertyName( \"material\" )] public string Material { get; set; }\r\n\r\n\t/// <summary>Unreal blend mode name (BLEND_OPAQUE / BLEND_MASKED / BLEND_TRANSLUCENT...). Null on old manifests.</summary>\r\n\t[JsonPropertyName( \"blend_mode\" )] public string BlendMode { get; set; }\r\n\r\n\t// Texture role -> staging-relative png path. Null when the material doesn't use that role.\r\n\t[JsonPropertyName( \"alb\" )] public string Alb { get; set; }\r\n\t[JsonPropertyName( \"nrm\" )] public string Nrm { get; set; }\r\n\t[JsonPropertyName( \"rma\" )] public string Rma { get; set; }\r\n\t[JsonPropertyName( \"rough\" )] public string Rough { get; set; }\r\n\t[JsonPropertyName( \"metal\" )] public string Metal { get; set; }\r\n\t[JsonPropertyName( \"ao\" )] public string Ao { get; set; }\r\n\t[JsonPropertyName( \"emissive\" )] public string Emissive { get; set; }\r\n\t[JsonPropertyName( \"opacity\" )] public string Opacity { get; set; }\r\n\r\n\t/// <summary>Displacement/height map. complex.shader has no slot for it - recorded so we can warn.</summary>\r\n\t[JsonPropertyName( \"height\" )] public string Height { get; set; }\r\n\r\n\t/// <summary>\r\n\t/// Channel layout of <see cref=\"Rma\"/>: \"rma\" (R=rough G=metal B=ao, the Fab convention),\r\n\t/// \"orm\"/\"arm\" (R=ao G=rough B=metal, Megascans) or \"mra\". Null on old manifests -> \"rma\".\r\n\t/// </summary>\r\n\t[JsonPropertyName( \"rma_order\" )] public string RmaOrder { get; set; }\r\n\r\n\t/// <summary>Grayscale tint mask (white = full tint). Packed into the normal's alpha by the complex shader.</summary>\r\n\t[JsonPropertyName( \"tintmask\" )] public string TintMask { get; set; }\r\n\r\n\t/// <summary>Best-guess single tint color [r,g,b,a] in Unreal LINEAR space (sRGB-encode for g_vColorTint).</summary>\r\n\t[JsonPropertyName( \"tint_color\" )] public float[] TintColor { get; set; }\r\n\r\n\t/// <summary>Multi-zone tint: mask channel (\"r\"/\"g\"/\"b\"/\"a\") -> LINEAR tint [r,g,b,a]. Baked into the albedo.</summary>\r\n\t[JsonPropertyName( \"tint_zones\" )] public Dictionary<string, float[]> TintZones { get; set; }\r\n\r\n\t/// <summary>Best-guess tint amount/strength (0..1) -> g_flModelTintAmount.</summary>\r\n\t[JsonPropertyName( \"tint_amount\" )] public float? TintAmount { get; set; }\r\n\r\n\t/// <summary>All scalar parameter overrides on the Material Instance (kept for fidelity).</summary>\r\n\t[JsonPropertyName( \"scalar_params\" )] public Dictionary<string, float> ScalarParams { get; set; }\r\n\r\n\t/// <summary>All vector (color) parameter overrides [r,g,b,a] on the Material Instance.</summary>\r\n\t[JsonPropertyName( \"vector_params\" )] public Dictionary<string, float[]> VectorParams { get; set; }\r\n}\r\n\r\npublic class ManifestScene\r\n{\r\n\t[JsonPropertyName( \"name\" )] public string Name { get; set; }\r\n\t[JsonPropertyName( \"map\" )] public string Map { get; set; }\r\n\t[JsonPropertyName( \"placements\" )] public List<ManifestPlacement> Placements { get; set; } = new();\r\n\t[JsonPropertyName( \"lights\" )] public List<ManifestLight> Lights { get; set; } = new();\r\n\r\n\t/// <summary>Things the exporter skipped (capped scatter ISMs, landscapes...) - surfaced in the import summary.</summary>\r\n\t[JsonPropertyName( \"warnings\" )] public List<string> Warnings { get; set; } = new();\r\n}\r\n\r\n/// <summary>\r\n/// One static-mesh placement in the level. Transform is raw Unreal: centimetres,\r\n/// left-handed X-fwd/Y-right/Z-up, quaternion xyzw. Conversion happens in ScenePrefabBuilder.\r\n/// </summary>\r\npublic class ManifestPlacement\r\n{\r\n\t[JsonPropertyName( \"mesh\" )] public string Mesh { get; set; }\r\n\t[JsonPropertyName( \"name\" )] public string Name { get; set; }\r\n\t[JsonPropertyName( \"pos\" )] public float[] Pos { get; set; }\r\n\t[JsonPropertyName( \"rot\" )] public float[] Rot { get; set; }\r\n\t[JsonPropertyName( \"scale\" )] public float[] Scale { get; set; }\r\n}\r\n\r\npublic class ManifestLight\r\n{\r\n\t/// <summary>\"point\", \"spot\" or \"directional\".</summary>\r\n\t[JsonPropertyName( \"type\" )] public string Type { get; set; }\r\n\t[JsonPropertyName( \"name\" )] public string Name { get; set; }\r\n\t[JsonPropertyName( \"pos\" )] public float[] Pos { get; set; }\r\n\t[JsonPropertyName( \"rot\" )] public float[] Rot { get; set; }\r\n\t[JsonPropertyName( \"scale\" )] public float[] Scale { get; set; }\r\n\t[JsonPropertyName( \"color\" )] public float[] Color { get; set; }\r\n\r\n\t/// <summary>Raw Unreal intensity - unit depends on <see cref=\"Units\"/> (lux for directional).</summary>\r\n\t[JsonPropertyName( \"intensity\" )] public float? Intensity { get; set; }\r\n\r\n\t/// <summary>Unreal ELightUnits name: CANDELAS / LUMENS / UNITLESS / EV. Debug info - use Candela.</summary>\r\n\t[JsonPropertyName( \"units\" )] public string Units { get; set; }\r\n\r\n\t/// <summary>Luminous intensity in candela, converted from Intensity+Units by the exporter. Point/spot only.</summary>\r\n\t[JsonPropertyName( \"candela\" )] public float? Candela { get; set; }\r\n\r\n\t/// <summary>Attenuation radius in centimetres.</summary>\r\n\t[JsonPropertyName( \"radius\" )] public float? Radius { get; set; }\r\n\t[JsonPropertyName( \"inner_cone\" )] public float? InnerCone { get; set; }\r\n\t[JsonPropertyName( \"outer_cone\" )] public float? OuterCone { get; set; }\r\n}\r\n"
},
{
"Ident": "brax.unrealimporter",
"Path": "Editor/Import/ScenePrefabBuilder.cs",
"FileName": "ScenePrefabBuilder.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 337301,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.IO;\nusing System.Text.Json;\nusing System.Text.Json.Nodes;\n\nnamespace Editor.UnrealImporter;\n\n/// <summary>\n/// Turns a manifest \"scene\" section (raw Unreal placements + lights) into an s&box\n/// .prefab: one child GameObject per placement with a ModelRenderer pointing at the\n/// imported vmdl, plus Point/Spot/Directional lights.\n///\n/// Coordinate conversion (Unreal cm left-handed X-fwd/Y-right/Z-up -> Source inch\n/// right-handed X-fwd/Y-left/Z-up) mirrors across the XZ plane:\n/// position (x, -y, z) / 2.54 quaternion mirror: (-x, y, -z, w)\n///\n/// The FBX mesh path adds a twist: UE's exporter negates Y and Source 2's importer\n/// rotates 90\u00b0, so an imported mesh's local axes are UE's with X and Y SWAPPED\n/// (verified against UE bounding boxes: sbox (x,y,z) = ue (y,x,z)/2.54). A model\n/// placement must compensate: R' = S\u00b7R\u00b7M, i.e. the mirrored quaternion post-multiplied\n/// by yaw -90, and non-uniform scale swaps x/y. Lights carry no mesh, so they use the\n/// plain mirror.\n/// </summary>\npublic static class ScenePrefabBuilder\n{\n\tconst float UeToInch = 1f / 2.54f;\n\n\t/// <summary>\n\t/// Write <scene name>.prefab under outputRoot. modelsByGamePath maps the manifest's\n\t/// /Game mesh paths to imported vmdl content paths; mirroredByGamePath the variants for\n\t/// mirrored (odd-negative-scale) placements. Returns the prefab's absolute path.\n\t/// </summary>\n\tpublic static string Build( ManifestScene scene, IReadOnlyDictionary<string, string> modelsByGamePath, string outputRoot, List<string> warnings,\n\t\tIReadOnlyDictionary<string, string> mirroredByGamePath = null, float lightScale = 1f )\n\t{\n\t\tvar children = new JsonArray();\n\t\tvar missingMeshes = new HashSet<string>();\n\t\tvar missingMirrors = new HashSet<string>();\n\n\t\tforeach ( var p in scene.Placements ?? new() )\n\t\t{\n\t\t\tif ( string.IsNullOrEmpty( p.Mesh ) || !modelsByGamePath.TryGetValue( p.Mesh, out var vmdl ) )\n\t\t\t{\n\t\t\t\tif ( p.Mesh is not null )\n\t\t\t\t\tmissingMeshes.Add( p.Mesh );\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// True mirrors (odd negative axes) swap to the mirrored model variant.\n\t\t\tif ( p.Scale is { Length: >= 3 } && NegativeCount( p.Scale ) % 2 == 1 )\n\t\t\t{\n\t\t\t\tif ( mirroredByGamePath is not null && mirroredByGamePath.TryGetValue( p.Mesh, out var mirrored ) )\n\t\t\t\t\tvmdl = mirrored;\n\t\t\t\telse\n\t\t\t\t\tmissingMirrors.Add( p.Mesh );\n\t\t\t}\n\n\t\t\tvar go = GameObjectNode( p.Name, p.Pos, p.Rot, p.Scale, isMesh: true );\n\t\t\tgo[\"Components\"] = new JsonArray( ComponentNode( \"Sandbox.ModelRenderer\", new()\n\t\t\t{\n\t\t\t\t[\"Model\"] = vmdl,\n\t\t\t\t[\"Tint\"] = \"1,1,1,1\",\n\t\t\t\t[\"RenderType\"] = \"On\",\n\t\t\t} ) );\n\t\t\tchildren.Add( go );\n\t\t}\n\n\t\tforeach ( var m in missingMirrors )\n\t\t\twarnings.Add( $\"scene: no mirrored model for {m}, its flipped placements will render inside-out.\" );\n\n\t\tforeach ( var l in scene.Lights ?? new() )\n\t\t{\n\t\t\tvar node = LightNode( l, lightScale );\n\t\t\tif ( node is not null )\n\t\t\t\tchildren.Add( node );\n\t\t}\n\n\t\tforeach ( var m in missingMeshes )\n\t\t\twarnings.Add( $\"scene: no imported model for {m}, its placements were skipped.\" );\n\n\t\tvar root = GameObjectNode( Sanitize( scene.Name ), null, null, null );\n\t\troot[\"Children\"] = children;\n\n\t\tvar prefab = new JsonObject\n\t\t{\n\t\t\t[\"RootObject\"] = root,\n\t\t\t[\"ResourceVersion\"] = 2,\n\t\t\t[\"ShowInMenu\"] = false,\n\t\t\t[\"MenuPath\"] = null,\n\t\t\t[\"MenuIcon\"] = null,\n\t\t\t[\"DontBreakAsTemplate\"] = false,\n\t\t\t[\"__references\"] = new JsonArray(),\n\t\t\t[\"__version\"] = 2,\n\t\t};\n\n\t\tvar path = Path.Combine( outputRoot, Sanitize( scene.Name ) + \".prefab\" );\n\t\tFile.WriteAllText( path, prefab.ToJsonString( new JsonSerializerOptions { WriteIndented = true } ) );\n\t\treturn path;\n\t}\n\n\tstatic JsonObject GameObjectNode( string name, float[] uePos, float[] ueRot, float[] ueScale, bool isMesh = false )\n\t{\n\t\tvar scale = ueScale ?? new float[] { 1, 1, 1 };\n\t\tif ( isMesh && scale.Length >= 3 )\n\t\t\tscale = new[] { scale[1], scale[0], scale[2] }; // mesh local axes are swapped\n\n\t\tvar rot = ConvertRotation( ueRot, isMesh );\n\t\t(rot, scale) = ResolveNegativeScale( rot, scale );\n\n\t\treturn new JsonObject\n\t\t{\n\t\t\t[\"__guid\"] = Guid.NewGuid().ToString(),\n\t\t\t[\"__version\"] = 2,\n\t\t\t[\"Flags\"] = 0,\n\t\t\t[\"Name\"] = string.IsNullOrEmpty( name ) ? \"unnamed\" : name,\n\t\t\t[\"Position\"] = Vec3( ConvertPosition( uePos ) ),\n\t\t\t[\"Rotation\"] = Quat( rot ),\n\t\t\t[\"Scale\"] = Vec3( scale ),\n\t\t\t[\"Enabled\"] = true,\n\t\t};\n\t}\n\n\tstatic int NegativeCount( float[] s ) => (s[0] < 0 ? 1 : 0) + (s[1] < 0 ? 1 : 0) + (s[2] < 0 ? 1 : 0);\n\n\t// 180\u00b0 rotations about local X / Y / Z, quaternion xyzw.\n\tstatic readonly float[][] Rot180 = { new float[] { 1, 0, 0, 0 }, new float[] { 0, 1, 0, 0 }, new float[] { 0, 0, 1, 0 } };\n\n\t/// <summary>\n\t/// s&box doesn't flip triangle winding for negative GameObject scale, so negative axes\n\t/// must not reach the prefab. diag(-a,-b,c) == rot180_z * diag(a,b,c): an EVEN number of\n\t/// negative axes folds into a 180\u00b0 local rotation (about the remaining positive axis).\n\t/// An ODD count is a true mirror, served by the model's mirrored variant M = diag(-1,1,1)\n\t/// (ScaleAndMirror across local X). The sign pattern factors as sign = M * Q with Q a\n\t/// 180\u00b0 rotation (sign matrices commute): negative x -> Q = identity; negative y ->\n\t/// rot180_z; negative z -> rot180_y; all three -> rot180_x. Callers pick the mirrored\n\t/// model for odd counts.\n\t/// </summary>\n\tstatic (float[] rot, float[] scale) ResolveNegativeScale( float[] rot, float[] scale )\n\t{\n\t\tif ( scale.Length < 3 || NegativeCount( scale ) == 0 )\n\t\t\treturn (rot, scale);\n\n\t\tint negatives = NegativeCount( scale );\n\t\tint axis = -1;\n\t\tif ( negatives == 2 )\n\t\t{\n\t\t\taxis = Array.FindIndex( scale, v => v >= 0 ); // rotate about the positive axis\n\t\t}\n\t\telse if ( negatives == 1 )\n\t\t{\n\t\t\t// X-mirrored model: sign pattern (-,+,+) is the model itself; (+,-,+) needs\n\t\t\t// rot180 about Z on top of it; (+,+,-) rot180 about Y.\n\t\t\tint neg = Array.FindIndex( scale, v => v < 0 );\n\t\t\taxis = neg switch { 1 => 2, 2 => 1, _ => -1 };\n\t\t}\n\t\telse if ( negatives == 3 )\n\t\t{\n\t\t\taxis = 0; // (-,-,-) = M * rot180_x\n\t\t}\n\n\t\tif ( axis >= 0 )\n\t\t\trot = MulQuat( rot, Rot180[axis] );\n\n\t\treturn (rot, new[] { Math.Abs( scale[0] ), Math.Abs( scale[1] ), Math.Abs( scale[2] ) });\n\t}\n\n\tstatic JsonObject ComponentNode( string type, JsonObject properties )\n\t{\n\t\tvar node = new JsonObject\n\t\t{\n\t\t\t[\"__type\"] = type,\n\t\t\t[\"__guid\"] = Guid.NewGuid().ToString(),\n\t\t\t[\"__enabled\"] = true,\n\t\t\t[\"Flags\"] = 0,\n\t\t};\n\t\tforeach ( var kv in properties )\n\t\t\tnode[kv.Key] = kv.Value?.DeepClone();\n\n\t\treturn node;\n\t}\n\n\tstatic JsonObject LightNode( ManifestLight l, float lightScale )\n\t{\n\t\tvar (type, props) = l.Type switch\n\t\t{\n\t\t\t\"point\" => (\"Sandbox.PointLight\", new JsonObject\n\t\t\t{\n\t\t\t\t[\"LightColor\"] = ColorStr( l, lightScale ),\n\t\t\t\t[\"Radius\"] = Round( (l.Radius ?? 1000f) * UeToInch ),\n\t\t\t}),\n\t\t\t\"spot\" => (\"Sandbox.SpotLight\", new JsonObject\n\t\t\t{\n\t\t\t\t[\"LightColor\"] = ColorStr( l, lightScale ),\n\t\t\t\t[\"Radius\"] = Round( (l.Radius ?? 1000f) * UeToInch ),\n\t\t\t\t[\"ConeInner\"] = Round( l.InnerCone ?? 30f ),\n\t\t\t\t[\"ConeOuter\"] = Round( l.OuterCone ?? 45f ),\n\t\t\t}),\n\t\t\t\"directional\" => (\"Sandbox.DirectionalLight\", new JsonObject\n\t\t\t{\n\t\t\t\t[\"LightColor\"] = ColorStr( l, lightScale ),\n\t\t\t\t[\"Shadows\"] = true,\n\t\t\t}),\n\t\t\t_ => (null, null),\n\t\t};\n\n\t\tif ( type is null )\n\t\t\treturn null;\n\n\t\tvar go = GameObjectNode( l.Name ?? l.Type, l.Pos, l.Rot, null );\n\t\tgo[\"Components\"] = new JsonArray( ComponentNode( type, props ) );\n\t\treturn go;\n\t}\n\n\tstatic float[] ConvertPosition( float[] p )\n\t{\n\t\tif ( p is null || p.Length < 3 )\n\t\t\treturn new float[] { 0, 0, 0 };\n\n\t\treturn new[] { p[0] * UeToInch, -p[1] * UeToInch, p[2] * UeToInch };\n\t}\n\n\t// Yaw -90: compensates the X<->Y swap the FBX mesh pipeline bakes into mesh space.\n\tstatic readonly float[] MeshAxisFix = { 0, 0, -0.70710678f, 0.70710678f };\n\n\tstatic float[] ConvertRotation( float[] q, bool isMesh )\n\t{\n\t\tif ( q is null || q.Length < 4 )\n\t\t\tq = new float[] { 0, 0, 0, 1 };\n\n\t\tvar mirrored = new[] { -q[0], q[1], -q[2], q[3] };\n\t\treturn isMesh ? MulQuat( mirrored, MeshAxisFix ) : mirrored;\n\t}\n\n\t/// <summary>Hamilton product a*b (xyzw): rotation b in local space followed by a.</summary>\n\tstatic float[] MulQuat( float[] a, float[] b )\n\t{\n\t\treturn new[]\n\t\t{\n\t\t\ta[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],\n\t\t\ta[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],\n\t\t\ta[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],\n\t\t\ta[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],\n\t\t};\n\t}\n\n\t/// <summary>\n\t/// A ~1000 cd source (strong ceiling fixture) maps to HDR magnitude 1.0. Calibrated\n\t/// visually against the CCA subway terminal: UE relies on auto-exposure to pull\n\t/// physically-lit interiors (dozens of overlapping 500-1000 cd lights) down to\n\t/// comfortable levels, s&box doesn't - mapping generously blows every surface to\n\t/// white. Hand-placed lights in this project sit at ~0.35-2 HDR magnitude.\n\t/// </summary>\n\tconst float RefCandela = 1000f;\n\n\t/// <summary>\n\t/// Legacy UNITLESS lights aren't physical: UE4-scale authoring puts a strong lamp\n\t/// around 1000-5000. Converting them through UE's official unitless->candela factor\n\t/// (16/10000) lands at fractions of a candela and everything goes black, so they get\n\t/// their own perceptual reference instead (calibrated with the same 0.4 factor as\n\t/// the candela path).\n\t/// </summary>\n\tconst float RefUnitless = 5000f;\n\n\t/// <summary>\n\t/// s&box lights carry brightness in LightColor's HDR magnitude. Scale the Unreal\n\t/// chroma by intensity: candela for physically-united point/spot lights, a UE4-scale\n\t/// heuristic for UNITLESS ones, lux for directional. Raw UE intensities are\n\t/// unit-dependent - comparing them without units is what made imports blinding.\n\t/// sqrt compresses the huge dynamic range of authored UE values.\n\t/// </summary>\n\tstatic string ColorStr( ManifestLight l, float lightScale )\n\t{\n\t\tvar c = l.Color is { Length: >= 3 } ? l.Color : new float[] { 1, 1, 1 };\n\n\t\tfloat brightness;\n\t\tif ( l.Type == \"directional\" && l.Intensity is > 0 )\n\t\t\tbrightness = Math.Clamp( MathF.Sqrt( l.Intensity.Value / 5f ), 0.5f, 2.5f ); // lux; UE legacy suns sit ~2-15\n\t\telse if ( l.Units?.StartsWith( \"UNITLESS\" ) == true && l.Intensity is > 0 )\n\t\t\tbrightness = Math.Clamp( MathF.Sqrt( l.Intensity.Value / RefUnitless ), 0.05f, 2f );\n\t\telse if ( l.Candela is > 0 )\n\t\t\tbrightness = Math.Clamp( MathF.Sqrt( l.Candela.Value / RefCandela ), 0.05f, 2f );\n\t\telse if ( l.Intensity is > 0 )\n\t\t\tbrightness = Math.Clamp( MathF.Sqrt( l.Intensity.Value / 8f ), 0.25f, 4f ); // old manifests: unit unknown\n\t\telse\n\t\t\tbrightness = 1f;\n\n\t\tbrightness = Math.Clamp( brightness * lightScale, 0.02f, 4f );\n\n\t\treturn $\"{F( c[0] * brightness )},{F( c[1] * brightness )},{F( c[2] * brightness )},1\";\n\t}\n\n\tstatic string Vec3( float[] v ) => $\"{F( v[0] )},{F( v[1] )},{F( v[2] )}\";\n\tstatic string Quat( float[] q ) => $\"{F( q[0] )},{F( q[1] )},{F( q[2] )},{F( q[3] )}\";\n\tstatic float Round( float v ) => (float)Math.Round( v, 3 );\n\tstatic string F( float v ) => v.ToString( \"0.######\", CultureInfo.InvariantCulture );\n\n\tstatic string Sanitize( string s )\n\t{\n\t\tif ( string.IsNullOrEmpty( s ) )\n\t\t\treturn \"unnamed_scene\";\n\n\t\tvar chars = s.ToLowerInvariant().ToCharArray();\n\t\tfor ( int i = 0; i < chars.Length; i++ )\n\t\t{\n\t\t\tvar ch = chars[i];\n\t\t\tif ( ch is not ((>= 'a' and <= 'z') or (>= '0' and <= '9') or '_') )\n\t\t\t\tchars[i] = '_';\n\t\t}\n\t\treturn new string( chars );\n\t}\n}\n"
},
{
"Ident": "brax.unrealimporter",
"Path": "Editor/Import/UassetMeshStats.cs",
"FileName": "UassetMeshStats.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 337301,
"Code": "using System;\nusing System.Collections.Concurrent;\nusing System.IO;\nusing System.Text;\nusing System.Text.Json;\nusing System.Threading;\nusing System.Threading.Tasks;\n\nnamespace Editor.UnrealImporter;\n\npublic class MeshStats\n{\n\tpublic long Triangles { get; set; } = -1;\n\tpublic long Vertices { get; set; } = -1;\n\tpublic long Materials { get; set; } = -1;\n\tpublic long LODs { get; set; } = -1;\n\n\t/// <summary>Source file stamp this was read from - stale entries re-parse.</summary>\n\tpublic long Mtime { get; set; }\n\tpublic long Size { get; set; }\n}\n\n/// <summary>\n/// Reads triangle/vertex counts straight out of an uncooked StaticMesh .uasset, no Unreal\n/// involved. The Unreal editor bakes asset-registry tags (\"Triangles\", \"Vertices\",\n/// \"Materials\", \"LODs\", ...) into every saved package as serialized FString key/value\n/// pairs: int32 length (incl. NUL), ascii chars, NUL. Rather than parsing the\n/// version-dependent FPackageFileSummary to find the block, we scan for that\n/// self-contained byte pattern - same magic-scan approach the thumbnail extractor uses,\n/// verified against UE 5.x Fab packs.\n///\n/// Cached in memory and on disk (.sbox/unrealimporter/meshstats.json, keyed mtime+size)\n/// because a full pack means gigabytes of .uasset reads otherwise.\n/// </summary>\npublic static class UassetMeshStats\n{\n\t// Registry tags live in the package header tables, which sit well before the bulk\n\t// mesh data - reading the head of the file is nearly always enough.\n\tconst int HeaderReadBytes = 4 * 1024 * 1024;\n\n\tstatic readonly ConcurrentDictionary<string, MeshStats> cache = new( StringComparer.OrdinalIgnoreCase );\n\tstatic readonly ConcurrentDictionary<string, Task<MeshStats>> inFlight = new( StringComparer.OrdinalIgnoreCase );\n\tstatic readonly SemaphoreSlim ioGate = new( 2 ); // don't hammer the disk when a folder expands\n\n\tstatic bool diskCacheLoaded;\n\tstatic int saveScheduled;\n\n\tstatic string CacheFile => Sandbox.Project.Current is not null\n\t\t? Path.Combine( Sandbox.Project.Current.GetRootPath(), \".sbox\", \"unrealimporter\", \"meshstats.json\" )\n\t\t: Path.Combine( Path.GetTempPath(), \"unrealimporter\", \"meshstats.json\" );\n\n\t/// <summary>Memory/disk cache lookup, no file IO on the asset itself.</summary>\n\tpublic static bool TryGetCached( string absPath, out MeshStats stats )\n\t{\n\t\tLoadDiskCache();\n\n\t\tif ( cache.TryGetValue( absPath, out stats ) )\n\t\t{\n\t\t\tvar fi = new FileInfo( absPath );\n\t\t\tif ( fi.Exists && fi.LastWriteTimeUtc.Ticks == stats.Mtime && fi.Length == stats.Size )\n\t\t\t\treturn true;\n\n\t\t\tcache.TryRemove( absPath, out _ );\n\t\t\tstats = null;\n\t\t}\n\n\t\treturn false;\n\t}\n\n\t/// <summary>Parse (or fetch cached) stats for one .uasset. Null when nothing was found.</summary>\n\tpublic static Task<MeshStats> LoadAsync( string absPath )\n\t{\n\t\tif ( TryGetCached( absPath, out var cached ) )\n\t\t\treturn Task.FromResult( cached );\n\n\t\treturn inFlight.GetOrAdd( absPath, p => Task.Run( async () =>\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tawait ioGate.WaitAsync();\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tvar stats = Parse( p );\n\t\t\t\t\tif ( stats is not null )\n\t\t\t\t\t{\n\t\t\t\t\t\tcache[p] = stats;\n\t\t\t\t\t\tScheduleSave();\n\t\t\t\t\t}\n\t\t\t\t\treturn stats;\n\t\t\t\t}\n\t\t\t\tfinally\n\t\t\t\t{\n\t\t\t\t\tioGate.Release();\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch\n\t\t\t{\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tfinally\n\t\t\t{\n\t\t\t\tinFlight.TryRemove( p, out _ );\n\t\t\t}\n\t\t} ) );\n\t}\n\n\tstatic MeshStats Parse( string absPath )\n\t{\n\t\tvar fi = new FileInfo( absPath );\n\t\tif ( !fi.Exists )\n\t\t\treturn null;\n\n\t\tvar data = ReadHead( absPath, HeaderReadBytes );\n\t\tvar tris = TagValue( data, \"Triangles\" );\n\n\t\t// Rare: huge header tables push the tag block past our head read.\n\t\tif ( tris < 0 && fi.Length > data.Length )\n\t\t{\n\t\t\tdata = File.ReadAllBytes( absPath );\n\t\t\ttris = TagValue( data, \"Triangles\" );\n\t\t}\n\n\t\tif ( tris < 0 )\n\t\t\treturn null;\n\n\t\treturn new MeshStats\n\t\t{\n\t\t\tTriangles = tris,\n\t\t\tVertices = TagValue( data, \"Vertices\" ),\n\t\t\tMaterials = TagValue( data, \"Materials\" ),\n\t\t\tLODs = TagValue( data, \"LODs\" ),\n\t\t\tMtime = fi.LastWriteTimeUtc.Ticks,\n\t\t\tSize = fi.Length,\n\t\t};\n\t}\n\n\tstatic byte[] ReadHead( string path, int maxBytes )\n\t{\n\t\tusing var fs = File.OpenRead( path );\n\t\tvar len = (int)Math.Min( fs.Length, maxBytes );\n\t\tvar buf = new byte[len];\n\t\tfs.ReadExactly( buf, 0, len );\n\t\treturn buf;\n\t}\n\n\t/// <summary>\n\t/// Find asset-registry tag <paramref name=\"key\"/> and return its numeric value, -1 when\n\t/// absent. Matches the FString serialization (length prefix + NUL) so plain-text\n\t/// occurrences of the word elsewhere can't false-positive.\n\t/// </summary>\n\tstatic long TagValue( ReadOnlySpan<byte> data, string key )\n\t{\n\t\tSpan<byte> pattern = stackalloc byte[4 + key.Length + 1];\n\t\tBitConverter.TryWriteBytes( pattern, key.Length + 1 );\n\t\tEncoding.ASCII.GetBytes( key, pattern[4..] );\n\t\tpattern[^1] = 0;\n\n\t\tvar at = data.IndexOf( pattern );\n\t\tif ( at < 0 )\n\t\t\treturn -1;\n\n\t\tvar vpos = at + pattern.Length;\n\t\tif ( vpos + 4 > data.Length )\n\t\t\treturn -1;\n\n\t\tint vlen = BitConverter.ToInt32( data[vpos..] );\n\t\tif ( vlen <= 1 || vlen > 64 || vpos + 4 + vlen > data.Length )\n\t\t\treturn -1;\n\n\t\tvar s = Encoding.ASCII.GetString( data.Slice( vpos + 4, vlen - 1 ) );\n\t\treturn long.TryParse( s, out var v ) ? v : -1;\n\t}\n\n\t// ---- disk cache ----\n\n\tstatic void LoadDiskCache()\n\t{\n\t\tif ( diskCacheLoaded )\n\t\t\treturn;\n\t\tdiskCacheLoaded = true;\n\n\t\ttry\n\t\t{\n\t\t\tif ( !File.Exists( CacheFile ) )\n\t\t\t\treturn;\n\n\t\t\tvar loaded = JsonSerializer.Deserialize<ConcurrentDictionary<string, MeshStats>>( File.ReadAllText( CacheFile ) );\n\t\t\tif ( loaded is null )\n\t\t\t\treturn;\n\n\t\t\tforeach ( var kv in loaded )\n\t\t\t\tcache.TryAdd( kv.Key, kv.Value );\n\t\t}\n\t\tcatch\n\t\t{\n\t\t\t// cache is disposable - a corrupt file just means re-parsing\n\t\t}\n\t}\n\n\tstatic void ScheduleSave()\n\t{\n\t\tif ( Interlocked.Exchange( ref saveScheduled, 1 ) == 1 )\n\t\t\treturn;\n\n\t\t_ = Task.Run( async () =>\n\t\t{\n\t\t\tawait Task.Delay( 3000 );\n\t\t\tInterlocked.Exchange( ref saveScheduled, 0 );\n\n\t\t\ttry\n\t\t\t{\n\t\t\t\tDirectory.CreateDirectory( Path.GetDirectoryName( CacheFile ) );\n\t\t\t\tFile.WriteAllText( CacheFile, JsonSerializer.Serialize( cache ) );\n\t\t\t}\n\t\t\tcatch\n\t\t\t{\n\t\t\t}\n\t\t} );\n\t}\n}\n"
},
{
"Ident": "brax.unrealimporter",
"Path": "Editor/Import/UnrealLocator.cs",
"FileName": "UnrealLocator.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 337301,
"Code": "using System;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\n\r\nnamespace Editor.UnrealImporter;\r\n\r\n/// <summary>\r\n/// Locates a .uproject's engine and the UnrealEditor-Cmd.exe used to run the headless export.\r\n/// </summary>\r\npublic static class UnrealLocator\r\n{\r\n\tpublic static string FindUprojectInFolder( string folder )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( folder ) || !Directory.Exists( folder ) )\r\n\t\t\treturn null;\r\n\r\n\t\treturn Directory.GetFiles( folder, \"*.uproject\", SearchOption.TopDirectoryOnly ).FirstOrDefault();\r\n\t}\r\n\r\n\t/// <summary>Reads \"EngineAssociation\" (e.g. \"5.5\") from a .uproject. May be null.</summary>\r\n\tpublic static string ReadEngineAssociation( string uprojectPath )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tusing var doc = JsonDocument.Parse( File.ReadAllText( uprojectPath ) );\r\n\t\t\tif ( doc.RootElement.TryGetProperty( \"EngineAssociation\", out var e ) )\r\n\t\t\t\treturn e.GetString();\r\n\t\t}\r\n\t\tcatch { }\r\n\r\n\t\treturn null;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Find UnrealEditor-Cmd.exe, preferring the version the project targets.\r\n\t/// Tries the registry first, then scans the standard Epic Games install root.\r\n\t/// When the exact version isn't installed, prefers the CLOSEST NEWER engine\r\n\t/// (a newer engine opens older assets; an older one can't read newer assets),\r\n\t/// falling back to the highest older install.\r\n\t/// </summary>\r\n\tpublic static string FindEditorCmd( string engineVersion )\r\n\t{\r\n\t\tvar fromReg = FromRegistry( engineVersion );\r\n\t\tif ( fromReg != null )\r\n\t\t\treturn fromReg;\r\n\r\n\t\tvar roots = new[]\r\n\t\t{\r\n\t\t\tEnvironment.GetEnvironmentVariable( \"ProgramW6432\" ),\r\n\t\t\tEnvironment.GetEnvironmentVariable( \"ProgramFiles\" ),\r\n\t\t}.Where( x => !string.IsNullOrEmpty( x ) ).Distinct();\r\n\r\n\t\tVersion.TryParse( engineVersion ?? \"\", out var wanted );\r\n\r\n\t\tforeach ( var pf in roots )\r\n\t\t{\r\n\t\t\tvar epic = Path.Combine( pf, \"Epic Games\" );\r\n\t\t\tif ( !Directory.Exists( epic ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tif ( !string.IsNullOrEmpty( engineVersion ) )\r\n\t\t\t{\r\n\t\t\t\tvar exact = CmdPath( Path.Combine( epic, $\"UE_{engineVersion}\" ) );\r\n\t\t\t\tif ( File.Exists( exact ) )\r\n\t\t\t\t\treturn exact;\r\n\t\t\t}\r\n\r\n\t\t\tvar installed = Directory.GetDirectories( epic, \"UE_*\" )\r\n\t\t\t\t.Where( d => File.Exists( CmdPath( d ) ) )\r\n\t\t\t\t.Select( d => (dir: d, ver: Version.TryParse( Path.GetFileName( d )[\"UE_\".Length..], out var v ) ? v : null) )\r\n\t\t\t\t.Where( x => x.ver is not null )\r\n\t\t\t\t.ToList();\r\n\r\n\t\t\tif ( installed.Count == 0 )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar pick = wanted is not null\r\n\t\t\t\t? installed.Where( x => x.ver >= wanted ).OrderBy( x => x.ver ).FirstOrDefault().dir\r\n\t\t\t\t\t?? installed.OrderByDescending( x => x.ver ).First().dir\r\n\t\t\t\t: installed.OrderByDescending( x => x.ver ).First().dir;\r\n\r\n\t\t\treturn CmdPath( pick );\r\n\t\t}\r\n\r\n\t\treturn null;\r\n\t}\r\n\r\n\tstatic string CmdPath( string engineRoot )\r\n\t\t=> Path.Combine( engineRoot, \"Engine\", \"Binaries\", \"Win64\", \"UnrealEditor-Cmd.exe\" );\r\n\r\n\tstatic string FromRegistry( string engineVersion )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( engineVersion ) )\r\n\t\t\treturn null;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tusing var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(\r\n\t\t\t\t$@\"SOFTWARE\\EpicGames\\Unreal Engine\\{engineVersion}\" );\r\n\r\n\t\t\tif ( key?.GetValue( \"InstalledDirectory\" ) is string dir && !string.IsNullOrEmpty( dir ) )\r\n\t\t\t{\r\n\t\t\t\tvar cmd = CmdPath( dir );\r\n\t\t\t\tif ( File.Exists( cmd ) )\r\n\t\t\t\t\treturn cmd;\r\n\t\t\t}\r\n\t\t}\r\n\t\tcatch { }\r\n\r\n\t\treturn null;\r\n\t}\r\n}\r\n"
},
{
"Ident": "brax.unrealimporter",
"Path": "Editor/Import/AssetImporter.cs",
"FileName": "AssetImporter.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 337301,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Text;\r\nusing System.Threading;\r\nusing System.Threading.Tasks;\r\nusing Sandbox;\r\n\r\nnamespace Editor.UnrealImporter;\r\n\r\npublic class ImportSummary\r\n{\r\n\tpublic int Models;\r\n\tpublic int Materials;\r\n\tpublic int Textures;\r\n\tpublic string OutputDir;\r\n\tpublic List<string> Warnings = new();\r\n\r\n\t/// <summary>Scene mode: placements in the generated prefab, and where it was written.</summary>\r\n\tpublic int Placements;\r\n\tpublic string PrefabPath;\r\n}\r\n\r\n/// <summary>How generated assets are laid out on disk.</summary>\r\npublic enum ImportLayout\r\n{\r\n\t/// <summary><output>/models, /materials, /textures.</summary>\r\n\tGrouped,\r\n\r\n\t/// <summary>Everything directly in <output>.</summary>\r\n\tFlat,\r\n\r\n\t/// <summary>\r\n\t/// One self-contained folder per imported asset: <output>/<asset>/ holds its\r\n\t/// model, materials and textures together. Shared materials are duplicated into each\r\n\t/// asset's folder - that's the point, each folder can be moved or deleted on its own.\r\n\t/// </summary>\r\n\tPerAsset,\r\n\r\n\t/// <summary>\r\n\t/// Classic Source style: Assets/models/<sub> for fbx+vmdl, Assets/materials/<sub> for\r\n\t/// vmat+textures, Assets/prefabs/<sub> for map prefabs. Ignores the picked output folder.\r\n\t/// </summary>\r\n\tClassicSource,\r\n}\r\n\r\n/// <summary>\r\n/// What a material picked on its own turns into. Materials on a MESH are always .vmat -\r\n/// a model's material slots can't reference a terrain or decal resource.\r\n/// </summary>\r\npublic enum MaterialOutput\r\n{\r\n\t/// <summary>A complex.shader .vmat (the default).</summary>\r\n\tMaterial,\r\n\r\n\t/// <summary>A .tmat Terrain Material - for tiling ground surfaces.</summary>\r\n\tTerrain,\r\n\r\n\t/// <summary>A .decal Decal Definition - projected decals.</summary>\r\n\tDecal,\r\n}\r\n\r\n/// <summary>Where each kind of generated file goes.</summary>\r\npublic class ImportPaths\r\n{\r\n\tpublic string ModelsDir;\r\n\tpublic string MaterialsDir;\r\n\tpublic string TexturesDir;\r\n\tpublic string PrefabDir;\r\n\r\n\t/// <summary>What to show the user as \"where it went\".</summary>\r\n\tpublic string Display;\r\n}\r\n\r\n/// <summary>\r\n/// Consumes a staging folder (FBX + PNG + manifest.json from the headless export) and writes\r\n/// sbox assets (.fbx + .vmat + .vmdl) into the project, ready for the engine to compile.\r\n/// </summary>\r\npublic static class AssetImporter\r\n{\r\n\t/// <summary>\r\n\t/// Resolve the destination folders for a layout. Classic Source hangs off the Assets root\r\n\t/// (type first, then subfolder) rather than off the picked output folder.\r\n\t/// </summary>\r\n\tpublic static ImportPaths ResolvePaths( string outputRoot, string assetsDir, ImportLayout layout, string subfolder )\r\n\t{\r\n\t\tswitch ( layout )\r\n\t\t{\r\n\t\t\tcase ImportLayout.Flat:\r\n\t\t\t\treturn new ImportPaths\r\n\t\t\t\t{\r\n\t\t\t\t\tModelsDir = outputRoot,\r\n\t\t\t\t\tMaterialsDir = outputRoot,\r\n\t\t\t\t\tTexturesDir = outputRoot,\r\n\t\t\t\t\tPrefabDir = outputRoot,\r\n\t\t\t\t\tDisplay = outputRoot,\r\n\t\t\t\t};\r\n\r\n\t\t\tcase ImportLayout.PerAsset:\r\n\t\t\t\t// These are the ROOT - Import() appends the per-asset folder as it goes.\r\n\t\t\t\treturn new ImportPaths\r\n\t\t\t\t{\r\n\t\t\t\t\tModelsDir = outputRoot,\r\n\t\t\t\t\tMaterialsDir = outputRoot,\r\n\t\t\t\t\tTexturesDir = outputRoot,\r\n\t\t\t\t\tPrefabDir = outputRoot,\r\n\t\t\t\t\tDisplay = Path.Combine( outputRoot, \"<asset>\" ),\r\n\t\t\t\t};\r\n\r\n\t\t\tcase ImportLayout.ClassicSource:\r\n\t\t\t{\r\n\t\t\t\t// Empty subfolder is legal - assets land straight in Assets/models, Assets/materials.\r\n\t\t\t\tvar sub = SanitizeSubfolder( subfolder );\r\n\t\t\t\tstring Under( string type ) => string.IsNullOrEmpty( sub )\r\n\t\t\t\t\t? Path.Combine( assetsDir, type )\r\n\t\t\t\t\t: Path.Combine( assetsDir, type, sub );\r\n\r\n\t\t\t\tvar models = Under( \"models\" );\r\n\t\t\t\tvar materials = Under( \"materials\" );\r\n\r\n\t\t\t\treturn new ImportPaths\r\n\t\t\t\t{\r\n\t\t\t\t\tModelsDir = models,\r\n\t\t\t\t\t// Textures live beside the vmats that reference them.\r\n\t\t\t\t\tMaterialsDir = materials,\r\n\t\t\t\t\tTexturesDir = materials,\r\n\t\t\t\t\tPrefabDir = Under( \"prefabs\" ),\r\n\t\t\t\t\tDisplay = $\"{models}\\n{materials}\",\r\n\t\t\t\t};\r\n\t\t\t}\r\n\r\n\t\t\tdefault:\r\n\t\t\t\treturn new ImportPaths\r\n\t\t\t\t{\r\n\t\t\t\t\tModelsDir = Path.Combine( outputRoot, \"models\" ),\r\n\t\t\t\t\tMaterialsDir = Path.Combine( outputRoot, \"materials\" ),\r\n\t\t\t\t\tTexturesDir = Path.Combine( outputRoot, \"textures\" ),\r\n\t\t\t\t\tPrefabDir = outputRoot,\r\n\t\t\t\t\tDisplay = outputRoot,\r\n\t\t\t\t};\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Trim a user-typed subfolder to a safe relative path (\"Props/Barrels\" stays nested).</summary>\r\n\tstatic string SanitizeSubfolder( string subfolder )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( subfolder ) )\r\n\t\t\treturn \"\";\r\n\r\n\t\tvar parts = subfolder.Split( new[] { '/', '\\\\' }, StringSplitOptions.RemoveEmptyEntries )\r\n\t\t\t.Select( p => p.Trim() )\r\n\t\t\t.Where( p => p.Length > 0 && p != \".\" && p != \"..\" )\r\n\t\t\t.Select( Sanitize );\r\n\r\n\t\treturn string.Join( Path.DirectorySeparatorChar, parts );\r\n\t}\r\n\r\n\t/// <param name=\"manifest\"></param>\r\n\t/// <param name=\"stagingDir\"></param>\r\n\t/// <param name=\"outputRoot\"></param>\r\n\t/// <param name=\"progressToken\"></param>\r\n\t/// <param name=\"layout\">How the generated files are foldered - see <see cref=\"ImportLayout\"/>.</param>\r\n\t/// <param name=\"subfolder\">Subfolder under Assets/models + Assets/materials, ClassicSource layout only.</param>\r\n\t/// <param name=\"onProgress\">(done, total, current asset name) per imported model.</param>\r\n\t/// <param name=\"generateLods\">When false, models get no auto-LOD chain (full detail always).</param>\r\n\t/// <param name=\"lightScale\">Extra multiplier on converted scene-light brightness (1 = calibrated default).</param>\r\n\t/// <param name=\"materialOutput\">What standalone materials become - vmat, tmat or decal. Mesh slots are always vmat.</param>\r\n\t/// <param name=\"perAssetFolderDepth\">\r\n\t/// PerAsset layout only: how many folders up the /Game path to name each asset's folder after.\r\n\t/// 0 = the asset's own name (e.g. mi_sjfnbeaa). Fab/Megascans bury the real name a couple of\r\n\t/// folders up (.../Fine_American_Road_sjfnbeaa/Medium/MI_sjfnbeaa), so 2 gives a readable folder.\r\n\t/// </param>\r\n\t/// <param name=\"maxTextureSize\">Cap every written texture's longest edge, downscaling bigger sources (0 = keep as-is).</param>\r\n\tpublic static async Task<ImportSummary> Import( ImportManifest manifest, string stagingDir, string outputRoot, CancellationToken progressToken, ImportLayout layout = ImportLayout.Grouped, string subfolder = null, Action<int, int, string> onProgress = null, bool generateLods = true, float lightScale = 1f, MaterialOutput materialOutput = MaterialOutput.Material, int perAssetFolderDepth = 0, int maxTextureSize = 0 )\r\n\t{\r\n\t\tvar assetsDir = FindAssetsDir( outputRoot ) ?? Sandbox.Project.Current?.GetAssetsPath();\r\n\t\tif ( string.IsNullOrEmpty( assetsDir ) )\r\n\t\t\tthrow new Exception( \"Could not resolve the project's Assets folder. Pick an output folder inside Assets/.\" );\r\n\r\n\t\tvar paths = ResolvePaths( outputRoot, assetsDir, layout, subfolder );\r\n\t\tvar summary = new ImportSummary { OutputDir = paths.Display };\r\n\r\n\t\tDirectory.CreateDirectory( paths.ModelsDir );\r\n\t\tDirectory.CreateDirectory( paths.MaterialsDir );\r\n\t\tDirectory.CreateDirectory( paths.TexturesDir );\r\n\r\n\t\t// PerAsset puts every asset in its own self-contained folder; every other layout\r\n\t\t// shares one set of directories for the whole import. The folder is named after a\r\n\t\t// parent of the /Game path (perAssetFolderDepth up) when the asset's own name is\r\n\t\t// unhelpful - Fab MIs are named like \"mi_sjfnbeaa\".\r\n\t\t(string models, string materials, string textures) DirsFor( string ownName, string gamePath )\r\n\t\t{\r\n\t\t\tif ( layout != ImportLayout.PerAsset )\r\n\t\t\t\treturn (paths.ModelsDir, paths.MaterialsDir, paths.TexturesDir);\r\n\r\n\t\t\tvar dir = Path.Combine( paths.ModelsDir, PerAssetFolder( gamePath, ownName, perAssetFolderDepth ) );\r\n\t\t\tDirectory.CreateDirectory( dir );\r\n\t\t\treturn (dir, dir, dir);\r\n\t\t}\r\n\r\n\t\t// Track materials we've already written so shared ones are processed once. Keyed by\r\n\t\t// folder too: under PerAsset the same material is deliberately written into each\r\n\t\t// asset's folder, so the name alone would wrongly dedupe it away.\r\n\t\tvar writtenVmats = new Dictionary<string, string>(); // \"<dir>|<base>\" -> vmat content path\r\n\t\tvar modelsByGamePath = new Dictionary<string, string>(); // /Game path -> vmdl content path\r\n\t\tvar mirroredByGamePath = new Dictionary<string, string>(); // /Game path -> mirrored vmdl content path\r\n\r\n\t\t// Scene placements with an odd number of negative scale axes are true mirrors -\r\n\t\t// s&box doesn't flip winding for negative GameObject scale, so those need a\r\n\t\t// mirrored model variant (negative vmdl import_scale bakes the mirror + winding).\r\n\t\t// Progress spans meshes then standalone materials as one run.\r\n\t\tvar totalAssets = manifest.Assets.Count + (manifest.Materials?.Count ?? 0);\r\n\r\n\t\tvar needsMirror = new HashSet<string>();\r\n\t\tforeach ( var p in manifest.Scene?.Placements ?? new() )\r\n\t\t{\r\n\t\t\tif ( p.Mesh is not null && p.Scale is { Length: >= 3 } && p.Scale.Count( v => v < 0 ) % 2 == 1 )\r\n\t\t\t\tneedsMirror.Add( p.Mesh );\r\n\t\t}\r\n\r\n\t\tfor ( int i = 0; i < manifest.Assets.Count; i++ )\r\n\t\t{\r\n\t\t\tvar asset = manifest.Assets[i];\r\n\t\t\tprogressToken.ThrowIfCancellationRequested();\r\n\r\n\t\t\t// Everything below is synchronous and slow (per-pixel texture passes, model\r\n\t\t\t// compiles), so hand the editor's event loop a chance to repaint between assets -\r\n\t\t\t// without this the whole import is one frozen window with a stale progress bar.\r\n\t\t\tawait Task.Delay( 1, progressToken );\r\n\t\t\tonProgress?.Invoke( i + 1, totalAssets, asset.Asset );\r\n\r\n\t\t\tif ( string.IsNullOrEmpty( asset.Fbx ) )\r\n\t\t\t{\r\n\t\t\t\tsummary.Warnings.Add( $\"{asset.Asset}: no fbx in manifest, skipped.\" );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\t// Copy the mesh.\r\n\t\t\tvar fbxSrc = Path.Combine( stagingDir, asset.Fbx.Replace( '/', Path.DirectorySeparatorChar ) );\r\n\t\t\tif ( !File.Exists( fbxSrc ) )\r\n\t\t\t{\r\n\t\t\t\tsummary.Warnings.Add( $\"{asset.Asset}: fbx missing at {fbxSrc}, skipped.\" );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tvar modelName = Sanitize( asset.Asset );\r\n\t\t\tvar (modelsDir, materialsDir, texturesDir) = DirsFor( modelName, asset.GamePath );\r\n\t\t\tvar fbxDst = Path.Combine( modelsDir, modelName + \".fbx\" );\r\n\t\t\tFile.Copy( fbxSrc, fbxDst, overwrite: true );\r\n\r\n\t\t\t// Build per-slot remaps, writing vmats + textures as needed.\r\n\t\t\tvar remaps = new List<(string slot, string vmat)>();\r\n\r\n\t\t\tforeach ( var mat in asset.Materials )\r\n\t\t\t{\r\n\t\t\t\tvar baseName = MaterialBaseName( mat );\r\n\r\n\t\t\t\t// A single material is seconds of texture work at 4K, and a mesh can have a\r\n\t\t\t\t// dozen - report each one, or the asset line alone looks stalled.\r\n\t\t\t\tonProgress?.Invoke( i + 1, totalAssets, $\"{asset.Asset} - {baseName}\" );\r\n\t\t\t\tawait Task.Delay( 1, progressToken );\r\n\r\n\t\t\t\t// s&box reads the FBX material *node* name, which Unreal writes as the assigned\r\n\t\t\t\t// material (the MI, e.g. \"MI_OilBarrel_01a\") - NOT the DCC slot label (\"lambert2\",\r\n\t\t\t\t// which ends up unused). So the remap must key off the material name.\r\n\t\t\t\tvar remapKey = !string.IsNullOrEmpty( mat.Material ) ? mat.Material : mat.Slot;\r\n\r\n\t\t\t\tvar vmatContent = await WriteVmat( mat, baseName, stagingDir, assetsDir, materialsDir, texturesDir, writtenVmats, summary, progressToken, maxTextureSize );\r\n\t\t\t\tremaps.Add( (remapKey, vmatContent) );\r\n\t\t\t}\r\n\r\n\t\t\t// UE's FBX exporter names material nodes after the assigned material - when two\r\n\t\t\t// slots share one material, the FBX SDK uniquifies the duplicates with numeric\r\n\t\t\t// suffixes (MI_Escalator_01a + MI_Escalator_01a_3). Those suffixed nodes need\r\n\t\t\t// remaps too, or the engine hunts for a literal \"mi_escalator_01a_3.vmat\".\r\n\t\t\tremaps.AddRange( SuffixedRemaps( fbxDst, remaps ) );\r\n\r\n\t\t\tonProgress?.Invoke( i + 1, totalAssets, $\"{asset.Asset} - compiling model\" );\r\n\t\t\tawait Task.Delay( 1, progressToken );\r\n\r\n\t\t\t// Write the model, then verify it compiles. Hull-from-render chokes on some\r\n\t\t\t// geometry (dense foliage cards -> \"Inconsistent hull geometry\"), so fall back\r\n\t\t\t// to a single hull, then to no collision, until the model compiles.\r\n\t\t\tvar fbxContent = ToContentPath( assetsDir, fbxDst );\r\n\t\t\tvar vmdlPath = Path.Combine( modelsDir, modelName + \".vmdl\" );\r\n\t\t\tvar scale = asset.ImportScale <= 0 ? 0.3937f : asset.ImportScale;\r\n\t\t\tstring usedHullMode = null;\r\n\r\n\t\t\tforeach ( var hullMode in new[] { \"HullPerElement\", \"SingleHull\", null } )\r\n\t\t\t{\r\n\t\t\t\tawait File.WriteAllTextAsync( vmdlPath, Kv3Writer.VmdlText( fbxContent, scale, remaps, hullMode, lods: generateLods ), progressToken );\r\n\r\n\t\t\t\tvar vmdlAsset = global::Editor.AssetSystem.RegisterFile( vmdlPath );\r\n\t\t\t\tif ( vmdlAsset is null )\r\n\t\t\t\t\tbreak; // can't verify here - leave the default and let the engine compile later\r\n\r\n\t\t\t\tif ( vmdlAsset.Compile( full: false ) && !vmdlAsset.IsCompileFailed )\r\n\t\t\t\t{\r\n\t\t\t\t\tusedHullMode = hullMode;\r\n\t\t\t\t\tbreak;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tif ( hullMode is null )\r\n\t\t\t\t\tsummary.Warnings.Add( $\"{asset.Asset}: model failed to compile even without collision - see console.\" );\r\n\t\t\t\telse\r\n\t\t\t\t\tsummary.Warnings.Add( $\"{asset.Asset}: collision '{hullMode}' failed to compile, falling back to {(hullMode == \"HullPerElement\" ? \"SingleHull\" : \"no collision\")}.\" );\r\n\t\t\t}\r\n\r\n\t\t\tsummary.Models++;\r\n\r\n\t\t\tif ( !string.IsNullOrEmpty( asset.GamePath ) )\r\n\t\t\t\tmodelsByGamePath[asset.GamePath] = ToContentPath( assetsDir, vmdlPath );\r\n\r\n\t\t\t// Mirrored variant for placements that flip this mesh. Uses the ScaleAndMirror\r\n\t\t\t// model modifier (flip across local X, winding corrected) - NOT a negative\r\n\t\t\t// import_scale, which mirrors the verts but leaves faces wound inside-out.\r\n\t\t\t// The prefab builder composes a 180\u00b0 rotation to turn the X-flip into whatever\r\n\t\t\t// mirror the placement actually wants.\r\n\t\t\tif ( asset.GamePath is not null && needsMirror.Contains( asset.GamePath ) )\r\n\t\t\t{\r\n\t\t\t\tvar mirrorPath = Path.Combine( modelsDir, modelName + \"_mirror.vmdl\" );\r\n\t\t\t\tawait File.WriteAllTextAsync( mirrorPath, Kv3Writer.VmdlText( fbxContent, scale, remaps, usedHullMode ?? \"HullPerElement\", mirror: true, lods: generateLods ), progressToken );\r\n\r\n\t\t\t\tvar mirrorAsset = global::Editor.AssetSystem.RegisterFile( mirrorPath );\r\n\t\t\t\tif ( mirrorAsset is not null && (!mirrorAsset.Compile( full: false ) || mirrorAsset.IsCompileFailed) )\r\n\t\t\t\t\tsummary.Warnings.Add( $\"{asset.Asset}: mirrored variant failed to compile - mirrored placements will use the unmirrored model.\" );\r\n\t\t\t\telse\r\n\t\t\t\t\tmirroredByGamePath[asset.GamePath] = ToContentPath( assetsDir, mirrorPath );\r\n\t\t\t}\r\n\r\n\t\t\tLog.Info( $\"[{i + 1}/{manifest.Assets.Count}] Imported {asset.Asset} -> {vmdlPath}\" +\r\n\t\t\t\t(usedHullMode != \"HullPerElement\" ? $\" (collision: {usedHullMode ?? \"none\"})\" : \"\") );\r\n\t\t}\r\n\r\n\t\t// A model's material slots have to be vmats, so a terrain/decal choice only applies to\r\n\t\t// the standalone materials - say so rather than leaving the user to wonder.\r\n\t\tif ( materialOutput != MaterialOutput.Material && manifest.Assets.Count > 0 )\r\n\t\t\tsummary.Warnings.Add( $\"Material output '{materialOutput}' applies to materials imported on their own; the {manifest.Assets.Count} mesh(es) still got .vmat materials.\" );\r\n\r\n\t\t// Materials picked on their own: no mesh, just a vmat + its textures. Surface packs\r\n\t\t// (Megascans Surfaces) consist of nothing else.\r\n\t\tforeach ( var mat in manifest.Materials ?? new() )\r\n\t\t{\r\n\t\t\tprogressToken.ThrowIfCancellationRequested();\r\n\r\n\t\t\tvar name = mat.Asset ?? mat.Material ?? \"material\";\r\n\t\t\tonProgress?.Invoke( manifest.Assets.Count + manifest.Materials.IndexOf( mat ) + 1, totalAssets, name );\r\n\t\t\tawait Task.Delay( 1, progressToken );\r\n\r\n\t\t\tvar baseName = MaterialBaseName( mat );\r\n\t\t\t// A standalone material is its own asset, so PerAsset gives it its own folder.\r\n\t\t\tvar (_, matDir, texDir) = DirsFor( baseName, mat.GamePath );\r\n\r\n\t\t\tstring written;\r\n\t\t\tif ( materialOutput == MaterialOutput.Terrain )\r\n\t\t\t{\r\n\t\t\t\twritten = WriteTerrainMaterial( mat, baseName, stagingDir, assetsDir, matDir, texDir, summary, maxTextureSize );\r\n\t\t\t}\r\n\t\t\telse if ( materialOutput == MaterialOutput.Decal )\r\n\t\t\t{\r\n\t\t\t\twritten = WriteDecal( mat, baseName, stagingDir, assetsDir, matDir, texDir, summary, maxTextureSize );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\twritten = await WriteVmat( mat, baseName, stagingDir, assetsDir, matDir, texDir, writtenVmats, summary, progressToken, maxTextureSize );\r\n\r\n\t\t\t\t// A vmat is just a file we wrote - nothing compiles it for us here (a model\r\n\t\t\t\t// would have pulled it in), so register it or it won't show in the asset\r\n\t\t\t\t// browser until a rescan. The GameResource paths are saved through the asset\r\n\t\t\t\t// system already, which registers and compiles them.\r\n\t\t\t\tglobal::Editor.AssetSystem.RegisterFile( Path.Combine( matDir, baseName + \".vmat\" ) );\r\n\t\t\t}\r\n\r\n\t\t\tLog.Info( $\"Imported material {name} -> {written}\" );\r\n\t\t}\r\n\r\n\t\t// Scene mode: turn the level's placements + lights into a prefab next to the models.\r\n\t\tif ( manifest.Scene is not null )\r\n\t\t{\r\n\t\t\tif ( manifest.Scene.Warnings is { Count: > 0 } )\r\n\t\t\t\tsummary.Warnings.AddRange( manifest.Scene.Warnings );\r\n\r\n\t\t\tDirectory.CreateDirectory( paths.PrefabDir );\r\n\t\t\tsummary.PrefabPath = ScenePrefabBuilder.Build( manifest.Scene, modelsByGamePath, paths.PrefabDir, summary.Warnings, mirroredByGamePath );\r\n\t\t\tsummary.Placements = manifest.Scene.Placements?.Count ?? 0;\r\n\t\t}\r\n\r\n\t\treturn summary;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Write (or reuse) the .vmat for one material, processing its textures on the way.\r\n\t/// Returns the vmat's content path. Shared by mesh slots and standalone material imports.\r\n\t/// </summary>\r\n\tstatic async Task<string> WriteVmat( ManifestMaterial mat, string baseName, string stagingDir, string assetsDir,\r\n\t\tstring materialsDir, string texturesDir, Dictionary<string, string> writtenVmats, ImportSummary summary, CancellationToken token, int maxTextureSize = 0 )\r\n\t{\r\n\t\tvar cacheKey = $\"{materialsDir}|{baseName}\";\r\n\t\tif ( writtenVmats.TryGetValue( cacheKey, out var existing ) )\r\n\t\t\treturn existing;\r\n\r\n\t\tvar emissive = EmissiveParams( mat );\r\n\t\tvar alphaRole = AlphaRoleFor( mat, emissive is not null );\r\n\r\n\t\tvar tex = TextureProcessor.Process( mat, stagingDir, texturesDir, baseName, alphaRole, maxTextureSize: maxTextureSize );\r\n\t\tsummary.Textures += CountTextures( tex );\r\n\r\n\t\t// Self-illum source: dedicated emissive texture wins, else the albedo-alpha mask.\r\n\t\tvar selfIllumMask = tex.Emissive ?? tex.SelfIllumMask;\r\n\r\n\t\tvar vmatText = Kv3Writer.VmatText(\r\n\t\t\tcolor: TexContent( assetsDir, texturesDir, tex.Color ),\r\n\t\t\tnormal: TexContent( assetsDir, texturesDir, tex.Normal ),\r\n\t\t\troughness: TexContent( assetsDir, texturesDir, tex.Roughness ),\r\n\t\t\tmetallic: TexContent( assetsDir, texturesDir, tex.Metallic ),\r\n\t\t\tao: TexContent( assetsDir, texturesDir, tex.Ao ),\r\n\t\t\talpha: TexContent( assetsDir, texturesDir, tex.Alpha ),\r\n\t\t\t// Tint stays INERT by default (white) so the albedo's own colours show through.\r\n\t\t\t// The mask + captured tint colours are emitted for optional manual recolouring.\r\n\t\t\ttintMask: TexContent( assetsDir, texturesDir, tex.TintMask ),\r\n\t\t\ttintColor: null,\r\n\t\t\ttintAmount: null,\r\n\t\t\ttintComment: TintComment( mat ),\r\n\t\t\talphaTest: mat.BlendMode?.Contains( \"MASKED\" ) == true,\r\n\t\t\tselfIllumMask: TexContent( assetsDir, texturesDir, selfIllumMask ),\r\n\t\t\tselfIllumTint: emissive?.tint,\r\n\t\t\tselfIllumBrightness: emissive?.magnitude ?? 1f,\r\n\t\t\tselfIllumFromAlbedoAlpha: tex.Emissive is null && tex.SelfIllumMask is not null );\r\n\r\n\t\tvar vmatPath = Path.Combine( materialsDir, baseName + \".vmat\" );\r\n\t\tawait File.WriteAllTextAsync( vmatPath, vmatText, token );\r\n\t\tsummary.Materials++;\r\n\r\n\t\t// complex.shader has no displacement input - say so rather than silently dropping it.\r\n\t\tif ( !string.IsNullOrEmpty( mat.Height ) )\r\n\t\t\tsummary.Warnings.Add( $\"{baseName}: has a displacement/height map, which complex.shader can't use - ignored.\" );\r\n\r\n\t\tvar content = ToContentPath( assetsDir, vmatPath );\r\n\t\twrittenVmats[cacheKey] = content;\r\n\t\treturn content;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Write a .tmat Terrain Material. Terrain wants separate grayscale maps plus the height\r\n\t/// map (which the vmat path has no slot for), and carries metalness as a scalar - so a\r\n\t/// metallic texture has nowhere to go and is reported rather than silently dropped.\r\n\t/// </summary>\r\n\tstatic string WriteTerrainMaterial( ManifestMaterial mat, string baseName, string stagingDir, string assetsDir,\r\n\t\tstring materialsDir, string texturesDir, ImportSummary summary, int maxTextureSize = 0 )\r\n\t{\r\n\t\tvar tex = TextureProcessor.Process( mat, stagingDir, texturesDir, baseName, AlphaRole.Ignore, packRmo: false, wantHeight: true, maxTextureSize: maxTextureSize );\r\n\t\tsummary.Textures += CountTextures( tex );\r\n\r\n\t\tvar path = Path.Combine( materialsDir, baseName + \".tmat\" );\r\n\t\tvar asset = GameResourceWriter.CreateTerrainMaterial( path,\r\n\t\t\talbedo: TexContent( assetsDir, texturesDir, tex.Color ),\r\n\t\t\troughness: TexContent( assetsDir, texturesDir, tex.Roughness ),\r\n\t\t\tnormal: TexContent( assetsDir, texturesDir, tex.Normal ),\r\n\t\t\theight: TexContent( assetsDir, texturesDir, tex.Height ),\r\n\t\t\tao: TexContent( assetsDir, texturesDir, tex.Ao ) );\r\n\r\n\t\tif ( asset is null )\r\n\t\t{\r\n\t\t\tsummary.Warnings.Add( $\"{baseName}: failed to create the terrain material - see console.\" );\r\n\t\t\treturn ToContentPath( assetsDir, path );\r\n\t\t}\r\n\r\n\t\tsummary.Materials++;\r\n\r\n\t\tif ( tex.Metallic is not null )\r\n\t\t\tsummary.Warnings.Add( $\"{baseName}: terrain materials carry metalness as a single value, not a texture - the metallic map was not used.\" );\r\n\t\tif ( tex.Height is null )\r\n\t\t\tsummary.Warnings.Add( $\"{baseName}: no height/displacement map found - terrain height blending will be flat.\" );\r\n\r\n\t\treturn asset.Path;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Write a .decal Decal Definition. Decals take ONE packed rough/metal/occlusion map\r\n\t/// rather than three, and are masked by the colour texture's alpha - so an opaque source\r\n\t/// material makes a decal that covers its whole quad.\r\n\t/// </summary>\r\n\tstatic string WriteDecal( ManifestMaterial mat, string baseName, string stagingDir, string assetsDir,\r\n\t\tstring materialsDir, string texturesDir, ImportSummary summary, int maxTextureSize = 0 )\r\n\t{\r\n\t\t// Keep the albedo's alpha as the decal mask whatever the Unreal blend mode says.\r\n\t\tvar tex = TextureProcessor.Process( mat, stagingDir, texturesDir, baseName, AlphaRole.Ignore, packRmo: true, wantHeight: true, maxTextureSize: maxTextureSize );\r\n\t\tsummary.Textures += CountTextures( tex );\r\n\r\n\t\tvar path = Path.Combine( materialsDir, baseName + \".decal\" );\r\n\t\tvar asset = GameResourceWriter.CreateDecal( path,\r\n\t\t\tcolor: TexContent( assetsDir, texturesDir, tex.Color ),\r\n\t\t\tnormal: TexContent( assetsDir, texturesDir, tex.Normal ),\r\n\t\t\trmo: TexContent( assetsDir, texturesDir, tex.RoughMetalOcclusion ),\r\n\t\t\temissive: TexContent( assetsDir, texturesDir, tex.Emissive ),\r\n\t\t\theight: TexContent( assetsDir, texturesDir, tex.Height ) );\r\n\r\n\t\tif ( asset is null )\r\n\t\t{\r\n\t\t\tsummary.Warnings.Add( $\"{baseName}: failed to create the decal - see console.\" );\r\n\t\t\treturn ToContentPath( assetsDir, path );\r\n\t\t}\r\n\r\n\t\tsummary.Materials++;\r\n\r\n\t\tif ( tex.Color is null )\r\n\t\t\tsummary.Warnings.Add( $\"{baseName}: decal has no colour texture - its alpha is what masks a decal, so this one won't show.\" );\r\n\r\n\t\treturn asset.Path;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Scan the FBX for numeric-suffixed variants of known material node names\r\n\t/// (duplicate-material slots uniquified by the FBX SDK) and remap them to the same\r\n\t/// vmat as their base name. False positives from unrelated strings just produce\r\n\t/// unused remap entries, which are harmless.\r\n\t/// </summary>\r\n\tstatic List<(string slot, string vmat)> SuffixedRemaps( string fbxPath, IReadOnlyList<(string slot, string vmat)> remaps )\r\n\t{\r\n\t\tvar extra = new List<(string, string)>();\r\n\r\n\t\tstring text;\r\n\t\ttry\r\n\t\t{\r\n\t\t\ttext = Encoding.ASCII.GetString( File.ReadAllBytes( fbxPath ) );\r\n\t\t}\r\n\t\tcatch\r\n\t\t{\r\n\t\t\treturn extra;\r\n\t\t}\r\n\r\n\t\tvar known = remaps.Select( r => r.slot ).ToHashSet( StringComparer.OrdinalIgnoreCase );\r\n\r\n\t\tforeach ( var (slot, vmat) in remaps.ToList() )\r\n\t\t{\r\n\t\t\tif ( string.IsNullOrEmpty( slot ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tforeach ( System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches( text, System.Text.RegularExpressions.Regex.Escape( slot ) + @\"_\\d+\" ) )\r\n\t\t\t{\r\n\t\t\t\tif ( known.Add( m.Value ) )\r\n\t\t\t\t\textra.Add( (m.Value, vmat) );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn extra;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// What the albedo's alpha channel means, from the Unreal blend mode. Opaque materials'\r\n\t/// alpha is NOT opacity - with emissive params present it's a self-illum mask (lamp\r\n\t/// housings etc.), otherwise it packs something we can't interpret and is ignored.\r\n\t/// Old manifests without blend_mode keep the legacy translucency behaviour.\r\n\t/// </summary>\r\n\tstatic AlphaRole AlphaRoleFor( ManifestMaterial mat, bool hasEmissiveParams )\r\n\t{\r\n\t\tvar blend = mat.BlendMode ?? \"\";\r\n\t\tif ( blend.Length == 0 || blend.Contains( \"TRANSLUCENT\" ) || blend.Contains( \"MASKED\" )\r\n\t\t\t|| blend.Contains( \"ADDITIVE\" ) || blend.Contains( \"MODULATE\" ) )\r\n\t\t\treturn AlphaRole.Translucency;\r\n\r\n\t\treturn hasEmissiveParams ? AlphaRole.SelfIllum : AlphaRole.Ignore;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Emissive tint (Unreal LINEAR) + linear brightness multiplier from the Material\r\n\t/// Instance's parameter overrides (\"Emissive Multiply\", \"Emissive Color Multi\", ...).\r\n\t/// Null when the material has no emissive-ish parameter.\r\n\t/// </summary>\r\n\tstatic (float[] tint, float magnitude)? EmissiveParams( ManifestMaterial mat )\r\n\t{\r\n\t\tif ( mat.VectorParams is not null )\r\n\t\t{\r\n\t\t\tforeach ( var kv in mat.VectorParams )\r\n\t\t\t{\r\n\t\t\t\tif ( !kv.Key.Contains( \"emissiv\", StringComparison.OrdinalIgnoreCase ) || kv.Value is not { Length: >= 3 } )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tfloat mag = Math.Max( kv.Value[0], Math.Max( kv.Value[1], kv.Value[2] ) );\r\n\t\t\t\tif ( mag > 0 )\r\n\t\t\t\t\treturn (kv.Value, mag);\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( mat.ScalarParams is not null )\r\n\t\t{\r\n\t\t\tforeach ( var kv in mat.ScalarParams )\r\n\t\t\t{\r\n\t\t\t\tif ( kv.Key.Contains( \"emissiv\", StringComparison.OrdinalIgnoreCase ) && kv.Value > 0 )\r\n\t\t\t\t\treturn (null, kv.Value);\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn null;\r\n\t}\r\n\r\n\t/// <summary>Human-readable note of the tint colours Unreal had, so they can be wired up by hand.</summary>\r\n\tstatic string TintComment( ManifestMaterial mat )\r\n\t{\r\n\t\tvar parts = new List<string>();\r\n\t\tif ( mat.TintColor is not null )\r\n\t\t\tparts.Add( $\"tint=[{FmtColor( mat.TintColor )}]\" );\r\n\t\tif ( mat.TintZones is not null )\r\n\t\t\tforeach ( var kv in mat.TintZones )\r\n\t\t\t\tparts.Add( $\"{kv.Key}=[{FmtColor( kv.Value )}]\" );\r\n\r\n\t\treturn parts.Count == 0 ? null : \"Captured Unreal tint (NOT auto-applied; set g_vColorTint to use): \" + string.Join( \", \", parts );\r\n\t}\r\n\r\n\tstatic string FmtColor( float[] c )\r\n\t{\r\n\t\tif ( c is null )\r\n\t\t\treturn \"\";\r\n\r\n\t\tvar sb = new StringBuilder();\r\n\t\tfor ( int i = 0; i < c.Length; i++ )\r\n\t\t{\r\n\t\t\tif ( i > 0 ) sb.Append( ' ' );\r\n\t\t\tsb.Append( c[i].ToString( \"0.###\", System.Globalization.CultureInfo.InvariantCulture ) );\r\n\t\t}\r\n\t\treturn sb.ToString();\r\n\t}\r\n\r\n\tstatic int CountTextures( ProcessedTextures t )\r\n\t{\r\n\t\tint n = 0;\r\n\t\tif ( t.Color != null ) n++;\r\n\t\tif ( t.Alpha != null ) n++;\r\n\t\tif ( t.Normal != null ) n++;\r\n\t\tif ( t.Roughness != null ) n++;\r\n\t\tif ( t.Metallic != null ) n++;\r\n\t\tif ( t.Ao != null ) n++;\r\n\t\tif ( t.Emissive != null ) n++;\r\n\t\tif ( t.TintMask != null ) n++;\r\n\t\tif ( t.SelfIllumMask != null ) n++;\r\n\t\tif ( t.Height != null ) n++;\r\n\t\tif ( t.RoughMetalOcclusion != null ) n++;\r\n\t\treturn n;\r\n\t}\r\n\r\n\tstatic string TexContent( string assetsDir, string texturesDir, string fileName )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( fileName ) )\r\n\t\t\treturn null;\r\n\r\n\t\treturn ToContentPath( assetsDir, Path.Combine( texturesDir, fileName ) );\r\n\t}\r\n\r\n\t/// <summary>Path relative to the Assets folder, forward slashes, lowercase.</summary>\r\n\tstatic string ToContentPath( string assetsDir, string absPath )\r\n\t\t=> Path.GetRelativePath( assetsDir, absPath ).Replace( '\\\\', '/' ).ToLowerInvariant();\r\n\r\n\tstatic string FindAssetsDir( string path )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( path ) )\r\n\t\t\treturn null;\r\n\r\n\t\tvar d = new DirectoryInfo( path );\r\n\t\twhile ( d != null )\r\n\t\t{\r\n\t\t\tif ( string.Equals( d.Name, \"Assets\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t\treturn d.FullName;\r\n\r\n\t\t\td = d.Parent;\r\n\t\t}\r\n\r\n\t\treturn null;\r\n\t}\r\n\r\n\t/// <summary>Base name (lowercase, dot-free) for a material's textures + vmat, from the MI name when available.</summary>\r\n\tstatic string MaterialBaseName( ManifestMaterial mat )\r\n\t{\r\n\t\tif ( !string.IsNullOrEmpty( mat.Material ) )\r\n\t\t\treturn Sanitize( mat.Material );\r\n\r\n\t\t// Fall back to a texture filename minus its role suffix.\r\n\t\tvar any = mat.Alb ?? mat.Nrm ?? mat.Rma ?? mat.Rough ?? mat.Metal ?? mat.Ao;\r\n\t\tif ( !string.IsNullOrEmpty( any ) )\r\n\t\t{\r\n\t\t\tvar name = Path.GetFileNameWithoutExtension( any );\r\n\t\t\tforeach ( var suffix in new[] { \"_ALB\", \"_ALBEDO\", \"_BASECOLOR\", \"_COLOR\", \"_NRM\", \"_NORMAL\", \"_RMA\", \"_ORM\" } )\r\n\t\t\t{\r\n\t\t\t\tif ( name.EndsWith( suffix, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tname = name[..^suffix.Length];\r\n\t\t\t\t\tbreak;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\treturn Sanitize( name );\r\n\t\t}\r\n\r\n\t\treturn Sanitize( mat.Slot ?? \"material\" );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Folder name for an asset under the PerAsset layout: its own sanitized name at depth 0,\r\n\t/// or an ancestor of its /Game path further up. Fab MIs carry meaningless names\r\n\t/// (mi_sjfnbeaa) while the human-readable pack name sits a couple of folders above\r\n\t/// (.../Fine_American_Road_sjfnbeaa/Medium/MI_sjfnbeaa), so depth 2 names the folder for it.\r\n\t/// Never climbs into the \"/Game\" mount root, and falls back to the own name if the path\r\n\t/// is too shallow for the requested depth.\r\n\t/// </summary>\r\n\tstatic string PerAssetFolder( string gamePath, string ownName, int depth )\r\n\t{\r\n\t\tif ( depth <= 0 || string.IsNullOrEmpty( gamePath ) )\r\n\t\t\treturn Sanitize( ownName );\r\n\r\n\t\tvar parts = gamePath.Split( '/', StringSplitOptions.RemoveEmptyEntries );\r\n\r\n\t\t// Last segment is the asset itself; walk `depth` folders up from it.\r\n\t\tint idx = parts.Length - 1 - depth;\r\n\r\n\t\t// parts[0] is normally the \"Game\" mount - don't name a folder after it.\r\n\t\tint floor = parts.Length > 1 && parts[0].Equals( \"Game\", StringComparison.OrdinalIgnoreCase ) ? 1 : 0;\r\n\r\n\t\tif ( idx < floor || idx >= parts.Length - 1 )\r\n\t\t\treturn Sanitize( ownName );\r\n\r\n\t\treturn Sanitize( parts[idx] );\r\n\t}\r\n\r\n\t/// <summary>Lowercase; non [a-z0-9_] -> '_'. Guarantees no dots in generated filenames.</summary>\r\n\tstatic string Sanitize( string s )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( s ) )\r\n\t\t\treturn \"unnamed\";\r\n\r\n\t\tvar sb = new StringBuilder( s.Length );\r\n\t\tforeach ( var ch in s.ToLowerInvariant() )\r\n\t\t\tsb.Append( (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' ? ch : '_' );\r\n\r\n\t\treturn sb.ToString();\r\n\t}\r\n}\r\n"
},
{
"Ident": "brax.unrealimporter",
"Path": "Editor/Import/UassetThumbnail.cs",
"FileName": "UassetThumbnail.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 337301,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Security.Cryptography;\nusing System.Text;\nusing System.Threading.Tasks;\nusing Sandbox;\n\nnamespace Editor.UnrealImporter;\n\n/// <summary>\n/// Extracts the editor thumbnail Unreal embeds in every saved (uncooked) .uasset.\n///\n/// The package stores an FObjectThumbnail: 12 bytes of header (int32 width, height,\n/// compressedSize) followed by the compressed image - PNG normally, JPEG in newer\n/// packs (flagged by a negative height). Rather than parsing the version-dependent\n/// package summary to find the thumbnail table, we scan for the PNG/JPEG magic and\n/// validate the header that precedes it - verified against UE 5.x Fab packs.\n///\n/// Extracted images are cached on disk under the project's .sbox/ folder (keyed on\n/// file mtime+size, so re-saved assets re-extract), plus an in-memory Pixmap cache\n/// because the import window rebuilds its list on every keystroke.\n/// </summary>\npublic static class UassetThumbnail\n{\n\t// path -> pixmap (null = scanned, no thumbnail found)\n\tstatic readonly Dictionary<string, Pixmap> memoryCache = new();\n\tstatic readonly Dictionary<string, Task<Pixmap>> inFlight = new();\n\n\tstatic string CacheDir => Sandbox.Project.Current is not null\n\t\t? Path.Combine( Sandbox.Project.Current.GetRootPath(), \".sbox\", \"unrealimporter\", \"thumbnails\" )\n\t\t: Path.Combine( Path.GetTempPath(), \"unrealimporter\", \"thumbnails\" );\n\n\t/// <summary>Memory-cache lookup. True if this path has been resolved (pixmap may still be null).</summary>\n\tpublic static bool TryGetCached( string absPath, out Pixmap pixmap )\n\t\t=> memoryCache.TryGetValue( absPath, out pixmap );\n\n\t/// <summary>\n\t/// Resolve the thumbnail for a .uasset: memory cache, then disk cache, then a scan of the\n\t/// file itself. Returns null if the asset has no embedded thumbnail. Safe to call\n\t/// repeatedly - concurrent requests for the same path share one task.\n\t/// </summary>\n\tpublic static Task<Pixmap> LoadAsync( string absPath )\n\t{\n\t\tif ( memoryCache.TryGetValue( absPath, out var cached ) )\n\t\t\treturn Task.FromResult( cached );\n\n\t\tif ( inFlight.TryGetValue( absPath, out var running ) )\n\t\t\treturn running;\n\n\t\tvar task = Load( absPath );\n\t\tinFlight[absPath] = task;\n\t\treturn task;\n\t}\n\n\tstatic async Task<Pixmap> Load( string absPath )\n\t{\n\t\tstring imagePath = null;\n\t\ttry\n\t\t{\n\t\t\t// File IO + scanning off the main thread; only the Pixmap itself is created back on it.\n\t\t\timagePath = await Task.Run( () => ResolveCacheFile( absPath ) );\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tLog.Warning( $\"Thumbnail extraction failed for {absPath}: {e.Message}\" );\n\t\t}\n\n\t\tvar pixmap = imagePath is not null ? Pixmap.FromFile( imagePath ) : null;\n\t\tmemoryCache[absPath] = pixmap;\n\t\tinFlight.Remove( absPath );\n\t\treturn pixmap;\n\t}\n\n\t/// <summary>\n\t/// Path to a cached thumbnail image for this uasset, extracting it if needed.\n\t/// Null if the asset has no embedded thumbnail (recorded with a .none marker).\n\t/// </summary>\n\tstatic string ResolveCacheFile( string absPath )\n\t{\n\t\tvar fi = new FileInfo( absPath );\n\t\tif ( !fi.Exists )\n\t\t\treturn null;\n\n\t\tvar pathHash = ShortHash( absPath.ToLowerInvariant() );\n\t\tvar statHash = ShortHash( $\"{fi.LastWriteTimeUtc.Ticks}|{fi.Length}\" );\n\t\tvar dir = CacheDir;\n\t\tvar baseName = Path.Combine( dir, $\"{pathHash}_{statHash}\" );\n\n\t\tif ( File.Exists( baseName + \".png\" ) ) return baseName + \".png\";\n\t\tif ( File.Exists( baseName + \".jpg\" ) ) return baseName + \".jpg\";\n\t\tif ( File.Exists( baseName + \".none\" ) ) return null;\n\n\t\tDirectory.CreateDirectory( dir );\n\n\t\t// The asset changed since it was last cached - drop the stale entries for this path.\n\t\tforeach ( var stale in Directory.EnumerateFiles( dir, pathHash + \"_*\" ) )\n\t\t\tFile.Delete( stale );\n\n\t\tvar (image, ext) = Extract( File.ReadAllBytes( absPath ) );\n\t\tif ( image is null )\n\t\t{\n\t\t\tFile.WriteAllBytes( baseName + \".none\", Array.Empty<byte>() );\n\t\t\treturn null;\n\t\t}\n\n\t\tvar target = baseName + ext;\n\t\tFile.WriteAllBytes( target, image );\n\t\treturn target;\n\t}\n\n\tstatic string ShortHash( string input )\n\t\t=> Convert.ToHexString( SHA256.HashData( Encoding.UTF8.GetBytes( input ) ) )[..16].ToLowerInvariant();\n\n\tstatic readonly byte[] PngMagic = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };\n\n\t/// <summary>Find the embedded thumbnail in raw .uasset bytes, or (null, null).</summary>\n\tinternal static (byte[] Image, string Extension) Extract( byte[] data )\n\t{\n\t\tif ( TryFindImage( data, PngMagic, out var png ) )\n\t\t\treturn (png, \".png\");\n\n\t\t// JPEG: SOI (FF D8 FF) followed by an APP0/APP1/DQT segment.\n\t\tif ( TryFindJpeg( data, out var jpg ) )\n\t\t\treturn (jpg, \".jpg\");\n\n\t\treturn (null, null);\n\t}\n\n\tstatic bool TryFindImage( byte[] data, byte[] magic, out byte[] image )\n\t{\n\t\tint pos = 12;\n\t\twhile ( (pos = IndexOf( data, magic, pos )) >= 0 )\n\t\t{\n\t\t\tif ( TrySlice( data, pos, out image ) )\n\t\t\t\treturn true;\n\t\t\tpos += 1;\n\t\t}\n\n\t\timage = null;\n\t\treturn false;\n\t}\n\n\tstatic bool TryFindJpeg( byte[] data, out byte[] image )\n\t{\n\t\tfor ( int pos = 12; pos < data.Length - 4; pos++ )\n\t\t{\n\t\t\tif ( data[pos] != 0xFF || data[pos + 1] != 0xD8 || data[pos + 2] != 0xFF )\n\t\t\t\tcontinue;\n\t\t\tvar seg = data[pos + 3];\n\t\t\tif ( seg != 0xE0 && seg != 0xE1 && seg != 0xDB )\n\t\t\t\tcontinue;\n\t\t\tif ( TrySlice( data, pos, out image ) )\n\t\t\t\treturn true;\n\t\t}\n\n\t\timage = null;\n\t\treturn false;\n\t}\n\n\t/// <summary>\n\t/// Validate the FObjectThumbnail header in the 12 bytes before the image magic and\n\t/// slice out the image. Rejects magic hits that aren't preceded by a sane header.\n\t/// </summary>\n\tstatic bool TrySlice( byte[] data, int magicPos, out byte[] image )\n\t{\n\t\timage = null;\n\t\tif ( magicPos < 12 )\n\t\t\treturn false;\n\n\t\tint width = BitConverter.ToInt32( data, magicPos - 12 );\n\t\tint height = Math.Abs( BitConverter.ToInt32( data, magicPos - 8 ) ); // negative = JPEG flag\n\t\tint size = BitConverter.ToInt32( data, magicPos - 4 );\n\n\t\tif ( width < 4 || width > 8192 || height < 4 || height > 8192 )\n\t\t\treturn false;\n\t\tif ( size < 16 || (long)magicPos + size > data.Length )\n\t\t\treturn false;\n\n\t\timage = data[magicPos..(magicPos + size)];\n\t\treturn true;\n\t}\n\n\tstatic int IndexOf( byte[] haystack, byte[] needle, int start )\n\t{\n\t\tvar idx = haystack.AsSpan( start ).IndexOf( needle );\n\t\treturn idx < 0 ? -1 : start + idx;\n\t}\n}\n"
},
{
"Ident": "brax.unrealimporter",
"Path": "Editor/Widgets/Fieldset.cs",
"FileName": "Fieldset.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 337301,
"Code": "using Sandbox;\n\nnamespace Editor.UnrealImporter;\n\n/// <summary>\n/// A titled section box: a rounded border with its title notched into the top-left edge,\n/// like an HTML fieldset/legend. Add content to <see cref=\"Widget.Layout\"/> as usual - the\n/// margins already leave room for the title and border.\n/// </summary>\npublic class Fieldset : Widget\n{\n\t/// <summary>Height reserved for the title row; the border runs through its middle.</summary>\n\tconst float TitleHeight = 16;\n\tconst float TitleInset = 10;\n\tconst float TitlePad = 5;\n\n\tpublic string Title { get; set; }\n\n\tpublic Fieldset( string title, Widget parent ) : base( parent )\n\t{\n\t\tTitle = title;\n\n\t\tLayout = Layout.Column();\n\t\tLayout.Spacing = 8;\n\t\tLayout.Margin = new Sandbox.UI.Margin( 12, TitleHeight + 10, 12, 12 );\n\t}\n\n\tprotected override void OnPaint()\n\t{\n\t\t// The border starts halfway down the title so the text can sit on the line.\n\t\tvar border = LocalRect.Shrink( 0.5f );\n\t\tborder.Top += TitleHeight * 0.5f;\n\n\t\t// Fill first: the section needs to read as a raised panel, not just an outline.\n\t\tPaint.ClearPen();\n\t\tPaint.SetBrush( ImportStyle.Panel );\n\t\tPaint.DrawRect( border, 4 );\n\n\t\tPaint.ClearBrush();\n\t\tPaint.SetPen( Theme.Border, 1 );\n\t\tPaint.DrawRect( border, 4 );\n\n\t\tif ( string.IsNullOrEmpty( Title ) )\n\t\t\treturn;\n\n\t\tPaint.SetDefaultFont( 8, 400 );\n\t\tvar text = Paint.MeasureText( Title );\n\n\t\t// Punch a gap in the border so the title reads as part of the frame, not on top of it.\n\t\t// The gap straddles the border line, so each half takes the fill it sits against -\n\t\t// window background above, panel fill below.\n\t\tvar gap = new Rect( TitleInset - TitlePad, border.Top - TitleHeight * 0.5f,\n\t\t\ttext.x + TitlePad * 2, TitleHeight );\n\n\t\tPaint.ClearPen();\n\n\t\tvar above = gap;\n\t\tabove.Bottom = border.Top;\n\t\tPaint.SetBrush( Theme.WindowBackground );\n\t\tPaint.DrawRect( above );\n\n\t\tvar below = gap;\n\t\tbelow.Top = border.Top;\n\t\tPaint.SetBrush( ImportStyle.Panel );\n\t\tPaint.DrawRect( below );\n\n\t\tPaint.ClearBrush();\n\t\tPaint.SetPen( Theme.Text.WithAlpha( 0.9f ) );\n\t\tPaint.DrawText( gap, Title, TextFlag.Center );\n\t}\n}\n"
},
{
"Ident": "brax.unrealimporter",
"Path": "Editor/Import/Kv3Writer.cs",
"FileName": "Kv3Writer.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 337301,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Globalization;\r\nusing System.Text;\r\n\r\nnamespace Editor.UnrealImporter;\r\n\r\n/// <summary>\r\n/// Generates sbox .vmat and .vmdl (kv3 text) from processed import data.\r\n/// Structure mirrors Assets/prefabs/capture_point/sm_flagpole_tall_01a.vmdl + .vmat.\r\n/// </summary>\r\npublic static class Kv3Writer\r\n{\r\n\tstatic string F( float v ) => v.ToString( \"0.0######\", CultureInfo.InvariantCulture );\r\n\r\n\t/// <summary>\r\n\t/// Format an Unreal LINEAR tint color as g_vColorTint's \"[r g b a]\" string.\r\n\t/// g_vColorTint is sRGB-gamma in the shader (it does SrgbGammaToLinear), so we sRGB-encode\r\n\t/// Unreal's linear value. Null/missing -> white (no tint).\r\n\t/// </summary>\r\n\tstatic string ColorTint( float[] c )\r\n\t{\r\n\t\tif ( c is null || c.Length < 3 )\r\n\t\t\treturn \"[1.000000 1.000000 1.000000 0.000000]\";\r\n\r\n\t\tfloat r = LinearToSrgb( c[0] ), g = LinearToSrgb( c[1] ), b = LinearToSrgb( c[2] );\r\n\t\treturn $\"[{r.ToString( \"0.000000\", CultureInfo.InvariantCulture )} \" +\r\n\t\t\t$\"{g.ToString( \"0.000000\", CultureInfo.InvariantCulture )} \" +\r\n\t\t\t$\"{b.ToString( \"0.000000\", CultureInfo.InvariantCulture )} 0.000000]\";\r\n\t}\r\n\r\n\tstatic float LinearToSrgb( float c )\r\n\t{\r\n\t\tc = System.Math.Clamp( c, 0f, 1f );\r\n\t\treturn c <= 0.0031308f ? c * 12.92f : 1.055f * System.MathF.Pow( c, 1f / 2.4f ) - 0.055f;\r\n\t}\r\n\r\n\t/// <summary>Chroma of an HDR color: components divided by the max (null/black -> white).</summary>\r\n\tstatic float[] Normalized( float[] c )\r\n\t{\r\n\t\tif ( c is null || c.Length < 3 )\r\n\t\t\treturn new float[] { 1f, 1f, 1f, 1f };\r\n\r\n\t\tfloat max = System.MathF.Max( c[0], System.MathF.Max( c[1], c[2] ) );\r\n\t\tif ( max <= 0f )\r\n\t\t\treturn new float[] { 1f, 1f, 1f, 1f };\r\n\r\n\t\treturn new[] { c[0] / max, c[1] / max, c[2] / max, 1f };\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// A complex.shader material. Texture arguments are Content-relative paths (forward slashes),\r\n\t/// or null to omit that slot. alphaTest picks F_ALPHA_TEST over F_TRANSLUCENT for the alpha\r\n\t/// map (UE Masked materials). selfIllumMask enables F_SELF_ILLUM: a grayscale albedo-alpha\r\n\t/// mask (selfIllumFromAlbedoAlpha=true, glow tinted by the albedo) or a dedicated RGB\r\n\t/// emissive texture. selfIllumBrightness is a LINEAR multiplier (converted to the shader's\r\n\t/// pow2 exponent), selfIllumTint an Unreal LINEAR color.\r\n\t/// </summary>\r\n\tpublic static string VmatText( string color, string normal, string roughness, string metallic, string ao, string alpha = null,\r\n\t\tstring tintMask = null, float[] tintColor = null, float? tintAmount = null, string tintComment = null,\r\n\t\tbool alphaTest = false, string selfIllumMask = null, float[] selfIllumTint = null, float selfIllumBrightness = 1f,\r\n\t\tbool selfIllumFromAlbedoAlpha = false )\r\n\t{\r\n\t\tvar sb = new StringBuilder();\r\n\t\tsb.AppendLine( \"// THIS FILE IS AUTO-GENERATED (unreal_importer)\" );\r\n\t\tif ( !string.IsNullOrEmpty( tintComment ) )\r\n\t\t\tsb.AppendLine( $\"// {tintComment}\" );\r\n\t\tsb.AppendLine();\r\n\t\tsb.AppendLine( \"Layer0\" );\r\n\t\tsb.AppendLine( \"{\" );\r\n\t\tsb.AppendLine( \"\\tshader \\\"shaders/complex.shader\\\"\" );\r\n\t\tsb.AppendLine();\r\n\t\tsb.AppendLine( \"\\t//---- PBR ----\" );\r\n\r\n\t\tif ( !string.IsNullOrEmpty( metallic ) )\r\n\t\t{\r\n\t\t\tsb.AppendLine( \"\\tF_METALNESS_TEXTURE 1\" );\r\n\t\t}\r\n\t\t\r\n\t\tsb.AppendLine( \"\\tF_SPECULAR 1\" );\r\n\t\t\r\n\t\tif ( !string.IsNullOrEmpty( tintMask ) )\r\n\t\t{\r\n\t\t\tsb.AppendLine( \"\\tF_TINT_MASK 1\" );\r\n\t\t}\r\n\r\n\t\tif ( !string.IsNullOrEmpty( alpha ) )\r\n\t\t{\r\n\t\t\tsb.AppendLine();\r\n\t\t\tsb.AppendLine( \"\\t//---- Alpha ----\" );\r\n\t\t\tif ( alphaTest )\r\n\t\t\t{\r\n\t\t\t\tsb.AppendLine( \"\\tF_ALPHA_TEST 1\" );\r\n\t\t\t\tsb.AppendLine( \"\\tg_flAlphaTestReference \\\"0.500\\\"\" );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tsb.AppendLine( \"\\tF_TRANSLUCENT 1\" );\r\n\t\t\t}\r\n\t\t\tsb.AppendLine( $\"\\tTextureTranslucency \\\"{alpha}\\\"\" );\r\n\t\t}\r\n\r\n\t\tif ( !string.IsNullOrEmpty( selfIllumMask ) )\r\n\t\t{\r\n\t\t\tfloat mag = MathF.Max( selfIllumBrightness, 0.001f );\r\n\t\t\tvar tint = Normalized( selfIllumTint );\r\n\t\t\tsb.AppendLine();\r\n\t\t\tsb.AppendLine( \"\\t//---- Self Illum ----\" );\r\n\t\t\tsb.AppendLine( \"\\tF_SELF_ILLUM 1\" );\r\n\t\t\tsb.AppendLine( $\"\\tTextureSelfIllumMask \\\"{selfIllumMask}\\\"\" );\r\n\t\t\tsb.AppendLine( $\"\\tg_vSelfIllumTint \\\"{ColorTint( tint )}\\\"\" );\r\n\t\t\tsb.AppendLine( $\"\\tg_flSelfIllumBrightness \\\"{F( Math.Clamp( MathF.Log2( mag ), -10f, 10f ) )}\\\"\" );\r\n\t\t\tsb.AppendLine( \"\\tg_flSelfIllumScale \\\"1.000\\\"\" );\r\n\t\t\t// Grayscale alpha masks carry no colour - let the albedo tint the glow.\r\n\t\t\tsb.AppendLine( $\"\\tg_flSelfIllumAlbedoFactor \\\"{(selfIllumFromAlbedoAlpha ? \"1.000\" : \"0.000\")}\\\"\" );\r\n\t\t}\r\n\r\n\t\tif ( !string.IsNullOrEmpty( ao ) )\r\n\t\t{\r\n\t\t\tsb.AppendLine();\r\n\t\t\tsb.AppendLine( \"\\t//---- Ambient Occlusion ----\" );\r\n\t\t\tsb.AppendLine( \"\\tg_flAmbientOcclusionDirectDiffuse \\\"0.000\\\"\" );\r\n\t\t\tsb.AppendLine( \"\\tg_flAmbientOcclusionDirectSpecular \\\"0.000\\\"\" );\r\n\t\t\tsb.AppendLine( $\"\\tTextureAmbientOcclusion \\\"{ao}\\\"\" );\r\n\t\t}\r\n\r\n\t\tsb.AppendLine();\r\n\t\tsb.AppendLine( \"\\t//---- Color ----\" );\r\n\t\tsb.AppendLine( $\"\\tg_flModelTintAmount \\\"{F( tintAmount ?? 1.0f )}\\\"\" );\r\n\t\tsb.AppendLine( $\"\\tg_vColorTint \\\"{ColorTint( tintColor )}\\\"\" );\r\n\t\tif ( !string.IsNullOrEmpty( color ) )\r\n\t\t\tsb.AppendLine( $\"\\tTextureColor \\\"{color}\\\"\" );\r\n\r\n\t\tif ( !string.IsNullOrEmpty( tintMask ) )\r\n\t\t{\r\n\t\t\tsb.AppendLine();\r\n\t\t\tsb.AppendLine( \"\\t//---- Tint Mask ----\" );\r\n\t\t\tsb.AppendLine( $\"\\tTextureTintMask \\\"{tintMask}\\\"\" );\r\n\t\t}\r\n\r\n\t\tsb.AppendLine();\r\n\t\tsb.AppendLine( \"\\t//---- Fog ----\" );\r\n\t\tsb.AppendLine( \"\\tg_bFogEnabled \\\"1\\\"\" );\r\n\r\n\t\tif ( !string.IsNullOrEmpty( metallic ) )\r\n\t\t{\r\n\t\t\tsb.AppendLine();\r\n\t\t\tsb.AppendLine( \"\\t//---- Metalness ----\" );\r\n\t\t\tsb.AppendLine( $\"\\tTextureMetalness \\\"{metallic}\\\"\" );\r\n\t\t}\r\n\r\n\t\tif ( !string.IsNullOrEmpty( normal ) )\r\n\t\t{\r\n\t\t\tsb.AppendLine();\r\n\t\t\tsb.AppendLine( \"\\t//---- Normal ----\" );\r\n\t\t\tsb.AppendLine( $\"\\tTextureNormal \\\"{normal}\\\"\" );\r\n\t\t}\r\n\r\n\t\tif ( !string.IsNullOrEmpty( roughness ) )\r\n\t\t{\r\n\t\t\tsb.AppendLine();\r\n\t\t\tsb.AppendLine( \"\\t//---- Roughness ----\" );\r\n\t\t\tsb.AppendLine( \"\\tg_flRoughnessScaleFactor \\\"1.000\\\"\" );\r\n\t\t\tsb.AppendLine( $\"\\tTextureRoughness \\\"{roughness}\\\"\" );\r\n\t\t}\r\n\r\n\t\tsb.AppendLine();\r\n\t\tsb.AppendLine( \"\\t//---- Texture Coordinates ----\" );\r\n\t\tsb.AppendLine( \"\\tg_vTexCoordOffset \\\"[0.000 0.000]\\\"\" );\r\n\t\tsb.AppendLine( \"\\tg_vTexCoordScale \\\"[1.000 1.000]\\\"\" );\r\n\t\tsb.AppendLine( \"\\tg_vTexCoordScrollSpeed \\\"[0.000 0.000]\\\"\" );\r\n\t\tsb.AppendLine( \"}\" );\r\n\r\n\t\treturn sb.ToString();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// A static model referencing an FBX, with per-slot material remaps, a hull-from-render\r\n\t/// collision shape, and a 5-level auto-LOD chain (matches the flagpole reference).\r\n\t/// hullMode: \"HullPerElement\" (default), \"SingleHull\", \"HullPerMesh\", or null for no\r\n\t/// collision at all - dense foliage geometry can fail hull generation entirely.\r\n\t/// mirror emits a ModelModifier_ScaleAndMirror flipping local X - unlike a negative\r\n\t/// import_scale (which mirrors but leaves the triangle winding inverted, so faces\r\n\t/// get culled from the wrong side), the modifier corrects winding properly.\r\n\t/// lods=false skips the auto-LOD chain entirely (full detail at every distance).\r\n\t/// </summary>\r\n\tpublic static string VmdlText( string fbxContentPath, float importScale, IReadOnlyList<(string slot, string vmat)> remaps, string hullMode = \"HullPerElement\", bool mirror = false, bool lods = true )\r\n\t{\r\n\t\tvar sb = new StringBuilder();\r\n\t\tsb.AppendLine( \"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->\" );\r\n\t\tsb.AppendLine( \"{\" );\r\n\t\tsb.AppendLine( \"\\trootNode =\" );\r\n\t\tsb.AppendLine( \"\\t{\" );\r\n\t\tsb.AppendLine( \"\\t\\t_class = \\\"RootNode\\\"\" );\r\n\t\tsb.AppendLine( \"\\t\\tchildren =\" );\r\n\t\tsb.AppendLine( \"\\t\\t[\" );\r\n\r\n\t\t// --- Material groups (remaps) ---\r\n\t\tsb.AppendLine( \"\\t\\t\\t{\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t_class = \\\"MaterialGroupList\\\"\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\tchildren =\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t[\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t{\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t_class = \\\"DefaultMaterialGroup\\\"\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tremaps =\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t[\" );\r\n\t\tforeach ( var (slot, vmat) in remaps )\r\n\t\t{\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t\\t{\" );\r\n\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\t\\t\\tfrom = \\\"{slot}\\\"\" );\r\n\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\t\\t\\tto = \\\"{vmat}\\\"\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t\\t},\" );\r\n\t\t}\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t]\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tuse_global_default = false\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tglobal_default_material = \\\"\\\"\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t},\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t]\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t},\" );\r\n\r\n\t\t// --- Mirror (proper winding-corrected flip across local X) ---\r\n\t\tif ( mirror )\r\n\t\t{\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t{\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t_class = \\\"ModelModifierList\\\"\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\tchildren =\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t[\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t{\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t_class = \\\"ModelModifier_ScaleAndMirror\\\"\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tscale = 1.0\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tmirror_x = true\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tmirror_y = false\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tmirror_z = false\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tflip_bone_forward = false\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tswap_left_and_right_bones = false\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t},\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t]\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t},\" );\r\n\t\t}\r\n\r\n\t\t// --- Collision (hull from render mesh) ---\r\n\t\tif ( hullMode is not null )\r\n\t\t{\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t{\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t_class = \\\"PhysicsShapeList\\\"\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\tchildren =\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t[\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t{\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t_class = \\\"PhysicsHullFromRender\\\"\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tparent_bone = \\\"\\\"\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tsurface_prop = \\\"default\\\"\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tcollision_tags = \\\"solid\\\"\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tfaceMergeAngle = 20.0\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tmaxHullVertices = 32\" );\r\n\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\thull_mode = \\\"{hullMode}\\\"\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t},\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t]\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t},\" );\r\n\t\t}\r\n\r\n\t\t// --- Render mesh (FBX) ---\r\n\t\tsb.AppendLine( \"\\t\\t\\t{\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t_class = \\\"RenderMeshList\\\"\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\tchildren =\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t[\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t{\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t_class = \\\"RenderMeshFile\\\"\" );\r\n\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\tfilename = \\\"{fbxContentPath}\\\"\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\timport_translation = [ 0.0, 0.0, 0.0 ]\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\timport_rotation = [ 0.0, 0.0, 0.0 ]\" );\r\n\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\timport_scale = {F( importScale )}\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\talign_origin_x_type = \\\"None\\\"\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\talign_origin_y_type = \\\"None\\\"\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\talign_origin_z_type = \\\"None\\\"\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tparent_bone = \\\"\\\"\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\timport_filter =\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t{\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t\\texclude_by_default = false\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t\\texception_list = [ ]\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t}\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t},\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t]\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t},\" );\r\n\r\n\t\t// --- Auto LODs ---\r\n\t\tif ( lods )\r\n\t\t\tAppendLodGroupList( sb );\r\n\r\n\t\tsb.AppendLine( \"\\t\\t]\" );\r\n\t\tsb.AppendLine( \"\\t\\tmodel_archetype = \\\"\\\"\" );\r\n\t\tsb.AppendLine( \"\\t\\tprimary_associated_entity = \\\"\\\"\" );\r\n\t\tsb.AppendLine( \"\\t\\tanim_graph_name = \\\"\\\"\" );\r\n\t\tsb.AppendLine( \"\\t\\tbase_model_name = \\\"\\\"\" );\r\n\t\tsb.AppendLine( \"\\t}\" );\r\n\t\tsb.AppendLine( \"}\" );\r\n\r\n\t\treturn sb.ToString();\r\n\t}\r\n\r\n\tstatic void AppendLodGroupList( StringBuilder sb )\r\n\t{\r\n\t\t// (switch_threshold, simplify_mode, reduction, lock_border, permissive, protect_uv, meshes-on-lod0)\r\n\t\t// Reductions compound down the chain; keep the cumulative ratio (~0.17) gentle enough\r\n\t\t// that low-poly meshes never simplify to 0 triangles - a LOD with no geometry fails\r\n\t\t// the whole model compile (seen with 12-triangle drywall sheets at cumulative 0.04).\r\n\t\tvar lods = new (float thr, int mode, float red, bool lockBorder, bool permissive, bool protectUv, bool hasMesh)[]\r\n\t\t{\r\n\t\t\t( 0.0f, 0, 0.5f, true, false, true, true ),\r\n\t\t\t( 25.0f, 1, 0.5f, true, false, true, false ),\r\n\t\t\t( 40.0f, 1, 0.6f, false, true, true, false ),\r\n\t\t\t( 60.0f, 1, 0.7f, false, true, false, false ),\r\n\t\t\t( 80.0f, 1, 0.8f, false, true, false, false ),\r\n\t\t};\r\n\r\n\t\tsb.AppendLine( \"\\t\\t\\t{\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t_class = \\\"LODGroupList\\\"\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\tchildren =\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t[\" );\r\n\r\n\t\tforeach ( var l in lods )\r\n\t\t{\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t{\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t_class = \\\"LODGroup\\\"\" );\r\n\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\tswitch_threshold = {F( l.thr )}\" );\r\n\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\tauto_simplify_mode = {l.mode}\" );\r\n\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\tauto_reduction = {F( l.red )}\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tauto_max_error = 0.0\" );\r\n\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\tauto_lock_border_vertices = {B( l.lockBorder )}\" );\r\n\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\tauto_permissive_simplification = {B( l.permissive )}\" );\r\n\t\t\tsb.AppendLine( $\"\\t\\t\\t\\t\\t\\tauto_protect_uv_seams = {B( l.protectUv )}\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tauto_regularize = 1\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tauto_prune_isolated_components = false\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tauto_strip_vertex_color = false\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tauto_material_culling_enabled = false\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tmeshes =\" );\r\n\t\t\tif ( l.hasMesh )\r\n\t\t\t{\r\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t[\" );\r\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t\\t\\\"unnamed_1\\\",\" );\r\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t]\" );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\t[ ]\" );\r\n\t\t\t}\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t\\tmaterial_culls = [ ]\" );\r\n\t\t\tsb.AppendLine( \"\\t\\t\\t\\t\\t},\" );\r\n\t\t}\r\n\r\n\t\tsb.AppendLine( \"\\t\\t\\t\\t]\" );\r\n\t\tsb.AppendLine( \"\\t\\t\\t},\" );\r\n\t}\r\n\r\n\tstatic string B( bool v ) => v ? \"true\" : \"false\";\r\n}\r\n"
},
{
"Ident": "brax.unrealimporter",
"Path": "Editor/UnrealImportWindow.cs",
"FileName": "UnrealImportWindow.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 337301,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing Sandbox;\n\nnamespace Editor.UnrealImporter;\n\n/// <summary>\n/// Editor tool: pick an Unreal project folder, tick the static meshes and materials to bring\n/// over, and export them to sbox (FBX + vmat + vmdl) via a headless Unreal pass + kv3 generation.\n///\n/// Materials can be picked on their own - they import as a standalone vmat, which is the whole\n/// point of surface packs (Megascans Surfaces are a material plus its textures, no mesh).\n///\n/// The browser is a folder tree mirroring /Game. Folder checkboxes (tri-state) tick whole\n/// subtrees, maps sit inline in their folders (double-click to import), and each mesh row\n/// shows the triangle count read straight from the .uasset's embedded asset-registry tags.\n/// Searching flattens the tree to matches.\n///\n/// TODO: max texture resolution selection\n/// TODO: make async with progress bar\n/// </summary>\n[EditorApp( \"Unreal Importer\", \"move_to_inbox\", \"Import Unreal / Fab meshes and materials into s&box\" )]\npublic class UnrealImportWindow : Widget\n{\n\t/// <summary>What an entry turns into once imported.</summary>\n\tenum AssetKind\n\t{\n\t\t/// <summary>StaticMesh -> fbx + vmdl (+ the vmats of its slots).</summary>\n\t\tMesh,\n\n\t\t/// <summary>Material / Material Instance -> a standalone vmat.</summary>\n\t\tMaterial,\n\t}\n\n\tclass AssetEntry\n\t{\n\t\tpublic AssetKind Kind;\n\t\tpublic string GamePath; // /Game/.../SM_X\n\t\tpublic string AbsPath; // ...\\Content\\...\\SM_X.uasset\n\t\tpublic string Display; // GamePath without the /Game/ prefix\n\t\tpublic long SizeBytes; // .uasset on disk (uncooked, so this is the whole asset)\n\t\tpublic long Triangles = -1; // meshes only: from the uasset's asset-registry tags; -1 until read\n\t\tpublic bool Selected; // opt-in: nothing ticked until the user picks\n\n\t\tpublic bool IsMesh => Kind == AssetKind.Mesh;\n\t}\n\n\tclass MapEntry\n\t{\n\t\tpublic string GamePath; // /Game/.../Maps/Demonstration\n\t\tpublic string AbsPath; // ...\\Content\\...\\Demonstration.umap\n\t\tpublic string Display;\n\t}\n\n\tclass FolderBucket\n\t{\n\t\tpublic readonly SortedSet<string> Subfolders = new( StringComparer.OrdinalIgnoreCase );\n\t\tpublic readonly List<AssetEntry> Assets = new();\n\t\tpublic readonly List<MapEntry> Maps = new();\n\n\t\t/// <summary>Every asset anywhere below this folder - drives the tri-state checkbox.</summary>\n\t\tpublic readonly List<AssetEntry> Subtree = new();\n\t}\n\n\tconst float CheckWidth = 26;\n\tconst float ThumbSize = 34;\n\n\tinterface ICheckRow\n\t{\n\t\tvoid OnCheckClicked();\n\t}\n\n\t/// <summary>A row that can supply a large hover preview.</summary>\n\tinterface IPreviewRow\n\t{\n\t\tPixmap PreviewPixmap { get; }\n\t\tstring PreviewCaption { get; }\n\t}\n\n\t/// <summary>\n\t/// Frameless tooltip window showing a row's embedded thumbnail at full size\n\t/// (Unreal stores them at 256x256; the list shrinks them to 34px).\n\t/// </summary>\n\tclass ThumbPreview : Widget\n\t{\n\t\tconst float ImageSize = 256;\n\t\tconst float CaptionHeight = 20;\n\t\tconst float Pad = 8;\n\n\t\treadonly Pixmap pixmap;\n\t\treadonly string caption;\n\n\t\tpublic object Key;\n\n\t\tpublic ThumbPreview( Pixmap pixmap, string caption, Vector2 screenPos ) : base( null )\n\t\t{\n\t\t\tthis.pixmap = pixmap;\n\t\t\tthis.caption = caption;\n\n\t\t\tWindowFlags = WindowFlags.ToolTip | WindowFlags.FramelessWindowHint | WindowFlags.WindowDoesNotAcceptFocus;\n\t\t\tFocusMode = FocusMode.None;\n\t\t\tTransparentForMouseEvents = true;\n\t\t\tShowWithoutActivating = true;\n\t\t\tNoSystemBackground = true;\n\n\t\t\tSize = new Vector2( ImageSize + Pad * 2, ImageSize + CaptionHeight + Pad * 2 );\n\t\t\tPosition = screenPos;\n\t\t\tShow();\n\t\t}\n\n\t\tprotected override void OnPaint()\n\t\t{\n\t\t\tPaint.ClearPen();\n\t\t\tPaint.SetBrushAndPen( Theme.ControlBackground, Theme.Border );\n\t\t\tPaint.DrawRect( LocalRect );\n\n\t\t\tvar img = LocalRect.Shrink( Pad );\n\t\t\timg.Height = ImageSize;\n\t\t\tPaint.Draw( img, pixmap );\n\n\t\t\tvar text = LocalRect.Shrink( Pad );\n\t\t\ttext.Top += ImageSize;\n\t\t\tPaint.SetPen( Theme.TextControl.WithAlpha( 0.8f ) );\n\t\t\tPaint.SetDefaultFont( 7 );\n\t\t\tPaint.DrawText( text, caption, TextFlag.Center );\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// TreeView that routes clicks on the leading checkbox column to the row, and pops a\n\t/// large thumbnail preview after hovering a mesh/map row briefly.\n\t/// </summary>\n\tclass ImportTreeView : TreeView\n\t{\n\t\tThumbPreview preview;\n\t\tobject hoverNode;\n\t\tRealTimeSince hoverSince;\n\n\t\tpublic ImportTreeView( Widget parent ) : base( parent )\n\t\t{\n\t\t\tMouseTracking = true;\n\t\t}\n\n\t\tprotected override bool OnItemPressed( VirtualWidget pressedItem, MouseEvent e )\n\t\t{\n\t\t\tif ( e.LeftMouseButton && pressedItem.Object is ICheckRow row )\n\t\t\t{\n\t\t\t\tvar box = pressedItem.Rect;\n\t\t\t\tbox.Left += IndentWidth * pressedItem.Column + ExpandWidth;\n\t\t\t\tbox.Width = CheckWidth;\n\n\t\t\t\tif ( box.IsInside( e.LocalPosition ) )\n\t\t\t\t{\n\t\t\t\t\trow.OnCheckClicked();\n\t\t\t\t\tUpdate();\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn base.OnItemPressed( pressedItem, e );\n\t\t}\n\n\t\tprotected override void OnMouseMove( MouseEvent e )\n\t\t{\n\t\t\tbase.OnMouseMove( e );\n\n\t\t\tvar node = GetItemAt( e.LocalPosition )?.Object;\n\t\t\tif ( node == hoverNode )\n\t\t\t\treturn;\n\n\t\t\thoverNode = node;\n\t\t\thoverSince = 0;\n\n\t\t\tif ( preview.IsValid() && preview.Key != node )\n\t\t\t{\n\t\t\t\tpreview.Destroy();\n\t\t\t\tpreview = null;\n\t\t\t}\n\t\t}\n\n\t\tprotected override void OnMouseLeave()\n\t\t{\n\t\t\tbase.OnMouseLeave();\n\t\t\tClearPreview();\n\t\t}\n\n\t\tpublic override void OnDestroyed()\n\t\t{\n\t\t\tbase.OnDestroyed();\n\t\t\tClearPreview();\n\t\t}\n\n\t\tvoid ClearPreview()\n\t\t{\n\t\t\thoverNode = null;\n\t\t\tpreview?.Destroy();\n\t\t\tpreview = null;\n\t\t}\n\n\t\t[EditorEvent.Frame]\n\t\tpublic void ShowPreviewWhenSettled()\n\t\t{\n\t\t\tif ( preview.IsValid() || hoverNode is not IPreviewRow row || hoverSince < 0.35f )\n\t\t\t\treturn;\n\n\t\t\tif ( row.PreviewPixmap is null )\n\t\t\t\treturn;\n\n\t\t\t// To the right of the cursor, nudged up so the image is centred on the row.\n\t\t\tvar pos = Application.CursorPosition + new Vector2( 28, -140 );\n\t\t\tpreview = new ThumbPreview( row.PreviewPixmap, row.PreviewCaption, pos ) { Key = hoverNode };\n\t\t}\n\t}\n\n\tclass FolderNode : TreeNode, ICheckRow\n\t{\n\t\treadonly UnrealImportWindow win;\n\t\treadonly string path; // folder path relative to /Game (\"\" only for the virtual root)\n\n\t\tpublic FolderNode( UnrealImportWindow win, string path )\n\t\t{\n\t\t\tthis.win = win;\n\t\t\tthis.path = path;\n\t\t\tValue = \"folder:\" + path;\n\t\t\tHeight = 26;\n\t\t}\n\n\t\tpublic override bool HasChildren => win.FolderHasChildren( path );\n\n\t\tprotected override void BuildChildren()\n\t\t{\n\t\t\tClear();\n\t\t\tAddItems( win.BuildFolderChildNodes( path ) );\n\t\t}\n\n\t\tpublic override void OnPaint( VirtualWidget item )\n\t\t{\n\t\t\tImportStyle.PaintRow( item, TreeView );\n\t\t\tvar r = item.Rect;\n\n\t\t\tvar (sel, total) = win.SubtreeSelection( path );\n\n\t\t\tvar check = r;\n\t\t\tcheck.Width = CheckWidth;\n\t\t\tPaint.SetPen( sel > 0 ? Theme.Primary : Theme.TextControl.WithAlpha( 0.5f ) );\n\t\t\tPaint.DrawIcon( check, sel == 0 ? \"check_box_outline_blank\" : sel == total ? \"check_box\" : \"indeterminate_check_box\", 16, TextFlag.Center );\n\n\t\t\tvar icon = r;\n\t\t\ticon.Left += CheckWidth;\n\t\t\ticon.Width = 22;\n\t\t\tPaint.SetPen( Theme.Yellow.WithAlpha( 0.8f ) );\n\t\t\tPaint.DrawIcon( icon, item.IsOpen ? \"folder_open\" : \"folder\", 16, TextFlag.Center );\n\n\t\t\tvar meta = r;\n\t\t\tmeta.Right -= 6;\n\t\t\tPaint.SetPen( Theme.TextControl.WithAlpha( 0.4f ) );\n\t\t\tPaint.SetDefaultFont( 7 );\n\t\t\tPaint.DrawText( meta, win.SubtreeSummary( path ), TextFlag.RightCenter );\n\n\t\t\tvar text = r;\n\t\t\ttext.Left += CheckWidth + 26;\n\t\t\ttext.Right -= 80;\n\t\t\tPaint.SetPen( Theme.Text );\n\t\t\tPaint.SetDefaultFont();\n\t\t\tvar name = path[(path.LastIndexOf( '/' ) + 1)..];\n\t\t\tPaint.DrawText( text, name, TextFlag.LeftCenter );\n\t\t}\n\n\t\tpublic void OnCheckClicked()\n\t\t{\n\t\t\tvar (sel, total) = win.SubtreeSelection( path );\n\t\t\twin.SetFolderSelected( path, sel < total );\n\t\t}\n\t}\n\n\tclass AssetNode : TreeNode, ICheckRow, IPreviewRow\n\t{\n\t\treadonly UnrealImportWindow win;\n\t\treadonly AssetEntry entry;\n\t\treadonly bool fullPath;\n\n\t\tPixmap pixmap;\n\t\tbool thumbResolved;\n\n\t\t/// <summary>Placeholder + material-row icon: a mesh reads as a solid, a material as a swatch.</summary>\n\t\tstring Icon => entry.IsMesh ? \"view_in_ar\" : \"palette\";\n\n\t\tpublic Pixmap PreviewPixmap => pixmap;\n\t\tpublic string PreviewCaption => entry.Triangles >= 0\n\t\t\t? $\"{entry.Display} \u00b7 {FormatCount( entry.Triangles )} tris\"\n\t\t\t: entry.IsMesh ? entry.Display : $\"{entry.Display} \u00b7 material\";\n\n\t\tpublic AssetNode( UnrealImportWindow win, AssetEntry entry, bool fullPath )\n\t\t{\n\t\t\tthis.win = win;\n\t\t\tthis.entry = entry;\n\t\t\tthis.fullPath = fullPath;\n\t\t\tValue = entry;\n\t\t\tHeight = 40;\n\n\t\t\tif ( UassetThumbnail.TryGetCached( entry.AbsPath, out pixmap ) )\n\t\t\t\tthumbResolved = true;\n\t\t\telse\n\t\t\t\t_ = ResolveThumb();\n\n\t\t\t// Triangle counts are a mesh-only asset-registry tag.\n\t\t\tif ( entry.IsMesh && entry.Triangles < 0 )\n\t\t\t\t_ = ResolveStats();\n\t\t}\n\n\t\tasync Task ResolveThumb()\n\t\t{\n\t\t\tpixmap = await UassetThumbnail.LoadAsync( entry.AbsPath );\n\t\t\tthumbResolved = true;\n\t\t\tTreeView?.Update();\n\t\t}\n\n\t\tasync Task ResolveStats()\n\t\t{\n\t\t\tvar stats = await UassetMeshStats.LoadAsync( entry.AbsPath );\n\t\t\tif ( stats is not null )\n\t\t\t{\n\t\t\t\tentry.Triangles = stats.Triangles;\n\t\t\t\twin.UpdateStatus();\n\t\t\t}\n\t\t\tTreeView?.Update();\n\t\t}\n\n\t\tpublic override void OnPaint( VirtualWidget item )\n\t\t{\n\t\t\tImportStyle.PaintRow( item, TreeView );\n\t\t\tvar r = item.Rect;\n\n\t\t\tvar check = r;\n\t\t\tcheck.Width = CheckWidth;\n\t\t\tPaint.SetPen( entry.Selected ? Theme.Primary : Theme.TextControl.WithAlpha( 0.5f ) );\n\t\t\tPaint.DrawIcon( check, entry.Selected ? \"check_box\" : \"check_box_outline_blank\", 16, TextFlag.Center );\n\n\t\t\tvar thumb = r;\n\t\t\tthumb.Left += CheckWidth;\n\t\t\tthumb.Width = ThumbSize;\n\t\t\tthumb.Top += (r.Height - ThumbSize) / 2;\n\t\t\tthumb.Height = ThumbSize;\n\t\t\tPaint.ClearPen();\n\t\t\tPaint.SetBrush( Theme.ControlBackground );\n\t\t\tPaint.DrawRect( thumb, 3 );\n\t\t\tif ( pixmap is not null )\n\t\t\t{\n\t\t\t\tPaint.Draw( thumb, pixmap, 1, 3 );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tPaint.SetPen( Theme.TextControl.WithAlpha( thumbResolved ? 0.25f : 0.1f ) );\n\t\t\t\tPaint.DrawIcon( thumb, Icon, 18 );\n\t\t\t}\n\n\t\t\tvar meta = r;\n\t\t\tmeta.Right -= 6;\n\t\t\tPaint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );\n\t\t\tPaint.SetDefaultFont( 7 );\n\t\t\tvar label = entry.Triangles >= 0\n\t\t\t\t? $\"{FormatCount( entry.Triangles )} tris \u00b7 {FormatSize( entry.SizeBytes )}\"\n\t\t\t\t: entry.IsMesh\n\t\t\t\t\t? FormatSize( entry.SizeBytes )\n\t\t\t\t\t: $\"material \u00b7 {FormatSize( entry.SizeBytes )}\";\n\t\t\tPaint.DrawText( meta, label, TextFlag.RightCenter );\n\n\t\t\tvar text = r;\n\t\t\ttext.Left += CheckWidth + ThumbSize + 8;\n\t\t\ttext.Right -= 120;\n\t\t\tPaint.SetPen( Theme.Text );\n\t\t\tPaint.SetDefaultFont();\n\t\t\tvar name = fullPath ? entry.Display : entry.Display[(entry.Display.LastIndexOf( '/' ) + 1)..];\n\t\t\tPaint.DrawText( text, name, TextFlag.LeftCenter );\n\t\t}\n\n\t\tpublic void OnCheckClicked()\n\t\t{\n\t\t\tentry.Selected = !entry.Selected;\n\t\t\twin.UpdateStatus();\n\t\t}\n\n\t\tpublic override void OnActivated()\n\t\t{\n\t\t\tOnCheckClicked();\n\t\t\tTreeView?.Update();\n\t\t}\n\t}\n\n\tclass MapNode : TreeNode, IPreviewRow\n\t{\n\t\treadonly UnrealImportWindow win;\n\t\treadonly MapEntry entry;\n\t\treadonly bool fullPath;\n\n\t\tPixmap pixmap;\n\t\tbool thumbResolved;\n\n\t\tpublic Pixmap PreviewPixmap => pixmap;\n\t\tpublic string PreviewCaption => $\"{entry.Display} \u00b7 map\";\n\n\t\tpublic MapNode( UnrealImportWindow win, MapEntry entry, bool fullPath )\n\t\t{\n\t\t\tthis.win = win;\n\t\t\tthis.entry = entry;\n\t\t\tthis.fullPath = fullPath;\n\t\t\tValue = entry;\n\t\t\tHeight = 40;\n\n\t\t\tif ( UassetThumbnail.TryGetCached( entry.AbsPath, out pixmap ) )\n\t\t\t\tthumbResolved = true;\n\t\t\telse\n\t\t\t\t_ = ResolveThumb();\n\t\t}\n\n\t\tasync Task ResolveThumb()\n\t\t{\n\t\t\tpixmap = await UassetThumbnail.LoadAsync( entry.AbsPath );\n\t\t\tthumbResolved = true;\n\t\t\tTreeView?.Update();\n\t\t}\n\n\t\tpublic override void OnPaint( VirtualWidget item )\n\t\t{\n\t\t\tImportStyle.PaintRow( item, TreeView );\n\t\t\tvar r = item.Rect;\n\n\t\t\tvar icon = r;\n\t\t\ticon.Width = CheckWidth;\n\t\t\tPaint.SetPen( Theme.Green.WithAlpha( 0.8f ) );\n\t\t\tPaint.DrawIcon( icon, \"public\", 16, TextFlag.Center );\n\n\t\t\tvar thumb = r;\n\t\t\tthumb.Left += CheckWidth;\n\t\t\tthumb.Width = ThumbSize;\n\t\t\tthumb.Top += (r.Height - ThumbSize) / 2;\n\t\t\tthumb.Height = ThumbSize;\n\t\t\tPaint.ClearPen();\n\t\t\tPaint.SetBrush( Theme.ControlBackground );\n\t\t\tPaint.DrawRect( thumb, 3 );\n\t\t\tif ( pixmap is not null )\n\t\t\t{\n\t\t\t\tPaint.Draw( thumb, pixmap, 1, 3 );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tPaint.SetPen( Theme.TextControl.WithAlpha( thumbResolved ? 0.25f : 0.1f ) );\n\t\t\t\tPaint.DrawIcon( thumb, \"public\", 18 );\n\t\t\t}\n\n\t\t\tvar meta = r;\n\t\t\tmeta.Right -= 6;\n\t\t\tPaint.SetPen( Theme.TextControl.WithAlpha( 0.4f ) );\n\t\t\tPaint.SetDefaultFont( 7 );\n\t\t\tPaint.DrawText( meta, \"map \u00b7 double-click to import\", TextFlag.RightCenter );\n\n\t\t\tvar text = r;\n\t\t\ttext.Left += CheckWidth + ThumbSize + 8;\n\t\t\ttext.Right -= 160;\n\t\t\tPaint.SetPen( Theme.Text );\n\t\t\tPaint.SetDefaultFont();\n\t\t\tvar name = fullPath ? entry.Display : entry.Display[(entry.Display.LastIndexOf( '/' ) + 1)..];\n\t\t\tPaint.DrawText( text, name, TextFlag.LeftCenter );\n\t\t}\n\n\t\tpublic override void OnActivated()\n\t\t{\n\t\t\twin.DoImportMap( entry );\n\t\t}\n\t}\n\n\tstring uprojectPath;\n\tstring uprojectFolder;\n\tstring outputFolder;\n\tstring searchFilter = \"\";\n\tbool flatView;\n\n\treadonly List<AssetEntry> entries = new();\n\treadonly List<MapEntry> mapEntries = new();\n\treadonly Dictionary<string, FolderBucket> folders = new( StringComparer.OrdinalIgnoreCase );\n\tList<TreeNode> rootNodes = new();\n\n\t/// <summary>Width of the left-hand label column, so the settings rows line up.</summary>\n\tconst float LabelWidth = 110;\n\n\tLineEdit projectLabel;\n\tLineEdit outputLabel;\n\tLabel statusLabel;\n\tLineEdit searchEdit;\n\tImportTreeView tree;\n\tButton exportButton;\n\tComboBox layoutCombo;\n\tLineEdit subfolderEdit;\n\tLabel subfolderLabel;\n\tCheckbox lodCheckbox;\n\tLineEdit lightScaleEdit;\n\tComboBox materialOutputCombo;\n\tComboBox perAssetFolderCombo;\n\tLabel perAssetFolderLabel;\n\tComboBox maxTextureSizeCombo;\n\n\t/// <summary>Combo item order - the layout row adds items in exactly this order.</summary>\n\tstatic readonly ImportLayout[] LayoutOrder = { ImportLayout.Grouped, ImportLayout.Flat, ImportLayout.ClassicSource, ImportLayout.PerAsset };\n\n\tImportLayout SelectedLayout => layoutCombo is null ? ImportLayout.Grouped : LayoutOrder[Math.Clamp( layoutCombo.CurrentIndex, 0, LayoutOrder.Length - 1 )];\n\n\t/// <summary>Combo item order - the material output row adds items in exactly this order.</summary>\n\tstatic readonly MaterialOutput[] MaterialOutputOrder = { MaterialOutput.Material, MaterialOutput.Terrain, MaterialOutput.Decal };\n\n\tMaterialOutput SelectedMaterialOutput => materialOutputCombo is null\n\t\t? MaterialOutput.Material\n\t\t: MaterialOutputOrder[Math.Clamp( materialOutputCombo.CurrentIndex, 0, MaterialOutputOrder.Length - 1 )];\n\n\t/// <summary>Per-asset folder-name depth: the combo index IS the depth (0 = asset's own name).</summary>\n\tint PerAssetFolderDepth => perAssetFolderCombo?.CurrentIndex ?? 0;\n\n\t/// <summary>Combo item order - the texture size row adds items in exactly this order. 0 = no cap.</summary>\n\tstatic readonly int[] MaxTextureSizeOrder = { 0, 4096, 2048, 1024, 512 };\n\n\tint MaxTextureSize => maxTextureSizeCombo is null\n\t\t? 0\n\t\t: MaxTextureSizeOrder[Math.Clamp( maxTextureSizeCombo.CurrentIndex, 0, MaxTextureSizeOrder.Length - 1 )];\n\n\tstring Subfolder() => subfolderEdit?.Text ?? \"\";\n\n\t/// <summary>The light-brightness multiplier from the UI, defensively parsed.</summary>\n\tfloat LightScale()\n\t{\n\t\tif ( lightScaleEdit is null )\n\t\t\treturn 1f;\n\n\t\treturn float.TryParse( lightScaleEdit.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v ) && v > 0\n\t\t\t? Math.Clamp( v, 0.01f, 20f )\n\t\t\t: 1f;\n\t}\n\n\tpublic UnrealImportWindow() : this( null ) { }\n\n\tpublic UnrealImportWindow( Widget parent ) : base( parent )\n\t{\n\t\tWindowFlags = WindowFlags.Dialog | WindowFlags.Customized | WindowFlags.WindowTitle | WindowFlags.CloseButton | WindowFlags.WindowSystemMenuHint;\n\t\tDeleteOnClose = true;\n\t\tWindowTitle = \"Unreal Importer\";\n\t\tSetWindowIcon( \"move_to_inbox\" );\n\n\t\toutputFolder = Sandbox.Project.Current is not null\n\t\t\t? Path.Combine( Sandbox.Project.Current.GetAssetsPath(), \"unrealimport\" )\n\t\t\t: null;\n\n\t\tLayout = Layout.Column();\n\t\tLayout.Spacing = 8;\n\t\tLayout.Margin = 16;\n\n\t\tLayout.Add( new WarningBox(\n\t\t\t\"Select an Unreal project folder, tick the meshes and materials you want, and export.\\n\" +\n\t\t\t\"This runs a headless Unreal pass to extract FBX + textures, then generates vmdl/vmat.\\n\" +\n\t\t\t\"A big pack takes minutes - the status line below tracks every phase.\", this ) );\n\n\t\t// Project row\n\t\t{\n\t\t\tvar row = Layout.Row();\n\t\t\trow.Spacing = 8;\n\t\t\trow.Add( new Label( \"Unreal Project\", this ) { FixedWidth = LabelWidth } );\n\n\t\t\tprojectLabel = new LineEdit( this )\n\t\t\t{\n\t\t\t\tReadOnly = true,\n\t\t\t\tPlaceholderText = \"No Unreal project selected\",\n\t\t\t\tToolTip = \"The .uproject the assets are read from\",\n\t\t\t}.StyleInput();\n\t\t\trow.Add( projectLabel, 1 );\n\t\t\trow.Add( new Button( \"Browse Project...\", \"folder_open\", this ) { Clicked = PickProject } );\n\t\t\tLayout.Add( row );\n\t\t}\n\n\t\t// ---- Asset Selection ----\n\t\t{\n\t\t\tvar section = new Fieldset( \"Asset Selection\", this );\n\n\t\t\tvar toolRow = Layout.Row();\n\t\t\ttoolRow.Spacing = 8;\n\n\t\t\tsearchEdit = new LineEdit( this ) { PlaceholderText = \"\u2315 Search meshes and materials\", ToolTip = \"Filter the list by name or path\" };\n\t\t\tsearchEdit.StyleInput();\n\t\t\tsearchEdit.TextEdited += t =>\n\t\t\t{\n\t\t\t\tsearchFilter = t ?? \"\";\n\t\t\t\tRefreshTree();\n\t\t\t\tUpdateStatus();\n\t\t\t};\n\t\t\ttoolRow.Add( searchEdit, 1 );\n\n\t\t\ttoolRow.Add( new Button( \"Select All\", \"done_all\", this ) { Clicked = () => SetAll( true ) } );\n\t\t\ttoolRow.Add( new Button( \"Select None\", \"remove_done\", this ) { Clicked = () => SetAll( false ) } );\n\n\t\t\tflatView = EditorCookie.Get( \"unreal_import_flat_view\", false );\n\t\t\tvar flatToggle = new Checkbox( \"Flat list\", this )\n\t\t\t{\n\t\t\t\tValue = flatView,\n\t\t\t\tToolTip = \"Show every asset as one flat list instead of the folder tree\",\n\t\t\t};\n\t\t\tflatToggle.Toggled = () =>\n\t\t\t{\n\t\t\t\tflatView = flatToggle.Value;\n\t\t\t\tEditorCookie.Set( \"unreal_import_flat_view\", flatView );\n\t\t\t\tRefreshTree();\n\t\t\t};\n\t\t\ttoolRow.Add( flatToggle );\n\n\t\t\tsection.Layout.Add( toolRow );\n\n\t\t\ttree = new ImportTreeView( this );\n\t\t\ttree.MultiSelect = false;\n\t\t\t// Sunk into the section: darker than the panel so the row stripes read against it.\n\t\t\ttree.SetStyles(\n\t\t\t\t$\"background-color: {Theme.WindowBackground.Hex};\" +\n\t\t\t\t$\"border: 1px solid {Theme.Border.WithAlpha( 0.5f ).Hex};\" +\n\t\t\t\t$\"border-radius: {Theme.ControlRadius}px;\" );\n\t\t\tsection.Layout.Add( tree, 1 );\n\n\t\t\t// The section (and the tree inside it) takes all the leftover height.\n\t\t\tLayout.Add( section, 1 );\n\t\t}\n\n\t\t// ---- Export Settings ----\n\t\t{\n\t\t\tvar section = new Fieldset( \"Export Settings\", this );\n\n\t\t\tvar grid = Layout.Grid();\n\t\t\tgrid.Spacing = 8;\n\t\t\tsection.Layout.Add( grid );\n\n\t\t\t// Row 0: output directory, spanning the full width.\n\t\t\tgrid.AddCell( 0, 0, new Label( \"Output Directory\", this ) { FixedWidth = LabelWidth } );\n\t\t\toutputLabel = new LineEdit( this )\n\t\t\t{\n\t\t\t\tReadOnly = true,\n\t\t\t\tPlaceholderText = \"No output folder selected\",\n\t\t\t\tToolTip = \"Where generated assets are written\",\n\t\t\t}.StyleInput();\n\t\t\tgrid.AddCell( 1, 0, outputLabel, xSpan: 3 );\n\t\t\tgrid.AddCell( 4, 0, new Button( \"Output...\", \"drive_file_move\", this ) { Clicked = PickOutput } );\n\n\t\t\t// Row 1: layout | map light brightness.\n\t\t\tgrid.AddCell( 0, 1, new Label( \"Layout\", this ) { FixedWidth = LabelWidth } );\n\n\t\t\tlayoutCombo = new ComboBox( this ) { MinimumWidth = 180 };\n\t\t\tlayoutCombo.AddItem( \"Grouped\", icon: \"folder\",\n\t\t\t\tdescription: \"<output>/models, /materials, /textures\" );\n\t\t\tlayoutCombo.AddItem( \"Flat\", icon: \"folder_open\",\n\t\t\t\tdescription: \"Everything directly in the output folder\" );\n\t\t\tlayoutCombo.AddItem( \"Classic Source\", icon: \"account_tree\",\n\t\t\t\tdescription: \"Assets/models/<subdir> for fbx+vmdl, Assets/materials/<subdir> for vmat+textures\" );\n\t\t\tlayoutCombo.AddItem( \"Per Asset\", icon: \"inventory_2\",\n\t\t\t\tdescription: \"<output>/<asset>/ - each asset's model, materials and textures together\" );\n\n\t\t\tvar savedLayout = EditorCookie.Get( \"unreal_import_layout\", 0 );\n\t\t\tlayoutCombo.CurrentIndex = Math.Clamp( savedLayout, 0, LayoutOrder.Length - 1 );\n\t\t\tlayoutCombo.ItemChanged += () =>\n\t\t\t{\n\t\t\t\tEditorCookie.Set( \"unreal_import_layout\", layoutCombo.CurrentIndex );\n\t\t\t\tUpdateLayoutRow();\n\t\t\t};\n\t\t\tgrid.AddCell( 1, 1, layoutCombo.StyleInput() );\n\n\t\t\t// Scene-light brightness: the conversion is calibrated, but UE maps lean on\n\t\t\t// auto-exposure that s&box doesn't have - taste (and pack) varies, so expose a knob.\n\t\t\tgrid.AddCell( 2, 1, new Label( \"Map light brightness\", this ), alignment: TextFlag.RightCenter );\n\t\t\tlightScaleEdit = new LineEdit( this )\n\t\t\t{\n\t\t\t\tText = EditorCookie.Get( \"unreal_import_light_scale\", 1f ).ToString( System.Globalization.CultureInfo.InvariantCulture ),\n\t\t\t\tToolTip = \"Multiplier on converted map light intensity. 1 = calibrated default; lower for moodier interiors, higher if too dark. Applies on (re)import.\",\n\t\t\t};\n\t\t\tlightScaleEdit.TextEdited += _ => EditorCookie.Set( \"unreal_import_light_scale\", LightScale() );\n\t\t\tgrid.AddCell( 3, 1, lightScaleEdit.StyleInput(), xSpan: 2 );\n\n\t\t\t// Row 2: subfolder | generate LODs.\n\t\t\tsubfolderLabel = new Label( \"Subfolder\", this ) { FixedWidth = LabelWidth };\n\t\t\tgrid.AddCell( 0, 2, subfolderLabel );\n\n\t\t\tsubfolderEdit = new LineEdit( this )\n\t\t\t{\n\t\t\t\tText = EditorCookie.Get( \"unreal_import_subfolder\", \"unrealimport\" ),\n\t\t\t\tPlaceholderText = \"(none)\",\n\t\t\t\tToolTip = \"Subfolder under Assets/models and Assets/materials. Leave empty to write straight into them.\",\n\t\t\t};\n\t\t\tsubfolderEdit.TextEdited += t =>\n\t\t\t{\n\t\t\t\tEditorCookie.Set( \"unreal_import_subfolder\", t ?? \"\" );\n\t\t\t\tif ( outputLabel is not null )\n\t\t\t\t\toutputLabel.Text = OutputDisplay();\n\t\t\t};\n\t\t\tgrid.AddCell( 1, 2, subfolderEdit.StyleInput() );\n\n\t\t\tlodCheckbox = new Checkbox( \"Generate LODs\", this )\n\t\t\t{\n\t\t\t\tValue = EditorCookie.Get( \"unreal_import_lods\", true ),\n\t\t\t\tToolTip = \"5-level auto chain; untick for full detail at every distance\",\n\t\t\t};\n\t\t\tgrid.AddCell( 2, 2, lodCheckbox, xSpan: 3 );\n\t\t\tlodCheckbox.Toggled = () => EditorCookie.Set( \"unreal_import_lods\", lodCheckbox.Value );\n\n\t\t\t// Row 3: what a material picked on its own becomes.\n\t\t\tgrid.AddCell( 0, 3, new Label( \"Material Output\", this ) { FixedWidth = LabelWidth } );\n\n\t\t\tmaterialOutputCombo = new ComboBox( this ) { MinimumWidth = 180 };\n\t\t\tmaterialOutputCombo.AddItem( \"Material (.vmat)\", icon: \"palette\",\n\t\t\t\tdescription: \"Standard complex.shader material\" );\n\t\t\tmaterialOutputCombo.AddItem( \"Terrain (.tmat)\", icon: \"landscape\",\n\t\t\t\tdescription: \"Terrain Material - tiling ground surface with height blending\" );\n\t\t\tmaterialOutputCombo.AddItem( \"Decal (.decal)\", icon: \"approval\",\n\t\t\t\tdescription: \"Decal Definition - projected decal masked by the colour alpha\" );\n\n\t\t\tmaterialOutputCombo.CurrentIndex = Math.Clamp(\n\t\t\t\tEditorCookie.Get( \"unreal_import_material_output\", 0 ), 0, MaterialOutputOrder.Length - 1 );\n\t\t\tmaterialOutputCombo.ItemChanged += () =>\n\t\t\t{\n\t\t\t\tEditorCookie.Set( \"unreal_import_material_output\", materialOutputCombo.CurrentIndex );\n\t\t\t\tUpdateStatus();\n\t\t\t};\n\t\t\tgrid.AddCell( 1, 3, materialOutputCombo.StyleInput() );\n\n\t\t\tgrid.AddCell( 2, 3, new Label( \"Meshes always use .vmat\", this )\n\t\t\t{\n\t\t\t\tColor = Theme.TextControl.WithAlpha( 0.5f ),\n\t\t\t\tToolTip = \"A model's material slots can't reference a terrain or decal resource, so this only applies to materials imported on their own.\",\n\t\t\t}, xSpan: 3 );\n\n\t\t\t// Row 4: Per Asset only - which folder to name each asset's subfolder after.\n\t\t\t// Fab/Megascans MIs are named like \"mi_sjfnbeaa\"; the readable name is a couple\n\t\t\t// folders up (.../Fine_American_Road_sjfnbeaa/Medium/MI_sjfnbeaa).\n\t\t\tperAssetFolderLabel = new Label( \"Folder name\", this ) { FixedWidth = LabelWidth };\n\t\t\tgrid.AddCell( 0, 4, perAssetFolderLabel );\n\n\t\t\tperAssetFolderCombo = new ComboBox( this ) { MinimumWidth = 180 };\n\t\t\tperAssetFolderCombo.AddItem( \"Asset name\", icon: \"description\",\n\t\t\t\tdescription: \"Name each folder after the asset itself (e.g. mi_sjfnbeaa)\" );\n\t\t\tperAssetFolderCombo.AddItem( \"1 folder up\", icon: \"north\",\n\t\t\t\tdescription: \"Name it after the asset's parent folder\" );\n\t\t\tperAssetFolderCombo.AddItem( \"2 folders up\", icon: \"north\",\n\t\t\t\tdescription: \"Grandparent folder - the readable pack name for Fab/Megascans\" );\n\t\t\tperAssetFolderCombo.AddItem( \"3 folders up\", icon: \"north\",\n\t\t\t\tdescription: \"Great-grandparent folder\" );\n\n\t\t\tperAssetFolderCombo.CurrentIndex = Math.Clamp( EditorCookie.Get( \"unreal_import_perasset_depth\", 0 ), 0, 3 );\n\t\t\tperAssetFolderCombo.ItemChanged += () => EditorCookie.Set( \"unreal_import_perasset_depth\", perAssetFolderCombo.CurrentIndex );\n\t\t\tgrid.AddCell( 1, 4, perAssetFolderCombo.StyleInput() );\n\n\t\t\tgrid.AddCell( 2, 4, new Label( \"Per Asset layout only\", this )\n\t\t\t{\n\t\t\t\tColor = Theme.TextControl.WithAlpha( 0.5f ),\n\t\t\t\tToolTip = \"Which folder each asset's subfolder is named after, when using the Per Asset layout.\",\n\t\t\t}, xSpan: 3 );\n\n\t\t\t// Row 5: texture size ceiling. Fab/Megascans ship 4K (sometimes 8K) maps that a\n\t\t\t// prop the size of a crate has no use for - capping them here cuts the import time\n\t\t\t// as well as the disk, since every per-pixel pass runs on the smaller bitmap.\n\t\t\tgrid.AddCell( 0, 5, new Label( \"Max texture size\", this ) { FixedWidth = LabelWidth } );\n\n\t\t\tmaxTextureSizeCombo = new ComboBox( this ) { MinimumWidth = 180 };\n\t\t\tmaxTextureSizeCombo.AddItem( \"Original\", icon: \"photo_size_select_actual\",\n\t\t\t\tdescription: \"Keep whatever the pack ships - no resizing\" );\n\t\t\tmaxTextureSizeCombo.AddItem( \"4096\", icon: \"photo_size_select_large\",\n\t\t\t\tdescription: \"Downscale anything larger than 4K\" );\n\t\t\tmaxTextureSizeCombo.AddItem( \"2048\", icon: \"photo_size_select_large\",\n\t\t\t\tdescription: \"Downscale anything larger than 2K - a good default for props\" );\n\t\t\tmaxTextureSizeCombo.AddItem( \"1024\", icon: \"photo_size_select_small\",\n\t\t\t\tdescription: \"Downscale anything larger than 1K\" );\n\t\t\tmaxTextureSizeCombo.AddItem( \"512\", icon: \"photo_size_select_small\",\n\t\t\t\tdescription: \"Downscale anything larger than 512 - small props and blockout\" );\n\n\t\t\tmaxTextureSizeCombo.CurrentIndex = Math.Clamp(\n\t\t\t\tEditorCookie.Get( \"unreal_import_max_texture_size\", 0 ), 0, MaxTextureSizeOrder.Length - 1 );\n\t\t\tmaxTextureSizeCombo.ItemChanged += () =>\n\t\t\t\tEditorCookie.Set( \"unreal_import_max_texture_size\", maxTextureSizeCombo.CurrentIndex );\n\t\t\tgrid.AddCell( 1, 5, maxTextureSizeCombo.StyleInput() );\n\n\t\t\tgrid.AddCell( 2, 5, new Label( \"Smaller = faster import\", this )\n\t\t\t{\n\t\t\t\tColor = Theme.TextControl.WithAlpha( 0.5f ),\n\t\t\t\tToolTip = \"Textures bigger than this are resampled down on the longest edge, keeping their aspect ratio. Never upscales.\",\n\t\t\t}, xSpan: 3 );\n\n\t\t\t// Only the field columns absorb extra width; the label columns stay tight.\n\t\t\tgrid.SetColumnStretch( 0, 3, 0, 2, 0 );\n\n\t\t\tLayout.Add( section );\n\t\t\tUpdateLayoutRow();\n\t\t}\n\n\t\tstatusLabel = new Label( \"\", this );\n\t\tstatusLabel.Color = Theme.TextControl.WithAlpha( 0.6f );\n\t\tLayout.Add( statusLabel );\n\n\t\t// Bottom bar\n\t\t{\n\t\t\tvar row = Layout.Row();\n\t\t\trow.Margin = new Sandbox.UI.Margin( 0, 8, 0, 0 );\n\t\t\trow.AddStretchCell();\n\t\t\texportButton = new Button.Primary( \"Export to s&box\", \"move_to_inbox\", this ) { Clicked = () => _ = DoExport() };\n\t\t\texportButton.Enabled = false;\n\t\t\trow.Add( exportButton );\n\t\t\tLayout.Add( row );\n\t\t}\n\n\t\tWidth = 640;\n\t\tMinimumWidth = 480;\n\t\tHeight = 680;\n\n\t\tShow();\n\t\tFocus();\n\n\t\tvar outputPath = EditorCookie.Get( \"unreal_import_project_path\", \"\" );\n\t\tif ( !string.IsNullOrEmpty( outputPath ) )\n\t\t{\n\t\t\tLog.Info( $\"UnrealImportWindow: restoring last project path: {outputPath}\" );\n\t\t\tuprojectPath = outputPath;\n\t\t\tuprojectFolder = Path.GetDirectoryName( outputPath );\n\t\t\tprojectLabel.Text = $\"{Path.GetFileName( outputPath )} ({Path.GetFileName( uprojectFolder )})\";\n\t\t\tScanAssets();\n\t\t}\n\t}\n\n\tstring OutputDisplay()\n\t{\n\t\t// Classic Source ignores the picked folder entirely - it writes off the Assets root.\n\t\tif ( SelectedLayout == ImportLayout.ClassicSource )\n\t\t{\n\t\t\tvar assets = Sandbox.Project.Current?.GetAssetsPath();\n\t\t\tif ( string.IsNullOrEmpty( assets ) )\n\t\t\t\treturn \"Assets/models + Assets/materials\";\n\n\t\t\tvar paths = AssetImporter.ResolvePaths( outputFolder, assets, ImportLayout.ClassicSource, Subfolder() );\n\t\t\treturn $\"{paths.ModelsDir} + {paths.MaterialsDir}\";\n\t\t}\n\n\t\tif ( string.IsNullOrEmpty( outputFolder ) )\n\t\t\treturn \"\";\n\n\t\t// Per Asset fans out into a folder per asset - show that rather than implying one folder.\n\t\treturn SelectedLayout == ImportLayout.PerAsset\n\t\t\t? Path.Combine( outputFolder, \"<asset>\" )\n\t\t\t: outputFolder;\n\t}\n\n\t/// <summary>\n\t/// The subfolder field only means anything in Classic Source; grey it out elsewhere.\n\t/// (Per Asset names its folders after the assets themselves, so there's nothing to type.)\n\t/// </summary>\n\tvoid UpdateLayoutRow()\n\t{\n\t\tvar classic = SelectedLayout == ImportLayout.ClassicSource;\n\t\tvar perAsset = SelectedLayout == ImportLayout.PerAsset;\n\n\t\tif ( subfolderEdit is not null )\n\t\t\tsubfolderEdit.Enabled = classic;\n\t\tif ( subfolderLabel is not null )\n\t\t\tsubfolderLabel.Enabled = classic;\n\n\t\t// The folder-name depth only matters when each asset gets its own folder.\n\t\tif ( perAssetFolderCombo is not null )\n\t\t\tperAssetFolderCombo.Enabled = perAsset;\n\t\tif ( perAssetFolderLabel is not null )\n\t\t\tperAssetFolderLabel.Enabled = perAsset;\n\n\t\tif ( outputLabel is not null )\n\t\t\toutputLabel.Text = OutputDisplay();\n\n\t\tUpdateExportEnabled();\n\t}\n\n\tvoid PickProject()\n\t{\n\t\tvar fd = new FileDialog( null ) { Title = \"Select Unreal Project Folder\" };\n\t\tfd.SetFindDirectory();\n\t\tfd.SetModeOpen();\n\t\tif ( !fd.Execute() )\n\t\t\treturn;\n\n\t\tvar folder = fd.SelectedFile;\n\t\tvar uproject = UnrealLocator.FindUprojectInFolder( folder );\n\t\tif ( uproject is null )\n\t\t{\n\t\t\tEditorUtility.DisplayDialog( \"Not an Unreal project\", $\"No .uproject found in:\\n{folder}\" );\n\t\t\treturn;\n\t\t}\n\n\t\tuprojectFolder = folder;\n\t\tuprojectPath = uproject;\n\t\tprojectLabel.Text = $\"{Path.GetFileName( uproject )} ({Path.GetFileName( folder )})\";\n\n\t\tEditorCookie.Set( \"unreal_import_project_path\", uprojectPath );\n\t\tLog.Info( $\"UnrealImportWindow: storing last project path: {uprojectPath}\" );\n\n\t\tScanAssets();\n\t}\n\n\tvoid PickOutput()\n\t{\n\t\tvar fd = new FileDialog( null ) { Title = \"Select Output Folder (inside Assets/)\", Directory = outputFolder };\n\t\tfd.SetFindDirectory();\n\t\tfd.SetModeOpen();\n\t\tif ( !string.IsNullOrEmpty( outputFolder ) )\n\t\t\tfd.Directory = outputFolder;\n\t\tif ( !fd.Execute() )\n\t\t\treturn;\n\n\t\toutputFolder = fd.SelectedFile;\n\t\toutputLabel.Text = OutputDisplay();\n\t\tUpdateExportEnabled();\n\t}\n\n\tvoid ScanAssets()\n\t{\n\t\tentries.Clear();\n\t\tmapEntries.Clear();\n\n\t\tvar content = Path.Combine( uprojectFolder, \"Content\" );\n\t\tif ( Directory.Exists( content ) )\n\t\t{\n\t\t\tforeach ( var file in new DirectoryInfo( content ).EnumerateFiles( \"*.umap\", SearchOption.AllDirectories ) )\n\t\t\t{\n\t\t\t\tvar gamePath = HeadlessExporter.ToGamePath( uprojectFolder, file.FullName );\n\t\t\t\tif ( gamePath.EndsWith( \".umap\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\t\tgamePath = gamePath[..^\".umap\".Length];\n\n\t\t\t\tmapEntries.Add( new MapEntry\n\t\t\t\t{\n\t\t\t\t\tAbsPath = file.FullName,\n\t\t\t\t\tGamePath = gamePath,\n\t\t\t\t\tDisplay = gamePath.StartsWith( \"/Game/\" ) ? gamePath[\"/Game/\".Length..] : gamePath,\n\t\t\t\t} );\n\t\t\t}\n\t\t\tmapEntries.Sort( ( a, b ) => string.CompareOrdinal( a.GamePath, b.GamePath ) );\n\n\t\t\t// FileInfo rather than plain paths so we get the size without a second stat per file.\n\t\t\tforeach ( var file in new DirectoryInfo( content ).EnumerateFiles( \"*.uasset\", SearchOption.AllDirectories ) )\n\t\t\t{\n\t\t\t\tvar kind = ClassifyUasset( file );\n\t\t\t\tif ( kind is null )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar gamePath = HeadlessExporter.ToGamePath( uprojectFolder, file.FullName );\n\n\t\t\t\tentries.Add( new AssetEntry\n\t\t\t\t{\n\t\t\t\t\tKind = kind.Value,\n\t\t\t\t\tAbsPath = file.FullName,\n\t\t\t\t\tGamePath = gamePath,\n\t\t\t\t\t// Show the path relative to /Game for readability.\n\t\t\t\t\tDisplay = gamePath.StartsWith( \"/Game/\" ) ? gamePath[\"/Game/\".Length..] : gamePath,\n\t\t\t\t\tSizeBytes = file.Length,\n\t\t\t\t} );\n\t\t\t}\n\t\t}\n\n\t\tif ( entries.Count == 0 )\n\t\t{\n\t\t\tLog.Warning( $\"No static meshes or materials found in {uprojectFolder}/Content.\" );\n\t\t}\n\n\t\tentries.Sort( ( a, b ) => string.CompareOrdinal( a.GamePath, b.GamePath ) );\n\t\tBuildFolderIndex();\n\t\tRefreshTree();\n\t\tUpdateExportEnabled();\n\t\tUpdateStatus();\n\n\t\t_ = WarmStats( entries.ToList() );\n\t}\n\n\t/// <summary>\n\t/// What a .uasset is, from its name and folder - null for anything we can't import.\n\t///\n\t/// Reading the real class out of the package would need version-dependent header parsing;\n\t/// Unreal/Fab naming is conventional enough that prefixes plus the type folder do the job.\n\t/// The exporter re-checks the actual type when it loads the asset, so a wrong guess here\n\t/// costs a warning, not a broken import.\n\t/// </summary>\n\tstatic AssetKind? ClassifyUasset( FileInfo file )\n\t{\n\t\tvar dir = (file.DirectoryName ?? \"\").Replace( '\\\\', '/' );\n\t\tvar name = Path.GetFileNameWithoutExtension( file.Name );\n\n\t\t// Name prefixes are stronger evidence than the folder - a material parked in a\n\t\t// Meshes/ folder is still a material.\n\t\tif ( name.StartsWith( \"SM_\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn AssetKind.Mesh;\n\n\t\t// MI_ = Material Instance, M_/MM_ = Material (master). Textures are T_/TX_, so the\n\t\t// single-letter M_ prefix doesn't collide with anything else we'd want to list.\n\t\tif ( name.StartsWith( \"MI_\", StringComparison.OrdinalIgnoreCase )\n\t\t\t|| name.StartsWith( \"M_\", StringComparison.OrdinalIgnoreCase )\n\t\t\t|| name.StartsWith( \"MM_\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn AssetKind.Material;\n\n\t\tif ( dir.Contains( \"/Meshes\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn AssetKind.Mesh;\n\n\t\tif ( dir.Contains( \"/Materials\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn AssetKind.Material;\n\n\t\treturn null;\n\t}\n\n\t/// <summary>\n\t/// Background pass reading tri counts for everything, so folder rows and the status\n\t/// total become accurate without expanding every folder. Throttled inside\n\t/// UassetMeshStats; cached on disk so later opens are instant.\n\t/// </summary>\n\tasync Task WarmStats( List<AssetEntry> list )\n\t{\n\t\tint done = 0;\n\t\tforeach ( var e in list )\n\t\t{\n\t\t\tif ( !IsValid || !entries.Contains( e ) )\n\t\t\t\treturn;\n\n\t\t\tif ( e.Triangles < 0 )\n\t\t\t{\n\t\t\t\tvar stats = await UassetMeshStats.LoadAsync( e.AbsPath );\n\t\t\t\tif ( stats is not null )\n\t\t\t\t\te.Triangles = stats.Triangles;\n\t\t\t}\n\n\t\t\tif ( ++done % 64 == 0 )\n\t\t\t{\n\t\t\t\tUpdateStatus();\n\t\t\t\ttree?.Update();\n\t\t\t}\n\t\t}\n\n\t\tif ( IsValid )\n\t\t{\n\t\t\tUpdateStatus();\n\t\t\ttree?.Update();\n\t\t}\n\t}\n\n\t// ---- folder index ----\n\n\tstatic string ParentOf( string path ) => path.Contains( '/' ) ? path[..path.LastIndexOf( '/' )] : \"\";\n\tstatic string DirOf( string display ) => display.Contains( '/' ) ? display[..display.LastIndexOf( '/' )] : \"\";\n\n\tFolderBucket Bucket( string path )\n\t{\n\t\tif ( !folders.TryGetValue( path, out var b ) )\n\t\t\tfolders[path] = b = new FolderBucket();\n\t\treturn b;\n\t}\n\n\tvoid BuildFolderIndex()\n\t{\n\t\tfolders.Clear();\n\t\tBucket( \"\" );\n\n\t\tvoid RegisterChain( string dir )\n\t\t{\n\t\t\twhile ( dir.Length > 0 )\n\t\t\t{\n\t\t\t\tvar parent = ParentOf( dir );\n\t\t\t\tBucket( parent ).Subfolders.Add( dir );\n\t\t\t\tBucket( dir );\n\t\t\t\tdir = parent;\n\t\t\t}\n\t\t}\n\n\t\tforeach ( var e in entries )\n\t\t{\n\t\t\tvar dir = DirOf( e.Display );\n\t\t\tRegisterChain( dir );\n\t\t\tBucket( dir ).Assets.Add( e );\n\n\t\t\tfor ( var p = dir; ; p = ParentOf( p ) )\n\t\t\t{\n\t\t\t\tBucket( p ).Subtree.Add( e );\n\t\t\t\tif ( p.Length == 0 )\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tforeach ( var m in mapEntries )\n\t\t{\n\t\t\tvar dir = DirOf( m.Display );\n\t\t\tRegisterChain( dir );\n\t\t\tBucket( dir ).Maps.Add( m );\n\t\t}\n\n\t\trootNodes = BuildFolderChildNodes( \"\" ).ToList();\n\t}\n\n\tbool FolderHasChildren( string path )\n\t\t=> folders.TryGetValue( path, out var b ) && (b.Subfolders.Count > 0 || b.Assets.Count > 0 || b.Maps.Count > 0);\n\n\tIEnumerable<TreeNode> BuildFolderChildNodes( string path )\n\t{\n\t\tif ( !folders.TryGetValue( path, out var b ) )\n\t\t\tyield break;\n\n\t\tforeach ( var sub in b.Subfolders )\n\t\t\tyield return new FolderNode( this, sub );\n\n\t\tforeach ( var m in b.Maps )\n\t\t\tyield return new MapNode( this, m, fullPath: false );\n\n\t\tforeach ( var e in b.Assets )\n\t\t\tyield return new AssetNode( this, e, fullPath: false );\n\t}\n\n\t(int selected, int total) SubtreeSelection( string path )\n\t{\n\t\tif ( !folders.TryGetValue( path, out var b ) )\n\t\t\treturn (0, 0);\n\n\t\tint sel = 0;\n\t\tforeach ( var e in b.Subtree )\n\t\t\tif ( e.Selected )\n\t\t\t\tsel++;\n\n\t\treturn (sel, b.Subtree.Count);\n\t}\n\n\t/// <summary>Right-hand folder label: \"12 meshes \u00b7 3 materials\", omitting whichever is zero.</summary>\n\tstring SubtreeSummary( string path )\n\t{\n\t\tif ( !folders.TryGetValue( path, out var b ) )\n\t\t\treturn \"\";\n\n\t\tint meshes = b.Subtree.Count( e => e.IsMesh );\n\t\tint mats = b.Subtree.Count - meshes;\n\n\t\tvar parts = new List<string>();\n\t\tif ( meshes > 0 )\n\t\t\tparts.Add( meshes == 1 ? \"1 mesh\" : $\"{meshes} meshes\" );\n\t\tif ( mats > 0 )\n\t\t\tparts.Add( mats == 1 ? \"1 material\" : $\"{mats} materials\" );\n\n\t\treturn string.Join( \" \u00b7 \", parts );\n\t}\n\n\tvoid SetFolderSelected( string path, bool on )\n\t{\n\t\tif ( !folders.TryGetValue( path, out var b ) )\n\t\t\treturn;\n\n\t\tforeach ( var e in b.Subtree )\n\t\t\te.Selected = on;\n\n\t\tUpdateStatus();\n\t\ttree?.Update();\n\t}\n\n\t// ---- filtering / tree ----\n\n\t/// <summary>Entries matching the current search box, in list order.</summary>\n\tIEnumerable<AssetEntry> Filtered()\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( searchFilter ) )\n\t\t\treturn entries;\n\n\t\tvar term = searchFilter.Trim();\n\t\treturn entries.Where( e => e.Display.Contains( term, StringComparison.OrdinalIgnoreCase ) );\n\t}\n\n\t/// <summary>Maps matching the current search box.</summary>\n\tIEnumerable<MapEntry> FilteredMaps()\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( searchFilter ) )\n\t\t\treturn mapEntries;\n\n\t\tvar term = searchFilter.Trim();\n\t\treturn mapEntries.Where( e => e.Display.Contains( term, StringComparison.OrdinalIgnoreCase ) );\n\t}\n\n\t/// <summary>Tree of folders normally; a flat list while searching or when toggled flat.</summary>\n\tvoid RefreshTree()\n\t{\n\t\tif ( tree is null )\n\t\t\treturn;\n\n\t\tbool searching = !string.IsNullOrWhiteSpace( searchFilter );\n\n\t\tif ( !searching && !flatView )\n\t\t{\n\t\t\t// Persistent nodes, so folder expansion survives search/flat round-trips.\n\t\t\ttree.SetItems( rootNodes );\n\n\t\t\tif ( rootNodes.Count == 1 )\n\t\t\t\ttree.Open( rootNodes[0] );\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// Filtered()/FilteredMaps() return everything when the search box is empty,\n\t\t\t// so this doubles as the plain flat view.\n\t\t\tvar flat = new List<TreeNode>();\n\t\t\tflat.AddRange( FilteredMaps().Select( m => (TreeNode)new MapNode( this, m, fullPath: true ) ) );\n\t\t\tflat.AddRange( Filtered().Select( e => (TreeNode)new AssetNode( this, e, fullPath: true ) ) );\n\n\t\t\tif ( flat.Count == 0 && searching )\n\t\t\t\tflat.Add( new TreeNode( $\"No matches for \\\"{searchFilter.Trim()}\\\"\" ) );\n\n\t\t\ttree.SetItems( flat );\n\t\t}\n\t}\n\n\tstatic string FormatSize( long bytes )\n\t{\n\t\tif ( bytes >= 1024L * 1024 * 1024 ) return $\"{bytes / (1024f * 1024 * 1024):0.##} GB\";\n\t\tif ( bytes >= 1024 * 1024 ) return $\"{bytes / (1024f * 1024):0.#} MB\";\n\t\tif ( bytes >= 1024 ) return $\"{bytes / 1024f:0} KB\";\n\t\treturn $\"{bytes} B\";\n\t}\n\n\tstatic string FormatCount( long n )\n\t{\n\t\tif ( n >= 1_000_000 ) return $\"{n / 1_000_000f:0.##}M\";\n\t\tif ( n >= 1_000 ) return $\"{n / 1_000f:0.#}k\";\n\t\treturn $\"{n}\";\n\t}\n\n\t/// <summary>Ticks or unticks everything currently shown - the search filter narrows this.</summary>\n\tvoid SetAll( bool on )\n\t{\n\t\tforeach ( var e in Filtered() )\n\t\t\te.Selected = on;\n\n\t\ttree?.Update();\n\t\tUpdateStatus();\n\t}\n\n\tvoid UpdateStatus()\n\t{\n\t\tif ( statusLabel is null )\n\t\t\treturn;\n\n\t\tif ( entries.Count == 0 )\n\t\t{\n\t\t\tstatusLabel.Text = \"\";\n\t\t\treturn;\n\t\t}\n\n\t\tvar selected = entries.Where( e => e.Selected ).ToList();\n\t\tvar shown = Filtered().Count();\n\n\t\tint meshCount = entries.Count( e => e.IsMesh );\n\t\tvar found = $\"{meshCount} static mesh(es), {entries.Count - meshCount} material(s)\";\n\t\tvar text = shown == entries.Count ? $\"{found} found.\" : $\"{shown} of {found} shown.\";\n\n\t\tif ( selected.Count > 0 )\n\t\t{\n\t\t\ttext += $\" {selected.Count} selected ({FormatSize( selected.Sum( e => e.SizeBytes ) )}\";\n\n\t\t\t// Tri counts only exist for meshes - \"+\" means some are still being read.\n\t\t\tlong tris = selected.Sum( e => Math.Max( 0, e.Triangles ) );\n\t\t\tbool partial = selected.Any( e => e.IsMesh && e.Triangles < 0 );\n\t\t\tif ( tris > 0 )\n\t\t\t\ttext += $\", {FormatCount( tris )}{(partial ? \"+\" : \"\")} tris\";\n\n\t\t\ttext += \").\";\n\t\t}\n\t\telse\n\t\t{\n\t\t\ttext += \" Nothing selected.\";\n\t\t}\n\n\t\tstatusLabel.Text = text;\n\t}\n\n\tvoid UpdateExportEnabled()\n\t{\n\t\tif ( exportButton is null )\n\t\t\treturn;\n\n\t\t// Classic Source writes off the Assets root, so it doesn't need a picked output folder.\n\t\tvar haveOutput = SelectedLayout == ImportLayout.ClassicSource || !string.IsNullOrEmpty( outputFolder );\n\t\texportButton.Enabled = entries.Count > 0 && haveOutput && !string.IsNullOrEmpty( uprojectPath );\n\t}\n\n\t/// <summary>Push a live export/import event into the progress toast + status line.</summary>\n\tvoid ApplyProgress( IProgressSection progress, ExportEvent ev )\n\t{\n\t\tif ( ev.Total is > 0 )\n\t\t\tprogress.TotalCount = ev.Total.Value;\n\t\tif ( ev.Done is > 0 )\n\t\t\tprogress.Current = ev.Done.Value;\n\t\tif ( !string.IsNullOrEmpty( ev.Message ) )\n\t\t{\n\t\t\tprogress.Subtitle = ev.Message;\n\t\t\tstatusLabel.Text = ev.Done is > 0 && ev.Total is > 0 ? $\"[{ev.Done}/{ev.Total}] {ev.Message}\" : ev.Message;\n\t\t}\n\t}\n\n\t/// <summary>Locate ue_export.py + the right UnrealEditor-Cmd, dialoging on failure.</summary>\n\tbool TryResolveTools( out string script, out string editorCmd )\n\t{\n\t\teditorCmd = null;\n\t\tscript = HeadlessExporter.FindExportScript();\n\t\tif ( script is null )\n\t\t{\n\t\t\tEditorUtility.DisplayDialog( \"Export script missing\", \"Could not find Tools/ue_export.py in this library.\" );\n\t\t\treturn false;\n\t\t}\n\n\t\tvar engineVersion = UnrealLocator.ReadEngineAssociation( uprojectPath );\n\t\teditorCmd = UnrealLocator.FindEditorCmd( engineVersion );\n\t\tif ( editorCmd is null )\n\t\t{\n\t\t\tEditorUtility.DisplayDialog( \"Unreal not found\",\n\t\t\t\t$\"Couldn't locate UnrealEditor-Cmd.exe for engine '{engineVersion}'.\\nIs Unreal installed under Epic Games?\" );\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/// <summary>Double-clicking a map row lands here - confirm before kicking a long export.</summary>\n\tvoid DoImportMap( MapEntry map )\n\t{\n\t\tEditorUtility.DisplayDialog( \"Import map?\",\n\t\t\t$\"Import {map.Display}?\\n\\nThis exports every mesh the level uses and builds a prefab of its layout. It can take a while.\",\n\t\t\t\"Cancel\", \"Import\", () => _ = RunImportMap( map ), \"\ud83c\udf0d\" );\n\t}\n\n\tasync Task RunImportMap( MapEntry map )\n\t{\n\t\tif ( string.IsNullOrEmpty( outputFolder ) && SelectedLayout != ImportLayout.ClassicSource )\n\t\t{\n\t\t\tEditorUtility.DisplayDialog( \"No output folder\", \"Pick an output folder (inside Assets/) first.\" );\n\t\t\treturn;\n\t\t}\n\n\t\tif ( !TryResolveTools( out var script, out var editorCmd ) )\n\t\t\treturn;\n\n\t\tawait Task.Delay( 100 );\n\n\t\tstatusLabel.Text = $\"Importing map {map.Display}... this exports every mesh the level uses and can take a while.\";\n\n\t\tusing var progress = Application.Editor.ProgressSection();\n\t\tprogress.Title = $\"Exporting map {map.Display}\";\n\t\tvar progressToken = progress.GetCancel();\n\n\t\ttry\n\t\t{\n\t\t\tvar export = await HeadlessExporter.Run( editorCmd, uprojectPath, Enumerable.Empty<string>(), script, progressToken, mapGamePath: map.GamePath,\n\t\t\t\tonProgress: ev => ApplyProgress( progress, ev ) );\n\t\t\tif ( !export.Success )\n\t\t\t{\n\t\t\t\tEditorUtility.DisplayDialog( \"Map export failed\", export.Error ?? \"Unknown error.\", icon: \"\u26a0\ufe0f\" );\n\t\t\t\tstatusLabel.Text = \"Map export failed.\";\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tprogress.Title = $\"Importing map {map.Display}\";\n\t\t\tvar manifest = ImportManifest.Load( export.ManifestPath );\n\t\t\tvar summary = await AssetImporter.Import( manifest, export.StagingDir, outputFolder, progressToken, SelectedLayout, Subfolder(),\n\t\t\t\tgenerateLods: lodCheckbox is null || lodCheckbox.Value,\n\t\t\t\tlightScale: LightScale(),\n\t\t\t\tmaterialOutput: SelectedMaterialOutput,\n\t\t\t\tperAssetFolderDepth: PerAssetFolderDepth,\n\t\t\t\tmaxTextureSize: MaxTextureSize,\n\t\t\t\tonProgress: ( done, total, name ) => ApplyProgress( progress, new ExportEvent( done, total, $\"Importing {name}\" ) ) );\n\n\t\t\tvar msg = $\"Imported {summary.Models} model(s), {summary.Materials} material(s), {summary.Textures} texture(s).\\n\" +\n\t\t\t\t$\"{summary.Placements} placement(s) written to:\\n{summary.PrefabPath}\";\n\t\t\tif ( summary.Warnings.Count > 0 )\n\t\t\t\tmsg += \"\\n\\nWarnings:\\n - \" + string.Join( \"\\n - \", summary.Warnings.Take( 10 ) );\n\n\t\t\tEditorUtility.DisplayDialog( \"Map import complete\", msg, icon: \"\u2705\" );\n\t\t\tstatusLabel.Text = $\"Done: {summary.Placements} placements, {summary.Models} models.\";\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tEditorUtility.DisplayDialog( \"Map import error\", e.ToString(), icon: \"\u26a0\ufe0f\" );\n\t\t\tstatusLabel.Text = \"Map import error.\";\n\t\t}\n\t}\n\n\tasync Task DoExport()\n\t{\n\t\t// Deliberately ignores the search filter - ticks persist across filtering, so everything\n\t\t// the user has selected gets exported whether or not it's on screen right now.\n\t\tvar selectedEntries = entries.Where( e => e.Selected ).ToList();\n\t\tvar selected = selectedEntries.Select( e => e.GamePath ).ToList();\n\t\tif ( selected.Count == 0 )\n\t\t{\n\t\t\tEditorUtility.DisplayDialog( \"Nothing selected\", \"Tick at least one mesh or material to export.\" );\n\t\t\treturn;\n\t\t}\n\n\t\tif ( !TryResolveTools( out var script, out var editorCmd ) )\n\t\t\treturn;\n\n\t\tawait Task.Delay( 100 );\n\n\t\t// Meshes and materials go over in one selection - the export script routes by asset type.\n\t\tint meshCount = selectedEntries.Count( e => e.IsMesh );\n\t\tvar what = meshCount == selected.Count ? $\"{meshCount} mesh(es)\"\n\t\t\t: meshCount == 0 ? $\"{selected.Count} material(s)\"\n\t\t\t: $\"{meshCount} mesh(es) + {selected.Count - meshCount} material(s)\";\n\t\tstatusLabel.Text = $\"Exporting {what} via headless Unreal... this can take a minute.\";\n\n\t\tusing var progress = Application.Editor.ProgressSection();\n\n\t\tprogress.Title = \"Exporting from Unreal\";\n\t\tprogress.TotalCount = selected.Count;\n\t\tvar progressToken = progress.GetCancel();\n\n\t\ttry\n\t\t{\n\t\t\tvar export = await HeadlessExporter.Run( editorCmd, uprojectPath, selected, script, progressToken,\n\t\t\t\tonProgress: ev => ApplyProgress( progress, ev ) );\n\t\t\tif ( !export.Success )\n\t\t\t{\n\t\t\t\tEditorUtility.DisplayDialog( \"Export failed\", export.Error ?? \"Unknown error.\", icon: \"\u26a0\ufe0f\" );\n\t\t\t\tstatusLabel.Text = \"Export failed.\";\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tprogress.Title = \"Importing into s&box\";\n\t\t\tvar manifest = ImportManifest.Load( export.ManifestPath );\n\t\t\tvar summary = await AssetImporter.Import( manifest, export.StagingDir, outputFolder, progressToken, SelectedLayout, Subfolder(),\n\t\t\t\tgenerateLods: lodCheckbox is null || lodCheckbox.Value,\n\t\t\t\tlightScale: LightScale(),\n\t\t\t\tmaterialOutput: SelectedMaterialOutput,\n\t\t\t\tperAssetFolderDepth: PerAssetFolderDepth,\n\t\t\t\tmaxTextureSize: MaxTextureSize,\n\t\t\t\tonProgress: ( done, total, name ) => ApplyProgress( progress, new ExportEvent( done, total, $\"Importing {name}\" ) ) );\n\n\t\t\tvar msg = $\"Imported {summary.Models} model(s), {summary.Materials} material(s), {summary.Textures} texture(s).\\n\\n\" +\n\t\t\t\t$\"Output:\\n{summary.OutputDir}\";\n\t\t\tif ( summary.Warnings.Count > 0 )\n\t\t\t\tmsg += \"\\n\\nWarnings:\\n - \" + string.Join( \"\\n - \", summary.Warnings.Take( 10 ) );\n\n\t\t\tEditorUtility.DisplayDialog( \"Import complete\", msg, icon: \"\u2705\" );\n\t\t\tstatusLabel.Text = $\"Done: {summary.Models} models, {summary.Materials} materials, {summary.Textures} textures.\";\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tEditorUtility.DisplayDialog( \"Import error\", e.ToString(), icon: \"\u26a0\ufe0f\" );\n\t\t\tstatusLabel.Text = \"Import error.\";\n\t\t}\n\t}\n}\n"
},
{
"Ident": "brax.unrealimporter",
"Path": "Editor/Import/HeadlessExporter.cs",
"FileName": "HeadlessExporter.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 337301,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Diagnostics;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Text.RegularExpressions;\r\nusing System.Threading;\r\nusing System.Threading.Tasks;\r\nusing Sandbox;\r\n\r\nnamespace Editor.UnrealImporter;\r\n\r\npublic class ExportResult\r\n{\r\n\tpublic bool Success;\r\n\tpublic string StagingDir;\r\n\tpublic string ManifestPath;\r\n\tpublic string Error;\r\n}\r\n\r\n/// <summary>A progress signal parsed out of the live Unreal log stream.</summary>\r\n/// <param name=\"Done\">Meshes exported so far, when the line carried a count.</param>\r\n/// <param name=\"Total\">Total meshes to export, when known.</param>\r\n/// <param name=\"Message\">Human-readable phase/state line.</param>\r\npublic record ExportEvent( int? Done, int? Total, string Message );\r\n\r\n/// <summary>\r\n/// Drives Tools/ue_export.py inside headless Unreal (UnrealEditor-Cmd) to turn selected\r\n/// .uasset StaticMeshes into FBX + PNG + manifest.json in a staging folder.\r\n/// </summary>\r\npublic static class HeadlessExporter\r\n{\r\n\t/// <summary>Find ue_export.py shipped in this library's Tools folder.</summary>\r\n\tpublic static string FindExportScript()\r\n\t{\r\n\t\tvar root = Sandbox.Project.Current?.GetRootPath();\r\n\t\tif ( !string.IsNullOrEmpty( root ) )\r\n\t\t{\r\n\t\t\tvar direct = Path.Combine( root, \"Libraries\", \"unrealimporter\", \"Tools\", \"ue_export.py\" );\r\n\t\t\tif ( File.Exists( direct ) )\r\n\t\t\t\treturn direct;\r\n\r\n\t\t\tvar hit = Directory.EnumerateFiles( root, \"ue_export.py\", SearchOption.AllDirectories ).FirstOrDefault();\r\n\t\t\tif ( hit != null )\r\n\t\t\t\treturn hit;\r\n\t\t}\r\n\r\n\t\treturn null;\r\n\t}\r\n\r\n\t/// <summary>Convert a Content-relative .uasset file path to a /Game object path.</summary>\r\n\t/// <example>.../Content/Construction_VOL1/Meshes/SM_Boxes_01a.uasset -> /Game/Construction_VOL1/Meshes/SM_Boxes_01a</example>\r\n\tpublic static string ToGamePath( string uprojectFolder, string uassetAbsPath )\r\n\t{\r\n\t\tvar content = Path.Combine( uprojectFolder, \"Content\" );\r\n\t\tvar rel = Path.GetRelativePath( content, uassetAbsPath ).Replace( '\\\\', '/' );\r\n\t\tif ( rel.EndsWith( \".uasset\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\trel = rel[..^\".uasset\".Length];\r\n\r\n\t\treturn \"/Game/\" + rel;\r\n\t}\r\n\r\n\t/// <param name=\"mapGamePath\">When set, scene mode: export this .umap's placements plus every mesh it uses (gameAssetPaths is ignored by the script).</param>\r\n\t/// <param name=\"onProgress\">\r\n\t/// Live progress parsed by tailing the -abslog file. Unreal's stdout only carries\r\n\t/// Display+ severity (verified: our script's Log-verbosity lines never appear there,\r\n\t/// with or without -stdout), but the log FILE gets every line. Invoked on the calling\r\n\t/// thread's context.\r\n\t/// </param>\r\n\tpublic static async Task<ExportResult> Run( string editorCmd, string uprojectPath, IEnumerable<string> gameAssetPaths, string scriptPath, CancellationToken progressToken, string mapGamePath = null, Action<ExportEvent> onProgress = null )\r\n\t{\r\n\t\tvar result = new ExportResult();\r\n\r\n\t\t// Marketplace packs often force-enable plugins that no longer ship with the engine\r\n\t\t// (NVIDIA Ansel is the classic) - Unreal hard-fatals on those at boot. Launch a\r\n\t\t// sanitized temp .uproject with the missing ones marked Optional instead.\r\n\t\t//\r\n\t\t// This walks the whole engine + project plugin trees looking for .uplugin files, which\r\n\t\t// is seconds of disk work on a big install - off the UI thread, and announced, or it\r\n\t\t// reads as the editor hanging before anything has even started.\r\n\t\tstring tempUproject = null;\r\n\t\tonProgress?.Invoke( new ExportEvent( null, null, \"Checking project plugins...\" ) );\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar sanitized = await Task.Run( () =>\r\n\t\t\t{\r\n\t\t\t\tvar path = SanitizeUproject( editorCmd, uprojectPath, out var temp );\r\n\t\t\t\treturn (path, temp);\r\n\t\t\t}, progressToken );\r\n\r\n\t\t\tuprojectPath = sanitized.path;\r\n\t\t\ttempUproject = sanitized.temp;\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"uproject plugin check failed, launching unmodified: {e.Message}\" );\r\n\t\t}\r\n\r\n\t\ttry\r\n\t\t{\r\n\r\n\t\tvar stagingDir = Path.Combine( Path.GetTempPath(), \"unrealimporter\", Guid.NewGuid().ToString( \"N\" ) );\r\n\t\tDirectory.CreateDirectory( stagingDir );\r\n\t\tresult.StagingDir = stagingDir;\r\n\r\n\t\t// Pass the selection via a file (env-var/command-line length is limited).\r\n\t\tvar assetsFile = Path.Combine( stagingDir, \"_assets.txt\" );\r\n\t\tawait File.WriteAllLinesAsync( assetsFile, gameAssetPaths, progressToken );\r\n\r\n\t\tvar logPath = Path.Combine( stagingDir, \"ue_export.log\" );\r\n\r\n\t\t// NOTE: -script must use forward slashes; a backslash before u/r/etc. is eaten as a python escape.\r\n\t\t// PCG ships with the engine since 5.2 - without it, PCG-scattered actors in World Partition\r\n\t\t// maps fail to deserialize (\"Invalid actor native class\") and their geometry is lost.\r\n\t\tvar script = scriptPath.Replace( '\\\\', '/' );\r\n\t\tvar args =\r\n\t\t\t$\"\\\"{uprojectPath}\\\" -run=pythonscript -script=\\\"{script}\\\" \" +\r\n\t\t\t$\"-EnablePlugins=PythonScriptPlugin,PCG -unattended -nosplash -nullrhi -abslog=\\\"{logPath}\\\"\";\r\n\r\n\t\tvar psi = new ProcessStartInfo\r\n\t\t{\r\n\t\t\tFileName = editorCmd,\r\n\t\t\tArguments = args,\r\n\t\t\tUseShellExecute = false,\r\n\t\t\tCreateNoWindow = true,\r\n\t\t};\r\n\t\tpsi.EnvironmentVariables[\"UE_EXPORT_OUT\"] = stagingDir;\r\n\t\tpsi.EnvironmentVariables[\"UE_EXPORT_ASSETS_FILE\"] = assetsFile;\r\n\t\tif ( !string.IsNullOrEmpty( mapGamePath ) )\r\n\t\t\tpsi.EnvironmentVariables[\"UE_EXPORT_MAP\"] = mapGamePath;\r\n\r\n\t\ttry\r\n\t\t{\r\n\t\t\tusing var proc = Process.Start( psi );\r\n\r\n\t\t\t// Cancelling the progress section actually stops Unreal rather than orphaning it.\r\n\t\t\tusing var killOnCancel = progressToken.Register( () =>\r\n\t\t\t{\r\n\t\t\t\ttry { proc.Kill( entireProcessTree: true ); }\r\n\t\t\t\tcatch { }\r\n\t\t\t} );\r\n\r\n\t\t\t// TailLog owns the status line from here - it emits immediately and then keeps a\r\n\t\t\t// heartbeat going, so there's no silent gap to fill in.\r\n\t\t\tvar tail = TailLog( proc, logPath, onProgress );\r\n\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\tawait proc.WaitForExitAsync( progressToken );\r\n\t\t\t}\r\n\t\t\tcatch ( OperationCanceledException )\r\n\t\t\t{\r\n\t\t\t\t// killOnCancel is stopping Unreal; fall through so the tail loop winds down.\r\n\t\t\t}\r\n\r\n\t\t\tawait tail;\r\n\r\n\t\t\tresult.ManifestPath = Path.Combine( stagingDir, \"manifest.json\" );\r\n\t\t\tif ( proc.ExitCode != 0 )\r\n\t\t\t{\r\n\t\t\t\tresult.Error = progressToken.IsCancellationRequested\r\n\t\t\t\t\t? \"Export cancelled.\"\r\n\t\t\t\t\t: $\"UnrealEditor-Cmd exited with code {proc.ExitCode}. See log:\\n{logPath}\";\r\n\t\t\t\treturn result;\r\n\t\t\t}\r\n\r\n\t\t\tif ( !File.Exists( result.ManifestPath ) )\r\n\t\t\t{\r\n\t\t\t\tresult.Error = $\"Export finished but no manifest.json was produced. See log:\\n{logPath}\";\r\n\t\t\t\treturn result;\r\n\t\t\t}\r\n\r\n\t\t\tresult.Success = true;\r\n\t\t\treturn result;\r\n\t\t}\r\n\t\tcatch ( Exception e )\r\n\t\t{\r\n\t\t\tresult.Error = e.Message;\r\n\t\t\treturn result;\r\n\t\t}\r\n\r\n\t\t}\r\n\t\tfinally\r\n\t\t{\r\n\t\t\tif ( tempUproject is not null )\r\n\t\t\t{\r\n\t\t\t\ttry { File.Delete( tempUproject ); }\r\n\t\t\t\tcatch { }\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tstatic readonly Dictionary<string, HashSet<string>> pluginScanCache = new( StringComparer.OrdinalIgnoreCase );\r\n\r\n\t/// <summary>Names of every .uplugin discoverable under a directory (cached - engine trees are big).</summary>\r\n\tstatic HashSet<string> AvailablePlugins( string dir )\r\n\t{\r\n\t\tif ( pluginScanCache.TryGetValue( dir, out var cached ) )\r\n\t\t\treturn cached;\r\n\r\n\t\tvar set = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\r\n\t\tif ( Directory.Exists( dir ) )\r\n\t\t{\r\n\t\t\tforeach ( var f in Directory.EnumerateFiles( dir, \"*.uplugin\", SearchOption.AllDirectories ) )\r\n\t\t\t\tset.Add( Path.GetFileNameWithoutExtension( f ) );\r\n\t\t}\r\n\r\n\t\tpluginScanCache[dir] = set;\r\n\t\treturn set;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// If the .uproject enables plugins that exist neither in the engine nor the project,\r\n\t/// write a sibling temp .uproject with those entries marked Optional (Unreal skips\r\n\t/// missing optional plugins instead of aborting) and return its path. Returns the\r\n\t/// original path untouched when everything resolves. Caller deletes the temp file.\r\n\t/// </summary>\r\n\tstatic string SanitizeUproject( string editorCmd, string uprojectPath, out string tempUproject )\r\n\t{\r\n\t\ttempUproject = null;\r\n\r\n\t\tvar root = System.Text.Json.Nodes.JsonNode.Parse( File.ReadAllText( uprojectPath ) );\r\n\t\tif ( root?[\"Plugins\"] is not System.Text.Json.Nodes.JsonArray plugins || plugins.Count == 0 )\r\n\t\t\treturn uprojectPath;\r\n\r\n\t\tvar enabled = plugins\r\n\t\t\t.Where( p => p?[\"Enabled\"]?.GetValue<bool>() == true )\r\n\t\t\t.Select( p => p?[\"Name\"]?.GetValue<string>() )\r\n\t\t\t.Where( n => !string.IsNullOrEmpty( n ) )\r\n\t\t\t.ToList();\r\n\t\tif ( enabled.Count == 0 )\r\n\t\t\treturn uprojectPath;\r\n\r\n\t\t// editorCmd = <root>/Engine/Binaries/Win64/UnrealEditor-Cmd.exe\r\n\t\tvar enginePlugins = Path.GetFullPath( Path.Combine( Path.GetDirectoryName( editorCmd ), \"..\", \"..\", \"Plugins\" ) );\r\n\t\tvar projFolder = Path.GetDirectoryName( uprojectPath );\r\n\r\n\t\tvar missing = enabled\r\n\t\t\t.Where( n => !AvailablePlugins( enginePlugins ).Contains( n )\r\n\t\t\t\t&& !AvailablePlugins( Path.Combine( projFolder, \"Plugins\" ) ).Contains( n )\r\n\t\t\t\t&& !AvailablePlugins( Path.Combine( projFolder, \"Mods\" ) ).Contains( n ) )\r\n\t\t\t.ToHashSet( StringComparer.OrdinalIgnoreCase );\r\n\t\tif ( missing.Count == 0 )\r\n\t\t\treturn uprojectPath;\r\n\r\n\t\tLog.Info( $\"uproject enables plugin(s) missing from this engine: {string.Join( \", \", missing )} - marking Optional for the export run.\" );\r\n\r\n\t\tforeach ( var p in plugins )\r\n\t\t{\r\n\t\t\tif ( p?[\"Name\"]?.GetValue<string>() is string name && missing.Contains( name ) )\r\n\t\t\t\tp[\"Optional\"] = true;\r\n\t\t}\r\n\r\n\t\t// Same folder, so Content/ and /Game paths resolve identically.\r\n\t\ttempUproject = Path.Combine( projFolder, Path.GetFileNameWithoutExtension( uprojectPath ) + \".sboximport.uproject\" );\r\n\t\tFile.WriteAllText( tempUproject, root.ToJsonString( new System.Text.Json.JsonSerializerOptions { WriteIndented = true } ) );\r\n\t\treturn tempUproject;\r\n\t}\r\n\r\n\t/// <summary>How long a phase may go without an update before its elapsed time is re-emitted.</summary>\r\n\tstatic readonly TimeSpan Heartbeat = TimeSpan.FromSeconds( 2 );\r\n\r\n\t/// <summary>\r\n\t/// Coarse phases recognised in Unreal's OWN boot log. Booting a marketplace project\r\n\t/// headlessly is a thousand-odd log lines and a minute-plus of wall clock before our\r\n\t/// script gets a word in, and reporting nothing through it is indistinguishable from a\r\n\t/// hang. Ordered earliest-to-latest and matched monotonically (a phase never goes\r\n\t/// backwards), because the categories interleave freely.\r\n\t/// </summary>\r\n\tstatic readonly (string Marker, string Phase)[] BootPhases =\r\n\t{\r\n\t\t( \"LogInit\", \"Unreal starting up\" ),\r\n\t\t( \"LogPluginManager: Mounting\", \"Unreal: mounting plugins\" ),\r\n\t\t( \"LogTargetPlatformManager\", \"Unreal: loading target platforms\" ),\r\n\t\t( \"LogDerivedDataCache\", \"Unreal: opening the derived data cache\" ),\r\n\t\t( \"LogAssetRegistry\", \"Unreal: reading the asset registry\" ),\r\n\t\t( \"LogPython\", \"Unreal: starting Python\" ),\r\n\t};\r\n\r\n\t/// <summary>Index into <see cref=\"BootPhases\"/> for a raw log line, or -1 for noise.</summary>\r\n\tstatic int BootPhase( string line )\r\n\t{\r\n\t\tfor ( int i = BootPhases.Length - 1; i >= 0; i-- )\r\n\t\t{\r\n\t\t\tif ( line.Contains( BootPhases[i].Marker, StringComparison.Ordinal ) )\r\n\t\t\t\treturn i;\r\n\t\t}\r\n\r\n\t\treturn -1;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Follow the growing Unreal log file, surfacing progress lines as they land. Unreal\r\n\t/// keeps the file open with shared read access and flushes frequently; a short poll\r\n\t/// keeps this cheap. Runs on the caller's sync context (awaited reads + delays), so\r\n\t/// onProgress can touch UI directly.\r\n\t///\r\n\t/// Every emitted message carries the elapsed time, and the current one is re-emitted on a\r\n\t/// <see cref=\"Heartbeat\"/> whenever the log goes quiet - so even the phases that log\r\n\t/// nothing at all (loading a big map, the asset registry scan) visibly tick over.\r\n\t/// </summary>\r\n\tstatic async Task TailLog( Process proc, string logPath, Action<ExportEvent> onProgress )\r\n\t{\r\n\t\tif ( onProgress is null )\r\n\t\t{\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar clock = Stopwatch.StartNew();\r\n\r\n\t\t// Latest known state, re-emitted by the heartbeat with a fresh elapsed stamp.\r\n\t\tint? done = null, total = null;\r\n\t\tvar message = \"Starting Unreal\";\r\n\t\tvar lastEmit = TimeSpan.MinValue;\r\n\r\n\t\tvoid Emit()\r\n\t\t{\r\n\t\t\t// Total minutes, not the TimeSpan minutes component - an hour-long map export\r\n\t\t\t// shouldn't look like it restarted its clock.\r\n\t\t\tvar elapsed = $\"{(int)clock.Elapsed.TotalMinutes}:{clock.Elapsed.Seconds:00}\";\r\n\t\t\tonProgress( new ExportEvent( done, total, $\"{message} ({elapsed})\" ) );\r\n\t\t\tlastEmit = clock.Elapsed;\r\n\t\t}\r\n\r\n\t\tasync Task Tick()\r\n\t\t{\r\n\t\t\tif ( clock.Elapsed - lastEmit >= Heartbeat )\r\n\t\t\t\tEmit();\r\n\r\n\t\t\tawait Task.Delay( 250 );\r\n\t\t}\r\n\r\n\t\tEmit();\r\n\r\n\t\twhile ( !proc.HasExited && !File.Exists( logPath ) )\r\n\t\t\tawait Tick();\r\n\r\n\t\tif ( !File.Exists( logPath ) )\r\n\t\t\treturn;\r\n\r\n\t\tusing var fs = new FileStream( logPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete );\r\n\t\tusing var reader = new StreamReader( fs );\r\n\r\n\t\t// UE's own python startup chatters on LogPython too - hold script messages back until\r\n\t\t// our script announces itself (\"=== ue_export: ... ===\").\r\n\t\tvar sawScript = false;\r\n\t\tvar bootPhase = -1;\r\n\t\tvar carry = \"\";\r\n\t\twhile ( true )\r\n\t\t{\r\n\t\t\tvar chunk = await reader.ReadToEndAsync();\r\n\t\t\tif ( chunk.Length > 0 )\r\n\t\t\t{\r\n\t\t\t\tcarry += chunk;\r\n\r\n\t\t\t\tint nl;\r\n\t\t\t\twhile ( (nl = carry.IndexOf( '\\n' )) >= 0 )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar line = carry[..nl].TrimEnd( '\\r' );\r\n\t\t\t\t\tcarry = carry[(nl + 1)..];\r\n\r\n\t\t\t\t\tvar ev = ParseLine( line );\r\n\r\n\t\t\t\t\t// Until our script announces itself, Unreal's own boot log is all there\r\n\t\t\t\t\t// is - track a coarse phase from EVERY line, ours or not, so the wait is\r\n\t\t\t\t\t// legible. UE's python startup chatters on LogPython as well, so a\r\n\t\t\t\t\t// LogPython line alone doesn't mean the script is talking yet.\r\n\t\t\t\t\tif ( !sawScript )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tsawScript = ev?.Done is not null\r\n\t\t\t\t\t\t\t|| ev?.Message?.StartsWith( \"ue_export\", StringComparison.OrdinalIgnoreCase ) == true;\r\n\r\n\t\t\t\t\t\tif ( !sawScript )\r\n\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\tvar phase = BootPhase( line );\r\n\t\t\t\t\t\t\tif ( phase > bootPhase )\r\n\t\t\t\t\t\t\t{\r\n\t\t\t\t\t\t\t\tbootPhase = phase;\r\n\t\t\t\t\t\t\t\tmessage = BootPhases[phase].Phase;\r\n\t\t\t\t\t\t\t\tEmit();\r\n\t\t\t\t\t\t\t}\r\n\r\n\t\t\t\t\t\t\tcontinue;\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tif ( ev is null )\r\n\t\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\t\tdone = ev.Done ?? done;\r\n\t\t\t\t\ttotal = ev.Total ?? total;\r\n\t\t\t\t\tif ( !string.IsNullOrEmpty( ev.Message ) )\r\n\t\t\t\t\t\tmessage = ev.Message;\r\n\r\n\t\t\t\t\tEmit();\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\telse if ( proc.HasExited )\r\n\t\t\t{\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\tawait Tick();\r\n\t\t}\r\n\t}\r\n\r\n\t// \"...LogPython: [6/98] SM_int_ceiling_300_01\" - the per-mesh export progress our script logs.\r\n\tstatic readonly Regex MeshProgressLine = new( @\"LogPython:\\s*\\[(\\d+)/(\\d+)\\]\\s*(.+)$\", RegexOptions.Compiled );\r\n\r\n\t/// <summary>\r\n\t/// Distil one raw Unreal log line into a progress event, or null for noise. Only our\r\n\t/// own script's output (LogPython) is surfaced; indented LogPython lines are per-slot\r\n\t/// texture detail and stay hidden.\r\n\t/// </summary>\r\n\tstatic ExportEvent ParseLine( string line )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( line ) )\r\n\t\t\treturn null;\r\n\r\n\t\tvar match = MeshProgressLine.Match( line );\r\n\t\tif ( match.Success )\r\n\t\t{\r\n\t\t\treturn new ExportEvent(\r\n\t\t\t\tint.Parse( match.Groups[1].Value ),\r\n\t\t\t\tint.Parse( match.Groups[2].Value ),\r\n\t\t\t\t$\"Exporting {match.Groups[3].Value.Trim()}\" );\r\n\t\t}\r\n\r\n\t\tvar idx = line.IndexOf( \"LogPython: \", StringComparison.Ordinal );\r\n\t\tif ( idx >= 0 )\r\n\t\t{\r\n\t\t\tvar msg = line[(idx + \"LogPython: \".Length)..];\r\n\t\t\tif ( msg.Length > 0 && !char.IsWhiteSpace( msg[0] ) )\r\n\t\t\t\treturn new ExportEvent( null, null, msg.Trim( '=', ' ' ) );\r\n\t\t}\r\n\r\n\t\treturn null;\r\n\t}\r\n}\r\n"
},
{
"Ident": "brax.unrealimporter",
"Path": "Editor/Import/SceneDebugTools.cs",
"FileName": "SceneDebugTools.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 337301,
"Code": "using System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing Editor.Mcp;\nusing Sandbox;\n\nnamespace Editor.UnrealImporter;\n\n// TEMPORARY verification tool - delete once scene import scale is confirmed.\n[McpToolset( \"unrealimporter\", \"Unreal importer debug tools\" )]\npublic static class SceneDebugTools\n{\n\t/// <summary>Run AssetImporter.Import on a staging folder (manifest.json + FBX + PNG).</summary>\n\t/// <param name=\"stagingDir\">Staging folder containing manifest.json.</param>\n\t/// <param name=\"outputFolder\">Output folder inside the project's Assets/.</param>\n\t/// <param name=\"layout\">Grouped (default), Flat, ClassicSource or PerAsset.</param>\n\t/// <param name=\"materialOutput\">Material (default), Terrain or Decal.</param>\n\t/// <param name=\"perAssetFolderDepth\">PerAsset layout: folders up the /Game path to name each folder after (0 = own name).</param>\n\t/// <param name=\"maxTextureSize\">Cap every written texture's longest edge, downscaling bigger sources (0 = keep as-is).</param>\n\t[McpTool( \"unreal_scene_import_test\" )]\n\tpublic static async Task<string> SceneImportTest( string stagingDir, string outputFolder, string layout = null, string materialOutput = null, int perAssetFolderDepth = 0, int maxTextureSize = 0 )\n\t{\n\t\tvar manifestPath = Path.Combine( stagingDir, \"manifest.json\" );\n\t\tif ( !File.Exists( manifestPath ) )\n\t\t\treturn $\"no manifest.json in {stagingDir}\";\n\n\t\tif ( !System.Enum.TryParse<ImportLayout>( layout ?? \"Grouped\", ignoreCase: true, out var importLayout ) )\n\t\t\treturn $\"unknown layout '{layout}'\";\n\t\tif ( !System.Enum.TryParse<MaterialOutput>( materialOutput ?? \"Material\", ignoreCase: true, out var matOutput ) )\n\t\t\treturn $\"unknown material output '{materialOutput}'\";\n\n\t\tvar manifest = ImportManifest.Load( manifestPath );\n\t\tvar summary = await AssetImporter.Import( manifest, stagingDir, outputFolder, CancellationToken.None, importLayout, materialOutput: matOutput, perAssetFolderDepth: perAssetFolderDepth, maxTextureSize: maxTextureSize );\n\n\t\tvar result = $\"models={summary.Models} materials={summary.Materials} textures={summary.Textures} \" +\n\t\t\t$\"placements={summary.Placements} prefab={summary.PrefabPath ?? \"(none)\"}\";\n\t\tif ( summary.Warnings.Count > 0 )\n\t\t\tresult += \"\\nwarnings:\\n - \" + string.Join( \"\\n - \", summary.Warnings.Take( 10 ) );\n\n\t\treturn result;\n\t}\n\n\t/// <summary>TEMP: read tri/vert counts from a .uasset via UassetMeshStats.</summary>\n\t/// <param name=\"uassetPath\">Absolute path to a .uasset.</param>\n\t[McpTool( \"unreal_meshstats_test\" )]\n\tpublic static async Task<string> MeshStatsTest( string uassetPath )\n\t{\n\t\tvar stats = await UassetMeshStats.LoadAsync( uassetPath );\n\t\tif ( stats is null )\n\t\t\treturn \"no stats found\";\n\n\t\treturn $\"tris={stats.Triangles} verts={stats.Vertices} mats={stats.Materials} lods={stats.LODs}\";\n\t}\n\n\t/// <summary>TEMP: run a small headless export and log the live progress events (verifies log tailing).</summary>\n\t/// <param name=\"uprojectPath\">Absolute path to the .uproject.</param>\n\t/// <param name=\"assets\">';'-separated /Game asset paths to export.</param>\n\t[McpTool( \"unreal_export_progress_test\" )]\n\tpublic static async Task<string> ExportProgressTest( string uprojectPath, string assets )\n\t{\n\t\tvar script = HeadlessExporter.FindExportScript();\n\t\tvar editorCmd = UnrealLocator.FindEditorCmd( UnrealLocator.ReadEngineAssociation( uprojectPath ) );\n\t\tif ( script is null || editorCmd is null )\n\t\t\treturn \"tools not found\";\n\n\t\tvar events = new List<string>();\n\t\tvar result = await HeadlessExporter.Run( editorCmd, uprojectPath, assets.Split( ';' ), script, System.Threading.CancellationToken.None,\n\t\t\tonProgress: ev =>\n\t\t\t{\n\t\t\t\tvar line = $\"{ev.Done}/{ev.Total} {ev.Message}\";\n\t\t\t\tevents.Add( line );\n\t\t\t\tLog.Info( $\"UEPROG {line}\" );\n\t\t\t} );\n\n\t\treturn $\"success={result.Success} events={events.Count}\\n\" + string.Join( \"\\n\", events.TakeLast( 12 ) );\n\t}\n\n\t/// <summary>TEMP: open the Unreal Importer window for UI verification.</summary>\n\t[McpTool( \"unreal_open_import_window\" )]\n\tpublic static string OpenImportWindow()\n\t{\n\t\t_ = new UnrealImportWindow();\n\t\treturn \"opened\";\n\t}\n\n\t/// <summary>Load a model and report its bounds in inches.</summary>\n\t/// <param name=\"modelPath\">Model content path, e.g. \"unrealimport/models/x.vmdl\".</param>\n\t[McpTool( \"unreal_model_bounds\" )]\n\tpublic static string ModelBounds( string modelPath )\n\t{\n\t\tvar model = Model.Load( modelPath );\n\t\tif ( model is null || model.IsError )\n\t\t\treturn $\"failed to load {modelPath}\";\n\n\t\tvar b = model.Bounds;\n\t\treturn $\"size=({b.Size.x:0.##}, {b.Size.y:0.##}, {b.Size.z:0.##}) in mins=({b.Mins.x:0.##},{b.Mins.y:0.##},{b.Mins.z:0.##}) maxs=({b.Maxs.x:0.##},{b.Maxs.y:0.##},{b.Maxs.z:0.##})\";\n\t}\n\n\t/// <summary>Bounds of every .vmdl in a folder, one json object per line.</summary>\n\t/// <param name=\"folder\">Absolute folder containing .vmdl files.</param>\n\t[McpTool( \"unreal_all_model_bounds\" )]\n\tpublic static string AllModelBounds( string folder )\n\t{\n\t\tvar sb = new System.Text.StringBuilder();\n\t\tforeach ( var f in Directory.EnumerateFiles( folder, \"*.vmdl\" ) )\n\t\t{\n\t\t\tvar rel = Path.GetRelativePath( Sandbox.Project.Current.GetAssetsPath(), f ).Replace( '\\\\', '/' );\n\t\t\tvar model = Model.Load( rel );\n\t\t\tif ( model is null || model.IsError )\n\t\t\t{\n\t\t\t\tsb.AppendLine( $\"{{\\\"model\\\":\\\"{Path.GetFileName( f )}\\\",\\\"error\\\":true}}\" );\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar b = model.Bounds;\n\t\t\tsb.AppendLine( System.FormattableString.Invariant(\n\t\t\t\t$\"{{\\\"model\\\":\\\"{Path.GetFileName( f )}\\\",\\\"min\\\":[{b.Mins.x:0.###},{b.Mins.y:0.###},{b.Mins.z:0.###}],\\\"max\\\":[{b.Maxs.x:0.###},{b.Maxs.y:0.###},{b.Maxs.z:0.###}]}}\" ) );\n\t\t}\n\t\treturn sb.ToString();\n\t}\n}\n"
},
{
"Ident": "brax.unrealimporter",
"Path": "Editor/Widgets/ImportStyle.cs",
"FileName": "ImportStyle.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 337301,
"Code": "using Sandbox;\n\nnamespace Editor.UnrealImporter;\n\n/// <summary>\n/// Shared colours + painting helpers for the importer window.\n///\n/// The editor's dark theme sets ControlBackground and WindowBackground to the SAME value\n/// (#181818), so a stock LineEdit or ComboBox is painted exactly the colour of the window\n/// behind it and reads as loose text rather than a field. These helpers derive contrasting\n/// tones by lerping towards the theme's surface colours, so they still track a custom theme\n/// instead of hardcoding greys.\n/// </summary>\npublic static class ImportStyle\n{\n\t/// <summary>Section/panel fill - a step up from the window background.</summary>\n\tpublic static Color Panel => Color.Lerp( Theme.WindowBackground, Theme.SurfaceBackground, 0.22f );\n\n\t/// <summary>Input field fill - a further step up, so fields read as sunken boxes.</summary>\n\tpublic static Color Input => Color.Lerp( Theme.WindowBackground, Theme.SurfaceBackground, 0.45f );\n\n\t/// <summary>Alternating tree row tint (matches the editor's own scene tree).</summary>\n\tpublic static Color RowStripe => Theme.SurfaceLightBackground.WithAlpha( 0.06f );\n\n\t/// <summary>Give a text field / combo a visible box, since the theme's default is invisible.</summary>\n\tpublic static T StyleInput<T>( this T widget ) where T : Widget\n\t{\n\t\twidget.SetStyles(\n\t\t\t$\"background-color: {Input.Hex};\" +\n\t\t\t$\"border: 1px solid {Theme.Border.WithAlpha( 0.5f ).Hex};\" +\n\t\t\t$\"border-radius: {Theme.ControlRadius}px;\" );\n\n\t\treturn widget;\n\t}\n\n\t/// <summary>\n\t/// Row background for a tree item: selection, then hover, then a zebra stripe. Spans the\n\t/// full width of the view rather than the (indented) item rect, so nested rows still\n\t/// stripe in line with their parents.\n\t/// </summary>\n\tpublic static void PaintRow( VirtualWidget item, TreeView tree )\n\t{\n\t\tvar full = item.Rect;\n\t\tfull.Left = 0;\n\t\tif ( tree.IsValid() )\n\t\t\tfull.Right = tree.Width;\n\n\t\tPaint.ClearPen();\n\n\t\tif ( item.Selected || item.Pressed )\n\t\t\tPaint.SetBrush( Theme.SelectedBackground.WithAlpha( 0.9f ) );\n\t\telse if ( item.Hovered )\n\t\t\tPaint.SetBrush( Theme.SelectedBackground.WithAlpha( 0.25f ) );\n\t\telse if ( item.Row % 2 == 0 )\n\t\t\tPaint.SetBrush( RowStripe );\n\t\telse\n\t\t\treturn;\n\n\t\tPaint.DrawRect( full );\n\t}\n}\n"
},
{
"Ident": "brax.unrealimporter",
"Path": "Editor/Import/TextureProcessor.cs",
"FileName": "TextureProcessor.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 337301,
"Code": "using System;\r\nusing System.IO;\r\nusing Sandbox;\r\n\r\nnamespace Editor.UnrealImporter;\r\n\r\n/// <summary>\r\n/// Output texture filenames (no path) for a processed material, or null where absent.\r\n/// </summary>\r\npublic class ProcessedTextures\r\n{\r\n\tpublic string Color;\r\n\tpublic string Alpha;\r\n\tpublic string Normal;\r\n\tpublic string Roughness;\r\n\tpublic string Metallic;\r\n\tpublic string Ao;\r\n\tpublic string Emissive;\r\n\tpublic string TintMask;\r\n\r\n\t/// <summary>Displacement/height map. Unused by complex.shader; terrain + decal resources want it.</summary>\r\n\tpublic string Height;\r\n\r\n\t/// <summary>Packed R=Roughness G=Metal B=Occlusion, for decal resources (which take one RMO map).</summary>\r\n\tpublic string RoughMetalOcclusion;\r\n\r\n\t/// <summary>Grayscale emissive mask extracted from the albedo's alpha (opaque materials with emissive params).</summary>\r\n\tpublic string SelfIllumMask;\r\n}\r\n\r\n/// <summary>What the albedo's alpha channel means for this material - decided from the Unreal blend mode.</summary>\r\npublic enum AlphaRole\r\n{\r\n\t/// <summary>UE blends/masks with it - extract as a translucency/alpha-test map.</summary>\r\n\tTranslucency,\r\n\t/// <summary>Opaque material with emissive params - the alpha is a self-illum mask.</summary>\r\n\tSelfIllum,\r\n\t/// <summary>Opaque, no emissive - the alpha packs something we can't interpret; ignore it.</summary>\r\n\tIgnore,\r\n}\r\n\r\n/// <summary>\r\n/// Turns Unreal's raw exported textures into sbox-ready ones using sbox's Bitmap:\r\n/// - splits RMA (R=roughness, G=metallic, B=ao) into separate grayscale maps\r\n/// - flips the normal's green channel (Unreal DirectX -> sbox OpenGL)\r\n/// - extracts the albedo's alpha to a separate map\r\n/// - writes everything as <base>_<role>.png (lowercase, no dots)\r\n/// </summary>\r\npublic static class TextureProcessor\r\n{\r\n\t/// <param name=\"packRmo\">\r\n\t/// Emit one packed R=Rough G=Metal B=AO map instead of three grayscale ones. Decal\r\n\t/// resources take a single RMO texture, so splitting and re-packing would be lossy churn.\r\n\t/// </param>\r\n\t/// <param name=\"wantHeight\">Also process the displacement map (terrain + decal resources use it).</param>\r\n\t/// <param name=\"maxTextureSize\">\r\n\t/// Downscale anything larger than this on the longest edge (0 = keep the source size).\r\n\t/// Applied at LOAD time, so the channel splits and per-pixel passes below run on the\r\n\t/// smaller bitmap too - a 4K pack imports several times faster at 1K.\r\n\t/// </param>\r\n\tpublic static ProcessedTextures Process( ManifestMaterial mat, string stagingDir, string outputTextureDir, string baseName, AlphaRole alphaRole = AlphaRole.Translucency, bool packRmo = false, bool wantHeight = false, int maxTextureSize = 0 )\r\n\t{\r\n\t\tDirectory.CreateDirectory( outputTextureDir );\r\n\t\tvar result = new ProcessedTextures();\r\n\r\n\t\t// --- Opacity (dedicated map) ---\r\n\t\t// Cutout foliage and thatch ship their mask as its OWN texture and leave the albedo\r\n\t\t// fully opaque, so there is no alpha for the colour block below to extract. A texture\r\n\t\t// Unreal explicitly bound to an opacity parameter wins over the albedo's alpha; done\r\n\t\t// first so that alpha still serves as the fallback when this map is missing.\r\n\t\tif ( alphaRole == AlphaRole.Translucency && !string.IsNullOrEmpty( mat.Opacity ) )\r\n\t\t{\r\n\t\t\tusing var opacity = Load( stagingDir, mat.Opacity, maxTextureSize );\r\n\t\t\tif ( opacity is not null )\r\n\t\t\t\tresult.Alpha = Save( ExtractChannel( opacity, DominantChannel( opacity, includeAlpha: true ) ), outputTextureDir, baseName, \"alpha\", dispose: true );\r\n\t\t}\r\n\r\n\t\t// --- Color (+ alpha) ---\r\n\t\tif ( !string.IsNullOrEmpty( mat.Alb ) )\r\n\t\t{\r\n\t\t\tusing var alb = Load( stagingDir, mat.Alb, maxTextureSize );\r\n\t\t\tif ( alb is not null )\r\n\t\t\t{\r\n\t\t\t\t// Export the albedo UNTOUCHED. Fab/Megascans albedos already contain the final\r\n\t\t\t\t// colours; the material's tint mask + tint colours are an OPTIONAL runtime-recolour\r\n\t\t\t\t// system (team colours / variants). Baking them here double-colours and corrupts\r\n\t\t\t\t// the result, so we keep the albedo pristine and leave tint inert in the vmat.\r\n\t\t\t\tresult.Color = Save( alb, outputTextureDir, baseName, \"color\" );\r\n\r\n\t\t\t\tif ( result.Alpha is null && !alb.IsOpaque() && alphaRole != AlphaRole.Ignore )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( alphaRole == AlphaRole.SelfIllum )\r\n\t\t\t\t\t\tresult.SelfIllumMask = Save( ExtractAlpha( alb ), outputTextureDir, baseName, \"selfillum\", dispose: true );\r\n\t\t\t\t\telse\r\n\t\t\t\t\t\tresult.Alpha = Save( ExtractAlpha( alb ), outputTextureDir, baseName, \"alpha\", dispose: true );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// --- Normal (flip green) ---\r\n\t\tif ( !string.IsNullOrEmpty( mat.Nrm ) )\r\n\t\t{\r\n\t\t\tusing var nrm = Load( stagingDir, mat.Nrm, maxTextureSize );\r\n\t\t\tif ( nrm is not null )\r\n\t\t\t\tresult.Normal = Save( FlipGreen( nrm ), outputTextureDir, baseName, \"normal\", dispose: true );\r\n\t\t}\r\n\r\n\t\t// --- Packed RMA/ORM -> roughness / metallic / ao (or one repacked RMO) ---\r\n\t\tif ( !string.IsNullOrEmpty( mat.Rma ) )\r\n\t\t{\r\n\t\t\tusing var rma = Load( stagingDir, mat.Rma, maxTextureSize );\r\n\t\t\tif ( rma is not null )\r\n\t\t\t{\r\n\t\t\t\tvar (rough, metal, ao) = RmaChannels( mat.RmaOrder );\r\n\r\n\t\t\t\tif ( packRmo )\r\n\t\t\t\t\tresult.RoughMetalOcclusion = Save( Reorder( rma, rough, metal, ao ), outputTextureDir, baseName, \"rmo\", dispose: true );\r\n\t\t\t\telse\r\n\t\t\t\t{\r\n\t\t\t\t\tresult.Roughness = Save( ExtractChannel( rma, rough ), outputTextureDir, baseName, \"roughness\", dispose: true );\r\n\t\t\t\t\tresult.Metallic = Save( ExtractChannel( rma, metal ), outputTextureDir, baseName, \"metallic\", dispose: true );\r\n\t\t\t\t\tresult.Ao = Save( ExtractChannel( rma, ao ), outputTextureDir, baseName, \"ao\", dispose: true );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// --- Explicit single-channel maps (override RMA-derived if both somehow present) ---\r\n\t\tProcessSingle( mat.Rough, stagingDir, outputTextureDir, baseName, \"roughness\", ref result.Roughness, maxTextureSize );\r\n\t\tProcessSingle( mat.Metal, stagingDir, outputTextureDir, baseName, \"metallic\", ref result.Metallic, maxTextureSize );\r\n\t\tProcessSingle( mat.Ao, stagingDir, outputTextureDir, baseName, \"ao\", ref result.Ao, maxTextureSize );\r\n\t\tProcessSingle( mat.Emissive, stagingDir, outputTextureDir, baseName, \"emissive\", ref result.Emissive, maxTextureSize );\r\n\r\n\t\tif ( wantHeight )\r\n\t\t\tProcessSingle( mat.Height, stagingDir, outputTextureDir, baseName, \"height\", ref result.Height, maxTextureSize );\r\n\r\n\t\t// A material with separate maps still owes a decal one packed RMO - build it from\r\n\t\t// whichever of the three exist (missing channels stay black).\r\n\t\tif ( packRmo && result.RoughMetalOcclusion is null && (result.Roughness ?? result.Metallic ?? result.Ao) is not null )\r\n\t\t{\r\n\t\t\tvar packed = Combine( outputTextureDir, result.Roughness, result.Metallic, result.Ao );\r\n\t\t\tif ( packed is not null )\r\n\t\t\t\tresult.RoughMetalOcclusion = Save( packed, outputTextureDir, baseName, \"rmo\", dispose: true );\r\n\t\t}\r\n\r\n\t\t// --- Tint mask (grayscale) - export the populated channel so it can drive optional\r\n\t\t// runtime tinting. Masks are single-channel but the data isn't always in R (this ATV\r\n\t\t// mask lives in B), so pick whichever channel actually carries data.\r\n\t\tif ( !string.IsNullOrEmpty( mat.TintMask ) )\r\n\t\t{\r\n\t\t\tusing var mask = Load( stagingDir, mat.TintMask, maxTextureSize );\r\n\t\t\tif ( mask is not null )\r\n\t\t\t\tresult.TintMask = Save( ExtractChannel( mask, DominantChannel( mask ) ), outputTextureDir, baseName, \"tintmask\", dispose: true );\r\n\t\t}\r\n\r\n\t\treturn result;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Which channel index (0=R, 1=G, 2=B) holds roughness / metalness / AO for a packed\r\n\t/// mask, from the manifest's layout name. Fab ships _RMA, Megascans ships _ORM with the\r\n\t/// exact same look but a different order - splitting one as the other swaps roughness\r\n\t/// and AO, which reads as a flat, wrongly-shiny surface rather than an obvious error.\r\n\t/// </summary>\r\n\tstatic (int rough, int metal, int ao) RmaChannels( string order ) => (order ?? \"rma\").ToLowerInvariant() switch\r\n\t{\r\n\t\t// \"aorm\" is \"orm\" spelled out - the leading A is the occlusion the O already names.\r\n\t\t// Read as plain RMA it binds the ROUGHNESS map as metalness (a near-white metal mask)\r\n\t\t// and the empty metal channel as AO (fully black), which wrecks the lighting.\r\n\t\t\"orm\" or \"arm\" or \"aorm\" => (1, 2, 0),\r\n\t\t\"mra\" => (1, 0, 2),\r\n\t\t_ => (0, 1, 2),\r\n\t};\r\n\r\n\tstatic void ProcessSingle( string rel, string stagingDir, string outDir, string baseName, string role, ref string slot, int maxSize = 0 )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( rel ) )\r\n\t\t\treturn;\r\n\r\n\t\tusing var bmp = Load( stagingDir, rel, maxSize );\r\n\t\tif ( bmp is not null )\r\n\t\t\tslot = Save( bmp, outDir, baseName, role );\r\n\t}\r\n\r\n\tstatic Bitmap Load( string stagingDir, string relPath, int maxSize = 0 )\r\n\t{\r\n\t\tvar abs = Path.Combine( stagingDir, relPath.Replace( '/', Path.DirectorySeparatorChar ) );\r\n\t\tif ( !File.Exists( abs ) )\r\n\t\t\treturn null;\r\n\r\n\t\tvar bmp = Bitmap.CreateFromBytes( File.ReadAllBytes( abs ) );\r\n\t\tif ( bmp is null || !bmp.IsValid )\r\n\t\t\treturn null;\r\n\r\n\t\treturn Downscale( bmp, maxSize );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Shrink a bitmap so neither edge exceeds maxSize, keeping its aspect ratio. Returns the\r\n\t/// original when it already fits (or when maxSize is 0), so the caller always owns exactly\r\n\t/// one bitmap. Never upscales - the cap is a ceiling, not a target.\r\n\t/// </summary>\r\n\tstatic Bitmap Downscale( Bitmap bmp, int maxSize )\r\n\t{\r\n\t\tif ( maxSize <= 0 || (bmp.Width <= maxSize && bmp.Height <= maxSize) )\r\n\t\t\treturn bmp;\r\n\r\n\t\tfloat scale = maxSize / (float)Math.Max( bmp.Width, bmp.Height );\r\n\t\tint w = Math.Max( 1, (int)MathF.Round( bmp.Width * scale ) );\r\n\t\tint h = Math.Max( 1, (int)MathF.Round( bmp.Height * scale ) );\r\n\r\n\t\tvar resized = bmp.Resize( w, h );\r\n\t\tbmp.Dispose();\r\n\t\treturn resized;\r\n\t}\r\n\r\n\tstatic string Save( Bitmap bmp, string outDir, string baseName, string role, bool dispose = false )\r\n\t{\r\n\t\tvar fileName = $\"{baseName}_{role}.png\";\r\n\t\tFile.WriteAllBytes( Path.Combine( outDir, fileName ), bmp.ToPng() );\r\n\t\tif ( dispose )\r\n\t\t\tbmp.Dispose();\r\n\r\n\t\treturn fileName;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Index (0=R,1=G,2=B,3=A) of the channel carrying the mask data (widest value range).\r\n\t/// includeAlpha lets alpha win - opacity maps are sometimes white RGB with the cutout in\r\n\t/// alpha, whereas a tint mask never lives there and would only be spoiled by considering it.\r\n\t/// </summary>\r\n\tstatic int DominantChannel( Bitmap src, bool includeAlpha = false )\r\n\t{\r\n\t\tvar px = src.GetPixels();\r\n\t\tvar min = new[] { 1f, 1f, 1f, 1f };\r\n\t\tvar max = new[] { 0f, 0f, 0f, 0f };\r\n\r\n\t\t// Sample sparsely - masks are large and uniform enough that this is plenty.\r\n\t\tint step = Math.Max( 1, px.Length / 100000 );\r\n\t\tfor ( int i = 0; i < px.Length; i += step )\r\n\t\t{\r\n\t\t\tvar c = px[i];\r\n\t\t\tvar v = new[] { c.r, c.g, c.b, c.a };\r\n\t\t\tfor ( int n = 0; n < 4; n++ )\r\n\t\t\t{\r\n\t\t\t\tif ( v[n] < min[n] ) min[n] = v[n];\r\n\t\t\t\tif ( v[n] > max[n] ) max[n] = v[n];\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tint count = includeAlpha ? 4 : 3;\r\n\t\tint best = 0;\r\n\t\tfor ( int n = 1; n < count; n++ )\r\n\t\t{\r\n\t\t\tif ( max[n] - min[n] > max[best] - min[best] )\r\n\t\t\t\tbest = n;\r\n\t\t}\r\n\r\n\t\treturn best;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// New bitmap with the source channels moved into R=Rough, G=Metal, B=AO order.\r\n\t/// A source that's already RMA comes out unchanged.\r\n\t/// </summary>\r\n\tstatic Bitmap Reorder( Bitmap src, int rough, int metal, int ao )\r\n\t{\r\n\t\tvar pixels = src.GetPixels();\r\n\t\tfor ( int i = 0; i < pixels.Length; i++ )\r\n\t\t{\r\n\t\t\tvar c = pixels[i];\r\n\t\t\tfloat Ch( int n ) => n == 0 ? c.r : n == 1 ? c.g : c.b;\r\n\t\t\tpixels[i] = new Color( Ch( rough ), Ch( metal ), Ch( ao ), 1f );\r\n\t\t}\r\n\r\n\t\tvar bmp = new Bitmap( src.Width, src.Height );\r\n\t\tbmp.SetPixels( pixels );\r\n\t\treturn bmp;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Pack three already-written grayscale maps into one RMO bitmap. Null slots stay black.\r\n\t/// Returns null unless every supplied map shares the same dimensions - rescaling here\r\n\t/// would be guesswork, and a mismatched pack is worse than none.\r\n\t/// </summary>\r\n\tstatic Bitmap Combine( string dir, string roughFile, string metalFile, string aoFile )\r\n\t{\r\n\t\tBitmap Read( string f ) => string.IsNullOrEmpty( f ) ? null : Load( dir, f );\r\n\r\n\t\tusing var r = Read( roughFile );\r\n\t\tusing var m = Read( metalFile );\r\n\t\tusing var a = Read( aoFile );\r\n\r\n\t\tvar any = r ?? m ?? a;\r\n\t\tif ( any is null )\r\n\t\t\treturn null;\r\n\r\n\t\tforeach ( var b in new[] { r, m, a } )\r\n\t\t{\r\n\t\t\tif ( b is not null && (b.Width != any.Width || b.Height != any.Height) )\r\n\t\t\t\treturn null;\r\n\t\t}\r\n\r\n\t\tvar rp = r?.GetPixels();\r\n\t\tvar mp = m?.GetPixels();\r\n\t\tvar ap = a?.GetPixels();\r\n\t\tvar outPixels = new Color[any.Width * any.Height];\r\n\r\n\t\tfor ( int i = 0; i < outPixels.Length; i++ )\r\n\t\t\toutPixels[i] = new Color( rp?[i].r ?? 0f, mp?[i].r ?? 0f, ap?[i].r ?? 0f, 1f );\r\n\r\n\t\tvar bmp = new Bitmap( any.Width, any.Height );\r\n\t\tbmp.SetPixels( outPixels );\r\n\t\treturn bmp;\r\n\t}\r\n\r\n\t/// <summary>New grayscale bitmap from one channel (0=R, 1=G, 2=B, 3=A).</summary>\r\n\tstatic Bitmap ExtractChannel( Bitmap src, int channel )\r\n\t{\r\n\t\tvar pixels = src.GetPixels();\r\n\t\tfor ( int i = 0; i < pixels.Length; i++ )\r\n\t\t{\r\n\t\t\tvar c = pixels[i];\r\n\t\t\tfloat v = channel == 0 ? c.r : channel == 1 ? c.g : channel == 2 ? c.b : c.a;\r\n\t\t\tpixels[i] = new Color( v, v, v, 1f );\r\n\t\t}\r\n\r\n\t\tvar bmp = new Bitmap( src.Width, src.Height );\r\n\t\tbmp.SetPixels( pixels );\r\n\t\treturn bmp;\r\n\t}\r\n\r\n\t/// <summary>New bitmap with the green channel inverted (DirectX -> OpenGL normals).</summary>\r\n\tstatic Bitmap FlipGreen( Bitmap src )\r\n\t{\r\n\t\tvar pixels = src.GetPixels();\r\n\t\tfor ( int i = 0; i < pixels.Length; i++ )\r\n\t\t{\r\n\t\t\tvar c = pixels[i];\r\n\t\t\tpixels[i] = new Color( c.r, 1f - c.g, c.b, c.a );\r\n\t\t}\r\n\r\n\t\tvar bmp = new Bitmap( src.Width, src.Height );\r\n\t\tbmp.SetPixels( pixels );\r\n\t\treturn bmp;\r\n\t}\r\n\r\n\t/// <summary>New grayscale bitmap holding the source alpha.</summary>\r\n\tstatic Bitmap ExtractAlpha( Bitmap src )\r\n\t{\r\n\t\tvar pixels = src.GetPixels();\r\n\t\tfor ( int i = 0; i < pixels.Length; i++ )\r\n\t\t{\r\n\t\t\tfloat a = pixels[i].a;\r\n\t\t\tpixels[i] = new Color( a, a, a, 1f );\r\n\t\t}\r\n\r\n\t\tvar bmp = new Bitmap( src.Width, src.Height );\r\n\t\tbmp.SetPixels( pixels );\r\n\t\treturn bmp;\r\n\t}\r\n}\r\n"
}
]
}