{"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/// \u003Csummary\u003E\n/// Creates s\u0026box GameResources - .tmat (Terrain Material) and .decal (Decal Definition).\n///\n/// These are built through the editor\u0027s own asset API rather than by writing json: create the\n/// asset, set properties on the real resource object, save. The resource classes\u0027 own defaults\n/// then apply to everything we don\u0027t set, and SaveToDisk serialises, compiles and registers.\n/// Only .vmat is hand-written, because it\u0027s 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\u0027t have yet - it returns null there. So a new resource is\n/// constructed with \u0060new T()\u0060 (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/// \u003C/summary\u003E\npublic static class GameResourceWriter\n{\n\t/// \u003Csummary\u003E\n\t/// A Terrain Material. Terrain takes separate grayscale roughness/AO/height maps and a\n\t/// scalar metalness (there\u0027s no metal texture slot), so a metallic map has nowhere to go.\n\t/// Null paths are simply left at the resource\u0027s default image.\n\t/// \u003C/summary\u003E\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( \u0022tmat\u0022, absolutePath );\n\t\tif ( asset is null )\n\t\t\treturn null;\n\n\t\t// Existing asset -\u003E update it in place; new one -\u003E start from the class defaults.\n\t\tvar isNew = !asset.TryLoadResource\u003CTerrainMaterial\u003E( 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\u0027t read Unreal\u0027s 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 \u0022no height\u0022 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/// \u003Csummary\u003E\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\u0027s alpha is what masks the decal.\n\t/// \u003C/summary\u003E\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( \u0022decal\u0022, absolutePath );\n\t\tif ( asset is null )\n\t\t\treturn null;\n\n\t\tvar isNew = !asset.TryLoadResource\u003CDecalDefinition\u003E( 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\u0027t 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\u0027t 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/// \u003Csummary\u003E\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\u0027s json.\n\t/// \u003C/summary\u003E\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/// \u003Csummary\u003E\r\n/// Mirrors the manifest.json produced by Tools/ue_export.py (the headless Unreal export).\r\n/// \u003C/summary\u003E\r\npublic class ImportManifest\r\n{\r\n\t[JsonPropertyName( \u0022version\u0022 )] public int Version { get; set; }\r\n\t[JsonPropertyName( \u0022assets\u0022 )] public List\u003CManifestAsset\u003E Assets { get; set; } = new();\r\n\r\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022materials\u0022 )] public List\u003CManifestMaterial\u003E Materials { get; set; } = new();\r\n\r\n\t/// \u003Csummary\u003EPresent only for scene-mode exports (UE_EXPORT_MAP): the level\u0027s placements \u002B lights.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022scene\u0022 )] 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\u003CImportManifest\u003E( File.ReadAllText( path ), opts );\r\n\t}\r\n}\r\n\r\npublic class ManifestAsset\r\n{\r\n\t[JsonPropertyName( \u0022asset\u0022 )] public string Asset { get; set; }\r\n\r\n\t/// \u003Csummary\u003E/Game package path - scene placements reference meshes by this.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022game_path\u0022 )] public string GamePath { get; set; }\r\n\t[JsonPropertyName( \u0022fbx\u0022 )] public string Fbx { get; set; }\r\n\t[JsonPropertyName( \u0022import_scale\u0022 )] public float ImportScale { get; set; } = 0.3937f;\r\n\t[JsonPropertyName( \u0022materials\u0022 )] public List\u003CManifestMaterial\u003E Materials { get; set; } = new();\r\n}\r\n\r\npublic class ManifestMaterial\r\n{\r\n\t/// \u003Csummary\u003EFBX material slot name (e.g. \u0022lambert2\u0022). Null for standalone material imports.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022slot\u0022 )] public string Slot { get; set; }\r\n\r\n\t/// \u003Csummary\u003EStandalone imports only: the picked asset\u0027s name, for progress/logging.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022asset\u0022 )] public string Asset { get; set; }\r\n\r\n\t/// \u003Csummary\u003EStandalone imports only: the /Game package path it came from.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022game_path\u0022 )] public string GamePath { get; set; }\r\n\r\n\t/// \u003Csummary\u003ESource Material Instance name (e.g. \u0022MI_CardboardBoxes_01a\u0022) - used for vmat/texture naming \u002B dedup.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022material\u0022 )] public string Material { get; set; }\r\n\r\n\t/// \u003Csummary\u003EUnreal blend mode name (BLEND_OPAQUE / BLEND_MASKED / BLEND_TRANSLUCENT...). Null on old manifests.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022blend_mode\u0022 )] public string BlendMode { get; set; }\r\n\r\n\t// Texture role -\u003E staging-relative png path. Null when the material doesn\u0027t use that role.\r\n\t[JsonPropertyName( \u0022alb\u0022 )] public string Alb { get; set; }\r\n\t[JsonPropertyName( \u0022nrm\u0022 )] public string Nrm { get; set; }\r\n\t[JsonPropertyName( \u0022rma\u0022 )] public string Rma { get; set; }\r\n\t[JsonPropertyName( \u0022rough\u0022 )] public string Rough { get; set; }\r\n\t[JsonPropertyName( \u0022metal\u0022 )] public string Metal { get; set; }\r\n\t[JsonPropertyName( \u0022ao\u0022 )] public string Ao { get; set; }\r\n\t[JsonPropertyName( \u0022emissive\u0022 )] public string Emissive { get; set; }\r\n\t[JsonPropertyName( \u0022opacity\u0022 )] public string Opacity { get; set; }\r\n\r\n\t/// \u003Csummary\u003EDisplacement/height map. complex.shader has no slot for it - recorded so we can warn.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022height\u0022 )] public string Height { get; set; }\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Channel layout of \u003Csee cref=\u0022Rma\u0022/\u003E: \u0022rma\u0022 (R=rough G=metal B=ao, the Fab convention),\r\n\t/// \u0022orm\u0022/\u0022arm\u0022 (R=ao G=rough B=metal, Megascans) or \u0022mra\u0022. Null on old manifests -\u003E \u0022rma\u0022.\r\n\t/// \u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022rma_order\u0022 )] public string RmaOrder { get; set; }\r\n\r\n\t/// \u003Csummary\u003EGrayscale tint mask (white = full tint). Packed into the normal\u0027s alpha by the complex shader.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022tintmask\u0022 )] public string TintMask { get; set; }\r\n\r\n\t/// \u003Csummary\u003EBest-guess single tint color [r,g,b,a] in Unreal LINEAR space (sRGB-encode for g_vColorTint).\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022tint_color\u0022 )] public float[] TintColor { get; set; }\r\n\r\n\t/// \u003Csummary\u003EMulti-zone tint: mask channel (\u0022r\u0022/\u0022g\u0022/\u0022b\u0022/\u0022a\u0022) -\u003E LINEAR tint [r,g,b,a]. Baked into the albedo.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022tint_zones\u0022 )] public Dictionary\u003Cstring, float[]\u003E TintZones { get; set; }\r\n\r\n\t/// \u003Csummary\u003EBest-guess tint amount/strength (0..1) -\u003E g_flModelTintAmount.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022tint_amount\u0022 )] public float? TintAmount { get; set; }\r\n\r\n\t/// \u003Csummary\u003EAll scalar parameter overrides on the Material Instance (kept for fidelity).\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022scalar_params\u0022 )] public Dictionary\u003Cstring, float\u003E ScalarParams { get; set; }\r\n\r\n\t/// \u003Csummary\u003EAll vector (color) parameter overrides [r,g,b,a] on the Material Instance.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022vector_params\u0022 )] public Dictionary\u003Cstring, float[]\u003E VectorParams { get; set; }\r\n}\r\n\r\npublic class ManifestScene\r\n{\r\n\t[JsonPropertyName( \u0022name\u0022 )] public string Name { get; set; }\r\n\t[JsonPropertyName( \u0022map\u0022 )] public string Map { get; set; }\r\n\t[JsonPropertyName( \u0022placements\u0022 )] public List\u003CManifestPlacement\u003E Placements { get; set; } = new();\r\n\t[JsonPropertyName( \u0022lights\u0022 )] public List\u003CManifestLight\u003E Lights { get; set; } = new();\r\n\r\n\t/// \u003Csummary\u003EThings the exporter skipped (capped scatter ISMs, landscapes...) - surfaced in the import summary.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022warnings\u0022 )] public List\u003Cstring\u003E Warnings { get; set; } = new();\r\n}\r\n\r\n/// \u003Csummary\u003E\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/// \u003C/summary\u003E\r\npublic class ManifestPlacement\r\n{\r\n\t[JsonPropertyName( \u0022mesh\u0022 )] public string Mesh { get; set; }\r\n\t[JsonPropertyName( \u0022name\u0022 )] public string Name { get; set; }\r\n\t[JsonPropertyName( \u0022pos\u0022 )] public float[] Pos { get; set; }\r\n\t[JsonPropertyName( \u0022rot\u0022 )] public float[] Rot { get; set; }\r\n\t[JsonPropertyName( \u0022scale\u0022 )] public float[] Scale { get; set; }\r\n}\r\n\r\npublic class ManifestLight\r\n{\r\n\t/// \u003Csummary\u003E\u0022point\u0022, \u0022spot\u0022 or \u0022directional\u0022.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022type\u0022 )] public string Type { get; set; }\r\n\t[JsonPropertyName( \u0022name\u0022 )] public string Name { get; set; }\r\n\t[JsonPropertyName( \u0022pos\u0022 )] public float[] Pos { get; set; }\r\n\t[JsonPropertyName( \u0022rot\u0022 )] public float[] Rot { get; set; }\r\n\t[JsonPropertyName( \u0022scale\u0022 )] public float[] Scale { get; set; }\r\n\t[JsonPropertyName( \u0022color\u0022 )] public float[] Color { get; set; }\r\n\r\n\t/// \u003Csummary\u003ERaw Unreal intensity - unit depends on \u003Csee cref=\u0022Units\u0022/\u003E (lux for directional).\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022intensity\u0022 )] public float? Intensity { get; set; }\r\n\r\n\t/// \u003Csummary\u003EUnreal ELightUnits name: CANDELAS / LUMENS / UNITLESS / EV. Debug info - use Candela.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022units\u0022 )] public string Units { get; set; }\r\n\r\n\t/// \u003Csummary\u003ELuminous intensity in candela, converted from Intensity\u002BUnits by the exporter. Point/spot only.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022candela\u0022 )] public float? Candela { get; set; }\r\n\r\n\t/// \u003Csummary\u003EAttenuation radius in centimetres.\u003C/summary\u003E\r\n\t[JsonPropertyName( \u0022radius\u0022 )] public float? Radius { get; set; }\r\n\t[JsonPropertyName( \u0022inner_cone\u0022 )] public float? InnerCone { get; set; }\r\n\t[JsonPropertyName( \u0022outer_cone\u0022 )] 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/// \u003Csummary\u003E\n/// Turns a manifest \u0022scene\u0022 section (raw Unreal placements \u002B lights) into an s\u0026amp;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 -\u003E 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\u0027s exporter negates Y and Source 2\u0027s importer\n/// rotates 90\u00B0, so an imported mesh\u0027s local axes are UE\u0027s 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\u0027 = 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/// \u003C/summary\u003E\npublic static class ScenePrefabBuilder\n{\n\tconst float UeToInch = 1f / 2.54f;\n\n\t/// \u003Csummary\u003E\n\t/// Write \u0026lt;scene name\u0026gt;.prefab under outputRoot. modelsByGamePath maps the manifest\u0027s\n\t/// /Game mesh paths to imported vmdl content paths; mirroredByGamePath the variants for\n\t/// mirrored (odd-negative-scale) placements. Returns the prefab\u0027s absolute path.\n\t/// \u003C/summary\u003E\n\tpublic static string Build( ManifestScene scene, IReadOnlyDictionary\u003Cstring, string\u003E modelsByGamePath, string outputRoot, List\u003Cstring\u003E warnings,\n\t\tIReadOnlyDictionary\u003Cstring, string\u003E mirroredByGamePath = null, float lightScale = 1f )\n\t{\n\t\tvar children = new JsonArray();\n\t\tvar missingMeshes = new HashSet\u003Cstring\u003E();\n\t\tvar missingMirrors = new HashSet\u003Cstring\u003E();\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: \u003E= 3 } \u0026\u0026 NegativeCount( p.Scale ) % 2 == 1 )\n\t\t\t{\n\t\t\t\tif ( mirroredByGamePath is not null \u0026\u0026 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[\u0022Components\u0022] = new JsonArray( ComponentNode( \u0022Sandbox.ModelRenderer\u0022, new()\n\t\t\t{\n\t\t\t\t[\u0022Model\u0022] = vmdl,\n\t\t\t\t[\u0022Tint\u0022] = \u00221,1,1,1\u0022,\n\t\t\t\t[\u0022RenderType\u0022] = \u0022On\u0022,\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( $\u0022scene: no mirrored model for {m}, its flipped placements will render inside-out.\u0022 );\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( $\u0022scene: no imported model for {m}, its placements were skipped.\u0022 );\n\n\t\tvar root = GameObjectNode( Sanitize( scene.Name ), null, null, null );\n\t\troot[\u0022Children\u0022] = children;\n\n\t\tvar prefab = new JsonObject\n\t\t{\n\t\t\t[\u0022RootObject\u0022] = root,\n\t\t\t[\u0022ResourceVersion\u0022] = 2,\n\t\t\t[\u0022ShowInMenu\u0022] = false,\n\t\t\t[\u0022MenuPath\u0022] = null,\n\t\t\t[\u0022MenuIcon\u0022] = null,\n\t\t\t[\u0022DontBreakAsTemplate\u0022] = false,\n\t\t\t[\u0022__references\u0022] = new JsonArray(),\n\t\t\t[\u0022__version\u0022] = 2,\n\t\t};\n\n\t\tvar path = Path.Combine( outputRoot, Sanitize( scene.Name ) \u002B \u0022.prefab\u0022 );\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 \u0026\u0026 scale.Length \u003E= 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[\u0022__guid\u0022] = Guid.NewGuid().ToString(),\n\t\t\t[\u0022__version\u0022] = 2,\n\t\t\t[\u0022Flags\u0022] = 0,\n\t\t\t[\u0022Name\u0022] = string.IsNullOrEmpty( name ) ? \u0022unnamed\u0022 : name,\n\t\t\t[\u0022Position\u0022] = Vec3( ConvertPosition( uePos ) ),\n\t\t\t[\u0022Rotation\u0022] = Quat( rot ),\n\t\t\t[\u0022Scale\u0022] = Vec3( scale ),\n\t\t\t[\u0022Enabled\u0022] = true,\n\t\t};\n\t}\n\n\tstatic int NegativeCount( float[] s ) =\u003E (s[0] \u003C 0 ? 1 : 0) \u002B (s[1] \u003C 0 ? 1 : 0) \u002B (s[2] \u003C 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/// \u003Csummary\u003E\n\t/// s\u0026amp;box doesn\u0027t 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\u0027s 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 -\u003E Q = identity; negative y -\u003E\n\t/// rot180_z; negative z -\u003E rot180_y; all three -\u003E rot180_x. Callers pick the mirrored\n\t/// model for odd counts.\n\t/// \u003C/summary\u003E\n\tstatic (float[] rot, float[] scale) ResolveNegativeScale( float[] rot, float[] scale )\n\t{\n\t\tif ( scale.Length \u003C 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 =\u003E v \u003E= 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 (-,\u002B,\u002B) is the model itself; (\u002B,-,\u002B) needs\n\t\t\t// rot180 about Z on top of it; (\u002B,\u002B,-) rot180 about Y.\n\t\t\tint neg = Array.FindIndex( scale, v =\u003E v \u003C 0 );\n\t\t\taxis = neg switch { 1 =\u003E 2, 2 =\u003E 1, _ =\u003E -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 \u003E= 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[\u0022__type\u0022] = type,\n\t\t\t[\u0022__guid\u0022] = Guid.NewGuid().ToString(),\n\t\t\t[\u0022__enabled\u0022] = true,\n\t\t\t[\u0022Flags\u0022] = 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\u0022point\u0022 =\u003E (\u0022Sandbox.PointLight\u0022, new JsonObject\n\t\t\t{\n\t\t\t\t[\u0022LightColor\u0022] = ColorStr( l, lightScale ),\n\t\t\t\t[\u0022Radius\u0022] = Round( (l.Radius ?? 1000f) * UeToInch ),\n\t\t\t}),\n\t\t\t\u0022spot\u0022 =\u003E (\u0022Sandbox.SpotLight\u0022, new JsonObject\n\t\t\t{\n\t\t\t\t[\u0022LightColor\u0022] = ColorStr( l, lightScale ),\n\t\t\t\t[\u0022Radius\u0022] = Round( (l.Radius ?? 1000f) * UeToInch ),\n\t\t\t\t[\u0022ConeInner\u0022] = Round( l.InnerCone ?? 30f ),\n\t\t\t\t[\u0022ConeOuter\u0022] = Round( l.OuterCone ?? 45f ),\n\t\t\t}),\n\t\t\t\u0022directional\u0022 =\u003E (\u0022Sandbox.DirectionalLight\u0022, new JsonObject\n\t\t\t{\n\t\t\t\t[\u0022LightColor\u0022] = ColorStr( l, lightScale ),\n\t\t\t\t[\u0022Shadows\u0022] = true,\n\t\t\t}),\n\t\t\t_ =\u003E (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[\u0022Components\u0022] = 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 \u003C 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\u003C-\u003EY 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 \u003C 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/// \u003Csummary\u003EHamilton product a*b (xyzw): rotation b in local space followed by a.\u003C/summary\u003E\n\tstatic float[] MulQuat( float[] a, float[] b )\n\t{\n\t\treturn new[]\n\t\t{\n\t\t\ta[3] * b[0] \u002B a[0] * b[3] \u002B a[1] * b[2] - a[2] * b[1],\n\t\t\ta[3] * b[1] - a[0] * b[2] \u002B a[1] * b[3] \u002B a[2] * b[0],\n\t\t\ta[3] * b[2] \u002B a[0] * b[1] - a[1] * b[0] \u002B 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/// \u003Csummary\u003E\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\u0026amp;box doesn\u0027t - mapping generously blows every surface to\n\t/// white. Hand-placed lights in this project sit at ~0.35-2 HDR magnitude.\n\t/// \u003C/summary\u003E\n\tconst float RefCandela = 1000f;\n\n\t/// \u003Csummary\u003E\n\t/// Legacy UNITLESS lights aren\u0027t physical: UE4-scale authoring puts a strong lamp\n\t/// around 1000-5000. Converting them through UE\u0027s official unitless-\u0026gt;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/// \u003C/summary\u003E\n\tconst float RefUnitless = 5000f;\n\n\t/// \u003Csummary\u003E\n\t/// s\u0026amp;box lights carry brightness in LightColor\u0027s 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/// \u003C/summary\u003E\n\tstatic string ColorStr( ManifestLight l, float lightScale )\n\t{\n\t\tvar c = l.Color is { Length: \u003E= 3 } ? l.Color : new float[] { 1, 1, 1 };\n\n\t\tfloat brightness;\n\t\tif ( l.Type == \u0022directional\u0022 \u0026\u0026 l.Intensity is \u003E 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( \u0022UNITLESS\u0022 ) == true \u0026\u0026 l.Intensity is \u003E 0 )\n\t\t\tbrightness = Math.Clamp( MathF.Sqrt( l.Intensity.Value / RefUnitless ), 0.05f, 2f );\n\t\telse if ( l.Candela is \u003E 0 )\n\t\t\tbrightness = Math.Clamp( MathF.Sqrt( l.Candela.Value / RefCandela ), 0.05f, 2f );\n\t\telse if ( l.Intensity is \u003E 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 $\u0022{F( c[0] * brightness )},{F( c[1] * brightness )},{F( c[2] * brightness )},1\u0022;\n\t}\n\n\tstatic string Vec3( float[] v ) =\u003E $\u0022{F( v[0] )},{F( v[1] )},{F( v[2] )}\u0022;\n\tstatic string Quat( float[] q ) =\u003E $\u0022{F( q[0] )},{F( q[1] )},{F( q[2] )},{F( q[3] )}\u0022;\n\tstatic float Round( float v ) =\u003E (float)Math.Round( v, 3 );\n\tstatic string F( float v ) =\u003E v.ToString( \u00220.######\u0022, CultureInfo.InvariantCulture );\n\n\tstatic string Sanitize( string s )\n\t{\n\t\tif ( string.IsNullOrEmpty( s ) )\n\t\t\treturn \u0022unnamed_scene\u0022;\n\n\t\tvar chars = s.ToLowerInvariant().ToCharArray();\n\t\tfor ( int i = 0; i \u003C chars.Length; i\u002B\u002B )\n\t\t{\n\t\t\tvar ch = chars[i];\n\t\t\tif ( ch is not ((\u003E= \u0027a\u0027 and \u003C= \u0027z\u0027) or (\u003E= \u00270\u0027 and \u003C= \u00279\u0027) or \u0027_\u0027) )\n\t\t\t\tchars[i] = \u0027_\u0027;\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/// \u003Csummary\u003ESource file stamp this was read from - stale entries re-parse.\u003C/summary\u003E\n\tpublic long Mtime { get; set; }\n\tpublic long Size { get; set; }\n}\n\n/// \u003Csummary\u003E\n/// Reads triangle/vertex counts straight out of an uncooked StaticMesh .uasset, no Unreal\n/// involved. The Unreal editor bakes asset-registry tags (\u0022Triangles\u0022, \u0022Vertices\u0022,\n/// \u0022Materials\u0022, \u0022LODs\u0022, ...) 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\u002Bsize)\n/// because a full pack means gigabytes of .uasset reads otherwise.\n/// \u003C/summary\u003E\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\u003Cstring, MeshStats\u003E cache = new( StringComparer.OrdinalIgnoreCase );\n\tstatic readonly ConcurrentDictionary\u003Cstring, Task\u003CMeshStats\u003E\u003E inFlight = new( StringComparer.OrdinalIgnoreCase );\n\tstatic readonly SemaphoreSlim ioGate = new( 2 );   // don\u0027t hammer the disk when a folder expands\n\n\tstatic bool diskCacheLoaded;\n\tstatic int saveScheduled;\n\n\tstatic string CacheFile =\u003E Sandbox.Project.Current is not null\n\t\t? Path.Combine( Sandbox.Project.Current.GetRootPath(), \u0022.sbox\u0022, \u0022unrealimporter\u0022, \u0022meshstats.json\u0022 )\n\t\t: Path.Combine( Path.GetTempPath(), \u0022unrealimporter\u0022, \u0022meshstats.json\u0022 );\n\n\t/// \u003Csummary\u003EMemory/disk cache lookup, no file IO on the asset itself.\u003C/summary\u003E\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 \u0026\u0026 fi.LastWriteTimeUtc.Ticks == stats.Mtime \u0026\u0026 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/// \u003Csummary\u003EParse (or fetch cached) stats for one .uasset. Null when nothing was found.\u003C/summary\u003E\n\tpublic static Task\u003CMeshStats\u003E 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 =\u003E Task.Run( async () =\u003E\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, \u0022Triangles\u0022 );\n\n\t\t// Rare: huge header tables push the tag block past our head read.\n\t\tif ( tris \u003C 0 \u0026\u0026 fi.Length \u003E data.Length )\n\t\t{\n\t\t\tdata = File.ReadAllBytes( absPath );\n\t\t\ttris = TagValue( data, \u0022Triangles\u0022 );\n\t\t}\n\n\t\tif ( tris \u003C 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, \u0022Vertices\u0022 ),\n\t\t\tMaterials = TagValue( data, \u0022Materials\u0022 ),\n\t\t\tLODs = TagValue( data, \u0022LODs\u0022 ),\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/// \u003Csummary\u003E\n\t/// Find asset-registry tag \u003Cparamref name=\u0022key\u0022/\u003E and return its numeric value, -1 when\n\t/// absent. Matches the FString serialization (length prefix \u002B NUL) so plain-text\n\t/// occurrences of the word elsewhere can\u0027t false-positive.\n\t/// \u003C/summary\u003E\n\tstatic long TagValue( ReadOnlySpan\u003Cbyte\u003E data, string key )\n\t{\n\t\tSpan\u003Cbyte\u003E pattern = stackalloc byte[4 \u002B key.Length \u002B 1];\n\t\tBitConverter.TryWriteBytes( pattern, key.Length \u002B 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 \u003C 0 )\n\t\t\treturn -1;\n\n\t\tvar vpos = at \u002B pattern.Length;\n\t\tif ( vpos \u002B 4 \u003E data.Length )\n\t\t\treturn -1;\n\n\t\tint vlen = BitConverter.ToInt32( data[vpos..] );\n\t\tif ( vlen \u003C= 1 || vlen \u003E 64 || vpos \u002B 4 \u002B vlen \u003E data.Length )\n\t\t\treturn -1;\n\n\t\tvar s = Encoding.ASCII.GetString( data.Slice( vpos \u002B 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\u003CConcurrentDictionary\u003Cstring, MeshStats\u003E\u003E( 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 () =\u003E\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/// \u003Csummary\u003E\r\n/// Locates a .uproject\u0027s engine and the UnrealEditor-Cmd.exe used to run the headless export.\r\n/// \u003C/summary\u003E\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, \u0022*.uproject\u0022, SearchOption.TopDirectoryOnly ).FirstOrDefault();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EReads \u0022EngineAssociation\u0022 (e.g. \u00225.5\u0022) from a .uproject. May be null.\u003C/summary\u003E\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( \u0022EngineAssociation\u0022, 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/// \u003Csummary\u003E\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\u0027t installed, prefers the CLOSEST NEWER engine\r\n\t/// (a newer engine opens older assets; an older one can\u0027t read newer assets),\r\n\t/// falling back to the highest older install.\r\n\t/// \u003C/summary\u003E\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( \u0022ProgramW6432\u0022 ),\r\n\t\t\tEnvironment.GetEnvironmentVariable( \u0022ProgramFiles\u0022 ),\r\n\t\t}.Where( x =\u003E !string.IsNullOrEmpty( x ) ).Distinct();\r\n\r\n\t\tVersion.TryParse( engineVersion ?? \u0022\u0022, 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, \u0022Epic Games\u0022 );\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, $\u0022UE_{engineVersion}\u0022 ) );\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, \u0022UE_*\u0022 )\r\n\t\t\t\t.Where( d =\u003E File.Exists( CmdPath( d ) ) )\r\n\t\t\t\t.Select( d =\u003E (dir: d, ver: Version.TryParse( Path.GetFileName( d )[\u0022UE_\u0022.Length..], out var v ) ? v : null) )\r\n\t\t\t\t.Where( x =\u003E 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 =\u003E x.ver \u003E= wanted ).OrderBy( x =\u003E x.ver ).FirstOrDefault().dir\r\n\t\t\t\t\t?? installed.OrderByDescending( x =\u003E x.ver ).First().dir\r\n\t\t\t\t: installed.OrderByDescending( x =\u003E 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=\u003E Path.Combine( engineRoot, \u0022Engine\u0022, \u0022Binaries\u0022, \u0022Win64\u0022, \u0022UnrealEditor-Cmd.exe\u0022 );\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$@\u0022SOFTWARE\\EpicGames\\Unreal Engine\\{engineVersion}\u0022 );\r\n\r\n\t\t\tif ( key?.GetValue( \u0022InstalledDirectory\u0022 ) is string dir \u0026\u0026 !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\u003Cstring\u003E Warnings = new();\r\n\r\n\t/// \u003Csummary\u003EScene mode: placements in the generated prefab, and where it was written.\u003C/summary\u003E\r\n\tpublic int Placements;\r\n\tpublic string PrefabPath;\r\n}\r\n\r\n/// \u003Csummary\u003EHow generated assets are laid out on disk.\u003C/summary\u003E\r\npublic enum ImportLayout\r\n{\r\n\t/// \u003Csummary\u003E\u0026lt;output\u0026gt;/models, /materials, /textures.\u003C/summary\u003E\r\n\tGrouped,\r\n\r\n\t/// \u003Csummary\u003EEverything directly in \u0026lt;output\u0026gt;.\u003C/summary\u003E\r\n\tFlat,\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// One self-contained folder per imported asset: \u0026lt;output\u0026gt;/\u0026lt;asset\u0026gt;/ holds its\r\n\t/// model, materials and textures together. Shared materials are duplicated into each\r\n\t/// asset\u0027s folder - that\u0027s the point, each folder can be moved or deleted on its own.\r\n\t/// \u003C/summary\u003E\r\n\tPerAsset,\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Classic Source style: Assets/models/\u0026lt;sub\u0026gt; for fbx\u002Bvmdl, Assets/materials/\u0026lt;sub\u0026gt; for\r\n\t/// vmat\u002Btextures, Assets/prefabs/\u0026lt;sub\u0026gt; for map prefabs. Ignores the picked output folder.\r\n\t/// \u003C/summary\u003E\r\n\tClassicSource,\r\n}\r\n\r\n/// \u003Csummary\u003E\r\n/// What a material picked on its own turns into. Materials on a MESH are always .vmat -\r\n/// a model\u0027s material slots can\u0027t reference a terrain or decal resource.\r\n/// \u003C/summary\u003E\r\npublic enum MaterialOutput\r\n{\r\n\t/// \u003Csummary\u003EA complex.shader .vmat (the default).\u003C/summary\u003E\r\n\tMaterial,\r\n\r\n\t/// \u003Csummary\u003EA .tmat Terrain Material - for tiling ground surfaces.\u003C/summary\u003E\r\n\tTerrain,\r\n\r\n\t/// \u003Csummary\u003EA .decal Decal Definition - projected decals.\u003C/summary\u003E\r\n\tDecal,\r\n}\r\n\r\n/// \u003Csummary\u003EWhere each kind of generated file goes.\u003C/summary\u003E\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/// \u003Csummary\u003EWhat to show the user as \u0022where it went\u0022.\u003C/summary\u003E\r\n\tpublic string Display;\r\n}\r\n\r\n/// \u003Csummary\u003E\r\n/// Consumes a staging folder (FBX \u002B PNG \u002B manifest.json from the headless export) and writes\r\n/// sbox assets (.fbx \u002B .vmat \u002B .vmdl) into the project, ready for the engine to compile.\r\n/// \u003C/summary\u003E\r\npublic static class AssetImporter\r\n{\r\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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, \u0022\u003Casset\u003E\u0022 ),\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 ) =\u003E 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( \u0022models\u0022 );\r\n\t\t\t\tvar materials = Under( \u0022materials\u0022 );\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( \u0022prefabs\u0022 ),\r\n\t\t\t\t\tDisplay = $\u0022{models}\\n{materials}\u0022,\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, \u0022models\u0022 ),\r\n\t\t\t\t\tMaterialsDir = Path.Combine( outputRoot, \u0022materials\u0022 ),\r\n\t\t\t\t\tTexturesDir = Path.Combine( outputRoot, \u0022textures\u0022 ),\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/// \u003Csummary\u003ETrim a user-typed subfolder to a safe relative path (\u0022Props/Barrels\u0022 stays nested).\u003C/summary\u003E\r\n\tstatic string SanitizeSubfolder( string subfolder )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( subfolder ) )\r\n\t\t\treturn \u0022\u0022;\r\n\r\n\t\tvar parts = subfolder.Split( new[] { \u0027/\u0027, \u0027\\\\\u0027 }, StringSplitOptions.RemoveEmptyEntries )\r\n\t\t\t.Select( p =\u003E p.Trim() )\r\n\t\t\t.Where( p =\u003E p.Length \u003E 0 \u0026\u0026 p != \u0022.\u0022 \u0026\u0026 p != \u0022..\u0022 )\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/// \u003Cparam name=\u0022manifest\u0022\u003E\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022stagingDir\u0022\u003E\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022outputRoot\u0022\u003E\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022progressToken\u0022\u003E\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022layout\u0022\u003EHow the generated files are foldered - see \u003Csee cref=\u0022ImportLayout\u0022/\u003E.\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022subfolder\u0022\u003ESubfolder under Assets/models \u002B Assets/materials, ClassicSource layout only.\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022onProgress\u0022\u003E(done, total, current asset name) per imported model.\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022generateLods\u0022\u003EWhen false, models get no auto-LOD chain (full detail always).\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022lightScale\u0022\u003EExtra multiplier on converted scene-light brightness (1 = calibrated default).\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022materialOutput\u0022\u003EWhat standalone materials become - vmat, tmat or decal. Mesh slots are always vmat.\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022perAssetFolderDepth\u0022\u003E\r\n\t/// PerAsset layout only: how many folders up the /Game path to name each asset\u0027s folder after.\r\n\t/// 0 = the asset\u0027s 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/// \u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022maxTextureSize\u0022\u003ECap every written texture\u0027s longest edge, downscaling bigger sources (0 = keep as-is).\u003C/param\u003E\r\n\tpublic static async Task\u003CImportSummary\u003E Import( ImportManifest manifest, string stagingDir, string outputRoot, CancellationToken progressToken, ImportLayout layout = ImportLayout.Grouped, string subfolder = null, Action\u003Cint, int, string\u003E 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( \u0022Could not resolve the project\u0027s Assets folder. Pick an output folder inside Assets/.\u0022 );\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\u0027s own name is\r\n\t\t// unhelpful - Fab MIs are named like \u0022mi_sjfnbeaa\u0022.\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\u0027ve 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\u0027s folder, so the name alone would wrongly dedupe it away.\r\n\t\tvar writtenVmats = new Dictionary\u003Cstring, string\u003E();   // \u0022\u003Cdir\u003E|\u003Cbase\u003E\u0022 -\u003E vmat content path\r\n\t\tvar modelsByGamePath = new Dictionary\u003Cstring, string\u003E();   // /Game path -\u003E vmdl content path\r\n\t\tvar mirroredByGamePath = new Dictionary\u003Cstring, string\u003E(); // /Game path -\u003E 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\u0026box doesn\u0027t flip winding for negative GameObject scale, so those need a\r\n\t\t// mirrored model variant (negative vmdl import_scale bakes the mirror \u002B winding).\r\n\t\t// Progress spans meshes then standalone materials as one run.\r\n\t\tvar totalAssets = manifest.Assets.Count \u002B (manifest.Materials?.Count ?? 0);\r\n\r\n\t\tvar needsMirror = new HashSet\u003Cstring\u003E();\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 \u0026\u0026 p.Scale is { Length: \u003E= 3 } \u0026\u0026 p.Scale.Count( v =\u003E v \u003C 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 \u003C manifest.Assets.Count; i\u002B\u002B )\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\u0027s 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 \u002B 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( $\u0022{asset.Asset}: no fbx in manifest, skipped.\u0022 );\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( \u0027/\u0027, Path.DirectorySeparatorChar ) );\r\n\t\t\tif ( !File.Exists( fbxSrc ) )\r\n\t\t\t{\r\n\t\t\t\tsummary.Warnings.Add( $\u0022{asset.Asset}: fbx missing at {fbxSrc}, skipped.\u0022 );\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 \u002B \u0022.fbx\u0022 );\r\n\t\t\tFile.Copy( fbxSrc, fbxDst, overwrite: true );\r\n\r\n\t\t\t// Build per-slot remaps, writing vmats \u002B textures as needed.\r\n\t\t\tvar remaps = new List\u003C(string slot, string vmat)\u003E();\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 \u002B 1, totalAssets, $\u0022{asset.Asset} - {baseName}\u0022 );\r\n\t\t\t\tawait Task.Delay( 1, progressToken );\r\n\r\n\t\t\t\t// s\u0026box reads the FBX material *node* name, which Unreal writes as the assigned\r\n\t\t\t\t// material (the MI, e.g. \u0022MI_OilBarrel_01a\u0022) - NOT the DCC slot label (\u0022lambert2\u0022,\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\u0027s 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 \u002B MI_Escalator_01a_3). Those suffixed nodes need\r\n\t\t\t// remaps too, or the engine hunts for a literal \u0022mi_escalator_01a_3.vmat\u0022.\r\n\t\t\tremaps.AddRange( SuffixedRemaps( fbxDst, remaps ) );\r\n\r\n\t\t\tonProgress?.Invoke( i \u002B 1, totalAssets, $\u0022{asset.Asset} - compiling model\u0022 );\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 -\u003E \u0022Inconsistent hull geometry\u0022), 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 \u002B \u0022.vmdl\u0022 );\r\n\t\t\tvar scale = asset.ImportScale \u003C= 0 ? 0.3937f : asset.ImportScale;\r\n\t\t\tstring usedHullMode = null;\r\n\r\n\t\t\tforeach ( var hullMode in new[] { \u0022HullPerElement\u0022, \u0022SingleHull\u0022, 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\u0027t verify here - leave the default and let the engine compile later\r\n\r\n\t\t\t\tif ( vmdlAsset.Compile( full: false ) \u0026\u0026 !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( $\u0022{asset.Asset}: model failed to compile even without collision - see console.\u0022 );\r\n\t\t\t\telse\r\n\t\t\t\t\tsummary.Warnings.Add( $\u0022{asset.Asset}: collision \u0027{hullMode}\u0027 failed to compile, falling back to {(hullMode == \u0022HullPerElement\u0022 ? \u0022SingleHull\u0022 : \u0022no collision\u0022)}.\u0022 );\r\n\t\t\t}\r\n\r\n\t\t\tsummary.Models\u002B\u002B;\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 \u0026\u0026 needsMirror.Contains( asset.GamePath ) )\r\n\t\t\t{\r\n\t\t\t\tvar mirrorPath = Path.Combine( modelsDir, modelName \u002B \u0022_mirror.vmdl\u0022 );\r\n\t\t\t\tawait File.WriteAllTextAsync( mirrorPath, Kv3Writer.VmdlText( fbxContent, scale, remaps, usedHullMode ?? \u0022HullPerElement\u0022, 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 \u0026\u0026 (!mirrorAsset.Compile( full: false ) || mirrorAsset.IsCompileFailed) )\r\n\t\t\t\t\tsummary.Warnings.Add( $\u0022{asset.Asset}: mirrored variant failed to compile - mirrored placements will use the unmirrored model.\u0022 );\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( $\u0022[{i \u002B 1}/{manifest.Assets.Count}] Imported {asset.Asset} -\u003E {vmdlPath}\u0022 \u002B\r\n\t\t\t\t(usedHullMode != \u0022HullPerElement\u0022 ? $\u0022 (collision: {usedHullMode ?? \u0022none\u0022})\u0022 : \u0022\u0022) );\r\n\t\t}\r\n\r\n\t\t// A model\u0027s 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 \u0026\u0026 manifest.Assets.Count \u003E 0 )\r\n\t\t\tsummary.Warnings.Add( $\u0022Material output \u0027{materialOutput}\u0027 applies to materials imported on their own; the {manifest.Assets.Count} mesh(es) still got .vmat materials.\u0022 );\r\n\r\n\t\t// Materials picked on their own: no mesh, just a vmat \u002B 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 ?? \u0022material\u0022;\r\n\t\t\tonProgress?.Invoke( manifest.Assets.Count \u002B manifest.Materials.IndexOf( mat ) \u002B 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\u0027t 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 \u002B \u0022.vmat\u0022 ) );\r\n\t\t\t}\r\n\r\n\t\t\tLog.Info( $\u0022Imported material {name} -\u003E {written}\u0022 );\r\n\t\t}\r\n\r\n\t\t// Scene mode: turn the level\u0027s placements \u002B 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: \u003E 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/// \u003Csummary\u003E\r\n\t/// Write (or reuse) the .vmat for one material, processing its textures on the way.\r\n\t/// Returns the vmat\u0027s content path. Shared by mesh slots and standalone material imports.\r\n\t/// \u003C/summary\u003E\r\n\tstatic async Task\u003Cstring\u003E WriteVmat( ManifestMaterial mat, string baseName, string stagingDir, string assetsDir,\r\n\t\tstring materialsDir, string texturesDir, Dictionary\u003Cstring, string\u003E writtenVmats, ImportSummary summary, CancellationToken token, int maxTextureSize = 0 )\r\n\t{\r\n\t\tvar cacheKey = $\u0022{materialsDir}|{baseName}\u0022;\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 \u002B= 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\u0027s own colours show through.\r\n\t\t\t// The mask \u002B 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( \u0022MASKED\u0022 ) == 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 \u0026\u0026 tex.SelfIllumMask is not null );\r\n\r\n\t\tvar vmatPath = Path.Combine( materialsDir, baseName \u002B \u0022.vmat\u0022 );\r\n\t\tawait File.WriteAllTextAsync( vmatPath, vmatText, token );\r\n\t\tsummary.Materials\u002B\u002B;\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( $\u0022{baseName}: has a displacement/height map, which complex.shader can\u0027t use - ignored.\u0022 );\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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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 \u002B= CountTextures( tex );\r\n\r\n\t\tvar path = Path.Combine( materialsDir, baseName \u002B \u0022.tmat\u0022 );\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( $\u0022{baseName}: failed to create the terrain material - see console.\u0022 );\r\n\t\t\treturn ToContentPath( assetsDir, path );\r\n\t\t}\r\n\r\n\t\tsummary.Materials\u002B\u002B;\r\n\r\n\t\tif ( tex.Metallic is not null )\r\n\t\t\tsummary.Warnings.Add( $\u0022{baseName}: terrain materials carry metalness as a single value, not a texture - the metallic map was not used.\u0022 );\r\n\t\tif ( tex.Height is null )\r\n\t\t\tsummary.Warnings.Add( $\u0022{baseName}: no height/displacement map found - terrain height blending will be flat.\u0022 );\r\n\r\n\t\treturn asset.Path;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\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\u0027s alpha - so an opaque source\r\n\t/// material makes a decal that covers its whole quad.\r\n\t/// \u003C/summary\u003E\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\u0027s 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 \u002B= CountTextures( tex );\r\n\r\n\t\tvar path = Path.Combine( materialsDir, baseName \u002B \u0022.decal\u0022 );\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( $\u0022{baseName}: failed to create the decal - see console.\u0022 );\r\n\t\t\treturn ToContentPath( assetsDir, path );\r\n\t\t}\r\n\r\n\t\tsummary.Materials\u002B\u002B;\r\n\r\n\t\tif ( tex.Color is null )\r\n\t\t\tsummary.Warnings.Add( $\u0022{baseName}: decal has no colour texture - its alpha is what masks a decal, so this one won\u0027t show.\u0022 );\r\n\r\n\t\treturn asset.Path;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\r\n\tstatic List\u003C(string slot, string vmat)\u003E SuffixedRemaps( string fbxPath, IReadOnlyList\u003C(string slot, string vmat)\u003E remaps )\r\n\t{\r\n\t\tvar extra = new List\u003C(string, string)\u003E();\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 =\u003E 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 ) \u002B @\u0022_\\d\u002B\u0022 ) )\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/// \u003Csummary\u003E\r\n\t/// What the albedo\u0027s alpha channel means, from the Unreal blend mode. Opaque materials\u0027\r\n\t/// alpha is NOT opacity - with emissive params present it\u0027s a self-illum mask (lamp\r\n\t/// housings etc.), otherwise it packs something we can\u0027t interpret and is ignored.\r\n\t/// Old manifests without blend_mode keep the legacy translucency behaviour.\r\n\t/// \u003C/summary\u003E\r\n\tstatic AlphaRole AlphaRoleFor( ManifestMaterial mat, bool hasEmissiveParams )\r\n\t{\r\n\t\tvar blend = mat.BlendMode ?? \u0022\u0022;\r\n\t\tif ( blend.Length == 0 || blend.Contains( \u0022TRANSLUCENT\u0022 ) || blend.Contains( \u0022MASKED\u0022 )\r\n\t\t\t|| blend.Contains( \u0022ADDITIVE\u0022 ) || blend.Contains( \u0022MODULATE\u0022 ) )\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/// \u003Csummary\u003E\r\n\t/// Emissive tint (Unreal LINEAR) \u002B linear brightness multiplier from the Material\r\n\t/// Instance\u0027s parameter overrides (\u0022Emissive Multiply\u0022, \u0022Emissive Color Multi\u0022, ...).\r\n\t/// Null when the material has no emissive-ish parameter.\r\n\t/// \u003C/summary\u003E\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( \u0022emissiv\u0022, StringComparison.OrdinalIgnoreCase ) || kv.Value is not { Length: \u003E= 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 \u003E 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( \u0022emissiv\u0022, StringComparison.OrdinalIgnoreCase ) \u0026\u0026 kv.Value \u003E 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/// \u003Csummary\u003EHuman-readable note of the tint colours Unreal had, so they can be wired up by hand.\u003C/summary\u003E\r\n\tstatic string TintComment( ManifestMaterial mat )\r\n\t{\r\n\t\tvar parts = new List\u003Cstring\u003E();\r\n\t\tif ( mat.TintColor is not null )\r\n\t\t\tparts.Add( $\u0022tint=[{FmtColor( mat.TintColor )}]\u0022 );\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( $\u0022{kv.Key}=[{FmtColor( kv.Value )}]\u0022 );\r\n\r\n\t\treturn parts.Count == 0 ? null : \u0022Captured Unreal tint (NOT auto-applied; set g_vColorTint to use): \u0022 \u002B string.Join( \u0022, \u0022, 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 \u0022\u0022;\r\n\r\n\t\tvar sb = new StringBuilder();\r\n\t\tfor ( int i = 0; i \u003C c.Length; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tif ( i \u003E 0 ) sb.Append( \u0027 \u0027 );\r\n\t\t\tsb.Append( c[i].ToString( \u00220.###\u0022, 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\u002B\u002B;\r\n\t\tif ( t.Alpha != null ) n\u002B\u002B;\r\n\t\tif ( t.Normal != null ) n\u002B\u002B;\r\n\t\tif ( t.Roughness != null ) n\u002B\u002B;\r\n\t\tif ( t.Metallic != null ) n\u002B\u002B;\r\n\t\tif ( t.Ao != null ) n\u002B\u002B;\r\n\t\tif ( t.Emissive != null ) n\u002B\u002B;\r\n\t\tif ( t.TintMask != null ) n\u002B\u002B;\r\n\t\tif ( t.SelfIllumMask != null ) n\u002B\u002B;\r\n\t\tif ( t.Height != null ) n\u002B\u002B;\r\n\t\tif ( t.RoughMetalOcclusion != null ) n\u002B\u002B;\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/// \u003Csummary\u003EPath relative to the Assets folder, forward slashes, lowercase.\u003C/summary\u003E\r\n\tstatic string ToContentPath( string assetsDir, string absPath )\r\n\t\t=\u003E Path.GetRelativePath( assetsDir, absPath ).Replace( \u0027\\\\\u0027, \u0027/\u0027 ).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, \u0022Assets\u0022, 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/// \u003Csummary\u003EBase name (lowercase, dot-free) for a material\u0027s textures \u002B vmat, from the MI name when available.\u003C/summary\u003E\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[] { \u0022_ALB\u0022, \u0022_ALBEDO\u0022, \u0022_BASECOLOR\u0022, \u0022_COLOR\u0022, \u0022_NRM\u0022, \u0022_NORMAL\u0022, \u0022_RMA\u0022, \u0022_ORM\u0022 } )\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 ?? \u0022material\u0022 );\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\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 \u0022/Game\u0022 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/// \u003C/summary\u003E\r\n\tstatic string PerAssetFolder( string gamePath, string ownName, int depth )\r\n\t{\r\n\t\tif ( depth \u003C= 0 || string.IsNullOrEmpty( gamePath ) )\r\n\t\t\treturn Sanitize( ownName );\r\n\r\n\t\tvar parts = gamePath.Split( \u0027/\u0027, StringSplitOptions.RemoveEmptyEntries );\r\n\r\n\t\t// Last segment is the asset itself; walk \u0060depth\u0060 folders up from it.\r\n\t\tint idx = parts.Length - 1 - depth;\r\n\r\n\t\t// parts[0] is normally the \u0022Game\u0022 mount - don\u0027t name a folder after it.\r\n\t\tint floor = parts.Length \u003E 1 \u0026\u0026 parts[0].Equals( \u0022Game\u0022, StringComparison.OrdinalIgnoreCase ) ? 1 : 0;\r\n\r\n\t\tif ( idx \u003C floor || idx \u003E= 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/// \u003Csummary\u003ELowercase; non [a-z0-9_] -\u003E \u0027_\u0027. Guarantees no dots in generated filenames.\u003C/summary\u003E\r\n\tstatic string Sanitize( string s )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( s ) )\r\n\t\t\treturn \u0022unnamed\u0022;\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 \u003E= \u0027a\u0027 \u0026\u0026 ch \u003C= \u0027z\u0027) || (ch \u003E= \u00270\u0027 \u0026\u0026 ch \u003C= \u00279\u0027) || ch == \u0027_\u0027 ? ch : \u0027_\u0027 );\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/// \u003Csummary\u003E\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\u0027s .sbox/ folder (keyed on\n/// file mtime\u002Bsize, so re-saved assets re-extract), plus an in-memory Pixmap cache\n/// because the import window rebuilds its list on every keystroke.\n/// \u003C/summary\u003E\npublic static class UassetThumbnail\n{\n\t// path -\u003E pixmap (null = scanned, no thumbnail found)\n\tstatic readonly Dictionary\u003Cstring, Pixmap\u003E memoryCache = new();\n\tstatic readonly Dictionary\u003Cstring, Task\u003CPixmap\u003E\u003E inFlight = new();\n\n\tstatic string CacheDir =\u003E Sandbox.Project.Current is not null\n\t\t? Path.Combine( Sandbox.Project.Current.GetRootPath(), \u0022.sbox\u0022, \u0022unrealimporter\u0022, \u0022thumbnails\u0022 )\n\t\t: Path.Combine( Path.GetTempPath(), \u0022unrealimporter\u0022, \u0022thumbnails\u0022 );\n\n\t/// \u003Csummary\u003EMemory-cache lookup. True if this path has been resolved (pixmap may still be null).\u003C/summary\u003E\n\tpublic static bool TryGetCached( string absPath, out Pixmap pixmap )\n\t\t=\u003E memoryCache.TryGetValue( absPath, out pixmap );\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tpublic static Task\u003CPixmap\u003E 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\u003CPixmap\u003E Load( string absPath )\n\t{\n\t\tstring imagePath = null;\n\t\ttry\n\t\t{\n\t\t\t// File IO \u002B scanning off the main thread; only the Pixmap itself is created back on it.\n\t\t\timagePath = await Task.Run( () =\u003E ResolveCacheFile( absPath ) );\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tLog.Warning( $\u0022Thumbnail extraction failed for {absPath}: {e.Message}\u0022 );\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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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( $\u0022{fi.LastWriteTimeUtc.Ticks}|{fi.Length}\u0022 );\n\t\tvar dir = CacheDir;\n\t\tvar baseName = Path.Combine( dir, $\u0022{pathHash}_{statHash}\u0022 );\n\n\t\tif ( File.Exists( baseName \u002B \u0022.png\u0022 ) ) return baseName \u002B \u0022.png\u0022;\n\t\tif ( File.Exists( baseName \u002B \u0022.jpg\u0022 ) ) return baseName \u002B \u0022.jpg\u0022;\n\t\tif ( File.Exists( baseName \u002B \u0022.none\u0022 ) ) 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 \u002B \u0022_*\u0022 ) )\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 \u002B \u0022.none\u0022, Array.Empty\u003Cbyte\u003E() );\n\t\t\treturn null;\n\t\t}\n\n\t\tvar target = baseName \u002B ext;\n\t\tFile.WriteAllBytes( target, image );\n\t\treturn target;\n\t}\n\n\tstatic string ShortHash( string input )\n\t\t=\u003E 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/// \u003Csummary\u003EFind the embedded thumbnail in raw .uasset bytes, or (null, null).\u003C/summary\u003E\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, \u0022.png\u0022);\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, \u0022.jpg\u0022);\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 )) \u003E= 0 )\n\t\t{\n\t\t\tif ( TrySlice( data, pos, out image ) )\n\t\t\t\treturn true;\n\t\t\tpos \u002B= 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 \u003C data.Length - 4; pos\u002B\u002B )\n\t\t{\n\t\t\tif ( data[pos] != 0xFF || data[pos \u002B 1] != 0xD8 || data[pos \u002B 2] != 0xFF )\n\t\t\t\tcontinue;\n\t\t\tvar seg = data[pos \u002B 3];\n\t\t\tif ( seg != 0xE0 \u0026\u0026 seg != 0xE1 \u0026\u0026 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/// \u003Csummary\u003E\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\u0027t preceded by a sane header.\n\t/// \u003C/summary\u003E\n\tstatic bool TrySlice( byte[] data, int magicPos, out byte[] image )\n\t{\n\t\timage = null;\n\t\tif ( magicPos \u003C 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 \u003C 4 || width \u003E 8192 || height \u003C 4 || height \u003E 8192 )\n\t\t\treturn false;\n\t\tif ( size \u003C 16 || (long)magicPos \u002B size \u003E data.Length )\n\t\t\treturn false;\n\n\t\timage = data[magicPos..(magicPos \u002B 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 \u003C 0 ? -1 : start \u002B 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/// \u003Csummary\u003E\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 \u003Csee cref=\u0022Widget.Layout\u0022/\u003E as usual - the\n/// margins already leave room for the title and border.\n/// \u003C/summary\u003E\npublic class Fieldset : Widget\n{\n\t/// \u003Csummary\u003EHeight reserved for the title row; the border runs through its middle.\u003C/summary\u003E\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 \u002B 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 \u002B= 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 \u002B 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/// \u003Csummary\u003E\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 \u002B .vmat.\r\n/// \u003C/summary\u003E\r\npublic static class Kv3Writer\r\n{\r\n\tstatic string F( float v ) =\u003E v.ToString( \u00220.0######\u0022, CultureInfo.InvariantCulture );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Format an Unreal LINEAR tint color as g_vColorTint\u0027s \u0022[r g b a]\u0022 string.\r\n\t/// g_vColorTint is sRGB-gamma in the shader (it does SrgbGammaToLinear), so we sRGB-encode\r\n\t/// Unreal\u0027s linear value. Null/missing -\u003E white (no tint).\r\n\t/// \u003C/summary\u003E\r\n\tstatic string ColorTint( float[] c )\r\n\t{\r\n\t\tif ( c is null || c.Length \u003C 3 )\r\n\t\t\treturn \u0022[1.000000 1.000000 1.000000 0.000000]\u0022;\r\n\r\n\t\tfloat r = LinearToSrgb( c[0] ), g = LinearToSrgb( c[1] ), b = LinearToSrgb( c[2] );\r\n\t\treturn $\u0022[{r.ToString( \u00220.000000\u0022, CultureInfo.InvariantCulture )} \u0022 \u002B\r\n\t\t\t$\u0022{g.ToString( \u00220.000000\u0022, CultureInfo.InvariantCulture )} \u0022 \u002B\r\n\t\t\t$\u0022{b.ToString( \u00220.000000\u0022, CultureInfo.InvariantCulture )} 0.000000]\u0022;\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 \u003C= 0.0031308f ? c * 12.92f : 1.055f * System.MathF.Pow( c, 1f / 2.4f ) - 0.055f;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EChroma of an HDR color: components divided by the max (null/black -\u003E white).\u003C/summary\u003E\r\n\tstatic float[] Normalized( float[] c )\r\n\t{\r\n\t\tif ( c is null || c.Length \u003C 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 \u003C= 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/// \u003Csummary\u003E\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\u0027s\r\n\t/// pow2 exponent), selfIllumTint an Unreal LINEAR color.\r\n\t/// \u003C/summary\u003E\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( \u0022// THIS FILE IS AUTO-GENERATED (unreal_importer)\u0022 );\r\n\t\tif ( !string.IsNullOrEmpty( tintComment ) )\r\n\t\t\tsb.AppendLine( $\u0022// {tintComment}\u0022 );\r\n\t\tsb.AppendLine();\r\n\t\tsb.AppendLine( \u0022Layer0\u0022 );\r\n\t\tsb.AppendLine( \u0022{\u0022 );\r\n\t\tsb.AppendLine( \u0022\\tshader \\\u0022shaders/complex.shader\\\u0022\u0022 );\r\n\t\tsb.AppendLine();\r\n\t\tsb.AppendLine( \u0022\\t//---- PBR ----\u0022 );\r\n\r\n\t\tif ( !string.IsNullOrEmpty( metallic ) )\r\n\t\t{\r\n\t\t\tsb.AppendLine( \u0022\\tF_METALNESS_TEXTURE 1\u0022 );\r\n\t\t}\r\n\t\t\r\n\t\tsb.AppendLine( \u0022\\tF_SPECULAR 1\u0022 );\r\n\t\t\r\n\t\tif ( !string.IsNullOrEmpty( tintMask ) )\r\n\t\t{\r\n\t\t\tsb.AppendLine( \u0022\\tF_TINT_MASK 1\u0022 );\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( \u0022\\t//---- Alpha ----\u0022 );\r\n\t\t\tif ( alphaTest )\r\n\t\t\t{\r\n\t\t\t\tsb.AppendLine( \u0022\\tF_ALPHA_TEST 1\u0022 );\r\n\t\t\t\tsb.AppendLine( \u0022\\tg_flAlphaTestReference \\\u00220.500\\\u0022\u0022 );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tsb.AppendLine( \u0022\\tF_TRANSLUCENT 1\u0022 );\r\n\t\t\t}\r\n\t\t\tsb.AppendLine( $\u0022\\tTextureTranslucency \\\u0022{alpha}\\\u0022\u0022 );\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( \u0022\\t//---- Self Illum ----\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\tF_SELF_ILLUM 1\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\tTextureSelfIllumMask \\\u0022{selfIllumMask}\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\tg_vSelfIllumTint \\\u0022{ColorTint( tint )}\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\tg_flSelfIllumBrightness \\\u0022{F( Math.Clamp( MathF.Log2( mag ), -10f, 10f ) )}\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\tg_flSelfIllumScale \\\u00221.000\\\u0022\u0022 );\r\n\t\t\t// Grayscale alpha masks carry no colour - let the albedo tint the glow.\r\n\t\t\tsb.AppendLine( $\u0022\\tg_flSelfIllumAlbedoFactor \\\u0022{(selfIllumFromAlbedoAlpha ? \u00221.000\u0022 : \u00220.000\u0022)}\\\u0022\u0022 );\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( \u0022\\t//---- Ambient Occlusion ----\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\tg_flAmbientOcclusionDirectDiffuse \\\u00220.000\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\tg_flAmbientOcclusionDirectSpecular \\\u00220.000\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\tTextureAmbientOcclusion \\\u0022{ao}\\\u0022\u0022 );\r\n\t\t}\r\n\r\n\t\tsb.AppendLine();\r\n\t\tsb.AppendLine( \u0022\\t//---- Color ----\u0022 );\r\n\t\tsb.AppendLine( $\u0022\\tg_flModelTintAmount \\\u0022{F( tintAmount ?? 1.0f )}\\\u0022\u0022 );\r\n\t\tsb.AppendLine( $\u0022\\tg_vColorTint \\\u0022{ColorTint( tintColor )}\\\u0022\u0022 );\r\n\t\tif ( !string.IsNullOrEmpty( color ) )\r\n\t\t\tsb.AppendLine( $\u0022\\tTextureColor \\\u0022{color}\\\u0022\u0022 );\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( \u0022\\t//---- Tint Mask ----\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\tTextureTintMask \\\u0022{tintMask}\\\u0022\u0022 );\r\n\t\t}\r\n\r\n\t\tsb.AppendLine();\r\n\t\tsb.AppendLine( \u0022\\t//---- Fog ----\u0022 );\r\n\t\tsb.AppendLine( \u0022\\tg_bFogEnabled \\\u00221\\\u0022\u0022 );\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( \u0022\\t//---- Metalness ----\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\tTextureMetalness \\\u0022{metallic}\\\u0022\u0022 );\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( \u0022\\t//---- Normal ----\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\tTextureNormal \\\u0022{normal}\\\u0022\u0022 );\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( \u0022\\t//---- Roughness ----\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\tg_flRoughnessScaleFactor \\\u00221.000\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\tTextureRoughness \\\u0022{roughness}\\\u0022\u0022 );\r\n\t\t}\r\n\r\n\t\tsb.AppendLine();\r\n\t\tsb.AppendLine( \u0022\\t//---- Texture Coordinates ----\u0022 );\r\n\t\tsb.AppendLine( \u0022\\tg_vTexCoordOffset \\\u0022[0.000 0.000]\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\tg_vTexCoordScale \\\u0022[1.000 1.000]\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\tg_vTexCoordScrollSpeed \\\u0022[0.000 0.000]\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022}\u0022 );\r\n\r\n\t\treturn sb.ToString();\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\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: \u0022HullPerElement\u0022 (default), \u0022SingleHull\u0022, \u0022HullPerMesh\u0022, 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/// \u003C/summary\u003E\r\n\tpublic static string VmdlText( string fbxContentPath, float importScale, IReadOnlyList\u003C(string slot, string vmat)\u003E remaps, string hullMode = \u0022HullPerElement\u0022, bool mirror = false, bool lods = true )\r\n\t{\r\n\t\tvar sb = new StringBuilder();\r\n\t\tsb.AppendLine( \u0022\u003C!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} --\u003E\u0022 );\r\n\t\tsb.AppendLine( \u0022{\u0022 );\r\n\t\tsb.AppendLine( \u0022\\trootNode =\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t{\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t_class = \\\u0022RootNode\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\tchildren =\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t[\u0022 );\r\n\r\n\t\t// --- Material groups (remaps) ---\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t{\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t_class = \\\u0022MaterialGroupList\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\tchildren =\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t[\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t{\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t_class = \\\u0022DefaultMaterialGroup\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tremaps =\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t[\u0022 );\r\n\t\tforeach ( var (slot, vmat) in remaps )\r\n\t\t{\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t\\t{\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\t\\t\\t\\t\\t\\t\\t\\tfrom = \\\u0022{slot}\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\t\\t\\t\\t\\t\\t\\t\\tto = \\\u0022{vmat}\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t\\t},\u0022 );\r\n\t\t}\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t]\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tuse_global_default = false\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tglobal_default_material = \\\u0022\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t},\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t]\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t},\u0022 );\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( \u0022\\t\\t\\t{\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t_class = \\\u0022ModelModifierList\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\tchildren =\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t[\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t{\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t_class = \\\u0022ModelModifier_ScaleAndMirror\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tscale = 1.0\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tmirror_x = true\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tmirror_y = false\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tmirror_z = false\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tflip_bone_forward = false\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tswap_left_and_right_bones = false\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t},\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t]\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t},\u0022 );\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( \u0022\\t\\t\\t{\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t_class = \\\u0022PhysicsShapeList\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\tchildren =\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t[\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t{\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t_class = \\\u0022PhysicsHullFromRender\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tparent_bone = \\\u0022\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tsurface_prop = \\\u0022default\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tcollision_tags = \\\u0022solid\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tfaceMergeAngle = 20.0\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tmaxHullVertices = 32\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\t\\t\\t\\t\\t\\thull_mode = \\\u0022{hullMode}\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t},\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t]\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t},\u0022 );\r\n\t\t}\r\n\r\n\t\t// --- Render mesh (FBX) ---\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t{\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t_class = \\\u0022RenderMeshList\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\tchildren =\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t[\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t{\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t_class = \\\u0022RenderMeshFile\\\u0022\u0022 );\r\n\t\tsb.AppendLine( $\u0022\\t\\t\\t\\t\\t\\tfilename = \\\u0022{fbxContentPath}\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\timport_translation = [ 0.0, 0.0, 0.0 ]\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\timport_rotation = [ 0.0, 0.0, 0.0 ]\u0022 );\r\n\t\tsb.AppendLine( $\u0022\\t\\t\\t\\t\\t\\timport_scale = {F( importScale )}\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\talign_origin_x_type = \\\u0022None\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\talign_origin_y_type = \\\u0022None\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\talign_origin_z_type = \\\u0022None\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tparent_bone = \\\u0022\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\timport_filter =\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t{\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t\\texclude_by_default = false\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t\\texception_list = [  ]\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t}\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t},\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t]\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t},\u0022 );\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( \u0022\\t\\t]\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\tmodel_archetype = \\\u0022\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\tprimary_associated_entity = \\\u0022\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\tanim_graph_name = \\\u0022\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\tbase_model_name = \\\u0022\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t}\u0022 );\r\n\t\tsb.AppendLine( \u0022}\u0022 );\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( \u0022\\t\\t\\t{\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t_class = \\\u0022LODGroupList\\\u0022\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\tchildren =\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t[\u0022 );\r\n\r\n\t\tforeach ( var l in lods )\r\n\t\t{\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t{\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t_class = \\\u0022LODGroup\\\u0022\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\t\\t\\t\\t\\t\\tswitch_threshold = {F( l.thr )}\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\t\\t\\t\\t\\t\\tauto_simplify_mode = {l.mode}\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\t\\t\\t\\t\\t\\tauto_reduction = {F( l.red )}\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tauto_max_error = 0.0\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\t\\t\\t\\t\\t\\tauto_lock_border_vertices = {B( l.lockBorder )}\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\t\\t\\t\\t\\t\\tauto_permissive_simplification = {B( l.permissive )}\u0022 );\r\n\t\t\tsb.AppendLine( $\u0022\\t\\t\\t\\t\\t\\tauto_protect_uv_seams = {B( l.protectUv )}\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tauto_regularize = 1\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tauto_prune_isolated_components = false\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tauto_strip_vertex_color = false\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tauto_material_culling_enabled = false\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tmeshes =\u0022 );\r\n\t\t\tif ( l.hasMesh )\r\n\t\t\t{\r\n\t\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t[\u0022 );\r\n\t\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t\\t\\\u0022unnamed_1\\\u0022,\u0022 );\r\n\t\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t]\u0022 );\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\t[  ]\u0022 );\r\n\t\t\t}\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t\\tmaterial_culls = [  ]\u0022 );\r\n\t\t\tsb.AppendLine( \u0022\\t\\t\\t\\t\\t},\u0022 );\r\n\t\t}\r\n\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t\\t]\u0022 );\r\n\t\tsb.AppendLine( \u0022\\t\\t\\t},\u0022 );\r\n\t}\r\n\r\n\tstatic string B( bool v ) =\u003E v ? \u0022true\u0022 : \u0022false\u0022;\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/// \u003Csummary\u003E\n/// Editor tool: pick an Unreal project folder, tick the static meshes and materials to bring\n/// over, and export them to sbox (FBX \u002B vmat \u002B vmdl) via a headless Unreal pass \u002B 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\u0027s 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/// \u003C/summary\u003E\n[EditorApp( \u0022Unreal Importer\u0022, \u0022move_to_inbox\u0022, \u0022Import Unreal / Fab meshes and materials into s\u0026box\u0022 )]\npublic class UnrealImportWindow : Widget\n{\n\t/// \u003Csummary\u003EWhat an entry turns into once imported.\u003C/summary\u003E\n\tenum AssetKind\n\t{\n\t\t/// \u003Csummary\u003EStaticMesh -\u003E fbx \u002B vmdl (\u002B the vmats of its slots).\u003C/summary\u003E\n\t\tMesh,\n\n\t\t/// \u003Csummary\u003EMaterial / Material Instance -\u003E a standalone vmat.\u003C/summary\u003E\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\u0027s 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 =\u003E 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\u003Cstring\u003E Subfolders = new( StringComparer.OrdinalIgnoreCase );\n\t\tpublic readonly List\u003CAssetEntry\u003E Assets = new();\n\t\tpublic readonly List\u003CMapEntry\u003E Maps = new();\n\n\t\t/// \u003Csummary\u003EEvery asset anywhere below this folder - drives the tri-state checkbox.\u003C/summary\u003E\n\t\tpublic readonly List\u003CAssetEntry\u003E 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/// \u003Csummary\u003EA row that can supply a large hover preview.\u003C/summary\u003E\n\tinterface IPreviewRow\n\t{\n\t\tPixmap PreviewPixmap { get; }\n\t\tstring PreviewCaption { get; }\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Frameless tooltip window showing a row\u0027s embedded thumbnail at full size\n\t/// (Unreal stores them at 256x256; the list shrinks them to 34px).\n\t/// \u003C/summary\u003E\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 \u002B Pad * 2, ImageSize \u002B CaptionHeight \u002B 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 \u002B= 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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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 \u0026\u0026 pressedItem.Object is ICheckRow row )\n\t\t\t{\n\t\t\t\tvar box = pressedItem.Rect;\n\t\t\t\tbox.Left \u002B= IndentWidth * pressedItem.Column \u002B 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() \u0026\u0026 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 \u003C 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 \u002B 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 (\u0022\u0022 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 = \u0022folder:\u0022 \u002B path;\n\t\t\tHeight = 26;\n\t\t}\n\n\t\tpublic override bool HasChildren =\u003E 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 \u003E 0 ? Theme.Primary : Theme.TextControl.WithAlpha( 0.5f ) );\n\t\t\tPaint.DrawIcon( check, sel == 0 ? \u0022check_box_outline_blank\u0022 : sel == total ? \u0022check_box\u0022 : \u0022indeterminate_check_box\u0022, 16, TextFlag.Center );\n\n\t\t\tvar icon = r;\n\t\t\ticon.Left \u002B= 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 ? \u0022folder_open\u0022 : \u0022folder\u0022, 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 \u002B= CheckWidth \u002B 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( \u0027/\u0027 ) \u002B 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 \u003C 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/// \u003Csummary\u003EPlaceholder \u002B material-row icon: a mesh reads as a solid, a material as a swatch.\u003C/summary\u003E\n\t\tstring Icon =\u003E entry.IsMesh ? \u0022view_in_ar\u0022 : \u0022palette\u0022;\n\n\t\tpublic Pixmap PreviewPixmap =\u003E pixmap;\n\t\tpublic string PreviewCaption =\u003E entry.Triangles \u003E= 0\n\t\t\t? $\u0022{entry.Display}  \u00B7  {FormatCount( entry.Triangles )} tris\u0022\n\t\t\t: entry.IsMesh ? entry.Display : $\u0022{entry.Display}  \u00B7  material\u0022;\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 \u0026\u0026 entry.Triangles \u003C 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 ? \u0022check_box\u0022 : \u0022check_box_outline_blank\u0022, 16, TextFlag.Center );\n\n\t\t\tvar thumb = r;\n\t\t\tthumb.Left \u002B= CheckWidth;\n\t\t\tthumb.Width = ThumbSize;\n\t\t\tthumb.Top \u002B= (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 \u003E= 0\n\t\t\t\t? $\u0022{FormatCount( entry.Triangles )} tris \u00B7 {FormatSize( entry.SizeBytes )}\u0022\n\t\t\t\t: entry.IsMesh\n\t\t\t\t\t? FormatSize( entry.SizeBytes )\n\t\t\t\t\t: $\u0022material \u00B7 {FormatSize( entry.SizeBytes )}\u0022;\n\t\t\tPaint.DrawText( meta, label, TextFlag.RightCenter );\n\n\t\t\tvar text = r;\n\t\t\ttext.Left \u002B= CheckWidth \u002B ThumbSize \u002B 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( \u0027/\u0027 ) \u002B 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 =\u003E pixmap;\n\t\tpublic string PreviewCaption =\u003E $\u0022{entry.Display}  \u00B7  map\u0022;\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, \u0022public\u0022, 16, TextFlag.Center );\n\n\t\t\tvar thumb = r;\n\t\t\tthumb.Left \u002B= CheckWidth;\n\t\t\tthumb.Width = ThumbSize;\n\t\t\tthumb.Top \u002B= (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, \u0022public\u0022, 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, \u0022map \u00B7 double-click to import\u0022, TextFlag.RightCenter );\n\n\t\t\tvar text = r;\n\t\t\ttext.Left \u002B= CheckWidth \u002B ThumbSize \u002B 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( \u0027/\u0027 ) \u002B 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 = \u0022\u0022;\n\tbool flatView;\n\n\treadonly List\u003CAssetEntry\u003E entries = new();\n\treadonly List\u003CMapEntry\u003E mapEntries = new();\n\treadonly Dictionary\u003Cstring, FolderBucket\u003E folders = new( StringComparer.OrdinalIgnoreCase );\n\tList\u003CTreeNode\u003E rootNodes = new();\n\n\t/// \u003Csummary\u003EWidth of the left-hand label column, so the settings rows line up.\u003C/summary\u003E\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/// \u003Csummary\u003ECombo item order - the layout row adds items in exactly this order.\u003C/summary\u003E\n\tstatic readonly ImportLayout[] LayoutOrder = { ImportLayout.Grouped, ImportLayout.Flat, ImportLayout.ClassicSource, ImportLayout.PerAsset };\n\n\tImportLayout SelectedLayout =\u003E layoutCombo is null ? ImportLayout.Grouped : LayoutOrder[Math.Clamp( layoutCombo.CurrentIndex, 0, LayoutOrder.Length - 1 )];\n\n\t/// \u003Csummary\u003ECombo item order - the material output row adds items in exactly this order.\u003C/summary\u003E\n\tstatic readonly MaterialOutput[] MaterialOutputOrder = { MaterialOutput.Material, MaterialOutput.Terrain, MaterialOutput.Decal };\n\n\tMaterialOutput SelectedMaterialOutput =\u003E materialOutputCombo is null\n\t\t? MaterialOutput.Material\n\t\t: MaterialOutputOrder[Math.Clamp( materialOutputCombo.CurrentIndex, 0, MaterialOutputOrder.Length - 1 )];\n\n\t/// \u003Csummary\u003EPer-asset folder-name depth: the combo index IS the depth (0 = asset\u0027s own name).\u003C/summary\u003E\n\tint PerAssetFolderDepth =\u003E perAssetFolderCombo?.CurrentIndex ?? 0;\n\n\t/// \u003Csummary\u003ECombo item order - the texture size row adds items in exactly this order. 0 = no cap.\u003C/summary\u003E\n\tstatic readonly int[] MaxTextureSizeOrder = { 0, 4096, 2048, 1024, 512 };\n\n\tint MaxTextureSize =\u003E maxTextureSizeCombo is null\n\t\t? 0\n\t\t: MaxTextureSizeOrder[Math.Clamp( maxTextureSizeCombo.CurrentIndex, 0, MaxTextureSizeOrder.Length - 1 )];\n\n\tstring Subfolder() =\u003E subfolderEdit?.Text ?? \u0022\u0022;\n\n\t/// \u003Csummary\u003EThe light-brightness multiplier from the UI, defensively parsed.\u003C/summary\u003E\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 ) \u0026\u0026 v \u003E 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 = \u0022Unreal Importer\u0022;\n\t\tSetWindowIcon( \u0022move_to_inbox\u0022 );\n\n\t\toutputFolder = Sandbox.Project.Current is not null\n\t\t\t? Path.Combine( Sandbox.Project.Current.GetAssetsPath(), \u0022unrealimport\u0022 )\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\u0022Select an Unreal project folder, tick the meshes and materials you want, and export.\\n\u0022 \u002B\n\t\t\t\u0022This runs a headless Unreal pass to extract FBX \u002B textures, then generates vmdl/vmat.\\n\u0022 \u002B\n\t\t\t\u0022A big pack takes minutes - the status line below tracks every phase.\u0022, 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( \u0022Unreal Project\u0022, 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 = \u0022No Unreal project selected\u0022,\n\t\t\t\tToolTip = \u0022The .uproject the assets are read from\u0022,\n\t\t\t}.StyleInput();\n\t\t\trow.Add( projectLabel, 1 );\n\t\t\trow.Add( new Button( \u0022Browse Project...\u0022, \u0022folder_open\u0022, 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( \u0022Asset Selection\u0022, this );\n\n\t\t\tvar toolRow = Layout.Row();\n\t\t\ttoolRow.Spacing = 8;\n\n\t\t\tsearchEdit = new LineEdit( this ) { PlaceholderText = \u0022\u2315  Search meshes and materials\u0022, ToolTip = \u0022Filter the list by name or path\u0022 };\n\t\t\tsearchEdit.StyleInput();\n\t\t\tsearchEdit.TextEdited \u002B= t =\u003E\n\t\t\t{\n\t\t\t\tsearchFilter = t ?? \u0022\u0022;\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( \u0022Select All\u0022, \u0022done_all\u0022, this ) { Clicked = () =\u003E SetAll( true ) } );\n\t\t\ttoolRow.Add( new Button( \u0022Select None\u0022, \u0022remove_done\u0022, this ) { Clicked = () =\u003E SetAll( false ) } );\n\n\t\t\tflatView = EditorCookie.Get( \u0022unreal_import_flat_view\u0022, false );\n\t\t\tvar flatToggle = new Checkbox( \u0022Flat list\u0022, this )\n\t\t\t{\n\t\t\t\tValue = flatView,\n\t\t\t\tToolTip = \u0022Show every asset as one flat list instead of the folder tree\u0022,\n\t\t\t};\n\t\t\tflatToggle.Toggled = () =\u003E\n\t\t\t{\n\t\t\t\tflatView = flatToggle.Value;\n\t\t\t\tEditorCookie.Set( \u0022unreal_import_flat_view\u0022, 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$\u0022background-color: {Theme.WindowBackground.Hex};\u0022 \u002B\n\t\t\t\t$\u0022border: 1px solid {Theme.Border.WithAlpha( 0.5f ).Hex};\u0022 \u002B\n\t\t\t\t$\u0022border-radius: {Theme.ControlRadius}px;\u0022 );\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( \u0022Export Settings\u0022, 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( \u0022Output Directory\u0022, 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 = \u0022No output folder selected\u0022,\n\t\t\t\tToolTip = \u0022Where generated assets are written\u0022,\n\t\t\t}.StyleInput();\n\t\t\tgrid.AddCell( 1, 0, outputLabel, xSpan: 3 );\n\t\t\tgrid.AddCell( 4, 0, new Button( \u0022Output...\u0022, \u0022drive_file_move\u0022, this ) { Clicked = PickOutput } );\n\n\t\t\t// Row 1: layout | map light brightness.\n\t\t\tgrid.AddCell( 0, 1, new Label( \u0022Layout\u0022, this ) { FixedWidth = LabelWidth } );\n\n\t\t\tlayoutCombo = new ComboBox( this ) { MinimumWidth = 180 };\n\t\t\tlayoutCombo.AddItem( \u0022Grouped\u0022, icon: \u0022folder\u0022,\n\t\t\t\tdescription: \u0022\u003Coutput\u003E/models, /materials, /textures\u0022 );\n\t\t\tlayoutCombo.AddItem( \u0022Flat\u0022, icon: \u0022folder_open\u0022,\n\t\t\t\tdescription: \u0022Everything directly in the output folder\u0022 );\n\t\t\tlayoutCombo.AddItem( \u0022Classic Source\u0022, icon: \u0022account_tree\u0022,\n\t\t\t\tdescription: \u0022Assets/models/\u003Csubdir\u003E for fbx\u002Bvmdl, Assets/materials/\u003Csubdir\u003E for vmat\u002Btextures\u0022 );\n\t\t\tlayoutCombo.AddItem( \u0022Per Asset\u0022, icon: \u0022inventory_2\u0022,\n\t\t\t\tdescription: \u0022\u003Coutput\u003E/\u003Casset\u003E/ - each asset\u0027s model, materials and textures together\u0022 );\n\n\t\t\tvar savedLayout = EditorCookie.Get( \u0022unreal_import_layout\u0022, 0 );\n\t\t\tlayoutCombo.CurrentIndex = Math.Clamp( savedLayout, 0, LayoutOrder.Length - 1 );\n\t\t\tlayoutCombo.ItemChanged \u002B= () =\u003E\n\t\t\t{\n\t\t\t\tEditorCookie.Set( \u0022unreal_import_layout\u0022, 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\u0026box doesn\u0027t have - taste (and pack) varies, so expose a knob.\n\t\t\tgrid.AddCell( 2, 1, new Label( \u0022Map light brightness\u0022, this ), alignment: TextFlag.RightCenter );\n\t\t\tlightScaleEdit = new LineEdit( this )\n\t\t\t{\n\t\t\t\tText = EditorCookie.Get( \u0022unreal_import_light_scale\u0022, 1f ).ToString( System.Globalization.CultureInfo.InvariantCulture ),\n\t\t\t\tToolTip = \u0022Multiplier on converted map light intensity. 1 = calibrated default; lower for moodier interiors, higher if too dark. Applies on (re)import.\u0022,\n\t\t\t};\n\t\t\tlightScaleEdit.TextEdited \u002B= _ =\u003E EditorCookie.Set( \u0022unreal_import_light_scale\u0022, 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( \u0022Subfolder\u0022, 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( \u0022unreal_import_subfolder\u0022, \u0022unrealimport\u0022 ),\n\t\t\t\tPlaceholderText = \u0022(none)\u0022,\n\t\t\t\tToolTip = \u0022Subfolder under Assets/models and Assets/materials. Leave empty to write straight into them.\u0022,\n\t\t\t};\n\t\t\tsubfolderEdit.TextEdited \u002B= t =\u003E\n\t\t\t{\n\t\t\t\tEditorCookie.Set( \u0022unreal_import_subfolder\u0022, t ?? \u0022\u0022 );\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( \u0022Generate LODs\u0022, this )\n\t\t\t{\n\t\t\t\tValue = EditorCookie.Get( \u0022unreal_import_lods\u0022, true ),\n\t\t\t\tToolTip = \u00225-level auto chain; untick for full detail at every distance\u0022,\n\t\t\t};\n\t\t\tgrid.AddCell( 2, 2, lodCheckbox, xSpan: 3 );\n\t\t\tlodCheckbox.Toggled = () =\u003E EditorCookie.Set( \u0022unreal_import_lods\u0022, 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( \u0022Material Output\u0022, this ) { FixedWidth = LabelWidth } );\n\n\t\t\tmaterialOutputCombo = new ComboBox( this ) { MinimumWidth = 180 };\n\t\t\tmaterialOutputCombo.AddItem( \u0022Material (.vmat)\u0022, icon: \u0022palette\u0022,\n\t\t\t\tdescription: \u0022Standard complex.shader material\u0022 );\n\t\t\tmaterialOutputCombo.AddItem( \u0022Terrain (.tmat)\u0022, icon: \u0022landscape\u0022,\n\t\t\t\tdescription: \u0022Terrain Material - tiling ground surface with height blending\u0022 );\n\t\t\tmaterialOutputCombo.AddItem( \u0022Decal (.decal)\u0022, icon: \u0022approval\u0022,\n\t\t\t\tdescription: \u0022Decal Definition - projected decal masked by the colour alpha\u0022 );\n\n\t\t\tmaterialOutputCombo.CurrentIndex = Math.Clamp(\n\t\t\t\tEditorCookie.Get( \u0022unreal_import_material_output\u0022, 0 ), 0, MaterialOutputOrder.Length - 1 );\n\t\t\tmaterialOutputCombo.ItemChanged \u002B= () =\u003E\n\t\t\t{\n\t\t\t\tEditorCookie.Set( \u0022unreal_import_material_output\u0022, 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( \u0022Meshes always use .vmat\u0022, this )\n\t\t\t{\n\t\t\t\tColor = Theme.TextControl.WithAlpha( 0.5f ),\n\t\t\t\tToolTip = \u0022A model\u0027s material slots can\u0027t reference a terrain or decal resource, so this only applies to materials imported on their own.\u0022,\n\t\t\t}, xSpan: 3 );\n\n\t\t\t// Row 4: Per Asset only - which folder to name each asset\u0027s subfolder after.\n\t\t\t// Fab/Megascans MIs are named like \u0022mi_sjfnbeaa\u0022; 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( \u0022Folder name\u0022, 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( \u0022Asset name\u0022, icon: \u0022description\u0022,\n\t\t\t\tdescription: \u0022Name each folder after the asset itself (e.g. mi_sjfnbeaa)\u0022 );\n\t\t\tperAssetFolderCombo.AddItem( \u00221 folder up\u0022, icon: \u0022north\u0022,\n\t\t\t\tdescription: \u0022Name it after the asset\u0027s parent folder\u0022 );\n\t\t\tperAssetFolderCombo.AddItem( \u00222 folders up\u0022, icon: \u0022north\u0022,\n\t\t\t\tdescription: \u0022Grandparent folder - the readable pack name for Fab/Megascans\u0022 );\n\t\t\tperAssetFolderCombo.AddItem( \u00223 folders up\u0022, icon: \u0022north\u0022,\n\t\t\t\tdescription: \u0022Great-grandparent folder\u0022 );\n\n\t\t\tperAssetFolderCombo.CurrentIndex = Math.Clamp( EditorCookie.Get( \u0022unreal_import_perasset_depth\u0022, 0 ), 0, 3 );\n\t\t\tperAssetFolderCombo.ItemChanged \u002B= () =\u003E EditorCookie.Set( \u0022unreal_import_perasset_depth\u0022, perAssetFolderCombo.CurrentIndex );\n\t\t\tgrid.AddCell( 1, 4, perAssetFolderCombo.StyleInput() );\n\n\t\t\tgrid.AddCell( 2, 4, new Label( \u0022Per Asset layout only\u0022, this )\n\t\t\t{\n\t\t\t\tColor = Theme.TextControl.WithAlpha( 0.5f ),\n\t\t\t\tToolTip = \u0022Which folder each asset\u0027s subfolder is named after, when using the Per Asset layout.\u0022,\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( \u0022Max texture size\u0022, this ) { FixedWidth = LabelWidth } );\n\n\t\t\tmaxTextureSizeCombo = new ComboBox( this ) { MinimumWidth = 180 };\n\t\t\tmaxTextureSizeCombo.AddItem( \u0022Original\u0022, icon: \u0022photo_size_select_actual\u0022,\n\t\t\t\tdescription: \u0022Keep whatever the pack ships - no resizing\u0022 );\n\t\t\tmaxTextureSizeCombo.AddItem( \u00224096\u0022, icon: \u0022photo_size_select_large\u0022,\n\t\t\t\tdescription: \u0022Downscale anything larger than 4K\u0022 );\n\t\t\tmaxTextureSizeCombo.AddItem( \u00222048\u0022, icon: \u0022photo_size_select_large\u0022,\n\t\t\t\tdescription: \u0022Downscale anything larger than 2K - a good default for props\u0022 );\n\t\t\tmaxTextureSizeCombo.AddItem( \u00221024\u0022, icon: \u0022photo_size_select_small\u0022,\n\t\t\t\tdescription: \u0022Downscale anything larger than 1K\u0022 );\n\t\t\tmaxTextureSizeCombo.AddItem( \u0022512\u0022, icon: \u0022photo_size_select_small\u0022,\n\t\t\t\tdescription: \u0022Downscale anything larger than 512 - small props and blockout\u0022 );\n\n\t\t\tmaxTextureSizeCombo.CurrentIndex = Math.Clamp(\n\t\t\t\tEditorCookie.Get( \u0022unreal_import_max_texture_size\u0022, 0 ), 0, MaxTextureSizeOrder.Length - 1 );\n\t\t\tmaxTextureSizeCombo.ItemChanged \u002B= () =\u003E\n\t\t\t\tEditorCookie.Set( \u0022unreal_import_max_texture_size\u0022, maxTextureSizeCombo.CurrentIndex );\n\t\t\tgrid.AddCell( 1, 5, maxTextureSizeCombo.StyleInput() );\n\n\t\t\tgrid.AddCell( 2, 5, new Label( \u0022Smaller = faster import\u0022, this )\n\t\t\t{\n\t\t\t\tColor = Theme.TextControl.WithAlpha( 0.5f ),\n\t\t\t\tToolTip = \u0022Textures bigger than this are resampled down on the longest edge, keeping their aspect ratio. Never upscales.\u0022,\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( \u0022\u0022, 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( \u0022Export to s\u0026box\u0022, \u0022move_to_inbox\u0022, this ) { Clicked = () =\u003E _ = 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( \u0022unreal_import_project_path\u0022, \u0022\u0022 );\n\t\tif ( !string.IsNullOrEmpty( outputPath ) )\n\t\t{\n\t\t\tLog.Info( $\u0022UnrealImportWindow: restoring last project path: {outputPath}\u0022 );\n\t\t\tuprojectPath = outputPath;\n\t\t\tuprojectFolder = Path.GetDirectoryName( outputPath );\n\t\t\tprojectLabel.Text = $\u0022{Path.GetFileName( outputPath )}  ({Path.GetFileName( uprojectFolder )})\u0022;\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 \u0022Assets/models \u002B Assets/materials\u0022;\n\n\t\t\tvar paths = AssetImporter.ResolvePaths( outputFolder, assets, ImportLayout.ClassicSource, Subfolder() );\n\t\t\treturn $\u0022{paths.ModelsDir}  \u002B  {paths.MaterialsDir}\u0022;\n\t\t}\n\n\t\tif ( string.IsNullOrEmpty( outputFolder ) )\n\t\t\treturn \u0022\u0022;\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, \u0022\u003Casset\u003E\u0022 )\n\t\t\t: outputFolder;\n\t}\n\n\t/// \u003Csummary\u003E\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\u0027s nothing to type.)\n\t/// \u003C/summary\u003E\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 = \u0022Select Unreal Project Folder\u0022 };\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( \u0022Not an Unreal project\u0022, $\u0022No .uproject found in:\\n{folder}\u0022 );\n\t\t\treturn;\n\t\t}\n\n\t\tuprojectFolder = folder;\n\t\tuprojectPath = uproject;\n\t\tprojectLabel.Text = $\u0022{Path.GetFileName( uproject )}  ({Path.GetFileName( folder )})\u0022;\n\n\t\tEditorCookie.Set( \u0022unreal_import_project_path\u0022, uprojectPath );\n\t\tLog.Info( $\u0022UnrealImportWindow: storing last project path: {uprojectPath}\u0022 );\n\n\t\tScanAssets();\n\t}\n\n\tvoid PickOutput()\n\t{\n\t\tvar fd = new FileDialog( null ) { Title = \u0022Select Output Folder (inside Assets/)\u0022, 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, \u0022Content\u0022 );\n\t\tif ( Directory.Exists( content ) )\n\t\t{\n\t\t\tforeach ( var file in new DirectoryInfo( content ).EnumerateFiles( \u0022*.umap\u0022, SearchOption.AllDirectories ) )\n\t\t\t{\n\t\t\t\tvar gamePath = HeadlessExporter.ToGamePath( uprojectFolder, file.FullName );\n\t\t\t\tif ( gamePath.EndsWith( \u0022.umap\u0022, StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\t\tgamePath = gamePath[..^\u0022.umap\u0022.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( \u0022/Game/\u0022 ) ? gamePath[\u0022/Game/\u0022.Length..] : gamePath,\n\t\t\t\t} );\n\t\t\t}\n\t\t\tmapEntries.Sort( ( a, b ) =\u003E 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( \u0022*.uasset\u0022, 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( \u0022/Game/\u0022 ) ? gamePath[\u0022/Game/\u0022.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( $\u0022No static meshes or materials found in {uprojectFolder}/Content.\u0022 );\n\t\t}\n\n\t\tentries.Sort( ( a, b ) =\u003E 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/// \u003Csummary\u003E\n\t/// What a .uasset is, from its name and folder - null for anything we can\u0027t 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/// \u003C/summary\u003E\n\tstatic AssetKind? ClassifyUasset( FileInfo file )\n\t{\n\t\tvar dir = (file.DirectoryName ?? \u0022\u0022).Replace( \u0027\\\\\u0027, \u0027/\u0027 );\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( \u0022SM_\u0022, 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\u0027t collide with anything else we\u0027d want to list.\n\t\tif ( name.StartsWith( \u0022MI_\u0022, StringComparison.OrdinalIgnoreCase )\n\t\t\t|| name.StartsWith( \u0022M_\u0022, StringComparison.OrdinalIgnoreCase )\n\t\t\t|| name.StartsWith( \u0022MM_\u0022, StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn AssetKind.Material;\n\n\t\tif ( dir.Contains( \u0022/Meshes\u0022, StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn AssetKind.Mesh;\n\n\t\tif ( dir.Contains( \u0022/Materials\u0022, StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn AssetKind.Material;\n\n\t\treturn null;\n\t}\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tasync Task WarmStats( List\u003CAssetEntry\u003E 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 \u003C 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 ( \u002B\u002Bdone % 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 ) =\u003E path.Contains( \u0027/\u0027 ) ? path[..path.LastIndexOf( \u0027/\u0027 )] : \u0022\u0022;\n\tstatic string DirOf( string display ) =\u003E display.Contains( \u0027/\u0027 ) ? display[..display.LastIndexOf( \u0027/\u0027 )] : \u0022\u0022;\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( \u0022\u0022 );\n\n\t\tvoid RegisterChain( string dir )\n\t\t{\n\t\t\twhile ( dir.Length \u003E 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( \u0022\u0022 ).ToList();\n\t}\n\n\tbool FolderHasChildren( string path )\n\t\t=\u003E folders.TryGetValue( path, out var b ) \u0026\u0026 (b.Subfolders.Count \u003E 0 || b.Assets.Count \u003E 0 || b.Maps.Count \u003E 0);\n\n\tIEnumerable\u003CTreeNode\u003E 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\u002B\u002B;\n\n\t\treturn (sel, b.Subtree.Count);\n\t}\n\n\t/// \u003Csummary\u003ERight-hand folder label: \u002212 meshes \u00B7 3 materials\u0022, omitting whichever is zero.\u003C/summary\u003E\n\tstring SubtreeSummary( string path )\n\t{\n\t\tif ( !folders.TryGetValue( path, out var b ) )\n\t\t\treturn \u0022\u0022;\n\n\t\tint meshes = b.Subtree.Count( e =\u003E e.IsMesh );\n\t\tint mats = b.Subtree.Count - meshes;\n\n\t\tvar parts = new List\u003Cstring\u003E();\n\t\tif ( meshes \u003E 0 )\n\t\t\tparts.Add( meshes == 1 ? \u00221 mesh\u0022 : $\u0022{meshes} meshes\u0022 );\n\t\tif ( mats \u003E 0 )\n\t\t\tparts.Add( mats == 1 ? \u00221 material\u0022 : $\u0022{mats} materials\u0022 );\n\n\t\treturn string.Join( \u0022 \u00B7 \u0022, 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/// \u003Csummary\u003EEntries matching the current search box, in list order.\u003C/summary\u003E\n\tIEnumerable\u003CAssetEntry\u003E 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 =\u003E e.Display.Contains( term, StringComparison.OrdinalIgnoreCase ) );\n\t}\n\n\t/// \u003Csummary\u003EMaps matching the current search box.\u003C/summary\u003E\n\tIEnumerable\u003CMapEntry\u003E 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 =\u003E e.Display.Contains( term, StringComparison.OrdinalIgnoreCase ) );\n\t}\n\n\t/// \u003Csummary\u003ETree of folders normally; a flat list while searching or when toggled flat.\u003C/summary\u003E\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 \u0026\u0026 !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\u003CTreeNode\u003E();\n\t\t\tflat.AddRange( FilteredMaps().Select( m =\u003E (TreeNode)new MapNode( this, m, fullPath: true ) ) );\n\t\t\tflat.AddRange( Filtered().Select( e =\u003E (TreeNode)new AssetNode( this, e, fullPath: true ) ) );\n\n\t\t\tif ( flat.Count == 0 \u0026\u0026 searching )\n\t\t\t\tflat.Add( new TreeNode( $\u0022No matches for \\\u0022{searchFilter.Trim()}\\\u0022\u0022 ) );\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 \u003E= 1024L * 1024 * 1024 ) return $\u0022{bytes / (1024f * 1024 * 1024):0.##} GB\u0022;\n\t\tif ( bytes \u003E= 1024 * 1024 ) return $\u0022{bytes / (1024f * 1024):0.#} MB\u0022;\n\t\tif ( bytes \u003E= 1024 ) return $\u0022{bytes / 1024f:0} KB\u0022;\n\t\treturn $\u0022{bytes} B\u0022;\n\t}\n\n\tstatic string FormatCount( long n )\n\t{\n\t\tif ( n \u003E= 1_000_000 ) return $\u0022{n / 1_000_000f:0.##}M\u0022;\n\t\tif ( n \u003E= 1_000 ) return $\u0022{n / 1_000f:0.#}k\u0022;\n\t\treturn $\u0022{n}\u0022;\n\t}\n\n\t/// \u003Csummary\u003ETicks or unticks everything currently shown - the search filter narrows this.\u003C/summary\u003E\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 = \u0022\u0022;\n\t\t\treturn;\n\t\t}\n\n\t\tvar selected = entries.Where( e =\u003E e.Selected ).ToList();\n\t\tvar shown = Filtered().Count();\n\n\t\tint meshCount = entries.Count( e =\u003E e.IsMesh );\n\t\tvar found = $\u0022{meshCount} static mesh(es), {entries.Count - meshCount} material(s)\u0022;\n\t\tvar text = shown == entries.Count ? $\u0022{found} found.\u0022 : $\u0022{shown} of {found} shown.\u0022;\n\n\t\tif ( selected.Count \u003E 0 )\n\t\t{\n\t\t\ttext \u002B= $\u0022  {selected.Count} selected ({FormatSize( selected.Sum( e =\u003E e.SizeBytes ) )}\u0022;\n\n\t\t\t// Tri counts only exist for meshes - \u0022\u002B\u0022 means some are still being read.\n\t\t\tlong tris = selected.Sum( e =\u003E Math.Max( 0, e.Triangles ) );\n\t\t\tbool partial = selected.Any( e =\u003E e.IsMesh \u0026\u0026 e.Triangles \u003C 0 );\n\t\t\tif ( tris \u003E 0 )\n\t\t\t\ttext \u002B= $\u0022, {FormatCount( tris )}{(partial ? \u0022\u002B\u0022 : \u0022\u0022)} tris\u0022;\n\n\t\t\ttext \u002B= \u0022).\u0022;\n\t\t}\n\t\telse\n\t\t{\n\t\t\ttext \u002B= \u0022  Nothing selected.\u0022;\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\u0027t need a picked output folder.\n\t\tvar haveOutput = SelectedLayout == ImportLayout.ClassicSource || !string.IsNullOrEmpty( outputFolder );\n\t\texportButton.Enabled = entries.Count \u003E 0 \u0026\u0026 haveOutput \u0026\u0026 !string.IsNullOrEmpty( uprojectPath );\n\t}\n\n\t/// \u003Csummary\u003EPush a live export/import event into the progress toast \u002B status line.\u003C/summary\u003E\n\tvoid ApplyProgress( IProgressSection progress, ExportEvent ev )\n\t{\n\t\tif ( ev.Total is \u003E 0 )\n\t\t\tprogress.TotalCount = ev.Total.Value;\n\t\tif ( ev.Done is \u003E 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 \u003E 0 \u0026\u0026 ev.Total is \u003E 0 ? $\u0022[{ev.Done}/{ev.Total}] {ev.Message}\u0022 : ev.Message;\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003ELocate ue_export.py \u002B the right UnrealEditor-Cmd, dialoging on failure.\u003C/summary\u003E\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( \u0022Export script missing\u0022, \u0022Could not find Tools/ue_export.py in this library.\u0022 );\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( \u0022Unreal not found\u0022,\n\t\t\t\t$\u0022Couldn\u0027t locate UnrealEditor-Cmd.exe for engine \u0027{engineVersion}\u0027.\\nIs Unreal installed under Epic Games?\u0022 );\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/// \u003Csummary\u003EDouble-clicking a map row lands here - confirm before kicking a long export.\u003C/summary\u003E\n\tvoid DoImportMap( MapEntry map )\n\t{\n\t\tEditorUtility.DisplayDialog( \u0022Import map?\u0022,\n\t\t\t$\u0022Import {map.Display}?\\n\\nThis exports every mesh the level uses and builds a prefab of its layout. It can take a while.\u0022,\n\t\t\t\u0022Cancel\u0022, \u0022Import\u0022, () =\u003E _ = RunImportMap( map ), \u0022\uD83C\uDF0D\u0022 );\n\t}\n\n\tasync Task RunImportMap( MapEntry map )\n\t{\n\t\tif ( string.IsNullOrEmpty( outputFolder ) \u0026\u0026 SelectedLayout != ImportLayout.ClassicSource )\n\t\t{\n\t\t\tEditorUtility.DisplayDialog( \u0022No output folder\u0022, \u0022Pick an output folder (inside Assets/) first.\u0022 );\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 = $\u0022Importing map {map.Display}... this exports every mesh the level uses and can take a while.\u0022;\n\n\t\tusing var progress = Application.Editor.ProgressSection();\n\t\tprogress.Title = $\u0022Exporting map {map.Display}\u0022;\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\u003Cstring\u003E(), script, progressToken, mapGamePath: map.GamePath,\n\t\t\t\tonProgress: ev =\u003E ApplyProgress( progress, ev ) );\n\t\t\tif ( !export.Success )\n\t\t\t{\n\t\t\t\tEditorUtility.DisplayDialog( \u0022Map export failed\u0022, export.Error ?? \u0022Unknown error.\u0022, icon: \u0022\u26A0\uFE0F\u0022 );\n\t\t\t\tstatusLabel.Text = \u0022Map export failed.\u0022;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tprogress.Title = $\u0022Importing map {map.Display}\u0022;\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 ) =\u003E ApplyProgress( progress, new ExportEvent( done, total, $\u0022Importing {name}\u0022 ) ) );\n\n\t\t\tvar msg = $\u0022Imported {summary.Models} model(s), {summary.Materials} material(s), {summary.Textures} texture(s).\\n\u0022 \u002B\n\t\t\t\t$\u0022{summary.Placements} placement(s) written to:\\n{summary.PrefabPath}\u0022;\n\t\t\tif ( summary.Warnings.Count \u003E 0 )\n\t\t\t\tmsg \u002B= \u0022\\n\\nWarnings:\\n - \u0022 \u002B string.Join( \u0022\\n - \u0022, summary.Warnings.Take( 10 ) );\n\n\t\t\tEditorUtility.DisplayDialog( \u0022Map import complete\u0022, msg, icon: \u0022\u2705\u0022 );\n\t\t\tstatusLabel.Text = $\u0022Done: {summary.Placements} placements, {summary.Models} models.\u0022;\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tEditorUtility.DisplayDialog( \u0022Map import error\u0022, e.ToString(), icon: \u0022\u26A0\uFE0F\u0022 );\n\t\t\tstatusLabel.Text = \u0022Map import error.\u0022;\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\u0027s on screen right now.\n\t\tvar selectedEntries = entries.Where( e =\u003E e.Selected ).ToList();\n\t\tvar selected = selectedEntries.Select( e =\u003E e.GamePath ).ToList();\n\t\tif ( selected.Count == 0 )\n\t\t{\n\t\t\tEditorUtility.DisplayDialog( \u0022Nothing selected\u0022, \u0022Tick at least one mesh or material to export.\u0022 );\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 =\u003E e.IsMesh );\n\t\tvar what = meshCount == selected.Count ? $\u0022{meshCount} mesh(es)\u0022\n\t\t\t: meshCount == 0 ? $\u0022{selected.Count} material(s)\u0022\n\t\t\t: $\u0022{meshCount} mesh(es) \u002B {selected.Count - meshCount} material(s)\u0022;\n\t\tstatusLabel.Text = $\u0022Exporting {what} via headless Unreal... this can take a minute.\u0022;\n\n\t\tusing var progress = Application.Editor.ProgressSection();\n\n\t\tprogress.Title = \u0022Exporting from Unreal\u0022;\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 =\u003E ApplyProgress( progress, ev ) );\n\t\t\tif ( !export.Success )\n\t\t\t{\n\t\t\t\tEditorUtility.DisplayDialog( \u0022Export failed\u0022, export.Error ?? \u0022Unknown error.\u0022, icon: \u0022\u26A0\uFE0F\u0022 );\n\t\t\t\tstatusLabel.Text = \u0022Export failed.\u0022;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tprogress.Title = \u0022Importing into s\u0026box\u0022;\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 ) =\u003E ApplyProgress( progress, new ExportEvent( done, total, $\u0022Importing {name}\u0022 ) ) );\n\n\t\t\tvar msg = $\u0022Imported {summary.Models} model(s), {summary.Materials} material(s), {summary.Textures} texture(s).\\n\\n\u0022 \u002B\n\t\t\t\t$\u0022Output:\\n{summary.OutputDir}\u0022;\n\t\t\tif ( summary.Warnings.Count \u003E 0 )\n\t\t\t\tmsg \u002B= \u0022\\n\\nWarnings:\\n - \u0022 \u002B string.Join( \u0022\\n - \u0022, summary.Warnings.Take( 10 ) );\n\n\t\t\tEditorUtility.DisplayDialog( \u0022Import complete\u0022, msg, icon: \u0022\u2705\u0022 );\n\t\t\tstatusLabel.Text = $\u0022Done: {summary.Models} models, {summary.Materials} materials, {summary.Textures} textures.\u0022;\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tEditorUtility.DisplayDialog( \u0022Import error\u0022, e.ToString(), icon: \u0022\u26A0\uFE0F\u0022 );\n\t\t\tstatusLabel.Text = \u0022Import error.\u0022;\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/// \u003Csummary\u003EA progress signal parsed out of the live Unreal log stream.\u003C/summary\u003E\r\n/// \u003Cparam name=\u0022Done\u0022\u003EMeshes exported so far, when the line carried a count.\u003C/param\u003E\r\n/// \u003Cparam name=\u0022Total\u0022\u003ETotal meshes to export, when known.\u003C/param\u003E\r\n/// \u003Cparam name=\u0022Message\u0022\u003EHuman-readable phase/state line.\u003C/param\u003E\r\npublic record ExportEvent( int? Done, int? Total, string Message );\r\n\r\n/// \u003Csummary\u003E\r\n/// Drives Tools/ue_export.py inside headless Unreal (UnrealEditor-Cmd) to turn selected\r\n/// .uasset StaticMeshes into FBX \u002B PNG \u002B manifest.json in a staging folder.\r\n/// \u003C/summary\u003E\r\npublic static class HeadlessExporter\r\n{\r\n\t/// \u003Csummary\u003EFind ue_export.py shipped in this library\u0027s Tools folder.\u003C/summary\u003E\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, \u0022Libraries\u0022, \u0022unrealimporter\u0022, \u0022Tools\u0022, \u0022ue_export.py\u0022 );\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, \u0022ue_export.py\u0022, 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/// \u003Csummary\u003EConvert a Content-relative .uasset file path to a /Game object path.\u003C/summary\u003E\r\n\t/// \u003Cexample\u003E.../Content/Construction_VOL1/Meshes/SM_Boxes_01a.uasset -\u003E /Game/Construction_VOL1/Meshes/SM_Boxes_01a\u003C/example\u003E\r\n\tpublic static string ToGamePath( string uprojectFolder, string uassetAbsPath )\r\n\t{\r\n\t\tvar content = Path.Combine( uprojectFolder, \u0022Content\u0022 );\r\n\t\tvar rel = Path.GetRelativePath( content, uassetAbsPath ).Replace( \u0027\\\\\u0027, \u0027/\u0027 );\r\n\t\tif ( rel.EndsWith( \u0022.uasset\u0022, StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\trel = rel[..^\u0022.uasset\u0022.Length];\r\n\r\n\t\treturn \u0022/Game/\u0022 \u002B rel;\r\n\t}\r\n\r\n\t/// \u003Cparam name=\u0022mapGamePath\u0022\u003EWhen set, scene mode: export this .umap\u0027s placements plus every mesh it uses (gameAssetPaths is ignored by the script).\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022onProgress\u0022\u003E\r\n\t/// Live progress parsed by tailing the -abslog file. Unreal\u0027s stdout only carries\r\n\t/// Display\u002B severity (verified: our script\u0027s 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\u0027s context.\r\n\t/// \u003C/param\u003E\r\n\tpublic static async Task\u003CExportResult\u003E Run( string editorCmd, string uprojectPath, IEnumerable\u003Cstring\u003E gameAssetPaths, string scriptPath, CancellationToken progressToken, string mapGamePath = null, Action\u003CExportEvent\u003E 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 \u002B 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, \u0022Checking project plugins...\u0022 ) );\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar sanitized = await Task.Run( () =\u003E\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( $\u0022uproject plugin check failed, launching unmodified: {e.Message}\u0022 );\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(), \u0022unrealimporter\u0022, Guid.NewGuid().ToString( \u0022N\u0022 ) );\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, \u0022_assets.txt\u0022 );\r\n\t\tawait File.WriteAllLinesAsync( assetsFile, gameAssetPaths, progressToken );\r\n\r\n\t\tvar logPath = Path.Combine( stagingDir, \u0022ue_export.log\u0022 );\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 (\u0022Invalid actor native class\u0022) and their geometry is lost.\r\n\t\tvar script = scriptPath.Replace( \u0027\\\\\u0027, \u0027/\u0027 );\r\n\t\tvar args =\r\n\t\t\t$\u0022\\\u0022{uprojectPath}\\\u0022 -run=pythonscript -script=\\\u0022{script}\\\u0022 \u0022 \u002B\r\n\t\t\t$\u0022-EnablePlugins=PythonScriptPlugin,PCG -unattended -nosplash -nullrhi -abslog=\\\u0022{logPath}\\\u0022\u0022;\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[\u0022UE_EXPORT_OUT\u0022] = stagingDir;\r\n\t\tpsi.EnvironmentVariables[\u0022UE_EXPORT_ASSETS_FILE\u0022] = assetsFile;\r\n\t\tif ( !string.IsNullOrEmpty( mapGamePath ) )\r\n\t\t\tpsi.EnvironmentVariables[\u0022UE_EXPORT_MAP\u0022] = 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( () =\u003E\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\u0027s 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, \u0022manifest.json\u0022 );\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? \u0022Export cancelled.\u0022\r\n\t\t\t\t\t: $\u0022UnrealEditor-Cmd exited with code {proc.ExitCode}. See log:\\n{logPath}\u0022;\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 = $\u0022Export finished but no manifest.json was produced. See log:\\n{logPath}\u0022;\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\u003Cstring, HashSet\u003Cstring\u003E\u003E pluginScanCache = new( StringComparer.OrdinalIgnoreCase );\r\n\r\n\t/// \u003Csummary\u003ENames of every .uplugin discoverable under a directory (cached - engine trees are big).\u003C/summary\u003E\r\n\tstatic HashSet\u003Cstring\u003E 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\u003Cstring\u003E( StringComparer.OrdinalIgnoreCase );\r\n\t\tif ( Directory.Exists( dir ) )\r\n\t\t{\r\n\t\t\tforeach ( var f in Directory.EnumerateFiles( dir, \u0022*.uplugin\u0022, 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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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?[\u0022Plugins\u0022] 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 =\u003E p?[\u0022Enabled\u0022]?.GetValue\u003Cbool\u003E() == true )\r\n\t\t\t.Select( p =\u003E p?[\u0022Name\u0022]?.GetValue\u003Cstring\u003E() )\r\n\t\t\t.Where( n =\u003E !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 = \u003Croot\u003E/Engine/Binaries/Win64/UnrealEditor-Cmd.exe\r\n\t\tvar enginePlugins = Path.GetFullPath( Path.Combine( Path.GetDirectoryName( editorCmd ), \u0022..\u0022, \u0022..\u0022, \u0022Plugins\u0022 ) );\r\n\t\tvar projFolder = Path.GetDirectoryName( uprojectPath );\r\n\r\n\t\tvar missing = enabled\r\n\t\t\t.Where( n =\u003E !AvailablePlugins( enginePlugins ).Contains( n )\r\n\t\t\t\t\u0026\u0026 !AvailablePlugins( Path.Combine( projFolder, \u0022Plugins\u0022 ) ).Contains( n )\r\n\t\t\t\t\u0026\u0026 !AvailablePlugins( Path.Combine( projFolder, \u0022Mods\u0022 ) ).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( $\u0022uproject enables plugin(s) missing from this engine: {string.Join( \u0022, \u0022, missing )} - marking Optional for the export run.\u0022 );\r\n\r\n\t\tforeach ( var p in plugins )\r\n\t\t{\r\n\t\t\tif ( p?[\u0022Name\u0022]?.GetValue\u003Cstring\u003E() is string name \u0026\u0026 missing.Contains( name ) )\r\n\t\t\t\tp[\u0022Optional\u0022] = 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 ) \u002B \u0022.sboximport.uproject\u0022 );\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/// \u003Csummary\u003EHow long a phase may go without an update before its elapsed time is re-emitted.\u003C/summary\u003E\r\n\tstatic readonly TimeSpan Heartbeat = TimeSpan.FromSeconds( 2 );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Coarse phases recognised in Unreal\u0027s 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/// \u003C/summary\u003E\r\n\tstatic readonly (string Marker, string Phase)[] BootPhases =\r\n\t{\r\n\t\t( \u0022LogInit\u0022, \u0022Unreal starting up\u0022 ),\r\n\t\t( \u0022LogPluginManager: Mounting\u0022, \u0022Unreal: mounting plugins\u0022 ),\r\n\t\t( \u0022LogTargetPlatformManager\u0022, \u0022Unreal: loading target platforms\u0022 ),\r\n\t\t( \u0022LogDerivedDataCache\u0022, \u0022Unreal: opening the derived data cache\u0022 ),\r\n\t\t( \u0022LogAssetRegistry\u0022, \u0022Unreal: reading the asset registry\u0022 ),\r\n\t\t( \u0022LogPython\u0022, \u0022Unreal: starting Python\u0022 ),\r\n\t};\r\n\r\n\t/// \u003Csummary\u003EIndex into \u003Csee cref=\u0022BootPhases\u0022/\u003E for a raw log line, or -1 for noise.\u003C/summary\u003E\r\n\tstatic int BootPhase( string line )\r\n\t{\r\n\t\tfor ( int i = BootPhases.Length - 1; i \u003E= 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/// \u003Csummary\u003E\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\u0027s sync context (awaited reads \u002B 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/// \u003Csee cref=\u0022Heartbeat\u0022/\u003E 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/// \u003C/summary\u003E\r\n\tstatic async Task TailLog( Process proc, string logPath, Action\u003CExportEvent\u003E 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 = \u0022Starting Unreal\u0022;\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\u0027t look like it restarted its clock.\r\n\t\t\tvar elapsed = $\u0022{(int)clock.Elapsed.TotalMinutes}:{clock.Elapsed.Seconds:00}\u0022;\r\n\t\t\tonProgress( new ExportEvent( done, total, $\u0022{message} ({elapsed})\u0022 ) );\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 \u003E= 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 \u0026\u0026 !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\u0027s own python startup chatters on LogPython too - hold script messages back until\r\n\t\t// our script announces itself (\u0022=== ue_export: ... ===\u0022).\r\n\t\tvar sawScript = false;\r\n\t\tvar bootPhase = -1;\r\n\t\tvar carry = \u0022\u0022;\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 \u003E 0 )\r\n\t\t\t{\r\n\t\t\t\tcarry \u002B= chunk;\r\n\r\n\t\t\t\tint nl;\r\n\t\t\t\twhile ( (nl = carry.IndexOf( \u0027\\n\u0027 )) \u003E= 0 )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar line = carry[..nl].TrimEnd( \u0027\\r\u0027 );\r\n\t\t\t\t\tcarry = carry[(nl \u002B 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\u0027s 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\u0027s python startup chatters on LogPython as well, so a\r\n\t\t\t\t\t// LogPython line alone doesn\u0027t 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( \u0022ue_export\u0022, 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 \u003E 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// \u0022...LogPython: [6/98] SM_int_ceiling_300_01\u0022 - the per-mesh export progress our script logs.\r\n\tstatic readonly Regex MeshProgressLine = new( @\u0022LogPython:\\s*\\[(\\d\u002B)/(\\d\u002B)\\]\\s*(.\u002B)$\u0022, RegexOptions.Compiled );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Distil one raw Unreal log line into a progress event, or null for noise. Only our\r\n\t/// own script\u0027s output (LogPython) is surfaced; indented LogPython lines are per-slot\r\n\t/// texture detail and stay hidden.\r\n\t/// \u003C/summary\u003E\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$\u0022Exporting {match.Groups[3].Value.Trim()}\u0022 );\r\n\t\t}\r\n\r\n\t\tvar idx = line.IndexOf( \u0022LogPython: \u0022, StringComparison.Ordinal );\r\n\t\tif ( idx \u003E= 0 )\r\n\t\t{\r\n\t\t\tvar msg = line[(idx \u002B \u0022LogPython: \u0022.Length)..];\r\n\t\t\tif ( msg.Length \u003E 0 \u0026\u0026 !char.IsWhiteSpace( msg[0] ) )\r\n\t\t\t\treturn new ExportEvent( null, null, msg.Trim( \u0027=\u0027, \u0027 \u0027 ) );\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( \u0022unrealimporter\u0022, \u0022Unreal importer debug tools\u0022 )]\npublic static class SceneDebugTools\n{\n\t/// \u003Csummary\u003ERun AssetImporter.Import on a staging folder (manifest.json \u002B FBX \u002B PNG).\u003C/summary\u003E\n\t/// \u003Cparam name=\u0022stagingDir\u0022\u003EStaging folder containing manifest.json.\u003C/param\u003E\n\t/// \u003Cparam name=\u0022outputFolder\u0022\u003EOutput folder inside the project\u0027s Assets/.\u003C/param\u003E\n\t/// \u003Cparam name=\u0022layout\u0022\u003EGrouped (default), Flat, ClassicSource or PerAsset.\u003C/param\u003E\n\t/// \u003Cparam name=\u0022materialOutput\u0022\u003EMaterial (default), Terrain or Decal.\u003C/param\u003E\n\t/// \u003Cparam name=\u0022perAssetFolderDepth\u0022\u003EPerAsset layout: folders up the /Game path to name each folder after (0 = own name).\u003C/param\u003E\n\t/// \u003Cparam name=\u0022maxTextureSize\u0022\u003ECap every written texture\u0027s longest edge, downscaling bigger sources (0 = keep as-is).\u003C/param\u003E\n\t[McpTool( \u0022unreal_scene_import_test\u0022 )]\n\tpublic static async Task\u003Cstring\u003E 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, \u0022manifest.json\u0022 );\n\t\tif ( !File.Exists( manifestPath ) )\n\t\t\treturn $\u0022no manifest.json in {stagingDir}\u0022;\n\n\t\tif ( !System.Enum.TryParse\u003CImportLayout\u003E( layout ?? \u0022Grouped\u0022, ignoreCase: true, out var importLayout ) )\n\t\t\treturn $\u0022unknown layout \u0027{layout}\u0027\u0022;\n\t\tif ( !System.Enum.TryParse\u003CMaterialOutput\u003E( materialOutput ?? \u0022Material\u0022, ignoreCase: true, out var matOutput ) )\n\t\t\treturn $\u0022unknown material output \u0027{materialOutput}\u0027\u0022;\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 = $\u0022models={summary.Models} materials={summary.Materials} textures={summary.Textures} \u0022 \u002B\n\t\t\t$\u0022placements={summary.Placements} prefab={summary.PrefabPath ?? \u0022(none)\u0022}\u0022;\n\t\tif ( summary.Warnings.Count \u003E 0 )\n\t\t\tresult \u002B= \u0022\\nwarnings:\\n - \u0022 \u002B string.Join( \u0022\\n - \u0022, summary.Warnings.Take( 10 ) );\n\n\t\treturn result;\n\t}\n\n\t/// \u003Csummary\u003ETEMP: read tri/vert counts from a .uasset via UassetMeshStats.\u003C/summary\u003E\n\t/// \u003Cparam name=\u0022uassetPath\u0022\u003EAbsolute path to a .uasset.\u003C/param\u003E\n\t[McpTool( \u0022unreal_meshstats_test\u0022 )]\n\tpublic static async Task\u003Cstring\u003E MeshStatsTest( string uassetPath )\n\t{\n\t\tvar stats = await UassetMeshStats.LoadAsync( uassetPath );\n\t\tif ( stats is null )\n\t\t\treturn \u0022no stats found\u0022;\n\n\t\treturn $\u0022tris={stats.Triangles} verts={stats.Vertices} mats={stats.Materials} lods={stats.LODs}\u0022;\n\t}\n\n\t/// \u003Csummary\u003ETEMP: run a small headless export and log the live progress events (verifies log tailing).\u003C/summary\u003E\n\t/// \u003Cparam name=\u0022uprojectPath\u0022\u003EAbsolute path to the .uproject.\u003C/param\u003E\n\t/// \u003Cparam name=\u0022assets\u0022\u003E\u0027;\u0027-separated /Game asset paths to export.\u003C/param\u003E\n\t[McpTool( \u0022unreal_export_progress_test\u0022 )]\n\tpublic static async Task\u003Cstring\u003E 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 \u0022tools not found\u0022;\n\n\t\tvar events = new List\u003Cstring\u003E();\n\t\tvar result = await HeadlessExporter.Run( editorCmd, uprojectPath, assets.Split( \u0027;\u0027 ), script, System.Threading.CancellationToken.None,\n\t\t\tonProgress: ev =\u003E\n\t\t\t{\n\t\t\t\tvar line = $\u0022{ev.Done}/{ev.Total} {ev.Message}\u0022;\n\t\t\t\tevents.Add( line );\n\t\t\t\tLog.Info( $\u0022UEPROG {line}\u0022 );\n\t\t\t} );\n\n\t\treturn $\u0022success={result.Success} events={events.Count}\\n\u0022 \u002B string.Join( \u0022\\n\u0022, events.TakeLast( 12 ) );\n\t}\n\n\t/// \u003Csummary\u003ETEMP: open the Unreal Importer window for UI verification.\u003C/summary\u003E\n\t[McpTool( \u0022unreal_open_import_window\u0022 )]\n\tpublic static string OpenImportWindow()\n\t{\n\t\t_ = new UnrealImportWindow();\n\t\treturn \u0022opened\u0022;\n\t}\n\n\t/// \u003Csummary\u003ELoad a model and report its bounds in inches.\u003C/summary\u003E\n\t/// \u003Cparam name=\u0022modelPath\u0022\u003EModel content path, e.g. \u0022unrealimport/models/x.vmdl\u0022.\u003C/param\u003E\n\t[McpTool( \u0022unreal_model_bounds\u0022 )]\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 $\u0022failed to load {modelPath}\u0022;\n\n\t\tvar b = model.Bounds;\n\t\treturn $\u0022size=({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.##})\u0022;\n\t}\n\n\t/// \u003Csummary\u003EBounds of every .vmdl in a folder, one json object per line.\u003C/summary\u003E\n\t/// \u003Cparam name=\u0022folder\u0022\u003EAbsolute folder containing .vmdl files.\u003C/param\u003E\n\t[McpTool( \u0022unreal_all_model_bounds\u0022 )]\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, \u0022*.vmdl\u0022 ) )\n\t\t{\n\t\t\tvar rel = Path.GetRelativePath( Sandbox.Project.Current.GetAssetsPath(), f ).Replace( \u0027\\\\\u0027, \u0027/\u0027 );\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( $\u0022{{\\\u0022model\\\u0022:\\\u0022{Path.GetFileName( f )}\\\u0022,\\\u0022error\\\u0022:true}}\u0022 );\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$\u0022{{\\\u0022model\\\u0022:\\\u0022{Path.GetFileName( f )}\\\u0022,\\\u0022min\\\u0022:[{b.Mins.x:0.###},{b.Mins.y:0.###},{b.Mins.z:0.###}],\\\u0022max\\\u0022:[{b.Maxs.x:0.###},{b.Maxs.y:0.###},{b.Maxs.z:0.###}]}}\u0022 ) );\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/// \u003Csummary\u003E\n/// Shared colours \u002B painting helpers for the importer window.\n///\n/// The editor\u0027s 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\u0027s surface colours, so they still track a custom theme\n/// instead of hardcoding greys.\n/// \u003C/summary\u003E\npublic static class ImportStyle\n{\n\t/// \u003Csummary\u003ESection/panel fill - a step up from the window background.\u003C/summary\u003E\n\tpublic static Color Panel =\u003E Color.Lerp( Theme.WindowBackground, Theme.SurfaceBackground, 0.22f );\n\n\t/// \u003Csummary\u003EInput field fill - a further step up, so fields read as sunken boxes.\u003C/summary\u003E\n\tpublic static Color Input =\u003E Color.Lerp( Theme.WindowBackground, Theme.SurfaceBackground, 0.45f );\n\n\t/// \u003Csummary\u003EAlternating tree row tint (matches the editor\u0027s own scene tree).\u003C/summary\u003E\n\tpublic static Color RowStripe =\u003E Theme.SurfaceLightBackground.WithAlpha( 0.06f );\n\n\t/// \u003Csummary\u003EGive a text field / combo a visible box, since the theme\u0027s default is invisible.\u003C/summary\u003E\n\tpublic static T StyleInput\u003CT\u003E( this T widget ) where T : Widget\n\t{\n\t\twidget.SetStyles(\n\t\t\t$\u0022background-color: {Input.Hex};\u0022 \u002B\n\t\t\t$\u0022border: 1px solid {Theme.Border.WithAlpha( 0.5f ).Hex};\u0022 \u002B\n\t\t\t$\u0022border-radius: {Theme.ControlRadius}px;\u0022 );\n\n\t\treturn widget;\n\t}\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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/// \u003Csummary\u003E\r\n/// Output texture filenames (no path) for a processed material, or null where absent.\r\n/// \u003C/summary\u003E\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/// \u003Csummary\u003EDisplacement/height map. Unused by complex.shader; terrain \u002B decal resources want it.\u003C/summary\u003E\r\n\tpublic string Height;\r\n\r\n\t/// \u003Csummary\u003EPacked R=Roughness G=Metal B=Occlusion, for decal resources (which take one RMO map).\u003C/summary\u003E\r\n\tpublic string RoughMetalOcclusion;\r\n\r\n\t/// \u003Csummary\u003EGrayscale emissive mask extracted from the albedo\u0027s alpha (opaque materials with emissive params).\u003C/summary\u003E\r\n\tpublic string SelfIllumMask;\r\n}\r\n\r\n/// \u003Csummary\u003EWhat the albedo\u0027s alpha channel means for this material - decided from the Unreal blend mode.\u003C/summary\u003E\r\npublic enum AlphaRole\r\n{\r\n\t/// \u003Csummary\u003EUE blends/masks with it - extract as a translucency/alpha-test map.\u003C/summary\u003E\r\n\tTranslucency,\r\n\t/// \u003Csummary\u003EOpaque material with emissive params - the alpha is a self-illum mask.\u003C/summary\u003E\r\n\tSelfIllum,\r\n\t/// \u003Csummary\u003EOpaque, no emissive - the alpha packs something we can\u0027t interpret; ignore it.\u003C/summary\u003E\r\n\tIgnore,\r\n}\r\n\r\n/// \u003Csummary\u003E\r\n/// Turns Unreal\u0027s raw exported textures into sbox-ready ones using sbox\u0027s Bitmap:\r\n///  - splits RMA (R=roughness, G=metallic, B=ao) into separate grayscale maps\r\n///  - flips the normal\u0027s green channel (Unreal DirectX -\u003E sbox OpenGL)\r\n///  - extracts the albedo\u0027s alpha to a separate map\r\n///  - writes everything as \u0026lt;base\u0026gt;_\u0026lt;role\u0026gt;.png (lowercase, no dots)\r\n/// \u003C/summary\u003E\r\npublic static class TextureProcessor\r\n{\r\n\t/// \u003Cparam name=\u0022packRmo\u0022\u003E\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/// \u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022wantHeight\u0022\u003EAlso process the displacement map (terrain \u002B decal resources use it).\u003C/param\u003E\r\n\t/// \u003Cparam name=\u0022maxTextureSize\u0022\u003E\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/// \u003C/param\u003E\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\u0027s 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 \u0026\u0026 !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, \u0022alpha\u0022, dispose: true );\r\n\t\t}\r\n\r\n\t\t// --- Color (\u002B 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\u0027s tint mask \u002B 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, \u0022color\u0022 );\r\n\r\n\t\t\t\tif ( result.Alpha is null \u0026\u0026 !alb.IsOpaque() \u0026\u0026 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, \u0022selfillum\u0022, dispose: true );\r\n\t\t\t\t\telse\r\n\t\t\t\t\t\tresult.Alpha = Save( ExtractAlpha( alb ), outputTextureDir, baseName, \u0022alpha\u0022, 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, \u0022normal\u0022, dispose: true );\r\n\t\t}\r\n\r\n\t\t// --- Packed RMA/ORM -\u003E 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, \u0022rmo\u0022, 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, \u0022roughness\u0022, dispose: true );\r\n\t\t\t\t\tresult.Metallic = Save( ExtractChannel( rma, metal ), outputTextureDir, baseName, \u0022metallic\u0022, dispose: true );\r\n\t\t\t\t\tresult.Ao = Save( ExtractChannel( rma, ao ), outputTextureDir, baseName, \u0022ao\u0022, 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, \u0022roughness\u0022, ref result.Roughness, maxTextureSize );\r\n\t\tProcessSingle( mat.Metal, stagingDir, outputTextureDir, baseName, \u0022metallic\u0022, ref result.Metallic, maxTextureSize );\r\n\t\tProcessSingle( mat.Ao, stagingDir, outputTextureDir, baseName, \u0022ao\u0022, ref result.Ao, maxTextureSize );\r\n\t\tProcessSingle( mat.Emissive, stagingDir, outputTextureDir, baseName, \u0022emissive\u0022, ref result.Emissive, maxTextureSize );\r\n\r\n\t\tif ( wantHeight )\r\n\t\t\tProcessSingle( mat.Height, stagingDir, outputTextureDir, baseName, \u0022height\u0022, 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 \u0026\u0026 result.RoughMetalOcclusion is null \u0026\u0026 (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, \u0022rmo\u0022, 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\u0027t 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, \u0022tintmask\u0022, dispose: true );\r\n\t\t}\r\n\r\n\t\treturn result;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\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\u0027s 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/// \u003C/summary\u003E\r\n\tstatic (int rough, int metal, int ao) RmaChannels( string order ) =\u003E (order ?? \u0022rma\u0022).ToLowerInvariant() switch\r\n\t{\r\n\t\t// \u0022aorm\u0022 is \u0022orm\u0022 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\u0022orm\u0022 or \u0022arm\u0022 or \u0022aorm\u0022 =\u003E (1, 2, 0),\r\n\t\t\u0022mra\u0022 =\u003E (1, 0, 2),\r\n\t\t_ =\u003E (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( \u0027/\u0027, 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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\r\n\tstatic Bitmap Downscale( Bitmap bmp, int maxSize )\r\n\t{\r\n\t\tif ( maxSize \u003C= 0 || (bmp.Width \u003C= maxSize \u0026\u0026 bmp.Height \u003C= 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 = $\u0022{baseName}_{role}.png\u0022;\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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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 \u003C px.Length; i \u002B= 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 \u003C 4; n\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tif ( v[n] \u003C min[n] ) min[n] = v[n];\r\n\t\t\t\tif ( v[n] \u003E 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 \u003C count; n\u002B\u002B )\r\n\t\t{\r\n\t\t\tif ( max[n] - min[n] \u003E 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/// \u003Csummary\u003E\r\n\t/// New bitmap with the source channels moved into R=Rough, G=Metal, B=AO order.\r\n\t/// A source that\u0027s already RMA comes out unchanged.\r\n\t/// \u003C/summary\u003E\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 \u003C pixels.Length; i\u002B\u002B )\r\n\t\t{\r\n\t\t\tvar c = pixels[i];\r\n\t\t\tfloat Ch( int n ) =\u003E 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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\r\n\tstatic Bitmap Combine( string dir, string roughFile, string metalFile, string aoFile )\r\n\t{\r\n\t\tBitmap Read( string f ) =\u003E 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 \u0026\u0026 (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 \u003C outPixels.Length; i\u002B\u002B )\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/// \u003Csummary\u003ENew grayscale bitmap from one channel (0=R, 1=G, 2=B, 3=A).\u003C/summary\u003E\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 \u003C pixels.Length; i\u002B\u002B )\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/// \u003Csummary\u003ENew bitmap with the green channel inverted (DirectX -\u003E OpenGL normals).\u003C/summary\u003E\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 \u003C pixels.Length; i\u002B\u002B )\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/// \u003Csummary\u003ENew grayscale bitmap holding the source alpha.\u003C/summary\u003E\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 \u003C pixels.Length; i\u002B\u002B )\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"}]}