Editor/Core/UnityPrefabConverter.cs

Editor code that converts Unity prefabs and scenes into s&box prefab and scene JSON. It parses Unity YAML objects, maps GameObjects, components, meshes, colliders, terrains, lights, cameras, reflection probes and audio sources, composes VMDLs from meshes, and emits scene/prefab JSON with stable deterministic IDs and reports.

File AccessReflection
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;

namespace ImportUnityPackage;

/// <summary>An imported model as prefabs and scenes reference it.</summary>
internal sealed class PrefabModel
{
	public string Vmdl { get; init; }
	public UnityModel Info { get; init; }
	public double Scale { get; init; }
	public string Name { get; init; }
	/// <summary>The mesh file the VMDLs read (the source, or a repaired or ASCII-named copy).</summary>
	public string MeshFile { get; init; }
	/// <summary>A VMDL holding only one mesh node, for renderers that show one mesh of a multi-mesh model.</summary>
	public Func<string, string> MeshVmdl { get; init; }
	/// <summary>The material group matching a slot → material GUID assignment ("" for the default group), and whether it matched exactly.</summary>
	public Func<IReadOnlyDictionary<string, string>, (string Group, bool Exact)> Group { get; init; }
	/// <summary>A physics-only VMDL for a mesh node (null for the whole model), as a convex hull or the exact mesh.</summary>
	public Func<string, bool, string> CollisionVmdl { get; init; }
}

/// <summary>
/// A VMDL to compose from mesh files: LOD levels of render meshes (empty for a physics-only model), switch thresholds
/// between levels, material remaps (slot.vmat to material) and an optional physics shape. Name is the output path
/// without extension, relative to the import destination.
/// </summary>
internal sealed record ComposedModel( string Name, IReadOnlyList<IReadOnlyList<MeshPart>> Lods, IReadOnlyList<double> Thresholds, IReadOnlyDictionary<string, string> Remaps, MeshPart Physics = null, bool Convex = false );

/// <summary>A converted skybox: its sky material, the SkyBox2D tint (null for none) and the sky object's yaw in degrees.</summary>
internal sealed record ConvertedSky( string Material, double[] Tint, double Yaw );

/// <summary>An OBJ written from a Unity mesh: its path relative to the destination, its resource path and submesh count, or why it could not be.</summary>
internal sealed record GeneratedMesh( string Relative, string Reference, int Submeshes, string Error );

/// <summary>An imported terrain as scenes and prefabs place it.</summary>
internal sealed class PrefabTerrain
{
	/// <summary>The .terrain resource.</summary>
	public string Path { get; init; }
	/// <summary>Side length and height range in s&amp;box units.</summary>
	public double Size { get; init; }
	public double Height { get; init; }
	/// <summary>The Unity terrain's size and trees (its maps are released).</summary>
	public UnityTerrainData Data { get; init; }
}

/// <summary>
/// Converts Unity prefabs and scenes into s&amp;box prefab/scene JSON: GameObject hierarchy and transforms, mesh renderers as
/// ModelRenderers (with the material group matching their materials), colliders, terrains, lights, cameras, reflection
/// probes and audio sources, nested prefabs as prefab references and model instances as whole-model renderers. Scripts
/// and effects are not converted.
/// </summary>
internal sealed partial class UnityPrefabConverter
{
	readonly Dictionary<string, UnityAsset> assets;
	readonly Dictionary<string, PrefabModel> models;
	readonly Dictionary<string, string> prefabOutputs;
	readonly Func<string, IReadOnlyList<UnityYamlObject>> parse;
	public UnityPrefabConverter( Dictionary<string, UnityAsset> assets, Dictionary<string, PrefabModel> models, Dictionary<string, string> prefabOutputs, Func<string, IReadOnlyList<UnityYamlObject>> parse )
	{
		this.assets = assets; this.models = models; this.prefabOutputs = prefabOutputs; this.parse = parse;
	}

	/// <summary>Converted materials by material GUID (VMAT resource paths).</summary>
	public Dictionary<string, string> Materials { get; init; } = new( StringComparer.OrdinalIgnoreCase );
	/// <summary>Writes a Unity mesh (by owning file GUID and file ID, or a built-in mesh) as an OBJ.</summary>
	public Func<string, long, GeneratedMesh> MeshObj { get; init; }
	/// <summary>Writes a composed VMDL and returns its resource path.</summary>
	public Func<ComposedModel, string> Compose { get; init; }
	/// <summary>Puts a collider's physics shape (exact, or one convex hull) in a VMDL written by this import; false when it holds a different shape.</summary>
	public Func<string, MeshPart, bool, bool> EmbedPhysics { get; init; }

	/// <summary>Converted skybox materials by material GUID.</summary>
	public Dictionary<string, ConvertedSky> Skies { get; init; } = new( StringComparer.OrdinalIgnoreCase );

	/// <summary>Converted terrains by TerrainData GUID.</summary>
	public Dictionary<string, PrefabTerrain> Terrains { get; init; } = new( StringComparer.OrdinalIgnoreCase );

	/// <summary>What one conversion produced, for the import report.</summary>
	public sealed class Summary
	{
		public string Source { get; init; }
		public int Objects { get; set; }
		public int Renderers { get; set; }
		public int ModelInstances { get; set; }
		public int PrefabInstances { get; set; }
		public int InlinedInstances { get; set; }
		public int Colliders { get; set; }
		public int Terrains { get; set; }
		public int GeneratedMeshes { get; set; }
		public int LodGroups { get; set; }
		public int Lights { get; set; }
		public int Cameras { get; set; }
		public int Probes { get; set; }
		public int Sounds { get; set; }
		public int Skies { get; set; }
		public int Trees { get; set; }
		public SortedDictionary<string, int> Skipped { get; } = new( StringComparer.Ordinal );
		public List<string> Notes { get; } = new();
		internal void Skip( string type ) => Skipped[type] = Skipped.GetValueOrDefault( type ) + 1;
	}

	/// <summary>GUIDs of the meshes, prefabs, terrains and audio clips a prefab or scene places.</summary>
	internal static IEnumerable<string> PlacedAssets( string text ) =>
		Regex.Matches( text, @"(?:m_Mesh|m_SourcePrefab|m_TerrainData|m_audioClip):\s*\{[^}\r\n]*guid:\s*([0-9a-fA-F]{32})" ).Select( m => m.Groups[1].Value.ToLowerInvariant() ).Distinct();

	/// <summary>
	/// Materials of renderers whose meshes the importer writes itself (built-in primitives, meshes stored in prefabs,
	/// scenes or .asset files), so a plan imports them; model meshes bring their materials through the model.
	/// </summary>
	internal static IEnumerable<string> GeneratedMeshMaterials( IReadOnlyList<UnityYamlObject> objects, Func<string, bool> isModel )
	{
		var filters = objects.Where( o => o.ClassId == 33 && !o.Stripped ).GroupBy( o => Ref( o.Body, "m_GameObject" ).FileId ).ToDictionary( g => g.Key, g => g.First() );
		foreach ( var renderer in objects.Where( o => o.ClassId is 23 or 137 && !o.Stripped ) )
		{
			var mesh = renderer.ClassId == 137 ? Ref( renderer.Body, "m_Mesh" ) : filters.TryGetValue( Ref( renderer.Body, "m_GameObject" ).FileId, out var filter ) ? Ref( filter.Body, "m_Mesh" ) : default;
			if ( mesh.FileId == 0 || mesh.HasGuid && !mesh.Guid.Equals( UnityMesh.BuiltinGuid, StringComparison.OrdinalIgnoreCase ) && isModel( mesh.Guid ) ) continue;
			foreach ( var material in renderer.Body["m_Materials"]?.Items ?? Array.Empty<YamlNode>() )
				if ( material.Reference.HasGuid ) yield return material.Reference.Guid;
		}
	}

	/// <summary>Deterministic GUIDs, so an unchanged source converts to identical files and references stay stable.</summary>
	internal static string Id( string guid, long fileId, string part )
	{
		var hash = MD5.HashData( Encoding.UTF8.GetBytes( $"{guid}:{fileId}:{part}" ) );
		hash[6] = (byte)((hash[6] & 0x0F) | 0x30); hash[8] = (byte)((hash[8] & 0x3F) | 0x80);
		return new Guid( hash ).ToString();
	}

	static UnityReference Ref( YamlNode body, string key ) => body?[key]?.Reference ?? default;
	static string F( double v ) => (Math.Abs( v ) < 1e-9 ? 0 : v).ToString( "0.######", CultureInfo.InvariantCulture );
	static string Vec( double[] v ) => string.Join( ",", v.Select( F ) );

	/// <summary>One parsed file: objects by ID, GameObjects' components and transforms.</summary>
	sealed class Document
	{
		public string Guid;
		public Dictionary<long, UnityYamlObject> Objects = new();
		public Dictionary<long, List<UnityYamlObject>> Components = new();
		public Dictionary<long, UnityYamlObject> TransformOf = new();
		public HashSet<long> HiddenRenderers = new();
		/// <summary>LOD groups by their LOD0 renderer (when LOD0 has one renderer): each level's screen-relative height and renderers.</summary>
		public Dictionary<long, List<(double Height, List<long> Renderers)>> LodGroups = new();
		/// <summary>Each LOD group's size (Unity's local m_Size, its largest bounds axis) in inches, by its LOD0 renderer.</summary>
		public Dictionary<long, double> LodSizes = new();
		/// <summary>The HDRP exposure reference of this file, once computed.</summary>
		public double? HdrpReference;
		/// <summary>VMDLs of generated meshes by GameObject, mesh GUID and file ID, for colliders on the same object.</summary>
		public Dictionary<(long, string, long), string> GeneratedRenders = new();
		// Built once per file, so per-object lookups do not scan the whole document (scenes hold tens of thousands of objects).
		/// <summary>Root transforms (no father), in file order.</summary>
		public List<UnityYamlObject> RootTransforms = new();
		/// <summary>Prefab instances by the transform they are placed under (0 for the file's roots), in file order.</summary>
		public Dictionary<long, List<UnityYamlObject>> InstancesByParent = new();
		/// <summary>Per prefab instance: the source objects of the stripped transforms this file keeps for it.</summary>
		public Dictionary<long, List<long>> StrippedTransformSources = new();
		/// <summary>Per prefab instance: transforms this file adds under that instance's objects.</summary>
		public Dictionary<long, List<UnityYamlObject>> AddedUnderInstance = new();
		/// <summary>Per prefab instance: the stripped GameObjects this file keeps for it (components added to its objects hang off them).</summary>
		public Dictionary<long, List<long>> StrippedGameObjects = new();
		public IEnumerable<UnityYamlObject> InstancesUnder( long parent ) => InstancesByParent.GetValueOrDefault( parent ) ?? Enumerable.Empty<UnityYamlObject>();
		public IEnumerable<UnityYamlObject> AddedUnder( long instance ) => AddedUnderInstance.GetValueOrDefault( instance ) ?? Enumerable.Empty<UnityYamlObject>();
		public Document( string guid, IReadOnlyList<UnityYamlObject> objects )
		{
			Guid = guid;
			foreach ( var o in objects ) Objects.TryAdd( o.FileId, o );
			static void Add<T>( Dictionary<long, List<T>> index, long key, T value )
			{
				if ( !index.TryGetValue( key, out var list ) ) index[key] = list = new();
				list.Add( value );
			}
			foreach ( var o in Objects.Values )
			{
				if ( o.ClassId == 1001 && o.Type == "PrefabInstance" && !o.Stripped ) Add( InstancesByParent, Ref( o.Body["m_Modification"], "m_TransformParent" ).FileId, o );
				if ( o.ClassId == 1 && o.Stripped ) Add( StrippedGameObjects, Ref( o.Body, "m_PrefabInstance" ).FileId, o.FileId );
				if ( o.ClassId is not (4 or 224) ) continue;
				if ( o.Stripped ) { Add( StrippedTransformSources, Ref( o.Body, "m_PrefabInstance" ).FileId, Ref( o.Body, "m_CorrespondingSourceObject" ).FileId ); continue; }
				var father = Ref( o.Body, "m_Father" ).FileId;
				if ( father == 0 ) RootTransforms.Add( o );
				else if ( Objects.TryGetValue( father, out var parent ) && parent.Stripped ) Add( AddedUnderInstance, Ref( parent.Body, "m_PrefabInstance" ).FileId, o );
			}
			foreach ( var o in objects.Where( o => !o.Stripped && o.ClassId != 1 && o.Body?["m_GameObject"] != null ) )
			{
				var owner = Ref( o.Body, "m_GameObject" ).FileId;
				if ( !Components.TryGetValue( owner, out var list ) ) Components[owner] = list = new();
				list.Add( o );
				if ( o.ClassId is 4 or 224 ) TransformOf[owner] = o;
			}
			// LODGroup: renderers only in lower levels are not converted on their own; a LOD0 renderer whose levels line up
			// gets one VMDL holding every level (LodModel). A renderer also in LOD0 stays.
			foreach ( var group in objects.Where( o => o.ClassId == 205 && !o.Stripped ) )
			{
				var lods = group.Body["m_LODs"]?.Items ?? Array.Empty<YamlNode>();
				var levels = lods.Select( l => (Height: l["screenRelativeHeight"]?.Number ?? 0, Renderers: (l["renderers"]?.Items ?? Array.Empty<YamlNode>()).Select( r => Ref( r, "renderer" ).FileId ).Where( id => id != 0 ).ToList()) ).ToList();
				// A trailing level without renderers is Unity's culled range, not a level to show.
				while ( levels.Count > 1 && levels[^1].Renderers.Count == 0 ) levels.RemoveAt( levels.Count - 1 );
				for ( var i = 1; i < levels.Count; i++ )
					foreach ( var renderer in levels[i].Renderers )
						if ( levels.Count == 0 || !levels[0].Renderers.Contains( renderer ) ) HiddenRenderers.Add( renderer );
				if ( levels.Count > 1 && levels[0].Renderers.Count == 1 && LodGroups.TryAdd( levels[0].Renderers[0], levels ) )
					LodSizes[levels[0].Renderers[0]] = (group.Body["m_Size"]?.Number ?? 1) * UnityTransform.InchesPerMeter;
			}
		}
		public long GameObjectOf( UnityYamlObject transform ) => Ref( transform.Body, "m_GameObject" ).FileId;
	}

	readonly Dictionary<string, Document> documents = new( StringComparer.OrdinalIgnoreCase );
	Document Load( string guid )
	{
		if ( !documents.TryGetValue( guid, out var document ) ) documents[guid] = document = new( guid, parse( guid ) );
		return document;
	}

	/// <summary>Unity modifications grouped by the source object they target.</summary>
	sealed class Overrides
	{
		readonly Dictionary<long, Dictionary<string, YamlNode>> byTarget = new();
		public Overrides( YamlNode modifications )
		{
			foreach ( var m in modifications?.Items ?? Array.Empty<YamlNode>() )
			{
				var target = Ref( m, "target" ).FileId;
				var path = m["propertyPath"]?.Value;
				if ( path == null ) continue;
				if ( !byTarget.TryGetValue( target, out var list ) ) byTarget[target] = list = new( StringComparer.Ordinal );
				list[path] = m;
			}
		}
		public IReadOnlyDictionary<string, YamlNode> For( long target ) => byTarget.GetValueOrDefault( target ) ?? (IReadOnlyDictionary<string, YamlNode>)new Dictionary<string, YamlNode>();
		public IEnumerable<long> Targets => byTarget.Keys;

		/// <summary>The name the outermost placement gives the instance root, when it renames it.</summary>
		public string RootName { get; private set; }
		/// <summary>Whether an outer modification applied to this instance.</summary>
		public bool Merged { get; private set; }

		/// <summary>
		/// This instance's modifications with an outer file's modifications on top, as Unity applies them (after and over
		/// the instance's own). The outer ones target objects of this instance as <paramref name="document"/> sees them:
		/// a stripped copy naming its source object, or (Unity 2018.3+) an unserialized ID equal to instance ID XOR source
		/// ID with the sign bit cleared. Of the two possible source IDs, one this instance already modifies or one
		/// <paramref name="known"/> recognizes is used.
		/// </summary>
		public Overrides With( Overrides outer, Document document, long instance, Func<long, bool> known )
		{
			if ( outer == null ) return this;
			RootName = outer.RootName;
			foreach ( var target in outer.Targets )
			{
				long source;
				if ( document.Objects.TryGetValue( target, out var stripped ) )
				{
					if ( !stripped.Stripped || Ref( stripped.Body, "m_PrefabInstance" ).FileId != instance ) continue;
					source = Ref( stripped.Body, "m_CorrespondingSourceObject" ).FileId;
				}
				else
				{
					var masked = (target ^ instance) & long.MaxValue;
					var match = new[] { masked, masked | long.MinValue }.Where( c => byTarget.ContainsKey( c ) || known( c ) ).ToArray();
					if ( match.Length != 1 ) continue;
					source = match[0];
				}
				if ( !byTarget.TryGetValue( source, out var list ) ) byTarget[source] = list = new( StringComparer.Ordinal );
				foreach ( var (path, m) in outer.For( target ) ) list[path] = m;
				Merged = true;
				if ( RootName == null && Text( outer.For( target ), "m_Name" ) is string name ) RootName = name;
			}
			return this;
		}
		public static double? Number( IReadOnlyDictionary<string, YamlNode> values, string path ) => values.TryGetValue( path, out var m ) && m["value"]?.Number is double v ? v : null;
		public static string Text( IReadOnlyDictionary<string, YamlNode> values, string path ) => values.TryGetValue( path, out var m ) ? m["value"]?.Value : null;
		public static UnityReference Object( IReadOnlyDictionary<string, YamlNode> values, string path ) => values.TryGetValue( path, out var m ) ? Ref( m, "objectReference" ) : default;
	}

	static (double[] Position, double[] Rotation, double[] Scale) Local( YamlNode transform, IReadOnlyDictionary<string, YamlNode> overrides,
		(double[] Position, double[] Rotation, double[] Scale)? defaults = null )
	{
		var fallback = defaults ?? (new double[] { 0, 0, 0 }, new double[] { 0, 0, 0, 1 }, new double[] { 1, 1, 1 });
		double[] Axis( string property, double[] fallbacks, params string[] axes ) =>
			axes.Select( ( a, i ) => overrides != null && Overrides.Number( overrides, $"{property}.{a}" ) is double v ? v : transform?[property]?[a]?.Number ?? fallbacks[i] ).ToArray();
		var rotation = Axis( "m_LocalRotation", fallback.Rotation, "x", "y", "z", "w" );
		return (Axis( "m_LocalPosition", fallback.Position, "x", "y", "z" ), rotation.All( c => c == 0 ) ? new double[] { 0, 0, 0, 1 } : rotation, Axis( "m_LocalScale", fallback.Scale, "x", "y", "z" ));
	}

	/// <summary>The change of basis from a model's file space (its units and FBX axes) into s&amp;box model space, as the VMDL import applies it.</summary>
	static UnityTransform Basis( PrefabModel model ) =>
		UnityTransform.Scaling( model.Scale, model.Scale, model.Scale ) * UnityTransform.Linear( new double[,] { { 0, 0, 1 }, { 1, 0, 0 }, { 0, 1, 0 } } );

	static void SetTransform( JsonObject target, (double[] Position, double[] Rotation, double[] Scale) unity )
	{
		var (position, rotation, scale) = UnityTransform.FromUnity( unity.Position, unity.Rotation, unity.Scale );
		target["Position"] = Vec( position ); target["Rotation"] = Vec( rotation ); target["Scale"] = Vec( scale );
	}

	static JsonObject GameObject( string id, string name, bool enabled ) => new()
	{
		["__guid"] = id, ["__version"] = 2, ["Flags"] = 0, ["Name"] = name, ["Position"] = "0,0,0", ["Rotation"] = "0,0,0,1", ["Scale"] = "1,1,1",
		["Tags"] = "", ["Enabled"] = enabled, ["Components"] = new JsonArray(), ["Children"] = new JsonArray()
	};
	static JsonObject Component( string type, string id, bool enabled ) => new() { ["__type"] = "Sandbox." + type, ["__guid"] = id, ["__enabled"] = enabled, ["Flags"] = 0 };

	public string ConvertPrefab( UnityAsset prefab, Summary summary )
	{
		var document = Load( prefab.Guid );
		var root = document.RootTransforms.FirstOrDefault();
		JsonObject rootObject;
		if ( root != null ) rootObject = Node( document, root, summary, document.Guid, 0 );
		else if ( document.InstancesUnder( 0 ).FirstOrDefault() is { } variant )
			rootObject = Inline( document, variant, summary, document.Guid, 0 ) ?? GameObject( Id( prefab.Guid, 0, "go" ), System.IO.Path.GetFileNameWithoutExtension( prefab.Path ), true );
		else throw new System.IO.InvalidDataException( "The prefab has no root object." );
		// Whatever places the prefab sets its position; the root keeps its rotation and scale, as Unity instantiates them.
		rootObject["Position"] = "0,0,0";
		convertedRoots[prefab.Guid] = rootObject;
		var file = new JsonObject { ["RootObject"] = rootObject, ["ShowInMenu"] = false, ["MenuPath"] = null, ["MenuIcon"] = null, ["DontBreakAsTemplate"] = false, ["ResourceVersion"] = 2, ["__references"] = new JsonArray(), ["__version"] = 2 };
		return file.ToJsonString( new JsonSerializerOptions { WriteIndented = true } );
	}

	readonly Dictionary<string, JsonObject> convertedRoots = new( StringComparer.OrdinalIgnoreCase );
	readonly HashSet<string> converting = new( StringComparer.OrdinalIgnoreCase );

	/// <summary>The root object of a converted prefab (converting it when needed), whose GUID instances refer to.</summary>
	JsonObject PrefabRoot( string guid )
	{
		if ( convertedRoots.TryGetValue( guid, out var root ) ) return root;
		if ( !assets.TryGetValue( guid, out var prefab ) || !converting.Add( guid ) ) return null;
		try { ConvertPrefab( prefab, new Summary { Source = prefab.Path } ); }
		catch ( System.IO.InvalidDataException ) { }
		finally { converting.Remove( guid ); }
		return convertedRoots.GetValueOrDefault( guid );
	}

	/// <summary>A Unity scene as an s&amp;box scene: its placed objects, lights, cameras and environment, without scripts.</summary>
	public string ConvertScene( UnityAsset scene, Summary summary, string title )
	{
		var document = Load( scene.Guid );
		var objects = new JsonArray();
		if ( Environment( document, summary, document.Guid ) is { } environment ) objects.Add( environment );
		var roots = document.RootTransforms
			.OrderBy( o => o.Body["m_RootOrder"]?.Integer ?? 0 ).ToList();
		foreach ( var root in roots ) if ( Node( document, root, summary, document.Guid, 0 ) is { } node && Keep( node ) ) objects.Add( node );
		foreach ( var instance in document.InstancesUnder( 0 ) )
			if ( Instance( document, instance, summary, document.Guid, 0 ) is { } node ) objects.Add( node );
		var file = new JsonObject
		{
			["__guid"] = Id( scene.Guid, 0, "scene" ),
			["GameObjects"] = objects,
			["SceneProperties"] = new JsonObject { ["NetworkInterpolation"] = true, ["PhysicsMode"] = "Physics3D", ["TimeScale"] = 1, ["WantsSystemScene"] = true,
				["Metadata"] = new JsonObject { ["Title"] = title }, ["NavMesh"] = new JsonObject { ["Enabled"] = false } },
			["ResourceVersion"] = 2, ["Title"] = null, ["Description"] = null, ["__references"] = new JsonArray(), ["__version"] = 2
		};
		return file.ToJsonString( new JsonSerializerOptions { WriteIndented = true } );
	}

	// Scene roots that hold nothing convertible (cameras, lights, managers) are left out.
	static bool Keep( JsonObject node ) => (node["Components"] as JsonArray)?.Count > 0 || ((node["Children"] as JsonArray)?.Any( c => c is JsonObject child && Keep( child ) ) ?? false) || node.ContainsKey( "__Prefab" );

	/// <summary>
	/// Converts one transform's GameObject and its children. <paramref name="space"/>/<paramref name="salt"/> name the GUID
	/// namespace (the file, or an inlined instance), and <paramref name="overrides"/> applies an inlined instance's modifications.
	/// </summary>
	// Hierarchy depth while converting: a cyclic hierarchy or a prefab that contains itself stops with an error.
	int nesting;
	void Enter()
	{
		if ( ++nesting > 512 ) throw new System.IO.InvalidDataException( "The object hierarchy is cyclic or nested too deeply." );
	}

	JsonObject Node( Document document, UnityYamlObject transform, Summary summary, string space, long salt, Overrides overrides = null )
	{
		Enter();
		try { return NodeBody( document, transform, summary, space, salt, overrides ); }
		finally { nesting--; }
	}

	JsonObject NodeBody( Document document, UnityYamlObject transform, Summary summary, string space, long salt, Overrides overrides )
	{
		var goId = document.GameObjectOf( transform );
		var go = document.Objects.GetValueOrDefault( goId );
		var goOverrides = overrides?.For( goId );
		var name = Overrides.Text( goOverrides ?? new Dictionary<string, YamlNode>(), "m_Name" ) ?? go?.Body["m_Name"]?.Value ?? "GameObject";
		var active = Overrides.Number( goOverrides ?? new Dictionary<string, YamlNode>(), "m_IsActive" ) ?? go?.Body["m_IsActive"]?.Number ?? 1;
		var node = GameObject( Id( space, goId ^ salt, "go" ), name.Trim(), active != 0 );
		SetTransform( node, Local( transform.Body, overrides?.For( transform.FileId ) ) );
		summary.Objects++;
		AddComponents( document, goId, go, transform, document.Components.GetValueOrDefault( goId ) ?? new(), node, overrides, summary, space, salt );
		var children = node["Children"].AsArray();
		// Children in Unity's order, then nested prefab instances parented here.
		var childIds = (transform.Body["m_Children"]?.Items ?? Array.Empty<YamlNode>()).Select( c => c.Reference.FileId ).ToList();
		foreach ( var childId in childIds )
		{
			if ( !document.Objects.TryGetValue( childId, out var child ) ) continue;
			if ( !child.Stripped ) { if ( Node( document, child, summary, space, salt, overrides ) is { } converted ) children.Add( converted ); }
		}
		// Instances nested here also receive the outer modifications aimed at their objects.
		foreach ( var instance in document.InstancesUnder( transform.FileId ) )
		{
			var nestedOverrides = overrides == null ? null : new Overrides( instance.Body["m_Modification"]?["m_Modifications"] ).With( overrides, document, instance.FileId, id => SourceHas( instance, id ) );
			if ( nestedOverrides is { Merged: false } ) nestedOverrides = null;
			if ( Instance( document, instance, summary, space, salt, nestedOverrides ) is { } placed ) children.Add( placed );
		}
		return node;
	}

	/// <summary>
	/// Converts a GameObject's components onto <paramref name="node"/>. <paramref name="transform"/> is null for components
	/// a placement adds to the root object of a nested prefab or model (Unity keeps them on a stripped copy of the object).
	/// </summary>
	void AddComponents( Document document, long goId, UnityYamlObject go, UnityYamlObject transform, List<UnityYamlObject> parts, JsonObject node,
		Overrides overrides, Summary summary, string space, long salt )
	{
		var components = node["Components"].AsArray();
		var children = node["Children"].AsArray();
		var filter = parts.FirstOrDefault( c => c.ClassId == 33 );
		// Mesh colliders after renderers, so a collider can put its shape in the model the object's renderer shows.
		foreach ( var part in parts.OrderBy( p => p.ClassId == 64 ) )
		{
			switch ( part.ClassId )
			{
				case 4 or 224 or 33: break;
				case 23 or 137:
				{
					if ( document.HiddenRenderers.Contains( part.FileId ) ) { summary.Skip( "LOD1+ renderer" ); break; }
					var mesh = part.ClassId == 137 ? Ref( part.Body, "m_Mesh" ) : Ref( filter?.Body, "m_Mesh" );
					if ( part.ClassId == 23 && overrides != null && filter != null && Overrides.Object( overrides.For( filter.FileId ), "m_Mesh" ) is { HasGuid: true } swapped ) mesh = swapped;
					var materials = (part.Body["m_Materials"]?.Items ?? Array.Empty<YamlNode>()).Select( m => m.Reference.Guid ).ToList();
					var renderOverrides = overrides?.For( part.FileId );
					if ( renderOverrides != null )
						foreach ( var (path, m) in renderOverrides )
							if ( Regex.Match( path, @"^m_Materials\.Array\.data\[([0-9]{1,6})\]$" ) is { Success: true } slot && int.Parse( slot.Groups[1].Value ) is var index && index < 64 )
							{
								while ( materials.Count <= index ) materials.Add( null );
								materials[index] = Ref( m, "objectReference" ).Guid;
							}
					var enabled = (Overrides.Number( renderOverrides ?? new Dictionary<string, YamlNode>(), "m_Enabled" ) ?? part.Body["m_Enabled"]?.Number ?? 1) != 0;
					// Unity's Cast Shadows: 0 Off, 1 On, 2 Two Sided, 3 Shadows Only (invisible, casting only).
					var castShadows = Overrides.Number( renderOverrides ?? new Dictionary<string, YamlNode>(), "m_CastShadows" ) ?? part.Body["m_CastShadows"]?.Number ?? 1;
					Renderer( document, part, components, children, mesh, materials, enabled, summary, space, goId ^ salt, castShadows );
					break;
				}
				case 64:
					MeshCollider( document, components, children, part, overrides?.For( part.FileId ), summary, space, salt );
					break;
				case 218:
					// A terrain is drawn from its object's own transform, which an added component's stripped object doesn't have.
					if ( transform == null ) summary.Notes.Add( "A terrain added to an object inside a nested prefab or model is not converted." );
					else Terrain( children, part, parts, transform, overrides, summary, space, salt );
					break;
				case 108:
					if ( Light( document, part, parts, overrides?.For( part.FileId ), summary, space, salt ) is { } light ) components.Add( light );
					break;
				case 20:
					components.Add( Camera( part, go, overrides?.For( part.FileId ), summary, space, salt ) );
					break;
				case 215:
					components.Add( ReflectionProbe( part, overrides?.For( part.FileId ), summary, space, salt ) );
					break;
				case 82:
					if ( AudioSource( part, overrides?.For( part.FileId ), summary, space, salt ) is { } sound ) components.Add( sound );
					break;
				case 154:
					// A terrain's collider becomes its EnableCollision setting.
					if ( !parts.Any( p => p.ClassId == 218 ) ) summary.Skip( "TerrainCollider without a terrain" );
					break;
				case 65 or 135 or 136:
					if ( Collider( part, overrides?.For( part.FileId ), space, salt ) is { } collider ) { components.Add( collider ); summary.Colliders++; }
					break;
				default: summary.Skip( part.Type ?? $"class {part.ClassId}" ); break;
			}
		}
	}

	/// <summary>
	/// A Unity MeshCollider as a static ModelCollider using a collision VMDL of the same mesh: one convex hull when the
	/// collider is convex, else the exact mesh. It sits at the renderer offset for that mesh, so both line up.
	/// </summary>
	void MeshCollider( Document document, JsonArray components, JsonArray children, UnityYamlObject collider, IReadOnlyDictionary<string, YamlNode> overrides, Summary summary, string space, long salt )
	{
		var ownerGuid = document.Guid;
		overrides ??= new Dictionary<string, YamlNode>();
		var mesh = Ref( collider.Body, "m_Mesh" );
		if ( Overrides.Object( overrides, "m_Mesh" ) is { FileId: not 0 } swapped ) mesh = swapped;
		bool Flag( string property, double fallback ) => (Overrides.Number( overrides, property ) ?? collider.Body[property]?.Number ?? fallback) != 0;
		if ( mesh.FileId == 0 && !mesh.HasGuid )
		{
			summary.Skip( "MeshCollider without a mesh" );
			return;
		}
		if ( Generated( mesh ) )
		{
			// Built-in, embedded and .asset meshes: a collision VMDL from the OBJ written for the mesh.
			var generated = GeneratedObj( mesh, ownerGuid, summary );
			if ( generated == null ) { summary.Skip( "MeshCollider with an unreadable mesh" ); return; }
			var convexShape = Flag( "m_Convex", 0 );
			var shape = Component( "ModelCollider", Id( space, collider.FileId ^ salt, "collider" ), Flag( "m_Enabled", 1 ) );
			var shapePart = new MeshPart( generated.Reference, Array.Empty<string>(), UnityTransform.InchesPerMeter );
			// The shape goes in the model this object's renderer shows for the same mesh; otherwise a physics-only VMDL.
			shape["Model"] = document.GeneratedRenders.TryGetValue( (Ref( collider.Body, "m_GameObject" ).FileId, mesh.Guid ?? "", mesh.FileId), out var rendered ) &&
				EmbedPhysics != null && EmbedPhysics( rendered, shapePart, convexShape )
				? rendered
				: Compose( new ComposedModel( ModelBase( mesh, ownerGuid, generated ) + "_iup_collision" + (convexShape ? "_hull" : ""), Array.Empty<IReadOnlyList<MeshPart>>(), null,
					new Dictionary<string, string>(), shapePart, convexShape ) );
			shape["Static"] = true;
			shape["IsTrigger"] = Flag( "m_IsTrigger", 0 );
			summary.Colliders++;
			components.Add( shape );
			return;
		}
		if ( !models.TryGetValue( mesh.Guid, out var model ) || model.CollisionVmdl == null )
		{
			summary.Skip( "MeshCollider with a mesh that is not a model" );
			return;
		}
		var convex = Flag( "m_Convex", 0 );
		var binding = model.Info.ResolveMesh( mesh.FileId );
		if ( binding == null ) summary.Notes.Add( $"A MeshCollider's mesh in '{model.Name}' could not be identified; the whole model's collision is used." );
		var offset = binding != null && model.Info.MeshNodes.TryGetValue( binding.Mesh, out var nodeTransform )
			? (model.Info.FileAxes * nodeTransform).Inverse().In( Basis( model ) ) : UnityTransform.Identity;
		var component = Component( "ModelCollider", Id( space, collider.FileId ^ salt, "collider" ), Flag( "m_Enabled", 1 ) );
		component["Model"] = model.CollisionVmdl( binding?.Mesh, convex );
		component["Static"] = true;
		component["IsTrigger"] = Flag( "m_IsTrigger", 0 );
		summary.Colliders++;
		if ( offset.IsIdentity() ) { components.Add( component ); return; }
		var holder = GameObject( Id( space, collider.FileId ^ salt, "collision" ), "collision", true );
		var (position, rotation, scale) = offset.Decompose();
		holder["Position"] = Vec( position ); holder["Rotation"] = Vec( rotation ); holder["Scale"] = Vec( scale );
		holder["Components"].AsArray().Add( component );
		children.Add( holder );
	}

	/// <summary>
	/// Whether a mesh reference is to a mesh the importer writes itself: a Unity built-in primitive, a mesh stored in this
	/// file or in a prefab or scene, or a mesh asset (.asset). Model files are imported as models instead.
	/// </summary>
	bool Generated( UnityReference mesh ) =>
		mesh.FileId != 0 && (!mesh.HasGuid || mesh.Guid.Equals( UnityMesh.BuiltinGuid, StringComparison.OrdinalIgnoreCase ) ||
			!models.ContainsKey( mesh.Guid ) && assets.GetValueOrDefault( mesh.Guid ) is { Kind: not UnityAssetKind.Model });

	static bool Builtin( UnityReference mesh ) => mesh.HasGuid && mesh.Guid.Equals( UnityMesh.BuiltinGuid, StringComparison.OrdinalIgnoreCase );

	/// <summary>
	/// Where a VMDL made from a generated mesh goes, without extension: beside the mesh's OBJ, except for built-in meshes,
	/// whose OBJs are shared in one folder while their VMDLs go beside the prefab or scene that places them.
	/// </summary>
	string ModelBase( UnityReference mesh, string ownerGuid, GeneratedMesh generated )
	{
		if ( !Builtin( mesh ) || !assets.TryGetValue( ownerGuid ?? "", out var owner ) ) return System.IO.Path.ChangeExtension( generated.Relative, null );
		var folder = (System.IO.Path.GetDirectoryName( owner.Path ) ?? "").Replace( '\\', '/' );
		return (folder.Length > 0 ? folder + "/" : "") + System.IO.Path.GetFileNameWithoutExtension( generated.Relative );
	}

	GeneratedMesh GeneratedObj( UnityReference mesh, string ownerGuid, Summary summary )
	{
		if ( MeshObj == null || Compose == null ) return null;
		var generated = MeshObj( mesh.HasGuid ? mesh.Guid : ownerGuid, mesh.FileId );
		if ( generated.Error != null ) { summary.Notes.Add( generated.Error ); return null; }
		return generated;
	}

	/// <summary>A VMDL for a generated mesh with the renderer's materials on its submeshes, in order.</summary>
	string GeneratedModel( UnityReference mesh, string ownerGuid, List<string> materials, Summary summary )
	{
		var generated = GeneratedObj( mesh, ownerGuid, summary );
		if ( generated == null ) return null;
		var remaps = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
		for ( var i = 0; i < generated.Submeshes && i < materials.Count; i++ )
			if ( materials[i] != null && Materials.TryGetValue( materials[i], out var vmat ) ) remaps[$"submesh{i}.vmat"] = vmat;
		// A built-in mesh is shared by every placement, so its VMDL is also named after the material it shows.
		var name = ModelBase( mesh, ownerGuid, generated );
		if ( Builtin( mesh ) && remaps.Count > 0 ) name += "_" + System.IO.Path.GetFileNameWithoutExtension( remaps.Values.First() );
		return Compose( new ComposedModel( name, new[] { (IReadOnlyList<MeshPart>)new[] { new MeshPart( generated.Reference, Array.Empty<string>(), UnityTransform.InchesPerMeter ) } }, null, remaps ) );
	}

	/// <summary>
	/// A VMDL holding every level of the renderer's LOD group, when each lower level is one renderer on the same object
	/// or a sibling with the same transform, showing one identified mesh of an imported model at the same offset. Each
	/// level's materials come from its renderer; thresholds from Unity's screen-relative heights. Otherwise null.
	/// </summary>
	string LodModel( Document document, UnityYamlObject renderer, List<(double Height, List<long> Renderers)> levels, PrefabModel model, UnityModel.RendererBinding binding, UnityTransform offset, List<string> materials )
	{
		if ( Compose == null || model.MeshFile == null ) return null;
		var lods = new List<IReadOnlyList<MeshPart>>();
		var thresholds = new List<double>();
		var remaps = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
		void Remap( UnityModel.RendererBinding b, List<string> mats )
		{
			for ( var i = 0; i < mats.Count; i++ )
			{
				var slot = b.Slots.Length == 0 ? (i == 0 ? b.Mesh : null) : i < b.Slots.Length ? b.Slots[i] : null;
				if ( slot != null && mats[i] != null && Materials.TryGetValue( mats[i], out var vmat ) ) remaps.TryAdd( slot.ToLowerInvariant() + ".vmat", vmat );
			}
		}
		lods.Add( new[] { new MeshPart( model.MeshFile, new[] { binding.Mesh }, model.Scale ) } );
		Remap( binding, materials );
		var go = Ref( renderer.Body, "m_GameObject" ).FileId;
		var transform = document.TransformOf.GetValueOrDefault( go );
		for ( var i = 1; i < levels.Count; i++ )
		{
			if ( levels[i].Renderers.Count != 1 || !document.Objects.TryGetValue( levels[i].Renderers[0], out var lower ) || lower.Stripped || lower.ClassId is not (23 or 137) ) return null;
			var lowerGo = Ref( lower.Body, "m_GameObject" ).FileId;
			if ( lowerGo != go )
			{
				var lowerTransform = document.TransformOf.GetValueOrDefault( lowerGo );
				if ( transform == null || lowerTransform == null || Ref( lowerTransform.Body, "m_Father" ).FileId != Ref( transform.Body, "m_Father" ).FileId ) return null;
				var a = Local( transform.Body, null ); var b = Local( lowerTransform.Body, null );
				if ( a.Position.Zip( b.Position ).Concat( a.Rotation.Zip( b.Rotation ) ).Concat( a.Scale.Zip( b.Scale ) ).Any( p => Math.Abs( p.First - p.Second ) > 1e-4 ) ) return null;
			}
			var filter = (document.Components.GetValueOrDefault( lowerGo ) ?? new()).FirstOrDefault( c => c.ClassId == 33 );
			var mesh = lower.ClassId == 137 ? Ref( lower.Body, "m_Mesh" ) : Ref( filter?.Body, "m_Mesh" );
			if ( !mesh.HasGuid || !models.TryGetValue( mesh.Guid, out var lowerModel ) || lowerModel.Vmdl == null || lowerModel.MeshFile == null ) return null;
			var lowerBinding = lowerModel.Info.ResolveMesh( mesh.FileId );
			if ( lowerBinding == null ) return null;
			var lowerOffset = lowerModel.Info.MeshNodes.TryGetValue( lowerBinding.Mesh, out var node ) ? (lowerModel.Info.FileAxes * node).Inverse().In( Basis( lowerModel ) ) : UnityTransform.Identity;
			if ( !(lowerOffset * offset.Inverse()).IsIdentity() ) return null;
			lods.Add( new[] { new MeshPart( lowerModel.MeshFile, new[] { lowerBinding.Mesh }, lowerModel.Scale ) } );
			Remap( lowerBinding, (lower.Body["m_Materials"]?.Items ?? Array.Empty<YamlNode>()).Select( m => m.Reference.Guid ).ToList() );
			thresholds.Add( UnityImport.LodThreshold( levels[i - 1].Height, document.LodSizes.GetValueOrDefault( renderer.FileId, UnityImport.DefaultLodSize ) ) );
		}
		var name = System.IO.Path.ChangeExtension( model.Vmdl.StartsWith( "Imported/", StringComparison.Ordinal ) ? model.Vmdl["Imported/".Length..] : model.Vmdl, null ) + "_iup_lod_" + Regex.Replace( binding.Mesh.Trim(), "[^A-Za-z0-9_-]+", "_" ).Trim( '_' );
		return Compose( new ComposedModel( name, lods, thresholds, remaps ) );
	}

	static string ShadowMode( double castShadows ) => castShadows switch { 0 => "Off", 3 => "ShadowsOnly", _ => "On" };

	void Renderer( Document document, UnityYamlObject part, JsonArray components, JsonArray children, UnityReference mesh, List<string> materials, bool enabled, Summary summary, string space, long id, double castShadows = 1 )
	{
		if ( mesh.HasGuid && models.TryGetValue( mesh.Guid, out var collisionOnly ) && collisionOnly.Vmdl == null )
		{
			summary.Skip( "renderer of a collision-only mesh" );
			return;
		}
		if ( Generated( mesh ) )
		{
			if ( GeneratedModel( mesh, document.Guid, materials, summary ) is not { } generatedVmdl ) return;
			document.GeneratedRenders[(Ref( part.Body, "m_GameObject" ).FileId, mesh.Guid ?? "", mesh.FileId)] = generatedVmdl;
			var generatedRenderer = Component( "ModelRenderer", Id( space, id, "renderer" ), enabled );
			generatedRenderer["Model"] = generatedVmdl; generatedRenderer["MaterialGroup"] = null;
			generatedRenderer["Tint"] = "1,1,1,1"; generatedRenderer["RenderType"] = ShadowMode( castShadows ); generatedRenderer["CreateAttachments"] = false;
			summary.Renderers++; summary.GeneratedMeshes++;
			components.Add( generatedRenderer );
			return;
		}
		if ( !mesh.HasGuid || !models.TryGetValue( mesh.Guid, out var model ) )
		{
			if ( mesh.HasGuid ) summary.Notes.Add( $"A renderer uses a mesh from '{assets.GetValueOrDefault( mesh.Guid )?.Path ?? mesh.Guid}', which is not imported as a model." );
			else summary.Skip( "renderer without a model mesh" );
			return;
		}
		var binding = model.Info.ResolveMesh( mesh.FileId );
		var slots = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
		if ( binding != null )
			for ( var i = 0; i < materials.Count; i++ )
			{
				var slot = binding.Slots.Length == 0 ? (i == 0 ? binding.Mesh : null) : i < binding.Slots.Length ? binding.Slots[i] : null;
				if ( slot != null && !string.IsNullOrEmpty( materials[i] ) ) slots[slot] = materials[i];
			}
		var (group, exact) = model.Group( slots );
		if ( !exact ) summary.Notes.Add( $"No material group of '{model.Name}' matches a renderer's materials exactly; the closest is used." );
		// One mesh of a multi-mesh model: a VMDL with only that mesh, offset to undo the node transform ModelDoc bakes in.
		var vmdl = model.Vmdl;
		var offset = UnityTransform.Identity;
		if ( binding == null ) summary.Notes.Add( $"A renderer's mesh in '{model.Name}' could not be identified; the whole model is shown." );
		else
		{
			if ( model.Info.ImportedMeshes.Length > 1 || !model.Info.ImportedMeshes.Contains( binding.Mesh ) ) vmdl = model.MeshVmdl( binding.Mesh );
			if ( model.Info.MeshNodes.TryGetValue( binding.Mesh, out var nodeTransform ) )
			{
				// Unity converts a file's axis system with one rotation at the model root, folded into the node transforms
				// (a Z-up file's +90 degree X node becomes unrotated); the mesh itself stays in the node's space.
				offset = (model.Info.FileAxes * nodeTransform).Inverse().In( Basis( model ) );
			}
			else summary.Notes.Add( $"Mesh '{binding.Mesh}' of '{model.Name}' has no unique node transform; it is placed without compensation." );
		}
		// A LOD group: one VMDL with every level, when the levels line up.
		if ( binding != null && document.LodGroups.TryGetValue( part.FileId, out var levels ) )
		{
			if ( LodModel( document, part, levels, model, binding, offset, materials ) is { } lodVmdl ) { vmdl = lodVmdl; group = null; summary.LodGroups++; }
			else summary.Notes.Add( $"A LOD group of '{model.Name}' shows only LOD0: its lower levels are not one renderer each at the same place, or not identified meshes." );
		}
		var renderer = Component( "ModelRenderer", Id( space, id, "renderer" ), enabled );
		renderer["Model"] = vmdl; renderer["MaterialGroup"] = string.IsNullOrEmpty( group ) ? null : group;
		renderer["Tint"] = "1,1,1,1"; renderer["RenderType"] = ShadowMode( castShadows ); renderer["CreateAttachments"] = false;
		summary.Renderers++;
		if ( offset.IsIdentity() ) { components.Add( renderer ); return; }
		var holder = GameObject( Id( space, id, "mesh" ), "mesh", true );
		var (position, rotation, scale) = offset.Decompose();
		holder["Position"] = Vec( position ); holder["Rotation"] = Vec( rotation ); holder["Scale"] = Vec( scale );
		holder["Components"].AsArray().Add( renderer );
		children.Add( holder );
	}

	/// <summary>
	/// A Unity terrain as an s&amp;box Terrain, with its painted trees. Unity draws a terrain from its object's position
	/// only, ignoring rotation and scale, with the terrain's corner there; s&amp;box terrains start at the corner on the
	/// Unity terrain's +X edge (their local X runs along Unity Z and Y along Unity -X). The terrain and its trees sit on
	/// children that undo the object's rotation and scale. Trees are instances of their prototype prefabs or models with
	/// the tree's position, rotation about the up axis and width/height scale, which Unity uses in place of the
	/// prototype root's own transform.
	/// </summary>
	void Terrain( JsonArray children, UnityYamlObject component, List<UnityYamlObject> parts, UnityYamlObject transform, Overrides overrides, Summary summary, string space, long salt )
	{
		var values = overrides?.For( component.FileId ) ?? new Dictionary<string, YamlNode>();
		var reference = Ref( component.Body, "m_TerrainData" );
		if ( Overrides.Object( values, "m_TerrainData" ) is { HasGuid: true } swapped ) reference = swapped;
		if ( !reference.HasGuid || !Terrains.TryGetValue( reference.Guid, out var terrain ) )
		{
			summary.Notes.Add( reference.HasGuid ? $"Terrain data '{assets.GetValueOrDefault( reference.Guid )?.Path ?? reference.Guid}' is not imported; the terrain was left out." : "A terrain without terrain data was left out." );
			return;
		}
		var local = Local( transform.Body, overrides?.For( transform.FileId ) );
		var (_, ownRotation, ownScale) = UnityTransform.FromUnity( local.Position, local.Rotation, local.Scale );
		var counter = (UnityTransform.Rotation( ownRotation[0], ownRotation[1], ownRotation[2], ownRotation[3] ) * UnityTransform.Scaling( ownScale[0], ownScale[1], ownScale[2] )).Inverse();
		JsonObject Holder( string part, string name, UnityTransform offset )
		{
			var holder = GameObject( Id( space, component.FileId ^ salt, part ), name, true );
			var (position, rotation, scale) = offset.Decompose();
			holder["Position"] = Vec( position ); holder["Rotation"] = Vec( rotation ); holder["Scale"] = Vec( scale );
			children.Add( holder );
			return holder;
		}
		var size = terrain.Data.Size;
		var surface = Holder( "terrain", "terrain", counter * UnityTransform.Translation( 0, -size[0] * UnityTransform.InchesPerMeter, 0 ) );
		var result = Component( "Terrain", Id( space, component.FileId ^ salt, "component" ), (Overrides.Number( values, "m_Enabled" ) ?? component.Body["m_Enabled"]?.Number ?? 1) != 0 );
		result["Storage"] = terrain.Path;
		result["TerrainSize"] = Math.Round( terrain.Size, 3 );
		result["TerrainHeight"] = Math.Round( terrain.Height, 3 );
		result["EnableCollision"] = parts.Any( p => p.ClassId == 154 && (Overrides.Number( overrides?.For( p.FileId ) ?? new Dictionary<string, YamlNode>(), "m_Enabled" ) ?? p.Body["m_Enabled"]?.Number ?? 1) != 0 );
		result["Static"] = true;
		surface["Components"].AsArray().Add( result );
		summary.Terrains++;
		if ( terrain.Data.Trees.Count == 0 ) return;
		var trees = Holder( "trees", "Trees", counter )["Children"].AsArray();
		var missing = new HashSet<string>();
		for ( var i = 0; i < terrain.Data.Trees.Count; i++ )
		{
			var tree = terrain.Data.Trees[i];
			var prototype = tree.Prototype >= 0 && tree.Prototype < terrain.Data.TreePrototypes.Count ? terrain.Data.TreePrototypes[tree.Prototype] : default;
			var half = tree.Rotation / 2;
			var placement = UnityTransform.FromUnity( new[] { tree.X * size[0], tree.Y * size[1], tree.Z * size[2] }, new[] { 0, Math.Sin( half ), 0, Math.Cos( half ) }, new[] { tree.Width, tree.Height, tree.Width } );
			var id = Id( space, component.FileId ^ salt, "tree:" + i );
			JsonObject placed = null;
			if ( prototype.HasGuid && models.TryGetValue( prototype.Guid, out var model ) && model.Vmdl != null )
			{
				placed = GameObject( id, model.Name, true );
				placed["Position"] = Vec( placement.Position ); placed["Rotation"] = Vec( placement.Rotation ); placed["Scale"] = Vec( placement.Scale );
				AddModel( placed, model, "", summary, space, component.FileId ^ salt ^ ((i + 1L) << 32) );
				summary.ModelInstances++;
			}
			else if ( prototype.HasGuid && prefabOutputs.TryGetValue( prototype.Guid, out var path ) && PrefabRoot( prototype.Guid ) is { } root )
			{
				var rootId = root["__guid"].GetValue<string>();
				var properties = new JsonArray();
				void Set( string property, JsonNode value ) =>
					properties.Add( new JsonObject { ["Target"] = new JsonObject { ["Type"] = "GameObject", ["IdValue"] = rootId }, ["Property"] = property, ["Value"] = value } );
				Set( "Name", root["Name"]?.GetValue<string>() ?? "Tree" );
				Set( "Position", Vec( placement.Position ) ); Set( "Rotation", Vec( placement.Rotation ) ); Set( "Scale", Vec( placement.Scale ) );
				placed = PrefabReference( id, path, rootId, properties );
				summary.PrefabInstances++;
			}
			else missing.Add( prototype.HasGuid ? assets.GetValueOrDefault( prototype.Guid )?.Path ?? prototype.Guid : "a missing prototype" );
			if ( placed != null ) { trees.Add( placed ); summary.Trees++; }
		}
		foreach ( var name in missing ) summary.Notes.Add( $"Trees using '{name}' were left out; the prototype is not imported." );
	}

	static JsonObject PrefabReference( string instanceId, string path, string rootId, JsonArray properties ) => new()
	{
		["__guid"] = instanceId, ["__version"] = 2, ["__Prefab"] = path,
		["__PrefabInstancePatch"] = new JsonObject { ["AddedObjects"] = new JsonArray(), ["RemovedObjects"] = new JsonArray(), ["PropertyOverrides"] = properties, ["MovedObjects"] = new JsonArray() },
		["__PrefabIdToInstanceId"] = new JsonObject { [rootId] = instanceId }
	};

	static JsonObject Collider( UnityYamlObject collider, IReadOnlyDictionary<string, YamlNode> overrides, string space, long salt )
	{
		// Prefab instances can override any of these values.
		double N( string property, double fallback ) => Num( collider, overrides, property, fallback );
		double[] V( string property, double fallback ) => new[] { "x", "y", "z" }.Select( a => N( property + "." + a, fallback ) ).ToArray();
		double[] Center() => V( "m_Center", 0 );
		double[] S( double[] u ) => new[] { u[2] * UnityTransform.InchesPerMeter, -u[0] * UnityTransform.InchesPerMeter, u[1] * UnityTransform.InchesPerMeter };
		var enabled = N( "m_Enabled", 1 ) != 0;
		var trigger = N( "m_IsTrigger", 0 ) != 0;
		JsonObject result;
		switch ( collider.ClassId )
		{
			case 65:
				var size = V( "m_Size", 1 );
				result = Component( "BoxCollider", Id( space, collider.FileId ^ salt, "collider" ), enabled );
				result["Center"] = Vec( S( Center() ) );
				result["Scale"] = Vec( new[] { size[2], size[0], size[1] }.Select( v => Math.Abs( v ) * UnityTransform.InchesPerMeter ).ToArray() );
				break;
			case 135:
				result = Component( "SphereCollider", Id( space, collider.FileId ^ salt, "collider" ), enabled );
				result["Center"] = Vec( S( Center() ) );
				result["Radius"] = F( N( "m_Radius", 0.5 ) * UnityTransform.InchesPerMeter );
				break;
			default:
				var radius = N( "m_Radius", 0.5 );
				var half = Math.Max( 0, N( "m_Height", 2 ) / 2 - radius );
				var axis = new double[3];
				axis[(int)Math.Clamp( N( "m_Direction", 1 ), 0, 2 )] = half;
				var center = Center();
				result = Component( "CapsuleCollider", Id( space, collider.FileId ^ salt, "collider" ), enabled );
				result["Start"] = Vec( S( center.Zip( axis, ( c, a ) => c - a ).ToArray() ) );
				result["End"] = Vec( S( center.Zip( axis, ( c, a ) => c + a ).ToArray() ) );
				result["Radius"] = F( radius * UnityTransform.InchesPerMeter );
				break;
		}
		result["IsTrigger"] = trigger; result["Static"] = true;
		return result;
	}

	/// <summary>
	/// Components a placement adds to objects inside its nested prefab or model. Unity keeps each such object as a stripped
	/// copy, naming its source object, with the added components on it. Returns the copy, its added components, and whether
	/// it is the source's root object (by <paramref name="isRoot"/>, given the source object ID).
	/// </summary>
	IEnumerable<(UnityYamlObject Go, List<UnityYamlObject> Parts, bool Root)> AddedComponents( Document document, UnityYamlObject instance, Func<long, bool> isRoot )
	{
		foreach ( var id in document.StrippedGameObjects.GetValueOrDefault( instance.FileId ) ?? new List<long>() )
			if ( document.Objects.GetValueOrDefault( id ) is { } go && document.Components.GetValueOrDefault( id ) is { Count: > 0 } parts )
				yield return (go, parts, isRoot( Ref( go.Body, "m_CorrespondingSourceObject" ).FileId ));
	}

	// A model's root GameObject: the legacy IDs, or the hashed path of the root node, as for RootTransformIds.
	static readonly HashSet<long> RootGameObjectIds = new() { 100000, 100002, UnityFileId.Hash( "Type:GameObject->//RootNode0" ), UnityFileId.Hash( "Type:GameObject->//RootNode/root0" ) };

	// Component classes the conversion handles on an object of its own: those of AddComponents, and LOD groups (205), which
	// hide their lower levels' renderers in the file that holds them.
	static readonly HashSet<int> ConvertedClasses = new() { 20, 23, 33, 64, 65, 82, 108, 135, 136, 137, 154, 205, 215, 218 };

	/// <summary>
	/// Notes the changes a placed instance makes inside its prefab or model that the conversion does not apply. Components
	/// added to the source's root object are converted onto the placed object (see AddComponents), so only those added to
	/// objects deeper inside count: as warnings when the conversion would convert them on an object of their own, else like
	/// any other unconverted component type. An added LOD group always counts, as its renderers are inside the source, so
	/// every level shows. Also GameObjects removed from the source (m_RemovedGameObjects, Unity 2022.2+).
	/// </summary>
	void ReportUnappliedChanges( Document document, UnityYamlObject instance, string sourcePath, Func<long, bool> isRoot, Summary summary )
	{
		var modification = instance.Body["m_Modification"];
		var seen = new HashSet<long>();
		var lost = new List<string>();
		foreach ( var (_, parts, root) in AddedComponents( document, instance, isRoot ) )
			foreach ( var part in parts )
			{
				seen.Add( part.FileId );
				if ( root && part.ClassId != 205 ) continue;
				if ( ConvertedClasses.Contains( part.ClassId ) ) lost.Add( part.Type ?? $"class {part.ClassId}" );
				else summary.Skip( part.Type ?? $"class {part.ClassId}" );
			}
		// Unity 2022.2+ also lists added components; any not on a stripped copy here can't be placed.
		foreach ( var entry in modification?["m_AddedComponents"]?.Items ?? Array.Empty<YamlNode>() )
			if ( Ref( entry, "addedObject" ).FileId is var id && id != 0 && seen.Add( id ) && document.Objects.GetValueOrDefault( id ) is { } part )
			{
				if ( ConvertedClasses.Contains( part.ClassId ) ) lost.Add( part.Type ?? $"class {part.ClassId}" );
				else summary.Skip( part.Type ?? $"class {part.ClassId}" );
			}
		if ( lost.Count > 0 )
			summary.Notes.Add( $"{lost.Count} component(s) added to objects inside an instance of '{sourcePath}' are not converted: " +
				string.Join( ", ", lost.GroupBy( t => t ).Select( g => g.Count() > 1 ? $"{g.Key} ×{g.Count()}" : g.Key ) ) + "." );
		var removedObjects = (modification?["m_RemovedGameObjects"]?.Items ?? Array.Empty<YamlNode>()).Count;
		if ( removedObjects > 0 ) summary.Notes.Add( $"{removedObjects} GameObject(s) removed from an instance of '{sourcePath}' are still shown." );
	}

	/// <summary>The root GameObject of a nested prefab (0 for a variant, which has no root object of its own).</summary>
	long PrefabRootObject( string guid ) => Load( guid ) is { } source && source.RootTransforms.FirstOrDefault() is { } root ? source.GameObjectOf( root ) : 0;

	/// <summary>
	/// A nested prefab or model placed by <paramref name="instance"/>. <paramref name="given"/> are the instance's
	/// modifications already merged with those of the files placing it, when it sits inside another instance.
	/// </summary>
	JsonObject Instance( Document document, UnityYamlObject instance, Summary summary, string space, long salt, Overrides given = null )
	{
		var source = Ref( instance.Body, "m_SourcePrefab" );
		var overrides = given ?? new Overrides( instance.Body["m_Modification"]?["m_Modifications"] );
		var sourcePath = assets.GetValueOrDefault( source.Guid ?? "" )?.Path ?? source.Guid;
		if ( models.TryGetValue( source.Guid ?? "", out var model ) )
		{
			ReportUnappliedChanges( document, instance, sourcePath, RootGameObjectIds.Contains, summary );
			return model.Vmdl == null ? null : ModelInstance( document, instance, model, overrides, summary, space, salt );
		}
		if ( !assets.TryGetValue( source.Guid ?? "", out var nested ) || !nested.Path.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase ) )
		{
			summary.Notes.Add( $"A nested prefab or model '{sourcePath}' is not imported; it was left out." );
			return null;
		}
		var nestedRoot = PrefabRootObject( nested.Guid );
		ReportUnappliedChanges( document, instance, sourcePath, id => id == nestedRoot, summary );
		// Objects added under objects inside the nested prefab, and components added to its root, need the nested prefab's
		// content here, so it is inlined.
		var addsInside = document.AddedUnder( instance.FileId ).Any() ||
			AddedComponents( document, instance, id => id == nestedRoot ).Any( a => a.Root && a.Parts.Any( p => ConvertedClasses.Contains( p.ClassId ) && p.ClassId != 205 ) );
		var removed = instance.Body["m_Modification"]?["m_RemovedComponents"]?.Items.Count > 0;
		if ( !prefabOutputs.TryGetValue( nested.Guid, out var path ) || addsInside || removed || given != null ) return Inline( document, instance, summary, space, salt, overrides );
		var sourceDocument = Load( nested.Guid );
		var sourceRoot = sourceDocument.RootTransforms.FirstOrDefault();
		// A prefab variant has no root object of its own to override, so it is copied in place with these overrides on top.
		if ( sourceRoot == null ) return Inline( document, instance, summary, space, salt, overrides );
		var instanceId = Id( space, instance.FileId ^ salt, "go" );
		var rootGo = sourceDocument.GameObjectOf( sourceRoot );
		var rootId = Id( sourceDocument.Guid, rootGo, "go" );
		var properties = new JsonArray();
		void Set( string type, string target, string property, JsonNode value ) =>
			properties.Add( new JsonObject { ["Target"] = new JsonObject { ["Type"] = type, ["IdValue"] = target }, ["Property"] = property, ["Value"] = value } );
		summary.PrefabInstances++;
		// Root: name and transform always, as s&box prefab instances store them.
		var rootGoOverrides = overrides.For( rootGo );
		Set( "GameObject", rootId, "Name", (Overrides.Text( rootGoOverrides, "m_Name" ) ?? sourceDocument.Objects.GetValueOrDefault( rootGo )?.Body["m_Name"]?.Value ?? nested.Path).Trim() );
		var (position, rotation, scale) = UnityTransform.FromUnity( Local( sourceRoot.Body, overrides.For( sourceRoot.FileId ) ).Position, Local( sourceRoot.Body, overrides.For( sourceRoot.FileId ) ).Rotation, Local( sourceRoot.Body, overrides.For( sourceRoot.FileId ) ).Scale );
		Set( "GameObject", rootId, "Position", Vec( position ) ); Set( "GameObject", rootId, "Rotation", Vec( rotation ) ); Set( "GameObject", rootId, "Scale", Vec( scale ) );
		if ( Overrides.Number( rootGoOverrides, "m_IsActive" ) is double rootActive ) Set( "GameObject", rootId, "Enabled", rootActive != 0 );
		// Other modifications on objects defined directly in the nested prefab.
		foreach ( var target in overrides.Targets.Where( t => t != rootGo && t != sourceRoot.FileId ) )
		{
			var values = overrides.For( target );
			if ( !sourceDocument.Objects.TryGetValue( target, out var targeted ) || targeted.Stripped )
			{
				if ( values.Keys.Any( k => k.StartsWith( "m_Materials", StringComparison.Ordinal ) || k.StartsWith( "m_Local", StringComparison.Ordinal ) ) )
					summary.Notes.Add( $"A modification of an object nested deeper inside '{nested.Path}' is not applied." );
				continue;
			}
			switch ( targeted.ClassId )
			{
				case 1:
					if ( Overrides.Text( values, "m_Name" ) is string name ) Set( "GameObject", Id( sourceDocument.Guid, target, "go" ), "Name", name.Trim() );
					if ( Overrides.Number( values, "m_IsActive" ) is double active ) Set( "GameObject", Id( sourceDocument.Guid, target, "go" ), "Enabled", active != 0 );
					break;
				case 4 or 224:
					if ( values.Keys.Any( k => k.StartsWith( "m_Local", StringComparison.Ordinal ) ) )
					{
						var local = Local( targeted.Body, values );
						var converted = UnityTransform.FromUnity( local.Position, local.Rotation, local.Scale );
						var go = Id( sourceDocument.Guid, sourceDocument.GameObjectOf( targeted ), "go" );
						Set( "GameObject", go, "Position", Vec( converted.Position ) ); Set( "GameObject", go, "Rotation", Vec( converted.Rotation ) ); Set( "GameObject", go, "Scale", Vec( converted.Scale ) );
					}
					break;
				case 23 or 137:
					if ( values.Keys.Any( k => k.StartsWith( "m_Materials", StringComparison.Ordinal ) ) && MaterialGroupFor( sourceDocument, targeted, values ) is var (group, known) && known )
						Set( "Component", Id( sourceDocument.Guid, sourceDocument.GameObjectOf( targeted ), "renderer" ), "MaterialGroup", string.IsNullOrEmpty( group ) ? null : group );
					break;
			}
		}
		return PrefabReference( instanceId, path, rootId, properties );
	}

	(string Group, bool Known) MaterialGroupFor( Document document, UnityYamlObject renderer, IReadOnlyDictionary<string, YamlNode> values )
	{
		var filter = (document.Components.GetValueOrDefault( document.GameObjectOf( renderer ) ) ?? new()).FirstOrDefault( c => c.ClassId == 33 );
		var mesh = renderer.ClassId == 137 ? Ref( renderer.Body, "m_Mesh" ) : Ref( filter?.Body, "m_Mesh" );
		if ( !mesh.HasGuid || !models.TryGetValue( mesh.Guid, out var model ) || model.Info.ResolveMesh( mesh.FileId ) is not { } binding ) return (null, false);
		var materials = (renderer.Body["m_Materials"]?.Items ?? Array.Empty<YamlNode>()).Select( m => m.Reference.Guid ).ToList();
		foreach ( var (path, m) in values )
			if ( Regex.Match( path, @"^m_Materials\.Array\.data\[([0-9]{1,6})\]$" ) is { Success: true } slot && int.Parse( slot.Groups[1].Value ) is var index && index < 64 )
			{
				while ( materials.Count <= index ) materials.Add( null );
				materials[index] = Ref( m, "objectReference" ).Guid;
			}
		var slots = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
		for ( var i = 0; i < materials.Count; i++ )
		{
			var name = binding.Slots.Length == 0 ? (i == 0 ? binding.Mesh : null) : i < binding.Slots.Length ? binding.Slots[i] : null;
			if ( name != null && !string.IsNullOrEmpty( materials[i] ) ) slots[name] = materials[i];
		}
		return (model.Group( slots ).Group, true);
	}

	/// <summary>A nested prefab copied in place with the instance's modifications applied.</summary>
	JsonObject Inline( Document document, UnityYamlObject instance, Summary summary, string space, long salt, Overrides given = null )
	{
		Enter();
		try { return InlineBody( document, instance, summary, space, salt, given ); }
		finally { nesting--; }
	}

	JsonObject InlineBody( Document document, UnityYamlObject instance, Summary summary, string space, long salt, Overrides given )
	{
		var source = Ref( instance.Body, "m_SourcePrefab" );
		if ( !assets.TryGetValue( source.Guid ?? "", out var nested ) || !nested.Path.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase ) )
			return models.ContainsKey( source.Guid ?? "" ) ? ModelInstance( document, instance, models[source.Guid], given ?? new Overrides( instance.Body["m_Modification"]?["m_Modifications"] ), summary, space, salt )
				: Instance( document, instance, summary, space, salt, given );
		var sourceDocument = Load( nested.Guid );
		var sourceRoot = sourceDocument.RootTransforms.FirstOrDefault();
		var overrides = given ?? new Overrides( instance.Body["m_Modification"]?["m_Modifications"] );
		summary.InlinedInstances++;
		// Unity names objects inside an instance (instance ID XOR source ID) with the sign bit cleared; the same derivation keeps GUIDs unique.
		var nestedSalt = salt ^ instance.FileId;
		JsonObject result;
		if ( sourceRoot != null ) result = Node( sourceDocument, sourceRoot, summary, space, nestedSalt, overrides );
		else if ( sourceDocument.InstancesUnder( 0 ).FirstOrDefault() is { } variant )
			result = Inline( sourceDocument, variant, summary, space, nestedSalt, new Overrides( variant.Body["m_Modification"]?["m_Modifications"] ).With( overrides, sourceDocument, variant.FileId, id => SourceHas( variant, id ) ) );
		else return null;
		if ( result == null ) return null;
		var removed = (instance.Body["m_Modification"]?["m_RemovedComponents"]?.Items ?? Array.Empty<YamlNode>()).Count;
		if ( removed > 0 ) summary.Notes.Add( $"{removed} component(s) removed by an instance of '{nested.Path}' are still shown." );
		// Components this file adds to the nested prefab's root object go on the inlined copy's root.
		var rootObject = sourceRoot != null ? sourceDocument.GameObjectOf( sourceRoot ) : 0;
		foreach ( var (go, parts, root) in AddedComponents( document, instance, id => id == rootObject ) )
			if ( root ) AddComponents( document, go.FileId, go, null, parts, result, null, summary, space, salt );
		// Objects this file adds under objects of the nested prefab go under the inlined copy's root.
		foreach ( var added in document.AddedUnder( instance.FileId ) )
			if ( Node( document, added, summary, space, salt ) is { } node ) result["Children"].AsArray().Add( node );
		return result;
	}

	// A model's root transform: the legacy IDs, or the hashed path of the root node (named "root" when the file has one top node).
	static readonly HashSet<long> RootTransformIds = new() { 400000, 400002, UnityFileId.Hash( "Type:Transform->//RootNode/Transform0" ), UnityFileId.Hash( "Type:Transform->//RootNode/root/Transform0" ) };

	/// <summary>Whether the source of <paramref name="instance"/> (a prefab or model) has an object with this ID.</summary>
	bool SourceHas( UnityYamlObject instance, long id )
	{
		var source = Ref( instance.Body, "m_SourcePrefab" ).Guid ?? "";
		if ( models.TryGetValue( source, out var model ) )
			return RootTransformIds.Contains( id ) || model.Info.MetadataObjectIds.Contains( id ) || model.Info.RendererSlots.ContainsKey( id ) || model.Info.ResolveMesh( id ) != null;
		return assets.TryGetValue( source, out var prefab ) && prefab.Kind == UnityAssetKind.Prefab && Load( prefab.Guid ).Objects.ContainsKey( id );
	}

	/// <summary>
	/// The whole model's renderer (and its generated collider) on a placed model's root object. When the model's single
	/// top node is the root, the VMDL bakes that node's transform into the mesh, so the parts sit under its inverse.
	/// </summary>
	void AddModel( JsonObject node, PrefabModel model, string group, Summary summary, string space, long id )
	{
		var collapsed = model.Info.CollapsedRoot is { } top ? (model.Info.FileAxes * top).In( Basis( model ) ) : null;
		var renderer = Component( "ModelRenderer", Id( space, id, "renderer" ), true );
		renderer["Model"] = model.Vmdl; renderer["MaterialGroup"] = string.IsNullOrEmpty( group ) ? null : group;
		renderer["Tint"] = "1,1,1,1"; renderer["RenderType"] = "On"; renderer["CreateAttachments"] = false;
		var parts = new List<JsonObject> { renderer };
		// Unity's "Generate Colliders" puts an exact collider on every mesh; the VMDL carries that physics mesh.
		if ( model.Info.GenerateColliders )
		{
			var collider = Component( "ModelCollider", Id( space, id, "collider" ), true );
			collider["Model"] = model.Vmdl; collider["Static"] = true; collider["IsTrigger"] = false;
			parts.Add( collider );
			summary.Colliders++;
		}
		if ( collapsed == null || collapsed.IsIdentity() ) foreach ( var part in parts ) node["Components"].AsArray().Add( part );
		else
		{
			var holder = GameObject( Id( space, id, "mesh" ), "mesh", true );
			var (position, rotation, scale) = collapsed.Inverse().Decompose();
			holder["Position"] = Vec( position ); holder["Rotation"] = Vec( rotation ); holder["Scale"] = Vec( scale );
			foreach ( var part in parts ) holder["Components"].AsArray().Add( part );
			node["Children"].AsArray().Add( holder );
		}
	}

	/// <summary>A model placed as a prefab instance: the whole model's VMDL with the material group of its overrides.</summary>
	JsonObject ModelInstance( Document document, UnityYamlObject instance, PrefabModel model, Overrides overrides, Summary summary, string space, long salt )
	{
		// The instance root: the stripped transform this file keeps for it, or the model's root transform IDs.
		var rootIds = RootTransformIds;
		var strippedRoot = document.StrippedTransformSources.GetValueOrDefault( instance.FileId ) ?? new List<long>();
		// The root transform: a known root ID, else the transform carrying m_RootOrder (only an instance root has one),
		// else a stripped transform this file keeps for the instance, else the only transformed target.
		var transformed = overrides.Targets.Where( t => overrides.For( t ).Keys.Any( k => k.StartsWith( "m_Local", StringComparison.Ordinal ) || k == "m_RootOrder" ) ).ToList();
		var rootTransform = strippedRoot.Concat( transformed ).FirstOrDefault( rootIds.Contains, 0 );
		if ( rootTransform == 0 ) rootTransform = transformed.FirstOrDefault( t => overrides.For( t ).ContainsKey( "m_RootOrder" ) );
		if ( rootTransform == 0 ) rootTransform = strippedRoot.FirstOrDefault();
		if ( rootTransform == 0 && transformed.Count == 1 ) rootTransform = transformed[0];
		var named = overrides.Targets.FirstOrDefault( t => Overrides.Text( overrides.For( t ), "m_Name" ) != null );
		var name = overrides.RootName ?? (named != 0 ? Overrides.Text( overrides.For( named ), "m_Name" ) : model.Name);
		// The root GameObject: the renamed target (Unity records the instance root's name), else the only GameObject target
		// that is switched on or off (not a transform or renderer of the model).
		var activeTargets = overrides.Targets.Where( t => Overrides.Number( overrides.For( t ), "m_IsActive" ) != null && !rootIds.Contains( t ) && !model.Info.RendererSlots.ContainsKey( t ) ).ToList();
		var rootObject = named != 0 ? named : activeTargets.Count == 1 ? activeTargets[0] : 0;
		var active = rootObject != 0 ? Overrides.Number( overrides.For( rootObject ), "m_IsActive" ) ?? 1 : 1;
		var node = GameObject( Id( space, instance.FileId ^ salt, "go" ), name.Trim(), active != 0 );
		// A file with one top node: Unity makes that node the prefab root, so its transform is the root's default, which the
		// placement's overrides replace. The VMDL bakes it into the mesh, so the renderer sits under its inverse.
		var collapsed = model.Info.CollapsedRoot is { } top ? (model.Info.FileAxes * top).In( Basis( model ) ) : null;
		SetTransform( node, Local( null, overrides.For( rootTransform ), collapsed == null ? null : UnityTransform.ToUnity( collapsed.Decompose() ) ) );
		// Material overrides on the model's renderers give the slot assignment for this placement.
		var slots = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var target in overrides.Targets )
		{
			var binding = model.Info.RendererSlots.GetValueOrDefault( target );
			foreach ( var (path, m) in overrides.For( target ) )
				if ( binding != null && Regex.Match( path, @"^m_Materials\.Array\.data\[([0-9]{1,6})\]$" ) is { Success: true } slot && int.Parse( slot.Groups[1].Value ) is var index )
				{
					var slotName = binding.Slots.Length == 0 ? (index == 0 ? binding.Mesh : null) : index < binding.Slots.Length ? binding.Slots[index] : null;
					if ( slotName != null && Ref( m, "objectReference" ).HasGuid ) slots[slotName] = Ref( m, "objectReference" ).Guid;
				}
		}
		var (group, exact) = model.Group( slots );
		if ( !exact ) summary.Notes.Add( $"No material group of '{model.Name}' matches an instance's materials exactly; the closest is used." );
		AddModel( node, model, group, summary, space, instance.FileId ^ salt );
		summary.ModelInstances++;
		// Components this file adds to the model's root object (often a MeshCollider) go on the placed object.
		foreach ( var (go, parts, root) in AddedComponents( document, instance, RootGameObjectIds.Contains ) )
			if ( root ) AddComponents( document, go.FileId, go, null, parts, node, null, summary, space, salt );
		if ( overrides.Targets.Any( t => t != rootTransform && overrides.For( t ).Keys.Any( k => k.StartsWith( "m_Local", StringComparison.Ordinal ) ) ) )
			summary.Notes.Add( $"Moved parts inside an instance of '{model.Name}' keep their model positions." );
		// Removed renderers matter only when their meshes are still in the VMDL (meshes no placement shows are left out of it).
		var removedMeshes = (instance.Body["m_Modification"]?["m_RemovedComponents"]?.Items ?? Array.Empty<YamlNode>())
			.Select( r => model.Info.RendererSlots.GetValueOrDefault( r.Reference.FileId )?.Mesh ).Where( m => m != null && model.Info.ImportedMeshes.Contains( m ) ).Distinct().ToArray();
		if ( removedMeshes.Length > 0 ) summary.Notes.Add( $"Meshes removed from an instance of '{model.Name}' are still shown: {string.Join( ", ", removedMeshes )}." );
		// Objects this file adds under the model's objects.
		foreach ( var added in document.AddedUnder( instance.FileId ) )
			if ( Node( document, added, summary, space, salt ) is { } child ) node["Children"].AsArray().Add( child );
		return node;
	}
}