From 4d11f3a2e20c9593e4ad82ee2e72f167a963ba62 Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 14 Jul 2026 02:21:50 -0700 Subject: [PATCH 01/14] Add byte-exact Unity mesh serialization for background model loading Hand-writes Unity 2019.4 Mesh (class 43) objects into a UnityFS asset bundle off the main thread, mirroring the texture bundle path. This is the serialization foundation for moving .mu model loading off the critical path. Library/Model/: - MeshBlob: mesh data container (channels, interleaved vertex bytes, index buffer, submeshes, bounds, bind pose). - MeshBlobBuilder: interleaves attribute arrays into Unity's single-stream vertex layout and packs the index buffer (16/32-bit). - MeshBundleBuilder: byte-exact WriteMeshBody + BuildMany, reusing the Library/TextureBundle writer stack. Canonicalizes m_Container keys (lowercase + forward slashes) so per-name LoadAssetAsync resolves, rejects duplicate keys, and sorts the container to match real bundles. - MeshBundleSelfTest: #if DEBUG in-KSP round-trip test (also a canonicalization regression guard). The class-43 Mesh type entry is merged into the shared TextureBundle/TextureTypeTrees.bin (now holds 28, 43, 142) since the writer resolves entries through one static ReferenceTypeTrees; SerializedTypeTrees.MeshClassId added. Validated byte-exact offline (single/multi-mesh, multi-submesh, 16/32-bit indices, absent channels) and in-KSP (readable round-trip, per-name lookup, float32 color, rendering). --- KSPCommunityFixes/Library/Model/MeshBlob.cs | 104 +++++ .../Library/Model/MeshBlobBuilder.cs | 251 ++++++++++++ .../Library/Model/MeshBundleBuilder.cs | 356 ++++++++++++++++++ .../Library/Model/MeshBundleSelfTest.cs | 179 +++++++++ .../TextureBundle/SerializedTypeTrees.cs | 1 + .../TextureBundle/TextureTypeTrees.bin | Bin 3840 -> 12582 bytes 6 files changed, 891 insertions(+) create mode 100644 KSPCommunityFixes/Library/Model/MeshBlob.cs create mode 100644 KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs create mode 100644 KSPCommunityFixes/Library/Model/MeshBundleBuilder.cs create mode 100644 KSPCommunityFixes/Library/Model/MeshBundleSelfTest.cs diff --git a/KSPCommunityFixes/Library/Model/MeshBlob.cs b/KSPCommunityFixes/Library/Model/MeshBlob.cs new file mode 100644 index 0000000..c7c5a1e --- /dev/null +++ b/KSPCommunityFixes/Library/Model/MeshBlob.cs @@ -0,0 +1,104 @@ +using UnityEngine; + +namespace KSPCommunityFixes.Library.Model +{ + /// + /// One serialization-ready Unity mesh, produced on a background thread by the model compiler and + /// written verbatim into a mesh bundle by . Everything here is + /// already in the exact on-wire form Unity 2019.4 expects: the vertex data is interleaved into a + /// single stream with a matching descriptor, indices are packed to + /// , and bounds/metrics are precomputed (there is no + /// RecalculateBounds on the main thread anymore). Geometry is stored inline in the bundle, + /// so there is no external stream data. + /// + internal sealed class MeshBlob + { + /// + /// The mesh's serialized m_Name and its m_Container key. Must already be + /// canonical (lowercased, forward slashes) so AssetBundle.LoadAssetAsync(name) can find + /// it — Unity canonicalizes the lookup name but compares it verbatim against the stored key. + /// + public string Name; + + public int VertexCount; + + /// + /// The full m_Channels descriptor array, one entry per Unity vertex attribute + /// (absent attributes have 0). Describes how + /// is laid out. + /// + public MeshChannel[] Channels; + + /// The interleaved vertex stream (m_VertexData.m_DataSize), inline. + public byte[] VertexData; + + /// 0 == 16-bit indices, 1 == 32-bit indices (Unity's IndexFormat). + public int IndexFormat; + + /// The concatenated per-submesh index buffer (m_IndexBuffer), inline. + public byte[] IndexData; + + public MeshSubMesh[] SubMeshes; + + /// Whole-mesh local bounds (m_LocalAABB): center + extent (half-size). + public Bounds LocalBounds; + + /// Skinning bind poses (m_BindPose); null/empty for a static mesh. + public Matrix4x4[] BindPose; + + /// + /// UV distribution metrics (m_MeshMetrics[0..1]). 1.0 is a neutral default: these values + /// only feed texture-mip streaming priority (which mips to keep resident), not per-pixel + /// mip selection, so leaving them at 1 has no visual effect. Real values are computed later. + /// + public float MeshMetric0 = 1f; + public float MeshMetric1 = 1f; + } + + /// One ChannelInfo entry: which stream/offset/format/dimension a vertex attribute uses. + internal readonly struct MeshChannel + { + public readonly byte Stream; + public readonly byte Offset; + public readonly byte Format; + public readonly byte Dimension; + + public MeshChannel(byte stream, byte offset, byte format, byte dimension) + { + Stream = stream; + Offset = offset; + Format = format; + Dimension = dimension; + } + } + + /// One SubMesh record: an index range plus its bounds and topology. + internal readonly struct MeshSubMesh + { + public readonly uint FirstByte; + public readonly uint IndexCount; + public readonly int Topology; + public readonly uint BaseVertex; + public readonly uint FirstVertex; + public readonly uint VertexCount; + public readonly Bounds LocalBounds; + + public MeshSubMesh( + uint firstByte, + uint indexCount, + int topology, + uint baseVertex, + uint firstVertex, + uint vertexCount, + Bounds localBounds) + { + FirstByte = firstByte; + IndexCount = indexCount; + Topology = topology; + BaseVertex = baseVertex; + FirstVertex = firstVertex; + VertexCount = vertexCount; + LocalBounds = localBounds; + } + } +} diff --git a/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs b/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs new file mode 100644 index 0000000..2ad7007 --- /dev/null +++ b/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs @@ -0,0 +1,251 @@ +using System; +using UnityEngine; + +namespace KSPCommunityFixes.Library.Model +{ + /// + /// Interleaves mesh attribute arrays into the single-stream vertex layout + /// Unity 2019.4 expects, and packs the index buffer. Pure CPU work; safe on a background thread + /// (no UnityEngine.Mesh is created). This is the mesh half of the model compiler; it is + /// also reused by the diff harness and the single-mesh validation. + /// + /// + /// Confirmed against a real Unity 2019.4.18f1 mesh: m_Channels is a fixed 14-entry array + /// (absent attributes have dimension 0), indexed by vertex attribute + /// (0=Position, 1=Normal, 2=Tangent, 3=Color, 4..11=TexCoord0..7, 12=BlendWeights, 13=BlendIndices); + /// present channels are all in stream 0, packed at contiguous offsets in attribute-index order. + /// v1 stores every present attribute as float32 (format 0), including colour (from Color32/255), + /// which matches the observed all-float32 convention with no format-code risk. + /// + internal static class MeshBlobBuilder + { + public const int ChannelCount = 14; + + // Vertex attribute channel indices (Unity 2019.4 order). + public const int ChPosition = 0; + public const int ChNormal = 1; + public const int ChTangent = 2; + public const int ChColor = 3; + public const int ChTexCoord0 = 4; + public const int ChTexCoord1 = 5; + + const byte FormatFloat32 = 0; + + /// Attribute arrays for one mesh. Null/empty arrays mark absent attributes. + public struct Arrays + { + public Vector3[] Vertices; + public Vector3[] Normals; + public Vector4[] Tangents; + public Color32[] Colors; + public Vector2[] Uv0; + public Vector2[] Uv1; + + /// Per-submesh triangle index lists (already the final winding). + public int[][] SubMeshTriangles; + } + + /// Build a from a live UnityEngine.Mesh (main thread only). + public static MeshBlob FromMesh(Mesh mesh, string name) + { + var arrays = new Arrays + { + Vertices = mesh.vertices, + Normals = mesh.normals, + Tangents = mesh.tangents, + Colors = mesh.colors32, + Uv0 = mesh.uv, + Uv1 = mesh.uv2, + }; + int subCount = mesh.subMeshCount; + arrays.SubMeshTriangles = new int[subCount][]; + for (int i = 0; i < subCount; ++i) + arrays.SubMeshTriangles[i] = mesh.GetTriangles(i); + return FromArrays(name, in arrays); + } + + public static unsafe MeshBlob FromArrays(string name, in Arrays a) + { + Vector3[] verts = a.Vertices ?? Array.Empty(); + int vertexCount = verts.Length; + + // A non-null attribute array whose length doesn't match the vertex count is dropped + // (treated as absent) below; warn so a mismatched .mu doesn't silently lose an attribute. + WarnIfWrongLength(name, "normals", Count(a.Normals), vertexCount); + WarnIfWrongLength(name, "tangents", Count(a.Tangents), vertexCount); + WarnIfWrongLength(name, "colors", Count(a.Colors), vertexCount); + WarnIfWrongLength(name, "uv0", Count(a.Uv0), vertexCount); + WarnIfWrongLength(name, "uv1", Count(a.Uv1), vertexCount); + + bool hasNormals = Count(a.Normals) == vertexCount && vertexCount > 0; + bool hasTangents = Count(a.Tangents) == vertexCount && vertexCount > 0; + bool hasColors = Count(a.Colors) == vertexCount && vertexCount > 0; + bool hasUv0 = Count(a.Uv0) == vertexCount && vertexCount > 0; + bool hasUv1 = Count(a.Uv1) == vertexCount && vertexCount > 0; + + // Assign channel offsets in attribute-index order for present attributes. v1 stores every + // present attribute (including colour, from Color32/255 below) as float32 (format 0): this + // is a deliberate v1 choice that is self-consistent with the emitted type tree and round- + // trips colors32 exactly. Unity-native would pack colour as UNorm8 (format 2), but we do + // not require byte-parity with Unity-generated bundles. + var channels = new MeshChannel[ChannelCount]; + int stride = 0; + AddChannel(channels, ChPosition, true, 3, ref stride); + AddChannel(channels, ChNormal, hasNormals, 3, ref stride); + AddChannel(channels, ChTangent, hasTangents, 4, ref stride); + AddChannel(channels, ChColor, hasColors, 4, ref stride); + AddChannel(channels, ChTexCoord0, hasUv0, 2, ref stride); + AddChannel(channels, ChTexCoord1, hasUv1, 2, ref stride); + + // Compute the buffer size in long: stride * vertexCount overflows int for an absurdly + // large mesh (unreachable for a real .mu) and would otherwise reach new byte[] as a + // negative size. Fail loud instead. + long vertexBytes = (long)stride * vertexCount; + if (vertexBytes > int.MaxValue) + throw new InvalidOperationException( + $"mesh '{name}': vertex buffer {vertexBytes} bytes (stride {stride} x {vertexCount} " + + "verts) exceeds int.MaxValue"); + + byte[] vertexData = new byte[(int)vertexBytes]; + fixed (byte* basePtr = vertexData) + { + for (int v = 0; v < vertexCount; ++v) + { + float* p = (float*)(basePtr + v * stride); + Vector3 pos = verts[v]; + *p++ = pos.x; *p++ = pos.y; *p++ = pos.z; + if (hasNormals) { Vector3 n = a.Normals[v]; *p++ = n.x; *p++ = n.y; *p++ = n.z; } + if (hasTangents) { Vector4 t = a.Tangents[v]; *p++ = t.x; *p++ = t.y; *p++ = t.z; *p++ = t.w; } + if (hasColors) { Color32 c = a.Colors[v]; *p++ = c.r / 255f; *p++ = c.g / 255f; *p++ = c.b / 255f; *p++ = c.a / 255f; } + if (hasUv0) { Vector2 uv = a.Uv0[v]; *p++ = uv.x; *p++ = uv.y; } + if (hasUv1) { Vector2 uv = a.Uv1[v]; *p++ = uv.x; *p++ = uv.y; } + } + } + + // Index buffer: concatenated per-submesh indices, 16-bit when the mesh fits. + int[][] tris = a.SubMeshTriangles ?? Array.Empty(); + bool use32 = vertexCount > ushort.MaxValue; + // Accumulate in long: a single int[] can't exceed int.MaxValue, but the SUM across + // submeshes can, and an int accumulator would wrap (to a small/negative value) BEFORE + // the guard below runs, letting an undersized indexData through to the unsafe write. + long totalIndices = 0; + for (int i = 0; i < tris.Length; ++i) + totalIndices += tris[i]?.Length ?? 0; + + // Same overflow guard for the index buffer (see the vertex buffer above). + long indexBytes = totalIndices * (use32 ? 4 : 2); + if (indexBytes > int.MaxValue) + throw new InvalidOperationException( + $"mesh '{name}': index buffer {indexBytes} bytes ({totalIndices} indices) " + + "exceeds int.MaxValue"); + + byte[] indexData = new byte[(int)indexBytes]; + var subMeshes = new MeshSubMesh[tris.Length]; + Bounds meshBounds = ComputeBounds(verts, 0, vertexCount); + + fixed (byte* idxBase = indexData) + { + int byteOffset = 0; + for (int s = 0; s < tris.Length; ++s) + { + int[] t = tris[s] ?? Array.Empty(); + uint firstByte = (uint)byteOffset; + if (use32) + { + uint* ip = (uint*)(idxBase + byteOffset); + for (int k = 0; k < t.Length; ++k) ip[k] = (uint)t[k]; + byteOffset += t.Length * 4; + } + else + { + ushort* ip = (ushort*)(idxBase + byteOffset); + for (int k = 0; k < t.Length; ++k) ip[k] = (ushort)t[k]; + byteOffset += t.Length * 2; + } + + // topology 0 == triangle list: .mu submeshes are always triangle lists, so a fixed + // triangle topology is a safe assumption. baseVertex 0 / firstVertex 0 / + // vertexCount = whole-mesh count is Unity's own convention for script-built meshes + // (indices are absolute into the shared vertex buffer, and the submesh's vertex + // range spans the entire mesh) — intentional, not a bug. + subMeshes[s] = new MeshSubMesh( + firstByte, + (uint)t.Length, + topology: 0, // triangles + baseVertex: 0, + firstVertex: 0, + vertexCount: (uint)vertexCount, + localBounds: ComputeSubMeshBounds(verts, t, meshBounds)); + } + } + + return new MeshBlob + { + Name = name, + VertexCount = vertexCount, + Channels = channels, + VertexData = vertexData, + IndexFormat = use32 ? 1 : 0, + IndexData = indexData, + SubMeshes = subMeshes, + LocalBounds = meshBounds, + BindPose = Array.Empty(), + }; + } + + static void AddChannel(MeshChannel[] channels, int index, bool present, int dimension, ref int stride) + { + if (!present) + return; + channels[index] = new MeshChannel((byte)0, (byte)stride, FormatFloat32, (byte)dimension); + stride += dimension * 4; + } + + static int Count(T[] array) => array?.Length ?? 0; + + // Warns only when an attribute array is actually present (non-null, non-empty) but its length + // disagrees with the vertex count, i.e. it is about to be silently dropped. Cheap: no + // allocation unless the (rare) warning path fires. + static void WarnIfWrongLength(string name, string attr, int length, int vertexCount) + { + if (length != 0 && length != vertexCount) + Debug.LogWarning( + $"[MeshBlobBuilder] mesh '{name}': {attr} array length {length} != vertexCount " + + $"{vertexCount}; dropping {attr}"); + } + + static Bounds ComputeBounds(Vector3[] verts, int start, int count) + { + if (count <= 0) + return new Bounds(Vector3.zero, Vector3.zero); + Vector3 min = verts[start], max = verts[start]; + for (int i = start + 1; i < start + count; ++i) + { + Vector3 p = verts[i]; + if (p.x < min.x) min.x = p.x; else if (p.x > max.x) max.x = p.x; + if (p.y < min.y) min.y = p.y; else if (p.y > max.y) max.y = p.y; + if (p.z < min.z) min.z = p.z; else if (p.z > max.z) max.z = p.z; + } + var b = new Bounds(); + b.SetMinMax(min, max); + return b; + } + + static Bounds ComputeSubMeshBounds(Vector3[] verts, int[] indices, Bounds fallback) + { + if (indices == null || indices.Length == 0 || verts.Length == 0) + return fallback; + Vector3 min = verts[indices[0]], max = min; + for (int i = 1; i < indices.Length; ++i) + { + Vector3 p = verts[indices[i]]; + if (p.x < min.x) min.x = p.x; else if (p.x > max.x) max.x = p.x; + if (p.y < min.y) min.y = p.y; else if (p.y > max.y) max.y = p.y; + if (p.z < min.z) min.z = p.z; else if (p.z > max.z) max.z = p.z; + } + var b = new Bounds(); + b.SetMinMax(min, max); + return b; + } + } +} diff --git a/KSPCommunityFixes/Library/Model/MeshBundleBuilder.cs b/KSPCommunityFixes/Library/Model/MeshBundleBuilder.cs new file mode 100644 index 0000000..5d05254 --- /dev/null +++ b/KSPCommunityFixes/Library/Model/MeshBundleBuilder.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using KSPCommunityFixes.Library.TextureBundle; +using UnityEngine; + +namespace KSPCommunityFixes.Library.Model +{ + /// + /// Builds an in-memory UnityFS bundle wrapping N Mesh objects plus the one + /// AssetBundle that references them, the mesh analogue of + /// . Unlike the texture path, mesh geometry can't be streamed + /// from the source file (the .mu layout differs from Unity's vertex stream, and Unity 2019.4 + /// never keeps a CPU-readable copy of streamed mesh vertices), so the interleaved vertex/index + /// bytes are written inline and m_StreamData is empty. Pure CPU work; safe to call + /// from a background thread. Reuses the writer stack unchanged. + /// + /// + /// reproduces Unity 2019.4.18f1's serialized Mesh (class 43) + /// field order, sizes and alignment exactly as the embedded type tree encodes them; only the + /// align-after flag (type-tree meta bit 0x4000) affects the byte stream. + /// + internal static class MeshBundleBuilder + { + // Unity BuildTarget.StandaloneWindows64. + public const int StandaloneWindows64 = 19; + + const long AssetBundlePathId = 1; + + // Mesh objects get path ids 2, 3, 4, ... (the AssetBundle object is path id 1). + const long FirstMeshPathId = 2; + + /// + /// Canonicalize an asset name into the exact form Unity uses for the m_Container lookup + /// key: lowercased (invariant culture) with backslashes replaced by forward slashes. Unity + /// canonicalizes the query passed to AssetBundle.LoadAsset(name) this same way but then + /// compares it verbatim against the stored key, so a stored key that isn't already + /// canonical can never be found. Pure; the model compiler reuses this to derive the lookup key. + /// + public static string Canonicalize(string name) => + (name ?? string.Empty).ToLowerInvariant().Replace('\\', '/'); + + /// + /// Build one UnityFS bundle wrapping every mesh in (each keyed in the + /// bundle's container by its canonical ) plus the one + /// AssetBundle object. Returns null when is empty. + /// Throws if two blobs canonicalize to the same container key (an ambiguous name lookup). + /// + public static byte[] BuildMany( + IReadOnlyList blobs, + int targetPlatform = StandaloneWindows64 + ) + { + if (blobs is null) + throw new ArgumentNullException(nameof(blobs)); + + int n = blobs.Count; + if (n == 0) + return null; + + // Canonicalize every container key once up front and reject duplicates: a duplicate + // canonical key makes AssetBundle.LoadAsset(name) ambiguous (the runtime container is a + // multimap, so a colliding key would silently resolve to an arbitrary mesh). Fail loud. + var canonicalKeys = new string[n]; + var seen = new Dictionary(n, StringComparer.Ordinal); + for (int i = 0; i < n; ++i) + { + string key = Canonicalize(blobs[i].Name); + if (seen.TryGetValue(key, out int prev)) + throw new InvalidOperationException( + $"duplicate mesh container key '{key}' from meshes '{blobs[prev].Name}' and " + + $"'{blobs[i].Name}'; canonical container keys must be unique for name lookup"); + seen[key] = i; + canonicalKeys[i] = key; + } + + // Unique guid so concurrent loads don't collide. + string cab = "CAB-" + Guid.NewGuid().ToString("N"); + + var w = new BundleBufferWriter(EstimateSize(blobs)); + + // Geometry is inline in the object bodies, so there is no streamed resS. + var prefix = BundleWriter.WriteHeaderAndBlocksInfo(w, cab, resSLength: 0); + + // One AssetBundle object plus one Mesh object per blob. + var objects = new SerializedFileWriter.ObjectMeta[n + 1]; + var slots = new SerializedFileWriter.ObjectSlot[n + 1]; + objects[0] = new SerializedFileWriter.ObjectMeta( + AssetBundlePathId, SerializedTypeTrees.AssetBundleClassId); + for (int i = 0; i < n; ++i) + objects[i + 1] = new SerializedFileWriter.ObjectMeta( + FirstMeshPathId + i, SerializedTypeTrees.MeshClassId); + + var file = SerializedFileWriter.BeginFile(w, targetPlatform, objects, slots); + + file.BeginObject(w, ref slots[0]); + WriteAssetBundleBody(w, cab, canonicalKeys); + file.EndObject(w, slots[0]); + + for (int i = 0; i < n; ++i) + { + file.BeginObject(w, ref slots[i + 1]); + WriteMeshBody(w, blobs[i]); + file.EndObject(w, slots[i + 1]); + } + + long serializedFileLength = file.End(w); + + w.AlignBase = 0; + BundleWriter.Finish(w, prefix, serializedFileLength); + + return w.ToArray(); + } + + static int EstimateSize(IReadOnlyList blobs) + { + long total = + 1024 + + ReferenceTypeTrees.TypeEntry(SerializedTypeTrees.AssetBundleClassId).Length + + ReferenceTypeTrees.TypeEntry(SerializedTypeTrees.MeshClassId).Length; + for (int i = 0; i < blobs.Count; ++i) + { + MeshBlob b = blobs[i]; + total += 512 + + (b.VertexData?.Length ?? 0) + + (b.IndexData?.Length ?? 0) + + (b.SubMeshes?.Length ?? 0) * 48 + + (b.BindPose?.Length ?? 0) * 64; + } + return total > int.MaxValue ? int.MaxValue : (int)total; + } + + // ---- Mesh object body (Unity 2019.4.18f1, class 43) --------------------------------------- + + static void WriteMeshBody(BundleBufferWriter w, MeshBlob mesh) + { + w.WriteAlignedString(mesh.Name); // m_Name + + // m_SubMeshes : vector, Array aligns + var subMeshes = w.BeginArray(); + MeshSubMesh[] subs = mesh.SubMeshes ?? Array.Empty(); + for (int i = 0; i < subs.Length; ++i) + { + subMeshes.Add(); + WriteSubMesh(w, subs[i]); + } + subMeshes.End(align: true); + + // m_Shapes : BlendShapeData — four empty aligned vectors (no blendshapes in .mu) + w.BeginArray().End(align: true); // vertices + w.BeginArray().End(align: true); // shapes + w.BeginArray().End(align: true); // channels + w.BeginArray().End(align: true); // fullWeights + + // m_BindPose : vector, Array aligns + var bindPose = w.BeginArray(); + Matrix4x4[] poses = mesh.BindPose ?? Array.Empty(); + for (int i = 0; i < poses.Length; ++i) + { + bindPose.Add(); + WriteMatrix4x4(w, poses[i]); + } + bindPose.End(align: true); + + w.BeginArray().End(align: true); // m_BoneNameHashes : vector + w.WriteUInt32(0); // m_RootBoneNameHash + w.BeginArray().End(align: true); // m_BonesAABB : vector + w.BeginArray().End(align: true); // m_VariableBoneCountWeights.m_Data : vector + + w.WriteByte(0); // m_MeshCompression (0 == uncompressed) + w.WriteBool(true); // m_IsReadable — keep the CPU copy (stock KSP behaviour) + w.WriteBool(false); // m_KeepVertices + w.WriteBool(false); // m_KeepIndices + w.Align(4); // m_KeepIndices aligns + + w.WriteInt32(mesh.IndexFormat); // m_IndexFormat (0 == UInt16, 1 == UInt32) + + // m_IndexBuffer : vector, Array aligns + byte[] indexData = mesh.IndexData ?? Array.Empty(); + w.WriteInt32(indexData.Length); + w.WriteBytes(indexData); + w.Align(4); + + // m_VertexData : VertexData (node aligns after) + w.WriteUInt32((uint)mesh.VertexCount); // m_VertexCount + var channels = w.BeginArray(); // m_Channels : vector, Array aligns + MeshChannel[] chans = mesh.Channels ?? Array.Empty(); + for (int i = 0; i < chans.Length; ++i) + { + channels.Add(); + w.WriteByte(chans[i].Stream); + w.WriteByte(chans[i].Offset); + w.WriteByte(chans[i].Format); + w.WriteByte(chans[i].Dimension); + } + channels.End(align: true); + byte[] vertexData = mesh.VertexData ?? Array.Empty(); + w.WriteInt32(vertexData.Length); // m_DataSize : TypelessData, aligns + w.WriteBytes(vertexData); + w.Align(4); + w.Align(4); // VertexData node aligns (no-op after m_DataSize's align) + + WriteEmptyCompressedMesh(w); // m_CompressedMesh (all packed vectors empty) + + WriteAABB(w, mesh.LocalBounds); // m_LocalAABB + w.WriteInt32(0); // m_MeshUsageFlags + w.BeginArray().End(align: true); // m_BakedConvexCollisionMesh : vector + w.BeginArray().End(align: true); // m_BakedTriangleCollisionMesh : vector + w.WriteSingle(mesh.MeshMetric0); // m_MeshMetrics[0] + w.WriteSingle(mesh.MeshMetric1); // m_MeshMetrics[1] + w.Align(4); // m_MeshMetrics[1] aligns + + // m_StreamData : StreamingInfo — empty (geometry is inline above) + w.WriteUInt32(0); // offset + w.WriteUInt32(0); // size + w.WriteAlignedString(string.Empty); // path + } + + static void WriteSubMesh(BundleBufferWriter w, in MeshSubMesh s) + { + w.WriteUInt32(s.FirstByte); + w.WriteUInt32(s.IndexCount); + w.WriteInt32(s.Topology); + w.WriteUInt32(s.BaseVertex); + w.WriteUInt32(s.FirstVertex); + w.WriteUInt32(s.VertexCount); + WriteAABB(w, s.LocalBounds); + } + + static void WriteAABB(BundleBufferWriter w, in Bounds bounds) + { + Vector3 c = bounds.center; + Vector3 e = bounds.extents; + w.WriteSingle(c.x); + w.WriteSingle(c.y); + w.WriteSingle(c.z); + w.WriteSingle(e.x); + w.WriteSingle(e.y); + w.WriteSingle(e.z); + } + + static void WriteMatrix4x4(BundleBufferWriter w, in Matrix4x4 m) + { + // Unity serializes e00,e01,...,e33 (row-major field names). Matrix4x4 indexer m[row,col] + // matches eRowCol. + w.WriteSingle(m.m00); w.WriteSingle(m.m01); w.WriteSingle(m.m02); w.WriteSingle(m.m03); + w.WriteSingle(m.m10); w.WriteSingle(m.m11); w.WriteSingle(m.m12); w.WriteSingle(m.m13); + w.WriteSingle(m.m20); w.WriteSingle(m.m21); w.WriteSingle(m.m22); w.WriteSingle(m.m23); + w.WriteSingle(m.m30); w.WriteSingle(m.m31); w.WriteSingle(m.m32); w.WriteSingle(m.m33); + } + + // All ten PackedBitVectors are empty; five carry m_Range/m_Start floats (see the type tree). + static void WriteEmptyCompressedMesh(BundleBufferWriter w) + { + WritePackedBitVector(w, hasRange: true); // m_Vertices + WritePackedBitVector(w, hasRange: true); // m_UV + WritePackedBitVector(w, hasRange: true); // m_Normals + WritePackedBitVector(w, hasRange: true); // m_Tangents + WritePackedBitVector(w, hasRange: false); // m_Weights + WritePackedBitVector(w, hasRange: false); // m_NormalSigns + WritePackedBitVector(w, hasRange: false); // m_TangentSigns + WritePackedBitVector(w, hasRange: true); // m_FloatColors + WritePackedBitVector(w, hasRange: false); // m_BoneIndices + WritePackedBitVector(w, hasRange: false); // m_Triangles + w.WriteUInt32(0); // m_UVInfo + } + + static void WritePackedBitVector(BundleBufferWriter w, bool hasRange) + { + w.WriteUInt32(0); // m_NumItems + if (hasRange) + { + w.WriteSingle(0f); // m_Range + w.WriteSingle(0f); // m_Start + } + w.BeginArray().End(align: true); // m_Data : vector, Array aligns + w.WriteByte(0); // m_BitSize + w.Align(4); // m_BitSize aligns + } + + // ---- AssetBundle object body -------------------------------------------------------------- + + static void WriteAssetBundleBody( + BundleBufferWriter w, + string identity, + string[] canonicalKeys + ) + { + int n = canonicalKeys.Length; + + w.WriteAlignedString(identity); // m_Name + + // m_PreloadTable : one PPtr per mesh, in blob order (path ids 2..N+1). Each container + // entry references its own single-entry slice of this table by blob index. + var preload = w.BeginArray(); + for (int i = 0; i < n; ++i) + { + preload.Add(); + WritePPtr(w, fileId: 0, pathId: FirstMeshPathId + i); + } + preload.End(align: true); + + // m_Container : map. Array not aligned. Written sorted ascending by + // canonical key. Ghidra (UnityPlayer 2019.4) shows Unity deserializes this map into a + // std::multimap> by inserting each pair into a + // red-black tree, so the runtime lookup is a tree search that re-sorts on load and is + // therefore independent of the serialized order. We still sort here to match the layout of + // real Unity-built bundles (correct whether the lookup were linear or binary). Only the + // container entry order changes: each entry still keys its own mesh (path id) and its own + // preload slice (blob index), so meshes keep path ids 2..N+1 in blob order. + var order = new int[n]; + for (int i = 0; i < n; ++i) + order[i] = i; + Array.Sort(order, (a, b) => string.CompareOrdinal(canonicalKeys[a], canonicalKeys[b])); + + var container = w.BeginArray(); + for (int j = 0; j < n; ++j) + { + int i = order[j]; + container.Add(); + w.WriteAlignedString(canonicalKeys[i]); // pair.first (canonical lookup key) + WriteAssetInfo( + w, preloadIndex: i, preloadSize: 1, fileId: 0, pathId: FirstMeshPathId + i); + } + container.End(); + + WriteAssetInfo(w, preloadIndex: 0, preloadSize: 0, fileId: 0, pathId: 0); // m_MainAsset + w.WriteUInt32(1); // m_RuntimeCompatibility + w.WriteAlignedString(identity); // m_AssetBundleName + w.BeginArray().End(align: true); // m_Dependencies (empty) + w.WriteBool(false); // m_IsStreamedSceneAssetBundle + w.Align(4); + w.WriteInt32(0); // m_ExplicitDataLayout + w.WriteInt32(0); // m_PathFlags + w.BeginArray().End(); // m_SceneHashes (empty map, Array not aligned) + } + + static void WritePPtr(BundleBufferWriter w, int fileId, long pathId) + { + w.WriteInt32(fileId); // m_FileID + w.WriteInt64(pathId); // m_PathID + } + + static void WriteAssetInfo( + BundleBufferWriter w, + int preloadIndex, + int preloadSize, + int fileId, + long pathId + ) + { + w.WriteInt32(preloadIndex); + w.WriteInt32(preloadSize); + WritePPtr(w, fileId, pathId); + } + } +} diff --git a/KSPCommunityFixes/Library/Model/MeshBundleSelfTest.cs b/KSPCommunityFixes/Library/Model/MeshBundleSelfTest.cs new file mode 100644 index 0000000..2bc09b6 --- /dev/null +++ b/KSPCommunityFixes/Library/Model/MeshBundleSelfTest.cs @@ -0,0 +1,179 @@ +#if DEBUG +using System.Collections; +using System.Text; +using UnityEngine; + +namespace KSPCommunityFixes.Library.Model +{ + /// + /// DEBUG-only self-test: builds a mesh bundle in memory, loads it via + /// AssetBundle.LoadFromMemoryAsync + a per-name LoadAssetAsync, and verifies the + /// loaded mesh renders and reads back identically to the source. Runs once at startup and logs a + /// [MeshSelfTest] PASS/FAIL sentinel. This validates the mesh serialization in the real + /// Unity runtime before the model pipeline is built on top of it. + /// The mesh name here is deliberately non-canonical (mixed case + backslash) and is + /// used verbatim as both the source blob name and the LoadAssetAsync query. Unity + /// canonicalizes the query, and canonicalizes the + /// stored container key, so the load only succeeds if that canonicalization is applied — this is + /// the regression guard for the container-key canonicalization fix. + /// + [KSPAddon(KSPAddon.Startup.Instantly, true)] + internal class MeshBundleSelfTest : MonoBehaviour + { + const string Tag = "[MeshSelfTest]"; + + // Deliberately non-canonical (uppercase + backslash) to exercise container-key + // canonicalization: Unity lowercases/forward-slashes the LoadAssetAsync query to + // "meshselftest/quad#0", which must match the canonicalized stored key. + const string MeshName = "MeshSelfTest\\Quad#0"; + + IEnumerator Start() + { + Mesh src = BuildSourceQuad(); + MeshBlob blob = MeshBlobBuilder.FromMesh(src, MeshName); + byte[] bytes = MeshBundleBuilder.BuildMany(new[] { blob }); + Debug.Log($"{Tag} built bundle: {bytes.Length} bytes, src verts={src.vertexCount}"); + + AssetBundleCreateRequest createReq = AssetBundle.LoadFromMemoryAsync(bytes); + yield return createReq; + AssetBundle bundle = createReq.assetBundle; + if (bundle == null) + { + Debug.LogError($"{Tag} FAIL: LoadFromMemoryAsync returned a null bundle"); + yield break; + } + + AssetBundleRequest loadReq = bundle.LoadAssetAsync(MeshName); + yield return loadReq; + Mesh loaded = loadReq.asset as Mesh; + if (loaded == null) + { + Debug.LogError($"{Tag} FAIL: LoadAssetAsync(\"{MeshName}\") returned null " + + "(container-key lookup or deserialization failed)"); + bundle.Unload(true); + yield break; + } + + var sb = new StringBuilder(); + bool ok = Compare(src, loaded, sb); + + // Exercise the render path: put it on a GameObject with a renderer (no exception == ok). + bool rendered = true; + try + { + var go = new GameObject("MeshSelfTestRender"); + go.AddComponent().sharedMesh = loaded; + go.AddComponent(); + go.SetActive(false); + Destroy(go); + } + catch (System.Exception e) + { + rendered = false; + sb.Append($"\n render setup threw: {e.GetType().Name}: {e.Message}"); + } + + if (ok && rendered && loaded.isReadable) + Debug.Log($"{Tag} PASS: loaded readable mesh round-trips (verts={loaded.vertexCount}, " + + $"subMeshes={loaded.subMeshCount}){sb}"); + else + Debug.LogError($"{Tag} FAIL: ok={ok} rendered={rendered} readable={loaded.isReadable}{sb}"); + + bundle.Unload(false); + } + + static Mesh BuildSourceQuad() + { + var mesh = new Mesh { name = "src_quad" }; + mesh.SetVertices(new System.Collections.Generic.List + { + new Vector3(0f, 0f, 0f), new Vector3(1f, 0f, 0f), + new Vector3(1f, 1f, 0f), new Vector3(0f, 1f, 0f), + }); + mesh.SetNormals(new System.Collections.Generic.List + { + Vector3.forward, Vector3.forward, Vector3.forward, Vector3.forward, + }); + mesh.SetTangents(new System.Collections.Generic.List + { + new Vector4(1f, 0f, 0f, 1f), new Vector4(1f, 0f, 0f, 1f), + new Vector4(1f, 0f, 0f, 1f), new Vector4(1f, 0f, 0f, 1f), + }); + mesh.colors32 = new[] + { + new Color32(255, 0, 0, 255), new Color32(0, 255, 0, 255), + new Color32(0, 0, 255, 255), new Color32(255, 255, 0, 128), + }; + mesh.SetUVs(0, new System.Collections.Generic.List + { + new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(1f, 1f), new Vector2(0f, 1f), + }); + mesh.SetUVs(1, new System.Collections.Generic.List + { + new Vector2(0f, 0f), new Vector2(0.5f, 0f), new Vector2(0.5f, 0.5f), new Vector2(0f, 0.5f), + }); + mesh.SetTriangles(new[] { 0, 1, 2, 0, 2, 3 }, 0); + mesh.RecalculateBounds(); + return mesh; + } + + static bool Compare(Mesh a, Mesh b, StringBuilder sb) + { + bool ok = true; + if (a.vertexCount != b.vertexCount) { ok = false; sb.Append($"\n vertexCount {a.vertexCount} != {b.vertexCount}"); return ok; } + if (a.subMeshCount != b.subMeshCount) { ok = false; sb.Append($"\n subMeshCount {a.subMeshCount} != {b.subMeshCount}"); } + ok &= CmpV3("vertices", a.vertices, b.vertices, sb); + ok &= CmpV3("normals", a.normals, b.normals, sb); + ok &= CmpV4("tangents", a.tangents, b.tangents, sb); + ok &= CmpV2("uv", a.uv, b.uv, sb); + ok &= CmpV2("uv2", a.uv2, b.uv2, sb); + ok &= CmpC32("colors32", a.colors32, b.colors32, sb); + int subs = Mathf.Min(a.subMeshCount, b.subMeshCount); + for (int s = 0; s < subs; ++s) + ok &= CmpInt($"triangles[{s}]", a.GetTriangles(s), b.GetTriangles(s), sb); + return ok; + } + + static bool CmpV3(string n, Vector3[] a, Vector3[] b, StringBuilder sb) + { + if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } + for (int i = 0; i < a.Length; ++i) + if ((a[i] - b[i]).sqrMagnitude > 1e-10f) { sb.Append($"\n {n}[{i}] {a[i]} != {b[i]}"); return false; } + return true; + } + + static bool CmpV4(string n, Vector4[] a, Vector4[] b, StringBuilder sb) + { + if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } + for (int i = 0; i < a.Length; ++i) + if ((a[i] - b[i]).sqrMagnitude > 1e-10f) { sb.Append($"\n {n}[{i}] {a[i]} != {b[i]}"); return false; } + return true; + } + + static bool CmpV2(string n, Vector2[] a, Vector2[] b, StringBuilder sb) + { + if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } + for (int i = 0; i < a.Length; ++i) + if ((a[i] - b[i]).sqrMagnitude > 1e-10f) { sb.Append($"\n {n}[{i}] {a[i]} != {b[i]}"); return false; } + return true; + } + + static bool CmpC32(string n, Color32[] a, Color32[] b, StringBuilder sb) + { + if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } + for (int i = 0; i < a.Length; ++i) + if (a[i].r != b[i].r || a[i].g != b[i].g || a[i].b != b[i].b || a[i].a != b[i].a) + { sb.Append($"\n {n}[{i}] ({a[i].r},{a[i].g},{a[i].b},{a[i].a}) != ({b[i].r},{b[i].g},{b[i].b},{b[i].a})"); return false; } + return true; + } + + static bool CmpInt(string n, int[] a, int[] b, StringBuilder sb) + { + if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } + for (int i = 0; i < a.Length; ++i) + if (a[i] != b[i]) { sb.Append($"\n {n}[{i}] {a[i]} != {b[i]}"); return false; } + return true; + } + } +} +#endif diff --git a/KSPCommunityFixes/Library/TextureBundle/SerializedTypeTrees.cs b/KSPCommunityFixes/Library/TextureBundle/SerializedTypeTrees.cs index d010756..2c44fd2 100644 --- a/KSPCommunityFixes/Library/TextureBundle/SerializedTypeTrees.cs +++ b/KSPCommunityFixes/Library/TextureBundle/SerializedTypeTrees.cs @@ -9,6 +9,7 @@ namespace KSPCommunityFixes.Library.TextureBundle internal static class SerializedTypeTrees { public const int Texture2DClassId = 28; + public const int MeshClassId = 43; public const int AssetBundleClassId = 142; } } diff --git a/KSPCommunityFixes/Library/TextureBundle/TextureTypeTrees.bin b/KSPCommunityFixes/Library/TextureBundle/TextureTypeTrees.bin index 805235bb5b2cb55704f5cf18c07573dc47d450fa..0eecfd63228a469ebef6d99246af47b0e1c96a3d 100644 GIT binary patch literal 12582 zcmd6tX_OpQ700V*2@v+M6P5uI2ung{hJDe>OcF9?CQLF}WC@`%T{CIY%h25uW)QRx zP!JIiHOL|vL`0EAWpM!svWmD6MG;Uy!435sIEUl$6aN42-8Wsgs(ZfbJ*Qs1cYk%? zyYJRps=8}w_sW$S&+~S~(J{aMkIy&1&=a75GW+56yw<#V6eD?fMr0k=0l z_-gk0_j88>-v-kroG)nhq7!lT2M`pIGpR{3UdCGjeow}7ww8&upS&RBHD%gi!Grd! zFXP8~v-bzEnW&=$jIL!o&6k5;fP;1O8qG7t9%{o{nTZMg>xJz;(9s?}_B@}76>_$J zG>WWyI~==NWKzdFQ6i^j_4sMRcWA)-g-^$semHUUpAQA==bcK{PyaVUPrIFP#KrY* z3`+8yi9O~$#=(*^q=v4UHA-~ znIQBRJ;|r)|csUF6z#^XEYF8aDCS0(&=q z4odNiw+RMUNc{)XHezCh_1^{i8^9U=HqpD2x03KC>4ULS6H{ObWyP~8014opj(s6 zRyW|hB+LzO;6>e~!bm=>hCPK~I2_1b974A;9IK9&!^w*JawFZP(IPMJsjLWtY_LAh z>w1QZrE=I8RH|WFYgJGo-W^wI6wHyY8TyYqc6@rmU9aVohS1RYK z7S1I9)*1K{64c3%{ zkyUbDE$1~j_cAc>aI*K^rF^M8FcJ)gUXE_HWrJ$qP4=g!IK+x=c_C40Xs8lay^#Qs zrf}gs2%~~$jPQ+TEuFac;u$yZHazF?8FxHA`Y&it#lYjbN%?=?hhD-s=l;Wa%5x?{ zB7%9K#yR&PJT8GX0x_(9?yrC`%s)`FgrO~+X?nCwXGU4fch+cWo*+{s?av#$6t=`+{Ulw|{%{?aLwIQ{V z!OlJb`vQuriThAJUJ`Eip*EDPIQt7xq&@c`ZExGJK+pEM4{5I3=RQ=AyY{&c)#I*x z?n9bqyi1_F1_#F%W0V+Q9>?DogmWBduGd-aLwfw_b(Z^(j(>}H8+06hb8t5M5YA3A zo{i6aP%u7rA8K-M=1X?x3E`jT#rZEv|-AC9fV zdr|irqURczv?qT@^zA(RNJL!!|BW*H-+{CCPtT7|@~ucjlKnppdfh&v4h8$q`j01% z#MoThYMz7m97}#eiceGf1J_yY|8^?=Cz(2th-C2QvK(!*efo7W4(*?f?@TB;K2E8_ zd4FdKKeYirR`_WtZpYUt;OyV&_ zzPJHDOZXWL_*&sh8t_1PPXitb|40L#6TY+oF9^q6tJ}W|h4(e!qry4Qk)0G5zj5Jb zHsF^E?{B~_6OOr1xBe@IuV}!p7Cz8`Un_iNirewW_au)0Rpb#8k>vB=YUrmLe{0B{ zvB@`^_0x_2kEVFW@H@o6v+8ipkB7uRK4(qk-#O$y5)qF7`1*e?^hy7ofP((<*<@{s zx0vU*7jVr^VCNa$hSUo8hY8ra=eKoGC-*mUw?CfWfL{nb=Uz{6 zI4AC3#@hnE2ik{=-o`qd`|E7s`3Ag0IPW9tal-YP_6vj;0kr@2cepdbdH)#aq&>&) zT5!g1gginblAK?>54wIme#_*}*yNi{{}~_Z`J5l;%_wa|+nc2QD*09txBLi|$a#?n^Z69l@wt)QMlyBGSY`gb3>Gd6kMzxz}6x_=Ll`$$BR{rfibx_;fi@1(fy-*?G< zBqFYVS?S+{&`;IBhsd3=$?N_-oU+&bdxYFaB9iRi_n_DH>;8Q|#dZIFK<*p^p1+SZ;BBa%`|FSD@c8_FoIFAzlAOO=q1XQF`TGR9*z&~qZHeuV zKTdHSpC`$EBqA<8m%)GTk556b{nPXJX>w<5^7{Tild{+M|5EBPGpQ?W^kUL|O*Zuoh%3k;H=j1*Tk!1gV0lltY_wSb}uKV{Q zxsODI{p0ibE$AP|-!>e&ep8C>1n2wfuTs28{r~W5!`qNranFZ*?_vEf;n44|c7E`^ zhWs}TINw{yf1BdAeZH6MgyZEDxAnXBf7gJ!_FrkhUHh-5xP3o+K;rkB;YdW>?{80l zbAJ3DdOd#h?{9clP23;-e)c-K)Xfv)yCpV%-!M87E3W<5r2RLco~r#nlKa+{Y4ZK! zPe!MCy#2RQJY(Ju{)6^;|K6^dnjHO`@bKF>Ao-uE^_f^9p9ydz4#w{o-s zXSDxo4Vj$d?)Uk>)#3af{sgK2@8nVKBJ^Lqf4l>v@ zU&6om3l?;Bq5AHyh<~-cF>f3%xP^6##;Q9oP_zgytV;c)@(dV=X!S2OqmY z)$idC{6diTszGs>^%w`eiZy08Y887Mg32;;b%oiL)}rDKZU~CSFkkWX<=s~86@x-( zk|hDcGgL;ghf%Ou+rOdFe147jAc23(cfpDNQYG}l`SWqK4ohmx1-#Say!Z$ zD0iUTfpQ1RohWyr+=+5$C#xzI!)0iZZ6gSUbt_7xYFsJTR7`ODa>c%2OyZ0JAj;nA zpq%3e541D>)?A$e$$el@_=!as-$C%xg~S&WOT%!4H^khd!a5ca9VwYOO@4-8N?oHv zL%93)9!e8+3O1}5EbiV&#(b+#F<)Z1uM~#t*CN@;k1O!q2tHykUxGNt!z{Z2-xG>{ zd~FhDyK>dl;b65?#s#syEE_HKRKtRaI>!p=Ks6}y;}BTLmnN(D<%Jtn_=v*|Dtw+X zIUXz?$PE`+mt5kCi}R(RYQE4Qo{HoRRoknkLF0oguD)^*haXv5~ zk#q&)(Yes$D?;c>nP0 zgt@=Frg*E0-)`V~7n`PjMcbXcRz335)Gjt7#aqljtJC4bo?v^#Y?*aWvE9hG;NUfJ zal9W&^1b2^d2-&rbCGB4_D*r}A9~vFgG1k+Y3ly*NmlcW@qaz^^ld*Jap6-0$A|NO z|2Rb6apynBQC!I5{vQyB$WM-64Zt_&fw^JwEjKJd|8)d7^*IQR4+Ts6Wi;NUX4Z&hO1Avi(_9M@+0F+n-JK zR?C=9#s_t7#&3QN znH+h1eQBpQGRP#!^`(Q_$RLv-J}SPQ(CYZ=_2oG7nmO`%eK|hG?fS;`h5bK)wh!lNzxc05zorv;Y7A From 1e742240c0326a98aa28b82a1b953cb7f3a99500 Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 14 Jul 2026 11:33:33 -0700 Subject: [PATCH 02/14] Add thread-safe MuBinaryReader for background .mu parsing Extract MuParser's low-level binary primitives (Advance/ReadInt/ReadFloat/ ReadString/7-bit length/ReadVector*/ReadColor*/ReadBoneWeight/ReadMatrix4x4/ ReadKeyFrame + bulk Fill* memcpy helpers) into an instance-state unsafe struct so many worker threads can parse different .mu files in parallel. Byte layout, cursor advancement, bounds checks and UTF8 string decoding reproduce MuParser exactly; the only shared state is an immutable UTF8Encoding. Hardened over a straight extraction: Fill* helpers pass the destination's true byte capacity to Buffer.MemoryCopy so an under-sized array throws instead of corrupting the heap; Advance rejects negative/overflowing byte counts from corrupt files. Documented the mutable-struct value-semantics contract (hold in one field/local, pass by ref, never copy - as Utf8JsonReader). Groundwork for MuModelCompiler (task #4); MuParser stays as the oracle. --- .../Library/Model/MuBinaryReader.cs | 347 ++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 KSPCommunityFixes/Library/Model/MuBinaryReader.cs diff --git a/KSPCommunityFixes/Library/Model/MuBinaryReader.cs b/KSPCommunityFixes/Library/Model/MuBinaryReader.cs new file mode 100644 index 0000000..79c3f61 --- /dev/null +++ b/KSPCommunityFixes/Library/Model/MuBinaryReader.cs @@ -0,0 +1,347 @@ +using System; +using System.Runtime.CompilerServices; +using System.Text; +using UnityEngine; + +namespace KSPCommunityFixes.Library.Model +{ + /// + /// Thread-safe, instance-state extraction of MuParser's low-level binary reading primitives. + /// + /// The stock MuParser keeps its cursor and scratch buffers in static mutable fields, which + /// makes it usable from only one thread at a time. This struct owns all of that state per-instance + /// (a base pointer, a length, a read cursor and a lazily grown decode buffer) so many worker + /// threads can parse different .mu files in parallel. The byte layout, cursor advancement, + /// bounds checks and string decoding are a faithful, byte-for-byte reproduction of the originals. + /// + /// + /// The caller is responsible for pinning the input byte[] (via a + /// or a fixed statement) and passing the resulting pointer plus valid length. The struct + /// stores the pointer and never pins on its own, so the buffer must stay pinned for the reader's + /// whole lifetime. Only plain value types (, , + /// , , , ...) are produced; + /// no UnityEngine.Object is ever created, so every method here is safe off the main thread. + /// + /// + /// IMPORTANT — this is a MUTABLE struct. It carries a live read cursor () and + /// a lazily-allocated decode buffer, so it must be treated like : + /// hold it in exactly one local variable or one non-readonly field and mutate that single + /// instance. Do NOT pass it by value to a helper, store it in a readonly field (which forces a + /// defensive copy on every access), capture it in a lambda, box it, or iterate it in a foreach — + /// each of those silently makes a copy whose cursor advances independently, desyncing the read + /// position with no compiler error. Any helper that must advance the reader has to take it by + /// ref MuBinaryReader. It stays a struct (rather than a class) deliberately, for the same + /// allocation/performance reasons as Utf8JsonReader; do not "fix" the mutable-struct footgun + /// by turning it into a class. + /// + /// + internal unsafe struct MuBinaryReader + { + // MuParser named this field "decoder", but it is a UTF8Encoding (an Encoding), not a + // System.Text.Decoder. That distinction matters for threading: a System.Text.Decoder carries + // per-instance state across calls and is NOT thread-safe, whereas Encoding.GetChars is + // stateless and documented as safe to call from multiple threads on a shared instance. So we + // keep a single shared static readonly UTF8Encoding and decode straight through it, exactly as + // the original does, with no per-thread hazard. + private static readonly UTF8Encoding utf8 = new UTF8Encoding(); + + /// Base pointer to the caller-pinned data buffer. + public readonly byte* Ptr; + + /// Valid length of the data buffer, in bytes (the original dataLength). + public readonly int Length; + + /// Current read cursor, in bytes (replaces the original static index). + public int Position; + + // Instance-owned UTF8 decode scratch buffer, lazily allocated and grown by ReadString + // (replaces the original static charBuffer). Being per-instance is what makes ReadString + // thread-safe. + private char[] charBuffer; + + /// + /// Create a reader over an already-pinned buffer. Pinning is the caller's responsibility; this + /// struct only stores the pointer and never pins. + /// + /// Base pointer to the pinned data buffer. + /// Valid length of the buffer in bytes. + public MuBinaryReader(byte* ptr, int length) + { + Ptr = ptr; + Length = length; + Position = 0; + charBuffer = null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int Advance(int bytes) + { + int currentIndex = Position; + int nextIndex = currentIndex + bytes; + // The unsigned compare plus the explicit bytes < 0 guard rejects overflow and negative reads + // from corrupt/malicious data (e.g. a huge or negative 7-bit-encoded length that would wrap + // nextIndex negative and slip past a plain "> Length" check), while remaining identical to the + // original bounds behavior for every valid file where 0 <= nextIndex <= Length. + if (bytes < 0 || (uint)nextIndex > (uint)Length) + ThrowEndOfDataException(); + + Position = nextIndex; + return currentIndex; + } + + private static void ThrowEndOfDataException() + { + throw new InvalidOperationException("Unable to read beyond the end of the data"); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public byte ReadByte() + { + int valIdx = Advance(1); + return *(Ptr + valIdx); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SkipInt() + { + Advance(4); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int ReadInt() + { + int valIdx = Advance(4); + return *(int*)(Ptr + valIdx); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public float ReadFloat() + { + int valIdx = Advance(4); + return *(float*)(Ptr + valIdx); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SkipBool() + { + Advance(1); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool ReadBool() + { + int valIdx = Advance(1); + return *(Ptr + valIdx) != 0; + } + + public void SkipString() + { + Advance(Read7BitEncodedInt()); + } + + public string ReadString() + { + int strByteLength = Read7BitEncodedInt(); + + if (strByteLength < 0) + throw new Exception("Invalid string length"); + + if (strByteLength == 0) + return string.Empty; + + ExpandCharBuffer(strByteLength); + + // Advance returns the pre-advance cursor, i.e. the start offset of the string bytes (the + // original captured "index" before advancing). We decode straight from the pinned buffer + // via the unsafe Encoding.GetChars overload instead of the byte[] overload the original + // used; the input bytes are identical, so the decoded chars are byte-for-byte the same. + int start = Advance(strByteLength); + int charCount; + fixed (char* charPtr = charBuffer) + charCount = utf8.GetChars(Ptr + start, strByteLength, charPtr, charBuffer.Length); + + return new string(charBuffer, 0, charCount); + } + + private void ExpandCharBuffer(int length) + { + if (charBuffer == null || charBuffer.Length < length) + charBuffer = new char[(int)(length * 1.5)]; + } + + // 7-bit-encoded length prefix (the BinaryWriter/BinaryReader string-length format): each byte + // contributes its low 7 bits, and the high bit flags that another byte follows. After 5 bytes + // (shift reaches 35) the value can no longer fit in an Int32, which is an error. + public int Read7BitEncodedInt() + { + int num = 0; + int num2 = 0; + byte b; + do + { + if (num2 == 35) + { + throw new FormatException("Too many bytes in what should have been a 7 bit encoded Int32."); + } + b = ReadByte(); + num |= (b & 0x7F) << num2; + num2 += 7; + } + while ((b & 0x80u) != 0); + return num; + } + + public Vector2 ReadVector2() + { + int valIdx = Advance(8); + return *(Vector2*)(Ptr + valIdx); + } + + public Vector3 ReadVector3() + { + int valIdx = Advance(12); + return *(Vector3*)(Ptr + valIdx); + } + + public Vector4 ReadVector4() + { + int valIdx = Advance(16); + return *(Vector4*)(Ptr + valIdx); + } + + public Quaternion ReadQuaternion() + { + int valIdx = Advance(16); + return *(Quaternion*)(Ptr + valIdx); + } + + public Color ReadColor() + { + int valIdx = Advance(16); + return *(Color*)(Ptr + valIdx); + } + + public Color32 ReadColor32() + { + int valIdx = Advance(4); + return *(Color32*)(Ptr + valIdx); + } + + public BoneWeight ReadBoneWeight() + { + int valIdx = Advance(32); + // data isn't packed with the same layout as the struct, so we fallback to setting every field + return new BoneWeight() + { + boneIndex0 = *(int*)(Ptr + valIdx), + weight0 = *(float*)(Ptr + valIdx + 4), + boneIndex1 = *(int*)(Ptr + valIdx + 8), + weight1 = *(float*)(Ptr + valIdx + 12), + boneIndex2 = *(int*)(Ptr + valIdx + 16), + weight2 = *(float*)(Ptr + valIdx + 20), + boneIndex3 = *(int*)(Ptr + valIdx + 24), + weight3 = *(float*)(Ptr + valIdx + 28) + }; + } + + public Matrix4x4 ReadMatrix4x4() + { + int valIdx = Advance(64); + // data isn't packed with the same layout as the struct, so we fallback to setting every field + return new Matrix4x4() + { + m00 = *(float*)(Ptr + valIdx), + m01 = *(float*)(Ptr + valIdx + 4), + m02 = *(float*)(Ptr + valIdx + 8), + m03 = *(float*)(Ptr + valIdx + 12), + m10 = *(float*)(Ptr + valIdx + 16), + m11 = *(float*)(Ptr + valIdx + 20), + m12 = *(float*)(Ptr + valIdx + 24), + m13 = *(float*)(Ptr + valIdx + 28), + m20 = *(float*)(Ptr + valIdx + 32), + m21 = *(float*)(Ptr + valIdx + 36), + m22 = *(float*)(Ptr + valIdx + 40), + m23 = *(float*)(Ptr + valIdx + 44), + m30 = *(float*)(Ptr + valIdx + 48), + m31 = *(float*)(Ptr + valIdx + 52), + m32 = *(float*)(Ptr + valIdx + 56), + m33 = *(float*)(Ptr + valIdx + 60) + }; + } + + public Keyframe ReadKeyFrame() + { + // this is encoded as 4 floats (16 bytes), but there is 4 bytes of padding at the end + int valIdx = Advance(20); + return new Keyframe( + *(float*)(Ptr + valIdx), + *(float*)(Ptr + valIdx + 4), + *(float*)(Ptr + valIdx + 8), + *(float*)(Ptr + valIdx + 12)); + } + + // The Fill* helpers bulk-copy N sequentially packed elements straight from the pinned data + // buffer into a caller-owned destination array. This mirrors MuParser exactly: the mu mesh + // data is laid out as a contiguous run of the element structs, so we Advance past the run and + // memcpy it in one shot rather than reading element by element. Unlike MuParser these do NOT + // own or grow the destination — the caller supplies an array with room for at least + // elements (the buffer growth that MuParser did internally now lives + // with whoever owns the buffers). + // + // The 3rd argument to Buffer.MemoryCopy is destinationSizeInBytes — the ONLY overflow guard — so + // it must be the destination's TRUE byte capacity (real array length * element size), never the + // copy size. Passing the copy size would defeat the guard and let a too-small destination be + // overrun as a silent out-of-bounds heap write; passing the real capacity makes MemoryCopy throw + // ArgumentOutOfRangeException instead. + + public void FillIntBuffer(int[] destination, int intCount) + { + int byteCount = intCount * 4; + int valIdx = Advance(byteCount); + + fixed (int* intBufferPtr = destination) + // 3rd arg is the destination's real byte capacity, so the copy is bounds-checked. + Buffer.MemoryCopy(Ptr + valIdx, intBufferPtr, (long)destination.Length * 4, byteCount); + } + + public void FillVector2Buffer(Vector2[] destination, int vector2Count) + { + int byteCount = vector2Count * 8; + int valIdx = Advance(byteCount); + + fixed (Vector2* vector2BufferPtr = destination) + // 3rd arg is the destination's real byte capacity, so the copy is bounds-checked. + Buffer.MemoryCopy(Ptr + valIdx, vector2BufferPtr, (long)destination.Length * 8, byteCount); + } + + public void FillVector3Buffer(Vector3[] destination, int vector3Count) + { + int byteCount = vector3Count * 12; + int valIdx = Advance(byteCount); + + fixed (Vector3* vector3BufferPtr = destination) + // 3rd arg is the destination's real byte capacity, so the copy is bounds-checked. + Buffer.MemoryCopy(Ptr + valIdx, vector3BufferPtr, (long)destination.Length * 12, byteCount); + } + + public void FillVector4Buffer(Vector4[] destination, int vector4Count) + { + int byteCount = vector4Count * 16; + int valIdx = Advance(byteCount); + + fixed (Vector4* vector4BufferPtr = destination) + // 3rd arg is the destination's real byte capacity, so the copy is bounds-checked. + Buffer.MemoryCopy(Ptr + valIdx, vector4BufferPtr, (long)destination.Length * 16, byteCount); + } + + public void FillColor32Buffer(Color32[] destination, int color32Count) + { + int byteCount = color32Count * 4; + int valIdx = Advance(byteCount); + + fixed (Color32* color32BufferPtr = destination) + // 3rd arg is the destination's real byte capacity, so the copy is bounds-checked. + Buffer.MemoryCopy(Ptr + valIdx, color32BufferPtr, (long)destination.Length * 4, byteCount); + } + } +} From 9101d41f803ab09422ca033758db5e7d5191b99d Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 14 Jul 2026 12:01:21 -0700 Subject: [PATCH 03/14] Add model replay instructions and compiled-model data types Split MuParser's GameObject assembly into main-thread replay instructions so a background compiler (next task) can bake the data off-thread. Each IModelInstruction.Execute reproduces the matching MuParser reader's object- building tail exactly - verified field-by-field against the oracle: - Hierarchy/tag+layer, mesh filter/renderer, skinned renderer + bone resolve - Materials (shader resolved at replay incl. MuParser's null-shader fallback; last-wins singular sharedMaterial; scale/offset always applied; missing texture skipped + logged as MuParser does), collider set (mesh/sphere/ capsule/box + isTrigger variants, wheel), light, camera, particle emitter (full KSPParticleEmitter field set + render-mode switch), legacy animation (isInvalid poisoning + curve-type mapping) CompiledModel carries Instructions[]/MeshBlob[]/MeshBinding[]/LocalCount plus a skinned-mesh flag (v1 falls back to MuParser for those). MeshBinding maps a locals[] slot to a canonical bundle mesh name for LoadAssetAsync. Data-carrier fields are baked by the not-yet-written compiler, so CS0649 "never assigned" warnings are expected and intentionally not suppressed. Groundwork for MuModelCompiler (task #4); MuParser stays as the oracle. --- .../Library/Model/CompiledModel.cs | 48 ++ .../Library/Model/MeshBinding.cs | 30 + .../Library/Model/ModelInstructions.cs | 738 ++++++++++++++++++ 3 files changed, 816 insertions(+) create mode 100644 KSPCommunityFixes/Library/Model/CompiledModel.cs create mode 100644 KSPCommunityFixes/Library/Model/MeshBinding.cs create mode 100644 KSPCommunityFixes/Library/Model/ModelInstructions.cs diff --git a/KSPCommunityFixes/Library/Model/CompiledModel.cs b/KSPCommunityFixes/Library/Model/CompiledModel.cs new file mode 100644 index 0000000..4568d47 --- /dev/null +++ b/KSPCommunityFixes/Library/Model/CompiledModel.cs @@ -0,0 +1,48 @@ +namespace KSPCommunityFixes.Library.Model +{ + /// + /// The fully compiled, main-thread-ready representation of a single .mu model file, produced + /// by the background model compiler (a later task) as a faithful split of what + /// does in one pass. It carries no + /// UnityEngine.Object: the meshes live as serialization-ready s (baked + /// into an AssetBundle off-thread) and the GameObject hierarchy lives as a flat + /// list. + /// + /// The main-thread driver allocates UnityEngine.Object[] locals of size + /// , uses to place each loaded Mesh into its + /// slot, then calls Execute(locals) on every instruction in order. locals[0] ends up + /// holding the root GameObject (the equivalent of MuParser.Parse's return value). + /// + /// + internal sealed class CompiledModel + { + /// The ordered assembly steps. Replayed front-to-back on the main thread; each reads + /// and/or writes locals by integer slot index (-1 meaning "none"). + public IModelInstruction[] Instructions; + + /// Serialization-ready meshes referenced by this model (static meshes only in v1). + public MeshBlob[] Blobs; + + /// Maps each mesh's locals slot to the canonical name the driver looks it up by + /// in the loaded mesh AssetBundle. + public MeshBinding[] Bindings; + + /// Size of the locals[] array the driver must allocate (high-water slot + 1). + public int LocalCount; + + /// The source file.url — used as the root name, for logging and for registration. + public string SourceUrl; + + /// True when the model contains a skinned mesh renderer. The v1 pipeline falls back to + /// the synchronous for these models rather than + /// baking skinned meshes. + public bool ContainsSkinnedMesh; + + /// True when compilation failed; / are then + /// meaningless and the pipeline must fall back. + public bool Failed; + + /// Human-readable reason set when is true. + public string FailureMessage; + } +} diff --git a/KSPCommunityFixes/Library/Model/MeshBinding.cs b/KSPCommunityFixes/Library/Model/MeshBinding.cs new file mode 100644 index 0000000..b74461d --- /dev/null +++ b/KSPCommunityFixes/Library/Model/MeshBinding.cs @@ -0,0 +1,30 @@ +namespace KSPCommunityFixes.Library.Model +{ + /// + /// Associates one locals[] slot with the canonical name of the mesh that belongs in it. + /// Before running a 's instructions, the main-thread driver — for every + /// binding — issues bundle.LoadAssetAsync<UnityEngine.Mesh>(CanonicalName) against the + /// loaded mesh AssetBundle and stores the resulting Mesh into locals[Slot], so + /// that the AddMeshFilter/AddMeshCollider/AddSkinnedMeshRenderer instructions + /// can read their mesh straight out of that slot. + /// + /// + /// is MeshBundleBuilder.Canonicalize($"{file.url}#{meshIndex}"), + /// i.e. the exact verbatim key stored in the bundle's container (lowercased, forward slashes) — see + /// . + /// + internal readonly struct MeshBinding + { + /// Index into locals[] where the loaded mesh is stored. + public readonly int Slot; + + /// The bundle container lookup key (already canonicalized). + public readonly string CanonicalName; + + public MeshBinding(int slot, string canonicalName) + { + Slot = slot; + CanonicalName = canonicalName; + } + } +} diff --git a/KSPCommunityFixes/Library/Model/ModelInstructions.cs b/KSPCommunityFixes/Library/Model/ModelInstructions.cs new file mode 100644 index 0000000..5688285 --- /dev/null +++ b/KSPCommunityFixes/Library/Model/ModelInstructions.cs @@ -0,0 +1,738 @@ +using System; +using KSPCommunityFixes.Library; +using PartToolsLib; +using UnityEngine; +using UnityEngine.Rendering; + +namespace KSPCommunityFixes.Library.Model +{ + /// + /// One atomic, main-thread GameObject-assembly step of a . The background + /// compiler bakes all the data (positions, enum codes, property values, resolved texture urls, ...) + /// into the instruction's fields; only does the UnityEngine calls, and + /// reproduces the matching reader's object-building + /// tail exactly. Slots are indices into the driver-owned locals array; -1 means "none". + /// No file reading happens here. + /// + internal interface IModelInstruction + { + void Execute(UnityEngine.Object[] locals); + } + + // ---- Hierarchy ------------------------------------------------------------------------------ + + /// Reproduces MuParser.ReadChild's object-building head: create the GameObject, + /// parent it, then set localPosition, localRotation, localScale IN THAT ORDER. + internal sealed class CreateGameObject : IModelInstruction + { + public int Dst; + public int Parent; + public string Name; + public Vector3 Pos; + public Quaternion Rot; + public Vector3 Scale; + + public void Execute(UnityEngine.Object[] locals) + { + GameObject go = new GameObject(Name); + // Parity: MuParser assigns parent, then localPosition, localRotation, localScale in this order. + go.transform.parent = Parent < 0 ? null : ((GameObject)locals[Parent]).transform; + go.transform.localPosition = Pos; + go.transform.localRotation = Rot; + go.transform.localScale = Scale; + locals[Dst] = go; + } + } + + /// Reproduces MuParser.ReadTagAndLayer. + internal sealed class SetTagAndLayer : IModelInstruction + { + public int Go; + public string Tag; + public int Layer; + + public void Execute(UnityEngine.Object[] locals) + { + GameObject go = (GameObject)locals[Go]; + // Parity: MuParser sets the tag unguarded. Assigning an unregistered tag throws + // UnityException; we reproduce that behaviour rather than swallowing it. + go.tag = Tag; + go.layer = Layer; + } + } + + // ---- Mesh / renderers ----------------------------------------------------------------------- + + /// Reproduces MuParser.ReadMeshFilter. + internal sealed class AddMeshFilter : IModelInstruction + { + public int Go; + public int MeshSlot; + + public void Execute(UnityEngine.Object[] locals) => + ((GameObject)locals[Go]).AddComponent().sharedMesh = (Mesh)locals[MeshSlot]; + } + + /// Reproduces MuParser.ReadMeshRenderer. The shadow flags are only present for + /// version >= 1 (baked as ). + internal sealed class AddMeshRenderer : IModelInstruction + { + public int Go; + public int Dst; + public bool HasShadowFlags; + public bool CastShadows; + public bool ReceiveShadows; + + public void Execute(UnityEngine.Object[] locals) + { + MeshRenderer mr = ((GameObject)locals[Go]).AddComponent(); + if (HasShadowFlags) + { + // Parity: MuParser maps the single "cast shadows" bool to ShadowCastingMode.On/Off only + // (never TwoSided/ShadowsOnly). + mr.shadowCastingMode = CastShadows ? ShadowCastingMode.On : ShadowCastingMode.Off; + mr.receiveShadows = ReceiveShadows; + } + locals[Dst] = mr; + } + } + + /// Reproduces MuParser.ReadSkinnedMeshRenderer's object-building (bone resolution is + /// a separate step). Deferred/not emitted in v1, but faithful. + internal sealed class AddSkinnedMeshRenderer : IModelInstruction + { + public int Go; + public int Dst; + public Bounds LocalBounds; + public int Quality; + public bool UpdateWhenOffscreen; + public int MeshSlot; + + public void Execute(UnityEngine.Object[] locals) + { + SkinnedMeshRenderer smr = ((GameObject)locals[Go]).AddComponent(); + smr.localBounds = LocalBounds; + smr.quality = (SkinQuality)Quality; + smr.updateWhenOffscreen = UpdateWhenOffscreen; + smr.sharedMesh = (Mesh)locals[MeshSlot]; + locals[Dst] = smr; + } + } + + // ---- Materials / textures ------------------------------------------------------------------- + + /// Reproduces the object-building of MuParser.ReadMaterial (v<4) / + /// ReadMaterial4 (v>=4) plus the deferred texture assignment from ReadTextures, + /// folded into a single baked step. Value/texture property names are baked as strings by the + /// compiler (the v<4 path's int property IDs are just Shader.PropertyToID(name), so the + /// string setters are equivalent). + internal sealed class CreateMaterial : IModelInstruction + { + public int Dst; + public ShaderRef Shader; + public ValueProp[] ValueProps; + public TextureProp[] TextureProps; + public string Name; + + public void Execute(UnityEngine.Object[] locals) + { + // Parity/RISK: Shader may resolve to null (unknown v>=4 shader name). MuParser does NOT + // substitute a default there — new Material((Shader)null) is the intended behaviour — so we + // must not coalesce to a fallback shader here. (The v<4 by-type route always resolves to a + // concrete KSP shader.) See ShaderRef.Resolve. + Material mat = new Material(Shader.Resolve()); + mat.name = Name; + + ValueProp[] values = ValueProps; + if (values != null) + { + for (int i = 0; i < values.Length; i++) + { + ValueProp v = values[i]; + switch (v.Kind) + { + case ValueProp.KindColor: + mat.SetColor(v.Name, v.ColorVal); + break; + case ValueProp.KindVector: + mat.SetVector(v.Name, v.VecVal); + break; + default: // KindFloat (covers MuParser's material property type codes 2 and 3) + mat.SetFloat(v.Name, v.FloatVal); + break; + } + } + } + + TextureProp[] textures = TextureProps; + if (textures != null) + { + for (int i = 0; i < textures.Length; i++) + { + TextureProp t = textures[i]; + // Parity: MuParser always sets scale/offset (in ReadMaterialTexture); the texture + // itself is resolved later in ReadTextures via GameDatabase.GetTexture(url, isNormal). + mat.SetTextureScale(t.Name, t.Scale); + mat.SetTextureOffset(t.Name, t.Offset); + if (t.Url != null) + { + // Parity: reproduce MuParser.ReadTextures' skip-and-log on a missing texture — if the + // resolved texture IsNullOrDestroyed, log the error and do NOT call SetTexture. + Texture2D tex = GameDatabase.Instance.GetTexture(t.Url, t.IsNormalMap); + if (tex.IsNullOrDestroyed()) + Debug.LogError($"Texture '{t.Url}' not found!"); + else + mat.SetTexture(t.Name, tex); + } + } + } + + locals[Dst] = mat; + } + } + + /// Reproduces the sharedMaterial/emitter-material fan-out of MuParser.ReadMaterials + /// (and the emitter registration in ReadParticles) for a single material index. + internal sealed class AssignMaterial : IModelInstruction + { + public int MaterialSlot; + public int[] RendererSlots; + public int[] EmitterSlots; + + public void Execute(UnityEngine.Object[] locals) + { + Material mat = (Material)locals[MaterialSlot]; + // Parity: MuParser uses the SINGULAR Renderer.sharedMaterial (not sharedMaterials[]), setting + // it once per material index. For a multi-material renderer the LAST material index that + // lists it wins; the compiler emits these instructions in material-index order to preserve + // that "last wins" outcome. + int[] renderers = RendererSlots; + if (renderers != null) + for (int i = 0; i < renderers.Length; i++) + ((Renderer)locals[renderers[i]]).sharedMaterial = mat; + + int[] emitters = EmitterSlots; + if (emitters != null) + for (int i = 0; i < emitters.Length; i++) + ((KSPParticleEmitter)locals[emitters[i]]).material = mat; + } + } + + // ---- Colliders ------------------------------------------------------------------------------ + + /// Reproduces MuParser.ReadMeshCollider (case 3) / ReadMeshCollider2 (case 25). + /// The isTrigger flag exists only in the "2" variant (baked as ). + internal sealed class AddMeshCollider : IModelInstruction + { + public int Go; + public bool HasTrigger; + public bool IsTrigger; + public int MeshSlot; + + public void Execute(UnityEngine.Object[] locals) + { + MeshCollider mc = ((GameObject)locals[Go]).AddComponent(); + mc.convex = true; // Parity: MuParser always forces convex (the file's "convex" bool is ignored). + if (HasTrigger) + mc.isTrigger = IsTrigger; + mc.sharedMesh = (Mesh)locals[MeshSlot]; + } + } + + /// Reproduces MuParser.ReadSphereCollider (case 4) / ReadSphereCollider2 (case 26). + internal sealed class AddSphereCollider : IModelInstruction + { + public int Go; + public bool HasTrigger; + public bool IsTrigger; + public float Radius; + public Vector3 Center; + + public void Execute(UnityEngine.Object[] locals) + { + SphereCollider sc = ((GameObject)locals[Go]).AddComponent(); + if (HasTrigger) + sc.isTrigger = IsTrigger; + sc.radius = Radius; + sc.center = Center; + } + } + + /// Reproduces MuParser.ReadCapsuleCollider (case 5) / ReadCapsuleCollider2 + /// (case 27). Height exists only in the "2" variant (baked as ). + internal sealed class AddCapsuleCollider : IModelInstruction + { + public int Go; + public bool HasTrigger; + public bool IsTrigger; + public float Radius; + public bool HasHeight; + public float Height; + public int Direction; + public Vector3 Center; + + public void Execute(UnityEngine.Object[] locals) + { + CapsuleCollider cc = ((GameObject)locals[Go]).AddComponent(); + if (HasTrigger) + cc.isTrigger = IsTrigger; + cc.radius = Radius; + if (HasHeight) + cc.height = Height; + // Parity: Direction is the raw axis index Unity uses (0 = X, 1 = Y, 2 = Z), copied verbatim. + cc.direction = Direction; + cc.center = Center; + } + } + + /// Reproduces MuParser.ReadBoxCollider (case 6) / ReadBoxCollider2 (case 28). + internal sealed class AddBoxCollider : IModelInstruction + { + public int Go; + public bool HasTrigger; + public bool IsTrigger; + public Vector3 Size; + public Vector3 Center; + + public void Execute(UnityEngine.Object[] locals) + { + BoxCollider bc = ((GameObject)locals[Go]).AddComponent(); + if (HasTrigger) + bc.isTrigger = IsTrigger; + bc.size = Size; + bc.center = Center; + } + } + + /// Reproduces MuParser.ReadWheelCollider (case 29), including the JointSpring and the + /// two WheelFrictionCurves, and the final enabled = false. + internal sealed class AddWheelCollider : IModelInstruction + { + public int Go; + public float Mass; + public float Radius; + public float SuspensionDistance; + public Vector3 Center; + public float SpringSpring; + public float SpringDamper; + public float SpringTarget; + // Parity: friction curve fields in MuParser's read order: + // [0]=extremumSlip [1]=extremumValue [2]=asymptoteSlip [3]=asymptoteValue [4]=stiffness. + public float[] Forward; + public float[] Sideways; + + public void Execute(UnityEngine.Object[] locals) + { + WheelCollider wc = ((GameObject)locals[Go]).AddComponent(); + wc.mass = Mass; + wc.radius = Radius; + wc.suspensionDistance = SuspensionDistance; + wc.center = Center; + wc.suspensionSpring = new JointSpring + { + spring = SpringSpring, + damper = SpringDamper, + targetPosition = SpringTarget + }; + wc.forwardFriction = new WheelFrictionCurve + { + extremumSlip = Forward[0], + extremumValue = Forward[1], + asymptoteSlip = Forward[2], + asymptoteValue = Forward[3], + stiffness = Forward[4] + }; + wc.sidewaysFriction = new WheelFrictionCurve + { + extremumSlip = Sideways[0], + extremumValue = Sideways[1], + asymptoteSlip = Sideways[2], + asymptoteValue = Sideways[3], + stiffness = Sideways[4] + }; + wc.enabled = false; // Parity: MuParser leaves wheel colliders disabled. + } + } + + // ---- Light / camera ------------------------------------------------------------------------- + + /// Reproduces MuParser.ReadLight (case 23). The spot angle exists only for + /// version > 1 (baked as ). + internal sealed class AddLight : IModelInstruction + { + public int Go; + public int Type; + public float Intensity; + public float Range; + public Color Color; + public int CullingMask; + public bool HasSpotAngle; + public float SpotAngle; + + public void Execute(UnityEngine.Object[] locals) + { + Light light = ((GameObject)locals[Go]).AddComponent(); + light.type = (LightType)Type; + light.intensity = Intensity; + light.range = Range; + light.color = Color; + light.cullingMask = CullingMask; + if (HasSpotAngle) + light.spotAngle = SpotAngle; + } + } + + /// Reproduces MuParser.ReadCamera (case 30), including the final + /// allowHDR = false; enabled = false. + internal sealed class AddCamera : IModelInstruction + { + public int Go; + public int ClearFlags; + public Color BackgroundColor; + public int CullingMask; + public bool Orthographic; + public float FieldOfView; + public float NearClip; + public float FarClip; + public float Depth; + + public void Execute(UnityEngine.Object[] locals) + { + Camera camera = ((GameObject)locals[Go]).AddComponent(); + camera.clearFlags = (CameraClearFlags)ClearFlags; + camera.backgroundColor = BackgroundColor; + camera.cullingMask = CullingMask; + camera.orthographic = Orthographic; + camera.fieldOfView = FieldOfView; + camera.nearClipPlane = NearClip; + camera.farClipPlane = FarClip; + camera.depth = Depth; + camera.allowHDR = false; // Parity: MuParser force-disables HDR and the camera itself. + camera.enabled = false; + } + } + + // ---- Particles ------------------------------------------------------------------------------ + + /// Reproduces MuParser.ReadParticles (case 31): copies every KSPParticleEmitter field + /// and maps the raw render-mode code. Material assignment is a separate + /// step (the emitter is exposed via ). + internal sealed class AddParticleEmitter : IModelInstruction + { + public int Go; + public int Dst; + public ParticleEmitterData Data; + + public void Execute(UnityEngine.Object[] locals) + { + KSPParticleEmitter e = ((GameObject)locals[Go]).AddComponent(); + ParticleEmitterData d = Data; + e.emit = d.Emit; + e.shape = (KSPParticleEmitter.EmissionShape)d.Shape; + // MuParser sets shape3D/shape2D component-wise; assigning the whole vector is equivalent. + e.shape3D = d.Shape3D; + e.shape2D = d.Shape2D; + e.shape1D = d.Shape1D; + e.color = d.Color; + e.useWorldSpace = d.UseWorldSpace; + e.minSize = d.MinSize; + e.maxSize = d.MaxSize; + e.minEnergy = d.MinEnergy; + e.maxEnergy = d.MaxEnergy; + e.minEmission = d.MinEmission; + e.maxEmission = d.MaxEmission; + e.worldVelocity = d.WorldVelocity; + e.localVelocity = d.LocalVelocity; + e.rndVelocity = d.RndVelocity; + e.emitterVelocityScale = d.EmitterVelocityScale; + e.angularVelocity = d.AngularVelocity; + e.rndAngularVelocity = d.RndAngularVelocity; + e.rndRotation = d.RndRotation; + e.doesAnimateColor = d.DoesAnimateColor; + // Parity: MuParser assigns a fresh Color[5]; copy so the emitter never aliases baked data. + Color[] colorAnimation = new Color[5]; + Color[] src = d.ColorAnimation; + for (int i = 0; i < 5; i++) + colorAnimation[i] = src[i]; + e.colorAnimation = colorAnimation; + e.worldRotationAxis = d.WorldRotationAxis; + e.localRotationAxis = d.LocalRotationAxis; + e.sizeGrow = d.SizeGrow; + e.rndForce = d.RndForce; + e.force = d.Force; + e.damping = d.Damping; + e.castShadows = d.CastShadows; + e.recieveShadows = d.RecieveShadows; // [sic] KSPParticleEmitter's own misspelled field name. + e.lengthScale = d.LengthScale; + e.velocityScale = d.VelocityScale; + e.maxParticleSize = d.MaxParticleSize; + // Parity: MuParser's render-mode switch — default => Billboard; 3/4/5 => the explicit modes. + switch (d.RenderModeCode) + { + default: + e.particleRenderMode = ParticleSystemRenderMode.Billboard; + break; + case 3: + e.particleRenderMode = ParticleSystemRenderMode.Stretch; + break; + case 4: + e.particleRenderMode = ParticleSystemRenderMode.HorizontalBillboard; + break; + case 5: + e.particleRenderMode = ParticleSystemRenderMode.VerticalBillboard; + break; + } + e.uvAnimationXTile = d.UvAnimationXTile; + e.uvAnimationYTile = d.UvAnimationYTile; + e.uvAnimationCycles = d.UvAnimationCycles; + locals[Dst] = e; + } + } + + // ---- Animation ------------------------------------------------------------------------------ + + /// Reproduces MuParser.ReadAnimation (case 2): rebuilds legacy Animation / + /// AnimationClip / AnimationCurve / Keyframe objects and replays the exact + /// isInvalid / null-skip logic and curve-type mapping. + internal sealed class AddAnimation : IModelInstruction + { + public int Go; + public AnimationClipData[] Clips; + public string DefaultClip; + public bool PlayAutomatically; + + public void Execute(UnityEngine.Object[] locals) + { + Animation animation = ((GameObject)locals[Go]).AddComponent(); + + // Parity: isInvalid is declared ONCE outside the clip loop, so a single invalid curve poisons + // every later clip (AddClip is skipped) and the default clip. This is faithful to MuParser, + // not a bug to "fix". + bool isInvalid = false; + + AnimationClipData[] clips = Clips ?? Array.Empty(); + for (int i = 0; i < clips.Length; i++) + { + AnimationClipData clip = clips[i]; + AnimationClip animationClip = new AnimationClip(); + animationClip.legacy = true; + animationClip.localBounds = new Bounds(clip.BoundsCenter, clip.BoundsSize); + animationClip.wrapMode = (WrapMode)clip.WrapMode; + + AnimationCurveData[] curves = clip.Curves ?? Array.Empty(); + for (int j = 0; j < curves.Length; j++) + { + AnimationCurveData curve = curves[j]; + // Parity: curve type code 0-3 => Transform/Material/Light/AudioSource; anything else + // leaves curveType null, which trips the isInvalid guard below. + Type curveType = null; + switch (curve.TypeCode) + { + case 0: + curveType = typeof(Transform); + break; + case 1: + curveType = typeof(Material); + break; + case 2: + curveType = typeof(Light); + break; + case 3: + curveType = typeof(AudioSource); + break; + } + + KeyframeData[] keys = curve.Keys ?? Array.Empty(); + Keyframe[] keyFrames = new Keyframe[keys.Length]; + for (int k = 0; k < keys.Length; k++) + keyFrames[k] = new Keyframe(keys[k].Time, keys[k].Value, keys[k].InTangent, keys[k].OutTangent); + + AnimationCurve animationCurve = new AnimationCurve(keyFrames); + animationCurve.preWrapMode = (WrapMode)curve.PreWrap; + animationCurve.postWrapMode = (WrapMode)curve.PostWrap; + + if (clip.Name == null || curve.Path == null || curveType == null || curve.Property == null) + { + isInvalid = true; + Debug.LogWarning($"{clip.Name ?? "Null clipName"} : {curve.Path ?? "Null curvePath"}, {(curveType == null ? "Null curveType" : curveType.ToString())}, {curve.Property ?? "Null curveProperty"}"); + continue; + } + + animationClip.SetCurve(curve.Path, curveType, curve.Property, animationCurve); + } + + if (!isInvalid) + animation.AddClip(animationClip, clip.Name); + } + + // Parity contract: the compiler must bake DefaultClip as string.Empty (never null) when absent, + // because MuParser derives it from ReadString(), which returns string.Empty for a zero-length + // string — a baked null would wrongly pass this guard. + if (DefaultClip != string.Empty && !isInvalid) + animation.clip = animation.GetClip(DefaultClip); + + animation.playAutomatically = PlayAutomatically; + } + } + + // ---- Bones ---------------------------------------------------------------------------------- + + /// Reproduces MuParser.AffectSkinnedMeshRenderersBones for one skinned mesh renderer: + /// resolves each bone by name from the model root and assigns the bone array. Deferred/not emitted in + /// v1, but faithful. + internal sealed class ResolveBones : IModelInstruction + { + public int SmrSlot; + public int RootSlot; + public string[] BoneNames; + + public void Execute(UnityEngine.Object[] locals) + { + Transform root = ((GameObject)locals[RootSlot]).transform; + Transform[] bones = new Transform[BoneNames.Length]; + for (int i = 0; i < BoneNames.Length; i++) + bones[i] = FindChildByName(root, BoneNames[i]); + ((SkinnedMeshRenderer)locals[SmrSlot]).bones = bones; + } + + // Ported verbatim from MuParser.FindChildByName: depth-first search returning the first transform + // whose name matches (including the root itself). + private static Transform FindChildByName(Transform parent, string name) + { + if (parent.name == name) + return parent; + + foreach (Transform item in parent) + { + Transform transform = FindChildByName(item, name); + if (transform != null) + return transform; + } + return null; + } + } + + // ---- Payload structs ------------------------------------------------------------------------ + + /// How a KSP shader is resolved at replay. The v<4 path resolves a + /// by type; the v>=4 path resolves a raw shader name. + /// + /// is byte-for-byte equivalent to MuParser's + /// Shader.Find(name) — it caches the same instances and, critically, returns null for + /// an unknown name (it never substitutes a default). So resolving the name route through + /// preserves MuParser's v>=4 null-shader fallback + /// (new Material((Shader)null)). The type route uses + /// , whose default maps to KSP/Diffuse, which + /// is exactly what MuParser's v<4 path does. + /// + internal struct ShaderRef + { + public bool ByName; + public ShaderType Type; + public string Name; + + public Shader Resolve() => ByName ? ShaderHelpers.GetShader(Name) : ShaderHelpers.GetShader(Type); + } + + /// One scalar/color/vector material property. : 0 Color, 1 Vector, 2 Float + /// (MuParser material property type codes 2 and 3 both map to Float). + internal struct ValueProp + { + public const int KindColor = 0; + public const int KindVector = 1; + public const int KindFloat = 2; + + public string Name; + public int Kind; + public Color ColorVal; + public Vector4 VecVal; + public float FloatVal; + } + + /// One material texture property. Scale/offset are always applied; non-null + /// additionally binds a texture via GameDatabase.GetTexture(Url, IsNormalMap). + internal struct TextureProp + { + public string Name; + public string Url; + public bool IsNormalMap; + public Vector2 Scale; + public Vector2 Offset; + } + + /// Every field MuParser.ReadParticles reads for a KSPParticleEmitter. + /// is the raw int mapped by ; + /// is a 5-element array. + internal struct ParticleEmitterData + { + public bool Emit; + public int Shape; + public Vector3 Shape3D; + public Vector2 Shape2D; + public float Shape1D; + public Color Color; + public bool UseWorldSpace; + public float MinSize; + public float MaxSize; + public float MinEnergy; + public float MaxEnergy; + public int MinEmission; + public int MaxEmission; + public Vector3 WorldVelocity; + public Vector3 LocalVelocity; + public Vector3 RndVelocity; + public float EmitterVelocityScale; + public float AngularVelocity; + public float RndAngularVelocity; + public bool RndRotation; + public bool DoesAnimateColor; + public Color[] ColorAnimation; + public Vector3 WorldRotationAxis; + public Vector3 LocalRotationAxis; + public float SizeGrow; + public Vector3 RndForce; + public Vector3 Force; + public float Damping; + public bool CastShadows; + public bool RecieveShadows; + public float LengthScale; + public float VelocityScale; + public float MaxParticleSize; + public int RenderModeCode; + public int UvAnimationXTile; + public int UvAnimationYTile; + public int UvAnimationCycles; + } + + /// One legacy AnimationClip: localBounds = new Bounds(BoundsCenter, BoundsSize), + /// wrapMode = (WrapMode)WrapMode, plus its curves. + internal struct AnimationClipData + { + public string Name; + public Vector3 BoundsCenter; + public Vector3 BoundsSize; + public int WrapMode; + public AnimationCurveData[] Curves; + } + + /// One animation curve. : 0 Transform, 1 Material, 2 Light, + /// 3 AudioSource (anything else marks the animation invalid, matching MuParser). + internal struct AnimationCurveData + { + public string Path; + public string Property; + public int TypeCode; + public int PreWrap; + public int PostWrap; + public KeyframeData[] Keys; + } + + /// One keyframe (four floats, matching MuParser's ReadKeyFrame; weights left default). + internal struct KeyframeData + { + public float Time; + public float Value; + public float InTangent; + public float OutTangent; + } +} From fceb8b8bac82a01728253308e4724e3671a46e44 Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 14 Jul 2026 12:41:00 -0700 Subject: [PATCH 04/14] Add MuModelCompiler: thread-safe .mu -> instruction/blob compiler Port MuParser's opcode-tree walk into a background-safe compiler that emits a flat IModelInstruction[] + MeshBlob[] + MeshBinding[] (a CompiledModel) instead of building UnityEngine objects on the main thread. All state is per-instance (reset per Compile, reusable per worker thread) with no statics; the buffer is pinned for the whole walk and read via the single mutable MuBinaryReader field. Faithful to MuParser for every real stock part (verified opcode-by-opcode against the oracle and empirically over the corpus): - Same EntryType dispatch + exact per-opcode read order/count; mesh sub-blocks (verts/uv/uv2/normals/tangents/triangles/colors/boneweights/bindposes) filled in order; slot policy (root=0; slots for GOs/meshes/materials/renderers/ material-bound emitters; LocalCount = high-water). - Two-pass material/texture resolution (Materials precedes Textures): texture slot refs resolved to urls at the Textures block (url = dir + "/" + name, isNormalMap from the texture entry, texCount guard preserved, fan-out to all referencing props); CreateMaterial+AssignMaterial emitted in ascending material-index order to preserve last-wins singular sharedMaterial. - v<4 ShaderType property-name table + v>=4 by-name shader, version gating (shadow flags v>=1, spot angle v>1), ResolveBones emitted last. - Skinned meshes (SkinnedMeshRenderer / bone weights / bind poses) fully consume their bytes to keep the cursor valid and flag ContainsSkinnedMesh; the v1 pipeline falls back to MuParser.Parse for those files. - Never throws: any parse error returns Failed=true with SourceUrl set. Offline-validated over 3506 real .mu (0 throws, 0 Failed, 0 sanity violations, 195 skinned flagged) with 40/40 sampled real-mesh bundles byte-exact incl. a 100k-vert 32-bit-index mesh, 7-submesh, and 544-mesh models. Completes the compiler half of the feature; FastLoader pipeline wiring is the next task. MuParser stays as the skinned fallback (deleted in a later task). --- .../Library/Model/MuModelCompiler.cs | 1201 +++++++++++++++++ 1 file changed, 1201 insertions(+) create mode 100644 KSPCommunityFixes/Library/Model/MuModelCompiler.cs diff --git a/KSPCommunityFixes/Library/Model/MuModelCompiler.cs b/KSPCommunityFixes/Library/Model/MuModelCompiler.cs new file mode 100644 index 0000000..4a3ccbe --- /dev/null +++ b/KSPCommunityFixes/Library/Model/MuModelCompiler.cs @@ -0,0 +1,1201 @@ +using System; +using System.Collections.Generic; +using System.IO; +using PartToolsLib; +using UnityEngine; + +namespace KSPCommunityFixes.Library.Model +{ + /// + /// Thread-safe, instance-state port of that walks a + /// .mu file's opcode tree and, instead of building UnityEngine.Objects on the main + /// thread, EMITS a flat list plus serialization-ready + /// s into a . Everything here is off-main-thread work + /// (no UnityEngine.Object is ever created), so many worker threads can compile different files + /// in parallel — hence every accumulator is an instance field, never a static. + /// + /// The opcode dispatch, read order and version-gating are a faithful reproduction of MuParser's + /// ReadChild switch and its readers; the non-obvious parity points (two-pass material/texture + /// deferral, slot allocation policy, the skinned-mesh seam, the texCount guard and the + /// last-wins material ordering) are commented inline where they occur. + /// + /// + /// A single instance may be reused across many files on one worker thread: + /// resets all instance state at its top, so no explicit disposal or re-instantiation is required. + /// + /// + internal sealed unsafe class MuModelCompiler + { + // ---- Per-Compile instance state (reset at the top of Compile) ------------------------------ + + // The single mutable reader. Per MuBinaryReader's contract it is held in exactly ONE + // non-readonly field and mutated in place; every read helper below is an instance method that + // advances this.reader, so the cursor never desyncs through a defensive struct copy. + private MuBinaryReader reader; + + private string fileUrl; + private string directoryUrl; + private int version; + + // Running slot counter. Slots are indices into the driver's locals[] array; the high-water mark + // (== this value at the end) becomes CompiledModel.LocalCount. + private int nextSlot; + + // Global mesh index over the whole file, used to build a globally-unique canonical mesh name. + private int meshIndex; + + private readonly List instructions = new List(); + private readonly List blobs = new List(); + private readonly List bindings = new List(); + + // matRefs[i] holds the renderer/emitter slots that reference material index i, mirroring + // MuParser's matDummies[i].renderers / .particleEmitters. Populated while walking (when an + // AddMeshRenderer/AddSkinnedMeshRenderer/AddParticleEmitter is emitted); consumed at finalize. + private readonly List matRefs = new List(); + + // Pending materials in material-index order (0..materialCount-1), parsed by ReadMaterials but + // NOT emitted until finalize (their texture urls aren't known until the Textures block). + private readonly List pendingMaterials = new List(); + + // Texture-slot table: textureSlots[slot] = every PendingTexture that references that texture + // slot. Mirrors MuParser's PartReader.TextureDummyList — grown so that Count == highest + // referenced slot + 1, which is what the texCount guard compares against. + private readonly List> textureSlots = new List>(); + + // Skinned renderers awaiting a ResolveBones step (mirrors MuParser's boneDummies). + private readonly List skinnedRenderers = new List(); + + private bool containsSkinnedMesh; + private bool skinnedLogged; + + /// + /// Compile one .mu file into a main-thread-ready . Never throws: + /// on any parse error a with set is + /// returned so a PLINQ Select over many files can't fault. + /// + /// The model's UrlFile.url. Used for globally-unique mesh names and + /// (the equivalent of MuParser's root object name). + /// The model's file.parent.url. Used to build texture urls, + /// exactly MuParser's modelDirectoryUrl parameter. + /// Raw model bytes. Must be pinnable (a plain managed array). + /// Valid byte count in ; if <= 0 the whole + /// array length is used (matching MuParser.Parse). + public CompiledModel Compile(string fileUrl, string directoryUrl, byte[] data, int dataLength) + { + ResetState(); + this.fileUrl = fileUrl; + this.directoryUrl = directoryUrl; + + try + { + int length = dataLength <= 0 ? data.Length : dataLength; + + // Pin for the whole walk: MuBinaryReader holds a raw byte*, so the buffer must stay + // pinned for the entire parse. A single fixed block around the walk matches MuParser's + // GCHandle-pinned scope; nothing reads the stream after this block closes. + fixed (byte* p = data) + { + reader = new MuBinaryReader(p, length); + + if (reader.ReadInt() != 76543) + throw new Exception("Invalid mu file"); + + version = reader.ReadInt(); + reader.SkipString(); + + // Root is the first thing allocated, so it lands in slot 0 (parent = -1 == none). + ReadChild(-1); + } + + // Two-pass finalize (see FinalizeMaterials): materials + textures are known now, so emit + // CreateMaterial/AssignMaterial in ascending material-index order, then the deferred bone + // resolution (MuParser runs AffectSkinnedMeshRenderersBones last). + FinalizeMaterials(); + FinalizeBones(); + + return new CompiledModel + { + SourceUrl = fileUrl, + Instructions = instructions.ToArray(), + Blobs = blobs.ToArray(), + Bindings = bindings.ToArray(), + LocalCount = nextSlot, + ContainsSkinnedMesh = containsSkinnedMesh, + Failed = false, + }; + } + catch (Exception e) + { + // SourceUrl is set first so the pipeline can log which file failed. Compile never throws. + return new CompiledModel + { + SourceUrl = fileUrl, + Failed = true, + FailureMessage = e.GetType().Name + ": " + e.Message, + }; + } + } + + private void ResetState() + { + // Fresh reader is assigned inside the fixed block; just clear the accumulators here so a + // worker thread can reuse one instance across many files. + reader = default; + fileUrl = null; + directoryUrl = null; + version = 0; + nextSlot = 0; + meshIndex = 0; + instructions.Clear(); + blobs.Clear(); + bindings.Clear(); + matRefs.Clear(); + pendingMaterials.Clear(); + textureSlots.Clear(); + skinnedRenderers.Clear(); + containsSkinnedMesh = false; + skinnedLogged = false; + } + + // ---- Core tree walk ------------------------------------------------------------------------ + + /// + /// Mirror of MuParser.ReadChild: reads the transform header, allocates a GameObject slot, + /// emits , then dispatches the child opcode stream until + /// ChildTransformEnd (or end of data). Returns the allocated slot. + /// + private int ReadChild(int parentSlot) + { + string name = reader.ReadString(); + Vector3 pos = reader.ReadVector3(); + Quaternion rot = reader.ReadQuaternion(); + Vector3 scale = reader.ReadVector3(); + + // Allocate this GameObject's slot and create it before processing any of its components, so + // colliders/renderers/etc. below configure the correct current slot and children can parent + // to it. Slot policy: a slot is allocated for the ROOT + every child GameObject, every mesh, + // every material, every MeshRenderer/SkinnedMeshRenderer and every material-bound emitter; + // NOT for MeshFilter/colliders/Light/Camera/Animation/tag+layer (those configure the current + // GameObject slot inline). + int mySlot = nextSlot++; + instructions.Add(new CreateGameObject + { + Dst = mySlot, + Parent = parentSlot, + Name = name, + Pos = pos, + Rot = rot, + Scale = scale, + }); + + // No default case: MuParser ignores unknown opcodes (they consume nothing and the loop reads + // the next int). The switched EntryType values are numerically identical to MuParser's raw + // int cases. + while (reader.Position < reader.Length) + { + switch ((EntryType)reader.ReadInt()) + { + case EntryType.ChildTransformStart: // 0 + ReadChild(mySlot); + break; + case EntryType.ChildTransformEnd: // 1 + return mySlot; + case EntryType.Animation: // 2 + ReadAnimation(mySlot); + break; + case EntryType.MeshCollider: // 3 + ReadMeshCollider(mySlot); + break; + case EntryType.SphereCollider: // 4 + ReadSphereCollider(mySlot); + break; + case EntryType.CapsuleCollider: // 5 + ReadCapsuleCollider(mySlot); + break; + case EntryType.BoxCollider: // 6 + ReadBoxCollider(mySlot); + break; + case EntryType.MeshFilter: // 7 + ReadMeshFilter(mySlot); + break; + case EntryType.MeshRenderer: // 8 + ReadMeshRenderer(mySlot); + break; + case EntryType.SkinnedMeshRenderer: // 9 + ReadSkinnedMeshRenderer(mySlot); + break; + case EntryType.Materials: // 10 + ReadMaterials(); + break; + case EntryType.Textures: // 12 + ReadTextures(); + break; + case EntryType.Light: // 23 + ReadLight(mySlot); + break; + case EntryType.TagAndLayer: // 24 + ReadTagAndLayer(mySlot); + break; + case EntryType.MeshCollider2: // 25 + ReadMeshCollider2(mySlot); + break; + case EntryType.SphereCollider2: // 26 + ReadSphereCollider2(mySlot); + break; + case EntryType.CapsuleCollider2: // 27 + ReadCapsuleCollider2(mySlot); + break; + case EntryType.BoxCollider2: // 28 + ReadBoxCollider2(mySlot); + break; + case EntryType.WheelCollider: // 29 + ReadWheelCollider(mySlot); + break; + case EntryType.Camera: // 30 + ReadCamera(mySlot); + break; + case EntryType.ParticleEmitter: // 31 + ReadParticles(mySlot); + break; + } + } + + return mySlot; + } + + // ---- Component readers --------------------------------------------------------------------- + + /// Mirror of MuParser.ReadAnimation: bakes clip/curve/keyframe data verbatim + /// (the isInvalid / null-skip logic and curve-type mapping run at replay in + /// ). + private void ReadAnimation(int goSlot) + { + int clipCount = reader.ReadInt(); + var clips = new AnimationClipData[clipCount]; + for (int i = 0; i < clipCount; i++) + { + string clipName = reader.ReadString(); + Vector3 boundsCenter = reader.ReadVector3(); + Vector3 boundsSize = reader.ReadVector3(); + int wrapMode = reader.ReadInt(); + + int curveCount = reader.ReadInt(); + var curves = new AnimationCurveData[curveCount]; + for (int j = 0; j < curveCount; j++) + { + string curvePath = reader.ReadString(); + string curveProperty = reader.ReadString(); + int typeCode = reader.ReadInt(); + int preWrap = reader.ReadInt(); + int postWrap = reader.ReadInt(); + + int keyFrameCount = reader.ReadInt(); + var keys = new KeyframeData[keyFrameCount]; + for (int k = 0; k < keyFrameCount; k++) + { + // reader.ReadKeyFrame consumes MuParser's 20-byte record (4 floats + 4 pad) and + // returns a plain Keyframe struct (safe off-thread); we bake its four floats. + Keyframe kf = reader.ReadKeyFrame(); + keys[k] = new KeyframeData + { + Time = kf.time, + Value = kf.value, + InTangent = kf.inTangent, + OutTangent = kf.outTangent, + }; + } + + curves[j] = new AnimationCurveData + { + Path = curvePath, + Property = curveProperty, + TypeCode = typeCode, + PreWrap = preWrap, + PostWrap = postWrap, + Keys = keys, + }; + } + + clips[i] = new AnimationClipData + { + Name = clipName, + BoundsCenter = boundsCenter, + BoundsSize = boundsSize, + WrapMode = wrapMode, + Curves = curves, + }; + } + + // ReadString returns string.Empty (never null) for an empty string; baked verbatim so the + // AddAnimation "DefaultClip != string.Empty" guard behaves exactly like MuParser. + string defaultClip = reader.ReadString(); + bool playAutomatically = reader.ReadBool(); + + instructions.Add(new AddAnimation + { + Go = goSlot, + Clips = clips, + DefaultClip = defaultClip, + PlayAutomatically = playAutomatically, + }); + } + + /// Mirror of MuParser.ReadMeshCollider (opcode 3): the file's "convex" bool is + /// read and discarded (always forced convex at replay), then the mesh. + private void ReadMeshCollider(int goSlot) + { + reader.SkipBool(); // "convex" bool, ignored (forced true in AddMeshCollider) + int meshSlot = ReadMesh(); + instructions.Add(new AddMeshCollider + { + Go = goSlot, + HasTrigger = false, + IsTrigger = false, + MeshSlot = meshSlot, + }); + } + + /// Mirror of MuParser.ReadMeshCollider2 (opcode 25): isTrigger, then the discarded + /// "convex" bool, then the mesh. + private void ReadMeshCollider2(int goSlot) + { + bool isTrigger = reader.ReadBool(); + reader.SkipBool(); // "convex" bool, ignored + int meshSlot = ReadMesh(); + instructions.Add(new AddMeshCollider + { + Go = goSlot, + HasTrigger = true, + IsTrigger = isTrigger, + MeshSlot = meshSlot, + }); + } + + /// Mirror of MuParser.ReadSphereCollider (opcode 4). + private void ReadSphereCollider(int goSlot) + { + float radius = reader.ReadFloat(); + Vector3 center = reader.ReadVector3(); + instructions.Add(new AddSphereCollider + { + Go = goSlot, + HasTrigger = false, + Radius = radius, + Center = center, + }); + } + + /// Mirror of MuParser.ReadSphereCollider2 (opcode 26). + private void ReadSphereCollider2(int goSlot) + { + bool isTrigger = reader.ReadBool(); + float radius = reader.ReadFloat(); + Vector3 center = reader.ReadVector3(); + instructions.Add(new AddSphereCollider + { + Go = goSlot, + HasTrigger = true, + IsTrigger = isTrigger, + Radius = radius, + Center = center, + }); + } + + /// Mirror of MuParser.ReadCapsuleCollider (opcode 5): radius, direction, center. + private void ReadCapsuleCollider(int goSlot) + { + float radius = reader.ReadFloat(); + int direction = reader.ReadInt(); + Vector3 center = reader.ReadVector3(); + instructions.Add(new AddCapsuleCollider + { + Go = goSlot, + HasTrigger = false, + Radius = radius, + HasHeight = false, + Direction = direction, + Center = center, + }); + } + + /// Mirror of MuParser.ReadCapsuleCollider2 (opcode 27): isTrigger, radius, height, + /// direction, center. + private void ReadCapsuleCollider2(int goSlot) + { + bool isTrigger = reader.ReadBool(); + float radius = reader.ReadFloat(); + float height = reader.ReadFloat(); + int direction = reader.ReadInt(); + Vector3 center = reader.ReadVector3(); + instructions.Add(new AddCapsuleCollider + { + Go = goSlot, + HasTrigger = true, + IsTrigger = isTrigger, + Radius = radius, + HasHeight = true, + Height = height, + Direction = direction, + Center = center, + }); + } + + /// Mirror of MuParser.ReadBoxCollider (opcode 6). + private void ReadBoxCollider(int goSlot) + { + Vector3 size = reader.ReadVector3(); + Vector3 center = reader.ReadVector3(); + instructions.Add(new AddBoxCollider + { + Go = goSlot, + HasTrigger = false, + Size = size, + Center = center, + }); + } + + /// Mirror of MuParser.ReadBoxCollider2 (opcode 28). + private void ReadBoxCollider2(int goSlot) + { + bool isTrigger = reader.ReadBool(); + Vector3 size = reader.ReadVector3(); + Vector3 center = reader.ReadVector3(); + instructions.Add(new AddBoxCollider + { + Go = goSlot, + HasTrigger = true, + IsTrigger = isTrigger, + Size = size, + Center = center, + }); + } + + /// Mirror of MuParser.ReadWheelCollider (opcode 29): mass/radius/suspension, the + /// JointSpring triplet, then the two 5-float friction curves (in MuParser's read order). + private void ReadWheelCollider(int goSlot) + { + float mass = reader.ReadFloat(); + float radius = reader.ReadFloat(); + float suspensionDistance = reader.ReadFloat(); + Vector3 center = reader.ReadVector3(); + float springSpring = reader.ReadFloat(); + float springDamper = reader.ReadFloat(); + float springTarget = reader.ReadFloat(); + + // Friction curve fields in MuParser's exact read order: + // extremumSlip, extremumValue, asymptoteSlip, asymptoteValue, stiffness. + float[] forward = new float[5]; + for (int i = 0; i < 5; i++) + forward[i] = reader.ReadFloat(); + float[] sideways = new float[5]; + for (int i = 0; i < 5; i++) + sideways[i] = reader.ReadFloat(); + + instructions.Add(new AddWheelCollider + { + Go = goSlot, + Mass = mass, + Radius = radius, + SuspensionDistance = suspensionDistance, + Center = center, + SpringSpring = springSpring, + SpringDamper = springDamper, + SpringTarget = springTarget, + Forward = forward, + Sideways = sideways, + }); + } + + /// Mirror of MuParser.ReadMeshFilter (opcode 7). + private void ReadMeshFilter(int goSlot) + { + int meshSlot = ReadMesh(); + instructions.Add(new AddMeshFilter { Go = goSlot, MeshSlot = meshSlot }); + } + + /// Mirror of MuParser.ReadMeshRenderer (opcode 8): shadow flags exist only for + /// version >= 1; a renderer registers itself under every material index it lists (last index + /// wins at replay, preserved by ascending-index AssignMaterial emission). + private void ReadMeshRenderer(int goSlot) + { + int rendererSlot = nextSlot++; + + bool hasShadowFlags = version >= 1; + bool castShadows = false; + bool receiveShadows = false; + if (hasShadowFlags) + { + castShadows = reader.ReadBool(); + receiveShadows = reader.ReadBool(); + } + + instructions.Add(new AddMeshRenderer + { + Go = goSlot, + Dst = rendererSlot, + HasShadowFlags = hasShadowFlags, + CastShadows = castShadows, + ReceiveShadows = receiveShadows, + }); + + int rendererCount = reader.ReadInt(); + for (int i = 0; i < rendererCount; i++) + { + int materialIndex = reader.ReadInt(); + EnsureMatRef(materialIndex); + matRefs[materialIndex].Renderers.Add(rendererSlot); + } + } + + /// Mirror of MuParser.ReadSkinnedMeshRenderer (opcode 9). Marks the model as + /// containing a skinned mesh (v1 pipeline falls back to MuParser) but still parses everything so + /// the cursor stays valid, and records a deferred step. + private void ReadSkinnedMeshRenderer(int goSlot) + { + MarkSkinned(); + + int smrSlot = nextSlot++; + + int rendererCount = reader.ReadInt(); + for (int i = 0; i < rendererCount; i++) + { + int materialIndex = reader.ReadInt(); + EnsureMatRef(materialIndex); + // SkinnedMeshRenderer is a Renderer, so it shares the renderer material fan-out. + matRefs[materialIndex].Renderers.Add(smrSlot); + } + + Vector3 boundsCenter = reader.ReadVector3(); + Vector3 boundsSize = reader.ReadVector3(); + int quality = reader.ReadInt(); + bool updateWhenOffscreen = reader.ReadBool(); + + int boneCount = reader.ReadInt(); + var boneNames = new string[boneCount]; + for (int j = 0; j < boneCount; j++) + boneNames[j] = reader.ReadString(); + + int meshSlot = ReadMesh(); + + instructions.Add(new AddSkinnedMeshRenderer + { + Go = goSlot, + Dst = smrSlot, + LocalBounds = new Bounds(boundsCenter, boundsSize), + Quality = quality, + UpdateWhenOffscreen = updateWhenOffscreen, + MeshSlot = meshSlot, + }); + + skinnedRenderers.Add(new SkinnedEntry { SmrSlot = smrSlot, BoneNames = boneNames }); + } + + /// Mirror of MuParser.ReadMaterials (opcode 10): reads each material into a pending + /// descriptor (deferred emission, since texture urls aren't known yet) and allocates its slot in + /// ascending index order. + private void ReadMaterials() + { + // Assumes exactly ONE Materials block per .mu (as all real/stock files have, matching MuParser and + // stock PartReader): a hypothetical second Materials block would append into pendingMaterials and + // shift the second block's material indices, diverging from the oracle's per-block re-indexing. This + // is a documented latent structural assumption, not a bug to fix. + int materialCount = reader.ReadInt(); + for (int i = 0; i < materialCount; i++) + { + PendingMaterial pending = version < 4 ? ReadMaterial() : ReadMaterial4(); + pending.Slot = nextSlot++; + pendingMaterials.Add(pending); // index i == material index i + } + } + + /// Mirror of MuParser.ReadMaterial (version < 4): shader is resolved by + /// at replay; the per-ShaderType read order and property NAMES exactly + /// match MuParser's int-property-id setters (each id is Shader.PropertyToID(name)). + private PendingMaterial ReadMaterial() + { + string name = reader.ReadString(); + ShaderType shaderType = (ShaderType)reader.ReadInt(); + + var pm = new PendingMaterial + { + Name = name, + Shader = new ShaderRef { ByName = false, Type = shaderType }, + }; + + switch (shaderType) + { + default: // Custom (0), Diffuse (1) and any unknown type: MuParser's default reads _MainTex + ReadMaterialTexture(pm, "_MainTex"); + break; + case ShaderType.Specular: + ReadMaterialTexture(pm, "_MainTex"); + AddColor(pm, "_SpecColor", reader.ReadColor()); + AddFloat(pm, "_Shininess", reader.ReadFloat()); + break; + case ShaderType.Bumped: + ReadMaterialTexture(pm, "_MainTex"); + ReadMaterialTexture(pm, "_BumpMap"); + break; + case ShaderType.BumpedSpecular: + ReadMaterialTexture(pm, "_MainTex"); + ReadMaterialTexture(pm, "_BumpMap"); + AddColor(pm, "_SpecColor", reader.ReadColor()); + AddFloat(pm, "_Shininess", reader.ReadFloat()); + break; + case ShaderType.Emissive: + ReadMaterialTexture(pm, "_MainTex"); + ReadMaterialTexture(pm, "_Emissive"); + AddColor(pm, "_EmissiveColor", reader.ReadColor()); + break; + case ShaderType.EmissiveSpecular: + ReadMaterialTexture(pm, "_MainTex"); + AddColor(pm, "_SpecColor", reader.ReadColor()); + AddFloat(pm, "_Shininess", reader.ReadFloat()); + ReadMaterialTexture(pm, "_Emissive"); + AddColor(pm, "_EmissiveColor", reader.ReadColor()); + break; + case ShaderType.EmissiveBumpedSpecular: + ReadMaterialTexture(pm, "_MainTex"); + ReadMaterialTexture(pm, "_BumpMap"); + AddColor(pm, "_SpecColor", reader.ReadColor()); + AddFloat(pm, "_Shininess", reader.ReadFloat()); + ReadMaterialTexture(pm, "_Emissive"); + AddColor(pm, "_EmissiveColor", reader.ReadColor()); + break; + case ShaderType.AlphaCutout: + ReadMaterialTexture(pm, "_MainTex"); + AddFloat(pm, "_Cutoff", reader.ReadFloat()); + break; + case ShaderType.AlphaCutoutBumped: + ReadMaterialTexture(pm, "_MainTex"); + ReadMaterialTexture(pm, "_BumpMap"); + AddFloat(pm, "_Cutoff", reader.ReadFloat()); + break; + case ShaderType.Alpha: + ReadMaterialTexture(pm, "_MainTex"); + break; + case ShaderType.AlphaSpecular: + ReadMaterialTexture(pm, "_MainTex"); + AddFloat(pm, "_Gloss", reader.ReadFloat()); + AddColor(pm, "_SpecColor", reader.ReadColor()); + AddFloat(pm, "_Shininess", reader.ReadFloat()); + break; + case ShaderType.AlphaUnlit: + ReadMaterialTexture(pm, "_MainTex"); + AddColor(pm, "_Color", reader.ReadColor()); + break; + case ShaderType.Unlit: + ReadMaterialTexture(pm, "_MainTex"); + AddColor(pm, "_Color", reader.ReadColor()); + break; + case ShaderType.ParticleAlpha: + ReadMaterialTexture(pm, "_MainTex"); + AddColor(pm, "_Color", reader.ReadColor()); + AddFloat(pm, "_InvFade", reader.ReadFloat()); + break; + case ShaderType.ParticleAdditive: + ReadMaterialTexture(pm, "_MainTex"); + AddColor(pm, "_Color", reader.ReadColor()); + AddFloat(pm, "_InvFade", reader.ReadFloat()); + break; + case ShaderType.BumpedSpecularMap: + ReadMaterialTexture(pm, "_MainTex"); + ReadMaterialTexture(pm, "_BumpMap"); + ReadMaterialTexture(pm, "_SpecMap"); + AddFloat(pm, "_SpecTint", reader.ReadFloat()); + AddFloat(pm, "_Shininess", reader.ReadFloat()); + break; + } + + return pm; + } + + /// Mirror of MuParser.ReadMaterial4 (version >= 4): shader is resolved by name at + /// replay; property type codes 0=Color, 1=Vector, 2 & 3=Float, 4=texture. An unknown type code + /// reads nothing after the property name (matching MuParser's switch with no default). + private PendingMaterial ReadMaterial4() + { + string matName = reader.ReadString(); + string shaderName = reader.ReadString(); + int propertyCount = reader.ReadInt(); + + var pm = new PendingMaterial + { + Name = matName, + Shader = new ShaderRef { ByName = true, Name = shaderName }, + }; + + for (int i = 0; i < propertyCount; i++) + { + string propName = reader.ReadString(); + switch (reader.ReadInt()) + { + case 0: + AddColor(pm, propName, reader.ReadColor()); + break; + case 1: + AddVector(pm, propName, reader.ReadVector4()); + break; + case 2: + AddFloat(pm, propName, reader.ReadFloat()); + break; + case 3: + AddFloat(pm, propName, reader.ReadFloat()); + break; + case 4: + ReadMaterialTexture(pm, propName); + break; + // No default: an unknown type code consumes nothing beyond the name, like MuParser. + } + } + + return pm; + } + + /// Mirror of MuParser.ReadMaterialTexture: reads the texture-slot index, scale and + /// offset. Scale/offset are always baked; the url is resolved later (Textures block). A slot of + /// -1 registers no dummy (MuParser's AddTextureDummy skips -1); otherwise the slot table is + /// grown so its Count matches MuParser's textureDummies.Count for the texCount guard. + private void ReadMaterialTexture(PendingMaterial pm, string propertyName) + { + int slotIndex = reader.ReadInt(); + Vector2 scale = reader.ReadVector2(); + Vector2 offset = reader.ReadVector2(); + + var pt = new PendingTexture + { + Name = propertyName, + Scale = scale, + Offset = offset, + Url = null, + IsNormalMap = false, + }; + pm.Textures.Add(pt); + + if (slotIndex != -1) + { + while (slotIndex >= textureSlots.Count) + textureSlots.Add(new List()); + // One texture slot can be referenced by many (material, property) pairs; the resolved url + // is fanned out to every registered PendingTexture in ReadTextures. (We keep a direct + // reference to the PendingTexture rather than MuParser's dedup-by-material list; the dedup + // only removed idempotent duplicate SetTexture calls, so the outcome is identical.) + textureSlots[slotIndex].Add(pt); + } + } + + /// Mirror of MuParser.ReadTextures (opcode 12): resolves texture urls and fans them + /// out to the pending textures. Preserves the texCount != textureDummies.Count guard — on + /// mismatch it logs and returns, leaving every url null (scale/offset still applied). + private void ReadTextures() + { + int texCount = reader.ReadInt(); + + // texCount guard: on mismatch MuParser logs and returns immediately WITHOUT reading the + // texture entries, so no url is ever assigned (every PendingTexture keeps Url == null). We + // reproduce that exactly, including leaving the cursor right after texCount. + if (texCount != textureSlots.Count) + { + Debug.LogError("TextureError: " + texCount + " " + textureSlots.Count); + return; + } + + for (int i = 0; i < texCount; i++) + { + string name = reader.ReadString(); + TextureType textureType = (TextureType)reader.ReadInt(); + + // Url built exactly as MuParser: directoryUrl + "/" + filename-without-extension. + string url = directoryUrl + "/" + Path.GetFileNameWithoutExtension(name); + // IsNormalMap is derived from the TEXTURE entry's type, not from any shader slot name. + bool isNormalMap = textureType == TextureType.NormalMap; + + List refs = textureSlots[i]; + for (int j = 0; j < refs.Count; j++) + { + refs[j].Url = url; + refs[j].IsNormalMap = isNormalMap; + } + } + } + + /// Mirror of MuParser.ReadLight (opcode 23): spot angle exists only for version > 1. + private void ReadLight(int goSlot) + { + int type = reader.ReadInt(); + float intensity = reader.ReadFloat(); + float range = reader.ReadFloat(); + Color color = reader.ReadColor(); + int cullingMask = reader.ReadInt(); + + bool hasSpotAngle = version > 1; + float spotAngle = hasSpotAngle ? reader.ReadFloat() : 0f; + + instructions.Add(new AddLight + { + Go = goSlot, + Type = type, + Intensity = intensity, + Range = range, + Color = color, + CullingMask = cullingMask, + HasSpotAngle = hasSpotAngle, + SpotAngle = spotAngle, + }); + } + + /// Mirror of MuParser.ReadTagAndLayer (opcode 24). Configures the current + /// GameObject slot inline (no new slot). + private void ReadTagAndLayer(int goSlot) + { + string tag = reader.ReadString(); + int layer = reader.ReadInt(); + instructions.Add(new SetTagAndLayer { Go = goSlot, Tag = tag, Layer = layer }); + } + + /// Mirror of MuParser.ReadCamera (opcode 30). + private void ReadCamera(int goSlot) + { + int clearFlags = reader.ReadInt(); + Color backgroundColor = reader.ReadColor(); + int cullingMask = reader.ReadInt(); + bool orthographic = reader.ReadBool(); + float fieldOfView = reader.ReadFloat(); + float nearClip = reader.ReadFloat(); + float farClip = reader.ReadFloat(); + float depth = reader.ReadFloat(); + + instructions.Add(new AddCamera + { + Go = goSlot, + ClearFlags = clearFlags, + BackgroundColor = backgroundColor, + CullingMask = cullingMask, + Orthographic = orthographic, + FieldOfView = fieldOfView, + NearClip = nearClip, + FarClip = farClip, + Depth = depth, + }); + } + + /// Mirror of MuParser.ReadParticles (opcode 31): reads every KSPParticleEmitter + /// field in order (including the 5-element color animation and the raw render-mode int), allocates + /// the emitter slot and registers it under its material index for the deferred fan-out. + private void ReadParticles(int goSlot) + { + int emitterSlot = nextSlot++; + + var d = new ParticleEmitterData(); + d.Emit = reader.ReadBool(); + d.Shape = reader.ReadInt(); + // Component-wise reads: arguments evaluate left-to-right, matching MuParser's x, y, z order. + d.Shape3D = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.Shape2D = new Vector2(reader.ReadFloat(), reader.ReadFloat()); + d.Shape1D = reader.ReadFloat(); + d.Color = reader.ReadColor(); + d.UseWorldSpace = reader.ReadBool(); + d.MinSize = reader.ReadFloat(); + d.MaxSize = reader.ReadFloat(); + d.MinEnergy = reader.ReadFloat(); + d.MaxEnergy = reader.ReadFloat(); + d.MinEmission = reader.ReadInt(); + d.MaxEmission = reader.ReadInt(); + d.WorldVelocity = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.LocalVelocity = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.RndVelocity = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.EmitterVelocityScale = reader.ReadFloat(); + d.AngularVelocity = reader.ReadFloat(); + d.RndAngularVelocity = reader.ReadFloat(); + d.RndRotation = reader.ReadBool(); + d.DoesAnimateColor = reader.ReadBool(); + var colorAnimation = new Color[5]; // MuParser always allocates exactly 5. + for (int i = 0; i < 5; i++) + colorAnimation[i] = reader.ReadColor(); + d.ColorAnimation = colorAnimation; + d.WorldRotationAxis = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.LocalRotationAxis = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.SizeGrow = reader.ReadFloat(); + d.RndForce = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.Force = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.Damping = reader.ReadFloat(); + d.CastShadows = reader.ReadBool(); + d.RecieveShadows = reader.ReadBool(); + d.LengthScale = reader.ReadFloat(); + d.VelocityScale = reader.ReadFloat(); + d.MaxParticleSize = reader.ReadFloat(); + d.RenderModeCode = reader.ReadInt(); // raw code; AddParticleEmitter maps it to the render mode + d.UvAnimationXTile = reader.ReadInt(); + d.UvAnimationYTile = reader.ReadInt(); + d.UvAnimationCycles = reader.ReadInt(); + + instructions.Add(new AddParticleEmitter { Go = goSlot, Dst = emitterSlot, Data = d }); + + int materialIndex = reader.ReadInt(); + EnsureMatRef(materialIndex); + matRefs[materialIndex].Emitters.Add(emitterSlot); + } + + // ---- Mesh parsing -------------------------------------------------------------------------- + + /// Mirror of MuParser.ReadMesh (opcode MeshStart): reads the sub-blocks in file + /// order into a , builds a , records a + /// and returns the allocated mesh slot. Bone weights / bind poses are + /// consumed (cursor parity) but discarded, and mark the model skinned. + private int ReadMesh() + { + EntryType entryType = (EntryType)reader.ReadInt(); + if (entryType != EntryType.MeshStart) + { + // Corrupt stream: MuParser logs "Mesh Error" and returns a null mesh (the component then + // gets a null sharedMesh) while the walk continues. We allocate a slot with NO binding so + // locals[slot] stays null == null mesh, matching that behaviour without a replay crash. + Debug.LogError("Mesh Error"); + return nextSlot++; + } + + int size = reader.ReadInt(); // vertex count for every per-vertex attribute + reader.SkipInt(); // unknown field, skipped exactly as MuParser + + int index = meshIndex++; + string canonicalName = MeshBundleBuilder.Canonicalize($"{fileUrl}#{index}"); + + var arrays = new MeshBlobBuilder.Arrays(); + var triangles = new List(); + + EntryType subType; + while ((subType = (EntryType)reader.ReadInt()) != EntryType.MeshEnd) + { + switch (subType) + { + case EntryType.MeshVertexColors: + { + var colors = new Color32[size]; + reader.FillColor32Buffer(colors, size); + arrays.Colors = colors; + break; + } + case EntryType.MeshVerts: + { + var verts = new Vector3[size]; + reader.FillVector3Buffer(verts, size); + arrays.Vertices = verts; + break; + } + case EntryType.MeshUV: + { + var uv0 = new Vector2[size]; + reader.FillVector2Buffer(uv0, size); + arrays.Uv0 = uv0; + break; + } + case EntryType.MeshUV2: + { + var uv1 = new Vector2[size]; + reader.FillVector2Buffer(uv1, size); + arrays.Uv1 = uv1; + break; + } + case EntryType.MeshNormals: + { + var normals = new Vector3[size]; + reader.FillVector3Buffer(normals, size); + arrays.Normals = normals; + break; + } + case EntryType.MeshTangents: + { + var tangents = new Vector4[size]; + reader.FillVector4Buffer(tangents, size); + arrays.Tangents = tangents; + break; + } + case EntryType.MeshTriangles: + { + int triangleCount = reader.ReadInt(); + var tris = new int[triangleCount]; + reader.FillIntBuffer(tris, triangleCount); + triangles.Add(tris); // one submesh per MeshTriangles block, in encounter order + break; + } + case EntryType.MeshBoneWeights: + { + // Skinned seam: consume exactly (one BoneWeight per vertex) so the cursor stays + // valid, but discard — Arrays has no bone fields and this model falls back. + MarkSkinned(); + for (int i = 0; i < size; i++) + reader.ReadBoneWeight(); + break; + } + case EntryType.MeshBindPoses: + { + MarkSkinned(); + int bindPosesCount = reader.ReadInt(); + for (int i = 0; i < bindPosesCount; i++) + reader.ReadMatrix4x4(); + break; + } + } + } + + arrays.SubMeshTriangles = triangles.ToArray(); + + MeshBlob blob = MeshBlobBuilder.FromArrays(canonicalName, in arrays); + blobs.Add(blob); + + int meshSlot = nextSlot++; + bindings.Add(new MeshBinding(meshSlot, canonicalName)); + return meshSlot; + } + + // ---- Finalize (two-pass materials/textures, then bones) ------------------------------------ + + /// + /// Emits the deferred material work. MuParser reads the Materials block BEFORE the Textures block, + /// so texture urls aren't known when a material is first parsed; we accumulate pending materials + + /// texture-slot references during the walk and resolve them here. For each material index in + /// ASCENDING order we emit then its — that + /// ordering preserves MuParser's LAST-WINS singular-sharedMaterial semantics (a renderer listed + /// under several material indices ends up with the highest one). + /// + private void FinalizeMaterials() + { + for (int i = 0; i < pendingMaterials.Count; i++) + { + PendingMaterial pm = pendingMaterials[i]; + + var textureProps = new TextureProp[pm.Textures.Count]; + for (int j = 0; j < pm.Textures.Count; j++) + { + PendingTexture pt = pm.Textures[j]; + textureProps[j] = new TextureProp + { + Name = pt.Name, + Url = pt.Url, // null when unresolved (slot -1, guard failed, or no Textures block) + IsNormalMap = pt.IsNormalMap, + Scale = pt.Scale, + Offset = pt.Offset, + }; + } + + instructions.Add(new CreateMaterial + { + Dst = pm.Slot, + Shader = pm.Shader, + ValueProps = pm.Values.ToArray(), + TextureProps = textureProps, + Name = pm.Name, + }); + + // Renderers/emitters that referenced material index i. A material never referenced by any + // renderer (matRefs shorter than pendingMaterials, or a defined-but-unused material) simply + // gets empty fan-out lists — harmless, and it avoids reproducing a potential MuParser + // IndexOutOfRange when the Materials count exceeds the referenced count. + int[] rendererSlots = Array.Empty(); + int[] emitterSlots = Array.Empty(); + if (i < matRefs.Count) + { + MatRef mr = matRefs[i]; + rendererSlots = mr.Renderers.ToArray(); + emitterSlots = mr.Emitters.ToArray(); + } + + instructions.Add(new AssignMaterial + { + MaterialSlot = pm.Slot, + RendererSlots = rendererSlots, + EmitterSlots = emitterSlots, + }); + } + } + + /// Mirror of MuParser.AffectSkinnedMeshRenderersBones, which Parse runs last: + /// emits one per skinned renderer, resolving bone names from the model + /// root (slot 0). + private void FinalizeBones() + { + for (int i = 0; i < skinnedRenderers.Count; i++) + { + SkinnedEntry se = skinnedRenderers[i]; + instructions.Add(new ResolveBones + { + SmrSlot = se.SmrSlot, + RootSlot = 0, // the root GameObject is always slot 0 + BoneNames = se.BoneNames, + }); + } + } + + // ---- Small helpers ------------------------------------------------------------------------- + + private void EnsureMatRef(int index) + { + while (index >= matRefs.Count) + matRefs.Add(new MatRef()); + } + + private static void AddColor(PendingMaterial pm, string name, Color value) => + pm.Values.Add(new ValueProp { Name = name, Kind = ValueProp.KindColor, ColorVal = value }); + + private static void AddFloat(PendingMaterial pm, string name, float value) => + pm.Values.Add(new ValueProp { Name = name, Kind = ValueProp.KindFloat, FloatVal = value }); + + private static void AddVector(PendingMaterial pm, string name, Vector4 value) => + pm.Values.Add(new ValueProp { Name = name, Kind = ValueProp.KindVector, VecVal = value }); + + private void MarkSkinned() + { + containsSkinnedMesh = true; + if (!skinnedLogged) + { + skinnedLogged = true; + // This and the compiler's other Debug.Log* calls (the MeshStart/mesh-error log and the + // texCount-mismatch guard) run on background worker threads: Compile is invoked via a + // parallel PLINQ query over all models. That's intentional and safe — Unity 2019.4's logger + // is internally synchronized, and off-thread diagnostic logging is already the established + // pattern in this codebase's background paths (e.g. MeshBlobBuilder's attribute-length warnings). + Debug.Log($"[MuModelCompiler] Model '{fileUrl}' contains a skinned mesh; the v1 pipeline " + + "will fall back to MuParser.Parse for this file."); + } + } + + // ---- Private accumulator types ------------------------------------------------------------- + + /// A material parsed but not yet emitted (its texture urls resolve after the walk). + private sealed class PendingMaterial + { + public int Slot; + public string Name; + public ShaderRef Shader; + public readonly List Values = new List(); + public readonly List Textures = new List(); + } + + /// A material texture property whose url is resolved later. A mutable CLASS (not the + /// struct) so the texture-slot table can hold references to it and fill + /// in the url when the Textures block is read. + private sealed class PendingTexture + { + public string Name; + public Vector2 Scale; + public Vector2 Offset; + public string Url; + public bool IsNormalMap; + } + + /// Renderer + emitter slots referencing one material index (mirrors MuParser's + /// MaterialDummy). + private sealed class MatRef + { + public readonly List Renderers = new List(); + public readonly List Emitters = new List(); + } + + /// A skinned renderer awaiting bone resolution (mirrors MuParser's BonesDummy). + private struct SkinnedEntry + { + public int SmrSlot; + public string[] BoneNames; + } + } +} From 6967f44830d8f980a2f539d56cfc0c438fdbe28d Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 14 Jul 2026 12:53:45 -0700 Subject: [PATCH 05/14] Buffer compiler diagnostics instead of logging off-thread MuModelCompiler runs on background PLINQ worker threads, but it was calling Debug.Log*/LogWarning/LogError directly (skinned notice, mesh error, texture- count guard, and MeshBlobBuilder's attribute-length warnings). Unity's own logger is synchronized, but KSP installs its own ILogHandler and mods chain handlers onto Application.logMessageReceived, and those are NOT thread-safe - logging from the compile workers can corrupt their state or crash. Fix: the compiler buffers diagnostics into CompiledModel.Logs (a List of type+message) during compilation and never logs itself; CompiledModel.FlushLogs() emits them safely on the main thread and will be called by the replay pipeline. MeshBlobBuilder.FromArrays/FromMesh take an optional Action warn sink (default null -> Debug.LogWarning for main-thread/offline callers like MeshBundleSelfTest and the harness); the compiler passes a cached sink that buffers into its log list. Kept MeshBlobBuilder free of any CompiledModel dependency so the offline MeshHarness still builds unchanged. MeshBundleBuilder only throws (no logging), so it needed no change. ModelInstructions' logging stays as-is (it runs on the main thread at replay). --- .../Library/Model/CompiledModel.cs | 45 ++++++++++++++++++ .../Library/Model/MeshBlobBuilder.cs | 35 +++++++++----- .../Library/Model/MuModelCompiler.cs | 47 +++++++++++++++---- 3 files changed, 105 insertions(+), 22 deletions(-) diff --git a/KSPCommunityFixes/Library/Model/CompiledModel.cs b/KSPCommunityFixes/Library/Model/CompiledModel.cs index 4568d47..2b7e9d3 100644 --- a/KSPCommunityFixes/Library/Model/CompiledModel.cs +++ b/KSPCommunityFixes/Library/Model/CompiledModel.cs @@ -44,5 +44,50 @@ internal sealed class CompiledModel /// Human-readable reason set when is true. public string FailureMessage; + + /// Diagnostics collected on the worker thread during compilation. KSP installs its own + /// ILogHandler and mods chain handlers onto Application.logMessageReceived, none of + /// which are thread-safe, so these must NEVER be logged off-thread; they are buffered here and + /// flushed on the MAIN thread via . + public System.Collections.Generic.List Logs; + + /// + /// MAIN THREAD ONLY. Emits every buffered through + /// UnityEngine.Debug, mapping to the matching Debug call + /// (Error/Exception → LogError, Warning → LogWarning, else → Log). Null-safe (no-op when + /// was never populated). + /// + public void FlushLogs() + { + if (Logs == null) + return; + + for (int i = 0; i < Logs.Count; i++) + { + DeferredLog log = Logs[i]; + switch (log.Type) + { + case UnityEngine.LogType.Error: + case UnityEngine.LogType.Exception: + UnityEngine.Debug.LogError(log.Message); + break; + case UnityEngine.LogType.Warning: + UnityEngine.Debug.LogWarning(log.Message); + break; + default: + UnityEngine.Debug.Log(log.Message); + break; + } + } + } + } + + /// A single diagnostic buffered during off-thread compilation, to be emitted later on the + /// main thread via . + internal readonly struct DeferredLog + { + public readonly UnityEngine.LogType Type; + public readonly string Message; + public DeferredLog(UnityEngine.LogType type, string message) { Type = type; Message = message; } } } diff --git a/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs b/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs index 2ad7007..788dd46 100644 --- a/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs +++ b/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs @@ -46,7 +46,7 @@ public struct Arrays } /// Build a from a live UnityEngine.Mesh (main thread only). - public static MeshBlob FromMesh(Mesh mesh, string name) + public static MeshBlob FromMesh(Mesh mesh, string name, Action warn = null) { var arrays = new Arrays { @@ -61,21 +61,24 @@ public static MeshBlob FromMesh(Mesh mesh, string name) arrays.SubMeshTriangles = new int[subCount][]; for (int i = 0; i < subCount; ++i) arrays.SubMeshTriangles[i] = mesh.GetTriangles(i); - return FromArrays(name, in arrays); + return FromArrays(name, in arrays, warn); } - public static unsafe MeshBlob FromArrays(string name, in Arrays a) + // is an optional attribute-mismatch sink. When null (main-thread/offline + // callers) warnings fall back to Debug.LogWarning; a worker-thread caller passes a sink that + // buffers the message instead, since Debug.LogWarning is not safe to call off the main thread. + public static unsafe MeshBlob FromArrays(string name, in Arrays a, Action warn = null) { Vector3[] verts = a.Vertices ?? Array.Empty(); int vertexCount = verts.Length; // A non-null attribute array whose length doesn't match the vertex count is dropped // (treated as absent) below; warn so a mismatched .mu doesn't silently lose an attribute. - WarnIfWrongLength(name, "normals", Count(a.Normals), vertexCount); - WarnIfWrongLength(name, "tangents", Count(a.Tangents), vertexCount); - WarnIfWrongLength(name, "colors", Count(a.Colors), vertexCount); - WarnIfWrongLength(name, "uv0", Count(a.Uv0), vertexCount); - WarnIfWrongLength(name, "uv1", Count(a.Uv1), vertexCount); + WarnIfWrongLength(name, "normals", Count(a.Normals), vertexCount, warn); + WarnIfWrongLength(name, "tangents", Count(a.Tangents), vertexCount, warn); + WarnIfWrongLength(name, "colors", Count(a.Colors), vertexCount, warn); + WarnIfWrongLength(name, "uv0", Count(a.Uv0), vertexCount, warn); + WarnIfWrongLength(name, "uv1", Count(a.Uv1), vertexCount, warn); bool hasNormals = Count(a.Normals) == vertexCount && vertexCount > 0; bool hasTangents = Count(a.Tangents) == vertexCount && vertexCount > 0; @@ -205,13 +208,21 @@ static void AddChannel(MeshChannel[] channels, int index, bool present, int dime // Warns only when an attribute array is actually present (non-null, non-empty) but its length // disagrees with the vertex count, i.e. it is about to be silently dropped. Cheap: no - // allocation unless the (rare) warning path fires. - static void WarnIfWrongLength(string name, string attr, int length, int vertexCount) + // allocation unless the (rare) warning path fires. When a sink is + // supplied (worker-thread callers) the message is routed there; otherwise it falls back to + // Debug.LogWarning for main-thread/offline callers. + static void WarnIfWrongLength(string name, string attr, int length, int vertexCount, Action warn) { if (length != 0 && length != vertexCount) - Debug.LogWarning( + { + string message = $"[MeshBlobBuilder] mesh '{name}': {attr} array length {length} != vertexCount " + - $"{vertexCount}; dropping {attr}"); + $"{vertexCount}; dropping {attr}"; + if (warn != null) + warn(message); + else + Debug.LogWarning(message); + } } static Bounds ComputeBounds(Vector3[] verts, int start, int count) diff --git a/KSPCommunityFixes/Library/Model/MuModelCompiler.cs b/KSPCommunityFixes/Library/Model/MuModelCompiler.cs index 4a3ccbe..4ea9e40 100644 --- a/KSPCommunityFixes/Library/Model/MuModelCompiler.cs +++ b/KSPCommunityFixes/Library/Model/MuModelCompiler.cs @@ -68,6 +68,23 @@ internal sealed unsafe class MuModelCompiler private bool containsSkinnedMesh; private bool skinnedLogged; + // Diagnostics buffered during compilation. Compile runs on background worker threads (via a + // parallel PLINQ query over all models), and KSP's ILogHandler plus the mod handlers chained onto + // Application.logMessageReceived are NOT thread-safe, so nothing here may log off-thread. Instead + // every diagnostic is appended to this list and handed to the returned CompiledModel, whose + // FlushLogs() emits them on the MAIN thread during replay. + private List logs; + + // Single cached sink passed to MeshBlobBuilder.FromArrays so its attribute-length warnings are + // buffered rather than logged off-thread. It closes over the logs FIELD (not a captured list), so + // after ResetState swaps in a fresh list it always targets the current one — no per-mesh alloc. + private readonly Action warnSink; + + public MuModelCompiler() + { + warnSink = msg => logs.Add(new DeferredLog(LogType.Warning, msg)); + } + /// /// Compile one .mu file into a main-thread-ready . Never throws: /// on any parse error a with set is @@ -122,16 +139,19 @@ public CompiledModel Compile(string fileUrl, string directoryUrl, byte[] data, i LocalCount = nextSlot, ContainsSkinnedMesh = containsSkinnedMesh, Failed = false, + Logs = logs, // flushed on the main thread by the replay pipeline (see FlushLogs) }; } catch (Exception e) { // SourceUrl is set first so the pipeline can log which file failed. Compile never throws. + // A Failed model still carries any diagnostics buffered before the fault so they aren't lost. return new CompiledModel { SourceUrl = fileUrl, Failed = true, FailureMessage = e.GetType().Name + ": " + e.Message, + Logs = logs, }; } } @@ -155,6 +175,10 @@ private void ResetState() skinnedRenderers.Clear(); containsSkinnedMesh = false; skinnedLogged = false; + // Fresh list per Compile so a returned CompiledModel keeps its own buffered diagnostics even + // after this instance is reused for the next file. warnSink closes over this field, so it + // automatically targets the new list. + logs = new List(); } // ---- Core tree walk ------------------------------------------------------------------------ @@ -796,7 +820,7 @@ private void ReadTextures() // reproduce that exactly, including leaving the cursor right after texCount. if (texCount != textureSlots.Count) { - Debug.LogError("TextureError: " + texCount + " " + textureSlots.Count); + logs.Add(new DeferredLog(LogType.Error, "TextureError: " + texCount + " " + textureSlots.Count)); return; } @@ -950,7 +974,7 @@ private int ReadMesh() // Corrupt stream: MuParser logs "Mesh Error" and returns a null mesh (the component then // gets a null sharedMesh) while the walk continues. We allocate a slot with NO binding so // locals[slot] stays null == null mesh, matching that behaviour without a replay crash. - Debug.LogError("Mesh Error"); + logs.Add(new DeferredLog(LogType.Error, "Mesh Error")); return nextSlot++; } @@ -1040,7 +1064,7 @@ private int ReadMesh() arrays.SubMeshTriangles = triangles.ToArray(); - MeshBlob blob = MeshBlobBuilder.FromArrays(canonicalName, in arrays); + MeshBlob blob = MeshBlobBuilder.FromArrays(canonicalName, in arrays, warnSink); blobs.Add(blob); int meshSlot = nextSlot++; @@ -1149,13 +1173,16 @@ private void MarkSkinned() if (!skinnedLogged) { skinnedLogged = true; - // This and the compiler's other Debug.Log* calls (the MeshStart/mesh-error log and the - // texCount-mismatch guard) run on background worker threads: Compile is invoked via a - // parallel PLINQ query over all models. That's intentional and safe — Unity 2019.4's logger - // is internally synchronized, and off-thread diagnostic logging is already the established - // pattern in this codebase's background paths (e.g. MeshBlobBuilder's attribute-length warnings). - Debug.Log($"[MuModelCompiler] Model '{fileUrl}' contains a skinned mesh; the v1 pipeline " + - "will fall back to MuParser.Parse for this file."); + // This notice, like the compiler's other diagnostics (the mesh-error log and the + // texCount-mismatch guard), is BUFFERED into the CompiledModel rather than logged here. + // Compile runs on background worker threads (a parallel PLINQ query over all models), and + // KSP's ILogHandler plus the mod handlers chained onto Application.logMessageReceived are + // NOT thread-safe — calling Debug.Log* off-thread could corrupt their state or crash. The + // buffered messages are emitted safely on the main thread by CompiledModel.FlushLogs during + // replay. (MeshBlobBuilder's attribute-length warnings are likewise routed through warnSink.) + logs.Add(new DeferredLog(LogType.Log, + $"[MuModelCompiler] Model '{fileUrl}' contains a skinned mesh; the v1 pipeline " + + "will fall back to MuParser.Parse for this file.")); } } From e8103cddfc7dbf166abfce608b11915db627e25e Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 14 Jul 2026 13:56:32 -0700 Subject: [PATCH 06/14] Wire background model pipeline into FastLoader Replace the main-thread FilesLoader/RawAsset/MuParser model path with a grouped background pipeline mirroring the texture bundle loader: - CompileModelGroups (background Task): PLINQ AsParallel().AsOrdered() compiles every .mu via a per-thread MuModelCompiler, chunks the ordered stream into count-based ~512-model groups, and MeshBundleBuilder.BuildMany's one bundle per group into a bounded BlockingCollection. .dae, skinned, compile-failed entries pass through as ordered markers. - ModelBundlePumpCoroutine (main thread): LoadFromMemoryAsync per group as bytes arrive, drops the managed byte[], and forwards each group's requests in order. - ModelDriverCoroutine/LoadModelCoroutine/InsertReadyModel trio (mirrors the texture driver): one coroutine per model, per-mesh LoadAssetAsync (not LoadAllAssetsAsync) into locals[], replay the IModelInstruction[] on the main thread, then FIFO in-order registration with first-wins dedup - preserving databaseModel order, modelsByDirectoryUrl first-wins, and byte-identical registration vs the old RawAsset path. Load order walks modelAssets exactly as before (AsOrdered fold -> in-order span -> ordered pump -> FIFO drain that waits on a Pending head, never reorders). Robustness (untestable offline, so hardened defensively): - Size-based pump backpressure (MaxResidentModelBundleBytes) bounds resident native bundle memory; bounded groupQueue bounds managed pressure - replacing the old 50MB streaming cap. - Per-group isolation: a BuildMany or LoadFromMemoryAsync failure hard-fails just that group's models (clear logged error) instead of aborting the fold. - All compiler diagnostics flush on the main thread (FlushLogs); the only remaining off-thread fault is stored and logged main-thread by the driver. - InsertReadyModel accounting (PendingBundleRefs decrement + Unload + resident release) runs exactly-once in a finally on every path; the driver logs+skips a throwing model instead of dying. No MuParser fallback for compile/replay failures - those hard-fail with a logged error so compiler bugs surface (and get fixed) rather than being masked, since MuParser is deleted in a later task. The only MuParser use is the temporary skinned-mesh fallback, removed once the compiler gains skinned support. Dead machinery (FilesLoader/ReadAssetsThread/RawAsset model methods/arrayPool/ MuParser) is kept for now and removed in the cleanup task. --- KSPCommunityFixes/Library/Model/ModelGroup.cs | 40 + .../Library/Model/ModelLoadRequest.cs | 57 ++ KSPCommunityFixes/Performance/FastLoader.cs | 730 +++++++++++++++++- 3 files changed, 805 insertions(+), 22 deletions(-) create mode 100644 KSPCommunityFixes/Library/Model/ModelGroup.cs create mode 100644 KSPCommunityFixes/Library/Model/ModelLoadRequest.cs diff --git a/KSPCommunityFixes/Library/Model/ModelGroup.cs b/KSPCommunityFixes/Library/Model/ModelGroup.cs new file mode 100644 index 0000000..f960ee9 --- /dev/null +++ b/KSPCommunityFixes/Library/Model/ModelGroup.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace KSPCommunityFixes.Library.Model +{ + /// + /// A contiguous, ordered span of model load requests whose static meshes are baked into ONE mesh + /// AssetBundle. The background compile task folds the ordered compile results into count-capped + /// groups (so no single becomes huge and native bundle + /// copies drain often), then the main-thread pump loads each group's bundle and forwards its requests + /// to the driver. Every request in (compiled/skinned/dae/failed) rides the span + /// in modelAssets order so registration preserves stock load order. + /// + internal sealed class ModelGroup + { + /// The baked mesh-bundle bytes (Phase-2 output). Null when the group contains no static + /// meshes at all. The main-thread pump nulls this the moment it kicks off + /// so the managed copy can be collected. + public byte[] BundleBytes; + + /// The in-flight bundle load, set by the main-thread pump. Null until the pump processes + /// this group (and stays null when was null — a mesh-less group). + public AssetBundleCreateRequest CreateRequest; + + /// The native bundle byte count, captured by the pump from right + /// before it nulls that managed copy. Feeds the pump's resident-memory backpressure accounting: the + /// pump adds it when it kicks off the load and the driver subtracts it on Unload. Stays 0 for a + /// mesh-less (bundle-less) group, so subtracting it is a harmless no-op. + public long BundleSize; + + /// ALL requests in this group's modelAssets span, in order. + public List Requests; + + /// Number of requests in the group. + /// Decremented in InsertReadyModel as each registers; when it reaches 0 the group's + /// bundle is Unload(false)ed (freeing the native copy, keeping the meshes the built + /// GameObjects now own). + public int PendingBundleRefs; + } +} diff --git a/KSPCommunityFixes/Library/Model/ModelLoadRequest.cs b/KSPCommunityFixes/Library/Model/ModelLoadRequest.cs new file mode 100644 index 0000000..fd378a1 --- /dev/null +++ b/KSPCommunityFixes/Library/Model/ModelLoadRequest.cs @@ -0,0 +1,57 @@ +using UnityEngine; + +namespace KSPCommunityFixes.Library.Model +{ + /// + /// The per-file work item + result carrier for the background model pipeline, the model analogue of + /// FastLoader's TextureLoadRequest. One is produced for every entry in modelAssets and + /// flows compile task -> pump -> driver in modelAssets order. selects + /// how the main-thread loader builds . + /// + internal sealed class ModelLoadRequest + { + /// Matches TextureLoadRequest.State: the driver's strict-FIFO drain peeks the head + /// and waits while it is , never reordering. + public enum State : byte { Pending, Ready, Failed } + + /// How the main-thread loader builds the model: + /// + /// : replay the compiled instructions against meshes loaded from the + /// group's bundle. + /// : v1 fallback to the synchronous MuParser.Parse (baking + /// skinned meshes is deferred), using the retained . + /// : reload via the stock DAE loader. + /// : file read failed or compilation failed; hard failure. + /// + public enum Kind : byte { CompiledMu, Skinned, Dae, Failed } + + public UrlDir.UrlFile File; + public Kind ModelKind; + + /// Carried for CompiledMu/Skinned/Failed (any request produced by the compiler): supplies + /// Instructions/Bindings/LocalCount for replay and, always, the buffered Logs that + /// InsertReadyModel flushes on the main thread. Blobs is nulled once its group's + /// bundle is built. Null for Dae and file-read failures. + public CompiledModel Compiled; + + /// CompiledMu only: back-reference to the owning group for the bundle-load await and the + /// Unload ref-count. + public ModelGroup Group; + + /// Skinned only: the retained raw .mu bytes handed to MuParser.Parse + /// (nulled right after the parse). + public byte[] RawBytes; + public int RawLength; + + public string FailureMessage; + + /// Byte count added to KSPCFFastLoaderReport.modelsBytesLoaded on success. + public long FileLength; + + /// Pending -> Ready/Failed. Volatile like TextureLoadRequest.Status (the loader + /// coroutine writes it, the driver polls it). + public volatile State Status; + + public GameObject Result; + } +} diff --git a/KSPCommunityFixes/Performance/FastLoader.cs b/KSPCommunityFixes/Performance/FastLoader.cs index fe50afb..4a70d86 100644 --- a/KSPCommunityFixes/Performance/FastLoader.cs +++ b/KSPCommunityFixes/Performance/FastLoader.cs @@ -1,4 +1,5 @@ // #define DEBUG_TEXTURE_CACHE +// #define DEBUG_MODEL_LOAD_ORDER using DDSHeaders; using Expansions; @@ -8,6 +9,7 @@ using KSPAssets.Loaders; using KSPCommunityFixes.Library.Buffers; using KSPCommunityFixes.Library.Collections; +using KSPCommunityFixes.Library.Model; using KSPCommunityFixes.Library.TextureBundle; using System; using System.Buffers.Binary; @@ -158,6 +160,29 @@ internal class KSPCFFastLoader : MonoBehaviour // This should roughly limit the max frame time spent on loading textures. private const int MaxTextureSpawnsPerFrame = 64; + // Max number of new model load coroutines that will be spawned each frame. + // This roughly limits the max frame time spent replaying models / loading their meshes. + private const int MaxModelSpawnsPerFrame = 12; + + // v1 tuning knob: cap on native mesh-bundle bytes resident at once. The pump waits before kicking off a + // group's LoadFromMemoryAsync while at least this many bytes are already resident, so the driver can + // Unload earlier groups first. Restores the old streaming loader's ~50 MB-capped bounded-memory + // behavior (regression guard vs loading every group's native copy up front). + private const long MaxResidentModelBundleBytes = 96L * 1024 * 1024; // 96 MB + + private const int ModelGroupQueueCapacity = 4; // groups the compile task may run ahead of the pump; bounds managed bundle-byte pressure (tuning knob) + + // Native mesh-bundle bytes currently resident: the pump ADDS a group's size when it kicks off the + // load, the driver SUBTRACTS it on Unload. Both the pump and the driver are main-thread Unity + // coroutines that never run concurrently, so this plain field needs no lock. Reset when the model + // pipeline is kicked off. + private static long residentModelBundleBytes; + + // Set by CompileModelGroups' last-resort outer catch on the background Task thread; logged ONCE on the + // main thread by ModelDriverCoroutine at termination. NEVER Debug.* from the Task thread (the mod + // handlers chained onto Application.logMessageReceived aren't thread-safe). + private static volatile Exception modelCompileFault; + private static Harmony persistentHarmony; private static string PersistentHarmonyID => typeof(KSPCFFastLoader).FullName; @@ -570,6 +595,21 @@ static IEnumerator FastAssetLoader(List configFileTypes) BundleState bundleState = new(); gdb.StartCoroutine(LoadBundledAssets(bundleState, bundleRequests, textureQueue)); + // Kick off the background model compile + bundle pump NOW so it overlaps texture loading. + // CompileModelGroups classifies/compiles every .mu off-thread and folds the ordered results into + // count-capped ModelGroups; ModelBundlePumpCoroutine kicks off each group's mesh-bundle + // LoadFromMemoryAsync and forwards its requests (in order) into modelQueue. The DRIVER is NOT + // started here: replaying a model's CreateMaterial.Execute needs every texture registered, which + // is only true after InsertBundledTextures below, so the driver is started at the model stage. + var groupQueue = new BlockingCollection(ModelGroupQueueCapacity); + var modelQueue = new BlockingCollection(); + // Reset the shared pump<->driver state before the pipeline starts (both are static, so a prior + // load in this process could have left them non-zero/non-null). + residentModelBundleBytes = 0; + modelCompileFault = null; + Task.Run(() => CompileModelGroups(modelAssets, groupQueue)); + gdb.StartCoroutine(ModelBundlePumpCoroutine(groupQueue, modelQueue)); + gdb.progressTitle = "Loading sound assets..."; KSPCFFastLoaderReport.wAudioLoading.Restart(); yield return null; @@ -731,8 +771,25 @@ static IEnumerator FastAssetLoader(List configFileTypes) } } - // call our custom loader - yield return gdb.StartCoroutine(FilesLoader(modelAssets, allModelFiles, "Loading model asset")); + // call our custom loader: drain the model pipeline (compiled meshes replayed from their group + // bundle, skinned/dae fallbacks, failures) in strict modelAssets order. The compile task + pump + // started overlapping texture loading above; the driver only registers models now that every + // texture is in the database (CreateMaterial replay resolves textures via GameDatabase). + yield return gdb.StartCoroutine(ModelDriverCoroutine(modelQueue, allModelFiles, modelAssets.Count)); + +#if DEBUG_MODEL_LOAD_ORDER + // Optional load-order dump for an old-vs-new diff (enable via the #define at the top of the file). + { + var sb = new System.Text.StringBuilder(1024); + sb.Append("[KSPCF:FastLoader] model load order (").Append(gdb.databaseModelFiles.Count).Append(" files):\n"); + for (int i = 0; i < gdb.databaseModelFiles.Count; i++) + sb.Append(gdb.databaseModelFiles[i].url).Append('\n'); + sb.Append("[KSPCF:FastLoader] modelsByDirectoryUrl first-wins (").Append(modelsByDirectoryUrl.Count).Append(" dirs):\n"); + foreach (var kvp in modelsByDirectoryUrl) + sb.Append(kvp.Key).Append(" -> ").Append(kvp.Value.IsNotNullOrDestroyed() ? kvp.Value.transform.name : "").Append('\n'); + Debug.Log(sb.ToString()); + } +#endif // all done, do some cleanup arrayPool = null; @@ -1190,28 +1247,12 @@ private GameObject LoadMU() return MuParser.Parse(file.parent.url, buffer, dataLength); } + // Body extracted into the shared static KSPCFFastLoader.LoadDAE(UrlFile) so the new model + // pipeline's Dae path can reuse it. This wrapper (and its only caller, LoadAndDisposeMainThread's + // model path) is dead once the driver replaces FilesLoader; task #6 removes it. private GameObject LoadDAE() { - // given that this is a quite obsolete thing and that it's mess to reimplement, just call the stock - // stuff and re-load the file - - GameObject gameObject = new DatabaseLoaderModel_DAE.DAE().Load(file, new FileInfo(file.fullPath)); - if (gameObject.IsNotNullOrDestroyed()) - { - MeshFilter[] componentsInChildren = gameObject.GetComponentsInChildren(); - foreach (MeshFilter meshFilter in componentsInChildren) - { - if (meshFilter.gameObject.name == "node_collider") - { - meshFilter.gameObject.AddComponent().sharedMesh = meshFilter.mesh; - MeshRenderer component = meshFilter.gameObject.GetComponent(); - UnityEngine.Object.Destroy(meshFilter); - UnityEngine.Object.Destroy(component); - } - } - } - - return gameObject; + return KSPCFFastLoader.LoadDAE(file); } } @@ -1495,6 +1536,651 @@ private static IEnumerator InsertBundledTextures( } #endregion + #region Model bundle loader + + // Outcome of compiling one model file off-thread. Mutually-exclusive shapes: a .dae marker + // (IsDae), a file-read failure (Compiled == null, ReadFailure set), or a compiled model (Compiled + // set, Data retained for the skinned fallback). The compile SUCCESS/SKINNED/COMPILE-FAILED split is + // decided in the fold from Compiled's flags. + private struct CompileResult + { + public UrlFile File; + public bool IsDae; + public string ReadFailure; + public CompiledModel Compiled; + public byte[] Data; + public int DataLength; + public long FileLength; + } + + // Compile one model file. MUST NOT throw (a faulted PLINQ Select would abort the whole enumeration): + // the only thrower here is File.ReadAllBytes, caught into a ReadFailure marker; MuModelCompiler.Compile + // never throws. + private static CompileResult CompileOne(RawAsset asset, ThreadLocal tl) + { + UrlFile file = asset.File; + + // .dae/.DAE never touch the compiler; the main-thread Dae path reloads them via the stock loader. + string ext = file.fileExtension; + if (ext == "dae" || ext == "DAE") + return new CompileResult { File = file, IsDae = true }; + + byte[] data; + try + { + // Plain managed array (NOT arrayPool): avoids cross-thread pool contention with the disk reader. + data = System.IO.File.ReadAllBytes(file.fullPath); + } + catch (Exception e) + { + return new CompileResult { File = file, ReadFailure = e.Message }; + } + + // Args mirror MuParser.Parse(file.parent.url, buffer, dataLength): fileUrl == file.url, + // directoryUrl == file.parent.url. Per-thread compiler (ResetState makes reuse safe). + CompiledModel cm = tl.Value.Compile(file.url, file.parent.url, data, data.Length); + + return new CompileResult + { + File = file, + Compiled = cm, + Data = data, + DataLength = data.Length, + FileLength = data.Length, + }; + } + + // Phase 1/2 background task (analogue of BuildDDSBundle): a parallel ORDERED PLINQ query compiles + // every model off-thread, and a serial fold on THIS single background thread groups the ordered + // results into count-capped ModelGroups, baking each group's static meshes into one mesh bundle via + // MeshBundleBuilder.BuildMany. AsOrdered + in-order span append is the first link of the load-order + // chain (pump forward + FIFO drain are the rest). Always CompleteAdding(groupQueue) in finally so the + // consumer terminates even on fault. + private static void CompileModelGroups( + List modelAssets, + BlockingCollection groupQueue) + { + // Tuning knob: max compiled (non-skinned) .mu models per bundle. Larger groups amortize + // LoadFromMemoryAsync overhead; smaller groups start spawning sooner and drain native bundle + // copies more often. + const int GroupModelCap = 512; + + // m9: the WHOLE body runs inside this try so groupQueue.CompleteAdding() (finally, below) fires even + // if the pre-loop setup (ThreadLocal / list allocations, PLINQ query construction) throws under OOM. + // Otherwise the pump and driver would hang forever on a queue that never completes. + try + { + // Per-thread compiler: MuModelCompiler holds mutable per-file accumulators, so it is NEVER + // shared/static; ResetState (top of Compile) makes reuse across files on one worker safe. + using var tl = new ThreadLocal(() => new MuModelCompiler()); + + var span = new List(); + var blobs = new List(); + var groupKeys = new HashSet(StringComparer.Ordinal); + int modelCount = 0; + + // Seal the current span+blobs into a group, build its bundle, free per-request geometry, hand + // it off, then start fresh. Never runs on an empty span in practice (all call sites guard it). + void Flush() + { + int compiledCount = 0; + for (int i = 0; i < span.Count; i++) + if (span[i].ModelKind == ModelLoadRequest.Kind.CompiledMu) + compiledCount++; + + var group = new ModelGroup + { + Requests = span, + PendingBundleRefs = compiledCount, + }; + + try + { + // BuildMany reads the blob list now (throws on duplicate canonical keys; prevented by + // the per-group key-set split below). Null bundle when the span has no static meshes. + group.BundleBytes = blobs.Count == 0 ? null : MeshBundleBuilder.BuildMany(blobs); + + for (int i = 0; i < span.Count; i++) + { + ModelLoadRequest r = span[i]; + if (r.ModelKind == ModelLoadRequest.Kind.CompiledMu) + { + r.Group = group; + r.Compiled.Blobs = null; // geometry now lives in the bundle; free managed copy + } + } + } + catch (Exception buildEx) + { + // M2: isolate a mesh-bundle build failure to THIS group instead of aborting the whole + // fold. HARD-FAIL every CompiledMu request in the group (surfaced by InsertReadyModel's + // LOAD FAILED path — no MuParser fallback, so a real compiler/mesh bug is discovered + // during in-KSP validation), drop the (absent) bundle and its ref-count, and still + // forward the group IN ORDER so load order is preserved. + string msg = "mesh bundle build failed: " + buildEx.Message; + for (int i = 0; i < span.Count; i++) + { + ModelLoadRequest r = span[i]; + if (r.ModelKind == ModelLoadRequest.Kind.CompiledMu) + { + r.ModelKind = ModelLoadRequest.Kind.Failed; + r.FailureMessage = msg; + if (r.Compiled != null) + r.Compiled.Blobs = null; + } + } + group.BundleBytes = null; + group.PendingBundleRefs = 0; + } + + groupQueue.Add(group); + + span = new List(); + blobs = new List(); + groupKeys.Clear(); + modelCount = 0; + } + + // AsOrdered() => the fold observes results in modelAssets order (load-order parity link #1). + foreach (CompileResult rec in modelAssets + .AsParallel() + .AsOrdered() + .Select(asset => CompileOne(asset, tl))) + { + var req = new ModelLoadRequest + { + File = rec.File, + FileLength = rec.FileLength, + }; + + if (rec.IsDae) + { + req.ModelKind = ModelLoadRequest.Kind.Dae; + span.Add(req); // rides the span for ordering; contributes no blobs + } + else if (rec.Compiled == null) + { + // File read failed: hard failure (no Compiled to flush). + req.ModelKind = ModelLoadRequest.Kind.Failed; + req.FailureMessage = rec.ReadFailure; + span.Add(req); + } + else + { + CompiledModel cm = rec.Compiled; + req.Compiled = cm; // carried for FlushLogs (and, for CompiledMu, replay) + + if (cm.Failed) + { + // Compilation failed: hard failure. cm carries its buffered diagnostics; its baked + // geometry is never used. + req.ModelKind = ModelLoadRequest.Kind.Failed; + req.FailureMessage = cm.FailureMessage; + cm.Blobs = null; + span.Add(req); + } + else if (cm.ContainsSkinnedMesh) + { + // v1 skinned fallback to synchronous MuParser.Parse on the main thread; retain the + // raw bytes. cm carries only its diagnostics (the skinned-notice log); baked geometry + // is unused. + req.ModelKind = ModelLoadRequest.Kind.Skinned; + req.RawBytes = rec.Data; + req.RawLength = rec.DataLength; + cm.Blobs = null; + span.Add(req); + } + else + { + req.ModelKind = ModelLoadRequest.Kind.CompiledMu; + + // Per-group duplicate-canonical-key split: two model files sharing a url (the reason + // the whole dedup machinery exists) emit identical mesh names ("{url}#i"), which would + // make BuildMany throw and fail the ENTIRE group. Flush first so each duplicate lands + // in its own bundle; the duplicate is then dropped first-wins at registration. + // MeshBlob.Name is already canonical (idempotent under Canonicalize), so we key on it + // directly, matching BuildMany's canonical-key dedup. + MeshBlob[] cmBlobs = cm.Blobs; + bool collides = false; + for (int i = 0; i < cmBlobs.Length; i++) + { + if (groupKeys.Contains(cmBlobs[i].Name)) + { + collides = true; + break; + } + } + if (collides) + Flush(); + + for (int i = 0; i < cmBlobs.Length; i++) + { + blobs.Add(cmBlobs[i]); + groupKeys.Add(cmBlobs[i].Name); + } + span.Add(req); + + if (++modelCount == GroupModelCap) + Flush(); + } + } + } + + // Trailing partial group (may carry a null bundle if it is all dae/skinned/failed). + if (span.Count > 0) + Flush(); + } + catch (Exception e) + { + // M2 last resort: with per-group Flush isolation this should almost never fire (a faulted PLINQ + // enumeration surfaces as AggregateException at the foreach above). Do NOT Debug.* from this + // background Task thread — the mod handlers chained onto Application.logMessageReceived aren't + // thread-safe (the exact hazard DeferredLog exists to avoid). Stash it for ModelDriverCoroutine + // to log ONCE on the main thread when it terminates. + modelCompileFault = e; + } + finally + { + // Guarantees the pump (and therefore the driver) terminates even on fault. Mirrors + // BuildDDSBundle's textureQueue.CompleteAdding(). + groupQueue.CompleteAdding(); + } + } + + // Phase 3 main-thread pump: drains finished ModelGroups, kicks off each group's mesh-bundle load, and + // forwards its requests (in order) into modelQueue for the driver. Poll form (TryTake) so it never + // blocks a coroutine thread on GetConsumingEnumerable. Completes modelQueue in finally. + private static IEnumerator ModelBundlePumpCoroutine( + BlockingCollection groupQueue, + BlockingCollection modelQueue) + { + try + { + while (!groupQueue.IsCompleted) + { + while (groupQueue.TryTake(out ModelGroup group)) + { + if (group.BundleBytes != null) + { + // Capture the native size before nulling the managed copy; BundleSize feeds the + // resident-memory accounting here and the driver's Unload adjust. + group.BundleSize = group.BundleBytes.Length; + + // M1 backpressure: bound resident native bundle memory. Wait until the driver has + // Unloaded enough earlier groups to make room before kicking this one off. The + // residentModelBundleBytes > 0 guard guarantees a single oversized group still loads + // once everything before it has drained (no deadlock). This yield loop sits OUTSIDE + // the LoadFromMemoryAsync try/catch below on purpose — a try with a catch may not + // contain a yield. + while (residentModelBundleBytes > 0 && + residentModelBundleBytes + group.BundleSize > MaxResidentModelBundleBytes) + yield return null; + + bool loadFailed = false; + string loadFailMsg = null; + try + { + group.CreateRequest = AssetBundle.LoadFromMemoryAsync(group.BundleBytes); + // Match the texture bundle: low priority so third-party bundle loads aren't + // starved. + group.CreateRequest.priority = -10; + } + catch (Exception e) + { + // M4: isolate a LoadFromMemoryAsync failure to this group. HARD-FAIL its + // CompiledMu requests (no MuParser fallback) and still forward ALL requests in + // order below so load order is preserved. + loadFailed = true; + loadFailMsg = "bundle load failed: " + e.Message; + } + + group.BundleBytes = null; // drop the managed copy either way; the bundle owns it now + + if (loadFailed) + { + group.CreateRequest = null; + List greqs = group.Requests; + for (int i = 0; i < greqs.Count; i++) + { + ModelLoadRequest r = greqs[i]; + if (r.ModelKind == ModelLoadRequest.Kind.CompiledMu) + { + r.ModelKind = ModelLoadRequest.Kind.Failed; + r.FailureMessage = loadFailMsg; + } + } + } + else + { + // Reserve the resident bytes now that the load is in flight; the driver frees + // them when the group's last CompiledMu registers and it Unload(false)s. + residentModelBundleBytes += group.BundleSize; + } + } + + List reqs = group.Requests; + for (int i = 0; i < reqs.Count; i++) + modelQueue.Add(reqs[i]); // in-order forward (load-order parity link #3) + + yield return null; + } + + yield return null; + } + } + finally + { + modelQueue.CompleteAdding(); + } + } + + // Phase 3 driver (clone of TextureDriverCoroutine): spawns up to MaxModelSpawnsPerFrame per-request + // loaders per frame and inserts finished ones in STRICT FIFO order. The FIFO drain (peek head; if + // still Pending, WAIT — never skip/reorder) is what preserves modelAssets load order. + private static IEnumerator ModelDriverCoroutine( + BlockingCollection modelQueue, + HashSet loadedUrls, + int totalModelCount) + { + GameDatabase gdb = GameDatabase.Instance; + Queue active = new(); + int completed = 0; + + while (true) + { + for (int i = 0; i < MaxModelSpawnsPerFrame; ++i) + { + if (!modelQueue.TryTake(out ModelLoadRequest request)) + break; + + gdb.StartCoroutine(LoadModelCoroutine(request)); + active.Enqueue(request); + } + + while (active.TryPeek(out ModelLoadRequest pending)) + { + if (pending.Status == ModelLoadRequest.State.Pending) + break; // head not done yet: WAIT, never reorder (load-order parity link #4) + + active.Dequeue(); + // A throw in InsertReadyModel's body must not kill the driver (that would halt ALL further + // model registration). Its finally still runs on the throwing path (ref-count/Unload/resident + // release), so we just log the one bad model and skip it. The bookkeeping below runs + // regardless so counts/progress don't stall on a skipped model. + try { InsertReadyModel(pending, loadedUrls); } + catch (Exception e) { Debug.LogException(e); } + loadedAssetCount++; + completed++; + } + + gdb.progressFraction = (float)loadedAssetCount / totalAssetCount; + gdb.progressTitle = $"Loading model asset {completed}/{totalModelCount}"; + + // Done when the producers have finished and everything spawned has been drained. + if (modelQueue.IsCompleted && active.Count == 0) + break; + + yield return null; + } + + // M2: surface any last-resort compile-task fault ONCE, here on the MAIN thread (the fold stashed it + // rather than calling Debug.* off the Task thread). + Exception fault = modelCompileFault; + if (fault != null) + { + modelCompileFault = null; + Debug.LogException(fault); + } + } + + // Exception-wrapping driver for one request (clone of LoadTextureCoroutine): drives the inner + // per-Kind enumerator, mapping any thrown exception to a Failed status + message. + private static IEnumerator LoadModelCoroutine(ModelLoadRequest req) + { + IEnumerator inner; + switch (req.ModelKind) + { + case ModelLoadRequest.Kind.CompiledMu: + inner = LoadCompiledModelCoroutine(req); + break; + case ModelLoadRequest.Kind.Skinned: + inner = LoadSkinnedModelCoroutine(req); + break; + case ModelLoadRequest.Kind.Dae: + inner = LoadDaeModelCoroutine(req); + break; + default: + inner = LoadFailedModelCoroutine(req); + break; + } + + while (true) + { + object current; + try + { + if (!inner.MoveNext()) + break; + + current = inner.Current; + } + catch (Exception e) + { + req.FailureMessage = $"{e.GetType().Name}: {e.Message}"; + req.Status = ModelLoadRequest.State.Failed; + yield break; + } + + yield return current; + } + + if (req.Status != ModelLoadRequest.State.Pending) + yield break; + + if (req.Result.IsNotNullOrDestroyed()) + { + req.Status = ModelLoadRequest.State.Ready; + } + else + { + req.FailureMessage ??= "Loader produced no result"; + req.Status = ModelLoadRequest.State.Failed; + } + } + + // CompiledMu: load this model's meshes from the group bundle (per-name LoadAssetAsync, NOT + // LoadAllAssetsAsync), then replay the compiled instructions on the main thread. Textures/shaders are + // resolved inside Execute, which is why the driver only runs after all textures are registered. + private static IEnumerator LoadCompiledModelCoroutine(ModelLoadRequest req) + { + CompiledModel cm = req.Compiled; + MeshBinding[] bindings = cm.Bindings; + var locals = new UnityEngine.Object[cm.LocalCount]; + + // A model with meshes always sits in a group with a bundle (its blobs made BundleBytes non-null), + // so waiting for CreateRequest can't deadlock. A mesh-less model (no bindings) may be in a + // bundle-less group whose CreateRequest stays null forever, so it MUST skip the wait entirely. + if (bindings.Length > 0) + { + ModelGroup g = req.Group; + while (g.CreateRequest == null) + yield return null; + + if (!g.CreateRequest.isDone) + yield return g.CreateRequest; + + AssetBundle bundle = g.CreateRequest.assetBundle; + if (bundle == null) + { + req.FailureMessage = "mesh bundle failed to load"; + req.Status = ModelLoadRequest.State.Failed; + yield break; + } + + for (int i = 0; i < bindings.Length; i++) + { + MeshBinding b = bindings[i]; + AssetBundleRequest ar = bundle.LoadAssetAsync(b.CanonicalName); + ar.priority = -10; + yield return ar; + // A missing mesh yields a null asset -> null mesh, matching MuParser's null-mesh handling. + locals[b.Slot] = ar.asset; + } + } + + IModelInstruction[] instructions = cm.Instructions; + for (int i = 0; i < instructions.Length; i++) + instructions[i].Execute(locals); + + req.Result = (GameObject)locals[0]; + req.Status = ModelLoadRequest.State.Ready; + } + + // Skinned v1 fallback: synchronous MuParser.Parse on the main thread. Parse uses static buffers and + // never yields, so two skinned parses can't interleave. Never yields here either (runs to completion + // inside the driver's spawn loop). + private static IEnumerator LoadSkinnedModelCoroutine(ModelLoadRequest req) + { + GameObject go = MuParser.Parse(req.File.parent.url, req.RawBytes, req.RawLength); + req.RawBytes = null; + + if (go.IsNullOrDestroyed()) + { + req.FailureMessage = "MU model load error"; + req.Status = ModelLoadRequest.State.Failed; + yield break; + } + + req.Result = go; + req.Status = ModelLoadRequest.State.Ready; + } + + // Dae fallback: reload via the stock DAE loader (see the shared LoadDAE helper). + private static IEnumerator LoadDaeModelCoroutine(ModelLoadRequest req) + { + GameObject go = LoadDAE(req.File); + + if (go.IsNullOrDestroyed()) + { + req.FailureMessage = "DAE model load error"; + req.Status = ModelLoadRequest.State.Failed; + yield break; + } + + req.FileLength = new FileInfo(req.File.fullPath).Length; + req.Result = go; + req.Status = ModelLoadRequest.State.Ready; + } + + // Failed: hard failure. Message was already set in the fold (read or compile failure); keep it. + private static IEnumerator LoadFailedModelCoroutine(ModelLoadRequest req) + { + req.FailureMessage ??= req.Compiled?.FailureMessage; + req.Status = ModelLoadRequest.State.Failed; + yield break; + } + + // Main-thread registration (clone of InsertReadyRequest; replicates RawAsset.LoadAndDisposeMainThread's + // model registration). Called ONLY from the driver's FIFO drain, so it walks modelAssets order. + private static void InsertReadyModel(ModelLoadRequest req, HashSet loadedUrls) + { + // m5: the ENTIRE body (FlushLogs, the Debug.* calls, and every registration branch) runs inside this + // try so the finally below runs on EVERY path — it guarantees the exactly-once PendingBundleRefs + // decrement + resident-bytes release + Unload for each CompiledMu, so even a throw in the body still + // frees the group's native bundle copy and never strands the resident cap. The throw itself is NOT + // swallowed here; ModelDriverCoroutine's FIFO drain wraps this call and logs+skips the one bad model, + // so a single failure can't kill the driver. + try + { + // On the MAIN THREAD: emit the compiler's buffered diagnostics (KSP's log handler and the mod + // handlers chained onto Application.logMessageReceived are not thread-safe, so they could not be + // flushed off-thread). Null-safe: Dae has no Compiled. + req.Compiled?.FlushLogs(); + + Debug.Log($"Load Model: {req.File.url}"); + + if (req.Status == ModelLoadRequest.State.Failed) + { + Debug.LogWarning($"LOAD FAILED: {req.File.url}: {req.FailureMessage}"); + if (req.Result.IsNotNullOrDestroyed()) + UnityEngine.Object.Destroy(req.Result); + return; + } + + // Built-before-check (like the texture dup path): a duplicate url means we already built the + // GameObject, so it must be destroyed. First-wins, matching stock FilesLoader. + if (!loadedUrls.Add(req.File.url)) + { + Debug.LogWarning($"Duplicate model asset '{req.File.url}' with extension '{req.File.fileExtension}' won't be loaded"); + if (req.Result.IsNotNullOrDestroyed()) + UnityEngine.Object.Destroy(req.Result); + return; + } + + // Exact replication of RawAsset.LoadAndDisposeMainThread's model registration. + GameObject model = req.Result; + model.transform.name = req.File.url; + model.transform.parent = Instance.transform; + model.transform.localPosition = Vector3.zero; + model.transform.localRotation = Quaternion.identity; + model.SetActive(false); + Instance.databaseModel.Add(model); + Instance.databaseModelFiles.Add(req.File); + modelsByUrl[req.File.url] = model; + // if multiple models in the same dir, we only add the first + // to ensure identical behavior as the GameDatabase.GetModelPrefabIn() method + modelsByDirectoryUrl.TryAdd(req.File.parent.url, model); + urlFilesByModel.Add(model, req.File); + KSPCFFastLoaderReport.modelsBytesLoaded += req.FileLength; + KSPCFFastLoaderReport.modelsLoaded++; + } + finally + { + if (req.ModelKind == ModelLoadRequest.Kind.CompiledMu && --req.Group.PendingBundleRefs == 0) + { + // M1: release this group's resident-bytes reservation so the pump can load more groups. + // Done BEFORE the Unload below on purpose: if Unload ever threw, skipping the release would + // strand the cap. BundleSize is 0 for a bundle-less group (pump never reserved for it), so + // this is a no-op there; it exactly reverses the pump's single += for a group whose bundle + // was loaded. + residentModelBundleBytes -= req.Group.BundleSize; + + // false: keep the loaded meshes (now owned by the built GameObjects); free the bundle's + // native copy. + AssetBundle b = req.Group.CreateRequest?.assetBundle; + if (b != null) + b.Unload(false); + } + } + } + + // Shared body of the (now-wrapped) RawAsset.LoadDAE, reused by the new Dae path. Reloads the file via + // the stock DAE loader and reproduces the node_collider fixup. + private static GameObject LoadDAE(UrlFile file) + { + // given that this is a quite obsolete thing and that it's mess to reimplement, just call the stock + // stuff and re-load the file + + GameObject gameObject = new DatabaseLoaderModel_DAE.DAE().Load(file, new FileInfo(file.fullPath)); + if (gameObject.IsNotNullOrDestroyed()) + { + MeshFilter[] componentsInChildren = gameObject.GetComponentsInChildren(); + foreach (MeshFilter meshFilter in componentsInChildren) + { + if (meshFilter.gameObject.name == "node_collider") + { + meshFilter.gameObject.AddComponent().sharedMesh = meshFilter.mesh; + MeshRenderer component = meshFilter.gameObject.GetComponent(); + UnityEngine.Object.Destroy(meshFilter); + UnityEngine.Object.Destroy(component); + } + } + } + + return gameObject; + } + #endregion + #region PNG texture cache // If the user opts into it, we cache compressed versions of PNG files as DDS files under From 4917c308b0b00ccf33d5a1f21acba1508e51180d Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 14 Jul 2026 14:32:03 -0700 Subject: [PATCH 07/14] Add skinned-mesh serialization (blend channels, bind pose, bone metadata) Extend the hand-written Unity 2019.4.18f1 Mesh (class 43) serializer to emit skinned meshes, reverse-engineered from 542 real skinned meshes in KSP's sharedassets0.assets and byte-structurally validated: - Vertex channels 12 (BlendWeights, format 0/Float32, dim 4) and 13 (BlendIndices, format 10/UInt32, dim 4) appended to the single interleaved stream 0; weights written as float32, bone indices as raw uint32. - m_BindPose (Matrix4x4[], serializedFloat[R*4+C]). - m_BoneNameHashes / m_RootBoneNameHash / m_BonesAABB populated to maintain Unity's bindpose==boneNameHashes==bonesAABB count invariant (m_VariableBoneCountWeights stays empty). BonesAABB filled conservatively with the whole-mesh AABB per bone. - Bone name hash = zlib CRC32 over the UTF-8 full transform path from the model root; verified exact against all 35 bones of a reference mesh plus a single-bone mesh. MeshBlobBuilder.Arrays gains BoneWeights/BindPoses/BoneNames; AddChannel takes a format param so ch13 emits UInt32. Static meshes are unchanged: ch12/13 absent, all skin arrays empty, byte-identical to before (verified against golden bundles; all static self-consistency + semantic validators still pass). Single-stream/all-dim4 layout differs from Unity's native multi-stream layout but honors each channel's (stream,offset,format,dim) descriptor - the same approach already proven for static meshes; whether Unity skins from it needs the in-KSP check when the compiler wiring makes this path live. Serializer capability only - currently dormant (the compiler still discards skin data and routes skinned models to the temporary MuParser fallback; wiring is the next unit). --- KSPCommunityFixes/Library/Model/MeshBlob.cs | 33 +++++ .../Library/Model/MeshBlobBuilder.cs | 131 +++++++++++++++++- .../Library/Model/MeshBundleBuilder.cs | 46 +++++- 3 files changed, 202 insertions(+), 8 deletions(-) diff --git a/KSPCommunityFixes/Library/Model/MeshBlob.cs b/KSPCommunityFixes/Library/Model/MeshBlob.cs index c7c5a1e..caadc93 100644 --- a/KSPCommunityFixes/Library/Model/MeshBlob.cs +++ b/KSPCommunityFixes/Library/Model/MeshBlob.cs @@ -46,6 +46,22 @@ internal sealed class MeshBlob /// Skinning bind poses (m_BindPose); null/empty for a static mesh. public Matrix4x4[] BindPose; + /// + /// One CRC32 per bone (m_BoneNameHashes); null/empty for a static mesh. Count must + /// equal and (Unity's per-bone invariant). + /// + public uint[] BoneNameHashes; + + /// The root bone's hash (m_RootBoneNameHash); BoneNameHashes[0], 0 for a static mesh. + public uint RootBoneNameHash; + + /// + /// Per-bone local bounds (m_BonesAABB); null/empty for a static mesh. Count must equal + /// . Filled conservatively with the whole-mesh bounds per bone (bounds + /// affect culling only, not skinning). + /// + public MeshBoneAABB[] BonesAABB; + /// /// UV distribution metrics (m_MeshMetrics[0..1]). 1.0 is a neutral default: these values /// only feed texture-mip streaming priority (which mips to keep resident), not per-pixel @@ -55,6 +71,23 @@ internal sealed class MeshBlob public float MeshMetric1 = 1f; } + /// + /// One MinMaxAABB entry (m_BonesAABB element): a min/max corner pair, 24 bytes + /// (two Vector3f). Distinct from (center/extent), which the local + /// AABB and submesh bounds use. + /// + internal readonly struct MeshBoneAABB + { + public readonly Vector3 Min; + public readonly Vector3 Max; + + public MeshBoneAABB(Vector3 min, Vector3 max) + { + Min = min; + Max = max; + } + } + /// One ChannelInfo entry: which stream/offset/format/dimension a vertex attribute uses. internal readonly struct MeshChannel { diff --git a/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs b/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs index 788dd46..a191dc1 100644 --- a/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs +++ b/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs @@ -1,4 +1,5 @@ using System; +using System.Text; using UnityEngine; namespace KSPCommunityFixes.Library.Model @@ -28,8 +29,13 @@ internal static class MeshBlobBuilder public const int ChColor = 3; public const int ChTexCoord0 = 4; public const int ChTexCoord1 = 5; + public const int ChBlendWeights = 12; + public const int ChBlendIndices = 13; const byte FormatFloat32 = 0; + // Unity 2019.4 VertexChannelFormat.kChannelFormatUInt32 (format enum 10). BlendIndices use + // this: the bone indices are unsigned 32-bit ints, NOT floats, NOT UNorm8/16. + const byte FormatUInt32 = 10; /// Attribute arrays for one mesh. Null/empty arrays mark absent attributes. public struct Arrays @@ -43,6 +49,25 @@ public struct Arrays /// Per-submesh triangle index lists (already the final winding). public int[][] SubMeshTriangles; + + // ---- Skinning (all three present together, or the mesh is treated as static) -------- + // Populated by the model compiler and the offline mesh harness, not within this assembly, + // so CS0649 ("never assigned") is expected here — it is not a defect. +#pragma warning disable CS0649 + /// Per-vertex bone weights (weight0..3) and indices (boneIndex0..3). + public BoneWeight[] BoneWeights; + + /// One bind pose per bone (m_BindPose); its length defines the bone count. + public Matrix4x4[] BindPoses; + + /// + /// One name per bone, index-aligned with , used to compute + /// m_BoneNameHashes. To reproduce Unity's exact stored hash the name must be the + /// bone's full transform path from the model root (e.g. globalMove01/joints01/bn_spA01); + /// see . + /// + public string[] BoneNames; +#pragma warning restore CS0649 } /// Build a from a live UnityEngine.Mesh (main thread only). @@ -86,6 +111,24 @@ public static unsafe MeshBlob FromArrays(string name, in Arrays a, Action 0; bool hasUv1 = Count(a.Uv1) == vertexCount && vertexCount > 0; + // A mesh is skinned when it carries per-vertex bone weights AND bind poses. Both are + // required together: BlendWeights/BlendIndices channels are meaningless without bind poses, + // and bind poses without weights can't bind vertices. A wrong-length BoneWeights array is + // dropped (treated as static) with a warning, like the other attributes above. + WarnIfWrongLength(name, "boneWeights", Count(a.BoneWeights), vertexCount, warn); + int boneCount = Count(a.BindPoses); + bool hasSkin = + Count(a.BoneWeights) == vertexCount && vertexCount > 0 && boneCount > 0; + + // The per-bone invariant Unity enforces (bindpose.count == boneNameHashes.count == + // bonesAABB.count) is the hard structural requirement, so BoneNames must supply exactly one + // name per bind pose. A missing/short array is a caller (compiler) bug — fail loud rather + // than emit a mesh Unity may reject. + if (hasSkin && Count(a.BoneNames) != boneCount) + throw new InvalidOperationException( + $"mesh '{name}': skinned mesh has {boneCount} bind pose(s) but " + + $"{Count(a.BoneNames)} bone name(s); BoneNames must be one-per-bone"); + // Assign channel offsets in attribute-index order for present attributes. v1 stores every // present attribute (including colour, from Color32/255 below) as float32 (format 0): this // is a deliberate v1 choice that is self-consistent with the emitted type tree and round- @@ -99,6 +142,13 @@ public static unsafe MeshBlob FromArrays(string name, in Arrays a, Action(); + uint[] boneNameHashes = Array.Empty(); + uint rootBoneNameHash = 0; + MeshBoneAABB[] bonesAABB = Array.Empty(); + if (hasSkin) + { + bindPose = a.BindPoses; + boneNameHashes = new uint[boneCount]; + for (int i = 0; i < boneCount; ++i) + boneNameHashes[i] = BoneNameHash(a.BoneNames[i]); + rootBoneNameHash = boneNameHashes[0]; + + // Conservative per-bone bounds: the whole-mesh AABB repeated. Per-bone tight bounds only + // affect culling, never skinning, so this is safe and keeps the invariant. + var boneMin = meshBounds.min; + var boneMax = meshBounds.max; + bonesAABB = new MeshBoneAABB[boneCount]; + for (int i = 0; i < boneCount; ++i) + bonesAABB[i] = new MeshBoneAABB(boneMin, boneMax); + } + return new MeshBlob { Name = name, @@ -192,20 +276,61 @@ public static unsafe MeshBlob FromArrays(string name, in Arrays a, Action(), + BindPose = bindPose, + BoneNameHashes = boneNameHashes, + RootBoneNameHash = rootBoneNameHash, + BonesAABB = bonesAABB, }; } - static void AddChannel(MeshChannel[] channels, int index, bool present, int dimension, ref int stride) + static void AddChannel( + MeshChannel[] channels, int index, bool present, int dimension, ref int stride, + byte format = FormatFloat32) { if (!present) return; - channels[index] = new MeshChannel((byte)0, (byte)stride, FormatFloat32, (byte)dimension); + channels[index] = new MeshChannel((byte)0, (byte)stride, format, (byte)dimension); + // Every format v1 emits (Float32=0 for all attributes, UInt32=10 for BlendIndices) is a + // 4-byte element, so the stride advance is dimension * 4 regardless of format. stride += dimension * 4; } static int Count(T[] array) => array?.Length ?? 0; + // Standard CRC-32 (ISO-HDLC / zlib: reflected, polynomial 0xEDB88320, init and final-xor + // 0xFFFFFFFF) of the UTF-8 bytes of . This is the exact algorithm Unity + // 2019.4 uses for m_BoneNameHashes / m_RootBoneNameHash — verified byte-for-byte against 542 + // real skinned meshes in KSP's sharedassets0.assets (e.g. all 35 bones of body01 and the EVA + // jetpack bone reproduced exactly). NOTE: Unity hashes the bone's FULL transform path from the + // model root (e.g. "globalMove01/joints01/bn_spA01"), not the bare leaf name, so the caller must + // pass that path in Arrays.BoneNames to match the native stored value. The stored value does not + // affect skinning (the compiler binds SkinnedMeshRenderer.bones by name at replay and the + // bindpose[i] <-> BlendIndices[i] <-> bones[i] correspondence is by index), so a leaf-only name + // still skins correctly; only the byte-parity of the hash field would differ. + static readonly uint[] Crc32Table = BuildCrc32Table(); + + static uint[] BuildCrc32Table() + { + var table = new uint[256]; + for (uint i = 0; i < 256; ++i) + { + uint c = i; + for (int k = 0; k < 8; ++k) + c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1; + table[i] = c; + } + return table; + } + + public static uint BoneNameHash(string name) + { + byte[] bytes = Encoding.UTF8.GetBytes(name ?? string.Empty); + uint crc = 0xFFFFFFFFu; + for (int i = 0; i < bytes.Length; ++i) + crc = (crc >> 8) ^ Crc32Table[(crc ^ bytes[i]) & 0xFF]; + return crc ^ 0xFFFFFFFFu; + } + // Warns only when an attribute array is actually present (non-null, non-empty) but its length // disagrees with the vertex count, i.e. it is about to be silently dropped. Cheap: no // allocation unless the (rare) warning path fires. When a sink is diff --git a/KSPCommunityFixes/Library/Model/MeshBundleBuilder.cs b/KSPCommunityFixes/Library/Model/MeshBundleBuilder.cs index 5d05254..0167509 100644 --- a/KSPCommunityFixes/Library/Model/MeshBundleBuilder.cs +++ b/KSPCommunityFixes/Library/Model/MeshBundleBuilder.cs @@ -124,7 +124,9 @@ static int EstimateSize(IReadOnlyList blobs) + (b.VertexData?.Length ?? 0) + (b.IndexData?.Length ?? 0) + (b.SubMeshes?.Length ?? 0) * 48 - + (b.BindPose?.Length ?? 0) * 64; + + (b.BindPose?.Length ?? 0) * 64 + + (b.BoneNameHashes?.Length ?? 0) * 4 + + (b.BonesAABB?.Length ?? 0) * 24; } return total > int.MaxValue ? int.MaxValue : (int)total; } @@ -161,10 +163,30 @@ static void WriteMeshBody(BundleBufferWriter w, MeshBlob mesh) } bindPose.End(align: true); - w.BeginArray().End(align: true); // m_BoneNameHashes : vector - w.WriteUInt32(0); // m_RootBoneNameHash - w.BeginArray().End(align: true); // m_BonesAABB : vector - w.BeginArray().End(align: true); // m_VariableBoneCountWeights.m_Data : vector + // m_BoneNameHashes : vector, Array aligns. One CRC32 per bone (empty for static). + var boneNameHashes = w.BeginArray(); + uint[] hashes = mesh.BoneNameHashes ?? Array.Empty(); + for (int i = 0; i < hashes.Length; ++i) + { + boneNameHashes.Add(); + w.WriteUInt32(hashes[i]); + } + boneNameHashes.End(align: true); + + w.WriteUInt32(mesh.RootBoneNameHash); // m_RootBoneNameHash + + // m_BonesAABB : vector, Array aligns. One 24-byte min/max per bone (empty for + // static). Element metaFlags carry no align-after bit, so no per-element padding. + var bonesAABB = w.BeginArray(); + MeshBoneAABB[] aabbs = mesh.BonesAABB ?? Array.Empty(); + for (int i = 0; i < aabbs.Length; ++i) + { + bonesAABB.Add(); + WriteMinMaxAABB(w, aabbs[i]); + } + bonesAABB.End(align: true); + + w.BeginArray().End(align: true); // m_VariableBoneCountWeights.m_Data : vector (empty) w.WriteByte(0); // m_MeshCompression (0 == uncompressed) w.WriteBool(true); // m_IsReadable — keep the CPU copy (stock KSP behaviour) @@ -238,6 +260,20 @@ static void WriteAABB(BundleBufferWriter w, in Bounds bounds) w.WriteSingle(e.z); } + // MinMaxAABB (m_BonesAABB element): two Vector3f (m_Min, m_Max), 24 bytes. Distinct from the + // AABB (m_Center/m_Extent) written by WriteAABB. + static void WriteMinMaxAABB(BundleBufferWriter w, in MeshBoneAABB a) + { + Vector3 min = a.Min; + Vector3 max = a.Max; + w.WriteSingle(min.x); + w.WriteSingle(min.y); + w.WriteSingle(min.z); + w.WriteSingle(max.x); + w.WriteSingle(max.y); + w.WriteSingle(max.z); + } + static void WriteMatrix4x4(BundleBufferWriter w, in Matrix4x4 m) { // Unity serializes e00,e01,...,e33 (row-major field names). Matrix4x4 indexer m[row,col] From 252f2b9df52e3f065287f98d5f44be13e4e6ee4e Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 14 Jul 2026 14:59:10 -0700 Subject: [PATCH 08/14] Compile skinned meshes through the bundle path (retire the fallback flag) Make MuModelCompiler populate skin data instead of discarding it, so skinned models load via the background bundle pipeline rather than the temporary MuParser fallback: - ReadMesh stores MeshBoneWeights -> Arrays.BoneWeights and MeshBindPoses -> Arrays.BindPoses (same read order/cursor as before). - The SkinnedMeshRenderer block's bone-name list is threaded into the mesh's Arrays.BoneNames (currentBoneNames field, set before ReadMesh, cleared after), index-aligned with the bind poses. The same bone-name list feeds both the mesh's cosmetic BoneNameHashes and the ResolveBones binding, so bindpose[i] <-> BlendIndices==i <-> bones[i] <-> BoneNameHashes[i] all match. - ContainsSkinnedMesh is never set now (MarkSkinned removed), so skinned models classify as CompiledMu and flow through the bundle path; the pipeline's Kind.Skinned fallback branch is dead (removed with MuParser later). - ReconcileBoneNames pads the cosmetic hash-source list to the bind-pose count for the rare .mu where an SMR declares fewer bone names than the mesh has bind poses (e.g. stock TriBitDrill, restock-claw), preserving Unity's per-bone count invariant while ResolveBones still binds from the original list - byte-for-byte MuParser-equivalent. Removed the now-resolved CS0649 pragma on the Arrays skin fields. Offline-validated over the real corpus: 0 failures, ContainsSkinnedMesh==0, 164 models now serialize skinned meshes (269 skinned blobs) with 0 structural violations; sampled real skinned bundles are byte-structurally valid (ch12=fmt0/dim4, ch13=fmt10/dim4, bind-pose/hash/AABB invariant); static output unchanged. Whether Unity deforms from this single-stream layout is the in-KSP gate (task #7). --- .../Library/Model/MeshBlobBuilder.cs | 9 +- .../Library/Model/MuModelCompiler.cs | 124 ++++++++++++------ 2 files changed, 91 insertions(+), 42 deletions(-) diff --git a/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs b/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs index a191dc1..c5120d1 100644 --- a/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs +++ b/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs @@ -51,9 +51,8 @@ public struct Arrays public int[][] SubMeshTriangles; // ---- Skinning (all three present together, or the mesh is treated as static) -------- - // Populated by the model compiler and the offline mesh harness, not within this assembly, - // so CS0649 ("never assigned") is expected here — it is not a defect. -#pragma warning disable CS0649 + // Assigned by MuModelCompiler (in-assembly) when it reads a skinned .mu mesh, and by the + // offline mesh harness for the synthetic skinned case. /// Per-vertex bone weights (weight0..3) and indices (boneIndex0..3). public BoneWeight[] BoneWeights; @@ -64,10 +63,10 @@ public struct Arrays /// One name per bone, index-aligned with , used to compute /// m_BoneNameHashes. To reproduce Unity's exact stored hash the name must be the /// bone's full transform path from the model root (e.g. globalMove01/joints01/bn_spA01); - /// see . + /// see . The model compiler passes the .mu's leaf bone names + /// (what the runtime binds by), so the stored hash is cosmetic — only the array count matters. /// public string[] BoneNames; -#pragma warning restore CS0649 } /// Build a from a live UnityEngine.Mesh (main thread only). diff --git a/KSPCommunityFixes/Library/Model/MuModelCompiler.cs b/KSPCommunityFixes/Library/Model/MuModelCompiler.cs index 4ea9e40..8514221 100644 --- a/KSPCommunityFixes/Library/Model/MuModelCompiler.cs +++ b/KSPCommunityFixes/Library/Model/MuModelCompiler.cs @@ -65,8 +65,12 @@ internal sealed unsafe class MuModelCompiler // Skinned renderers awaiting a ResolveBones step (mirrors MuParser's boneDummies). private readonly List skinnedRenderers = new List(); - private bool containsSkinnedMesh; - private bool skinnedLogged; + // Bone names for the SkinnedMeshRenderer currently being parsed. ReadSkinnedMeshRenderer sets + // this to the SMR's bone-name list immediately BEFORE it calls ReadMesh (which reads the matching + // bind poses) and clears it (null) right AFTER, so ReadMesh can attach the index-aligned bone-name + // list to the skinned mesh blob it builds. Null whenever a mesh is read outside a skinned renderer + // (MeshFilter / collider meshes), which never carry bind poses. + private string[] currentBoneNames; // Diagnostics buffered during compilation. Compile runs on background worker threads (via a // parallel PLINQ query over all models), and KSP's ILogHandler plus the mod handlers chained onto @@ -137,7 +141,10 @@ public CompiledModel Compile(string fileUrl, string directoryUrl, byte[] data, i Blobs = blobs.ToArray(), Bindings = bindings.ToArray(), LocalCount = nextSlot, - ContainsSkinnedMesh = containsSkinnedMesh, + // Skinned meshes are now fully serialized (blend channels + bind pose + bone + // metadata) and flow through the background bundle path like any static model, so + // this is never set — the FastLoader skinned/MuParser fallback is no longer taken. + ContainsSkinnedMesh = false, Failed = false, Logs = logs, // flushed on the main thread by the replay pipeline (see FlushLogs) }; @@ -173,8 +180,7 @@ private void ResetState() pendingMaterials.Clear(); textureSlots.Clear(); skinnedRenderers.Clear(); - containsSkinnedMesh = false; - skinnedLogged = false; + currentBoneNames = null; // Fresh list per Compile so a returned CompiledModel keeps its own buffered diagnostics even // after this instance is reused for the next file. warnSink closes over this field, so it // automatically targets the new list. @@ -571,13 +577,14 @@ private void ReadMeshRenderer(int goSlot) } } - /// Mirror of MuParser.ReadSkinnedMeshRenderer (opcode 9). Marks the model as - /// containing a skinned mesh (v1 pipeline falls back to MuParser) but still parses everything so - /// the cursor stays valid, and records a deferred step. + /// Mirror of MuParser.ReadSkinnedMeshRenderer (opcode 9): reads the material + /// fan-out, local bounds / quality / updateWhenOffscreen, the bone NAMES and the mesh; emits an + /// and records a deferred step. The + /// bone names are threaded into (via ) so the + /// skinned mesh blob carries them index-aligned with its bind poses; the SAME list also drives the + /// bone binding in , so hashes and binding stay consistent. private void ReadSkinnedMeshRenderer(int goSlot) { - MarkSkinned(); - int smrSlot = nextSlot++; int rendererCount = reader.ReadInt(); @@ -599,7 +606,13 @@ private void ReadSkinnedMeshRenderer(int goSlot) for (int j = 0; j < boneCount; j++) boneNames[j] = reader.ReadString(); + // Hand the SMR's bone names to ReadMesh so the skinned mesh blob it builds attaches them + // index-aligned with the bind poses it reads (BoneNames[i] <-> BindPoses[i]). MuParser reads + // this SMR's mesh immediately after its bone names, so the pairing matches the oracle. Cleared + // right after so a later non-skinned ReadMesh can't pick up stale names. + currentBoneNames = boneNames; int meshSlot = ReadMesh(); + currentBoneNames = null; instructions.Add(new AddSkinnedMeshRenderer { @@ -964,8 +977,10 @@ private void ReadParticles(int goSlot) /// Mirror of MuParser.ReadMesh (opcode MeshStart): reads the sub-blocks in file /// order into a , builds a , records a - /// and returns the allocated mesh slot. Bone weights / bind poses are - /// consumed (cursor parity) but discarded, and mark the model skinned. + /// and returns the allocated mesh slot. Bone weights / bind poses (when + /// present) are stored into the arrays; together with the SMR bone names threaded in via + /// they let emit a fully + /// skinned mesh (blend channels, bind pose and bone metadata). private int ReadMesh() { EntryType entryType = (EntryType)reader.ReadInt(); @@ -1044,19 +1059,26 @@ private int ReadMesh() } case EntryType.MeshBoneWeights: { - // Skinned seam: consume exactly (one BoneWeight per vertex) so the cursor stays - // valid, but discard — Arrays has no bone fields and this model falls back. - MarkSkinned(); + // Skinned seam: one BoneWeight per vertex (four weights + four bone indices). + // Stored now (was discarded before skin support) so MeshBlobBuilder emits the + // BlendWeights (ch12) and BlendIndices (ch13) vertex channels. Same read order and + // count as the oracle, so cursor parity is preserved. + var boneWeights = new BoneWeight[size]; for (int i = 0; i < size; i++) - reader.ReadBoneWeight(); + boneWeights[i] = reader.ReadBoneWeight(); + arrays.BoneWeights = boneWeights; break; } case EntryType.MeshBindPoses: { - MarkSkinned(); + // One bind pose per bone; its length defines the bone count. Stored now (was + // discarded before skin support) so the blob carries m_BindPose and its bone + // metadata. Same read order and count as the oracle, so cursor parity is preserved. int bindPosesCount = reader.ReadInt(); + var bindPoses = new Matrix4x4[bindPosesCount]; for (int i = 0; i < bindPosesCount; i++) - reader.ReadMatrix4x4(); + bindPoses[i] = reader.ReadMatrix4x4(); + arrays.BindPoses = bindPoses; break; } } @@ -1064,6 +1086,17 @@ private int ReadMesh() arrays.SubMeshTriangles = triangles.ToArray(); + // Skinned mesh: attach the SMR's bone names (set by ReadSkinnedMeshRenderer just before this + // call) index-aligned with the bind poses just read, so BoneNames.Length == BindPoses.Length. + // FromArrays hashes them into m_BoneNameHashes. These are the .mu's LEAF bone names — exactly + // what ResolveBones/FindChildByName binds SkinnedMeshRenderer.bones by at replay. Unity + // natively hashes each bone's FULL transform path, so the emitted hash won't byte-match Unity's + // stored value; that is COSMETIC (binding is by name, and only the per-bone array COUNT is + // structurally required). Full-path reconstruction is a possible future refinement, only if an + // in-KSP issue ever implicates the hash value. + if (arrays.BindPoses != null) + arrays.BoneNames = ReconcileBoneNames(currentBoneNames, arrays.BindPoses.Length, canonicalName); + MeshBlob blob = MeshBlobBuilder.FromArrays(canonicalName, in arrays, warnSink); blobs.Add(blob); @@ -1150,6 +1183,42 @@ private void FinalizeBones() } } + /// + /// Returns a bone-name array whose length equals the mesh's bind-pose count — the count + /// requires to satisfy Unity's per-bone invariant + /// (m_BindPose / m_BoneNameHashes / m_BonesAABB all equal). In the normal + /// case the SkinnedMeshRenderer supplies exactly one bone name per bind pose and the same array is + /// returned unchanged (no allocation). A few stock/mod .mu files export a + /// SkinnedMeshRenderer whose bone-name list is shorter than (often empty relative to) the + /// mesh's bind poses; MuParser tolerates this (it simply sets a shorter — possibly empty — + /// SkinnedMeshRenderer.bones, so the mesh is not actually deformed), so rather than fail the + /// whole model we pad the mesh's (cosmetic, leaf-name) hash source with empty names to keep the + /// count invariant. Binding is unaffected: still emits + /// from the ORIGINAL SMR name list, so the runtime + /// SkinnedMeshRenderer.bones stays byte-for-byte MuParser-equivalent — only the mesh's + /// ignored hash values gain padding entries. + /// + private string[] ReconcileBoneNames(string[] smrBoneNames, int boneCount, string meshName) + { + int have = smrBoneNames?.Length ?? 0; + if (have == boneCount) + return smrBoneNames ?? Array.Empty(); + + var names = new string[boneCount]; + int copy = Math.Min(have, boneCount); + for (int i = 0; i < copy; i++) + names[i] = smrBoneNames[i]; + for (int i = copy; i < boneCount; i++) + names[i] = string.Empty; + + logs.Add(new DeferredLog(LogType.Log, + $"[MuModelCompiler] Model '{fileUrl}' mesh '{meshName}': SkinnedMeshRenderer declares " + + $"{have} bone name(s) but the mesh has {boneCount} bind pose(s). This is an unusual but " + + "valid .mu; reconciling by padding the mesh's cosmetic bone-name/hash list to preserve " + + "Unity's per-bone count invariant. This is benign - bone binding is unaffected.")); + return names; + } + // ---- Small helpers ------------------------------------------------------------------------- private void EnsureMatRef(int index) @@ -1167,25 +1236,6 @@ private static void AddFloat(PendingMaterial pm, string name, float value) => private static void AddVector(PendingMaterial pm, string name, Vector4 value) => pm.Values.Add(new ValueProp { Name = name, Kind = ValueProp.KindVector, VecVal = value }); - private void MarkSkinned() - { - containsSkinnedMesh = true; - if (!skinnedLogged) - { - skinnedLogged = true; - // This notice, like the compiler's other diagnostics (the mesh-error log and the - // texCount-mismatch guard), is BUFFERED into the CompiledModel rather than logged here. - // Compile runs on background worker threads (a parallel PLINQ query over all models), and - // KSP's ILogHandler plus the mod handlers chained onto Application.logMessageReceived are - // NOT thread-safe — calling Debug.Log* off-thread could corrupt their state or crash. The - // buffered messages are emitted safely on the main thread by CompiledModel.FlushLogs during - // replay. (MeshBlobBuilder's attribute-length warnings are likewise routed through warnSink.) - logs.Add(new DeferredLog(LogType.Log, - $"[MuModelCompiler] Model '{fileUrl}' contains a skinned mesh; the v1 pipeline " + - "will fall back to MuParser.Parse for this file.")); - } - } - // ---- Private accumulator types ------------------------------------------------------------- /// A material parsed but not yet emitted (its texture urls resolve after the walk). From 0cd3fc1647b89fefdb3297fc92f9b2fdc6b05a35 Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 14 Jul 2026 16:04:29 -0700 Subject: [PATCH 09/14] Fix shared AssetBundleCreateRequest yielded from multiple coroutines Each ModelGroup's AssetBundleCreateRequest is shared by all its model coroutines, and the driver runs up to MaxModelSpawnsPerFrame of them at once. Yielding one AsyncOperation from multiple coroutines is forbidden by Unity ('This asynchronous operation is already being yielded from another coroutine') - it produced 33 errors on the first in-KSP load. Poll CreateRequest.isDone with 'yield return null' instead of yielding the shared op; each coroutine waits independently. Per-mesh LoadAssetAsync results are unique per coroutine and still yielded directly. Verified in-KSP: 0 async errors (was 33), 1573 models load, MeshSelfTest PASS, main menu reached. --- KSPCommunityFixes/Performance/FastLoader.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/KSPCommunityFixes/Performance/FastLoader.cs b/KSPCommunityFixes/Performance/FastLoader.cs index 4a70d86..3485d91 100644 --- a/KSPCommunityFixes/Performance/FastLoader.cs +++ b/KSPCommunityFixes/Performance/FastLoader.cs @@ -2006,8 +2006,13 @@ private static IEnumerator LoadCompiledModelCoroutine(ModelLoadRequest req) while (g.CreateRequest == null) yield return null; - if (!g.CreateRequest.isDone) - yield return g.CreateRequest; + // POLL isDone; do NOT `yield return g.CreateRequest`. This AssetBundleCreateRequest is SHARED by + // every model coroutine in the group (created once by ModelBundlePumpCoroutine), and the driver + // runs up to MaxModelSpawnsPerFrame of them concurrently. Unity forbids yielding one async op + // from more than one coroutine ("...already being yielded from another coroutine"), so each + // coroutine waits independently by yielding null until the shared op reports done. + while (!g.CreateRequest.isDone) + yield return null; AssetBundle bundle = g.CreateRequest.assetBundle; if (bundle == null) From af4f2ab77802941460d6931b2867852faaac21c8 Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 14 Jul 2026 16:43:02 -0700 Subject: [PATCH 10/14] Add DEBUG in-KSP diff harness (new model path vs MuParser) Main-menu #if DEBUG addon that, for a sample of real .mu (default 60 spread + all skinned/robotic/complex parts forced in), builds each model both via MuParser.Parse (oracle) and via the new Compile->BuildMany->LoadAssetAsync-> replay path, then diffs hierarchy (names/TRS/component types), mesh data (verts/normals/tangents/uv/uv2/colors/triangles/bounds), and skin (bindposes/boneWeights/bones). Logs [ModelDiff] PASS/FAIL + SUMMARY. Verified in-KSP: 183/183 passed (60 sampled + 123 complex) - the bundle path reproduces MuParser exactly, including skinned meshes (boneWeights/bindposes read back identical, proving channels 12/13 deserialize correctly). This is the semantic-parity gate; the harness (and MuParser) are removed in the cleanup task. --- .../Library/Model/ModelDiffHarness.cs | 563 ++++++++++++++++++ 1 file changed, 563 insertions(+) create mode 100644 KSPCommunityFixes/Library/Model/ModelDiffHarness.cs diff --git a/KSPCommunityFixes/Library/Model/ModelDiffHarness.cs b/KSPCommunityFixes/Library/Model/ModelDiffHarness.cs new file mode 100644 index 0000000..68cf86c --- /dev/null +++ b/KSPCommunityFixes/Library/Model/ModelDiffHarness.cs @@ -0,0 +1,563 @@ +#if DEBUG +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text; +using KSPCommunityFixes.Library; +using UnityEngine; + +namespace KSPCommunityFixes.Library.Model +{ + /// + /// DEBUG-only semantic-parity gate for the background model-loading pipeline. For a sample of real + /// .mu models it builds the SAME model two ways from the SAME on-disk bytes: + /// + /// Oracle: (the original main-thread parser that builds + /// UnityEngine.Objects directly). + /// New path: -> -> + /// AssetBundle.LoadFromMemoryAsync -> per-mesh LoadAssetAsync -> replay of the compiled + /// list. + /// + /// It then diffs the two GameObject hierarchies (structure + transforms + component types) and every + /// mesh (geometry, submeshes, bounds, and — the key skinned check — bind poses, bone weights, and the + /// resolved SkinnedMeshRenderer.bones). This is the parity proof required before + /// is deleted. Results are grep-friendly under the [ModelDiff] sentinel. + /// Runs at the main menu (GameDatabase loaded, so textures/shaders resolve for the new path's + /// material instructions, and the .mu files are on disk). DEBUG-only, so it never ships. + /// + [KSPAddon(KSPAddon.Startup.MainMenu, true)] + internal sealed class ModelDiffHarness : MonoBehaviour + { + const string Tag = "[ModelDiff]"; + + // ---- Tweakables -------------------------------------------------------------------------- + + /// How many models to spread-sample across the whole (optionally filtered) .mu set. + /// Forced complex/skinned models (see ) are added ON TOP of this. + const int SampleCount = 60; + + /// Optional case-insensitive url-substring filter. When non-empty, only .mu files whose + /// url contains it are considered (e.g. "Squad/Parts/Aero" or a single part name to focus a run). + /// Null/empty = whole database. + const string UrlFilter = null; + + /// Any .mu whose url contains one of these (case-insensitive) is force-included regardless + /// of the spread sample — these bias toward skinned/animated/complex parts, which exercise the + /// riskiest parts of the new path (skinned channels, bones, animation, many submeshes). + static readonly string[] ComplexKeywords = + { + "landingleg", "landinggear", "gearbay", "robot", "hinge", "rotor", "piston", "drill", + "claw", "solar", "panel", "radiator", "kerbaleva", "kerbalgirl", "serenity", + }; + + // ---- Epsilons ---------------------------------------------------------------------------- + + // Geometry attributes round-trip through float32 with identical bits on both paths, so these are + // effectively exact-equality checks with a tiny safety margin. Bounds get a looser tolerance + // because the oracle uses Unity's RecalculateBounds while the new path computes its own AABB. + const float VecEpsSq = 1e-8f; // squared per-vertex vector tolerance (pos/normal/tangent/uv) + const float QuatEps = 1e-6f; // 1 - |dot| tolerance for localRotation + const float MatEps = 1e-5f; // per-element bind-pose matrix tolerance + const float WeightEps = 1e-5f; // per-vertex bone weight tolerance + const float BoundsEps = 1e-2f; // absolute floor for the (relative) mesh-bounds tolerance + + // The component types whose PRESENCE (and count) must match per node. SkinnedMeshRenderer and + // MeshRenderer are distinct concrete types (both derive from Renderer, neither from the other), so + // each is queried separately and unambiguously. + static readonly (string Name, Type Type)[] Tracked = + { + ("MeshFilter", typeof(MeshFilter)), + ("MeshRenderer", typeof(MeshRenderer)), + ("SkinnedMeshRenderer", typeof(SkinnedMeshRenderer)), + ("MeshCollider", typeof(MeshCollider)), + ("BoxCollider", typeof(BoxCollider)), + ("SphereCollider", typeof(SphereCollider)), + ("CapsuleCollider", typeof(CapsuleCollider)), + ("WheelCollider", typeof(WheelCollider)), + ("Light", typeof(Light)), + ("Camera", typeof(Camera)), + ("KSPParticleEmitter", typeof(KSPParticleEmitter)), + ("Animation", typeof(Animation)), + }; + + IEnumerator Start() + { + GameDatabase gdb = GameDatabase.Instance; + if (gdb == null || gdb.root == null) + { + Debug.LogError($"{Tag} GameDatabase not available; aborting."); + yield break; + } + + // 1) Collect candidate .mu files (optionally filtered). + var all = new List(); + foreach (UrlDir.UrlFile f in gdb.root.AllFiles) + { + if (f == null || f.fileExtension != "mu") + continue; + if (!string.IsNullOrEmpty(UrlFilter) && f.url.IndexOf(UrlFilter, StringComparison.OrdinalIgnoreCase) < 0) + continue; + all.Add(f); + } + + if (all.Count == 0) + { + Debug.LogWarning($"{Tag} no .mu models found (filter='{UrlFilter}'); nothing to do."); + yield break; + } + + // Spread-sample every Nth, then force-include complex/skinned parts on top (deduped by url). + var selected = new List(); + var seen = new HashSet(StringComparer.Ordinal); + int step = Mathf.Max(1, all.Count / Mathf.Max(1, SampleCount)); + for (int i = 0; i < all.Count && selected.Count < SampleCount; i += step) + if (seen.Add(all[i].url)) + selected.Add(all[i]); + + int sampled = selected.Count; + int forced = 0; + for (int i = 0; i < all.Count; i++) + if (ContainsKeyword(all[i].url) && seen.Add(all[i].url)) + { + selected.Add(all[i]); + forced++; + } + + Debug.Log($"{Tag} START: {selected.Count} models ({sampled} sampled + {forced} forced complex) " + + $"of {all.Count} .mu files. SampleCount={SampleCount}, filter='{UrlFilter}'."); + + var compiler = new MuModelCompiler(); + var failures = new List(); + int passed = 0; + int total = 0; + + foreach (UrlDir.UrlFile file in selected) + { + total++; + string url = file.url; + + GameObject a = null; + GameObject b = null; + AssetBundle bundle = null; + CompiledModel cm = null; + UnityEngine.Object[] locals = null; + byte[] bytes; + byte[] bundleBytes = null; + string fail = null; + string mismatch = null; + + // Phase A (synchronous, may throw): read bytes, build the oracle, compile, bake bundle + // bytes. Kept out of the yield path so it can be guarded by try/catch (C# forbids yield + // inside a try that has a catch). + try + { + bytes = System.IO.File.ReadAllBytes(file.fullPath); + a = MuParser.Parse(file.parent.url, bytes, bytes.Length); + cm = compiler.Compile(file.url, file.parent.url, bytes, bytes.Length); + if (cm.Failed) + fail = "compile failed: " + cm.FailureMessage; + else + // Null when the model has no static meshes at all (nothing to bundle). + bundleBytes = MeshBundleBuilder.BuildMany(cm.Blobs); + } + catch (Exception e) + { + fail = "exception (build): " + e.GetType().Name + ": " + e.Message; + } + + // Phase B (async): load the mesh bundle. Outside try/catch (contains a yield). + if (fail == null && bundleBytes != null) + { + AssetBundleCreateRequest createReq = AssetBundle.LoadFromMemoryAsync(bundleBytes); + yield return createReq; + bundle = createReq.assetBundle; + if (bundle == null) + fail = "AssetBundle.LoadFromMemoryAsync returned a null bundle"; + } + + // Phase C (async): place each loaded mesh into its locals slot. + if (fail == null) + { + locals = new UnityEngine.Object[cm.LocalCount]; + if (bundle != null) + { + MeshBinding[] bindings = cm.Bindings; + for (int i = 0; i < bindings.Length; i++) + { + AssetBundleRequest lr = bundle.LoadAssetAsync(bindings[i].CanonicalName); + yield return lr; + locals[bindings[i].Slot] = lr.asset; + } + } + } + + // Phase D (synchronous, may throw): replay the instructions and diff a vs b. + if (fail == null) + { + try + { + IModelInstruction[] ins = cm.Instructions; + for (int i = 0; i < ins.Length; i++) + ins[i].Execute(locals); + b = locals.Length > 0 ? locals[0] as GameObject : null; + mismatch = DiffModels(a, b); + } + catch (Exception e) + { + fail = "exception (replay/diff): " + e.GetType().Name + ": " + e.Message + + "\n" + e.StackTrace; + } + } + + // Surface any diagnostics the compiler buffered off-thread (main thread only). + if (cm != null) + cm.FlushLogs(); + + if (fail == null && mismatch == null) + { + passed++; + Debug.Log($"{Tag} PASS {url}"); + } + else + { + string reason = fail ?? mismatch; + failures.Add(url + ": " + reason); + Debug.LogError($"{Tag} FAIL {url}: {reason}"); + } + + // Cleanup. b's meshes are owned by the bundle and freed by Unload(true); the oracle's + // meshes are standalone Unity objects that would otherwise leak, so destroy them too. + if (a != null) + { + DestroyOracleMeshes(a); + UnityEngine.Object.Destroy(a); + } + if (b != null) + UnityEngine.Object.Destroy(b); + // A partial tree left behind by an exception mid-replay (b never assigned) is still rooted + // at locals[0]; destroy it so it doesn't leak. + else if (locals != null && locals.Length > 0 && locals[0] is GameObject partial) + UnityEngine.Object.Destroy(partial); + if (bundle != null) + bundle.Unload(true); + + yield return null; // spread the work across frames to avoid a long main-menu hitch + } + + MuParser.ReleaseBuffers(); + + Debug.Log($"{Tag} SUMMARY: {passed}/{total} passed"); + if (failures.Count > 0) + { + Debug.LogError($"{Tag} {failures.Count} FAILED:"); + for (int i = 0; i < failures.Count; i++) + Debug.LogError($"{Tag} {failures[i]}"); + } + } + + static bool ContainsKeyword(string url) + { + for (int i = 0; i < ComplexKeywords.Length; i++) + if (url.IndexOf(ComplexKeywords[i], StringComparison.OrdinalIgnoreCase) >= 0) + return true; + return false; + } + + // ---- Diff: hierarchy then meshes --------------------------------------------------------- + + /// Returns the FIRST mismatch between the oracle tree and the new-path + /// tree , or null if they are semantically identical. Hierarchy divergence is + /// reported first (mesh matching is meaningless once structure diverges). + static string DiffModels(GameObject a, GameObject b) + { + if (a == null && b == null) return "both roots null (nothing built)"; + if (a == null) return "oracle (MuParser) produced a null root"; + if (b == null) return "new path produced a null root"; + + var pairs = new List(); + string hm = DfsMatch(a.transform, b.transform, a.transform.name, pairs); + if (hm != null) + return "hierarchy: " + hm; + + // Structure matches, so the i-th node of each tree is the same logical node; compare meshes. + for (int i = 0; i < pairs.Count; i++) + { + NodePair p = pairs[i]; + string m; + + m = CompareMeshPair(p.Path + " MeshFilter.sharedMesh", + Sm(p.A.GetComponent()), Sm(p.B.GetComponent())); + if (m != null) return "mesh: " + m; + + m = CompareMeshPair(p.Path + " MeshCollider.sharedMesh", + Sm(p.A.GetComponent()), Sm(p.B.GetComponent())); + if (m != null) return "mesh: " + m; + + SkinnedMeshRenderer sa = p.A.GetComponent(); + SkinnedMeshRenderer sb = p.B.GetComponent(); + if (sa != null || sb != null) + { + m = CompareMeshPair(p.Path + " SkinnedMeshRenderer.sharedMesh", + sa != null ? sa.sharedMesh : null, sb != null ? sb.sharedMesh : null); + if (m != null) return "mesh: " + m; + + m = CompareBones(p.Path, sa, sb); + if (m != null) return "skin: " + m; + } + } + + return null; + } + + /// Parallel DFS. Verifies name, local TRS, and the tracked component-type multiset at each + /// node, and that child counts match, recording each matched (a,b) node pair for the mesh pass. + /// Returns the first structural mismatch (with a path), or null. + static string DfsMatch(Transform a, Transform b, string path, List pairs) + { + if (a.name != b.name) + return $"{path}: name '{a.name}' != '{b.name}'"; + if ((a.localPosition - b.localPosition).sqrMagnitude > VecEpsSq) + return $"{path}: localPosition {S(a.localPosition)} != {S(b.localPosition)}"; + if (1f - Mathf.Abs(Quaternion.Dot(a.localRotation, b.localRotation)) > QuatEps) + return $"{path}: localRotation {S(a.localRotation)} != {S(b.localRotation)}"; + if ((a.localScale - b.localScale).sqrMagnitude > VecEpsSq) + return $"{path}: localScale {S(a.localScale)} != {S(b.localScale)}"; + + string sigA = ComponentSig(a.gameObject); + string sigB = ComponentSig(b.gameObject); + if (sigA != sigB) + return $"{path}: components [{sigA}] != [{sigB}]"; + + pairs.Add(new NodePair(a, b, path)); + + if (a.childCount != b.childCount) + return $"{path}: childCount {a.childCount} != {b.childCount}"; + + for (int i = 0; i < a.childCount; i++) + { + Transform ca = a.GetChild(i); + Transform cb = b.GetChild(i); + string m = DfsMatch(ca, cb, path + "/" + ca.name, pairs); + if (m != null) + return m; + } + + return null; + } + + static string ComponentSig(GameObject go) + { + var sb = new StringBuilder(); + for (int i = 0; i < Tracked.Length; i++) + { + int count = go.GetComponents(Tracked[i].Type).Length; + if (count > 0) + { + if (sb.Length > 0) sb.Append(','); + sb.Append(Tracked[i].Name).Append(':').Append(count); + } + } + return sb.ToString(); + } + + static Mesh Sm(MeshFilter c) => c != null ? c.sharedMesh : null; + static Mesh Sm(MeshCollider c) => c != null ? c.sharedMesh : null; + + /// Compares a matched pair of meshes, handling the null cases. Returns null when both are + /// absent or both compare equal; otherwise the first mismatch (prefixed with ). + static string CompareMeshPair(string prefix, Mesh a, Mesh b) + { + bool aNull = a == null; + bool bNull = b == null; + if (aNull && bNull) return null; + if (aNull != bNull) + return $"{prefix}: present on oracle={!aNull}, on new={!bNull}"; + string m = CompareMesh(a, b); + return m == null ? null : prefix + ": " + m; + } + + /// Field-by-field mesh comparison in a fixed order; returns the first differing field + /// (name + index + values) or null. Bind poses / bone weights are compared whenever EITHER mesh + /// carries them — that is the load-bearing skinned check (equality proves Unity deserialized the + /// BlendWeights/BlendIndices channels 12/13 correctly). + static string CompareMesh(Mesh a, Mesh b) + { + if (a.vertexCount != b.vertexCount) + return $"vertexCount {a.vertexCount} != {b.vertexCount}"; + + string m; + if ((m = CmpV3("vertices", a.vertices, b.vertices)) != null) return m; + if ((m = CmpV3("normals", a.normals, b.normals)) != null) return m; + if ((m = CmpV4("tangents", a.tangents, b.tangents)) != null) return m; + if ((m = CmpC32("colors32", a.colors32, b.colors32)) != null) return m; + if ((m = CmpV2("uv", a.uv, b.uv)) != null) return m; + if ((m = CmpV2("uv2", a.uv2, b.uv2)) != null) return m; + + if (a.subMeshCount != b.subMeshCount) + return $"subMeshCount {a.subMeshCount} != {b.subMeshCount}"; + for (int s = 0; s < a.subMeshCount; s++) + if ((m = CmpInt($"triangles[{s}]", a.GetTriangles(s), b.GetTriangles(s))) != null) return m; + + // Looser: oracle uses RecalculateBounds, new path computes its own AABB from the same verts. + if ((m = CmpBounds("bounds", a.bounds, b.bounds)) != null) return m; + + Matrix4x4[] abp = a.bindposes; + Matrix4x4[] bbp = b.bindposes; + if (abp.Length > 0 || bbp.Length > 0) + if ((m = CmpMat("bindposes", abp, bbp)) != null) return m; + + BoneWeight[] abw = a.boneWeights; + BoneWeight[] bbw = b.boneWeights; + if (abw.Length > 0 || bbw.Length > 0) + if ((m = CmpBoneWeights("boneWeights", abw, bbw)) != null) return m; + + return null; + } + + /// Compares the resolved SkinnedMeshRenderer.bones (count + bound transform names in + /// order) — confirms ResolveBones/AffectSkinnedMeshRenderersBones bound the same bones. + static string CompareBones(string path, SkinnedMeshRenderer a, SkinnedMeshRenderer b) + { + Transform[] ba = a != null ? a.bones : Array.Empty(); + Transform[] bb = b != null ? b.bones : Array.Empty(); + if (ba.Length != bb.Length) + return $"{path} SMR.bones length {ba.Length} != {bb.Length}"; + for (int i = 0; i < ba.Length; i++) + { + string na = ba[i] != null ? ba[i].name : ""; + string nb = bb[i] != null ? bb[i].name : ""; + if (na != nb) + return $"{path} SMR.bones[{i}] '{na}' != '{nb}'"; + } + return null; + } + + // ---- Field comparison helpers (return the first mismatch string, or null) ---------------- + + static string CmpV3(string n, Vector3[] a, Vector3[] b) + { + if (a.Length != b.Length) return $"{n} length {a.Length} != {b.Length}"; + for (int i = 0; i < a.Length; i++) + if ((a[i] - b[i]).sqrMagnitude > VecEpsSq) return $"{n}[{i}] {S(a[i])} != {S(b[i])}"; + return null; + } + + static string CmpV4(string n, Vector4[] a, Vector4[] b) + { + if (a.Length != b.Length) return $"{n} length {a.Length} != {b.Length}"; + for (int i = 0; i < a.Length; i++) + if ((a[i] - b[i]).sqrMagnitude > VecEpsSq) return $"{n}[{i}] {S(a[i])} != {S(b[i])}"; + return null; + } + + static string CmpV2(string n, Vector2[] a, Vector2[] b) + { + if (a.Length != b.Length) return $"{n} length {a.Length} != {b.Length}"; + for (int i = 0; i < a.Length; i++) + if ((a[i] - b[i]).sqrMagnitude > VecEpsSq) return $"{n}[{i}] {S(a[i])} != {S(b[i])}"; + return null; + } + + static string CmpC32(string n, Color32[] a, Color32[] b) + { + if (a.Length != b.Length) return $"{n} length {a.Length} != {b.Length}"; + for (int i = 0; i < a.Length; i++) + if (a[i].r != b[i].r || a[i].g != b[i].g || a[i].b != b[i].b || a[i].a != b[i].a) + return $"{n}[{i}] ({a[i].r},{a[i].g},{a[i].b},{a[i].a}) != ({b[i].r},{b[i].g},{b[i].b},{b[i].a})"; + return null; + } + + static string CmpInt(string n, int[] a, int[] b) + { + if (a.Length != b.Length) return $"{n} length {a.Length} != {b.Length}"; + for (int i = 0; i < a.Length; i++) + if (a[i] != b[i]) return $"{n}[{i}] {a[i]} != {b[i]}"; + return null; + } + + static string CmpMat(string n, Matrix4x4[] a, Matrix4x4[] b) + { + if (a.Length != b.Length) return $"{n} length {a.Length} != {b.Length}"; + for (int i = 0; i < a.Length; i++) + for (int e = 0; e < 16; e++) + if (Mathf.Abs(a[i][e] - b[i][e]) > MatEps) + return $"{n}[{i}].e{e} {a[i][e]:R} != {b[i][e]:R}"; + return null; + } + + static string CmpBoneWeights(string n, BoneWeight[] a, BoneWeight[] b) + { + if (a.Length != b.Length) return $"{n} length {a.Length} != {b.Length}"; + for (int i = 0; i < a.Length; i++) + { + BoneWeight x = a[i]; + BoneWeight y = b[i]; + if (x.boneIndex0 != y.boneIndex0 || x.boneIndex1 != y.boneIndex1 || + x.boneIndex2 != y.boneIndex2 || x.boneIndex3 != y.boneIndex3) + return $"{n}[{i}] indices ({x.boneIndex0},{x.boneIndex1},{x.boneIndex2},{x.boneIndex3}) != " + + $"({y.boneIndex0},{y.boneIndex1},{y.boneIndex2},{y.boneIndex3})"; + if (Mathf.Abs(x.weight0 - y.weight0) > WeightEps || Mathf.Abs(x.weight1 - y.weight1) > WeightEps || + Mathf.Abs(x.weight2 - y.weight2) > WeightEps || Mathf.Abs(x.weight3 - y.weight3) > WeightEps) + return $"{n}[{i}] weights ({x.weight0:R},{x.weight1:R},{x.weight2:R},{x.weight3:R}) != " + + $"({y.weight0:R},{y.weight1:R},{y.weight2:R},{y.weight3:R})"; + } + return null; + } + + static string CmpBounds(string n, Bounds a, Bounds b) + { + if (!ApproxLooseV3(a.center, b.center)) + return $"{n}.center {S(a.center)} != {S(b.center)} (loose eps {BoundsEps:R})"; + if (!ApproxLooseV3(a.size, b.size)) + return $"{n}.size {S(a.size)} != {S(b.size)} (loose eps {BoundsEps:R})"; + return null; + } + + // Relative-aware loose tolerance so far-from-origin bounds don't false-positive on float rounding. + static bool ApproxLoose(float x, float y) + { + float tol = Mathf.Max(BoundsEps, 1e-4f * Mathf.Max(Mathf.Abs(x), Mathf.Abs(y))); + return Mathf.Abs(x - y) <= tol; + } + + static bool ApproxLooseV3(Vector3 a, Vector3 b) => + ApproxLoose(a.x, b.x) && ApproxLoose(a.y, b.y) && ApproxLoose(a.z, b.z); + + // ---- Formatting / cleanup --------------------------------------------------------------- + + static string S(Vector2 v) => $"({v.x:F5},{v.y:F5})"; + static string S(Vector3 v) => $"({v.x:F5},{v.y:F5},{v.z:F5})"; + static string S(Vector4 v) => $"({v.x:F5},{v.y:F5},{v.z:F5},{v.w:F5})"; + static string S(Quaternion q) => $"({q.x:F5},{q.y:F5},{q.z:F5},{q.w:F5})"; + + static void DestroyOracleMeshes(GameObject root) + { + var meshes = new HashSet(); + foreach (MeshFilter mf in root.GetComponentsInChildren(true)) + if (mf.sharedMesh != null) meshes.Add(mf.sharedMesh); + foreach (MeshCollider mc in root.GetComponentsInChildren(true)) + if (mc.sharedMesh != null) meshes.Add(mc.sharedMesh); + foreach (SkinnedMeshRenderer sr in root.GetComponentsInChildren(true)) + if (sr.sharedMesh != null) meshes.Add(sr.sharedMesh); + foreach (Mesh m in meshes) + UnityEngine.Object.Destroy(m); + } + + /// A structurally-matched pair of nodes (same logical transform in each tree) plus its + /// diagnostic path, collected during for the mesh comparison pass. + private readonly struct NodePair + { + public readonly Transform A; + public readonly Transform B; + public readonly string Path; + + public NodePair(Transform a, Transform b, string path) + { + A = a; + B = b; + Path = path; + } + } + } +} +#endif From a654514cb8b3bdd73ed49e95f4ecbe5a7836ee8b Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 14 Jul 2026 17:05:27 -0700 Subject: [PATCH 11/14] Delete MuParser and the old main-thread model-loading machinery The background bundle pipeline is validated in-KSP (183/183 diff-parity vs MuParser, incl. skinned parts; clean full-database load), so the old path and the deprecated Library.MuParser are removed - completing the full migration: - Delete Library/MuParser.cs (the old .mu parser) and the DEBUG ModelDiffHarness (its oracle). - FastLoader: remove FilesLoader/ReadAssetsThread, strip RawAsset to a thin carrier (kept AssetType enum + File/ctor used by the texture path and the model bucketing), drop arrayPool/loadedBytes/lockObject and the MuParser.ReleaseBuffers cleanup. - Remove the now-dead skinned->MuParser fallback: the Kind.Skinned branch/arm, LoadSkinnedModelCoroutine, ModelLoadRequest.RawBytes/RawLength, and CompiledModel.ContainsSkinnedMesh (the compiler serializes skinned meshes directly, so it never flagged them). .dae is already loaded inline by LoadDaeModelCoroutine. QoL.MuParser (an unrelated texture-name scanner in NoIVA.cs) and MeshBundleSelfTest are untouched. ~2113 lines removed; Debug and Release both build clean; in-KSP relaunch loads all 1573 models with 0 errors. --- .../Library/Model/CompiledModel.cs | 5 - .../Library/Model/ModelDiffHarness.cs | 563 -------- .../Library/Model/ModelLoadRequest.cs | 9 +- .../Library/Model/MuModelCompiler.cs | 4 - KSPCommunityFixes/Library/MuParser.cs | 1161 ----------------- KSPCommunityFixes/Performance/FastLoader.cs | 383 +----- 6 files changed, 6 insertions(+), 2119 deletions(-) delete mode 100644 KSPCommunityFixes/Library/Model/ModelDiffHarness.cs delete mode 100644 KSPCommunityFixes/Library/MuParser.cs diff --git a/KSPCommunityFixes/Library/Model/CompiledModel.cs b/KSPCommunityFixes/Library/Model/CompiledModel.cs index 2b7e9d3..6147b34 100644 --- a/KSPCommunityFixes/Library/Model/CompiledModel.cs +++ b/KSPCommunityFixes/Library/Model/CompiledModel.cs @@ -33,11 +33,6 @@ internal sealed class CompiledModel /// The source file.url — used as the root name, for logging and for registration. public string SourceUrl; - /// True when the model contains a skinned mesh renderer. The v1 pipeline falls back to - /// the synchronous for these models rather than - /// baking skinned meshes. - public bool ContainsSkinnedMesh; - /// True when compilation failed; / are then /// meaningless and the pipeline must fall back. public bool Failed; diff --git a/KSPCommunityFixes/Library/Model/ModelDiffHarness.cs b/KSPCommunityFixes/Library/Model/ModelDiffHarness.cs deleted file mode 100644 index 68cf86c..0000000 --- a/KSPCommunityFixes/Library/Model/ModelDiffHarness.cs +++ /dev/null @@ -1,563 +0,0 @@ -#if DEBUG -using System; -using System.Collections; -using System.Collections.Generic; -using System.Text; -using KSPCommunityFixes.Library; -using UnityEngine; - -namespace KSPCommunityFixes.Library.Model -{ - /// - /// DEBUG-only semantic-parity gate for the background model-loading pipeline. For a sample of real - /// .mu models it builds the SAME model two ways from the SAME on-disk bytes: - /// - /// Oracle: (the original main-thread parser that builds - /// UnityEngine.Objects directly). - /// New path: -> -> - /// AssetBundle.LoadFromMemoryAsync -> per-mesh LoadAssetAsync -> replay of the compiled - /// list. - /// - /// It then diffs the two GameObject hierarchies (structure + transforms + component types) and every - /// mesh (geometry, submeshes, bounds, and — the key skinned check — bind poses, bone weights, and the - /// resolved SkinnedMeshRenderer.bones). This is the parity proof required before - /// is deleted. Results are grep-friendly under the [ModelDiff] sentinel. - /// Runs at the main menu (GameDatabase loaded, so textures/shaders resolve for the new path's - /// material instructions, and the .mu files are on disk). DEBUG-only, so it never ships. - /// - [KSPAddon(KSPAddon.Startup.MainMenu, true)] - internal sealed class ModelDiffHarness : MonoBehaviour - { - const string Tag = "[ModelDiff]"; - - // ---- Tweakables -------------------------------------------------------------------------- - - /// How many models to spread-sample across the whole (optionally filtered) .mu set. - /// Forced complex/skinned models (see ) are added ON TOP of this. - const int SampleCount = 60; - - /// Optional case-insensitive url-substring filter. When non-empty, only .mu files whose - /// url contains it are considered (e.g. "Squad/Parts/Aero" or a single part name to focus a run). - /// Null/empty = whole database. - const string UrlFilter = null; - - /// Any .mu whose url contains one of these (case-insensitive) is force-included regardless - /// of the spread sample — these bias toward skinned/animated/complex parts, which exercise the - /// riskiest parts of the new path (skinned channels, bones, animation, many submeshes). - static readonly string[] ComplexKeywords = - { - "landingleg", "landinggear", "gearbay", "robot", "hinge", "rotor", "piston", "drill", - "claw", "solar", "panel", "radiator", "kerbaleva", "kerbalgirl", "serenity", - }; - - // ---- Epsilons ---------------------------------------------------------------------------- - - // Geometry attributes round-trip through float32 with identical bits on both paths, so these are - // effectively exact-equality checks with a tiny safety margin. Bounds get a looser tolerance - // because the oracle uses Unity's RecalculateBounds while the new path computes its own AABB. - const float VecEpsSq = 1e-8f; // squared per-vertex vector tolerance (pos/normal/tangent/uv) - const float QuatEps = 1e-6f; // 1 - |dot| tolerance for localRotation - const float MatEps = 1e-5f; // per-element bind-pose matrix tolerance - const float WeightEps = 1e-5f; // per-vertex bone weight tolerance - const float BoundsEps = 1e-2f; // absolute floor for the (relative) mesh-bounds tolerance - - // The component types whose PRESENCE (and count) must match per node. SkinnedMeshRenderer and - // MeshRenderer are distinct concrete types (both derive from Renderer, neither from the other), so - // each is queried separately and unambiguously. - static readonly (string Name, Type Type)[] Tracked = - { - ("MeshFilter", typeof(MeshFilter)), - ("MeshRenderer", typeof(MeshRenderer)), - ("SkinnedMeshRenderer", typeof(SkinnedMeshRenderer)), - ("MeshCollider", typeof(MeshCollider)), - ("BoxCollider", typeof(BoxCollider)), - ("SphereCollider", typeof(SphereCollider)), - ("CapsuleCollider", typeof(CapsuleCollider)), - ("WheelCollider", typeof(WheelCollider)), - ("Light", typeof(Light)), - ("Camera", typeof(Camera)), - ("KSPParticleEmitter", typeof(KSPParticleEmitter)), - ("Animation", typeof(Animation)), - }; - - IEnumerator Start() - { - GameDatabase gdb = GameDatabase.Instance; - if (gdb == null || gdb.root == null) - { - Debug.LogError($"{Tag} GameDatabase not available; aborting."); - yield break; - } - - // 1) Collect candidate .mu files (optionally filtered). - var all = new List(); - foreach (UrlDir.UrlFile f in gdb.root.AllFiles) - { - if (f == null || f.fileExtension != "mu") - continue; - if (!string.IsNullOrEmpty(UrlFilter) && f.url.IndexOf(UrlFilter, StringComparison.OrdinalIgnoreCase) < 0) - continue; - all.Add(f); - } - - if (all.Count == 0) - { - Debug.LogWarning($"{Tag} no .mu models found (filter='{UrlFilter}'); nothing to do."); - yield break; - } - - // Spread-sample every Nth, then force-include complex/skinned parts on top (deduped by url). - var selected = new List(); - var seen = new HashSet(StringComparer.Ordinal); - int step = Mathf.Max(1, all.Count / Mathf.Max(1, SampleCount)); - for (int i = 0; i < all.Count && selected.Count < SampleCount; i += step) - if (seen.Add(all[i].url)) - selected.Add(all[i]); - - int sampled = selected.Count; - int forced = 0; - for (int i = 0; i < all.Count; i++) - if (ContainsKeyword(all[i].url) && seen.Add(all[i].url)) - { - selected.Add(all[i]); - forced++; - } - - Debug.Log($"{Tag} START: {selected.Count} models ({sampled} sampled + {forced} forced complex) " + - $"of {all.Count} .mu files. SampleCount={SampleCount}, filter='{UrlFilter}'."); - - var compiler = new MuModelCompiler(); - var failures = new List(); - int passed = 0; - int total = 0; - - foreach (UrlDir.UrlFile file in selected) - { - total++; - string url = file.url; - - GameObject a = null; - GameObject b = null; - AssetBundle bundle = null; - CompiledModel cm = null; - UnityEngine.Object[] locals = null; - byte[] bytes; - byte[] bundleBytes = null; - string fail = null; - string mismatch = null; - - // Phase A (synchronous, may throw): read bytes, build the oracle, compile, bake bundle - // bytes. Kept out of the yield path so it can be guarded by try/catch (C# forbids yield - // inside a try that has a catch). - try - { - bytes = System.IO.File.ReadAllBytes(file.fullPath); - a = MuParser.Parse(file.parent.url, bytes, bytes.Length); - cm = compiler.Compile(file.url, file.parent.url, bytes, bytes.Length); - if (cm.Failed) - fail = "compile failed: " + cm.FailureMessage; - else - // Null when the model has no static meshes at all (nothing to bundle). - bundleBytes = MeshBundleBuilder.BuildMany(cm.Blobs); - } - catch (Exception e) - { - fail = "exception (build): " + e.GetType().Name + ": " + e.Message; - } - - // Phase B (async): load the mesh bundle. Outside try/catch (contains a yield). - if (fail == null && bundleBytes != null) - { - AssetBundleCreateRequest createReq = AssetBundle.LoadFromMemoryAsync(bundleBytes); - yield return createReq; - bundle = createReq.assetBundle; - if (bundle == null) - fail = "AssetBundle.LoadFromMemoryAsync returned a null bundle"; - } - - // Phase C (async): place each loaded mesh into its locals slot. - if (fail == null) - { - locals = new UnityEngine.Object[cm.LocalCount]; - if (bundle != null) - { - MeshBinding[] bindings = cm.Bindings; - for (int i = 0; i < bindings.Length; i++) - { - AssetBundleRequest lr = bundle.LoadAssetAsync(bindings[i].CanonicalName); - yield return lr; - locals[bindings[i].Slot] = lr.asset; - } - } - } - - // Phase D (synchronous, may throw): replay the instructions and diff a vs b. - if (fail == null) - { - try - { - IModelInstruction[] ins = cm.Instructions; - for (int i = 0; i < ins.Length; i++) - ins[i].Execute(locals); - b = locals.Length > 0 ? locals[0] as GameObject : null; - mismatch = DiffModels(a, b); - } - catch (Exception e) - { - fail = "exception (replay/diff): " + e.GetType().Name + ": " + e.Message + - "\n" + e.StackTrace; - } - } - - // Surface any diagnostics the compiler buffered off-thread (main thread only). - if (cm != null) - cm.FlushLogs(); - - if (fail == null && mismatch == null) - { - passed++; - Debug.Log($"{Tag} PASS {url}"); - } - else - { - string reason = fail ?? mismatch; - failures.Add(url + ": " + reason); - Debug.LogError($"{Tag} FAIL {url}: {reason}"); - } - - // Cleanup. b's meshes are owned by the bundle and freed by Unload(true); the oracle's - // meshes are standalone Unity objects that would otherwise leak, so destroy them too. - if (a != null) - { - DestroyOracleMeshes(a); - UnityEngine.Object.Destroy(a); - } - if (b != null) - UnityEngine.Object.Destroy(b); - // A partial tree left behind by an exception mid-replay (b never assigned) is still rooted - // at locals[0]; destroy it so it doesn't leak. - else if (locals != null && locals.Length > 0 && locals[0] is GameObject partial) - UnityEngine.Object.Destroy(partial); - if (bundle != null) - bundle.Unload(true); - - yield return null; // spread the work across frames to avoid a long main-menu hitch - } - - MuParser.ReleaseBuffers(); - - Debug.Log($"{Tag} SUMMARY: {passed}/{total} passed"); - if (failures.Count > 0) - { - Debug.LogError($"{Tag} {failures.Count} FAILED:"); - for (int i = 0; i < failures.Count; i++) - Debug.LogError($"{Tag} {failures[i]}"); - } - } - - static bool ContainsKeyword(string url) - { - for (int i = 0; i < ComplexKeywords.Length; i++) - if (url.IndexOf(ComplexKeywords[i], StringComparison.OrdinalIgnoreCase) >= 0) - return true; - return false; - } - - // ---- Diff: hierarchy then meshes --------------------------------------------------------- - - /// Returns the FIRST mismatch between the oracle tree and the new-path - /// tree , or null if they are semantically identical. Hierarchy divergence is - /// reported first (mesh matching is meaningless once structure diverges). - static string DiffModels(GameObject a, GameObject b) - { - if (a == null && b == null) return "both roots null (nothing built)"; - if (a == null) return "oracle (MuParser) produced a null root"; - if (b == null) return "new path produced a null root"; - - var pairs = new List(); - string hm = DfsMatch(a.transform, b.transform, a.transform.name, pairs); - if (hm != null) - return "hierarchy: " + hm; - - // Structure matches, so the i-th node of each tree is the same logical node; compare meshes. - for (int i = 0; i < pairs.Count; i++) - { - NodePair p = pairs[i]; - string m; - - m = CompareMeshPair(p.Path + " MeshFilter.sharedMesh", - Sm(p.A.GetComponent()), Sm(p.B.GetComponent())); - if (m != null) return "mesh: " + m; - - m = CompareMeshPair(p.Path + " MeshCollider.sharedMesh", - Sm(p.A.GetComponent()), Sm(p.B.GetComponent())); - if (m != null) return "mesh: " + m; - - SkinnedMeshRenderer sa = p.A.GetComponent(); - SkinnedMeshRenderer sb = p.B.GetComponent(); - if (sa != null || sb != null) - { - m = CompareMeshPair(p.Path + " SkinnedMeshRenderer.sharedMesh", - sa != null ? sa.sharedMesh : null, sb != null ? sb.sharedMesh : null); - if (m != null) return "mesh: " + m; - - m = CompareBones(p.Path, sa, sb); - if (m != null) return "skin: " + m; - } - } - - return null; - } - - /// Parallel DFS. Verifies name, local TRS, and the tracked component-type multiset at each - /// node, and that child counts match, recording each matched (a,b) node pair for the mesh pass. - /// Returns the first structural mismatch (with a path), or null. - static string DfsMatch(Transform a, Transform b, string path, List pairs) - { - if (a.name != b.name) - return $"{path}: name '{a.name}' != '{b.name}'"; - if ((a.localPosition - b.localPosition).sqrMagnitude > VecEpsSq) - return $"{path}: localPosition {S(a.localPosition)} != {S(b.localPosition)}"; - if (1f - Mathf.Abs(Quaternion.Dot(a.localRotation, b.localRotation)) > QuatEps) - return $"{path}: localRotation {S(a.localRotation)} != {S(b.localRotation)}"; - if ((a.localScale - b.localScale).sqrMagnitude > VecEpsSq) - return $"{path}: localScale {S(a.localScale)} != {S(b.localScale)}"; - - string sigA = ComponentSig(a.gameObject); - string sigB = ComponentSig(b.gameObject); - if (sigA != sigB) - return $"{path}: components [{sigA}] != [{sigB}]"; - - pairs.Add(new NodePair(a, b, path)); - - if (a.childCount != b.childCount) - return $"{path}: childCount {a.childCount} != {b.childCount}"; - - for (int i = 0; i < a.childCount; i++) - { - Transform ca = a.GetChild(i); - Transform cb = b.GetChild(i); - string m = DfsMatch(ca, cb, path + "/" + ca.name, pairs); - if (m != null) - return m; - } - - return null; - } - - static string ComponentSig(GameObject go) - { - var sb = new StringBuilder(); - for (int i = 0; i < Tracked.Length; i++) - { - int count = go.GetComponents(Tracked[i].Type).Length; - if (count > 0) - { - if (sb.Length > 0) sb.Append(','); - sb.Append(Tracked[i].Name).Append(':').Append(count); - } - } - return sb.ToString(); - } - - static Mesh Sm(MeshFilter c) => c != null ? c.sharedMesh : null; - static Mesh Sm(MeshCollider c) => c != null ? c.sharedMesh : null; - - /// Compares a matched pair of meshes, handling the null cases. Returns null when both are - /// absent or both compare equal; otherwise the first mismatch (prefixed with ). - static string CompareMeshPair(string prefix, Mesh a, Mesh b) - { - bool aNull = a == null; - bool bNull = b == null; - if (aNull && bNull) return null; - if (aNull != bNull) - return $"{prefix}: present on oracle={!aNull}, on new={!bNull}"; - string m = CompareMesh(a, b); - return m == null ? null : prefix + ": " + m; - } - - /// Field-by-field mesh comparison in a fixed order; returns the first differing field - /// (name + index + values) or null. Bind poses / bone weights are compared whenever EITHER mesh - /// carries them — that is the load-bearing skinned check (equality proves Unity deserialized the - /// BlendWeights/BlendIndices channels 12/13 correctly). - static string CompareMesh(Mesh a, Mesh b) - { - if (a.vertexCount != b.vertexCount) - return $"vertexCount {a.vertexCount} != {b.vertexCount}"; - - string m; - if ((m = CmpV3("vertices", a.vertices, b.vertices)) != null) return m; - if ((m = CmpV3("normals", a.normals, b.normals)) != null) return m; - if ((m = CmpV4("tangents", a.tangents, b.tangents)) != null) return m; - if ((m = CmpC32("colors32", a.colors32, b.colors32)) != null) return m; - if ((m = CmpV2("uv", a.uv, b.uv)) != null) return m; - if ((m = CmpV2("uv2", a.uv2, b.uv2)) != null) return m; - - if (a.subMeshCount != b.subMeshCount) - return $"subMeshCount {a.subMeshCount} != {b.subMeshCount}"; - for (int s = 0; s < a.subMeshCount; s++) - if ((m = CmpInt($"triangles[{s}]", a.GetTriangles(s), b.GetTriangles(s))) != null) return m; - - // Looser: oracle uses RecalculateBounds, new path computes its own AABB from the same verts. - if ((m = CmpBounds("bounds", a.bounds, b.bounds)) != null) return m; - - Matrix4x4[] abp = a.bindposes; - Matrix4x4[] bbp = b.bindposes; - if (abp.Length > 0 || bbp.Length > 0) - if ((m = CmpMat("bindposes", abp, bbp)) != null) return m; - - BoneWeight[] abw = a.boneWeights; - BoneWeight[] bbw = b.boneWeights; - if (abw.Length > 0 || bbw.Length > 0) - if ((m = CmpBoneWeights("boneWeights", abw, bbw)) != null) return m; - - return null; - } - - /// Compares the resolved SkinnedMeshRenderer.bones (count + bound transform names in - /// order) — confirms ResolveBones/AffectSkinnedMeshRenderersBones bound the same bones. - static string CompareBones(string path, SkinnedMeshRenderer a, SkinnedMeshRenderer b) - { - Transform[] ba = a != null ? a.bones : Array.Empty(); - Transform[] bb = b != null ? b.bones : Array.Empty(); - if (ba.Length != bb.Length) - return $"{path} SMR.bones length {ba.Length} != {bb.Length}"; - for (int i = 0; i < ba.Length; i++) - { - string na = ba[i] != null ? ba[i].name : ""; - string nb = bb[i] != null ? bb[i].name : ""; - if (na != nb) - return $"{path} SMR.bones[{i}] '{na}' != '{nb}'"; - } - return null; - } - - // ---- Field comparison helpers (return the first mismatch string, or null) ---------------- - - static string CmpV3(string n, Vector3[] a, Vector3[] b) - { - if (a.Length != b.Length) return $"{n} length {a.Length} != {b.Length}"; - for (int i = 0; i < a.Length; i++) - if ((a[i] - b[i]).sqrMagnitude > VecEpsSq) return $"{n}[{i}] {S(a[i])} != {S(b[i])}"; - return null; - } - - static string CmpV4(string n, Vector4[] a, Vector4[] b) - { - if (a.Length != b.Length) return $"{n} length {a.Length} != {b.Length}"; - for (int i = 0; i < a.Length; i++) - if ((a[i] - b[i]).sqrMagnitude > VecEpsSq) return $"{n}[{i}] {S(a[i])} != {S(b[i])}"; - return null; - } - - static string CmpV2(string n, Vector2[] a, Vector2[] b) - { - if (a.Length != b.Length) return $"{n} length {a.Length} != {b.Length}"; - for (int i = 0; i < a.Length; i++) - if ((a[i] - b[i]).sqrMagnitude > VecEpsSq) return $"{n}[{i}] {S(a[i])} != {S(b[i])}"; - return null; - } - - static string CmpC32(string n, Color32[] a, Color32[] b) - { - if (a.Length != b.Length) return $"{n} length {a.Length} != {b.Length}"; - for (int i = 0; i < a.Length; i++) - if (a[i].r != b[i].r || a[i].g != b[i].g || a[i].b != b[i].b || a[i].a != b[i].a) - return $"{n}[{i}] ({a[i].r},{a[i].g},{a[i].b},{a[i].a}) != ({b[i].r},{b[i].g},{b[i].b},{b[i].a})"; - return null; - } - - static string CmpInt(string n, int[] a, int[] b) - { - if (a.Length != b.Length) return $"{n} length {a.Length} != {b.Length}"; - for (int i = 0; i < a.Length; i++) - if (a[i] != b[i]) return $"{n}[{i}] {a[i]} != {b[i]}"; - return null; - } - - static string CmpMat(string n, Matrix4x4[] a, Matrix4x4[] b) - { - if (a.Length != b.Length) return $"{n} length {a.Length} != {b.Length}"; - for (int i = 0; i < a.Length; i++) - for (int e = 0; e < 16; e++) - if (Mathf.Abs(a[i][e] - b[i][e]) > MatEps) - return $"{n}[{i}].e{e} {a[i][e]:R} != {b[i][e]:R}"; - return null; - } - - static string CmpBoneWeights(string n, BoneWeight[] a, BoneWeight[] b) - { - if (a.Length != b.Length) return $"{n} length {a.Length} != {b.Length}"; - for (int i = 0; i < a.Length; i++) - { - BoneWeight x = a[i]; - BoneWeight y = b[i]; - if (x.boneIndex0 != y.boneIndex0 || x.boneIndex1 != y.boneIndex1 || - x.boneIndex2 != y.boneIndex2 || x.boneIndex3 != y.boneIndex3) - return $"{n}[{i}] indices ({x.boneIndex0},{x.boneIndex1},{x.boneIndex2},{x.boneIndex3}) != " + - $"({y.boneIndex0},{y.boneIndex1},{y.boneIndex2},{y.boneIndex3})"; - if (Mathf.Abs(x.weight0 - y.weight0) > WeightEps || Mathf.Abs(x.weight1 - y.weight1) > WeightEps || - Mathf.Abs(x.weight2 - y.weight2) > WeightEps || Mathf.Abs(x.weight3 - y.weight3) > WeightEps) - return $"{n}[{i}] weights ({x.weight0:R},{x.weight1:R},{x.weight2:R},{x.weight3:R}) != " + - $"({y.weight0:R},{y.weight1:R},{y.weight2:R},{y.weight3:R})"; - } - return null; - } - - static string CmpBounds(string n, Bounds a, Bounds b) - { - if (!ApproxLooseV3(a.center, b.center)) - return $"{n}.center {S(a.center)} != {S(b.center)} (loose eps {BoundsEps:R})"; - if (!ApproxLooseV3(a.size, b.size)) - return $"{n}.size {S(a.size)} != {S(b.size)} (loose eps {BoundsEps:R})"; - return null; - } - - // Relative-aware loose tolerance so far-from-origin bounds don't false-positive on float rounding. - static bool ApproxLoose(float x, float y) - { - float tol = Mathf.Max(BoundsEps, 1e-4f * Mathf.Max(Mathf.Abs(x), Mathf.Abs(y))); - return Mathf.Abs(x - y) <= tol; - } - - static bool ApproxLooseV3(Vector3 a, Vector3 b) => - ApproxLoose(a.x, b.x) && ApproxLoose(a.y, b.y) && ApproxLoose(a.z, b.z); - - // ---- Formatting / cleanup --------------------------------------------------------------- - - static string S(Vector2 v) => $"({v.x:F5},{v.y:F5})"; - static string S(Vector3 v) => $"({v.x:F5},{v.y:F5},{v.z:F5})"; - static string S(Vector4 v) => $"({v.x:F5},{v.y:F5},{v.z:F5},{v.w:F5})"; - static string S(Quaternion q) => $"({q.x:F5},{q.y:F5},{q.z:F5},{q.w:F5})"; - - static void DestroyOracleMeshes(GameObject root) - { - var meshes = new HashSet(); - foreach (MeshFilter mf in root.GetComponentsInChildren(true)) - if (mf.sharedMesh != null) meshes.Add(mf.sharedMesh); - foreach (MeshCollider mc in root.GetComponentsInChildren(true)) - if (mc.sharedMesh != null) meshes.Add(mc.sharedMesh); - foreach (SkinnedMeshRenderer sr in root.GetComponentsInChildren(true)) - if (sr.sharedMesh != null) meshes.Add(sr.sharedMesh); - foreach (Mesh m in meshes) - UnityEngine.Object.Destroy(m); - } - - /// A structurally-matched pair of nodes (same logical transform in each tree) plus its - /// diagnostic path, collected during for the mesh comparison pass. - private readonly struct NodePair - { - public readonly Transform A; - public readonly Transform B; - public readonly string Path; - - public NodePair(Transform a, Transform b, string path) - { - A = a; - B = b; - Path = path; - } - } - } -} -#endif diff --git a/KSPCommunityFixes/Library/Model/ModelLoadRequest.cs b/KSPCommunityFixes/Library/Model/ModelLoadRequest.cs index fd378a1..4a7dbc3 100644 --- a/KSPCommunityFixes/Library/Model/ModelLoadRequest.cs +++ b/KSPCommunityFixes/Library/Model/ModelLoadRequest.cs @@ -18,12 +18,10 @@ public enum State : byte { Pending, Ready, Failed } /// /// : replay the compiled instructions against meshes loaded from the /// group's bundle. - /// : v1 fallback to the synchronous MuParser.Parse (baking - /// skinned meshes is deferred), using the retained . /// : reload via the stock DAE loader. /// : file read failed or compilation failed; hard failure. /// - public enum Kind : byte { CompiledMu, Skinned, Dae, Failed } + public enum Kind : byte { CompiledMu, Dae, Failed } public UrlDir.UrlFile File; public Kind ModelKind; @@ -38,11 +36,6 @@ public enum Kind : byte { CompiledMu, Skinned, Dae, Failed } /// Unload ref-count. public ModelGroup Group; - /// Skinned only: the retained raw .mu bytes handed to MuParser.Parse - /// (nulled right after the parse). - public byte[] RawBytes; - public int RawLength; - public string FailureMessage; /// Byte count added to KSPCFFastLoaderReport.modelsBytesLoaded on success. diff --git a/KSPCommunityFixes/Library/Model/MuModelCompiler.cs b/KSPCommunityFixes/Library/Model/MuModelCompiler.cs index 8514221..777d76c 100644 --- a/KSPCommunityFixes/Library/Model/MuModelCompiler.cs +++ b/KSPCommunityFixes/Library/Model/MuModelCompiler.cs @@ -141,10 +141,6 @@ public CompiledModel Compile(string fileUrl, string directoryUrl, byte[] data, i Blobs = blobs.ToArray(), Bindings = bindings.ToArray(), LocalCount = nextSlot, - // Skinned meshes are now fully serialized (blend channels + bind pose + bone - // metadata) and flow through the background bundle path like any static model, so - // this is never set — the FastLoader skinned/MuParser fallback is no longer taken. - ContainsSkinnedMesh = false, Failed = false, Logs = logs, // flushed on the main thread by the replay pipeline (see FlushLogs) }; diff --git a/KSPCommunityFixes/Library/MuParser.cs b/KSPCommunityFixes/Library/MuParser.cs deleted file mode 100644 index 63ecbfc..0000000 --- a/KSPCommunityFixes/Library/MuParser.cs +++ /dev/null @@ -1,1161 +0,0 @@ -using PartToolsLib; -using System; -using System.Collections.Generic; -using System.IO; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Text; -using UnityEngine; -using UnityEngine.Rendering; - -namespace KSPCommunityFixes.Library -{ - // For reference on my 5800X3D/DDR4, when disk reading isn't a factor and for loading 485MB worth of models : - // - The stock parser has a throughput of 120 MB/s - // - The KSPCF parser has a throughput of 290 MB/s - // Roadblocks to further optimizations : - // - We can't avoid making a copy of the mesh data, avoiding that would require Unity accepting Span in the various Mesh.Set*() methods - // - For some mesh data, the mu data layout doesn't match a continuous array of structs, so we have to parse those structs one by one - // - Animation parsing is inerently slow due to two strings having to be parsed for every curve, plus other hard to overcome inefficiencies - // - Ideally, the dummy* data structures should avoid manipulating strings, would use dictionaries instead of lists, and could be reused instead of re-instantatied for every model. - // Overall, further optimization probably won't be beneficial anyway, we will almost always be bottlenecked by disk read speed. - - /// - /// Reimplementation of the stock mu model format parser (PartReader). Roughly 60% faster. - /// - internal class MuParser - { - private static readonly UTF8Encoding decoder = new UTF8Encoding(); - private static char[] charBuffer; - - private static int[] intBuffer; - private static Vector2[] vector2Buffer; - private static Vector3[] vector3Buffer; - private static Vector4[] vector4Buffer; - private static Color32[] color32Buffer; - private static Keyframe[][] keyFrameBuffers; - - private static List matDummies; - private static PartReader.TextureDummyList textureDummies; - private static List boneDummies; - - private static string modelDirectoryUrl; - private static byte[] data; - private static unsafe byte* dataPtr; - private static int dataLength; - private static int index; - - private static int version; - - /// - /// Parse a mu model into a GameObject hierarchy - /// - /// GameData relative path to the folder containing the model. For a model known by its , this will be the urlFile.parent.url value. - /// A byte array containing the raw model file data - /// The length of the data in the array. Ignored if zero. - public static unsafe GameObject Parse(string modelDirectoryUrl, byte[] data, int dataLength = 0) - { - if (matDummies == null) - matDummies = new List(); - - if (textureDummies == null) - textureDummies = new PartReader.TextureDummyList(); - - if (boneDummies == null) - boneDummies = new List(); - - GameObject model; - - GCHandle pinnedArray = GCHandle.Alloc(data, GCHandleType.Pinned); - - try - { - MuParser.modelDirectoryUrl = modelDirectoryUrl; - MuParser.data = data; - MuParser.dataLength = dataLength <= 0 ? data.Length : dataLength; - dataPtr = (byte*)pinnedArray.AddrOfPinnedObject(); - index = 0; - - if (ReadInt() != 76543) - throw new Exception("Invalid mu file"); - - version = ReadInt(); - SkipString(); - - model = ReadChild(null); - AffectSkinnedMeshRenderersBones(model); - } - catch (Exception e) - { - model = null; - Debug.LogError($"Model {modelDirectoryUrl} error: {e.Message}\n{e.StackTrace}"); - } - finally - { - pinnedArray.Free(); - MuParser.matDummies.Clear(); - MuParser.boneDummies.Clear(); - MuParser.textureDummies.Clear(); - MuParser.modelDirectoryUrl = null; - MuParser.data = null; - MuParser.dataPtr = null; - MuParser.dataLength = 0; - MuParser.index = 0; - MuParser.version = 0; - } - - return model; - } - - /// - /// Call this to release the memory used by the static buffers. - /// This is safe to use at any point. - /// - public static void ReleaseBuffers() - { - intBuffer = null; - vector2Buffer = null; - vector3Buffer = null; - vector4Buffer = null; - color32Buffer = null; - keyFrameBuffers = null; - charBuffer = null; - matDummies = null; - textureDummies = null; - boneDummies = null; - } - - #region Core methods - - private static GameObject ReadChild(Transform parent) - { - GameObject gameObject = new GameObject(ReadString()); - - gameObject.transform.parent = parent; - gameObject.transform.localPosition = ReadVector3(); - gameObject.transform.localRotation = ReadQuaternion(); - gameObject.transform.localScale = ReadVector3(); - - while (index < dataLength) - { - switch (ReadInt()) - { - case 0: - ReadChild(gameObject.transform); - break; - case 2: - ReadAnimation(gameObject); - break; - case 3: - ReadMeshCollider(gameObject); - break; - case 4: - ReadSphereCollider(gameObject); - break; - case 5: - ReadCapsuleCollider(gameObject); - break; - case 6: - ReadBoxCollider(gameObject); - break; - case 7: - ReadMeshFilter(gameObject); - break; - case 8: - ReadMeshRenderer(gameObject); - break; - case 9: - ReadSkinnedMeshRenderer(gameObject); - break; - case 10: - ReadMaterials(); - break; - case 12: - ReadTextures(gameObject); - break; - case 23: - ReadLight(gameObject); - break; - case 24: - ReadTagAndLayer(gameObject); - break; - case 25: - ReadMeshCollider2(gameObject); - break; - case 26: - ReadSphereCollider2(gameObject); - break; - case 27: - ReadCapsuleCollider2(gameObject); - break; - case 28: - ReadBoxCollider2(gameObject); - break; - case 29: - ReadWheelCollider(gameObject); - break; - case 30: - ReadCamera(gameObject); - break; - case 31: - ReadParticles(gameObject); - break; - case 1: - return gameObject; - } - } - return gameObject; - } - - private static void AffectSkinnedMeshRenderersBones(GameObject model) - { - if (boneDummies.Count > 0) - { - int i = 0; - for (int count = boneDummies.Count; i < count; i++) - { - Transform[] array = new Transform[boneDummies[i].bones.Count]; - int j = 0; - for (int count2 = boneDummies[i].bones.Count; j < count2; j++) - { - array[j] = FindChildByName(model.transform, boneDummies[i].bones[j]); - } - boneDummies[i].smr.bones = array; - } - } - } - - private static Transform FindChildByName(Transform parent, string name) - { - if (parent.name == name) - { - return parent; - } - foreach (Transform item in parent) - { - Transform transform = FindChildByName(item, name); - if (transform != null) - { - return transform; - } - } - return null; - } - - #endregion - - #region Component parsers - - private static void ReadAnimation(GameObject o) - { - Animation animation = o.AddComponent(); - int clipCount = ReadInt(); - bool isInvalid = false; - for (int i = 0; i < clipCount; i++) - { - AnimationClip animationClip = new AnimationClip(); - animationClip.legacy = true; - string clipName = ReadString(); - animationClip.localBounds = new Bounds(ReadVector3(), ReadVector3()); - animationClip.wrapMode = (WrapMode)ReadInt(); - - int curveCount = ReadInt(); - for (int j = 0; j < curveCount; j++) - { - string curvePath = ReadString(); - string curveProperty = ReadString(); - Type curveType = null; - switch (ReadInt()) - { - case 0: - curveType = typeof(Transform); - break; - case 1: - curveType = typeof(Material); - break; - case 2: - curveType = typeof(Light); - break; - case 3: - curveType = typeof(AudioSource); - break; - } - WrapMode preWrapMode = (WrapMode)ReadInt(); - WrapMode postWrapMode = (WrapMode)ReadInt(); - - int keyFrameCount = ReadInt(); - Keyframe[] keyFrames = GetKeyFrameBuffer(keyFrameCount); - for (int k = 0; k < keyFrameCount; k++) - keyFrames[k] = ReadKeyFrame(); - - AnimationCurve animationCurve = new AnimationCurve(keyFrames); - animationCurve.preWrapMode = preWrapMode; - animationCurve.postWrapMode = postWrapMode; - - if (clipName == null || curvePath == null || curveType == null || curveProperty == null) - { - isInvalid = true; - Debug.LogWarning($"{clipName ?? "Null clipName"} : {curvePath ?? "Null curvePath"}, {(curveType == null ? "Null curveType" : curveType.ToString())}, {curveProperty ?? "Null curveProperty"}"); - continue; - } - - animationClip.SetCurve(curvePath, curveType, curveProperty, animationCurve); - } - if (!isInvalid) - { - animation.AddClip(animationClip, clipName); - } - } - string defaultclipName = ReadString(); - if (defaultclipName != string.Empty && !isInvalid) - animation.clip = animation.GetClip(defaultclipName); - - animation.playAutomatically = ReadBool(); - } - - /// - /// Usually, the curve will have less than 10 keyframes, this method will return a - /// cached buffer instead of instantiatiating a new one in such cases. - /// - private static Keyframe[] GetKeyFrameBuffer(int keyFrameCount) - { - if (keyFrameBuffers == null) - { - keyFrameBuffers = new Keyframe[10][]; - keyFrameBuffers[0] = new Keyframe[0]; - keyFrameBuffers[1] = new Keyframe[1]; - keyFrameBuffers[2] = new Keyframe[2]; - keyFrameBuffers[3] = new Keyframe[3]; - keyFrameBuffers[4] = new Keyframe[4]; - keyFrameBuffers[5] = new Keyframe[5]; - keyFrameBuffers[6] = new Keyframe[6]; - keyFrameBuffers[7] = new Keyframe[7]; - keyFrameBuffers[8] = new Keyframe[8]; - keyFrameBuffers[9] = new Keyframe[9]; - } - - if (keyFrameCount < 10) - return keyFrameBuffers[keyFrameCount]; - - return new Keyframe[keyFrameCount]; - } - - private static void ReadMeshCollider(GameObject o) - { - MeshCollider meshCollider = o.AddComponent(); - SkipBool(); // this is actually the "convex" property, but it is always forced to true - meshCollider.convex = true; - meshCollider.sharedMesh = ReadMesh(); - } - - private static void ReadSphereCollider(GameObject o) - { - SphereCollider sphereCollider = o.AddComponent(); - sphereCollider.radius = ReadFloat(); - sphereCollider.center = ReadVector3(); - } - - private static void ReadCapsuleCollider(GameObject o) - { - CapsuleCollider capsuleCollider = o.AddComponent(); - capsuleCollider.radius = ReadFloat(); - capsuleCollider.direction = ReadInt(); - capsuleCollider.center = ReadVector3(); - } - - private static void ReadBoxCollider(GameObject o) - { - BoxCollider boxCollider = o.AddComponent(); - boxCollider.size = ReadVector3(); - boxCollider.center = ReadVector3(); - } - - private static void ReadMeshFilter(GameObject o) - { - o.AddComponent().sharedMesh = ReadMesh(); - } - - private static void ReadMeshRenderer(GameObject o) - { - MeshRenderer meshRenderer = o.AddComponent(); - if (version >= 1) - { - meshRenderer.shadowCastingMode = ReadBool() ? ShadowCastingMode.On : ShadowCastingMode.Off; - meshRenderer.receiveShadows = ReadBool(); - } - int rendererCount = ReadInt(); - for (int i = 0; i < rendererCount; i++) - { - int materialCount = ReadInt(); - while (materialCount >= matDummies.Count) - matDummies.Add(new PartReader.MaterialDummy()); - - matDummies[materialCount].renderers.Add(meshRenderer); - } - } - - private static void ReadSkinnedMeshRenderer(GameObject o) - { - SkinnedMeshRenderer skinnedMeshRenderer = o.AddComponent(); - int rendererCount = ReadInt(); - for (int i = 0; i < rendererCount; i++) - { - int materialCount = ReadInt(); - while (materialCount >= matDummies.Count) - { - matDummies.Add(new PartReader.MaterialDummy()); - } - matDummies[materialCount].renderers.Add(skinnedMeshRenderer); - } - skinnedMeshRenderer.localBounds = new Bounds(ReadVector3(), ReadVector3()); - skinnedMeshRenderer.quality = (SkinQuality)ReadInt(); - skinnedMeshRenderer.updateWhenOffscreen = ReadBool(); - int num3 = ReadInt(); - - PartReader.BonesDummy bonesDummy = new PartReader.BonesDummy(); - bonesDummy.smr = skinnedMeshRenderer; - for (int j = 0; j < num3; j++) - { - bonesDummy.bones.Add(ReadString()); - } - boneDummies.Add(bonesDummy); - skinnedMeshRenderer.sharedMesh = ReadMesh(); - } - - private static void ReadMaterials() - { - int materialCount = ReadInt(); - for (int i = 0; i < materialCount; i++) - { - PartReader.MaterialDummy materialDummy = matDummies[i]; - Material material = version < 4 ? ReadMaterial() : ReadMaterial4(); - - for (int j = materialDummy.renderers.Count; j-- > 0;) - materialDummy.renderers[j].sharedMaterial = material; - - for (int j = materialDummy.particleEmitters.Count; j-- > 0;) - materialDummy.particleEmitters[j].material = material; - } - } - - private static Material ReadMaterial() - { - string name = ReadString(); - ShaderType shaderType = (ShaderType)ReadInt(); - Material material = new Material(ShaderHelpers.GetShader(shaderType)); - switch (shaderType) - { - default: - - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - break; - case ShaderType.Specular: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - material.SetColor(ShaderHelpers.SpecColorPropId, ReadColor()); - material.SetFloat(ShaderHelpers.ShininessPropId, ReadFloat()); - break; - case ShaderType.Bumped: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - ReadMaterialTexture(material, "_BumpMap", ShaderHelpers.BumpMapPropId); - break; - case ShaderType.BumpedSpecular: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - ReadMaterialTexture(material, "_BumpMap", ShaderHelpers.BumpMapPropId); - material.SetColor(ShaderHelpers.SpecColorPropId, ReadColor()); - material.SetFloat(ShaderHelpers.ShininessPropId, ReadFloat()); - break; - case ShaderType.Emissive: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - ReadMaterialTexture(material, "_Emissive", ShaderHelpers.EmissivePropId); - material.SetColor(PropertyIDs._EmissiveColor, ReadColor()); - break; - case ShaderType.EmissiveSpecular: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - material.SetColor(ShaderHelpers.SpecColorPropId, ReadColor()); - material.SetFloat(ShaderHelpers.ShininessPropId, ReadFloat()); - ReadMaterialTexture(material, "_Emissive", ShaderHelpers.EmissivePropId); - material.SetColor(PropertyIDs._EmissiveColor, ReadColor()); - break; - case ShaderType.EmissiveBumpedSpecular: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - ReadMaterialTexture(material, "_BumpMap", ShaderHelpers.BumpMapPropId); - material.SetColor(ShaderHelpers.SpecColorPropId, ReadColor()); - material.SetFloat(ShaderHelpers.ShininessPropId, ReadFloat()); - ReadMaterialTexture(material, "_Emissive", ShaderHelpers.EmissivePropId); - material.SetColor(PropertyIDs._EmissiveColor, ReadColor()); - break; - case ShaderType.AlphaCutout: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - material.SetFloat(ShaderHelpers.CutoffPropId, ReadFloat()); - break; - case ShaderType.AlphaCutoutBumped: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - ReadMaterialTexture(material, "_BumpMap", ShaderHelpers.BumpMapPropId); - material.SetFloat(ShaderHelpers.CutoffPropId, ReadFloat()); - break; - case ShaderType.Alpha: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - break; - case ShaderType.AlphaSpecular: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - material.SetFloat(ShaderHelpers.GlossPropId, ReadFloat()); - material.SetColor(ShaderHelpers.SpecColorPropId, ReadColor()); - material.SetFloat(ShaderHelpers.ShininessPropId, ReadFloat()); - break; - case ShaderType.AlphaUnlit: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - material.SetColor(ShaderHelpers.ColorPropId, ReadColor()); - break; - case ShaderType.Unlit: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - material.SetColor(ShaderHelpers.ColorPropId, ReadColor()); - break; - case ShaderType.ParticleAlpha: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - material.SetColor(ShaderHelpers.ColorPropId, ReadColor()); - material.SetFloat(ShaderHelpers.InvFadePropId, ReadFloat()); - break; - case ShaderType.ParticleAdditive: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - material.SetColor(ShaderHelpers.ColorPropId, ReadColor()); - material.SetFloat(ShaderHelpers.InvFadePropId, ReadFloat()); - break; - case ShaderType.BumpedSpecularMap: - ReadMaterialTexture(material, "_MainTex", ShaderHelpers.MainTexPropId); - ReadMaterialTexture(material, "_BumpMap", ShaderHelpers.BumpMapPropId); - ReadMaterialTexture(material, "_SpecMap", ShaderHelpers.SpecMapPropId); - material.SetFloat(ShaderHelpers.SpecTintPropId, ReadFloat()); - material.SetFloat(ShaderHelpers.ShininessPropId, ReadFloat()); - break; - } - - material.name = name; - return material; - } - - private static Material ReadMaterial4() - { - string matName = ReadString(); - string shaderName = ReadString(); - int propertyCount = ReadInt(); - Material material = new Material(Shader.Find(shaderName)); - material.name = matName; - for (int i = 0; i < propertyCount; i++) - { - string text = ReadString(); - switch (ReadInt()) - { - case 0: - material.SetColor(text, ReadColor()); - break; - case 1: - material.SetVector(text, ReadVector4()); - break; - case 2: - material.SetFloat(text, ReadFloat()); - break; - case 3: - material.SetFloat(text, ReadFloat()); - break; - case 4: - ReadMaterialTexture(material, text, Shader.PropertyToID(text)); - break; - } - } - return material; - } - - private static void ReadMaterialTexture(Material mat, string textureName, int textureId) - { - // we would need to reimplement the whole texture/material dummy thing to get ride of - // having to use the texture name. Probably doesn't matter much... - textureDummies.AddTextureDummy(ReadInt(), mat, textureName); - mat.SetTextureScale(textureId, ReadVector2()); - mat.SetTextureOffset(textureId, ReadVector2()); - } - - private static void ReadTextures(GameObject o) - { - int texCount = ReadInt(); - if (texCount != textureDummies.Count) - { - Debug.LogError("TextureError: " + texCount + " " + textureDummies.Count); - return; - } - for (int i = 0; i < texCount; i++) - { - string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(ReadString()); - TextureType textureType = (TextureType)ReadInt(); - string url = modelDirectoryUrl + "/" + fileNameWithoutExtension; - Texture2D texture = GameDatabase.Instance.GetTexture(url, textureType == TextureType.NormalMap); - if (texture.IsNullOrDestroyed()) - { - Debug.LogError($"Texture '{url}' not found!"); - continue; - } - int j = 0; - for (int materialCount = textureDummies[i].Count; j < materialCount; j++) - { - PartReader.TextureMaterialDummy textureMaterialDummy = textureDummies[i][j]; - int k = 0; - for (int texPropCount = textureMaterialDummy.shaderName.Count; k < texPropCount; k++) - { - string texProperty = textureMaterialDummy.shaderName[k]; - textureMaterialDummy.material.SetTexture(texProperty, texture); - } - } - } - } - - private static void ReadLight(GameObject o) - { - Light light = o.AddComponent(); - - light.type = (LightType)ReadInt(); - light.intensity = ReadFloat(); - light.range = ReadFloat(); - light.color = ReadColor(); - light.cullingMask = ReadInt(); - - if (version > 1) - light.spotAngle = ReadFloat(); - } - - private static void ReadTagAndLayer(GameObject o) - { - o.tag = ReadString(); - o.layer = ReadInt(); - } - - private static void ReadMeshCollider2(GameObject o) - { - MeshCollider meshCollider = o.AddComponent(); - meshCollider.convex = true; - meshCollider.isTrigger = ReadBool(); - SkipBool(); // this is actually the "convex" property, but it is always forced to true; - meshCollider.sharedMesh = ReadMesh(); - } - - private static void ReadSphereCollider2(GameObject o) - { - SphereCollider sphereCollider = o.AddComponent(); - sphereCollider.isTrigger = ReadBool(); - sphereCollider.radius = ReadFloat(); - sphereCollider.center = ReadVector3(); - } - - private static void ReadCapsuleCollider2(GameObject o) - { - CapsuleCollider capsuleCollider = o.AddComponent(); - capsuleCollider.isTrigger = ReadBool(); - capsuleCollider.radius = ReadFloat(); - capsuleCollider.height = ReadFloat(); - capsuleCollider.direction = ReadInt(); - capsuleCollider.center = ReadVector3(); - } - - private static void ReadBoxCollider2(GameObject o) - { - BoxCollider boxCollider = o.AddComponent(); - boxCollider.isTrigger = ReadBool(); - boxCollider.size = ReadVector3(); - boxCollider.center = ReadVector3(); - } - - private static void ReadWheelCollider(GameObject o) - { - WheelCollider wheelCollider = o.AddComponent(); - wheelCollider.mass = ReadFloat(); - wheelCollider.radius = ReadFloat(); - wheelCollider.suspensionDistance = ReadFloat(); - wheelCollider.center = ReadVector3(); - wheelCollider.suspensionSpring = new JointSpring - { - spring = ReadFloat(), - damper = ReadFloat(), - targetPosition = ReadFloat() - }; - wheelCollider.forwardFriction = new WheelFrictionCurve - { - extremumSlip = ReadFloat(), - extremumValue = ReadFloat(), - asymptoteSlip = ReadFloat(), - asymptoteValue = ReadFloat(), - stiffness = ReadFloat() - }; - wheelCollider.sidewaysFriction = new WheelFrictionCurve - { - extremumSlip = ReadFloat(), - extremumValue = ReadFloat(), - asymptoteSlip = ReadFloat(), - asymptoteValue = ReadFloat(), - stiffness = ReadFloat() - }; - wheelCollider.enabled = false; - } - - private static void ReadCamera(GameObject o) - { - Camera camera = o.AddComponent(); - camera.clearFlags = (CameraClearFlags)ReadInt(); - camera.backgroundColor = ReadColor(); - camera.cullingMask = ReadInt(); - camera.orthographic = ReadBool(); - camera.fieldOfView = ReadFloat(); - camera.nearClipPlane = ReadFloat(); - camera.farClipPlane = ReadFloat(); - camera.depth = ReadFloat(); - camera.allowHDR = false; - camera.enabled = false; - } - - private static void ReadParticles(GameObject o) - { - KSPParticleEmitter kSPParticleEmitter = o.AddComponent(); - kSPParticleEmitter.emit = ReadBool(); - kSPParticleEmitter.shape = (KSPParticleEmitter.EmissionShape)ReadInt(); - kSPParticleEmitter.shape3D.x = ReadFloat(); - kSPParticleEmitter.shape3D.y = ReadFloat(); - kSPParticleEmitter.shape3D.z = ReadFloat(); - kSPParticleEmitter.shape2D.x = ReadFloat(); - kSPParticleEmitter.shape2D.y = ReadFloat(); - kSPParticleEmitter.shape1D = ReadFloat(); - kSPParticleEmitter.color = ReadColor(); - kSPParticleEmitter.useWorldSpace = ReadBool(); - kSPParticleEmitter.minSize = ReadFloat(); - kSPParticleEmitter.maxSize = ReadFloat(); - kSPParticleEmitter.minEnergy = ReadFloat(); - kSPParticleEmitter.maxEnergy = ReadFloat(); - kSPParticleEmitter.minEmission = ReadInt(); - kSPParticleEmitter.maxEmission = ReadInt(); - kSPParticleEmitter.worldVelocity.x = ReadFloat(); - kSPParticleEmitter.worldVelocity.y = ReadFloat(); - kSPParticleEmitter.worldVelocity.z = ReadFloat(); - kSPParticleEmitter.localVelocity.x = ReadFloat(); - kSPParticleEmitter.localVelocity.y = ReadFloat(); - kSPParticleEmitter.localVelocity.z = ReadFloat(); - kSPParticleEmitter.rndVelocity.x = ReadFloat(); - kSPParticleEmitter.rndVelocity.y = ReadFloat(); - kSPParticleEmitter.rndVelocity.z = ReadFloat(); - kSPParticleEmitter.emitterVelocityScale = ReadFloat(); - kSPParticleEmitter.angularVelocity = ReadFloat(); - kSPParticleEmitter.rndAngularVelocity = ReadFloat(); - kSPParticleEmitter.rndRotation = ReadBool(); - kSPParticleEmitter.doesAnimateColor = ReadBool(); - kSPParticleEmitter.colorAnimation = new Color[5]; - for (int i = 0; i < 5; i++) - { - kSPParticleEmitter.colorAnimation[i] = ReadColor(); - } - kSPParticleEmitter.worldRotationAxis.x = ReadFloat(); - kSPParticleEmitter.worldRotationAxis.y = ReadFloat(); - kSPParticleEmitter.worldRotationAxis.z = ReadFloat(); - kSPParticleEmitter.localRotationAxis.x = ReadFloat(); - kSPParticleEmitter.localRotationAxis.y = ReadFloat(); - kSPParticleEmitter.localRotationAxis.z = ReadFloat(); - kSPParticleEmitter.sizeGrow = ReadFloat(); - kSPParticleEmitter.rndForce.x = ReadFloat(); - kSPParticleEmitter.rndForce.y = ReadFloat(); - kSPParticleEmitter.rndForce.z = ReadFloat(); - kSPParticleEmitter.force.x = ReadFloat(); - kSPParticleEmitter.force.y = ReadFloat(); - kSPParticleEmitter.force.z = ReadFloat(); - kSPParticleEmitter.damping = ReadFloat(); - kSPParticleEmitter.castShadows = ReadBool(); - kSPParticleEmitter.recieveShadows = ReadBool(); - kSPParticleEmitter.lengthScale = ReadFloat(); - kSPParticleEmitter.velocityScale = ReadFloat(); - kSPParticleEmitter.maxParticleSize = ReadFloat(); - switch (ReadInt()) - { - default: - kSPParticleEmitter.particleRenderMode = ParticleSystemRenderMode.Billboard; - break; - case 3: - kSPParticleEmitter.particleRenderMode = ParticleSystemRenderMode.Stretch; - break; - case 4: - kSPParticleEmitter.particleRenderMode = ParticleSystemRenderMode.HorizontalBillboard; - break; - case 5: - kSPParticleEmitter.particleRenderMode = ParticleSystemRenderMode.VerticalBillboard; - break; - } - kSPParticleEmitter.uvAnimationXTile = ReadInt(); - kSPParticleEmitter.uvAnimationYTile = ReadInt(); - kSPParticleEmitter.uvAnimationCycles = ReadInt(); - int num = ReadInt(); - while (num >= matDummies.Count) - { - matDummies.Add(new PartReader.MaterialDummy()); - } - matDummies[num].particleEmitters.Add(kSPParticleEmitter); - } - - #endregion - - #region Mesh parsing - - private static Mesh ReadMesh() - { - Mesh mesh = new Mesh(); - EntryType entryType = (EntryType)ReadInt(); - if (entryType != EntryType.MeshStart) - { - Debug.LogError("Mesh Error"); - return null; - } - - int size = ReadInt(); - SkipInt(); - - int subMeshIndex = 0; - while ((entryType = (EntryType)ReadInt()) != EntryType.MeshEnd) - { - switch (entryType) - { - case EntryType.MeshVertexColors: - { - FillColor32Buffer(size); - mesh.SetColors(color32Buffer, 0, size); - - break; - } - case EntryType.MeshVerts: - { - FillVector3Buffer(size); - mesh.SetVertices(vector3Buffer, 0, size); - - break; - } - case EntryType.MeshUV: - { - FillVector2Buffer(size); - mesh.SetUVs(0, vector2Buffer, 0, size); - break; - } - case EntryType.MeshUV2: - { - FillVector2Buffer(size); - mesh.SetUVs(1, vector2Buffer, 0, size); - break; - } - case EntryType.MeshNormals: - { - FillVector3Buffer(size); - mesh.SetNormals(vector3Buffer, 0, size); - break; - } - case EntryType.MeshTangents: - { - FillVector4Buffer(size); - mesh.SetTangents(vector4Buffer, 0, size); - break; - } - case EntryType.MeshTriangles: - { - if (mesh.subMeshCount == subMeshIndex) - mesh.subMeshCount++; - - int triangleCount = ReadInt(); - FillIntBuffer(triangleCount); - mesh.SetTriangles(intBuffer, 0, triangleCount, subMeshIndex); - subMeshIndex++; - break; - } - case EntryType.MeshBoneWeights: - { - BoneWeight[] boneWeights = new BoneWeight[size]; - for (int i = 0; i < size; i++) - boneWeights[i] = ReadBoneWeight(); - - mesh.boneWeights = boneWeights; - break; - } - case EntryType.MeshBindPoses: - { - int bindPosesCount = ReadInt(); - Matrix4x4[] bindPoses = new Matrix4x4[bindPosesCount]; - for (int i = 0; i < bindPosesCount; i++) - bindPoses[i] = ReadMatrix4x4(); - - mesh.bindposes = bindPoses; - break; - } - } - } - mesh.RecalculateBounds(); - return mesh; - } - - // note : to fill mesh data, we take advantage of the fact that the various - // structs (int, vector3, etc) are sequentially packed in the raw data array. - // Ideally, we would pass the raw data array with a pointer offset to the various - // mesh.Set*() methods, but that would require those methods accepting a Span - // that we would built from an arbitrary pointer offset. - // So we fallback to a memcopy of the raw data to a buffer array that we then - // pass to the unity methods. - - private static unsafe void FillIntBuffer(int intCount) - { - int byteCount = intCount * 4; - int valIdx = Advance(byteCount); - - if (intBuffer == null || intBuffer.Length < intCount) - intBuffer = new int[(int)(intCount * 1.5)]; - - fixed (int* intBufferPtr = intBuffer) - Buffer.MemoryCopy(dataPtr + valIdx, intBufferPtr, byteCount, byteCount); - } - - private static unsafe void FillVector2Buffer(int vector2Count) - { - int byteCount = vector2Count * 8; - int valIdx = Advance(byteCount); - - if (vector2Buffer == null || vector2Buffer.Length < vector2Count) - vector2Buffer = new Vector2[(int)(vector2Count * 1.5)]; - - fixed (Vector2* vector2BufferPtr = vector2Buffer) - Buffer.MemoryCopy(dataPtr + valIdx, vector2BufferPtr, byteCount, byteCount); - } - - private static unsafe void FillVector3Buffer(int vector3Count) - { - int byteCount = vector3Count * 12; - int valIdx = Advance(byteCount); - - if (vector3Buffer == null || vector3Buffer.Length < vector3Count) - vector3Buffer = new Vector3[(int)(vector3Count * 1.5)]; - - fixed (Vector3* vector3BufferPtr = vector3Buffer) - Buffer.MemoryCopy(dataPtr + valIdx, vector3BufferPtr, byteCount, byteCount); - } - - private static unsafe void FillVector4Buffer(int vector4Count) - { - int byteCount = vector4Count * 16; - int valIdx = Advance(byteCount); - - if (vector4Buffer == null || vector4Buffer.Length < vector4Count) - vector4Buffer = new Vector4[(int)(vector4Count * 1.5)]; - - fixed (Vector4* vector4BufferPtr = vector4Buffer) - Buffer.MemoryCopy(dataPtr + valIdx, vector4BufferPtr, byteCount, byteCount); - } - - private static unsafe void FillColor32Buffer(int color32Count) - { - int byteCount = color32Count * 4; - int valIdx = Advance(byteCount); - - if (color32Buffer == null || color32Buffer.Length < color32Count) - color32Buffer = new Color32[(int)(color32Count * 1.5)]; - - fixed (Color32* color32BufferPtr = color32Buffer) - Buffer.MemoryCopy(dataPtr + valIdx, color32BufferPtr, byteCount, byteCount); - } - - - #endregion - - #region Base binary reader infrastructure - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int Advance(int bytes) - { - int currentIndex = index; - int nextIndex = currentIndex + bytes; - if (nextIndex > dataLength) - ThrowEndOfDataException(); - - index = nextIndex; - return currentIndex; - } - - private static void ThrowEndOfDataException() - { - throw new InvalidOperationException("Unable to read beyond the end of the data"); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static unsafe byte ReadByte() - { - int valIdx = Advance(1); - return *(dataPtr + valIdx); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SkipInt() - { - Advance(4); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static unsafe int ReadInt() - { - int valIdx = Advance(4); - return *(int*)(dataPtr + valIdx); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static unsafe float ReadFloat() - { - int valIdx = Advance(4); - return *(float*)(dataPtr + valIdx); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SkipBool() - { - Advance(1); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static unsafe bool ReadBool() - { - int valIdx = Advance(1); - return *(dataPtr + valIdx) != 0; - } - - private static void SkipString() - { - Advance(Read7BitEncodedInt()); - } - - private static string ReadString() - { - int strByteLength = Read7BitEncodedInt(); - - if (strByteLength < 0) - throw new Exception("Invalid string length"); - - if (strByteLength == 0) - return string.Empty; - - ExpandCharBuffer(strByteLength); - - int currentPos = index; - Advance(strByteLength); - - int charCount = decoder.GetChars(data, currentPos, strByteLength, charBuffer, 0); - return new string(charBuffer, 0, charCount); - } - - private static void ExpandCharBuffer(int length) - { - if (charBuffer == null || charBuffer.Length < length) - charBuffer = new char[(int)(length * 1.5)]; - } - - private static int Read7BitEncodedInt() - { - int num = 0; - int num2 = 0; - byte b; - do - { - if (num2 == 35) - { - throw new FormatException("Too many bytes in what should have been a 7 bit encoded Int32."); - } - b = ReadByte(); - num |= (b & 0x7F) << num2; - num2 += 7; - } - while ((b & 0x80u) != 0); - return num; - } - - private static unsafe Vector2 ReadVector2() - { - int valIdx = Advance(8); - return *(Vector2*)(dataPtr + valIdx); - } - - private static unsafe Vector3 ReadVector3() - { - int valIdx = Advance(12); - return *(Vector3*)(dataPtr + valIdx); - } - - private static unsafe Vector4 ReadVector4() - { - int valIdx = Advance(16); - return *(Vector4*)(dataPtr + valIdx); - } - - private static unsafe Quaternion ReadQuaternion() - { - int valIdx = Advance(16); - return *(Quaternion*)(dataPtr + valIdx); - } - - private static unsafe Color ReadColor() - { - int valIdx = Advance(16); - return *(Color*)(dataPtr + valIdx); - } - - private static unsafe Color32 ReadColor32() - { - int valIdx = Advance(4); - return *(Color32*)(dataPtr + valIdx); - } - - private static unsafe BoneWeight ReadBoneWeight() - { - int valIdx = Advance(32); - // data isn't packed with the same layout as the struct, so we fallback to setting every field - return new BoneWeight() - { - boneIndex0 = *(int*)(dataPtr + valIdx), - weight0 = *(float*)(dataPtr + valIdx + 4), - boneIndex1 = *(int*)(dataPtr + valIdx + 8), - weight1 = *(float*)(dataPtr + valIdx + 12), - boneIndex2 = *(int*)(dataPtr + valIdx + 16), - weight2 = *(float*)(dataPtr + valIdx + 20), - boneIndex3 = *(int*)(dataPtr + valIdx + 24), - weight3 = *(float*)(dataPtr + valIdx + 28) - }; - } - - private static unsafe Matrix4x4 ReadMatrix4x4() - { - int valIdx = Advance(64); - // data isn't packed with the same layout as the struct, so we fallback to setting every field - return new Matrix4x4() - { - m00 = *(float*)(dataPtr + valIdx), - m01 = *(float*)(dataPtr + valIdx + 4), - m02 = *(float*)(dataPtr + valIdx + 8), - m03 = *(float*)(dataPtr + valIdx + 12), - m10 = *(float*)(dataPtr + valIdx + 16), - m11 = *(float*)(dataPtr + valIdx + 20), - m12 = *(float*)(dataPtr + valIdx + 24), - m13 = *(float*)(dataPtr + valIdx + 28), - m20 = *(float*)(dataPtr + valIdx + 32), - m21 = *(float*)(dataPtr + valIdx + 36), - m22 = *(float*)(dataPtr + valIdx + 40), - m23 = *(float*)(dataPtr + valIdx + 44), - m30 = *(float*)(dataPtr + valIdx + 48), - m31 = *(float*)(dataPtr + valIdx + 52), - m32 = *(float*)(dataPtr + valIdx + 56), - m33 = *(float*)(dataPtr + valIdx + 60) - }; - } - - private static unsafe Keyframe ReadKeyFrame() - { - // this is encoded as 4 floats (16 bytes), but there is 4 bytes of padding at the end - int valIdx = Advance(20); - return new Keyframe( - *(float*)(dataPtr + valIdx), - *(float*)(dataPtr + valIdx + 4), - *(float*)(dataPtr + valIdx + 8), - *(float*)(dataPtr + valIdx + 12)); - } - - #endregion - } -} diff --git a/KSPCommunityFixes/Performance/FastLoader.cs b/KSPCommunityFixes/Performance/FastLoader.cs index 3485d91..b988e8c 100644 --- a/KSPCommunityFixes/Performance/FastLoader.cs +++ b/KSPCommunityFixes/Performance/FastLoader.cs @@ -562,11 +562,11 @@ static IEnumerator FastAssetLoader(List configFileTypes) switch (file.fileExtension) { case "mu": - modelAssets.Add(new RawAsset(file, RawAsset.AssetType.ModelMU)); + modelAssets.Add(new RawAsset(file)); break; case "dae": case "DAE": - modelAssets.Add(new RawAsset(file, RawAsset.AssetType.ModelDAE)); + modelAssets.Add(new RawAsset(file)); break; default: unsupportedModelFiles.Add(file); @@ -694,9 +694,6 @@ static IEnumerator FastAssetLoader(List configFileTypes) loadedAssetCount += audioFilesLoaded; - // initialize array pool - arrayPool = ArrayPool.Create(1024 * 1024 * 20, 50); - // start texture loading gdb.progressFraction = 0.25f; KSPCFFastLoaderReport.wAudioLoading.Stop(); @@ -791,10 +788,6 @@ static IEnumerator FastAssetLoader(List configFileTypes) } #endif - // all done, do some cleanup - arrayPool = null; - MuParser.ReleaseBuffers(); - QualitySettings.asyncUploadBufferSize = 32; // stock stuff @@ -901,133 +894,8 @@ static IEnumerator AudioLoader(UrlFile urlFile) #region Asset loader reimplementation (texture/model loader) - static ArrayPool arrayPool; - static int loadedBytes; - static object lockObject = new object(); - - /// - /// Textures / models loader coroutine implementing threaded disk reads and framerate decoupling - /// - static IEnumerator FilesLoader(List assets, HashSet loadedUrls, string loadingLabel) - { - GameDatabase gdb = GameDatabase.Instance; - - Deque assetBuffer = new Deque(); - int assetCount = assets.Count; - int currentAssetIndex = 0; - - Thread readerThread = new Thread(() => ReadAssetsThread(assets, assetBuffer)); - readerThread.Start(); - - double nextFrameTime = ElapsedTime + minFrameTimeD; - SpinWait spinWait = new SpinWait(); - - while (currentAssetIndex < assetCount) - { - while (!Monitor.TryEnter(lockObject)) - spinWait.SpinOnce(); - - RawAsset rawAsset; - int bufferTotalSize; - - try - { - if (assetBuffer.Count > 0) - { - rawAsset = assetBuffer.RemoveFromBack(); - loadedBytes -= rawAsset.DataLength; - bufferTotalSize = loadedBytes; - } - else - { - continue; - } - } - finally - { - Monitor.Exit(lockObject); - } - - try - { - if (!loadedUrls.Add(rawAsset.File.url)) - { - rawAsset.Dispose(); - Debug.LogWarning($"Duplicate {rawAsset.TypeName} '{rawAsset.File.url}' with extension '{rawAsset.File.fileExtension}' won't be loaded"); - continue; - } - - Debug.Log($"Load {rawAsset.TypeName}: {rawAsset.File.url}"); - rawAsset.LoadAndDisposeMainThread(); - - if (rawAsset.State == RawAsset.Result.Failed) - { - Debug.LogWarning($"LOAD FAILED : {rawAsset.Message}"); - } - else if (rawAsset.State == RawAsset.Result.Warning) - { - Debug.LogWarning(rawAsset.Message); - } - } - catch (Exception e) - { - Debug.LogException(e); - } - finally - { - loadedAssetCount++; - currentAssetIndex++; - spinWait = new SpinWait(); - } - - if (ElapsedTime > nextFrameTime) - { - nextFrameTime = ElapsedTime + minFrameTimeD; - gdb.progressFraction = (float)loadedAssetCount / totalAssetCount; - gdb.progressTitle = $"{loadingLabel} {currentAssetIndex}/{assetCount} (buffer={bufferTotalSize / (1024 * 1024)}MB)"; - yield return null; - } - } - } - - /// - /// Disk read thread started from FilesLoader - /// - static void ReadAssetsThread(List files, Deque buffer) - { - foreach (RawAsset rawAsset in files) - { - rawAsset.ReadFromDiskWorkerThread(); - - SpinWait spin = new SpinWait(); - bool assetAdded = false; - - while (!assetAdded) - { - while (!Monitor.TryEnter(lockObject)) - spin.SpinOnce(); - - try - { - // load next file if already sum of already loaded file size is less than 50 MB - // or if less than 10 files are loaded - if (loadedBytes < maxBufferSize || buffer.Count < minFileRead) - { - loadedBytes += rawAsset.DataLength; - buffer.AddToFront(rawAsset); - assetAdded = true; - } - } - finally - { - Monitor.Exit(lockObject); - } - } - } - } - /// - /// Asset wrapper class, actual implementation of the disk reader, individual texture/model formats loaders + /// Asset wrapper class, carrier for model files flowing through the background model pipeline /// private class RawAsset { @@ -1044,217 +912,14 @@ public enum AssetType ModelDAE } - private static readonly string[] assetTypeNames = - { - "DDS texture", - "JPG texture", - "MBM texture", - "PNG texture", - "TGA texture", - "TRUECOLOR texture", - "MU model", - "DAE model" - }; - - public enum Result - { - Valid, - Warning, - Failed - } - private UrlFile file; - private AssetType assetType; - private bool useRentedBuffer; - private byte[] buffer; - private int dataLength; - private Result result; - private string resultMessage; public UrlFile File => file; - public Result State => result; - public string Message => resultMessage; - public int DataLength => dataLength; - public string TypeName => assetTypeNames[(int)assetType]; - public RawAsset(UrlFile file, AssetType assetType) + public RawAsset(UrlFile file) { - this.result = Result.Valid; this.file = file; - this.assetType = assetType; - } - - private void SetError(string message) - { - result = Result.Failed; - if (resultMessage == null) - resultMessage = message; - else - resultMessage = $"{resultMessage}\n{message}"; - } - - private void SetWarning(string message) - { - if (result == Result.Failed) - { - if (resultMessage == null) - resultMessage = message; - else - resultMessage = $"{resultMessage}\nWARNING: {message}"; - } - else - { - result = Result.Warning; - if (resultMessage == null) - resultMessage = message; - else - resultMessage = $"{resultMessage}\n{message}"; - } - } - - public void ReadFromDiskWorkerThread() - { - switch (assetType) - { - case AssetType.ModelMU: - case AssetType.ModelDAE: - useRentedBuffer = true; - break; - } - - try - { - string path = file.fullPath; - - using (FileStream fileStream = System.IO.File.OpenRead(path)) - { - long length = fileStream.Length; - if (length > int.MaxValue) - { - throw new IOException("Reading more than 2GB with this call is not supported"); - } - dataLength = (int)length; - int offset = 0; - int count = dataLength; - - // Don't use array pool for small files < 1KB (allocating is faster) - // Don't use array pool for huge files > 20MB (memory usage concerns) - if (useRentedBuffer) - if (dataLength < 1024 || dataLength > 1024 * 1024 * 20) - useRentedBuffer = false; - - if (useRentedBuffer) - buffer = arrayPool.Rent(dataLength); - else - buffer = new byte[dataLength]; - - try - { - while (count > 0) - { - int read = fileStream.Read(buffer, offset, count); - if (read == 0) - { - throw new IOException("Unexpected end of stream"); - } - offset += read; - count -= read; - } - } - catch - { - if (useRentedBuffer) - { - arrayPool.Return(buffer); - buffer = null; - } - throw; - } - } - } - catch (Exception e) - { - SetError(e.Message); - } - } - - public void LoadAndDisposeMainThread() - { - try - { - if (result == Result.Failed) - return; - - if (file.fileType == FileType.Model) - { - GameObject model; - switch (assetType) - { - case AssetType.ModelMU: - model = LoadMU(); - break; - case AssetType.ModelDAE: - model = LoadDAE(); - break; - default: - SetError("Unknown model format"); - return; - } - - if (result == Result.Failed || model.IsNullOrDestroyed()) - { - result = Result.Failed; - if (string.IsNullOrEmpty(resultMessage)) - resultMessage = $"{TypeName} load error"; - } - else - { - model.transform.name = file.url; - model.transform.parent = Instance.transform; - model.transform.localPosition = Vector3.zero; - model.transform.localRotation = Quaternion.identity; - model.SetActive(false); - Instance.databaseModel.Add(model); - Instance.databaseModelFiles.Add(file); - modelsByUrl[file.url] = model; - // if multiple models in the same dir, we only add the first - // to ensure identical behavior as the GameDatabase.GetModelPrefabIn() method - modelsByDirectoryUrl.TryAdd(file.parent.url, model); - urlFilesByModel.Add(model, file); - KSPCFFastLoaderReport.modelsBytesLoaded += dataLength; - KSPCFFastLoaderReport.modelsLoaded++; - } - } - } - catch (Exception e) - { - SetError(e.ToString()); - } - finally - { - Dispose(); - } - } - - public void Dispose() - { - if (useRentedBuffer) - arrayPool.Return(buffer); - } - - private GameObject LoadMU() - { - return MuParser.Parse(file.parent.url, buffer, dataLength); - } - - // Body extracted into the shared static KSPCFFastLoader.LoadDAE(UrlFile) so the new model - // pipeline's Dae path can reuse it. This wrapper (and its only caller, LoadAndDisposeMainThread's - // model path) is dead once the driver replaces FilesLoader; task #6 removes it. - private GameObject LoadDAE() - { - return KSPCFFastLoader.LoadDAE(file); } - } #endregion @@ -1540,16 +1205,13 @@ private static IEnumerator InsertBundledTextures( // Outcome of compiling one model file off-thread. Mutually-exclusive shapes: a .dae marker // (IsDae), a file-read failure (Compiled == null, ReadFailure set), or a compiled model (Compiled - // set, Data retained for the skinned fallback). The compile SUCCESS/SKINNED/COMPILE-FAILED split is - // decided in the fold from Compiled's flags. + // set). The compile SUCCESS/COMPILE-FAILED split is decided in the fold from Compiled's flags. private struct CompileResult { public UrlFile File; public bool IsDae; public string ReadFailure; public CompiledModel Compiled; - public byte[] Data; - public int DataLength; public long FileLength; } @@ -1584,8 +1246,6 @@ private static CompileResult CompileOne(RawAsset asset, ThreadLocal Date: Tue, 14 Jul 2026 20:43:22 -0700 Subject: [PATCH 12/14] Cleanup, make performance better --- .../Library/Model/CompiledModel.cs | 7 - KSPCommunityFixes/Library/Model/ModelGroup.cs | 52 +- .../Library/Model/MuModelCompiler.cs | 203 +++--- KSPCommunityFixes/Performance/FastLoader.cs | 655 +++++++----------- 4 files changed, 351 insertions(+), 566 deletions(-) diff --git a/KSPCommunityFixes/Library/Model/CompiledModel.cs b/KSPCommunityFixes/Library/Model/CompiledModel.cs index 6147b34..cffb714 100644 --- a/KSPCommunityFixes/Library/Model/CompiledModel.cs +++ b/KSPCommunityFixes/Library/Model/CompiledModel.cs @@ -33,13 +33,6 @@ internal sealed class CompiledModel /// The source file.url — used as the root name, for logging and for registration. public string SourceUrl; - /// True when compilation failed; / are then - /// meaningless and the pipeline must fall back. - public bool Failed; - - /// Human-readable reason set when is true. - public string FailureMessage; - /// Diagnostics collected on the worker thread during compilation. KSP installs its own /// ILogHandler and mods chain handlers onto Application.logMessageReceived, none of /// which are thread-safe, so these must NEVER be logged off-thread; they are buffered here and diff --git a/KSPCommunityFixes/Library/Model/ModelGroup.cs b/KSPCommunityFixes/Library/Model/ModelGroup.cs index f960ee9..54a35bd 100644 --- a/KSPCommunityFixes/Library/Model/ModelGroup.cs +++ b/KSPCommunityFixes/Library/Model/ModelGroup.cs @@ -1,40 +1,28 @@ using System.Collections.Generic; +using System.Threading.Tasks; using UnityEngine; -namespace KSPCommunityFixes.Library.Model -{ - /// - /// A contiguous, ordered span of model load requests whose static meshes are baked into ONE mesh - /// AssetBundle. The background compile task folds the ordered compile results into count-capped - /// groups (so no single becomes huge and native bundle - /// copies drain often), then the main-thread pump loads each group's bundle and forwards its requests - /// to the driver. Every request in (compiled/skinned/dae/failed) rides the span - /// in modelAssets order so registration preserves stock load order. - /// - internal sealed class ModelGroup - { - /// The baked mesh-bundle bytes (Phase-2 output). Null when the group contains no static - /// meshes at all. The main-thread pump nulls this the moment it kicks off - /// so the managed copy can be collected. - public byte[] BundleBytes; +namespace KSPCommunityFixes.Library.Model; - /// The in-flight bundle load, set by the main-thread pump. Null until the pump processes - /// this group (and stays null when was null — a mesh-less group). - public AssetBundleCreateRequest CreateRequest; +/// +/// A contiguous, ordered span of model load requests whose static meshes are baked into ONE mesh +/// AssetBundle. The background compile task folds the ordered compile results into count-capped +/// groups (so no single becomes huge and native bundle +/// copies drain often), then the main-thread pump loads each group's bundle and forwards its requests +/// to the driver. Every request in (compiled/skinned/dae/failed) rides the span +/// in modelAssets order so registration preserves stock load order. +/// +internal sealed class ModelGroup +{ + /// The baked mesh-bundle bytes (Phase-2 output). Null when the group contains no static + /// meshes at all. The main-thread pump nulls this the moment it kicks off + /// so the managed copy can be collected. + public byte[] BundleBytes; - /// The native bundle byte count, captured by the pump from right - /// before it nulls that managed copy. Feeds the pump's resident-memory backpressure accounting: the - /// pump adds it when it kicks off the load and the driver subtracts it on Unload. Stays 0 for a - /// mesh-less (bundle-less) group, so subtracting it is a harmless no-op. - public long BundleSize; + public Task> Index; - /// ALL requests in this group's modelAssets span, in order. - public List Requests; + public AssetBundle Bundle; - /// Number of requests in the group. - /// Decremented in InsertReadyModel as each registers; when it reaches 0 the group's - /// bundle is Unload(false)ed (freeing the native copy, keeping the meshes the built - /// GameObjects now own). - public int PendingBundleRefs; - } + /// ALL requests in this group's modelAssets span, in order. + public List Requests; } diff --git a/KSPCommunityFixes/Library/Model/MuModelCompiler.cs b/KSPCommunityFixes/Library/Model/MuModelCompiler.cs index 777d76c..3cdf0df 100644 --- a/KSPCommunityFixes/Library/Model/MuModelCompiler.cs +++ b/KSPCommunityFixes/Library/Model/MuModelCompiler.cs @@ -107,56 +107,41 @@ public CompiledModel Compile(string fileUrl, string directoryUrl, byte[] data, i this.fileUrl = fileUrl; this.directoryUrl = directoryUrl; - try - { - int length = dataLength <= 0 ? data.Length : dataLength; - // Pin for the whole walk: MuBinaryReader holds a raw byte*, so the buffer must stay - // pinned for the entire parse. A single fixed block around the walk matches MuParser's - // GCHandle-pinned scope; nothing reads the stream after this block closes. - fixed (byte* p = data) - { - reader = new MuBinaryReader(p, length); + int length = dataLength <= 0 ? data.Length : dataLength; - if (reader.ReadInt() != 76543) - throw new Exception("Invalid mu file"); + // Pin for the whole walk: MuBinaryReader holds a raw byte*, so the buffer must stay + // pinned for the entire parse. A single fixed block around the walk matches MuParser's + // GCHandle-pinned scope; nothing reads the stream after this block closes. + fixed (byte* p = data) + { + reader = new MuBinaryReader(p, length); - version = reader.ReadInt(); - reader.SkipString(); + if (reader.ReadInt() != 76543) + throw new Exception("Invalid mu file"); - // Root is the first thing allocated, so it lands in slot 0 (parent = -1 == none). - ReadChild(-1); - } + version = reader.ReadInt(); + reader.SkipString(); - // Two-pass finalize (see FinalizeMaterials): materials + textures are known now, so emit - // CreateMaterial/AssignMaterial in ascending material-index order, then the deferred bone - // resolution (MuParser runs AffectSkinnedMeshRenderersBones last). - FinalizeMaterials(); - FinalizeBones(); - - return new CompiledModel - { - SourceUrl = fileUrl, - Instructions = instructions.ToArray(), - Blobs = blobs.ToArray(), - Bindings = bindings.ToArray(), - LocalCount = nextSlot, - Failed = false, - Logs = logs, // flushed on the main thread by the replay pipeline (see FlushLogs) - }; + // Root is the first thing allocated, so it lands in slot 0 (parent = -1 == none). + ReadChild(-1); } - catch (Exception e) + + // Two-pass finalize (see FinalizeMaterials): materials + textures are known now, so emit + // CreateMaterial/AssignMaterial in ascending material-index order, then the deferred bone + // resolution (MuParser runs AffectSkinnedMeshRenderersBones last). + FinalizeMaterials(); + FinalizeBones(); + + return new CompiledModel { - // SourceUrl is set first so the pipeline can log which file failed. Compile never throws. - // A Failed model still carries any diagnostics buffered before the fault so they aren't lost. - return new CompiledModel - { - SourceUrl = fileUrl, - Failed = true, - FailureMessage = e.GetType().Name + ": " + e.Message, - Logs = logs, - }; - } + SourceUrl = fileUrl, + Instructions = [.. instructions], + Blobs = [.. blobs], + Bindings = [.. bindings], + LocalCount = nextSlot, + Logs = logs, + }; } private void ResetState() @@ -778,7 +763,7 @@ private PendingMaterial ReadMaterial4() case 4: ReadMaterialTexture(pm, propName); break; - // No default: an unknown type code consumes nothing beyond the name, like MuParser. + // No default: an unknown type code consumes nothing beyond the name, like MuParser. } } @@ -1004,79 +989,79 @@ private int ReadMesh() switch (subType) { case EntryType.MeshVertexColors: - { - var colors = new Color32[size]; - reader.FillColor32Buffer(colors, size); - arrays.Colors = colors; - break; - } + { + var colors = new Color32[size]; + reader.FillColor32Buffer(colors, size); + arrays.Colors = colors; + break; + } case EntryType.MeshVerts: - { - var verts = new Vector3[size]; - reader.FillVector3Buffer(verts, size); - arrays.Vertices = verts; - break; - } + { + var verts = new Vector3[size]; + reader.FillVector3Buffer(verts, size); + arrays.Vertices = verts; + break; + } case EntryType.MeshUV: - { - var uv0 = new Vector2[size]; - reader.FillVector2Buffer(uv0, size); - arrays.Uv0 = uv0; - break; - } + { + var uv0 = new Vector2[size]; + reader.FillVector2Buffer(uv0, size); + arrays.Uv0 = uv0; + break; + } case EntryType.MeshUV2: - { - var uv1 = new Vector2[size]; - reader.FillVector2Buffer(uv1, size); - arrays.Uv1 = uv1; - break; - } + { + var uv1 = new Vector2[size]; + reader.FillVector2Buffer(uv1, size); + arrays.Uv1 = uv1; + break; + } case EntryType.MeshNormals: - { - var normals = new Vector3[size]; - reader.FillVector3Buffer(normals, size); - arrays.Normals = normals; - break; - } + { + var normals = new Vector3[size]; + reader.FillVector3Buffer(normals, size); + arrays.Normals = normals; + break; + } case EntryType.MeshTangents: - { - var tangents = new Vector4[size]; - reader.FillVector4Buffer(tangents, size); - arrays.Tangents = tangents; - break; - } + { + var tangents = new Vector4[size]; + reader.FillVector4Buffer(tangents, size); + arrays.Tangents = tangents; + break; + } case EntryType.MeshTriangles: - { - int triangleCount = reader.ReadInt(); - var tris = new int[triangleCount]; - reader.FillIntBuffer(tris, triangleCount); - triangles.Add(tris); // one submesh per MeshTriangles block, in encounter order - break; - } + { + int triangleCount = reader.ReadInt(); + var tris = new int[triangleCount]; + reader.FillIntBuffer(tris, triangleCount); + triangles.Add(tris); // one submesh per MeshTriangles block, in encounter order + break; + } case EntryType.MeshBoneWeights: - { - // Skinned seam: one BoneWeight per vertex (four weights + four bone indices). - // Stored now (was discarded before skin support) so MeshBlobBuilder emits the - // BlendWeights (ch12) and BlendIndices (ch13) vertex channels. Same read order and - // count as the oracle, so cursor parity is preserved. - var boneWeights = new BoneWeight[size]; - for (int i = 0; i < size; i++) - boneWeights[i] = reader.ReadBoneWeight(); - arrays.BoneWeights = boneWeights; - break; - } + { + // Skinned seam: one BoneWeight per vertex (four weights + four bone indices). + // Stored now (was discarded before skin support) so MeshBlobBuilder emits the + // BlendWeights (ch12) and BlendIndices (ch13) vertex channels. Same read order and + // count as the oracle, so cursor parity is preserved. + var boneWeights = new BoneWeight[size]; + for (int i = 0; i < size; i++) + boneWeights[i] = reader.ReadBoneWeight(); + arrays.BoneWeights = boneWeights; + break; + } case EntryType.MeshBindPoses: - { - // One bind pose per bone; its length defines the bone count. Stored now (was - // discarded before skin support) so the blob carries m_BindPose and its bone - // metadata. Same read order and count as the oracle, so cursor parity is preserved. - int bindPosesCount = reader.ReadInt(); - var bindPoses = new Matrix4x4[bindPosesCount]; - for (int i = 0; i < bindPosesCount; i++) - bindPoses[i] = reader.ReadMatrix4x4(); - arrays.BindPoses = bindPoses; - break; - } + { + // One bind pose per bone; its length defines the bone count. Stored now (was + // discarded before skin support) so the blob carries m_BindPose and its bone + // metadata. Same read order and count as the oracle, so cursor parity is preserved. + int bindPosesCount = reader.ReadInt(); + var bindPoses = new Matrix4x4[bindPosesCount]; + for (int i = 0; i < bindPosesCount; i++) + bindPoses[i] = reader.ReadMatrix4x4(); + arrays.BindPoses = bindPoses; + break; + } } } diff --git a/KSPCommunityFixes/Performance/FastLoader.cs b/KSPCommunityFixes/Performance/FastLoader.cs index b988e8c..b680a19 100644 --- a/KSPCommunityFixes/Performance/FastLoader.cs +++ b/KSPCommunityFixes/Performance/FastLoader.cs @@ -162,7 +162,7 @@ internal class KSPCFFastLoader : MonoBehaviour // Max number of new model load coroutines that will be spawned each frame. // This roughly limits the max frame time spent replaying models / loading their meshes. - private const int MaxModelSpawnsPerFrame = 12; + private const int MaxModelSpawnsPerFrame = 64; // v1 tuning knob: cap on native mesh-bundle bytes resident at once. The pump waits before kicking off a // group's LoadFromMemoryAsync while at least this many bytes are already resident, so the driver can @@ -172,17 +172,6 @@ internal class KSPCFFastLoader : MonoBehaviour private const int ModelGroupQueueCapacity = 4; // groups the compile task may run ahead of the pump; bounds managed bundle-byte pressure (tuning knob) - // Native mesh-bundle bytes currently resident: the pump ADDS a group's size when it kicks off the - // load, the driver SUBTRACTS it on Unload. Both the pump and the driver are main-thread Unity - // coroutines that never run concurrently, so this plain field needs no lock. Reset when the model - // pipeline is kicked off. - private static long residentModelBundleBytes; - - // Set by CompileModelGroups' last-resort outer catch on the background Task thread; logged ONCE on the - // main thread by ModelDriverCoroutine at termination. NEVER Debug.* from the Task thread (the mod - // handlers chained onto Application.logMessageReceived aren't thread-safe). - private static volatile Exception modelCompileFault; - private static Harmony persistentHarmony; private static string PersistentHarmonyID => typeof(KSPCFFastLoader).FullName; @@ -595,20 +584,10 @@ static IEnumerator FastAssetLoader(List configFileTypes) BundleState bundleState = new(); gdb.StartCoroutine(LoadBundledAssets(bundleState, bundleRequests, textureQueue)); - // Kick off the background model compile + bundle pump NOW so it overlaps texture loading. - // CompileModelGroups classifies/compiles every .mu off-thread and folds the ordered results into - // count-capped ModelGroups; ModelBundlePumpCoroutine kicks off each group's mesh-bundle - // LoadFromMemoryAsync and forwards its requests (in order) into modelQueue. The DRIVER is NOT - // started here: replaying a model's CreateMaterial.Execute needs every texture registered, which - // is only true after InsertBundledTextures below, so the driver is started at the model stage. - var groupQueue = new BlockingCollection(ModelGroupQueueCapacity); + // Kick off the background model build var modelQueue = new BlockingCollection(); - // Reset the shared pump<->driver state before the pipeline starts (both are static, so a prior - // load in this process could have left them non-zero/non-null). - residentModelBundleBytes = 0; - modelCompileFault = null; - Task.Run(() => CompileModelGroups(modelAssets, groupQueue)); - gdb.StartCoroutine(ModelBundlePumpCoroutine(groupQueue, modelQueue)); + var modelTask = Task.Run(() => CompileModelGroups(modelAssets)); + gdb.StartCoroutine(PreloadModelBundles(modelTask, modelQueue)); gdb.progressTitle = "Loading sound assets..."; KSPCFFastLoaderReport.wAudioLoading.Restart(); @@ -714,8 +693,6 @@ static IEnumerator FastAssetLoader(List configFileTypes) // Now wait for all asset bundle textures to finish yield return gdb.StartCoroutine(InsertBundledTextures(bundleState, allTextureFiles, textureCount)); - QualitySettings.asyncUploadTimeSlice = 2; - // start model loading gdb.progressFraction = 0.75f; KSPCFFastLoaderReport.wTextureLoading.Stop(); @@ -768,11 +745,8 @@ static IEnumerator FastAssetLoader(List configFileTypes) } } - // call our custom loader: drain the model pipeline (compiled meshes replayed from their group - // bundle, skinned/dae fallbacks, failures) in strict modelAssets order. The compile task + pump - // started overlapping texture loading above; the driver only registers models now that every - // texture is in the database (CreateMaterial replay resolves textures via GameDatabase). - yield return gdb.StartCoroutine(ModelDriverCoroutine(modelQueue, allModelFiles, modelAssets.Count)); + // load all models + yield return gdb.StartCoroutine(ModelDriverCoroutine(modelQueue, allModelFiles, modelAssets.Count, modelTask)); #if DEBUG_MODEL_LOAD_ORDER // Optional load-order dump for an old-vs-new diff (enable via the #define at the top of the file). @@ -788,6 +762,7 @@ static IEnumerator FastAssetLoader(List configFileTypes) } #endif + QualitySettings.asyncUploadTimeSlice = 2; QualitySettings.asyncUploadBufferSize = 32; // stock stuff @@ -1203,333 +1178,213 @@ private static IEnumerator InsertBundledTextures( #region Model bundle loader - // Outcome of compiling one model file off-thread. Mutually-exclusive shapes: a .dae marker - // (IsDae), a file-read failure (Compiled == null, ReadFailure set), or a compiled model (Compiled - // set). The compile SUCCESS/COMPILE-FAILED split is decided in the fold from Compiled's flags. - private struct CompileResult + private readonly struct QueueWriteGuard(BlockingCollection queue) : IDisposable { - public UrlFile File; - public bool IsDae; - public string ReadFailure; - public CompiledModel Compiled; - public long FileLength; + public void Dispose() => queue.CompleteAdding(); } - // Compile one model file. MUST NOT throw (a faulted PLINQ Select would abort the whole enumeration): - // the only thrower here is File.ReadAllBytes, caught into a ReadFailure marker; MuModelCompiler.Compile - // never throws. - private static CompileResult CompileOne(RawAsset asset, ThreadLocal tl) + static readonly ProfilerMarker s_pmCompileOne = new("KSPCF.CompileOne"); + private static ModelLoadRequest CompileOne(RawAsset asset, ThreadLocal tl) { - UrlFile file = asset.File; + using var scope = s_pmCompileOne.Auto(); - // .dae/.DAE never touch the compiler; the main-thread Dae path reloads them via the stock loader. - string ext = file.fileExtension; - if (ext == "dae" || ext == "DAE") - return new CompileResult { File = file, IsDae = true }; + UrlFile file = asset.File; + ModelLoadRequest req = new() + { + File = file, + }; - byte[] data; try { - // Plain managed array (NOT arrayPool): avoids cross-thread pool contention with the disk reader. - data = System.IO.File.ReadAllBytes(file.fullPath); + + // .dae/.DAE never touch the compiler; the main-thread Dae path reloads them via the stock loader. + string ext = file.fileExtension; + if (ext == "dae" || ext == "DAE") + { + req.ModelKind = ModelLoadRequest.Kind.Dae; + return req; + } + + byte[] data = File.ReadAllBytes(file.fullPath); + var cm = tl.Value.Compile(file.url, file.parent.url, data, data.Length); + + req.Compiled = cm; + req.FileLength = data.Length; + req.ModelKind = ModelLoadRequest.Kind.CompiledMu; } catch (Exception e) { - return new CompileResult { File = file, ReadFailure = e.Message }; + req.FailureMessage = $"{e.GetType()}: {e}"; + req.ModelKind = ModelLoadRequest.Kind.Failed; } - // Args mirror MuParser.Parse(file.parent.url, buffer, dataLength): fileUrl == file.url, - // directoryUrl == file.parent.url. Per-thread compiler (ResetState makes reuse safe). - CompiledModel cm = tl.Value.Compile(file.url, file.parent.url, data, data.Length); + return req; + } - return new CompileResult + static readonly ProfilerMarker s_pmCompileModelGroup = new("KSPCF.CompileModelGroup"); + private static ModelGroup CompileModelGroup( + IEnumerable results) + { + using var scope = s_pmCompileModelGroup.Auto(); + var requests = results.ToList(); + + ModelGroup group = new() { - File = file, - Compiled = cm, - FileLength = data.Length, + Requests = requests, }; - } - // Phase 1/2 background task (analogue of BuildDDSBundle): a parallel ORDERED PLINQ query compiles - // every model off-thread, and a serial fold on THIS single background thread groups the ordered - // results into count-capped ModelGroups, baking each group's static meshes into one mesh bundle via - // MeshBundleBuilder.BuildMany. AsOrdered + in-order span append is the first link of the load-order - // chain (pump forward + FIFO drain are the rest). Always CompleteAdding(groupQueue) in finally so the - // consumer terminates even on fault. - private static void CompileModelGroups( - List modelAssets, - BlockingCollection groupQueue) - { - // Tuning knob: max compiled (non-skinned) .mu models per bundle. Larger groups amortize - // LoadFromMemoryAsync overhead; smaller groups start spawning sooner and drain native bundle - // copies more often. - const int GroupModelCap = 512; + var blobs = new List(requests.Count); + var bundleReqs = new List(requests.Count); - // m9: the WHOLE body runs inside this try so groupQueue.CompleteAdding() (finally, below) fires even - // if the pre-loop setup (ThreadLocal / list allocations, PLINQ query construction) throws under OOM. - // Otherwise the pump and driver would hang forever on a queue that never completes. try { - // Per-thread compiler: MuModelCompiler holds mutable per-file accumulators, so it is NEVER - // shared/static; ResetState (top of Compile) makes reuse across files on one worker safe. - using var tl = new ThreadLocal(() => new MuModelCompiler()); + foreach (var req in requests) + { + if (req.ModelKind != ModelLoadRequest.Kind.CompiledMu) + continue; + + blobs.AddRange(req.Compiled.Blobs); + + req.Compiled.Blobs = null; // now lives in the bundle + req.Group = group; + } - var span = new List(); - var blobs = new List(); - var groupKeys = new HashSet(StringComparer.Ordinal); - int modelCount = 0; + if (blobs.Count == 0) + return group; - // Seal the current span+blobs into a group, build its bundle, free per-request geometry, hand - // it off, then start fresh. Never runs on an empty span in practice (all call sites guard it). - void Flush() + group.BundleBytes = MeshBundleBuilder.BuildMany(blobs); + } + catch (Exception ex) + { + group.BundleBytes = null; + + var message = $"failed to build mesh bundle: {ex}"; + foreach (var req in requests) { - int compiledCount = 0; - for (int i = 0; i < span.Count; i++) - if (span[i].ModelKind == ModelLoadRequest.Kind.CompiledMu) - compiledCount++; + if (req.ModelKind != ModelLoadRequest.Kind.CompiledMu) + continue; - var group = new ModelGroup - { - Requests = span, - PendingBundleRefs = compiledCount, - }; + req.ModelKind = ModelLoadRequest.Kind.Failed; + req.FailureMessage = message; + if (req.Compiled is not null) + req.Compiled.Blobs = null; + } + } - try - { - // BuildMany reads the blob list now (throws on duplicate canonical keys; prevented by - // the per-group key-set split below). Null bundle when the span has no static meshes. - group.BundleBytes = blobs.Count == 0 ? null : MeshBundleBuilder.BuildMany(blobs); + return group; + } - for (int i = 0; i < span.Count; i++) - { - ModelLoadRequest r = span[i]; - if (r.ModelKind == ModelLoadRequest.Kind.CompiledMu) - { - r.Group = group; - r.Compiled.Blobs = null; // geometry now lives in the bundle; free managed copy - } - } - } - catch (Exception buildEx) - { - // M2: isolate a mesh-bundle build failure to THIS group instead of aborting the whole - // fold. HARD-FAIL every CompiledMu request in the group (surfaced by InsertReadyModel's - // LOAD FAILED path — no MuParser fallback, so a real compiler/mesh bug is discovered - // during in-KSP validation), drop the (absent) bundle and its ref-count, and still - // forward the group IN ORDER so load order is preserved. - string msg = "mesh bundle build failed: " + buildEx.Message; - for (int i = 0; i < span.Count; i++) - { - ModelLoadRequest r = span[i]; - if (r.ModelKind == ModelLoadRequest.Kind.CompiledMu) - { - r.ModelKind = ModelLoadRequest.Kind.Failed; - r.FailureMessage = msg; - if (r.Compiled != null) - r.Compiled.Blobs = null; - } - } - group.BundleBytes = null; - group.PendingBundleRefs = 0; - } + static readonly ProfilerMarker s_pmCompileModelGroups = new("KSPCF.CompileModelGroups"); - groupQueue.Add(group); + // Build asset bundles for individual models. + private static async Task> CompileModelGroups( + List modelAssets) + { + // The max number of models we are willing to shove in the same bundle. + const int GroupModelCap = 512; - span = new List(); - blobs = new List(); - groupKeys.Clear(); - modelCount = 0; - } + using var scope = s_pmCompileModelGroups.Auto(); - // AsOrdered() => the fold observes results in modelAssets order (load-order parity link #1). - foreach (CompileResult rec in modelAssets - .AsParallel() - .AsOrdered() - .Select(asset => CompileOne(asset, tl))) - { - var req = new ModelLoadRequest - { - File = rec.File, - FileLength = rec.FileLength, - }; + // MuModelCompiler has internal shared state, + using var tl = new ThreadLocal(() => new MuModelCompiler()); - if (rec.IsDae) - { - req.ModelKind = ModelLoadRequest.Kind.Dae; - span.Add(req); // rides the span for ordering; contributes no blobs - } - else if (rec.Compiled == null) - { - // File read failed: hard failure (no Compiled to flush). - req.ModelKind = ModelLoadRequest.Kind.Failed; - req.FailureMessage = rec.ReadFailure; - span.Add(req); - } - else - { - CompiledModel cm = rec.Compiled; - req.Compiled = cm; // carried for FlushLogs (and, for CompiledMu, replay) + return modelAssets + .AsParallel() + .Select((asset, index) => (index, CompileOne(asset, tl))) + .GroupBy<(int index, ModelLoadRequest request), int>((elem) => elem.index / GroupModelCap) + .Select(group => (group.Key, CompileModelGroup(group.Select(elem => elem.request)))) + .OrderBy<(int index, ModelGroup group), int>(elem => elem.index) + .Select(elem => elem.group) + .ToList(); + } - if (cm.Failed) - { - // Compilation failed: hard failure. cm carries its buffered diagnostics; its baked - // geometry is never used. - req.ModelKind = ModelLoadRequest.Kind.Failed; - req.FailureMessage = cm.FailureMessage; - cm.Blobs = null; - span.Add(req); - } - else - { - req.ModelKind = ModelLoadRequest.Kind.CompiledMu; - - // Per-group duplicate-canonical-key split: two model files sharing a url (the reason - // the whole dedup machinery exists) emit identical mesh names ("{url}#i"), which would - // make BuildMany throw and fail the ENTIRE group. Flush first so each duplicate lands - // in its own bundle; the duplicate is then dropped first-wins at registration. - // MeshBlob.Name is already canonical (idempotent under Canonicalize), so we key on it - // directly, matching BuildMany's canonical-key dedup. - MeshBlob[] cmBlobs = cm.Blobs; - bool collides = false; - for (int i = 0; i < cmBlobs.Length; i++) - { - if (groupKeys.Contains(cmBlobs[i].Name)) - { - collides = true; - break; - } - } - if (collides) - Flush(); + static readonly ProfilerMarker s_pmLoadModelBundle = new("KSPCF.LoadModelBundleAsync"); - for (int i = 0; i < cmBlobs.Length; i++) - { - blobs.Add(cmBlobs[i]); - groupKeys.Add(cmBlobs[i].Name); - } - span.Add(req); + // This task waits for the bundle builder task to complete, then kicks + private static IEnumerator PreloadModelBundles( + Task> groupTask, + BlockingCollection modelQueue) + { + using var wguard = new QueueWriteGuard(modelQueue); + var gdb = GameDatabase.Instance; - if (++modelCount == GroupModelCap) - Flush(); - } - } - } + while (!groupTask.IsCompleted) + yield return null; - // Trailing partial group (may carry a null bundle if it is all dae/skinned/failed). - if (span.Count > 0) - Flush(); + List groups; + try + { + groups = groupTask.Result; } - catch (Exception e) + catch (Exception ex) { - // M2 last resort: with per-group Flush isolation this should almost never fire (a faulted PLINQ - // enumeration surfaces as AggregateException at the foreach above). Do NOT Debug.* from this - // background Task thread — the mod handlers chained onto Application.logMessageReceived aren't - // thread-safe (the exact hazard DeferredLog exists to avoid). Stash it for ModelDriverCoroutine - // to log ONCE on the main thread when it terminates. - modelCompileFault = e; + Debug.LogError("Failed to build asset bundles for models"); + Debug.LogException(ex); + yield break; } - finally + + foreach (var group in groups) { - // Guarantees the pump (and therefore the driver) terminates even on fault. Mirrors - // BuildDDSBundle's textureQueue.CompleteAdding(). - groupQueue.CompleteAdding(); + if (group.BundleBytes is null) + continue; + + using var scope = s_pmLoadModelBundle.Auto(); + + var request = AssetBundle.LoadFromStreamAsync(new MemoryStream(group.BundleBytes)); + request.priority = -15; + + foreach (var req in group.Requests) + modelQueue.Add(req); + + gdb.StartCoroutine(PopulateModelIndex(group, request)); } } - // Phase 3 main-thread pump: drains finished ModelGroups, kicks off each group's mesh-bundle load, and - // forwards its requests (in order) into modelQueue for the driver. Poll form (TryTake) so it never - // blocks a coroutine thread on GetConsumingEnumerable. Completes modelQueue in finally. - private static IEnumerator ModelBundlePumpCoroutine( - BlockingCollection groupQueue, - BlockingCollection modelQueue) + readonly struct TaskLostGuard(TaskCompletionSource tcs) : IDisposable { - try - { - while (!groupQueue.IsCompleted) - { - while (groupQueue.TryTake(out ModelGroup group)) - { - if (group.BundleBytes != null) - { - // Capture the native size before nulling the managed copy; BundleSize feeds the - // resident-memory accounting here and the driver's Unload adjust. - group.BundleSize = group.BundleBytes.Length; - - // M1 backpressure: bound resident native bundle memory. Wait until the driver has - // Unloaded enough earlier groups to make room before kicking this one off. The - // residentModelBundleBytes > 0 guard guarantees a single oversized group still loads - // once everything before it has drained (no deadlock). This yield loop sits OUTSIDE - // the LoadFromMemoryAsync try/catch below on purpose — a try with a catch may not - // contain a yield. - while (residentModelBundleBytes > 0 && - residentModelBundleBytes + group.BundleSize > MaxResidentModelBundleBytes) - yield return null; - - bool loadFailed = false; - string loadFailMsg = null; - try - { - group.CreateRequest = AssetBundle.LoadFromMemoryAsync(group.BundleBytes); - // Match the texture bundle: low priority so third-party bundle loads aren't - // starved. - group.CreateRequest.priority = -10; - } - catch (Exception e) - { - // M4: isolate a LoadFromMemoryAsync failure to this group. HARD-FAIL its - // CompiledMu requests (no MuParser fallback) and still forward ALL requests in - // order below so load order is preserved. - loadFailed = true; - loadFailMsg = "bundle load failed: " + e.Message; - } + public void Dispose() => tcs.TrySetCanceled(); + } - group.BundleBytes = null; // drop the managed copy either way; the bundle owns it now + private static IEnumerator PopulateModelIndex( + ModelGroup group, + AssetBundleCreateRequest bundleRequest + ) + { + var tcs = new TaskCompletionSource>(); + group.Index = tcs.Task; + using var guard = new TaskLostGuard>(tcs); - if (loadFailed) - { - group.CreateRequest = null; - List greqs = group.Requests; - for (int i = 0; i < greqs.Count; i++) - { - ModelLoadRequest r = greqs[i]; - if (r.ModelKind == ModelLoadRequest.Kind.CompiledMu) - { - r.ModelKind = ModelLoadRequest.Kind.Failed; - r.FailureMessage = loadFailMsg; - } - } - } - else - { - // Reserve the resident bytes now that the load is in flight; the driver frees - // them when the group's last CompiledMu registers and it Unload(false)s. - residentModelBundleBytes += group.BundleSize; - } - } + yield return bundleRequest; - List reqs = group.Requests; - for (int i = 0; i < reqs.Count; i++) - modelQueue.Add(reqs[i]); // in-order forward (load-order parity link #3) + var bundle = bundleRequest.assetBundle; + if (bundle == null) + { + tcs.SetException(new Exception("asset bundle failed to load")); + yield break; + } - yield return null; - } + var request = bundle.LoadAllAssetsAsync(); + request.priority = -100; + yield return request; - yield return null; - } - } - finally + var assets = request.allAssets; + if (assets == null) { - modelQueue.CompleteAdding(); + tcs.SetException(new Exception("no assets were loaded from the bundle")); + yield break; } + + var index = new Dictionary(); + foreach (var asset in assets) + index.Add(asset.name, asset); + tcs.SetResult(index); } - // Phase 3 driver (clone of TextureDriverCoroutine): spawns up to MaxModelSpawnsPerFrame per-request - // loaders per frame and inserts finished ones in STRICT FIFO order. The FIFO drain (peek head; if - // still Pending, WAIT — never skip/reorder) is what preserves modelAssets load order. private static IEnumerator ModelDriverCoroutine( BlockingCollection modelQueue, HashSet loadedUrls, - int totalModelCount) + int totalModelCount, + Task> groupTask) { GameDatabase gdb = GameDatabase.Instance; Queue active = new(); @@ -1552,12 +1407,14 @@ private static IEnumerator ModelDriverCoroutine( break; // head not done yet: WAIT, never reorder (load-order parity link #4) active.Dequeue(); - // A throw in InsertReadyModel's body must not kill the driver (that would halt ALL further - // model registration). Its finally still runs on the throwing path (ref-count/Unload/resident - // release), so we just log the one bad model and skip it. The bookkeeping below runs - // regardless so counts/progress don't stall on a skipped model. - try { InsertReadyModel(pending, loadedUrls); } - catch (Exception e) { Debug.LogException(e); } + try + { + InsertReadyModel(pending, loadedUrls); + } + catch (Exception e) + { + Debug.LogException(e); + } loadedAssetCount++; completed++; } @@ -1572,13 +1429,22 @@ private static IEnumerator ModelDriverCoroutine( yield return null; } - // M2: surface any last-resort compile-task fault ONCE, here on the MAIN thread (the fold stashed it - // rather than calling Debug.* off the Task thread). - Exception fault = modelCompileFault; - if (fault != null) + try + { + List groups = groupTask.Result; + + foreach (var group in groups) + { + var bundle = group.Bundle; + if (bundle == null) + continue; + + bundle.Unload(false); + } + } + catch { - modelCompileFault = null; - Debug.LogException(fault); + yield break; } } @@ -1586,19 +1452,12 @@ private static IEnumerator ModelDriverCoroutine( // per-Kind enumerator, mapping any thrown exception to a Failed status + message. private static IEnumerator LoadModelCoroutine(ModelLoadRequest req) { - IEnumerator inner; - switch (req.ModelKind) + IEnumerator inner = req.ModelKind switch { - case ModelLoadRequest.Kind.CompiledMu: - inner = LoadCompiledModelCoroutine(req); - break; - case ModelLoadRequest.Kind.Dae: - inner = LoadDaeModelCoroutine(req); - break; - default: - inner = LoadFailedModelCoroutine(req); - break; - } + ModelLoadRequest.Kind.CompiledMu => LoadCompiledModelCoroutine(req), + ModelLoadRequest.Kind.Dae => LoadDaeModelCoroutine(req), + _ => LoadFailedModelCoroutine(req), + }; while (true) { @@ -1643,39 +1502,20 @@ private static IEnumerator LoadCompiledModelCoroutine(ModelLoadRequest req) MeshBinding[] bindings = cm.Bindings; var locals = new UnityEngine.Object[cm.LocalCount]; - // A model with meshes always sits in a group with a bundle (its blobs made BundleBytes non-null), - // so waiting for CreateRequest can't deadlock. A mesh-less model (no bindings) may be in a - // bundle-less group whose CreateRequest stays null forever, so it MUST skip the wait entirely. if (bindings.Length > 0) { - ModelGroup g = req.Group; - while (g.CreateRequest == null) - yield return null; + ModelGroup group = req.Group; + var indexTask = group.Index; - // POLL isDone; do NOT `yield return g.CreateRequest`. This AssetBundleCreateRequest is SHARED by - // every model coroutine in the group (created once by ModelBundlePumpCoroutine), and the driver - // runs up to MaxModelSpawnsPerFrame of them concurrently. Unity forbids yielding one async op - // from more than one coroutine ("...already being yielded from another coroutine"), so each - // coroutine waits independently by yielding null until the shared op reports done. - while (!g.CreateRequest.isDone) + while (!indexTask.IsCompleted) yield return null; - AssetBundle bundle = g.CreateRequest.assetBundle; - if (bundle == null) - { - req.FailureMessage = "mesh bundle failed to load"; - req.Status = ModelLoadRequest.State.Failed; - yield break; - } + var index = indexTask.Result; for (int i = 0; i < bindings.Length; i++) { - MeshBinding b = bindings[i]; - AssetBundleRequest ar = bundle.LoadAssetAsync(b.CanonicalName); - ar.priority = -10; - yield return ar; - // A missing mesh yields a null asset -> null mesh, matching MuParser's null-mesh handling. - locals[b.Slot] = ar.asset; + var binding = bindings[i]; + locals[binding.Slot] = index[bindings[i].CanonicalName]; } } @@ -1683,7 +1523,11 @@ private static IEnumerator LoadCompiledModelCoroutine(ModelLoadRequest req) for (int i = 0; i < instructions.Length; i++) instructions[i].Execute(locals); - req.Result = (GameObject)locals[0]; + GameObject go = (GameObject)locals[0]; + if (go.IsNotNullOrDestroyed()) + go.SetActive(false); + + req.Result = go; req.Status = ModelLoadRequest.State.Ready; } @@ -1699,6 +1543,8 @@ private static IEnumerator LoadDaeModelCoroutine(ModelLoadRequest req) yield break; } + go.SetActive(false); + req.FileLength = new FileInfo(req.File.fullPath).Length; req.Result = go; req.Status = ModelLoadRequest.State.Ready; @@ -1707,7 +1553,6 @@ private static IEnumerator LoadDaeModelCoroutine(ModelLoadRequest req) // Failed: hard failure. Message was already set in the fold (read or compile failure); keep it. private static IEnumerator LoadFailedModelCoroutine(ModelLoadRequest req) { - req.FailureMessage ??= req.Compiled?.FailureMessage; req.Status = ModelLoadRequest.State.Failed; yield break; } @@ -1716,74 +1561,48 @@ private static IEnumerator LoadFailedModelCoroutine(ModelLoadRequest req) // model registration). Called ONLY from the driver's FIFO drain, so it walks modelAssets order. private static void InsertReadyModel(ModelLoadRequest req, HashSet loadedUrls) { - // m5: the ENTIRE body (FlushLogs, the Debug.* calls, and every registration branch) runs inside this - // try so the finally below runs on EVERY path — it guarantees the exactly-once PendingBundleRefs - // decrement + resident-bytes release + Unload for each CompiledMu, so even a throw in the body still - // frees the group's native bundle copy and never strands the resident cap. The throw itself is NOT - // swallowed here; ModelDriverCoroutine's FIFO drain wraps this call and logs+skips the one bad model, - // so a single failure can't kill the driver. - try - { - // On the MAIN THREAD: emit the compiler's buffered diagnostics (KSP's log handler and the mod - // handlers chained onto Application.logMessageReceived are not thread-safe, so they could not be - // flushed off-thread). Null-safe: Dae has no Compiled. - req.Compiled?.FlushLogs(); - Debug.Log($"Load Model: {req.File.url}"); + // On the MAIN THREAD: emit the compiler's buffered diagnostics (KSP's log handler and the mod + // handlers chained onto Application.logMessageReceived are not thread-safe, so they could not be + // flushed off-thread). Null-safe: Dae has no Compiled. + req.Compiled?.FlushLogs(); - if (req.Status == ModelLoadRequest.State.Failed) - { - Debug.LogWarning($"LOAD FAILED: {req.File.url}: {req.FailureMessage}"); - if (req.Result.IsNotNullOrDestroyed()) - UnityEngine.Object.Destroy(req.Result); - return; - } - - // Built-before-check (like the texture dup path): a duplicate url means we already built the - // GameObject, so it must be destroyed. First-wins, matching stock FilesLoader. - if (!loadedUrls.Add(req.File.url)) - { - Debug.LogWarning($"Duplicate model asset '{req.File.url}' with extension '{req.File.fileExtension}' won't be loaded"); - if (req.Result.IsNotNullOrDestroyed()) - UnityEngine.Object.Destroy(req.Result); - return; - } + Debug.Log($"Load Model: {req.File.url}"); - // Exact replication of RawAsset.LoadAndDisposeMainThread's model registration. - GameObject model = req.Result; - model.transform.name = req.File.url; - model.transform.parent = Instance.transform; - model.transform.localPosition = Vector3.zero; - model.transform.localRotation = Quaternion.identity; - model.SetActive(false); - Instance.databaseModel.Add(model); - Instance.databaseModelFiles.Add(req.File); - modelsByUrl[req.File.url] = model; - // if multiple models in the same dir, we only add the first - // to ensure identical behavior as the GameDatabase.GetModelPrefabIn() method - modelsByDirectoryUrl.TryAdd(req.File.parent.url, model); - urlFilesByModel.Add(model, req.File); - KSPCFFastLoaderReport.modelsBytesLoaded += req.FileLength; - KSPCFFastLoaderReport.modelsLoaded++; - } - finally + if (req.Status == ModelLoadRequest.State.Failed) { - if (req.ModelKind == ModelLoadRequest.Kind.CompiledMu && --req.Group.PendingBundleRefs == 0) - { - // M1: release this group's resident-bytes reservation so the pump can load more groups. - // Done BEFORE the Unload below on purpose: if Unload ever threw, skipping the release would - // strand the cap. BundleSize is 0 for a bundle-less group (pump never reserved for it), so - // this is a no-op there; it exactly reverses the pump's single += for a group whose bundle - // was loaded. - residentModelBundleBytes -= req.Group.BundleSize; + Debug.LogWarning($"LOAD FAILED: {req.File.url}: {req.FailureMessage}"); + if (req.Result.IsNotNullOrDestroyed()) + UnityEngine.Object.Destroy(req.Result); + return; + } - // false: keep the loaded meshes (now owned by the built GameObjects); free the bundle's - // native copy. - AssetBundle b = req.Group.CreateRequest?.assetBundle; - if (b != null) - b.Unload(false); - } + // Built-before-check (like the texture dup path): a duplicate url means we already built the + // GameObject, so it must be destroyed. First-wins, matching stock FilesLoader. + if (!loadedUrls.Add(req.File.url)) + { + Debug.LogWarning($"Duplicate model asset '{req.File.url}' with extension '{req.File.fileExtension}' won't be loaded"); + if (req.Result.IsNotNullOrDestroyed()) + UnityEngine.Object.Destroy(req.Result); + return; } + + // Exact replication of RawAsset.LoadAndDisposeMainThread's model registration. + GameObject model = req.Result; + model.transform.name = req.File.url; + model.transform.parent = Instance.transform; + model.transform.localPosition = Vector3.zero; + model.transform.localRotation = Quaternion.identity; + model.SetActive(false); + Instance.databaseModel.Add(model); + Instance.databaseModelFiles.Add(req.File); + modelsByUrl[req.File.url] = model; + // if multiple models in the same dir, we only add the first + // to ensure identical behavior as the GameDatabase.GetModelPrefabIn() method + modelsByDirectoryUrl.TryAdd(req.File.parent.url, model); + urlFilesByModel.Add(model, req.File); + KSPCFFastLoaderReport.modelsBytesLoaded += req.FileLength; + KSPCFFastLoaderReport.modelsLoaded++; } // Shared body of the (now-wrapped) RawAsset.LoadDAE, reused by the new Dae path. Reloads the file via From 6c3e3eec1d07e8f3f04b57bf78dd46311227e4e3 Mon Sep 17 00:00:00 2001 From: Phantomical Date: Thu, 16 Jul 2026 01:13:07 -0700 Subject: [PATCH 13/14] Bake real UV distribution metric during mesh compilation MeshMetrics.Compute now bakes the real per-UV-channel m_MeshMetrics value during mesh compilation (was a 1.0 placeholder), wired through MeshBlobBuilder and verified by MeshBundleSelfTest, so the serialized mesh matches Unity's m_MeshMetrics. Split from 16a0cc7 on streaming-mipmaps: this is the mesh-serialization half of that commit. The mipmap-streaming consumer (the release-at-bind logic plus the texture/FastLoader machinery) stays on streaming-mipmaps. --- KSPCommunityFixes/Library/Model/MeshBlob.cs | 5 +- .../Library/Model/MeshBlobBuilder.cs | 7 + .../Library/Model/MeshBundleSelfTest.cs | 23 +++ .../Library/Model/MeshMetrics.cs | 163 ++++++++++++++++++ 4 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 KSPCommunityFixes/Library/Model/MeshMetrics.cs diff --git a/KSPCommunityFixes/Library/Model/MeshBlob.cs b/KSPCommunityFixes/Library/Model/MeshBlob.cs index caadc93..713d0ed 100644 --- a/KSPCommunityFixes/Library/Model/MeshBlob.cs +++ b/KSPCommunityFixes/Library/Model/MeshBlob.cs @@ -63,9 +63,8 @@ internal sealed class MeshBlob public MeshBoneAABB[] BonesAABB; /// - /// UV distribution metrics (m_MeshMetrics[0..1]). 1.0 is a neutral default: these values - /// only feed texture-mip streaming priority (which mips to keep resident), not per-pixel - /// mip selection, so leaving them at 1 has no visual effect. Real values are computed later. + /// UV distribution metrics (m_MeshMetrics[0..1]), one per UV channel, baked by + /// during compilation. A channel with no UVs keeps the neutral 1.0. /// public float MeshMetric0 = 1f; public float MeshMetric1 = 1f; diff --git a/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs b/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs index c5120d1..1dc5932 100644 --- a/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs +++ b/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs @@ -265,6 +265,11 @@ public static unsafe MeshBlob FromArrays(string name, in Arrays a, Action ratio 1) and UV1 a [0,0.5] square (UV area 0.125 -> ratio 4), + // so the metrics are exactly 1 and 4. + static bool CheckMetrics(MeshBlob blob, Mesh loaded, StringBuilder sb) + { + bool ok = CheckMetric("uv0", 1f, blob.MeshMetric0, loaded.GetUVDistributionMetric(0), sb); + ok &= CheckMetric("uv1", 4f, blob.MeshMetric1, loaded.GetUVDistributionMetric(1), sb); + return ok; + } + + static bool CheckMetric(string n, float expected, float baked, float readBack, StringBuilder sb) + { + bool ok = true; + if (Mathf.Abs(baked - expected) > 1e-4f) + { ok = false; sb.Append($"\n metric {n} baked {baked} != expected {expected}"); } + if (Mathf.Abs(readBack - baked) > 1e-6f) + { ok = false; sb.Append($"\n metric {n} read-back {readBack} != baked {baked}"); } + return ok; + } + static bool Compare(Mesh a, Mesh b, StringBuilder sb) { bool ok = true; diff --git a/KSPCommunityFixes/Library/Model/MeshMetrics.cs b/KSPCommunityFixes/Library/Model/MeshMetrics.cs new file mode 100644 index 0000000..44915bc --- /dev/null +++ b/KSPCommunityFixes/Library/Model/MeshMetrics.cs @@ -0,0 +1,163 @@ +using System; +using UnityEngine; + +namespace KSPCommunityFixes.Library.Model +{ + /// + /// Helpers for computing the UV distribution metric of a mesh. + /// + /// + /// This mostly follows how this is implemented in the unity editor with a + /// few fixes for things that looked obviously wrong. + /// + internal static class MeshMetrics + { + // minimum edge length, area-threshold floor, and final-metric floor. + const float MinLen = 0.001f; + // minimum interior angle; rejects sliver triangles. + const float MinAngle = 5f * Mathf.Deg2Rad; + // minimum UV-space triangle area; guards the area divide. + const float MinUvArea = 1e-9f; + // if the below-mean area spread is under this, the area threshold collapses + // to MinLen (the mesh's triangles are too uniform for the spread to mean anything). + const float StdDevGate = 0.1f; + // Unity's fallback when the metric can't be measure. + const float Neutral = 1f; + + /// + /// Bake the metric for over the whole mesh. + /// + public static float Compute(Vector3[] verts, Vector2[] uv, int[][] subMeshTriangles) + { + if (verts == null || verts.Length == 0 || uv == null || subMeshTriangles == null) + return Neutral; + + int triCount = 0; + for (int s = 0; s < subMeshTriangles.Length; ++s) + { + int[] t = subMeshTriangles[s]; + if (t != null) + triCount += t.Length / 3; + } + if (triCount == 0) + return Neutral; + + // One scratch buffer, reused across the two passes: pass 1 fills it with per-triangle 3D + // areas (consumed to derive the threshold), pass 2 overwrites it with the kept ratios. + float[] scratch = new float[triCount]; + + // ---- Pass 1: per-triangle object-space areas -> robust small-triangle area threshold ---- + int ti = 0; + for (int s = 0; s < subMeshTriangles.Length; ++s) + { + int[] t = subMeshTriangles[s]; + if (t == null) + continue; + for (int k = 0; k + 2 < t.Length; k += 3) + scratch[ti++] = TriArea(verts[t[k]], verts[t[k + 1]], verts[t[k + 2]]); + } + + Array.Sort(scratch, 0, triCount); // ascending + int keep = triCount - triCount / 10; // robust stats over the smallest 90% of triangles + + double areaSum = 0; + for (int i = 0; i < keep; ++i) + areaSum += scratch[i]; + float areaMean = (float)(areaSum / keep); + + // One-sided (below-mean) standard deviation of the kept areas. + double belowSq = 0; + int below = 0; + for (int i = 0; i < keep; ++i) + { + float d = scratch[i] - areaMean; + if (d < 0f) + { + belowSq += (double)d * d; + below++; + } + } + float areaStdBelow = below > 0 ? (float)Math.Sqrt(belowSq / below) : 0f; + + float areaThreshold = areaMean - areaStdBelow; + if (areaThreshold <= MinLen || areaStdBelow < StdDevGate) + areaThreshold = MinLen; + + // ---- Pass 2: object-area / UV-area ratio for every valid triangle ---- + int count = 0; + for (int s = 0; s < subMeshTriangles.Length; ++s) + { + int[] t = subMeshTriangles[s]; + if (t == null) + continue; + for (int k = 0; k + 2 < t.Length; k += 3) + { + int i0 = t[k], i1 = t[k + 1], i2 = t[k + 2]; + float ratio = EvalTriangle( + verts[i0], verts[i1], verts[i2], uv[i0], uv[i1], uv[i2], areaThreshold); + if (ratio > MinLen) + scratch[count++] = ratio; + } + } + if (count == 0) + return Neutral; + + // ---- metric = mean(ratios) + one-sided (above-mean) standard deviation ---- + double ratioSum = 0; + for (int i = 0; i < count; ++i) + ratioSum += scratch[i]; + float ratioMean = (float)(ratioSum / count); + + double aboveSq = 0; + int above = 0; + for (int i = 0; i < count; ++i) + { + float d = scratch[i] - ratioMean; + if (d > 0f) + { + aboveSq += (double)d * d; + above++; + } + } + float ratioStdAbove = above > 0 ? (float)Math.Sqrt(aboveSq / above) : 0f; + + float metric = ratioMean + ratioStdAbove; + return metric <= MinLen ? Neutral : metric; + } + + /// Object-space area of a triangle (half the cross-product magnitude of two edges). + static float TriArea(Vector3 a, Vector3 b, Vector3 c) + => 0.5f * Vector3.Cross(b - a, c - a).magnitude; + + /// + /// Object-area / UV-area for one triangle, or 0 if it's degenerate, a sliver, too small + /// (below ), or has a near-zero UV footprint. + /// + static float EvalTriangle( + Vector3 v0, Vector3 v1, Vector3 v2, + Vector2 t0, Vector2 t1, Vector2 t2, float areaThreshold) + { + float a = (v1 - v0).magnitude; // edge v0->v1 + float b = (v2 - v0).magnitude; // edge v0->v2 + float c = (v1 - v2).magnitude; // edge v2->v1 + if (a < MinLen || b < MinLen || c < MinLen) + return 0f; + + // Every interior angle must be >= 5 degrees (law of cosines). The cosine is clamped into + // [-1, 1] before acos so float error at a near-degenerate corner can't produce a NaN. + float a2 = a * a, b2 = b * b, c2 = c * c; + if (Mathf.Acos(Mathf.Clamp((a2 + b2 - c2) / (2f * a * b), -1f, 1f)) < MinAngle) return 0f; + if (Mathf.Acos(Mathf.Clamp((c2 + b2 - a2) / (2f * b * c), -1f, 1f)) < MinAngle) return 0f; + if (Mathf.Acos(Mathf.Clamp((c2 + a2 - b2) / (2f * c * a), -1f, 1f)) < MinAngle) return 0f; + + float area3D = 0.5f * Vector3.Cross(v2 - v0, v1 - v0).magnitude; + // UV-space triangle area: half the absolute 2D cross product of the UV edges. + float areaUV = 0.5f * Mathf.Abs( + (t2.y - t0.y) * (t1.x - t0.x) - (t1.y - t0.y) * (t2.x - t0.x)); + + if (area3D < areaThreshold || areaUV < MinUvArea) + return 0f; + return area3D / areaUV; + } + } +} From 4db80072294e63c81798a4129f89ba47e766bcf9 Mon Sep 17 00:00:00 2001 From: Phantomical Date: Thu, 16 Jul 2026 02:16:13 -0700 Subject: [PATCH 14/14] Various style and comment cleanups --- .../Library/Model/CompiledModel.cs | 114 +- .../Library/Model/MeshBinding.cs | 37 +- KSPCommunityFixes/Library/Model/MeshBlob.cs | 223 +- .../Library/Model/MeshBlobBuilder.cs | 71 +- .../Library/Model/MeshBundleBuilder.cs | 683 +++--- .../Library/Model/MeshBundleSelfTest.cs | 338 ++- .../Library/Model/MeshMetrics.cs | 271 ++- KSPCommunityFixes/Library/Model/ModelGroup.cs | 12 +- .../Library/Model/ModelInstructions.cs | 164 +- .../Library/Model/ModelLoadRequest.cs | 84 +- .../Library/Model/MuBinaryReader.cs | 538 ++--- .../Library/Model/MuModelCompiler.cs | 2088 ++++++++--------- KSPCommunityFixes/Performance/FastLoader.cs | 63 +- 13 files changed, 2156 insertions(+), 2530 deletions(-) diff --git a/KSPCommunityFixes/Library/Model/CompiledModel.cs b/KSPCommunityFixes/Library/Model/CompiledModel.cs index cffb714..4a94ba1 100644 --- a/KSPCommunityFixes/Library/Model/CompiledModel.cs +++ b/KSPCommunityFixes/Library/Model/CompiledModel.cs @@ -1,81 +1,61 @@ -namespace KSPCommunityFixes.Library.Model +namespace KSPCommunityFixes.Library.Model; + +/// +/// A representation of a .mu file that is ready to be executed. +/// +internal sealed class CompiledModel { - /// - /// The fully compiled, main-thread-ready representation of a single .mu model file, produced - /// by the background model compiler (a later task) as a faithful split of what - /// does in one pass. It carries no - /// UnityEngine.Object: the meshes live as serialization-ready s (baked - /// into an AssetBundle off-thread) and the GameObject hierarchy lives as a flat - /// list. - /// - /// The main-thread driver allocates UnityEngine.Object[] locals of size - /// , uses to place each loaded Mesh into its - /// slot, then calls Execute(locals) on every instruction in order. locals[0] ends up - /// holding the root GameObject (the equivalent of MuParser.Parse's return value). - /// - /// - internal sealed class CompiledModel - { - /// The ordered assembly steps. Replayed front-to-back on the main thread; each reads - /// and/or writes locals by integer slot index (-1 meaning "none"). - public IModelInstruction[] Instructions; + /// The ordered assembly steps. + public IModelInstruction[] Instructions; - /// Serialization-ready meshes referenced by this model (static meshes only in v1). - public MeshBlob[] Blobs; + /// Serialization-ready meshes referenced by this model (static meshes only). + public MeshBlob[] Blobs; - /// Maps each mesh's locals slot to the canonical name the driver looks it up by - /// in the loaded mesh AssetBundle. - public MeshBinding[] Bindings; + /// Maps each mesh's locals slot to the canonical name the driver looks it up by + /// in the loaded mesh AssetBundle. + public MeshBinding[] Bindings; - /// Size of the locals[] array the driver must allocate (high-water slot + 1). - public int LocalCount; + /// Size of the locals[] array the driver must allocate (high-water slot + 1). + public int LocalCount; - /// The source file.url — used as the root name, for logging and for registration. - public string SourceUrl; + /// The source file.url. + public string SourceUrl; - /// Diagnostics collected on the worker thread during compilation. KSP installs its own - /// ILogHandler and mods chain handlers onto Application.logMessageReceived, none of - /// which are thread-safe, so these must NEVER be logged off-thread; they are buffered here and - /// flushed on the MAIN thread via . - public System.Collections.Generic.List Logs; + /// Diagnostics collected on the worker thread during compilation. KSP installs its own + /// ILogHandler and mods chain handlers onto Application.logMessageReceived, none of + /// which are thread-safe, so these must NEVER be logged off-thread; they are buffered here and + /// flushed on the MAIN thread via . + public System.Collections.Generic.List Logs; - /// - /// MAIN THREAD ONLY. Emits every buffered through - /// UnityEngine.Debug, mapping to the matching Debug call - /// (Error/Exception → LogError, Warning → LogWarning, else → Log). Null-safe (no-op when - /// was never populated). - /// - public void FlushLogs() - { - if (Logs == null) - return; + /// Emits the buffered diagnostics through UnityEngine.Debug. + public void FlushLogs() + { + if (Logs == null) + return; - for (int i = 0; i < Logs.Count; i++) + for (int i = 0; i < Logs.Count; i++) + { + DeferredLog log = Logs[i]; + switch (log.Type) { - DeferredLog log = Logs[i]; - switch (log.Type) - { - case UnityEngine.LogType.Error: - case UnityEngine.LogType.Exception: - UnityEngine.Debug.LogError(log.Message); - break; - case UnityEngine.LogType.Warning: - UnityEngine.Debug.LogWarning(log.Message); - break; - default: - UnityEngine.Debug.Log(log.Message); - break; - } + case UnityEngine.LogType.Error: + case UnityEngine.LogType.Exception: + UnityEngine.Debug.LogError(log.Message); + break; + case UnityEngine.LogType.Warning: + UnityEngine.Debug.LogWarning(log.Message); + break; + default: + UnityEngine.Debug.Log(log.Message); + break; } } } +} - /// A single diagnostic buffered during off-thread compilation, to be emitted later on the - /// main thread via . - internal readonly struct DeferredLog - { - public readonly UnityEngine.LogType Type; - public readonly string Message; - public DeferredLog(UnityEngine.LogType type, string message) { Type = type; Message = message; } - } +/// A single diagnostic buffered during off-thread compilation. +internal readonly struct DeferredLog(UnityEngine.LogType type, string message) +{ + public readonly UnityEngine.LogType Type = type; + public readonly string Message = message; } diff --git a/KSPCommunityFixes/Library/Model/MeshBinding.cs b/KSPCommunityFixes/Library/Model/MeshBinding.cs index b74461d..40d3500 100644 --- a/KSPCommunityFixes/Library/Model/MeshBinding.cs +++ b/KSPCommunityFixes/Library/Model/MeshBinding.cs @@ -1,30 +1,13 @@ -namespace KSPCommunityFixes.Library.Model -{ - /// - /// Associates one locals[] slot with the canonical name of the mesh that belongs in it. - /// Before running a 's instructions, the main-thread driver — for every - /// binding — issues bundle.LoadAssetAsync<UnityEngine.Mesh>(CanonicalName) against the - /// loaded mesh AssetBundle and stores the resulting Mesh into locals[Slot], so - /// that the AddMeshFilter/AddMeshCollider/AddSkinnedMeshRenderer instructions - /// can read their mesh straight out of that slot. - /// - /// - /// is MeshBundleBuilder.Canonicalize($"{file.url}#{meshIndex}"), - /// i.e. the exact verbatim key stored in the bundle's container (lowercased, forward slashes) — see - /// . - /// - internal readonly struct MeshBinding - { - /// Index into locals[] where the loaded mesh is stored. - public readonly int Slot; +namespace KSPCommunityFixes.Library.Model; - /// The bundle container lookup key (already canonicalized). - public readonly string CanonicalName; +/// +/// Associates one locals[] slot with the canonical name of the mesh that belongs in it. +/// +internal readonly struct MeshBinding(int slot, string canonicalName) +{ + /// Index into locals[] where the loaded mesh is stored. + public readonly int Slot = slot; - public MeshBinding(int slot, string canonicalName) - { - Slot = slot; - CanonicalName = canonicalName; - } - } + /// The key to use to look up the asset in the bundle. + public readonly string CanonicalName = canonicalName; } diff --git a/KSPCommunityFixes/Library/Model/MeshBlob.cs b/KSPCommunityFixes/Library/Model/MeshBlob.cs index 713d0ed..110c30b 100644 --- a/KSPCommunityFixes/Library/Model/MeshBlob.cs +++ b/KSPCommunityFixes/Library/Model/MeshBlob.cs @@ -1,136 +1,117 @@ using UnityEngine; -namespace KSPCommunityFixes.Library.Model +namespace KSPCommunityFixes.Library.Model; + +/// +/// The data for a unity mesh in the format it would be stored in an asset bundle. +/// +internal sealed class MeshBlob { /// - /// One serialization-ready Unity mesh, produced on a background thread by the model compiler and - /// written verbatim into a mesh bundle by . Everything here is - /// already in the exact on-wire form Unity 2019.4 expects: the vertex data is interleaved into a - /// single stream with a matching descriptor, indices are packed to - /// , and bounds/metrics are precomputed (there is no - /// RecalculateBounds on the main thread anymore). Geometry is stored inline in the bundle, - /// so there is no external stream data. + /// The mesh's serialized m_Name and its m_Container key. /// - internal sealed class MeshBlob - { - /// - /// The mesh's serialized m_Name and its m_Container key. Must already be - /// canonical (lowercased, forward slashes) so AssetBundle.LoadAssetAsync(name) can find - /// it — Unity canonicalizes the lookup name but compares it verbatim against the stored key. - /// - public string Name; - - public int VertexCount; - - /// - /// The full m_Channels descriptor array, one entry per Unity vertex attribute - /// (absent attributes have 0). Describes how - /// is laid out. - /// - public MeshChannel[] Channels; - - /// The interleaved vertex stream (m_VertexData.m_DataSize), inline. - public byte[] VertexData; - - /// 0 == 16-bit indices, 1 == 32-bit indices (Unity's IndexFormat). - public int IndexFormat; - - /// The concatenated per-submesh index buffer (m_IndexBuffer), inline. - public byte[] IndexData; - - public MeshSubMesh[] SubMeshes; - - /// Whole-mesh local bounds (m_LocalAABB): center + extent (half-size). - public Bounds LocalBounds; - - /// Skinning bind poses (m_BindPose); null/empty for a static mesh. - public Matrix4x4[] BindPose; - - /// - /// One CRC32 per bone (m_BoneNameHashes); null/empty for a static mesh. Count must - /// equal and (Unity's per-bone invariant). - /// - public uint[] BoneNameHashes; - - /// The root bone's hash (m_RootBoneNameHash); BoneNameHashes[0], 0 for a static mesh. - public uint RootBoneNameHash; - - /// - /// Per-bone local bounds (m_BonesAABB); null/empty for a static mesh. Count must equal - /// . Filled conservatively with the whole-mesh bounds per bone (bounds - /// affect culling only, not skinning). - /// - public MeshBoneAABB[] BonesAABB; - - /// - /// UV distribution metrics (m_MeshMetrics[0..1]), one per UV channel, baked by - /// during compilation. A channel with no UVs keeps the neutral 1.0. - /// - public float MeshMetric0 = 1f; - public float MeshMetric1 = 1f; - } + public string Name; + + public int VertexCount; /// - /// One MinMaxAABB entry (m_BonesAABB element): a min/max corner pair, 24 bytes - /// (two Vector3f). Distinct from (center/extent), which the local - /// AABB and submesh bounds use. + /// The full m_Channels descriptor array, one entry per Unity vertex attribute + /// (absent attributes have 0). Describes how + /// is laid out. /// - internal readonly struct MeshBoneAABB - { - public readonly Vector3 Min; - public readonly Vector3 Max; - - public MeshBoneAABB(Vector3 min, Vector3 max) - { - Min = min; - Max = max; - } - } + public MeshChannel[] Channels; + + /// The interleaved vertex stream (m_VertexData.m_DataSize), inline. + public byte[] VertexData; + + /// 0 == 16-bit indices, 1 == 32-bit indices (Unity's IndexFormat). + public int IndexFormat; + + /// The concatenated per-submesh index buffer (m_IndexBuffer), inline. + public byte[] IndexData; + + public MeshSubMesh[] SubMeshes; + + /// Whole-mesh local bounds (m_LocalAABB): center + extent (half-size). + public Bounds LocalBounds; + + /// Skinning bind poses (m_BindPose); null/empty for a static mesh. + public Matrix4x4[] BindPose; + + /// + /// One CRC32 per bone (m_BoneNameHashes); null/empty for a static mesh. Count must + /// equal and . + /// + public uint[] BoneNameHashes; - /// One ChannelInfo entry: which stream/offset/format/dimension a vertex attribute uses. - internal readonly struct MeshChannel + /// The root bone's hash (m_RootBoneNameHash); BoneNameHashes[0], 0 for a static mesh. + public uint RootBoneNameHash; + + /// + /// Per-bone local bounds (m_BonesAABB); null/empty for a static mesh. Count must equal + /// .. + /// + public MeshBoneAABB[] BonesAABB; + + /// + /// UV distribution metrics (m_MeshMetrics[0..1]), one per UV channel.. + /// + public float MeshMetric0 = 1f; + public float MeshMetric1 = 1f; +} + +/// +/// One MinMaxAABB entry (m_BonesAABB element). +/// +internal readonly struct MeshBoneAABB(Vector3 min, Vector3 max) +{ + public readonly Vector3 Min = min; + public readonly Vector3 Max = max; +} + +/// One ChannelInfo entry: which stream/offset/format/dimension a vertex attribute uses. +internal readonly struct MeshChannel +{ + public readonly byte Stream; + public readonly byte Offset; + public readonly byte Format; + public readonly byte Dimension; + + public MeshChannel(byte stream, byte offset, byte format, byte dimension) { - public readonly byte Stream; - public readonly byte Offset; - public readonly byte Format; - public readonly byte Dimension; - - public MeshChannel(byte stream, byte offset, byte format, byte dimension) - { - Stream = stream; - Offset = offset; - Format = format; - Dimension = dimension; - } + Stream = stream; + Offset = offset; + Format = format; + Dimension = dimension; } +} - /// One SubMesh record: an index range plus its bounds and topology. - internal readonly struct MeshSubMesh +/// One SubMesh record: an index range plus its bounds and topology. +internal readonly struct MeshSubMesh +{ + public readonly uint FirstByte; + public readonly uint IndexCount; + public readonly int Topology; + public readonly uint BaseVertex; + public readonly uint FirstVertex; + public readonly uint VertexCount; + public readonly Bounds LocalBounds; + + public MeshSubMesh( + uint firstByte, + uint indexCount, + int topology, + uint baseVertex, + uint firstVertex, + uint vertexCount, + Bounds localBounds) { - public readonly uint FirstByte; - public readonly uint IndexCount; - public readonly int Topology; - public readonly uint BaseVertex; - public readonly uint FirstVertex; - public readonly uint VertexCount; - public readonly Bounds LocalBounds; - - public MeshSubMesh( - uint firstByte, - uint indexCount, - int topology, - uint baseVertex, - uint firstVertex, - uint vertexCount, - Bounds localBounds) - { - FirstByte = firstByte; - IndexCount = indexCount; - Topology = topology; - BaseVertex = baseVertex; - FirstVertex = firstVertex; - VertexCount = vertexCount; - LocalBounds = localBounds; - } + FirstByte = firstByte; + IndexCount = indexCount; + Topology = topology; + BaseVertex = baseVertex; + FirstVertex = firstVertex; + VertexCount = vertexCount; + LocalBounds = localBounds; } } diff --git a/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs b/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs index 1dc5932..ed5be85 100644 --- a/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs +++ b/KSPCommunityFixes/Library/Model/MeshBlobBuilder.cs @@ -6,17 +6,14 @@ namespace KSPCommunityFixes.Library.Model { /// /// Interleaves mesh attribute arrays into the single-stream vertex layout - /// Unity 2019.4 expects, and packs the index buffer. Pure CPU work; safe on a background thread - /// (no UnityEngine.Mesh is created). This is the mesh half of the model compiler; it is - /// also reused by the diff harness and the single-mesh validation. + /// Unity 2019.4 expects, and packs the index buffer. /// /// - /// Confirmed against a real Unity 2019.4.18f1 mesh: m_Channels is a fixed 14-entry array - /// (absent attributes have dimension 0), indexed by vertex attribute - /// (0=Position, 1=Normal, 2=Tangent, 3=Color, 4..11=TexCoord0..7, 12=BlendWeights, 13=BlendIndices); - /// present channels are all in stream 0, packed at contiguous offsets in attribute-index order. - /// v1 stores every present attribute as float32 (format 0), including colour (from Color32/255), - /// which matches the observed all-float32 convention with no format-code risk. + /// m_Channels is a fixed 14-entry array (absent attributes have dimension 0), indexed by + /// vertex attribute (0=Position, 1=Normal, 2=Tangent, 3=Color, 4..11=TexCoord0..7, + /// 12=BlendWeights, 13=BlendIndices); present channels are all in stream 0, packed at contiguous + /// offsets in attribute-index order. Every present attribute is stored as float32 (format 0), + /// including colour (from Color32/255). /// internal static class MeshBlobBuilder { @@ -51,8 +48,6 @@ public struct Arrays public int[][] SubMeshTriangles; // ---- Skinning (all three present together, or the mesh is treated as static) -------- - // Assigned by MuModelCompiler (in-assembly) when it reads a skinned .mu mesh, and by the - // offline mesh harness for the synthetic skinned case. /// Per-vertex bone weights (weight0..3) and indices (boneIndex0..3). public BoneWeight[] BoneWeights; @@ -63,13 +58,12 @@ public struct Arrays /// One name per bone, index-aligned with , used to compute /// m_BoneNameHashes. To reproduce Unity's exact stored hash the name must be the /// bone's full transform path from the model root (e.g. globalMove01/joints01/bn_spA01); - /// see . The model compiler passes the .mu's leaf bone names - /// (what the runtime binds by), so the stored hash is cosmetic — only the array count matters. + /// see . /// public string[] BoneNames; } - /// Build a from a live UnityEngine.Mesh (main thread only). + /// Build a from a live UnityEngine.Mesh. public static MeshBlob FromMesh(Mesh mesh, string name, Action warn = null) { var arrays = new Arrays @@ -88,9 +82,9 @@ public static MeshBlob FromMesh(Mesh mesh, string name, Action warn = nu return FromArrays(name, in arrays, warn); } - // is an optional attribute-mismatch sink. When null (main-thread/offline - // callers) warnings fall back to Debug.LogWarning; a worker-thread caller passes a sink that - // buffers the message instead, since Debug.LogWarning is not safe to call off the main thread. + // is an optional attribute-mismatch sink. When null, warnings fall back + // to Debug.LogWarning, which is not safe to call off the main thread; a worker-thread caller + // passes a sink that buffers the message instead. public static unsafe MeshBlob FromArrays(string name, in Arrays a, Action warn = null) { Vector3[] verts = a.Vertices ?? Array.Empty(); @@ -128,11 +122,9 @@ public static unsafe MeshBlob FromArrays(string name, in Arrays a, Action(); uint[] boneNameHashes = Array.Empty(); uint rootBoneNameHash = 0; @@ -296,7 +285,7 @@ static void AddChannel( if (!present) return; channels[index] = new MeshChannel((byte)0, (byte)stride, format, (byte)dimension); - // Every format v1 emits (Float32=0 for all attributes, UInt32=10 for BlendIndices) is a + // Every format emitted (Float32=0 for all attributes, UInt32=10 for BlendIndices) is a // 4-byte element, so the stride advance is dimension * 4 regardless of format. stride += dimension * 4; } @@ -304,15 +293,10 @@ static void AddChannel( static int Count(T[] array) => array?.Length ?? 0; // Standard CRC-32 (ISO-HDLC / zlib: reflected, polynomial 0xEDB88320, init and final-xor - // 0xFFFFFFFF) of the UTF-8 bytes of . This is the exact algorithm Unity - // 2019.4 uses for m_BoneNameHashes / m_RootBoneNameHash — verified byte-for-byte against 542 - // real skinned meshes in KSP's sharedassets0.assets (e.g. all 35 bones of body01 and the EVA - // jetpack bone reproduced exactly). NOTE: Unity hashes the bone's FULL transform path from the + // 0xFFFFFFFF) of the UTF-8 bytes of name. This is the algorithm Unity 2019.4 uses for + // m_BoneNameHashes / m_RootBoneNameHash. Unity hashes the bone's FULL transform path from the // model root (e.g. "globalMove01/joints01/bn_spA01"), not the bare leaf name, so the caller must - // pass that path in Arrays.BoneNames to match the native stored value. The stored value does not - // affect skinning (the compiler binds SkinnedMeshRenderer.bones by name at replay and the - // bindpose[i] <-> BlendIndices[i] <-> bones[i] correspondence is by index), so a leaf-only name - // still skins correctly; only the byte-parity of the hash field would differ. + // pass that path in Arrays.BoneNames to match the native stored value. static readonly uint[] Crc32Table = BuildCrc32Table(); static uint[] BuildCrc32Table() @@ -337,11 +321,8 @@ public static uint BoneNameHash(string name) return crc ^ 0xFFFFFFFFu; } - // Warns only when an attribute array is actually present (non-null, non-empty) but its length - // disagrees with the vertex count, i.e. it is about to be silently dropped. Cheap: no - // allocation unless the (rare) warning path fires. When a sink is - // supplied (worker-thread callers) the message is routed there; otherwise it falls back to - // Debug.LogWarning for main-thread/offline callers. + // Warns when an attribute array is present but its length disagrees with the vertex count (it + // is about to be silently dropped). static void WarnIfWrongLength(string name, string attr, int length, int vertexCount, Action warn) { if (length != 0 && length != vertexCount) diff --git a/KSPCommunityFixes/Library/Model/MeshBundleBuilder.cs b/KSPCommunityFixes/Library/Model/MeshBundleBuilder.cs index 0167509..42c7fe8 100644 --- a/KSPCommunityFixes/Library/Model/MeshBundleBuilder.cs +++ b/KSPCommunityFixes/Library/Model/MeshBundleBuilder.cs @@ -3,390 +3,377 @@ using KSPCommunityFixes.Library.TextureBundle; using UnityEngine; -namespace KSPCommunityFixes.Library.Model +namespace KSPCommunityFixes.Library.Model; + +/// +/// Builds an in-memory UnityFS bundle wrapping N Mesh objects plus the one +/// AssetBundle that references them. +/// +/// +/// reproduces Unity 2019.4.18f1's serialized Mesh (class 43) +/// field order, sizes and alignment exactly as the embedded type tree encodes them. +/// +internal static class MeshBundleBuilder { + // Unity BuildTarget.StandaloneWindows64. + public const int StandaloneWindows64 = 19; + + const long AssetBundlePathId = 1; + + // Mesh objects get path ids 2, 3, 4, ... (the AssetBundle object is path id 1). + const long FirstMeshPathId = 2; + /// - /// Builds an in-memory UnityFS bundle wrapping N Mesh objects plus the one - /// AssetBundle that references them, the mesh analogue of - /// . Unlike the texture path, mesh geometry can't be streamed - /// from the source file (the .mu layout differs from Unity's vertex stream, and Unity 2019.4 - /// never keeps a CPU-readable copy of streamed mesh vertices), so the interleaved vertex/index - /// bytes are written inline and m_StreamData is empty. Pure CPU work; safe to call - /// from a background thread. Reuses the writer stack unchanged. + /// Canonicalize an asset name into the format unity expects internally. /// - /// - /// reproduces Unity 2019.4.18f1's serialized Mesh (class 43) - /// field order, sizes and alignment exactly as the embedded type tree encodes them; only the - /// align-after flag (type-tree meta bit 0x4000) affects the byte stream. - /// - internal static class MeshBundleBuilder + public static string Canonicalize(string name) => + (name ?? string.Empty).ToLowerInvariant().Replace('\\', '/'); + + /// + /// Build one UnityFS bundle wrapping every mesh in plus + /// the one AssetBundle object. Returns null when is empty. + /// Throws if two blobs have the same canonical name. + /// + public static byte[] BuildMany( + IReadOnlyList blobs, + int targetPlatform = StandaloneWindows64 + ) { - // Unity BuildTarget.StandaloneWindows64. - public const int StandaloneWindows64 = 19; - - const long AssetBundlePathId = 1; - - // Mesh objects get path ids 2, 3, 4, ... (the AssetBundle object is path id 1). - const long FirstMeshPathId = 2; - - /// - /// Canonicalize an asset name into the exact form Unity uses for the m_Container lookup - /// key: lowercased (invariant culture) with backslashes replaced by forward slashes. Unity - /// canonicalizes the query passed to AssetBundle.LoadAsset(name) this same way but then - /// compares it verbatim against the stored key, so a stored key that isn't already - /// canonical can never be found. Pure; the model compiler reuses this to derive the lookup key. - /// - public static string Canonicalize(string name) => - (name ?? string.Empty).ToLowerInvariant().Replace('\\', '/'); - - /// - /// Build one UnityFS bundle wrapping every mesh in (each keyed in the - /// bundle's container by its canonical ) plus the one - /// AssetBundle object. Returns null when is empty. - /// Throws if two blobs canonicalize to the same container key (an ambiguous name lookup). - /// - public static byte[] BuildMany( - IReadOnlyList blobs, - int targetPlatform = StandaloneWindows64 - ) + if (blobs is null) + throw new ArgumentNullException(nameof(blobs)); + + int n = blobs.Count; + if (n == 0) + return null; + + // Canonicalize every container key once up front and reject duplicates: a duplicate + // canonical key makes AssetBundle.LoadAsset(name) ambiguous (the runtime container is a + // multimap, so a colliding key would silently resolve to an arbitrary mesh). + var canonicalKeys = new string[n]; + var seen = new Dictionary(n, StringComparer.Ordinal); + for (int i = 0; i < n; ++i) { - if (blobs is null) - throw new ArgumentNullException(nameof(blobs)); - - int n = blobs.Count; - if (n == 0) - return null; - - // Canonicalize every container key once up front and reject duplicates: a duplicate - // canonical key makes AssetBundle.LoadAsset(name) ambiguous (the runtime container is a - // multimap, so a colliding key would silently resolve to an arbitrary mesh). Fail loud. - var canonicalKeys = new string[n]; - var seen = new Dictionary(n, StringComparer.Ordinal); - for (int i = 0; i < n; ++i) - { - string key = Canonicalize(blobs[i].Name); - if (seen.TryGetValue(key, out int prev)) - throw new InvalidOperationException( - $"duplicate mesh container key '{key}' from meshes '{blobs[prev].Name}' and " + - $"'{blobs[i].Name}'; canonical container keys must be unique for name lookup"); - seen[key] = i; - canonicalKeys[i] = key; - } - - // Unique guid so concurrent loads don't collide. - string cab = "CAB-" + Guid.NewGuid().ToString("N"); - - var w = new BundleBufferWriter(EstimateSize(blobs)); - - // Geometry is inline in the object bodies, so there is no streamed resS. - var prefix = BundleWriter.WriteHeaderAndBlocksInfo(w, cab, resSLength: 0); - - // One AssetBundle object plus one Mesh object per blob. - var objects = new SerializedFileWriter.ObjectMeta[n + 1]; - var slots = new SerializedFileWriter.ObjectSlot[n + 1]; - objects[0] = new SerializedFileWriter.ObjectMeta( - AssetBundlePathId, SerializedTypeTrees.AssetBundleClassId); - for (int i = 0; i < n; ++i) - objects[i + 1] = new SerializedFileWriter.ObjectMeta( - FirstMeshPathId + i, SerializedTypeTrees.MeshClassId); - - var file = SerializedFileWriter.BeginFile(w, targetPlatform, objects, slots); - - file.BeginObject(w, ref slots[0]); - WriteAssetBundleBody(w, cab, canonicalKeys); - file.EndObject(w, slots[0]); - - for (int i = 0; i < n; ++i) - { - file.BeginObject(w, ref slots[i + 1]); - WriteMeshBody(w, blobs[i]); - file.EndObject(w, slots[i + 1]); - } - - long serializedFileLength = file.End(w); - - w.AlignBase = 0; - BundleWriter.Finish(w, prefix, serializedFileLength); - - return w.ToArray(); + string key = Canonicalize(blobs[i].Name); + if (seen.TryGetValue(key, out int prev)) + throw new InvalidOperationException( + $"duplicate mesh container key '{key}' from meshes '{blobs[prev].Name}' and " + + $"'{blobs[i].Name}'; canonical container keys must be unique for name lookup"); + seen[key] = i; + canonicalKeys[i] = key; } - static int EstimateSize(IReadOnlyList blobs) - { - long total = - 1024 - + ReferenceTypeTrees.TypeEntry(SerializedTypeTrees.AssetBundleClassId).Length - + ReferenceTypeTrees.TypeEntry(SerializedTypeTrees.MeshClassId).Length; - for (int i = 0; i < blobs.Count; ++i) - { - MeshBlob b = blobs[i]; - total += 512 - + (b.VertexData?.Length ?? 0) - + (b.IndexData?.Length ?? 0) - + (b.SubMeshes?.Length ?? 0) * 48 - + (b.BindPose?.Length ?? 0) * 64 - + (b.BoneNameHashes?.Length ?? 0) * 4 - + (b.BonesAABB?.Length ?? 0) * 24; - } - return total > int.MaxValue ? int.MaxValue : (int)total; - } + // Unique guid so concurrent loads don't collide. + string cab = "CAB-" + Guid.NewGuid().ToString("N"); + + var w = new BundleBufferWriter(EstimateSize(blobs)); + + // Geometry is inline in the object bodies, so there is no streamed resS. + var prefix = BundleWriter.WriteHeaderAndBlocksInfo(w, cab, resSLength: 0); + + var objects = new SerializedFileWriter.ObjectMeta[n + 1]; + var slots = new SerializedFileWriter.ObjectSlot[n + 1]; + objects[0] = new SerializedFileWriter.ObjectMeta( + AssetBundlePathId, SerializedTypeTrees.AssetBundleClassId); + for (int i = 0; i < n; ++i) + objects[i + 1] = new SerializedFileWriter.ObjectMeta( + FirstMeshPathId + i, SerializedTypeTrees.MeshClassId); - // ---- Mesh object body (Unity 2019.4.18f1, class 43) --------------------------------------- + var file = SerializedFileWriter.BeginFile(w, targetPlatform, objects, slots); - static void WriteMeshBody(BundleBufferWriter w, MeshBlob mesh) + file.BeginObject(w, ref slots[0]); + WriteAssetBundleBody(w, cab, canonicalKeys); + file.EndObject(w, slots[0]); + + for (int i = 0; i < n; ++i) { - w.WriteAlignedString(mesh.Name); // m_Name - - // m_SubMeshes : vector, Array aligns - var subMeshes = w.BeginArray(); - MeshSubMesh[] subs = mesh.SubMeshes ?? Array.Empty(); - for (int i = 0; i < subs.Length; ++i) - { - subMeshes.Add(); - WriteSubMesh(w, subs[i]); - } - subMeshes.End(align: true); - - // m_Shapes : BlendShapeData — four empty aligned vectors (no blendshapes in .mu) - w.BeginArray().End(align: true); // vertices - w.BeginArray().End(align: true); // shapes - w.BeginArray().End(align: true); // channels - w.BeginArray().End(align: true); // fullWeights - - // m_BindPose : vector, Array aligns - var bindPose = w.BeginArray(); - Matrix4x4[] poses = mesh.BindPose ?? Array.Empty(); - for (int i = 0; i < poses.Length; ++i) - { - bindPose.Add(); - WriteMatrix4x4(w, poses[i]); - } - bindPose.End(align: true); - - // m_BoneNameHashes : vector, Array aligns. One CRC32 per bone (empty for static). - var boneNameHashes = w.BeginArray(); - uint[] hashes = mesh.BoneNameHashes ?? Array.Empty(); - for (int i = 0; i < hashes.Length; ++i) - { - boneNameHashes.Add(); - w.WriteUInt32(hashes[i]); - } - boneNameHashes.End(align: true); - - w.WriteUInt32(mesh.RootBoneNameHash); // m_RootBoneNameHash - - // m_BonesAABB : vector, Array aligns. One 24-byte min/max per bone (empty for - // static). Element metaFlags carry no align-after bit, so no per-element padding. - var bonesAABB = w.BeginArray(); - MeshBoneAABB[] aabbs = mesh.BonesAABB ?? Array.Empty(); - for (int i = 0; i < aabbs.Length; ++i) - { - bonesAABB.Add(); - WriteMinMaxAABB(w, aabbs[i]); - } - bonesAABB.End(align: true); - - w.BeginArray().End(align: true); // m_VariableBoneCountWeights.m_Data : vector (empty) - - w.WriteByte(0); // m_MeshCompression (0 == uncompressed) - w.WriteBool(true); // m_IsReadable — keep the CPU copy (stock KSP behaviour) - w.WriteBool(false); // m_KeepVertices - w.WriteBool(false); // m_KeepIndices - w.Align(4); // m_KeepIndices aligns - - w.WriteInt32(mesh.IndexFormat); // m_IndexFormat (0 == UInt16, 1 == UInt32) - - // m_IndexBuffer : vector, Array aligns - byte[] indexData = mesh.IndexData ?? Array.Empty(); - w.WriteInt32(indexData.Length); - w.WriteBytes(indexData); - w.Align(4); - - // m_VertexData : VertexData (node aligns after) - w.WriteUInt32((uint)mesh.VertexCount); // m_VertexCount - var channels = w.BeginArray(); // m_Channels : vector, Array aligns - MeshChannel[] chans = mesh.Channels ?? Array.Empty(); - for (int i = 0; i < chans.Length; ++i) - { - channels.Add(); - w.WriteByte(chans[i].Stream); - w.WriteByte(chans[i].Offset); - w.WriteByte(chans[i].Format); - w.WriteByte(chans[i].Dimension); - } - channels.End(align: true); - byte[] vertexData = mesh.VertexData ?? Array.Empty(); - w.WriteInt32(vertexData.Length); // m_DataSize : TypelessData, aligns - w.WriteBytes(vertexData); - w.Align(4); - w.Align(4); // VertexData node aligns (no-op after m_DataSize's align) - - WriteEmptyCompressedMesh(w); // m_CompressedMesh (all packed vectors empty) - - WriteAABB(w, mesh.LocalBounds); // m_LocalAABB - w.WriteInt32(0); // m_MeshUsageFlags - w.BeginArray().End(align: true); // m_BakedConvexCollisionMesh : vector - w.BeginArray().End(align: true); // m_BakedTriangleCollisionMesh : vector - w.WriteSingle(mesh.MeshMetric0); // m_MeshMetrics[0] - w.WriteSingle(mesh.MeshMetric1); // m_MeshMetrics[1] - w.Align(4); // m_MeshMetrics[1] aligns - - // m_StreamData : StreamingInfo — empty (geometry is inline above) - w.WriteUInt32(0); // offset - w.WriteUInt32(0); // size - w.WriteAlignedString(string.Empty); // path + file.BeginObject(w, ref slots[i + 1]); + WriteMeshBody(w, blobs[i]); + file.EndObject(w, slots[i + 1]); } - static void WriteSubMesh(BundleBufferWriter w, in MeshSubMesh s) + long serializedFileLength = file.End(w); + + w.AlignBase = 0; + BundleWriter.Finish(w, prefix, serializedFileLength); + + return w.ToArray(); + } + + static int EstimateSize(IReadOnlyList blobs) + { + long total = + 1024 + + ReferenceTypeTrees.TypeEntry(SerializedTypeTrees.AssetBundleClassId).Length + + ReferenceTypeTrees.TypeEntry(SerializedTypeTrees.MeshClassId).Length; + for (int i = 0; i < blobs.Count; ++i) { - w.WriteUInt32(s.FirstByte); - w.WriteUInt32(s.IndexCount); - w.WriteInt32(s.Topology); - w.WriteUInt32(s.BaseVertex); - w.WriteUInt32(s.FirstVertex); - w.WriteUInt32(s.VertexCount); - WriteAABB(w, s.LocalBounds); + MeshBlob b = blobs[i]; + total += 512 + + (b.VertexData?.Length ?? 0) + + (b.IndexData?.Length ?? 0) + + (b.SubMeshes?.Length ?? 0) * 48 + + (b.BindPose?.Length ?? 0) * 64 + + (b.BoneNameHashes?.Length ?? 0) * 4 + + (b.BonesAABB?.Length ?? 0) * 24; } + return total > int.MaxValue ? int.MaxValue : (int)total; + } + + // ---- Mesh object body (Unity 2019.4.18f1, class 43) --------------------------------------- + + static void WriteMeshBody(BundleBufferWriter w, MeshBlob mesh) + { + w.WriteAlignedString(mesh.Name); // m_Name - static void WriteAABB(BundleBufferWriter w, in Bounds bounds) + // m_SubMeshes : vector, Array aligns + var subMeshes = w.BeginArray(); + MeshSubMesh[] subs = mesh.SubMeshes ?? Array.Empty(); + for (int i = 0; i < subs.Length; ++i) { - Vector3 c = bounds.center; - Vector3 e = bounds.extents; - w.WriteSingle(c.x); - w.WriteSingle(c.y); - w.WriteSingle(c.z); - w.WriteSingle(e.x); - w.WriteSingle(e.y); - w.WriteSingle(e.z); + subMeshes.Add(); + WriteSubMesh(w, subs[i]); } - - // MinMaxAABB (m_BonesAABB element): two Vector3f (m_Min, m_Max), 24 bytes. Distinct from the - // AABB (m_Center/m_Extent) written by WriteAABB. - static void WriteMinMaxAABB(BundleBufferWriter w, in MeshBoneAABB a) + subMeshes.End(align: true); + + // m_Shapes : BlendShapeData — four empty aligned vectors (no blendshapes in .mu) + w.BeginArray().End(align: true); // vertices + w.BeginArray().End(align: true); // shapes + w.BeginArray().End(align: true); // channels + w.BeginArray().End(align: true); // fullWeights + + // m_BindPose : vector, Array aligns + var bindPose = w.BeginArray(); + Matrix4x4[] poses = mesh.BindPose ?? Array.Empty(); + for (int i = 0; i < poses.Length; ++i) { - Vector3 min = a.Min; - Vector3 max = a.Max; - w.WriteSingle(min.x); - w.WriteSingle(min.y); - w.WriteSingle(min.z); - w.WriteSingle(max.x); - w.WriteSingle(max.y); - w.WriteSingle(max.z); + bindPose.Add(); + WriteMatrix4x4(w, poses[i]); } + bindPose.End(align: true); - static void WriteMatrix4x4(BundleBufferWriter w, in Matrix4x4 m) + // m_BoneNameHashes : vector, Array aligns. One CRC32 per bone. + var boneNameHashes = w.BeginArray(); + uint[] hashes = mesh.BoneNameHashes ?? []; + for (int i = 0; i < hashes.Length; ++i) { - // Unity serializes e00,e01,...,e33 (row-major field names). Matrix4x4 indexer m[row,col] - // matches eRowCol. - w.WriteSingle(m.m00); w.WriteSingle(m.m01); w.WriteSingle(m.m02); w.WriteSingle(m.m03); - w.WriteSingle(m.m10); w.WriteSingle(m.m11); w.WriteSingle(m.m12); w.WriteSingle(m.m13); - w.WriteSingle(m.m20); w.WriteSingle(m.m21); w.WriteSingle(m.m22); w.WriteSingle(m.m23); - w.WriteSingle(m.m30); w.WriteSingle(m.m31); w.WriteSingle(m.m32); w.WriteSingle(m.m33); + boneNameHashes.Add(); + w.WriteUInt32(hashes[i]); } + boneNameHashes.End(align: true); + + w.WriteUInt32(mesh.RootBoneNameHash); // m_RootBoneNameHash - // All ten PackedBitVectors are empty; five carry m_Range/m_Start floats (see the type tree). - static void WriteEmptyCompressedMesh(BundleBufferWriter w) + // m_BonesAABB : vector, Array aligns. One 24-byte min/max per bone (empty for + // static). Element metaFlags carry no align-after bit, so no per-element padding. + var bonesAABB = w.BeginArray(); + MeshBoneAABB[] aabbs = mesh.BonesAABB ?? []; + for (int i = 0; i < aabbs.Length; ++i) { - WritePackedBitVector(w, hasRange: true); // m_Vertices - WritePackedBitVector(w, hasRange: true); // m_UV - WritePackedBitVector(w, hasRange: true); // m_Normals - WritePackedBitVector(w, hasRange: true); // m_Tangents - WritePackedBitVector(w, hasRange: false); // m_Weights - WritePackedBitVector(w, hasRange: false); // m_NormalSigns - WritePackedBitVector(w, hasRange: false); // m_TangentSigns - WritePackedBitVector(w, hasRange: true); // m_FloatColors - WritePackedBitVector(w, hasRange: false); // m_BoneIndices - WritePackedBitVector(w, hasRange: false); // m_Triangles - w.WriteUInt32(0); // m_UVInfo + bonesAABB.Add(); + WriteMinMaxAABB(w, aabbs[i]); } + bonesAABB.End(align: true); + + w.BeginArray().End(align: true); // m_VariableBoneCountWeights.m_Data : vector (empty) + + w.WriteByte(0); // m_MeshCompression (0 == uncompressed) + w.WriteBool(true); // m_IsReadable — keep the CPU copy (stock KSP behaviour) + w.WriteBool(false); // m_KeepVertices + w.WriteBool(false); // m_KeepIndices + w.Align(4); // m_KeepIndices aligns - static void WritePackedBitVector(BundleBufferWriter w, bool hasRange) + w.WriteInt32(mesh.IndexFormat); // m_IndexFormat (0 == UInt16, 1 == UInt32) + + // m_IndexBuffer : vector, Array aligns + byte[] indexData = mesh.IndexData ?? Array.Empty(); + w.WriteInt32(indexData.Length); + w.WriteBytes(indexData); + w.Align(4); + + // m_VertexData : VertexData (node aligns after) + w.WriteUInt32((uint)mesh.VertexCount); // m_VertexCount + var channels = w.BeginArray(); // m_Channels : vector, Array aligns + MeshChannel[] chans = mesh.Channels ?? Array.Empty(); + for (int i = 0; i < chans.Length; ++i) { - w.WriteUInt32(0); // m_NumItems - if (hasRange) - { - w.WriteSingle(0f); // m_Range - w.WriteSingle(0f); // m_Start - } - w.BeginArray().End(align: true); // m_Data : vector, Array aligns - w.WriteByte(0); // m_BitSize - w.Align(4); // m_BitSize aligns + channels.Add(); + w.WriteByte(chans[i].Stream); + w.WriteByte(chans[i].Offset); + w.WriteByte(chans[i].Format); + w.WriteByte(chans[i].Dimension); } + channels.End(align: true); + byte[] vertexData = mesh.VertexData ?? Array.Empty(); + w.WriteInt32(vertexData.Length); // m_DataSize : TypelessData, aligns + w.WriteBytes(vertexData); + w.Align(4); + w.Align(4); // VertexData node aligns (no-op after m_DataSize's align) + + WriteEmptyCompressedMesh(w); // m_CompressedMesh (all packed vectors empty) + + WriteAABB(w, mesh.LocalBounds); // m_LocalAABB + w.WriteInt32(0); // m_MeshUsageFlags + w.BeginArray().End(align: true); // m_BakedConvexCollisionMesh : vector + w.BeginArray().End(align: true); // m_BakedTriangleCollisionMesh : vector + w.WriteSingle(mesh.MeshMetric0); // m_MeshMetrics[0] + w.WriteSingle(mesh.MeshMetric1); // m_MeshMetrics[1] + w.Align(4); // m_MeshMetrics[1] aligns + + // m_StreamData : StreamingInfo — empty (geometry is inline above) + w.WriteUInt32(0); // offset + w.WriteUInt32(0); // size + w.WriteAlignedString(string.Empty); // path + } - // ---- AssetBundle object body -------------------------------------------------------------- + static void WriteSubMesh(BundleBufferWriter w, in MeshSubMesh s) + { + w.WriteUInt32(s.FirstByte); + w.WriteUInt32(s.IndexCount); + w.WriteInt32(s.Topology); + w.WriteUInt32(s.BaseVertex); + w.WriteUInt32(s.FirstVertex); + w.WriteUInt32(s.VertexCount); + WriteAABB(w, s.LocalBounds); + } + + static void WriteAABB(BundleBufferWriter w, in Bounds bounds) + { + Vector3 c = bounds.center; + Vector3 e = bounds.extents; + w.WriteSingle(c.x); + w.WriteSingle(c.y); + w.WriteSingle(c.z); + w.WriteSingle(e.x); + w.WriteSingle(e.y); + w.WriteSingle(e.z); + } + + // MinMaxAABB (m_BonesAABB element): two Vector3f (m_Min, m_Max), 24 bytes. Distinct from the + // AABB (m_Center/m_Extent) written by WriteAABB. + static void WriteMinMaxAABB(BundleBufferWriter w, in MeshBoneAABB a) + { + Vector3 min = a.Min; + Vector3 max = a.Max; + w.WriteSingle(min.x); + w.WriteSingle(min.y); + w.WriteSingle(min.z); + w.WriteSingle(max.x); + w.WriteSingle(max.y); + w.WriteSingle(max.z); + } + + static void WriteMatrix4x4(BundleBufferWriter w, in Matrix4x4 m) + { + // Unity serializes e00,e01,...,e33 (row-major field names). Matrix4x4 indexer m[row,col] + // matches eRowCol. + w.WriteSingle(m.m00); w.WriteSingle(m.m01); w.WriteSingle(m.m02); w.WriteSingle(m.m03); + w.WriteSingle(m.m10); w.WriteSingle(m.m11); w.WriteSingle(m.m12); w.WriteSingle(m.m13); + w.WriteSingle(m.m20); w.WriteSingle(m.m21); w.WriteSingle(m.m22); w.WriteSingle(m.m23); + w.WriteSingle(m.m30); w.WriteSingle(m.m31); w.WriteSingle(m.m32); w.WriteSingle(m.m33); + } + + // All ten PackedBitVectors are empty; five carry m_Range/m_Start floats (see the type tree). + static void WriteEmptyCompressedMesh(BundleBufferWriter w) + { + WritePackedBitVector(w, hasRange: true); // m_Vertices + WritePackedBitVector(w, hasRange: true); // m_UV + WritePackedBitVector(w, hasRange: true); // m_Normals + WritePackedBitVector(w, hasRange: true); // m_Tangents + WritePackedBitVector(w, hasRange: false); // m_Weights + WritePackedBitVector(w, hasRange: false); // m_NormalSigns + WritePackedBitVector(w, hasRange: false); // m_TangentSigns + WritePackedBitVector(w, hasRange: true); // m_FloatColors + WritePackedBitVector(w, hasRange: false); // m_BoneIndices + WritePackedBitVector(w, hasRange: false); // m_Triangles + w.WriteUInt32(0); // m_UVInfo + } - static void WriteAssetBundleBody( - BundleBufferWriter w, - string identity, - string[] canonicalKeys - ) + static void WritePackedBitVector(BundleBufferWriter w, bool hasRange) + { + w.WriteUInt32(0); // m_NumItems + if (hasRange) { - int n = canonicalKeys.Length; - - w.WriteAlignedString(identity); // m_Name - - // m_PreloadTable : one PPtr per mesh, in blob order (path ids 2..N+1). Each container - // entry references its own single-entry slice of this table by blob index. - var preload = w.BeginArray(); - for (int i = 0; i < n; ++i) - { - preload.Add(); - WritePPtr(w, fileId: 0, pathId: FirstMeshPathId + i); - } - preload.End(align: true); - - // m_Container : map. Array not aligned. Written sorted ascending by - // canonical key. Ghidra (UnityPlayer 2019.4) shows Unity deserializes this map into a - // std::multimap> by inserting each pair into a - // red-black tree, so the runtime lookup is a tree search that re-sorts on load and is - // therefore independent of the serialized order. We still sort here to match the layout of - // real Unity-built bundles (correct whether the lookup were linear or binary). Only the - // container entry order changes: each entry still keys its own mesh (path id) and its own - // preload slice (blob index), so meshes keep path ids 2..N+1 in blob order. - var order = new int[n]; - for (int i = 0; i < n; ++i) - order[i] = i; - Array.Sort(order, (a, b) => string.CompareOrdinal(canonicalKeys[a], canonicalKeys[b])); - - var container = w.BeginArray(); - for (int j = 0; j < n; ++j) - { - int i = order[j]; - container.Add(); - w.WriteAlignedString(canonicalKeys[i]); // pair.first (canonical lookup key) - WriteAssetInfo( - w, preloadIndex: i, preloadSize: 1, fileId: 0, pathId: FirstMeshPathId + i); - } - container.End(); - - WriteAssetInfo(w, preloadIndex: 0, preloadSize: 0, fileId: 0, pathId: 0); // m_MainAsset - w.WriteUInt32(1); // m_RuntimeCompatibility - w.WriteAlignedString(identity); // m_AssetBundleName - w.BeginArray().End(align: true); // m_Dependencies (empty) - w.WriteBool(false); // m_IsStreamedSceneAssetBundle - w.Align(4); - w.WriteInt32(0); // m_ExplicitDataLayout - w.WriteInt32(0); // m_PathFlags - w.BeginArray().End(); // m_SceneHashes (empty map, Array not aligned) + w.WriteSingle(0f); // m_Range + w.WriteSingle(0f); // m_Start } + w.BeginArray().End(align: true); // m_Data : vector, Array aligns + w.WriteByte(0); // m_BitSize + w.Align(4); // m_BitSize aligns + } - static void WritePPtr(BundleBufferWriter w, int fileId, long pathId) + // ---- AssetBundle object body -------------------------------------------------------------- + + static void WriteAssetBundleBody( + BundleBufferWriter w, + string identity, + string[] canonicalKeys + ) + { + int n = canonicalKeys.Length; + + w.WriteAlignedString(identity); // m_Name + + // m_PreloadTable : one PPtr per mesh, in blob order (path ids 2..N+1). Each container + // entry references its own single-entry slice of this table by blob index. + var preload = w.BeginArray(); + for (int i = 0; i < n; ++i) { - w.WriteInt32(fileId); // m_FileID - w.WriteInt64(pathId); // m_PathID + preload.Add(); + WritePPtr(w, fileId: 0, pathId: FirstMeshPathId + i); } - - static void WriteAssetInfo( - BundleBufferWriter w, - int preloadIndex, - int preloadSize, - int fileId, - long pathId - ) + preload.End(align: true); + + // m_Container : map. Array not aligned. Written sorted ascending by + // canonical key. Ghidra (UnityPlayer 2019.4) shows Unity deserializes this map into a + // std::multimap> by inserting each pair into a + // red-black tree, so the runtime lookup is a tree search that re-sorts on load and is + // therefore independent of the serialized order. We still sort here to match the layout of + // real Unity-built bundles (correct whether the lookup were linear or binary). Only the + // container entry order changes: each entry still keys its own mesh (path id) and its own + // preload slice (blob index), so meshes keep path ids 2..N+1 in blob order. + var order = new int[n]; + for (int i = 0; i < n; ++i) + order[i] = i; + Array.Sort(order, (a, b) => string.CompareOrdinal(canonicalKeys[a], canonicalKeys[b])); + + var container = w.BeginArray(); + for (int j = 0; j < n; ++j) { - w.WriteInt32(preloadIndex); - w.WriteInt32(preloadSize); - WritePPtr(w, fileId, pathId); + int i = order[j]; + container.Add(); + w.WriteAlignedString(canonicalKeys[i]); // pair.first (canonical lookup key) + WriteAssetInfo( + w, preloadIndex: i, preloadSize: 1, fileId: 0, pathId: FirstMeshPathId + i); } + container.End(); + + WriteAssetInfo(w, preloadIndex: 0, preloadSize: 0, fileId: 0, pathId: 0); // m_MainAsset + w.WriteUInt32(1); // m_RuntimeCompatibility + w.WriteAlignedString(identity); // m_AssetBundleName + w.BeginArray().End(align: true); // m_Dependencies (empty) + w.WriteBool(false); // m_IsStreamedSceneAssetBundle + w.Align(4); + w.WriteInt32(0); // m_ExplicitDataLayout + w.WriteInt32(0); // m_PathFlags + w.BeginArray().End(); // m_SceneHashes (empty map, Array not aligned) + } + + static void WritePPtr(BundleBufferWriter w, int fileId, long pathId) + { + w.WriteInt32(fileId); // m_FileID + w.WriteInt64(pathId); // m_PathID + } + + static void WriteAssetInfo( + BundleBufferWriter w, + int preloadIndex, + int preloadSize, + int fileId, + long pathId + ) + { + w.WriteInt32(preloadIndex); + w.WriteInt32(preloadSize); + WritePPtr(w, fileId, pathId); } } diff --git a/KSPCommunityFixes/Library/Model/MeshBundleSelfTest.cs b/KSPCommunityFixes/Library/Model/MeshBundleSelfTest.cs index 17196d0..fe9b3ac 100644 --- a/KSPCommunityFixes/Library/Model/MeshBundleSelfTest.cs +++ b/KSPCommunityFixes/Library/Model/MeshBundleSelfTest.cs @@ -3,200 +3,190 @@ using System.Text; using UnityEngine; -namespace KSPCommunityFixes.Library.Model +namespace KSPCommunityFixes.Library.Model; + +/// +/// A debug-only self-test that round-trips a mesh through an asset bundle. +/// +[KSPAddon(KSPAddon.Startup.Instantly, true)] +internal class MeshBundleSelfTest : MonoBehaviour { - /// - /// DEBUG-only self-test: builds a mesh bundle in memory, loads it via - /// AssetBundle.LoadFromMemoryAsync + a per-name LoadAssetAsync, and verifies the - /// loaded mesh renders and reads back identically to the source. Runs once at startup and logs a - /// [MeshSelfTest] PASS/FAIL sentinel. This validates the mesh serialization in the real - /// Unity runtime before the model pipeline is built on top of it. - /// The mesh name here is deliberately non-canonical (mixed case + backslash) and is - /// used verbatim as both the source blob name and the LoadAssetAsync query. Unity - /// canonicalizes the query, and canonicalizes the - /// stored container key, so the load only succeeds if that canonicalization is applied — this is - /// the regression guard for the container-key canonicalization fix. - /// - [KSPAddon(KSPAddon.Startup.Instantly, true)] - internal class MeshBundleSelfTest : MonoBehaviour - { - const string Tag = "[MeshSelfTest]"; + const string Tag = "[MeshSelfTest]"; - // Deliberately non-canonical (uppercase + backslash) to exercise container-key - // canonicalization: Unity lowercases/forward-slashes the LoadAssetAsync query to - // "meshselftest/quad#0", which must match the canonicalized stored key. - const string MeshName = "MeshSelfTest\\Quad#0"; + // Deliberately non-canonical (uppercase + backslash) to exercise container-key + // canonicalization: Unity lowercases/forward-slashes the LoadAssetAsync query to + // "meshselftest/quad#0", which must match the canonicalized stored key. + const string MeshName = "MeshSelfTest\\Quad#0"; - IEnumerator Start() + IEnumerator Start() + { + Mesh src = BuildSourceQuad(); + MeshBlob blob = MeshBlobBuilder.FromMesh(src, MeshName); + byte[] bytes = MeshBundleBuilder.BuildMany(new[] { blob }); + Debug.Log($"{Tag} built bundle: {bytes.Length} bytes, src verts={src.vertexCount}"); + + AssetBundleCreateRequest createReq = AssetBundle.LoadFromMemoryAsync(bytes); + yield return createReq; + AssetBundle bundle = createReq.assetBundle; + if (bundle == null) { - Mesh src = BuildSourceQuad(); - MeshBlob blob = MeshBlobBuilder.FromMesh(src, MeshName); - byte[] bytes = MeshBundleBuilder.BuildMany(new[] { blob }); - Debug.Log($"{Tag} built bundle: {bytes.Length} bytes, src verts={src.vertexCount}"); - - AssetBundleCreateRequest createReq = AssetBundle.LoadFromMemoryAsync(bytes); - yield return createReq; - AssetBundle bundle = createReq.assetBundle; - if (bundle == null) - { - Debug.LogError($"{Tag} FAIL: LoadFromMemoryAsync returned a null bundle"); - yield break; - } - - AssetBundleRequest loadReq = bundle.LoadAssetAsync(MeshName); - yield return loadReq; - Mesh loaded = loadReq.asset as Mesh; - if (loaded == null) - { - Debug.LogError($"{Tag} FAIL: LoadAssetAsync(\"{MeshName}\") returned null " + - "(container-key lookup or deserialization failed)"); - bundle.Unload(true); - yield break; - } - - var sb = new StringBuilder(); - bool ok = Compare(src, loaded, sb); - ok &= CheckMetrics(blob, loaded, sb); - - // Exercise the render path: put it on a GameObject with a renderer (no exception == ok). - bool rendered = true; - try - { - var go = new GameObject("MeshSelfTestRender"); - go.AddComponent().sharedMesh = loaded; - go.AddComponent(); - go.SetActive(false); - Destroy(go); - } - catch (System.Exception e) - { - rendered = false; - sb.Append($"\n render setup threw: {e.GetType().Name}: {e.Message}"); - } - - if (ok && rendered && loaded.isReadable) - Debug.Log($"{Tag} PASS: loaded readable mesh round-trips (verts={loaded.vertexCount}, " + - $"subMeshes={loaded.subMeshCount}){sb}"); - else - Debug.LogError($"{Tag} FAIL: ok={ok} rendered={rendered} readable={loaded.isReadable}{sb}"); - - bundle.Unload(false); + Debug.LogError($"{Tag} FAIL: LoadFromMemoryAsync returned a null bundle"); + yield break; } - static Mesh BuildSourceQuad() + AssetBundleRequest loadReq = bundle.LoadAssetAsync(MeshName); + yield return loadReq; + Mesh loaded = loadReq.asset as Mesh; + if (loaded == null) { - var mesh = new Mesh { name = "src_quad" }; - mesh.SetVertices(new System.Collections.Generic.List - { - new Vector3(0f, 0f, 0f), new Vector3(1f, 0f, 0f), - new Vector3(1f, 1f, 0f), new Vector3(0f, 1f, 0f), - }); - mesh.SetNormals(new System.Collections.Generic.List - { - Vector3.forward, Vector3.forward, Vector3.forward, Vector3.forward, - }); - mesh.SetTangents(new System.Collections.Generic.List - { - new Vector4(1f, 0f, 0f, 1f), new Vector4(1f, 0f, 0f, 1f), - new Vector4(1f, 0f, 0f, 1f), new Vector4(1f, 0f, 0f, 1f), - }); - mesh.colors32 = new[] - { - new Color32(255, 0, 0, 255), new Color32(0, 255, 0, 255), - new Color32(0, 0, 255, 255), new Color32(255, 255, 0, 128), - }; - mesh.SetUVs(0, new System.Collections.Generic.List - { - new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(1f, 1f), new Vector2(0f, 1f), - }); - mesh.SetUVs(1, new System.Collections.Generic.List - { - new Vector2(0f, 0f), new Vector2(0.5f, 0f), new Vector2(0.5f, 0.5f), new Vector2(0f, 0.5f), - }); - mesh.SetTriangles(new[] { 0, 1, 2, 0, 2, 3 }, 0); - mesh.RecalculateBounds(); - return mesh; + Debug.LogError($"{Tag} FAIL: LoadAssetAsync(\"{MeshName}\") returned null " + + "(container-key lookup or deserialization failed)"); + bundle.Unload(true); + yield break; } - // Verifies the baked UV distribution metric survives serialization. GetUVDistributionMetric is a - // pure getter of the stored m_MeshMetrics, so the loaded mesh must read back the value baked into - // the blob. The source quad is a unit square (each triangle's object area 0.5); UV0 covers the - // full [0,1] square (UV area 0.5 -> ratio 1) and UV1 a [0,0.5] square (UV area 0.125 -> ratio 4), - // so the metrics are exactly 1 and 4. - static bool CheckMetrics(MeshBlob blob, Mesh loaded, StringBuilder sb) - { - bool ok = CheckMetric("uv0", 1f, blob.MeshMetric0, loaded.GetUVDistributionMetric(0), sb); - ok &= CheckMetric("uv1", 4f, blob.MeshMetric1, loaded.GetUVDistributionMetric(1), sb); - return ok; - } + var sb = new StringBuilder(); + bool ok = Compare(src, loaded, sb); + ok &= CheckMetrics(blob, loaded, sb); - static bool CheckMetric(string n, float expected, float baked, float readBack, StringBuilder sb) + // Exercise the render path: assigning the mesh to a renderer must not throw. + bool rendered = true; + try { - bool ok = true; - if (Mathf.Abs(baked - expected) > 1e-4f) - { ok = false; sb.Append($"\n metric {n} baked {baked} != expected {expected}"); } - if (Mathf.Abs(readBack - baked) > 1e-6f) - { ok = false; sb.Append($"\n metric {n} read-back {readBack} != baked {baked}"); } - return ok; + var go = new GameObject("MeshSelfTestRender"); + go.AddComponent().sharedMesh = loaded; + go.AddComponent(); + go.SetActive(false); + Destroy(go); } - - static bool Compare(Mesh a, Mesh b, StringBuilder sb) + catch (System.Exception e) { - bool ok = true; - if (a.vertexCount != b.vertexCount) { ok = false; sb.Append($"\n vertexCount {a.vertexCount} != {b.vertexCount}"); return ok; } - if (a.subMeshCount != b.subMeshCount) { ok = false; sb.Append($"\n subMeshCount {a.subMeshCount} != {b.subMeshCount}"); } - ok &= CmpV3("vertices", a.vertices, b.vertices, sb); - ok &= CmpV3("normals", a.normals, b.normals, sb); - ok &= CmpV4("tangents", a.tangents, b.tangents, sb); - ok &= CmpV2("uv", a.uv, b.uv, sb); - ok &= CmpV2("uv2", a.uv2, b.uv2, sb); - ok &= CmpC32("colors32", a.colors32, b.colors32, sb); - int subs = Mathf.Min(a.subMeshCount, b.subMeshCount); - for (int s = 0; s < subs; ++s) - ok &= CmpInt($"triangles[{s}]", a.GetTriangles(s), b.GetTriangles(s), sb); - return ok; + rendered = false; + sb.Append($"\n render setup threw: {e.GetType().Name}: {e.Message}"); } - static bool CmpV3(string n, Vector3[] a, Vector3[] b, StringBuilder sb) - { - if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } - for (int i = 0; i < a.Length; ++i) - if ((a[i] - b[i]).sqrMagnitude > 1e-10f) { sb.Append($"\n {n}[{i}] {a[i]} != {b[i]}"); return false; } - return true; - } + if (ok && rendered && loaded.isReadable) + Debug.Log($"{Tag} PASS: loaded readable mesh round-trips (verts={loaded.vertexCount}, " + + $"subMeshes={loaded.subMeshCount}){sb}"); + else + Debug.LogError($"{Tag} FAIL: ok={ok} rendered={rendered} readable={loaded.isReadable}{sb}"); - static bool CmpV4(string n, Vector4[] a, Vector4[] b, StringBuilder sb) - { - if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } - for (int i = 0; i < a.Length; ++i) - if ((a[i] - b[i]).sqrMagnitude > 1e-10f) { sb.Append($"\n {n}[{i}] {a[i]} != {b[i]}"); return false; } - return true; - } + bundle.Unload(false); + } - static bool CmpV2(string n, Vector2[] a, Vector2[] b, StringBuilder sb) + static Mesh BuildSourceQuad() + { + var mesh = new Mesh { name = "src_quad" }; + mesh.SetVertices(new System.Collections.Generic.List { - if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } - for (int i = 0; i < a.Length; ++i) - if ((a[i] - b[i]).sqrMagnitude > 1e-10f) { sb.Append($"\n {n}[{i}] {a[i]} != {b[i]}"); return false; } - return true; - } - - static bool CmpC32(string n, Color32[] a, Color32[] b, StringBuilder sb) + new Vector3(0f, 0f, 0f), new Vector3(1f, 0f, 0f), + new Vector3(1f, 1f, 0f), new Vector3(0f, 1f, 0f), + }); + mesh.SetNormals(new System.Collections.Generic.List { - if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } - for (int i = 0; i < a.Length; ++i) - if (a[i].r != b[i].r || a[i].g != b[i].g || a[i].b != b[i].b || a[i].a != b[i].a) - { sb.Append($"\n {n}[{i}] ({a[i].r},{a[i].g},{a[i].b},{a[i].a}) != ({b[i].r},{b[i].g},{b[i].b},{b[i].a})"); return false; } - return true; - } - - static bool CmpInt(string n, int[] a, int[] b, StringBuilder sb) + Vector3.forward, Vector3.forward, Vector3.forward, Vector3.forward, + }); + mesh.SetTangents(new System.Collections.Generic.List { - if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } - for (int i = 0; i < a.Length; ++i) - if (a[i] != b[i]) { sb.Append($"\n {n}[{i}] {a[i]} != {b[i]}"); return false; } - return true; - } + new Vector4(1f, 0f, 0f, 1f), new Vector4(1f, 0f, 0f, 1f), + new Vector4(1f, 0f, 0f, 1f), new Vector4(1f, 0f, 0f, 1f), + }); + mesh.colors32 = new[] + { + new Color32(255, 0, 0, 255), new Color32(0, 255, 0, 255), + new Color32(0, 0, 255, 255), new Color32(255, 255, 0, 128), + }; + mesh.SetUVs(0, new System.Collections.Generic.List + { + new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(1f, 1f), new Vector2(0f, 1f), + }); + mesh.SetUVs(1, new System.Collections.Generic.List + { + new Vector2(0f, 0f), new Vector2(0.5f, 0f), new Vector2(0.5f, 0.5f), new Vector2(0f, 0.5f), + }); + mesh.SetTriangles(new[] { 0, 1, 2, 0, 2, 3 }, 0); + mesh.RecalculateBounds(); + return mesh; + } + + // Verifies the baked UV distribution metric survives serialization. GetUVDistributionMetric is a + // pure getter of the stored m_MeshMetrics, so the loaded mesh must read back the value baked into + // the blob. The source quad is a unit square (each triangle's object area 0.5); UV0 covers the + // full [0,1] square (UV area 0.5 -> ratio 1) and UV1 a [0,0.5] square (UV area 0.125 -> ratio 4), + // so the metrics are exactly 1 and 4. + static bool CheckMetrics(MeshBlob blob, Mesh loaded, StringBuilder sb) + { + bool ok = CheckMetric("uv0", 1f, blob.MeshMetric0, loaded.GetUVDistributionMetric(0), sb); + ok &= CheckMetric("uv1", 4f, blob.MeshMetric1, loaded.GetUVDistributionMetric(1), sb); + return ok; + } + + static bool CheckMetric(string n, float expected, float baked, float readBack, StringBuilder sb) + { + bool ok = true; + if (Mathf.Abs(baked - expected) > 1e-4f) + { ok = false; sb.Append($"\n metric {n} baked {baked} != expected {expected}"); } + if (Mathf.Abs(readBack - baked) > 1e-6f) + { ok = false; sb.Append($"\n metric {n} read-back {readBack} != baked {baked}"); } + return ok; + } + + static bool Compare(Mesh a, Mesh b, StringBuilder sb) + { + bool ok = true; + if (a.vertexCount != b.vertexCount) { ok = false; sb.Append($"\n vertexCount {a.vertexCount} != {b.vertexCount}"); return ok; } + if (a.subMeshCount != b.subMeshCount) { ok = false; sb.Append($"\n subMeshCount {a.subMeshCount} != {b.subMeshCount}"); } + ok &= CmpV3("vertices", a.vertices, b.vertices, sb); + ok &= CmpV3("normals", a.normals, b.normals, sb); + ok &= CmpV4("tangents", a.tangents, b.tangents, sb); + ok &= CmpV2("uv", a.uv, b.uv, sb); + ok &= CmpV2("uv2", a.uv2, b.uv2, sb); + ok &= CmpC32("colors32", a.colors32, b.colors32, sb); + int subs = Mathf.Min(a.subMeshCount, b.subMeshCount); + for (int s = 0; s < subs; ++s) + ok &= CmpInt($"triangles[{s}]", a.GetTriangles(s), b.GetTriangles(s), sb); + return ok; + } + + static bool CmpV3(string n, Vector3[] a, Vector3[] b, StringBuilder sb) + { + if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } + for (int i = 0; i < a.Length; ++i) + if ((a[i] - b[i]).sqrMagnitude > 1e-10f) { sb.Append($"\n {n}[{i}] {a[i]} != {b[i]}"); return false; } + return true; + } + + static bool CmpV4(string n, Vector4[] a, Vector4[] b, StringBuilder sb) + { + if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } + for (int i = 0; i < a.Length; ++i) + if ((a[i] - b[i]).sqrMagnitude > 1e-10f) { sb.Append($"\n {n}[{i}] {a[i]} != {b[i]}"); return false; } + return true; + } + + static bool CmpV2(string n, Vector2[] a, Vector2[] b, StringBuilder sb) + { + if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } + for (int i = 0; i < a.Length; ++i) + if ((a[i] - b[i]).sqrMagnitude > 1e-10f) { sb.Append($"\n {n}[{i}] {a[i]} != {b[i]}"); return false; } + return true; + } + + static bool CmpC32(string n, Color32[] a, Color32[] b, StringBuilder sb) + { + if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } + for (int i = 0; i < a.Length; ++i) + if (a[i].r != b[i].r || a[i].g != b[i].g || a[i].b != b[i].b || a[i].a != b[i].a) + { sb.Append($"\n {n}[{i}] ({a[i].r},{a[i].g},{a[i].b},{a[i].a}) != ({b[i].r},{b[i].g},{b[i].b},{b[i].a})"); return false; } + return true; + } + + static bool CmpInt(string n, int[] a, int[] b, StringBuilder sb) + { + if (a.Length != b.Length) { sb.Append($"\n {n} len {a.Length} != {b.Length}"); return false; } + for (int i = 0; i < a.Length; ++i) + if (a[i] != b[i]) { sb.Append($"\n {n}[{i}] {a[i]} != {b[i]}"); return false; } + return true; } } #endif diff --git a/KSPCommunityFixes/Library/Model/MeshMetrics.cs b/KSPCommunityFixes/Library/Model/MeshMetrics.cs index 44915bc..bb58bae 100644 --- a/KSPCommunityFixes/Library/Model/MeshMetrics.cs +++ b/KSPCommunityFixes/Library/Model/MeshMetrics.cs @@ -1,163 +1,158 @@ using System; using UnityEngine; -namespace KSPCommunityFixes.Library.Model +namespace KSPCommunityFixes.Library.Model; + +/// +/// Helpers for computing the UV distribution metric of a mesh. +/// +internal static class MeshMetrics { + // minimum edge length, area-threshold floor, and final-metric floor. + const float MinLen = 0.001f; + // minimum interior angle; rejects sliver triangles. + const float MinAngle = 5f * Mathf.Deg2Rad; + // minimum UV-space triangle area; guards the area divide. + const float MinUvArea = 1e-9f; + // if the below-mean area spread is under this, the area threshold collapses + // to MinLen (the mesh's triangles are too uniform for the spread to mean anything). + const float StdDevGate = 0.1f; + // Unity's fallback when the metric can't be measured. + const float Neutral = 1f; + /// - /// Helpers for computing the UV distribution metric of a mesh. + /// Bake the metric for over the whole mesh. /// - /// - /// This mostly follows how this is implemented in the unity editor with a - /// few fixes for things that looked obviously wrong. - /// - internal static class MeshMetrics + public static float Compute(Vector3[] verts, Vector2[] uv, int[][] subMeshTriangles) { - // minimum edge length, area-threshold floor, and final-metric floor. - const float MinLen = 0.001f; - // minimum interior angle; rejects sliver triangles. - const float MinAngle = 5f * Mathf.Deg2Rad; - // minimum UV-space triangle area; guards the area divide. - const float MinUvArea = 1e-9f; - // if the below-mean area spread is under this, the area threshold collapses - // to MinLen (the mesh's triangles are too uniform for the spread to mean anything). - const float StdDevGate = 0.1f; - // Unity's fallback when the metric can't be measure. - const float Neutral = 1f; - - /// - /// Bake the metric for over the whole mesh. - /// - public static float Compute(Vector3[] verts, Vector2[] uv, int[][] subMeshTriangles) - { - if (verts == null || verts.Length == 0 || uv == null || subMeshTriangles == null) - return Neutral; + if (verts == null || verts.Length == 0 || uv == null || subMeshTriangles == null) + return Neutral; - int triCount = 0; - for (int s = 0; s < subMeshTriangles.Length; ++s) - { - int[] t = subMeshTriangles[s]; - if (t != null) - triCount += t.Length / 3; - } - if (triCount == 0) - return Neutral; + int triCount = 0; + for (int s = 0; s < subMeshTriangles.Length; ++s) + { + int[] t = subMeshTriangles[s]; + if (t != null) + triCount += t.Length / 3; + } + if (triCount == 0) + return Neutral; - // One scratch buffer, reused across the two passes: pass 1 fills it with per-triangle 3D - // areas (consumed to derive the threshold), pass 2 overwrites it with the kept ratios. - float[] scratch = new float[triCount]; + // One scratch buffer, reused across the two passes: pass 1 fills it with per-triangle 3D + // areas (consumed to derive the threshold), pass 2 overwrites it with the kept ratios. + float[] scratch = new float[triCount]; - // ---- Pass 1: per-triangle object-space areas -> robust small-triangle area threshold ---- - int ti = 0; - for (int s = 0; s < subMeshTriangles.Length; ++s) - { - int[] t = subMeshTriangles[s]; - if (t == null) - continue; - for (int k = 0; k + 2 < t.Length; k += 3) - scratch[ti++] = TriArea(verts[t[k]], verts[t[k + 1]], verts[t[k + 2]]); - } + // ---- Pass 1: per-triangle object-space areas -> robust small-triangle area threshold ---- + int ti = 0; + for (int s = 0; s < subMeshTriangles.Length; ++s) + { + int[] t = subMeshTriangles[s]; + if (t == null) + continue; + for (int k = 0; k + 2 < t.Length; k += 3) + scratch[ti++] = TriArea(verts[t[k]], verts[t[k + 1]], verts[t[k + 2]]); + } - Array.Sort(scratch, 0, triCount); // ascending - int keep = triCount - triCount / 10; // robust stats over the smallest 90% of triangles + Array.Sort(scratch, 0, triCount); + int keep = triCount - triCount / 10; // robust stats over the smallest 90% of triangles - double areaSum = 0; - for (int i = 0; i < keep; ++i) - areaSum += scratch[i]; - float areaMean = (float)(areaSum / keep); + double areaSum = 0; + for (int i = 0; i < keep; ++i) + areaSum += scratch[i]; + float areaMean = (float)(areaSum / keep); - // One-sided (below-mean) standard deviation of the kept areas. - double belowSq = 0; - int below = 0; - for (int i = 0; i < keep; ++i) + // One-sided (below-mean) standard deviation of the kept areas. + double belowSq = 0; + int below = 0; + for (int i = 0; i < keep; ++i) + { + float d = scratch[i] - areaMean; + if (d < 0f) { - float d = scratch[i] - areaMean; - if (d < 0f) - { - belowSq += (double)d * d; - below++; - } + belowSq += (double)d * d; + below++; } - float areaStdBelow = below > 0 ? (float)Math.Sqrt(belowSq / below) : 0f; + } + float areaStdBelow = below > 0 ? (float)Math.Sqrt(belowSq / below) : 0f; - float areaThreshold = areaMean - areaStdBelow; - if (areaThreshold <= MinLen || areaStdBelow < StdDevGate) - areaThreshold = MinLen; + float areaThreshold = areaMean - areaStdBelow; + if (areaThreshold <= MinLen || areaStdBelow < StdDevGate) + areaThreshold = MinLen; - // ---- Pass 2: object-area / UV-area ratio for every valid triangle ---- - int count = 0; - for (int s = 0; s < subMeshTriangles.Length; ++s) + // ---- Pass 2: object-area / UV-area ratio for every valid triangle ---- + int count = 0; + for (int s = 0; s < subMeshTriangles.Length; ++s) + { + int[] t = subMeshTriangles[s]; + if (t == null) + continue; + for (int k = 0; k + 2 < t.Length; k += 3) { - int[] t = subMeshTriangles[s]; - if (t == null) - continue; - for (int k = 0; k + 2 < t.Length; k += 3) - { - int i0 = t[k], i1 = t[k + 1], i2 = t[k + 2]; - float ratio = EvalTriangle( - verts[i0], verts[i1], verts[i2], uv[i0], uv[i1], uv[i2], areaThreshold); - if (ratio > MinLen) - scratch[count++] = ratio; - } + int i0 = t[k], i1 = t[k + 1], i2 = t[k + 2]; + float ratio = EvalTriangle( + verts[i0], verts[i1], verts[i2], uv[i0], uv[i1], uv[i2], areaThreshold); + if (ratio > MinLen) + scratch[count++] = ratio; } - if (count == 0) - return Neutral; - - // ---- metric = mean(ratios) + one-sided (above-mean) standard deviation ---- - double ratioSum = 0; - for (int i = 0; i < count; ++i) - ratioSum += scratch[i]; - float ratioMean = (float)(ratioSum / count); - - double aboveSq = 0; - int above = 0; - for (int i = 0; i < count; ++i) + } + if (count == 0) + return Neutral; + + // ---- metric = mean(ratios) + one-sided (above-mean) standard deviation ---- + double ratioSum = 0; + for (int i = 0; i < count; ++i) + ratioSum += scratch[i]; + float ratioMean = (float)(ratioSum / count); + + double aboveSq = 0; + int above = 0; + for (int i = 0; i < count; ++i) + { + float d = scratch[i] - ratioMean; + if (d > 0f) { - float d = scratch[i] - ratioMean; - if (d > 0f) - { - aboveSq += (double)d * d; - above++; - } + aboveSq += (double)d * d; + above++; } - float ratioStdAbove = above > 0 ? (float)Math.Sqrt(aboveSq / above) : 0f; - - float metric = ratioMean + ratioStdAbove; - return metric <= MinLen ? Neutral : metric; } + float ratioStdAbove = above > 0 ? (float)Math.Sqrt(aboveSq / above) : 0f; - /// Object-space area of a triangle (half the cross-product magnitude of two edges). - static float TriArea(Vector3 a, Vector3 b, Vector3 c) - => 0.5f * Vector3.Cross(b - a, c - a).magnitude; - - /// - /// Object-area / UV-area for one triangle, or 0 if it's degenerate, a sliver, too small - /// (below ), or has a near-zero UV footprint. - /// - static float EvalTriangle( - Vector3 v0, Vector3 v1, Vector3 v2, - Vector2 t0, Vector2 t1, Vector2 t2, float areaThreshold) - { - float a = (v1 - v0).magnitude; // edge v0->v1 - float b = (v2 - v0).magnitude; // edge v0->v2 - float c = (v1 - v2).magnitude; // edge v2->v1 - if (a < MinLen || b < MinLen || c < MinLen) - return 0f; - - // Every interior angle must be >= 5 degrees (law of cosines). The cosine is clamped into - // [-1, 1] before acos so float error at a near-degenerate corner can't produce a NaN. - float a2 = a * a, b2 = b * b, c2 = c * c; - if (Mathf.Acos(Mathf.Clamp((a2 + b2 - c2) / (2f * a * b), -1f, 1f)) < MinAngle) return 0f; - if (Mathf.Acos(Mathf.Clamp((c2 + b2 - a2) / (2f * b * c), -1f, 1f)) < MinAngle) return 0f; - if (Mathf.Acos(Mathf.Clamp((c2 + a2 - b2) / (2f * c * a), -1f, 1f)) < MinAngle) return 0f; - - float area3D = 0.5f * Vector3.Cross(v2 - v0, v1 - v0).magnitude; - // UV-space triangle area: half the absolute 2D cross product of the UV edges. - float areaUV = 0.5f * Mathf.Abs( - (t2.y - t0.y) * (t1.x - t0.x) - (t1.y - t0.y) * (t2.x - t0.x)); - - if (area3D < areaThreshold || areaUV < MinUvArea) - return 0f; - return area3D / areaUV; - } + float metric = ratioMean + ratioStdAbove; + return metric <= MinLen ? Neutral : metric; + } + + /// Object-space area of a triangle (half the cross-product magnitude of two edges). + static float TriArea(Vector3 a, Vector3 b, Vector3 c) + => 0.5f * Vector3.Cross(b - a, c - a).magnitude; + + /// + /// Object-area / UV-area for one triangle, or 0 if it's degenerate, a sliver, too small + /// (below ), or has a near-zero UV footprint. + /// + static float EvalTriangle( + Vector3 v0, Vector3 v1, Vector3 v2, + Vector2 t0, Vector2 t1, Vector2 t2, float areaThreshold) + { + float a = (v1 - v0).magnitude; + float b = (v2 - v0).magnitude; + float c = (v1 - v2).magnitude; + if (a < MinLen || b < MinLen || c < MinLen) + return 0f; + + // Every interior angle must be >= 5 degrees (law of cosines). The cosine is clamped into + // [-1, 1] before acos so float error at a near-degenerate corner can't produce a NaN. + float a2 = a * a, b2 = b * b, c2 = c * c; + if (Mathf.Acos(Mathf.Clamp((a2 + b2 - c2) / (2f * a * b), -1f, 1f)) < MinAngle) return 0f; + if (Mathf.Acos(Mathf.Clamp((c2 + b2 - a2) / (2f * b * c), -1f, 1f)) < MinAngle) return 0f; + if (Mathf.Acos(Mathf.Clamp((c2 + a2 - b2) / (2f * c * a), -1f, 1f)) < MinAngle) return 0f; + + float area3D = 0.5f * Vector3.Cross(v2 - v0, v1 - v0).magnitude; + // UV-space triangle area: half the absolute 2D cross product of the UV edges. + float areaUV = 0.5f * Mathf.Abs( + (t2.y - t0.y) * (t1.x - t0.x) - (t1.y - t0.y) * (t2.x - t0.x)); + + if (area3D < areaThreshold || areaUV < MinUvArea) + return 0f; + return area3D / areaUV; } } diff --git a/KSPCommunityFixes/Library/Model/ModelGroup.cs b/KSPCommunityFixes/Library/Model/ModelGroup.cs index 54a35bd..00ffd7c 100644 --- a/KSPCommunityFixes/Library/Model/ModelGroup.cs +++ b/KSPCommunityFixes/Library/Model/ModelGroup.cs @@ -5,18 +5,12 @@ namespace KSPCommunityFixes.Library.Model; /// -/// A contiguous, ordered span of model load requests whose static meshes are baked into ONE mesh -/// AssetBundle. The background compile task folds the ordered compile results into count-capped -/// groups (so no single becomes huge and native bundle -/// copies drain often), then the main-thread pump loads each group's bundle and forwards its requests -/// to the driver. Every request in (compiled/skinned/dae/failed) rides the span -/// in modelAssets order so registration preserves stock load order. +/// A contiguous, ordered span of model load requests whose static meshes are baked into +/// a single asset bundle. /// internal sealed class ModelGroup { - /// The baked mesh-bundle bytes (Phase-2 output). Null when the group contains no static - /// meshes at all. The main-thread pump nulls this the moment it kicks off - /// so the managed copy can be collected. + /// The baked mesh-bundle bytes, or null when the group contains no static meshes. public byte[] BundleBytes; public Task> Index; diff --git a/KSPCommunityFixes/Library/Model/ModelInstructions.cs b/KSPCommunityFixes/Library/Model/ModelInstructions.cs index 5688285..ae098e1 100644 --- a/KSPCommunityFixes/Library/Model/ModelInstructions.cs +++ b/KSPCommunityFixes/Library/Model/ModelInstructions.cs @@ -7,12 +7,10 @@ namespace KSPCommunityFixes.Library.Model { /// - /// One atomic, main-thread GameObject-assembly step of a . The background - /// compiler bakes all the data (positions, enum codes, property values, resolved texture urls, ...) - /// into the instruction's fields; only does the UnityEngine calls, and - /// reproduces the matching reader's object-building - /// tail exactly. Slots are indices into the driver-owned locals array; -1 means "none". - /// No file reading happens here. + /// One atomic GameObject-assembly step of a . The background compiler bakes + /// all the data (positions, enum codes, property values, resolved texture urls, ...) into the + /// instruction's fields; performs the UnityEngine calls. Slots are indices + /// into the driver-owned locals array; -1 means "none". /// internal interface IModelInstruction { @@ -21,8 +19,8 @@ internal interface IModelInstruction // ---- Hierarchy ------------------------------------------------------------------------------ - /// Reproduces MuParser.ReadChild's object-building head: create the GameObject, - /// parent it, then set localPosition, localRotation, localScale IN THAT ORDER. + /// Creates a GameObject, parents it under another, and applies its local position, rotation + /// and scale. internal sealed class CreateGameObject : IModelInstruction { public int Dst; @@ -35,7 +33,6 @@ internal sealed class CreateGameObject : IModelInstruction public void Execute(UnityEngine.Object[] locals) { GameObject go = new GameObject(Name); - // Parity: MuParser assigns parent, then localPosition, localRotation, localScale in this order. go.transform.parent = Parent < 0 ? null : ((GameObject)locals[Parent]).transform; go.transform.localPosition = Pos; go.transform.localRotation = Rot; @@ -44,7 +41,7 @@ public void Execute(UnityEngine.Object[] locals) } } - /// Reproduces MuParser.ReadTagAndLayer. + /// Sets a GameObject's tag and layer. internal sealed class SetTagAndLayer : IModelInstruction { public int Go; @@ -54,8 +51,7 @@ internal sealed class SetTagAndLayer : IModelInstruction public void Execute(UnityEngine.Object[] locals) { GameObject go = (GameObject)locals[Go]; - // Parity: MuParser sets the tag unguarded. Assigning an unregistered tag throws - // UnityException; we reproduce that behaviour rather than swallowing it. + // Assigning an unregistered tag throws UnityException; left unguarded deliberately. go.tag = Tag; go.layer = Layer; } @@ -63,7 +59,7 @@ public void Execute(UnityEngine.Object[] locals) // ---- Mesh / renderers ----------------------------------------------------------------------- - /// Reproduces MuParser.ReadMeshFilter. + /// Adds a MeshFilter to a GameObject and assigns its shared mesh. internal sealed class AddMeshFilter : IModelInstruction { public int Go; @@ -73,8 +69,8 @@ public void Execute(UnityEngine.Object[] locals) => ((GameObject)locals[Go]).AddComponent().sharedMesh = (Mesh)locals[MeshSlot]; } - /// Reproduces MuParser.ReadMeshRenderer. The shadow flags are only present for - /// version >= 1 (baked as ). + /// Adds a MeshRenderer to a GameObject. Shadow flags are present only in newer .mu versions + /// (baked as ). internal sealed class AddMeshRenderer : IModelInstruction { public int Go; @@ -88,8 +84,6 @@ public void Execute(UnityEngine.Object[] locals) MeshRenderer mr = ((GameObject)locals[Go]).AddComponent(); if (HasShadowFlags) { - // Parity: MuParser maps the single "cast shadows" bool to ShadowCastingMode.On/Off only - // (never TwoSided/ShadowsOnly). mr.shadowCastingMode = CastShadows ? ShadowCastingMode.On : ShadowCastingMode.Off; mr.receiveShadows = ReceiveShadows; } @@ -97,8 +91,8 @@ public void Execute(UnityEngine.Object[] locals) } } - /// Reproduces MuParser.ReadSkinnedMeshRenderer's object-building (bone resolution is - /// a separate step). Deferred/not emitted in v1, but faithful. + /// Adds a SkinnedMeshRenderer to a GameObject (bone resolution is a separate + /// step). internal sealed class AddSkinnedMeshRenderer : IModelInstruction { public int Go; @@ -121,11 +115,8 @@ public void Execute(UnityEngine.Object[] locals) // ---- Materials / textures ------------------------------------------------------------------- - /// Reproduces the object-building of MuParser.ReadMaterial (v<4) / - /// ReadMaterial4 (v>=4) plus the deferred texture assignment from ReadTextures, - /// folded into a single baked step. Value/texture property names are baked as strings by the - /// compiler (the v<4 path's int property IDs are just Shader.PropertyToID(name), so the - /// string setters are equivalent). + /// Creates a Material: resolves its shader, applies its scalar/color/vector properties, and + /// binds its textures. Property names are keyed by string. internal sealed class CreateMaterial : IModelInstruction { public int Dst; @@ -136,10 +127,8 @@ internal sealed class CreateMaterial : IModelInstruction public void Execute(UnityEngine.Object[] locals) { - // Parity/RISK: Shader may resolve to null (unknown v>=4 shader name). MuParser does NOT - // substitute a default there — new Material((Shader)null) is the intended behaviour — so we - // must not coalesce to a fallback shader here. (The v<4 by-type route always resolves to a - // concrete KSP shader.) See ShaderRef.Resolve. + // Shader may resolve to null for an unknown shader name; new Material((Shader)null) is + // intentional, so do not coalesce to a fallback shader here. See ShaderRef. Material mat = new Material(Shader.Resolve()); mat.name = Name; @@ -157,7 +146,7 @@ public void Execute(UnityEngine.Object[] locals) case ValueProp.KindVector: mat.SetVector(v.Name, v.VecVal); break; - default: // KindFloat (covers MuParser's material property type codes 2 and 3) + default: // KindFloat mat.SetFloat(v.Name, v.FloatVal); break; } @@ -170,14 +159,10 @@ public void Execute(UnityEngine.Object[] locals) for (int i = 0; i < textures.Length; i++) { TextureProp t = textures[i]; - // Parity: MuParser always sets scale/offset (in ReadMaterialTexture); the texture - // itself is resolved later in ReadTextures via GameDatabase.GetTexture(url, isNormal). mat.SetTextureScale(t.Name, t.Scale); mat.SetTextureOffset(t.Name, t.Offset); if (t.Url != null) { - // Parity: reproduce MuParser.ReadTextures' skip-and-log on a missing texture — if the - // resolved texture IsNullOrDestroyed, log the error and do NOT call SetTexture. Texture2D tex = GameDatabase.Instance.GetTexture(t.Url, t.IsNormalMap); if (tex.IsNullOrDestroyed()) Debug.LogError($"Texture '{t.Url}' not found!"); @@ -191,8 +176,8 @@ public void Execute(UnityEngine.Object[] locals) } } - /// Reproduces the sharedMaterial/emitter-material fan-out of MuParser.ReadMaterials - /// (and the emitter registration in ReadParticles) for a single material index. + /// Assigns one material to the renderers and particle emitters that reference its material + /// index. internal sealed class AssignMaterial : IModelInstruction { public int MaterialSlot; @@ -202,10 +187,9 @@ internal sealed class AssignMaterial : IModelInstruction public void Execute(UnityEngine.Object[] locals) { Material mat = (Material)locals[MaterialSlot]; - // Parity: MuParser uses the SINGULAR Renderer.sharedMaterial (not sharedMaterials[]), setting - // it once per material index. For a multi-material renderer the LAST material index that - // lists it wins; the compiler emits these instructions in material-index order to preserve - // that "last wins" outcome. + // Uses the SINGULAR Renderer.sharedMaterial (not sharedMaterials[]), set once per material + // index. For a multi-material renderer the LAST material index that lists it wins; the compiler + // emits these instructions in material-index order to preserve that "last wins" outcome. int[] renderers = RendererSlots; if (renderers != null) for (int i = 0; i < renderers.Length; i++) @@ -220,8 +204,8 @@ public void Execute(UnityEngine.Object[] locals) // ---- Colliders ------------------------------------------------------------------------------ - /// Reproduces MuParser.ReadMeshCollider (case 3) / ReadMeshCollider2 (case 25). - /// The isTrigger flag exists only in the "2" variant (baked as ). + /// Adds a convex MeshCollider to a GameObject. The isTrigger flag exists only in the newer + /// variant (baked as ). internal sealed class AddMeshCollider : IModelInstruction { public int Go; @@ -232,14 +216,14 @@ internal sealed class AddMeshCollider : IModelInstruction public void Execute(UnityEngine.Object[] locals) { MeshCollider mc = ((GameObject)locals[Go]).AddComponent(); - mc.convex = true; // Parity: MuParser always forces convex (the file's "convex" bool is ignored). + mc.convex = true; // Convex is forced on; the file's "convex" bool is ignored. if (HasTrigger) mc.isTrigger = IsTrigger; mc.sharedMesh = (Mesh)locals[MeshSlot]; } } - /// Reproduces MuParser.ReadSphereCollider (case 4) / ReadSphereCollider2 (case 26). + /// Adds a SphereCollider to a GameObject. internal sealed class AddSphereCollider : IModelInstruction { public int Go; @@ -258,8 +242,8 @@ public void Execute(UnityEngine.Object[] locals) } } - /// Reproduces MuParser.ReadCapsuleCollider (case 5) / ReadCapsuleCollider2 - /// (case 27). Height exists only in the "2" variant (baked as ). + /// Adds a CapsuleCollider to a GameObject. Height exists only in the newer variant (baked as + /// ). internal sealed class AddCapsuleCollider : IModelInstruction { public int Go; @@ -279,13 +263,13 @@ public void Execute(UnityEngine.Object[] locals) cc.radius = Radius; if (HasHeight) cc.height = Height; - // Parity: Direction is the raw axis index Unity uses (0 = X, 1 = Y, 2 = Z), copied verbatim. + // Direction is the raw Unity axis index (0 = X, 1 = Y, 2 = Z). cc.direction = Direction; cc.center = Center; } } - /// Reproduces MuParser.ReadBoxCollider (case 6) / ReadBoxCollider2 (case 28). + /// Adds a BoxCollider to a GameObject. internal sealed class AddBoxCollider : IModelInstruction { public int Go; @@ -304,8 +288,8 @@ public void Execute(UnityEngine.Object[] locals) } } - /// Reproduces MuParser.ReadWheelCollider (case 29), including the JointSpring and the - /// two WheelFrictionCurves, and the final enabled = false. + /// Adds a WheelCollider to a GameObject, with its suspension spring and forward/sideways + /// friction curves. internal sealed class AddWheelCollider : IModelInstruction { public int Go; @@ -316,8 +300,8 @@ internal sealed class AddWheelCollider : IModelInstruction public float SpringSpring; public float SpringDamper; public float SpringTarget; - // Parity: friction curve fields in MuParser's read order: - // [0]=extremumSlip [1]=extremumValue [2]=asymptoteSlip [3]=asymptoteValue [4]=stiffness. + // Friction curve fields: [0]=extremumSlip [1]=extremumValue [2]=asymptoteSlip + // [3]=asymptoteValue [4]=stiffness. public float[] Forward; public float[] Sideways; @@ -350,14 +334,14 @@ public void Execute(UnityEngine.Object[] locals) asymptoteValue = Sideways[3], stiffness = Sideways[4] }; - wc.enabled = false; // Parity: MuParser leaves wheel colliders disabled. + wc.enabled = false; // Created disabled, matching the stock loader. } } // ---- Light / camera ------------------------------------------------------------------------- - /// Reproduces MuParser.ReadLight (case 23). The spot angle exists only for - /// version > 1 (baked as ). + /// Adds a Light to a GameObject. The spot angle exists only in newer .mu versions (baked as + /// ). internal sealed class AddLight : IModelInstruction { public int Go; @@ -382,8 +366,7 @@ public void Execute(UnityEngine.Object[] locals) } } - /// Reproduces MuParser.ReadCamera (case 30), including the final - /// allowHDR = false; enabled = false. + /// Adds a disabled, non-HDR Camera to a GameObject. internal sealed class AddCamera : IModelInstruction { public int Go; @@ -407,16 +390,15 @@ public void Execute(UnityEngine.Object[] locals) camera.nearClipPlane = NearClip; camera.farClipPlane = FarClip; camera.depth = Depth; - camera.allowHDR = false; // Parity: MuParser force-disables HDR and the camera itself. + camera.allowHDR = false; camera.enabled = false; } } // ---- Particles ------------------------------------------------------------------------------ - /// Reproduces MuParser.ReadParticles (case 31): copies every KSPParticleEmitter field - /// and maps the raw render-mode code. Material assignment is a separate - /// step (the emitter is exposed via ). + /// Adds a KSPParticleEmitter to a GameObject and copies its fields. Material assignment is a + /// separate step (the emitter is exposed via ). internal sealed class AddParticleEmitter : IModelInstruction { public int Go; @@ -429,7 +411,6 @@ public void Execute(UnityEngine.Object[] locals) ParticleEmitterData d = Data; e.emit = d.Emit; e.shape = (KSPParticleEmitter.EmissionShape)d.Shape; - // MuParser sets shape3D/shape2D component-wise; assigning the whole vector is equivalent. e.shape3D = d.Shape3D; e.shape2D = d.Shape2D; e.shape1D = d.Shape1D; @@ -449,7 +430,7 @@ public void Execute(UnityEngine.Object[] locals) e.rndAngularVelocity = d.RndAngularVelocity; e.rndRotation = d.RndRotation; e.doesAnimateColor = d.DoesAnimateColor; - // Parity: MuParser assigns a fresh Color[5]; copy so the emitter never aliases baked data. + // Copy into a fresh Color[5] so the emitter never aliases the baked data. Color[] colorAnimation = new Color[5]; Color[] src = d.ColorAnimation; for (int i = 0; i < 5; i++) @@ -466,7 +447,6 @@ public void Execute(UnityEngine.Object[] locals) e.lengthScale = d.LengthScale; e.velocityScale = d.VelocityScale; e.maxParticleSize = d.MaxParticleSize; - // Parity: MuParser's render-mode switch — default => Billboard; 3/4/5 => the explicit modes. switch (d.RenderModeCode) { default: @@ -491,9 +471,8 @@ public void Execute(UnityEngine.Object[] locals) // ---- Animation ------------------------------------------------------------------------------ - /// Reproduces MuParser.ReadAnimation (case 2): rebuilds legacy Animation / - /// AnimationClip / AnimationCurve / Keyframe objects and replays the exact - /// isInvalid / null-skip logic and curve-type mapping. + /// Adds a legacy Animation component and rebuilds its clips, curves and + /// keyframes. internal sealed class AddAnimation : IModelInstruction { public int Go; @@ -505,8 +484,8 @@ public void Execute(UnityEngine.Object[] locals) { Animation animation = ((GameObject)locals[Go]).AddComponent(); - // Parity: isInvalid is declared ONCE outside the clip loop, so a single invalid curve poisons - // every later clip (AddClip is skipped) and the default clip. This is faithful to MuParser, + // isInvalid is declared ONCE outside the clip loop, so a single invalid curve poisons every + // later clip (AddClip is skipped) and the default clip. This matches the stock loader; it is // not a bug to "fix". bool isInvalid = false; @@ -523,8 +502,8 @@ public void Execute(UnityEngine.Object[] locals) for (int j = 0; j < curves.Length; j++) { AnimationCurveData curve = curves[j]; - // Parity: curve type code 0-3 => Transform/Material/Light/AudioSource; anything else - // leaves curveType null, which trips the isInvalid guard below. + // Type code 0-3 => Transform/Material/Light/AudioSource; anything else leaves curveType + // null, which trips the isInvalid guard below. Type curveType = null; switch (curve.TypeCode) { @@ -565,9 +544,8 @@ public void Execute(UnityEngine.Object[] locals) animation.AddClip(animationClip, clip.Name); } - // Parity contract: the compiler must bake DefaultClip as string.Empty (never null) when absent, - // because MuParser derives it from ReadString(), which returns string.Empty for a zero-length - // string — a baked null would wrongly pass this guard. + // The compiler must bake DefaultClip as string.Empty (never null) when absent; a baked null + // would wrongly pass this guard. if (DefaultClip != string.Empty && !isInvalid) animation.clip = animation.GetClip(DefaultClip); @@ -577,9 +555,8 @@ public void Execute(UnityEngine.Object[] locals) // ---- Bones ---------------------------------------------------------------------------------- - /// Reproduces MuParser.AffectSkinnedMeshRenderersBones for one skinned mesh renderer: - /// resolves each bone by name from the model root and assigns the bone array. Deferred/not emitted in - /// v1, but faithful. + /// Resolves a skinned mesh renderer's bones by name from the model root and assigns the bone + /// array. internal sealed class ResolveBones : IModelInstruction { public int SmrSlot; @@ -595,8 +572,7 @@ public void Execute(UnityEngine.Object[] locals) ((SkinnedMeshRenderer)locals[SmrSlot]).bones = bones; } - // Ported verbatim from MuParser.FindChildByName: depth-first search returning the first transform - // whose name matches (including the root itself). + // Depth-first search returning the first transform whose name matches, including the root itself. private static Transform FindChildByName(Transform parent, string name) { if (parent.name == name) @@ -614,16 +590,13 @@ private static Transform FindChildByName(Transform parent, string name) // ---- Payload structs ------------------------------------------------------------------------ - /// How a KSP shader is resolved at replay. The v<4 path resolves a - /// by type; the v>=4 path resolves a raw shader name. + /// How a KSP shader is resolved at replay: either by or + /// by raw shader name. /// - /// is byte-for-byte equivalent to MuParser's - /// Shader.Find(name) — it caches the same instances and, critically, returns null for - /// an unknown name (it never substitutes a default). So resolving the name route through - /// preserves MuParser's v>=4 null-shader fallback - /// (new Material((Shader)null)). The type route uses - /// , whose default maps to KSP/Diffuse, which - /// is exactly what MuParser's v<4 path does. + /// returns null for an unknown name (it never + /// substitutes a default), so an unrecognized shader name yields new Material((Shader)null) + /// rather than a fallback shader. The type route uses , + /// whose default maps to KSP/Diffuse. /// internal struct ShaderRef { @@ -634,8 +607,8 @@ internal struct ShaderRef public Shader Resolve() => ByName ? ShaderHelpers.GetShader(Name) : ShaderHelpers.GetShader(Type); } - /// One scalar/color/vector material property. : 0 Color, 1 Vector, 2 Float - /// (MuParser material property type codes 2 and 3 both map to Float). + /// One scalar/color/vector material property. : 0 Color, 1 Vector, + /// 2 Float. internal struct ValueProp { public const int KindColor = 0; @@ -660,9 +633,9 @@ internal struct TextureProp public Vector2 Offset; } - /// Every field MuParser.ReadParticles reads for a KSPParticleEmitter. - /// is the raw int mapped by ; - /// is a 5-element array. + /// The KSPParticleEmitter fields carried to . + /// is the raw int it maps; is a 5-element + /// array. internal struct ParticleEmitterData { public bool Emit; @@ -704,8 +677,7 @@ internal struct ParticleEmitterData public int UvAnimationCycles; } - /// One legacy AnimationClip: localBounds = new Bounds(BoundsCenter, BoundsSize), - /// wrapMode = (WrapMode)WrapMode, plus its curves. + /// One legacy AnimationClip: its bounds, wrap mode and curves. internal struct AnimationClipData { public string Name; @@ -716,7 +688,7 @@ internal struct AnimationClipData } /// One animation curve. : 0 Transform, 1 Material, 2 Light, - /// 3 AudioSource (anything else marks the animation invalid, matching MuParser). + /// 3 AudioSource (anything else marks the animation invalid). internal struct AnimationCurveData { public string Path; @@ -727,7 +699,7 @@ internal struct AnimationCurveData public KeyframeData[] Keys; } - /// One keyframe (four floats, matching MuParser's ReadKeyFrame; weights left default). + /// One keyframe (four floats; weights left default). internal struct KeyframeData { public float Time; diff --git a/KSPCommunityFixes/Library/Model/ModelLoadRequest.cs b/KSPCommunityFixes/Library/Model/ModelLoadRequest.cs index 4a7dbc3..6eb039e 100644 --- a/KSPCommunityFixes/Library/Model/ModelLoadRequest.cs +++ b/KSPCommunityFixes/Library/Model/ModelLoadRequest.cs @@ -1,50 +1,46 @@ using UnityEngine; -namespace KSPCommunityFixes.Library.Model +namespace KSPCommunityFixes.Library.Model; + +/// +/// The per-file work item and result carrier for the background model pipeline. +/// +internal sealed class ModelLoadRequest { - /// - /// The per-file work item + result carrier for the background model pipeline, the model analogue of - /// FastLoader's TextureLoadRequest. One is produced for every entry in modelAssets and - /// flows compile task -> pump -> driver in modelAssets order. selects - /// how the main-thread loader builds . - /// - internal sealed class ModelLoadRequest + public enum State : byte { Pending, Ready, Failed } + + public enum Kind : byte { - /// Matches TextureLoadRequest.State: the driver's strict-FIFO drain peeks the head - /// and waits while it is , never reordering. - public enum State : byte { Pending, Ready, Failed } - - /// How the main-thread loader builds the model: - /// - /// : replay the compiled instructions against meshes loaded from the - /// group's bundle. - /// : reload via the stock DAE loader. - /// : file read failed or compilation failed; hard failure. - /// - public enum Kind : byte { CompiledMu, Dae, Failed } - - public UrlDir.UrlFile File; - public Kind ModelKind; - - /// Carried for CompiledMu/Skinned/Failed (any request produced by the compiler): supplies - /// Instructions/Bindings/LocalCount for replay and, always, the buffered Logs that - /// InsertReadyModel flushes on the main thread. Blobs is nulled once its group's - /// bundle is built. Null for Dae and file-read failures. - public CompiledModel Compiled; - - /// CompiledMu only: back-reference to the owning group for the bundle-load await and the - /// Unload ref-count. - public ModelGroup Group; - - public string FailureMessage; - - /// Byte count added to KSPCFFastLoaderReport.modelsBytesLoaded on success. - public long FileLength; - - /// Pending -> Ready/Failed. Volatile like TextureLoadRequest.Status (the loader - /// coroutine writes it, the driver polls it). - public volatile State Status; - - public GameObject Result; + /// + /// This request contains a set of instructions that need to be executed + /// in order to build the model. + /// + CompiledMu, + /// + /// This request contains a DAE model. + /// + Dae, + /// + /// An error occurred when loading the model for this request. + /// + Failed } + + public UrlDir.UrlFile File; + public Kind ModelKind; + + /// The compiled model, if any. + public CompiledModel Compiled; + + /// The owning , if any. + public ModelGroup Group; + + public string FailureMessage; + + /// Byte length of the model file. + public long FileLength; + + public volatile State Status; + + public GameObject Result; } diff --git a/KSPCommunityFixes/Library/Model/MuBinaryReader.cs b/KSPCommunityFixes/Library/Model/MuBinaryReader.cs index 79c3f61..f37cb67 100644 --- a/KSPCommunityFixes/Library/Model/MuBinaryReader.cs +++ b/KSPCommunityFixes/Library/Model/MuBinaryReader.cs @@ -3,345 +3,295 @@ using System.Text; using UnityEngine; -namespace KSPCommunityFixes.Library.Model +namespace KSPCommunityFixes.Library.Model; + +/// +/// Binary reader for .mu files. +/// +internal unsafe struct MuBinaryReader { + private static readonly UTF8Encoding utf8 = new(); + + /// Base pointer to the caller-pinned data buffer. + public readonly byte* Ptr; + + /// Valid length of the data buffer, in bytes. + public readonly int Length; + + /// Current read cursor, in bytes. + public int Position; + + // Per-instance UTF8 decode scratch buffer, lazily allocated and grown by ReadString. Being + // per-instance rather than static is what makes ReadString thread-safe. + private char[] charBuffer; + /// - /// Thread-safe, instance-state extraction of MuParser's low-level binary reading primitives. - /// - /// The stock MuParser keeps its cursor and scratch buffers in static mutable fields, which - /// makes it usable from only one thread at a time. This struct owns all of that state per-instance - /// (a base pointer, a length, a read cursor and a lazily grown decode buffer) so many worker - /// threads can parse different .mu files in parallel. The byte layout, cursor advancement, - /// bounds checks and string decoding are a faithful, byte-for-byte reproduction of the originals. - /// - /// - /// The caller is responsible for pinning the input byte[] (via a - /// or a fixed statement) and passing the resulting pointer plus valid length. The struct - /// stores the pointer and never pins on its own, so the buffer must stay pinned for the reader's - /// whole lifetime. Only plain value types (, , - /// , , , ...) are produced; - /// no UnityEngine.Object is ever created, so every method here is safe off the main thread. - /// - /// - /// IMPORTANT — this is a MUTABLE struct. It carries a live read cursor () and - /// a lazily-allocated decode buffer, so it must be treated like : - /// hold it in exactly one local variable or one non-readonly field and mutate that single - /// instance. Do NOT pass it by value to a helper, store it in a readonly field (which forces a - /// defensive copy on every access), capture it in a lambda, box it, or iterate it in a foreach — - /// each of those silently makes a copy whose cursor advances independently, desyncing the read - /// position with no compiler error. Any helper that must advance the reader has to take it by - /// ref MuBinaryReader. It stays a struct (rather than a class) deliberately, for the same - /// allocation/performance reasons as Utf8JsonReader; do not "fix" the mutable-struct footgun - /// by turning it into a class. - /// + /// Create a reader over an already-pinned buffer. Pinning is the caller's responsibility; this + /// struct only stores the pointer and never pins. /// - internal unsafe struct MuBinaryReader + /// Base pointer to the pinned data buffer. + /// Valid length of the buffer in bytes. + public MuBinaryReader(byte* ptr, int length) { - // MuParser named this field "decoder", but it is a UTF8Encoding (an Encoding), not a - // System.Text.Decoder. That distinction matters for threading: a System.Text.Decoder carries - // per-instance state across calls and is NOT thread-safe, whereas Encoding.GetChars is - // stateless and documented as safe to call from multiple threads on a shared instance. So we - // keep a single shared static readonly UTF8Encoding and decode straight through it, exactly as - // the original does, with no per-thread hazard. - private static readonly UTF8Encoding utf8 = new UTF8Encoding(); - - /// Base pointer to the caller-pinned data buffer. - public readonly byte* Ptr; - - /// Valid length of the data buffer, in bytes (the original dataLength). - public readonly int Length; - - /// Current read cursor, in bytes (replaces the original static index). - public int Position; - - // Instance-owned UTF8 decode scratch buffer, lazily allocated and grown by ReadString - // (replaces the original static charBuffer). Being per-instance is what makes ReadString - // thread-safe. - private char[] charBuffer; - - /// - /// Create a reader over an already-pinned buffer. Pinning is the caller's responsibility; this - /// struct only stores the pointer and never pins. - /// - /// Base pointer to the pinned data buffer. - /// Valid length of the buffer in bytes. - public MuBinaryReader(byte* ptr, int length) - { - Ptr = ptr; - Length = length; - Position = 0; - charBuffer = null; - } + Ptr = ptr; + Length = length; + Position = 0; + charBuffer = null; + } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public int Advance(int bytes) - { - int currentIndex = Position; - int nextIndex = currentIndex + bytes; - // The unsigned compare plus the explicit bytes < 0 guard rejects overflow and negative reads - // from corrupt/malicious data (e.g. a huge or negative 7-bit-encoded length that would wrap - // nextIndex negative and slip past a plain "> Length" check), while remaining identical to the - // original bounds behavior for every valid file where 0 <= nextIndex <= Length. - if (bytes < 0 || (uint)nextIndex > (uint)Length) - ThrowEndOfDataException(); - - Position = nextIndex; - return currentIndex; - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int Advance(int bytes) + { + int currentIndex = Position; + int nextIndex = currentIndex + bytes; + // The unsigned compare plus the explicit bytes < 0 guard rejects overflow and negative reads + // from corrupt/malicious data (e.g. a huge or negative 7-bit-encoded length that would wrap + // nextIndex negative and slip past a plain "> Length" check). + if (bytes < 0 || (uint)nextIndex > (uint)Length) + ThrowEndOfDataException(); + + Position = nextIndex; + return currentIndex; + } - private static void ThrowEndOfDataException() - { - throw new InvalidOperationException("Unable to read beyond the end of the data"); - } + private static void ThrowEndOfDataException() + { + throw new InvalidOperationException("Unable to read beyond the end of the data"); + } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public byte ReadByte() - { - int valIdx = Advance(1); - return *(Ptr + valIdx); - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public byte ReadByte() + { + int valIdx = Advance(1); + return *(Ptr + valIdx); + } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void SkipInt() - { - Advance(4); - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SkipInt() + { + Advance(4); + } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public int ReadInt() - { - int valIdx = Advance(4); - return *(int*)(Ptr + valIdx); - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int ReadInt() + { + int valIdx = Advance(4); + return *(int*)(Ptr + valIdx); + } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public float ReadFloat() - { - int valIdx = Advance(4); - return *(float*)(Ptr + valIdx); - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public float ReadFloat() + { + int valIdx = Advance(4); + return *(float*)(Ptr + valIdx); + } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void SkipBool() - { - Advance(1); - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SkipBool() + { + Advance(1); + } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool ReadBool() - { - int valIdx = Advance(1); - return *(Ptr + valIdx) != 0; - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool ReadBool() + { + int valIdx = Advance(1); + return *(Ptr + valIdx) != 0; + } - public void SkipString() - { - Advance(Read7BitEncodedInt()); - } + public void SkipString() + { + Advance(Read7BitEncodedInt()); + } - public string ReadString() - { - int strByteLength = Read7BitEncodedInt(); + public string ReadString() + { + int strByteLength = Read7BitEncodedInt(); - if (strByteLength < 0) - throw new Exception("Invalid string length"); + if (strByteLength < 0) + throw new Exception("Invalid string length"); - if (strByteLength == 0) - return string.Empty; + if (strByteLength == 0) + return string.Empty; - ExpandCharBuffer(strByteLength); + ExpandCharBuffer(strByteLength); - // Advance returns the pre-advance cursor, i.e. the start offset of the string bytes (the - // original captured "index" before advancing). We decode straight from the pinned buffer - // via the unsafe Encoding.GetChars overload instead of the byte[] overload the original - // used; the input bytes are identical, so the decoded chars are byte-for-byte the same. - int start = Advance(strByteLength); - int charCount; - fixed (char* charPtr = charBuffer) - charCount = utf8.GetChars(Ptr + start, strByteLength, charPtr, charBuffer.Length); + int start = Advance(strByteLength); + int charCount; + fixed (char* charPtr = charBuffer) + charCount = utf8.GetChars(Ptr + start, strByteLength, charPtr, charBuffer.Length); - return new string(charBuffer, 0, charCount); - } + return new string(charBuffer, 0, charCount); + } - private void ExpandCharBuffer(int length) - { - if (charBuffer == null || charBuffer.Length < length) - charBuffer = new char[(int)(length * 1.5)]; - } + private void ExpandCharBuffer(int length) + { + if (charBuffer == null || charBuffer.Length < length) + charBuffer = new char[(int)(length * 1.5)]; + } - // 7-bit-encoded length prefix (the BinaryWriter/BinaryReader string-length format): each byte - // contributes its low 7 bits, and the high bit flags that another byte follows. After 5 bytes - // (shift reaches 35) the value can no longer fit in an Int32, which is an error. - public int Read7BitEncodedInt() + // The BinaryWriter/BinaryReader 7-bit-encoded length-prefix format. + public int Read7BitEncodedInt() + { + int num = 0; + int num2 = 0; + byte b; + do { - int num = 0; - int num2 = 0; - byte b; - do + if (num2 == 35) { - if (num2 == 35) - { - throw new FormatException("Too many bytes in what should have been a 7 bit encoded Int32."); - } - b = ReadByte(); - num |= (b & 0x7F) << num2; - num2 += 7; + throw new FormatException("Too many bytes in what should have been a 7 bit encoded Int32."); } - while ((b & 0x80u) != 0); - return num; + b = ReadByte(); + num |= (b & 0x7F) << num2; + num2 += 7; } + while ((b & 0x80u) != 0); + return num; + } - public Vector2 ReadVector2() - { - int valIdx = Advance(8); - return *(Vector2*)(Ptr + valIdx); - } + public Vector2 ReadVector2() + { + int valIdx = Advance(8); + return *(Vector2*)(Ptr + valIdx); + } - public Vector3 ReadVector3() - { - int valIdx = Advance(12); - return *(Vector3*)(Ptr + valIdx); - } + public Vector3 ReadVector3() + { + int valIdx = Advance(12); + return *(Vector3*)(Ptr + valIdx); + } - public Vector4 ReadVector4() - { - int valIdx = Advance(16); - return *(Vector4*)(Ptr + valIdx); - } + public Vector4 ReadVector4() + { + int valIdx = Advance(16); + return *(Vector4*)(Ptr + valIdx); + } - public Quaternion ReadQuaternion() - { - int valIdx = Advance(16); - return *(Quaternion*)(Ptr + valIdx); - } + public Quaternion ReadQuaternion() + { + int valIdx = Advance(16); + return *(Quaternion*)(Ptr + valIdx); + } - public Color ReadColor() - { - int valIdx = Advance(16); - return *(Color*)(Ptr + valIdx); - } + public Color ReadColor() + { + int valIdx = Advance(16); + return *(Color*)(Ptr + valIdx); + } - public Color32 ReadColor32() - { - int valIdx = Advance(4); - return *(Color32*)(Ptr + valIdx); - } + public Color32 ReadColor32() + { + int valIdx = Advance(4); + return *(Color32*)(Ptr + valIdx); + } - public BoneWeight ReadBoneWeight() + public BoneWeight ReadBoneWeight() + { + int valIdx = Advance(32); + // data isn't packed with the same layout as the struct, so we fallback to setting every field + return new BoneWeight() { - int valIdx = Advance(32); - // data isn't packed with the same layout as the struct, so we fallback to setting every field - return new BoneWeight() - { - boneIndex0 = *(int*)(Ptr + valIdx), - weight0 = *(float*)(Ptr + valIdx + 4), - boneIndex1 = *(int*)(Ptr + valIdx + 8), - weight1 = *(float*)(Ptr + valIdx + 12), - boneIndex2 = *(int*)(Ptr + valIdx + 16), - weight2 = *(float*)(Ptr + valIdx + 20), - boneIndex3 = *(int*)(Ptr + valIdx + 24), - weight3 = *(float*)(Ptr + valIdx + 28) - }; - } + boneIndex0 = *(int*)(Ptr + valIdx), + weight0 = *(float*)(Ptr + valIdx + 4), + boneIndex1 = *(int*)(Ptr + valIdx + 8), + weight1 = *(float*)(Ptr + valIdx + 12), + boneIndex2 = *(int*)(Ptr + valIdx + 16), + weight2 = *(float*)(Ptr + valIdx + 20), + boneIndex3 = *(int*)(Ptr + valIdx + 24), + weight3 = *(float*)(Ptr + valIdx + 28) + }; + } - public Matrix4x4 ReadMatrix4x4() + public Matrix4x4 ReadMatrix4x4() + { + int valIdx = Advance(64); + // data isn't packed with the same layout as the struct, so we fallback to setting every field + return new Matrix4x4() { - int valIdx = Advance(64); - // data isn't packed with the same layout as the struct, so we fallback to setting every field - return new Matrix4x4() - { - m00 = *(float*)(Ptr + valIdx), - m01 = *(float*)(Ptr + valIdx + 4), - m02 = *(float*)(Ptr + valIdx + 8), - m03 = *(float*)(Ptr + valIdx + 12), - m10 = *(float*)(Ptr + valIdx + 16), - m11 = *(float*)(Ptr + valIdx + 20), - m12 = *(float*)(Ptr + valIdx + 24), - m13 = *(float*)(Ptr + valIdx + 28), - m20 = *(float*)(Ptr + valIdx + 32), - m21 = *(float*)(Ptr + valIdx + 36), - m22 = *(float*)(Ptr + valIdx + 40), - m23 = *(float*)(Ptr + valIdx + 44), - m30 = *(float*)(Ptr + valIdx + 48), - m31 = *(float*)(Ptr + valIdx + 52), - m32 = *(float*)(Ptr + valIdx + 56), - m33 = *(float*)(Ptr + valIdx + 60) - }; - } + m00 = *(float*)(Ptr + valIdx), + m01 = *(float*)(Ptr + valIdx + 4), + m02 = *(float*)(Ptr + valIdx + 8), + m03 = *(float*)(Ptr + valIdx + 12), + m10 = *(float*)(Ptr + valIdx + 16), + m11 = *(float*)(Ptr + valIdx + 20), + m12 = *(float*)(Ptr + valIdx + 24), + m13 = *(float*)(Ptr + valIdx + 28), + m20 = *(float*)(Ptr + valIdx + 32), + m21 = *(float*)(Ptr + valIdx + 36), + m22 = *(float*)(Ptr + valIdx + 40), + m23 = *(float*)(Ptr + valIdx + 44), + m30 = *(float*)(Ptr + valIdx + 48), + m31 = *(float*)(Ptr + valIdx + 52), + m32 = *(float*)(Ptr + valIdx + 56), + m33 = *(float*)(Ptr + valIdx + 60) + }; + } - public Keyframe ReadKeyFrame() - { - // this is encoded as 4 floats (16 bytes), but there is 4 bytes of padding at the end - int valIdx = Advance(20); - return new Keyframe( - *(float*)(Ptr + valIdx), - *(float*)(Ptr + valIdx + 4), - *(float*)(Ptr + valIdx + 8), - *(float*)(Ptr + valIdx + 12)); - } + public Keyframe ReadKeyFrame() + { + // this is encoded as 4 floats (16 bytes), but there is 4 bytes of padding at the end + int valIdx = Advance(20); + return new Keyframe( + *(float*)(Ptr + valIdx), + *(float*)(Ptr + valIdx + 4), + *(float*)(Ptr + valIdx + 8), + *(float*)(Ptr + valIdx + 12)); + } - // The Fill* helpers bulk-copy N sequentially packed elements straight from the pinned data - // buffer into a caller-owned destination array. This mirrors MuParser exactly: the mu mesh - // data is laid out as a contiguous run of the element structs, so we Advance past the run and - // memcpy it in one shot rather than reading element by element. Unlike MuParser these do NOT - // own or grow the destination — the caller supplies an array with room for at least - // elements (the buffer growth that MuParser did internally now lives - // with whoever owns the buffers). - // - // The 3rd argument to Buffer.MemoryCopy is destinationSizeInBytes — the ONLY overflow guard — so - // it must be the destination's TRUE byte capacity (real array length * element size), never the - // copy size. Passing the copy size would defeat the guard and let a too-small destination be - // overrun as a silent out-of-bounds heap write; passing the real capacity makes MemoryCopy throw - // ArgumentOutOfRangeException instead. - - public void FillIntBuffer(int[] destination, int intCount) - { - int byteCount = intCount * 4; - int valIdx = Advance(byteCount); + // The Fill* helpers bulk-copy N contiguously packed element structs straight from the pinned + // data buffer into a caller-owned destination array in one memcpy, rather than reading element + // by element. They do NOT grow the destination: the caller must supply an array with room for + // at least the requested element count. + // + // The 3rd argument to Buffer.MemoryCopy is destinationSizeInBytes — the ONLY overflow guard — so + // it must be the destination's TRUE byte capacity (real array length * element size), never the + // copy size. Passing the copy size would defeat the guard and let a too-small destination be + // overrun as a silent out-of-bounds heap write; passing the real capacity makes MemoryCopy throw + // ArgumentOutOfRangeException instead. + + public void FillIntBuffer(int[] destination, int intCount) + { + int byteCount = intCount * 4; + int valIdx = Advance(byteCount); - fixed (int* intBufferPtr = destination) - // 3rd arg is the destination's real byte capacity, so the copy is bounds-checked. - Buffer.MemoryCopy(Ptr + valIdx, intBufferPtr, (long)destination.Length * 4, byteCount); - } + fixed (int* intBufferPtr = destination) + Buffer.MemoryCopy(Ptr + valIdx, intBufferPtr, (long)destination.Length * 4, byteCount); + } - public void FillVector2Buffer(Vector2[] destination, int vector2Count) - { - int byteCount = vector2Count * 8; - int valIdx = Advance(byteCount); + public void FillVector2Buffer(Vector2[] destination, int vector2Count) + { + int byteCount = vector2Count * 8; + int valIdx = Advance(byteCount); - fixed (Vector2* vector2BufferPtr = destination) - // 3rd arg is the destination's real byte capacity, so the copy is bounds-checked. - Buffer.MemoryCopy(Ptr + valIdx, vector2BufferPtr, (long)destination.Length * 8, byteCount); - } + fixed (Vector2* vector2BufferPtr = destination) + Buffer.MemoryCopy(Ptr + valIdx, vector2BufferPtr, (long)destination.Length * 8, byteCount); + } - public void FillVector3Buffer(Vector3[] destination, int vector3Count) - { - int byteCount = vector3Count * 12; - int valIdx = Advance(byteCount); + public void FillVector3Buffer(Vector3[] destination, int vector3Count) + { + int byteCount = vector3Count * 12; + int valIdx = Advance(byteCount); - fixed (Vector3* vector3BufferPtr = destination) - // 3rd arg is the destination's real byte capacity, so the copy is bounds-checked. - Buffer.MemoryCopy(Ptr + valIdx, vector3BufferPtr, (long)destination.Length * 12, byteCount); - } + fixed (Vector3* vector3BufferPtr = destination) + Buffer.MemoryCopy(Ptr + valIdx, vector3BufferPtr, (long)destination.Length * 12, byteCount); + } - public void FillVector4Buffer(Vector4[] destination, int vector4Count) - { - int byteCount = vector4Count * 16; - int valIdx = Advance(byteCount); + public void FillVector4Buffer(Vector4[] destination, int vector4Count) + { + int byteCount = vector4Count * 16; + int valIdx = Advance(byteCount); - fixed (Vector4* vector4BufferPtr = destination) - // 3rd arg is the destination's real byte capacity, so the copy is bounds-checked. - Buffer.MemoryCopy(Ptr + valIdx, vector4BufferPtr, (long)destination.Length * 16, byteCount); - } + fixed (Vector4* vector4BufferPtr = destination) + Buffer.MemoryCopy(Ptr + valIdx, vector4BufferPtr, (long)destination.Length * 16, byteCount); + } - public void FillColor32Buffer(Color32[] destination, int color32Count) - { - int byteCount = color32Count * 4; - int valIdx = Advance(byteCount); + public void FillColor32Buffer(Color32[] destination, int color32Count) + { + int byteCount = color32Count * 4; + int valIdx = Advance(byteCount); - fixed (Color32* color32BufferPtr = destination) - // 3rd arg is the destination's real byte capacity, so the copy is bounds-checked. - Buffer.MemoryCopy(Ptr + valIdx, color32BufferPtr, (long)destination.Length * 4, byteCount); - } + fixed (Color32* color32BufferPtr = destination) + Buffer.MemoryCopy(Ptr + valIdx, color32BufferPtr, (long)destination.Length * 4, byteCount); } } diff --git a/KSPCommunityFixes/Library/Model/MuModelCompiler.cs b/KSPCommunityFixes/Library/Model/MuModelCompiler.cs index 3cdf0df..952c89a 100644 --- a/KSPCommunityFixes/Library/Model/MuModelCompiler.cs +++ b/KSPCommunityFixes/Library/Model/MuModelCompiler.cs @@ -4,1256 +4,1118 @@ using PartToolsLib; using UnityEngine; -namespace KSPCommunityFixes.Library.Model +namespace KSPCommunityFixes.Library.Model; + +/// +/// Compiles a .mu file into a . +/// +internal sealed unsafe class MuModelCompiler { - /// - /// Thread-safe, instance-state port of that walks a - /// .mu file's opcode tree and, instead of building UnityEngine.Objects on the main - /// thread, EMITS a flat list plus serialization-ready - /// s into a . Everything here is off-main-thread work - /// (no UnityEngine.Object is ever created), so many worker threads can compile different files - /// in parallel — hence every accumulator is an instance field, never a static. - /// - /// The opcode dispatch, read order and version-gating are a faithful reproduction of MuParser's - /// ReadChild switch and its readers; the non-obvious parity points (two-pass material/texture - /// deferral, slot allocation policy, the skinned-mesh seam, the texCount guard and the - /// last-wins material ordering) are commented inline where they occur. - /// - /// - /// A single instance may be reused across many files on one worker thread: - /// resets all instance state at its top, so no explicit disposal or re-instantiation is required. - /// - /// - internal sealed unsafe class MuModelCompiler - { - // ---- Per-Compile instance state (reset at the top of Compile) ------------------------------ - - // The single mutable reader. Per MuBinaryReader's contract it is held in exactly ONE - // non-readonly field and mutated in place; every read helper below is an instance method that - // advances this.reader, so the cursor never desyncs through a defensive struct copy. - private MuBinaryReader reader; - - private string fileUrl; - private string directoryUrl; - private int version; - - // Running slot counter. Slots are indices into the driver's locals[] array; the high-water mark - // (== this value at the end) becomes CompiledModel.LocalCount. - private int nextSlot; - - // Global mesh index over the whole file, used to build a globally-unique canonical mesh name. - private int meshIndex; - - private readonly List instructions = new List(); - private readonly List blobs = new List(); - private readonly List bindings = new List(); - - // matRefs[i] holds the renderer/emitter slots that reference material index i, mirroring - // MuParser's matDummies[i].renderers / .particleEmitters. Populated while walking (when an - // AddMeshRenderer/AddSkinnedMeshRenderer/AddParticleEmitter is emitted); consumed at finalize. - private readonly List matRefs = new List(); - - // Pending materials in material-index order (0..materialCount-1), parsed by ReadMaterials but - // NOT emitted until finalize (their texture urls aren't known until the Textures block). - private readonly List pendingMaterials = new List(); - - // Texture-slot table: textureSlots[slot] = every PendingTexture that references that texture - // slot. Mirrors MuParser's PartReader.TextureDummyList — grown so that Count == highest - // referenced slot + 1, which is what the texCount guard compares against. - private readonly List> textureSlots = new List>(); - - // Skinned renderers awaiting a ResolveBones step (mirrors MuParser's boneDummies). - private readonly List skinnedRenderers = new List(); - - // Bone names for the SkinnedMeshRenderer currently being parsed. ReadSkinnedMeshRenderer sets - // this to the SMR's bone-name list immediately BEFORE it calls ReadMesh (which reads the matching - // bind poses) and clears it (null) right AFTER, so ReadMesh can attach the index-aligned bone-name - // list to the skinned mesh blob it builds. Null whenever a mesh is read outside a skinned renderer - // (MeshFilter / collider meshes), which never carry bind poses. - private string[] currentBoneNames; - - // Diagnostics buffered during compilation. Compile runs on background worker threads (via a - // parallel PLINQ query over all models), and KSP's ILogHandler plus the mod handlers chained onto - // Application.logMessageReceived are NOT thread-safe, so nothing here may log off-thread. Instead - // every diagnostic is appended to this list and handed to the returned CompiledModel, whose - // FlushLogs() emits them on the MAIN thread during replay. - private List logs; - - // Single cached sink passed to MeshBlobBuilder.FromArrays so its attribute-length warnings are - // buffered rather than logged off-thread. It closes over the logs FIELD (not a captured list), so - // after ResetState swaps in a fresh list it always targets the current one — no per-mesh alloc. - private readonly Action warnSink; - - public MuModelCompiler() - { - warnSink = msg => logs.Add(new DeferredLog(LogType.Warning, msg)); - } + // ---- Per-Compile instance state ------------------------------ - /// - /// Compile one .mu file into a main-thread-ready . Never throws: - /// on any parse error a with set is - /// returned so a PLINQ Select over many files can't fault. - /// - /// The model's UrlFile.url. Used for globally-unique mesh names and - /// (the equivalent of MuParser's root object name). - /// The model's file.parent.url. Used to build texture urls, - /// exactly MuParser's modelDirectoryUrl parameter. - /// Raw model bytes. Must be pinnable (a plain managed array). - /// Valid byte count in ; if <= 0 the whole - /// array length is used (matching MuParser.Parse). - public CompiledModel Compile(string fileUrl, string directoryUrl, byte[] data, int dataLength) - { - ResetState(); - this.fileUrl = fileUrl; - this.directoryUrl = directoryUrl; + private MuBinaryReader reader; + private string fileUrl; + private string directoryUrl; + private int version; - int length = dataLength <= 0 ? data.Length : dataLength; + // Running slot counter. Slots are indices into the driver's locals[] array. + private int nextSlot; - // Pin for the whole walk: MuBinaryReader holds a raw byte*, so the buffer must stay - // pinned for the entire parse. A single fixed block around the walk matches MuParser's - // GCHandle-pinned scope; nothing reads the stream after this block closes. - fixed (byte* p = data) - { - reader = new MuBinaryReader(p, length); + // Global mesh index over the whole file, used to build a globally-unique canonical mesh name. + private int meshIndex; - if (reader.ReadInt() != 76543) - throw new Exception("Invalid mu file"); + private readonly List instructions = []; + private readonly List blobs = []; + private readonly List bindings = []; - version = reader.ReadInt(); - reader.SkipString(); + // Holds the renderer/emitter slots that reference material index i. + private readonly List matRefs = []; - // Root is the first thing allocated, so it lands in slot 0 (parent = -1 == none). - ReadChild(-1); - } + // Pending materials in material-index order (0..materialCount-1). + private readonly List pendingMaterials = []; - // Two-pass finalize (see FinalizeMaterials): materials + textures are known now, so emit - // CreateMaterial/AssignMaterial in ascending material-index order, then the deferred bone - // resolution (MuParser runs AffectSkinnedMeshRenderersBones last). - FinalizeMaterials(); - FinalizeBones(); + // Texture-slot table: textureSlots[slot] = every PendingTexture that references that texture slot. + private readonly List> textureSlots = []; - return new CompiledModel - { - SourceUrl = fileUrl, - Instructions = [.. instructions], - Blobs = [.. blobs], - Bindings = [.. bindings], - LocalCount = nextSlot, - Logs = logs, - }; - } + // Skinned renderers awaiting a ResolveBones step. + private readonly List skinnedRenderers = []; + + // Bone names for the SkinnedMeshRenderer currently being parsed. + private string[] currentBoneNames; + + private List logs; - private void ResetState() + private readonly Action warnSink; + + public MuModelCompiler() + { + warnSink = msg => logs.Add(new DeferredLog(LogType.Warning, msg)); + } + + /// Compiles one .mu file into a . + /// + /// The model's UrlFile.url; used for globally-unique mesh names and + /// . + /// + /// The model's file.parent.url; used to build texture urls. + /// Raw model bytes. Must be pinnable (a plain managed array). + /// + /// Valid byte count in ; if <= 0 the whole + /// array length is used. + /// + public CompiledModel Compile(string fileUrl, string directoryUrl, byte[] data, int dataLength) + { + ResetState(); + this.fileUrl = fileUrl; + this.directoryUrl = directoryUrl; + + int length = dataLength <= 0 ? data.Length : dataLength; + + // MuBinaryReader holds a raw byte*, so the buffer must stay pinned for the whole parse; + // nothing reads the stream after this block closes. + fixed (byte* p = data) { - // Fresh reader is assigned inside the fixed block; just clear the accumulators here so a - // worker thread can reuse one instance across many files. - reader = default; - fileUrl = null; - directoryUrl = null; - version = 0; - nextSlot = 0; - meshIndex = 0; - instructions.Clear(); - blobs.Clear(); - bindings.Clear(); - matRefs.Clear(); - pendingMaterials.Clear(); - textureSlots.Clear(); - skinnedRenderers.Clear(); - currentBoneNames = null; - // Fresh list per Compile so a returned CompiledModel keeps its own buffered diagnostics even - // after this instance is reused for the next file. warnSink closes over this field, so it - // automatically targets the new list. - logs = new List(); + reader = new MuBinaryReader(p, length); + + if (reader.ReadInt() != 76543) + throw new Exception("Invalid mu file"); + + version = reader.ReadInt(); + reader.SkipString(); + + // Root is the first thing allocated, so it lands in slot 0 (parent = -1 == none). + ReadChild(-1); } - // ---- Core tree walk ------------------------------------------------------------------------ + // Two-pass finalize: materials + textures are fully known now, so emit them (see + // FinalizeMaterials), then the deferred bone resolution. + FinalizeMaterials(); + FinalizeBones(); - /// - /// Mirror of MuParser.ReadChild: reads the transform header, allocates a GameObject slot, - /// emits , then dispatches the child opcode stream until - /// ChildTransformEnd (or end of data). Returns the allocated slot. - /// - private int ReadChild(int parentSlot) + return new CompiledModel { - string name = reader.ReadString(); - Vector3 pos = reader.ReadVector3(); - Quaternion rot = reader.ReadQuaternion(); - Vector3 scale = reader.ReadVector3(); - - // Allocate this GameObject's slot and create it before processing any of its components, so - // colliders/renderers/etc. below configure the correct current slot and children can parent - // to it. Slot policy: a slot is allocated for the ROOT + every child GameObject, every mesh, - // every material, every MeshRenderer/SkinnedMeshRenderer and every material-bound emitter; - // NOT for MeshFilter/colliders/Light/Camera/Animation/tag+layer (those configure the current - // GameObject slot inline). - int mySlot = nextSlot++; - instructions.Add(new CreateGameObject - { - Dst = mySlot, - Parent = parentSlot, - Name = name, - Pos = pos, - Rot = rot, - Scale = scale, - }); + SourceUrl = fileUrl, + Instructions = [.. instructions], + Blobs = [.. blobs], + Bindings = [.. bindings], + LocalCount = nextSlot, + Logs = logs, + }; + } - // No default case: MuParser ignores unknown opcodes (they consume nothing and the loop reads - // the next int). The switched EntryType values are numerically identical to MuParser's raw - // int cases. - while (reader.Position < reader.Length) + private void ResetState() + { + reader = default; + fileUrl = null; + directoryUrl = null; + version = 0; + nextSlot = 0; + meshIndex = 0; + instructions.Clear(); + blobs.Clear(); + bindings.Clear(); + matRefs.Clear(); + pendingMaterials.Clear(); + textureSlots.Clear(); + skinnedRenderers.Clear(); + currentBoneNames = null; + logs = []; + } + + // ---- Core tree walk ------------------------------------------------------------------------ + + /// + /// Reads one transform node and returns the allocated slot. + /// + private int ReadChild(int parentSlot) + { + string name = reader.ReadString(); + Vector3 pos = reader.ReadVector3(); + Quaternion rot = reader.ReadQuaternion(); + Vector3 scale = reader.ReadVector3(); + + // Allocate this GameObject's slot and create it before processing any of its components, so + // colliders/renderers/etc. below configure the correct current slot and children can parent + // to it. Slot policy: a slot is allocated for the ROOT + every child GameObject, every mesh, + // every material, every MeshRenderer/SkinnedMeshRenderer and every material-bound emitter; + // NOT for MeshFilter/colliders/Light/Camera/Animation/tag+layer (those configure the current + // GameObject slot inline). + int mySlot = nextSlot++; + instructions.Add(new CreateGameObject + { + Dst = mySlot, + Parent = parentSlot, + Name = name, + Pos = pos, + Rot = rot, + Scale = scale, + }); + + // No default case: an unknown opcode is ignored — it consumes nothing, and the loop reads the + // next int as the following opcode. + while (reader.Position < reader.Length) + { + switch ((EntryType)reader.ReadInt()) { - switch ((EntryType)reader.ReadInt()) - { - case EntryType.ChildTransformStart: // 0 - ReadChild(mySlot); - break; - case EntryType.ChildTransformEnd: // 1 - return mySlot; - case EntryType.Animation: // 2 - ReadAnimation(mySlot); - break; - case EntryType.MeshCollider: // 3 - ReadMeshCollider(mySlot); - break; - case EntryType.SphereCollider: // 4 - ReadSphereCollider(mySlot); - break; - case EntryType.CapsuleCollider: // 5 - ReadCapsuleCollider(mySlot); - break; - case EntryType.BoxCollider: // 6 - ReadBoxCollider(mySlot); - break; - case EntryType.MeshFilter: // 7 - ReadMeshFilter(mySlot); - break; - case EntryType.MeshRenderer: // 8 - ReadMeshRenderer(mySlot); - break; - case EntryType.SkinnedMeshRenderer: // 9 - ReadSkinnedMeshRenderer(mySlot); - break; - case EntryType.Materials: // 10 - ReadMaterials(); - break; - case EntryType.Textures: // 12 - ReadTextures(); - break; - case EntryType.Light: // 23 - ReadLight(mySlot); - break; - case EntryType.TagAndLayer: // 24 - ReadTagAndLayer(mySlot); - break; - case EntryType.MeshCollider2: // 25 - ReadMeshCollider2(mySlot); - break; - case EntryType.SphereCollider2: // 26 - ReadSphereCollider2(mySlot); - break; - case EntryType.CapsuleCollider2: // 27 - ReadCapsuleCollider2(mySlot); - break; - case EntryType.BoxCollider2: // 28 - ReadBoxCollider2(mySlot); - break; - case EntryType.WheelCollider: // 29 - ReadWheelCollider(mySlot); - break; - case EntryType.Camera: // 30 - ReadCamera(mySlot); - break; - case EntryType.ParticleEmitter: // 31 - ReadParticles(mySlot); - break; - } + case EntryType.ChildTransformStart: // 0 + ReadChild(mySlot); + break; + case EntryType.ChildTransformEnd: // 1 + return mySlot; + case EntryType.Animation: // 2 + ReadAnimation(mySlot); + break; + case EntryType.MeshCollider: // 3 + ReadMeshCollider(mySlot); + break; + case EntryType.SphereCollider: // 4 + ReadSphereCollider(mySlot); + break; + case EntryType.CapsuleCollider: // 5 + ReadCapsuleCollider(mySlot); + break; + case EntryType.BoxCollider: // 6 + ReadBoxCollider(mySlot); + break; + case EntryType.MeshFilter: // 7 + ReadMeshFilter(mySlot); + break; + case EntryType.MeshRenderer: // 8 + ReadMeshRenderer(mySlot); + break; + case EntryType.SkinnedMeshRenderer: // 9 + ReadSkinnedMeshRenderer(mySlot); + break; + case EntryType.Materials: // 10 + ReadMaterials(); + break; + case EntryType.Textures: // 12 + ReadTextures(); + break; + case EntryType.Light: // 23 + ReadLight(mySlot); + break; + case EntryType.TagAndLayer: // 24 + ReadTagAndLayer(mySlot); + break; + case EntryType.MeshCollider2: // 25 + ReadMeshColliderWithTrigger(mySlot); + break; + case EntryType.SphereCollider2: // 26 + ReadSphereColliderWithTrigger(mySlot); + break; + case EntryType.CapsuleCollider2: // 27 + ReadCapsuleColliderWithTrigger(mySlot); + break; + case EntryType.BoxCollider2: // 28 + ReadBoxColliderWithTrigger(mySlot); + break; + case EntryType.WheelCollider: // 29 + ReadWheelCollider(mySlot); + break; + case EntryType.Camera: // 30 + ReadCamera(mySlot); + break; + case EntryType.ParticleEmitter: // 31 + ReadParticles(mySlot); + break; } - - return mySlot; } - // ---- Component readers --------------------------------------------------------------------- + return mySlot; + } + + // ---- Component readers --------------------------------------------------------------------- - /// Mirror of MuParser.ReadAnimation: bakes clip/curve/keyframe data verbatim - /// (the isInvalid / null-skip logic and curve-type mapping run at replay in - /// ). - private void ReadAnimation(int goSlot) + private void ReadAnimation(int goSlot) + { + int clipCount = reader.ReadInt(); + var clips = new AnimationClipData[clipCount]; + for (int i = 0; i < clipCount; i++) { - int clipCount = reader.ReadInt(); - var clips = new AnimationClipData[clipCount]; - for (int i = 0; i < clipCount; i++) + string clipName = reader.ReadString(); + Vector3 boundsCenter = reader.ReadVector3(); + Vector3 boundsSize = reader.ReadVector3(); + int wrapMode = reader.ReadInt(); + + int curveCount = reader.ReadInt(); + var curves = new AnimationCurveData[curveCount]; + for (int j = 0; j < curveCount; j++) { - string clipName = reader.ReadString(); - Vector3 boundsCenter = reader.ReadVector3(); - Vector3 boundsSize = reader.ReadVector3(); - int wrapMode = reader.ReadInt(); - - int curveCount = reader.ReadInt(); - var curves = new AnimationCurveData[curveCount]; - for (int j = 0; j < curveCount; j++) + string curvePath = reader.ReadString(); + string curveProperty = reader.ReadString(); + int typeCode = reader.ReadInt(); + int preWrap = reader.ReadInt(); + int postWrap = reader.ReadInt(); + + int keyFrameCount = reader.ReadInt(); + var keys = new KeyframeData[keyFrameCount]; + for (int k = 0; k < keyFrameCount; k++) { - string curvePath = reader.ReadString(); - string curveProperty = reader.ReadString(); - int typeCode = reader.ReadInt(); - int preWrap = reader.ReadInt(); - int postWrap = reader.ReadInt(); - - int keyFrameCount = reader.ReadInt(); - var keys = new KeyframeData[keyFrameCount]; - for (int k = 0; k < keyFrameCount; k++) - { - // reader.ReadKeyFrame consumes MuParser's 20-byte record (4 floats + 4 pad) and - // returns a plain Keyframe struct (safe off-thread); we bake its four floats. - Keyframe kf = reader.ReadKeyFrame(); - keys[k] = new KeyframeData - { - Time = kf.time, - Value = kf.value, - InTangent = kf.inTangent, - OutTangent = kf.outTangent, - }; - } - - curves[j] = new AnimationCurveData + // The .mu keyframe record is 20 bytes (4 floats + 4 pad); only the four floats are kept. + Keyframe kf = reader.ReadKeyFrame(); + keys[k] = new KeyframeData { - Path = curvePath, - Property = curveProperty, - TypeCode = typeCode, - PreWrap = preWrap, - PostWrap = postWrap, - Keys = keys, + Time = kf.time, + Value = kf.value, + InTangent = kf.inTangent, + OutTangent = kf.outTangent, }; } - clips[i] = new AnimationClipData + curves[j] = new AnimationCurveData { - Name = clipName, - BoundsCenter = boundsCenter, - BoundsSize = boundsSize, - WrapMode = wrapMode, - Curves = curves, + Path = curvePath, + Property = curveProperty, + TypeCode = typeCode, + PreWrap = preWrap, + PostWrap = postWrap, + Keys = keys, }; } - // ReadString returns string.Empty (never null) for an empty string; baked verbatim so the - // AddAnimation "DefaultClip != string.Empty" guard behaves exactly like MuParser. - string defaultClip = reader.ReadString(); - bool playAutomatically = reader.ReadBool(); - - instructions.Add(new AddAnimation + clips[i] = new AnimationClipData { - Go = goSlot, - Clips = clips, - DefaultClip = defaultClip, - PlayAutomatically = playAutomatically, - }); + Name = clipName, + BoundsCenter = boundsCenter, + BoundsSize = boundsSize, + WrapMode = wrapMode, + Curves = curves, + }; } - /// Mirror of MuParser.ReadMeshCollider (opcode 3): the file's "convex" bool is - /// read and discarded (always forced convex at replay), then the mesh. - private void ReadMeshCollider(int goSlot) + // ReadString returns string.Empty (never null) for an empty string, which is what AddAnimation's + // "DefaultClip != string.Empty" guard relies on. + string defaultClip = reader.ReadString(); + bool playAutomatically = reader.ReadBool(); + + instructions.Add(new AddAnimation { - reader.SkipBool(); // "convex" bool, ignored (forced true in AddMeshCollider) - int meshSlot = ReadMesh(); - instructions.Add(new AddMeshCollider - { - Go = goSlot, - HasTrigger = false, - IsTrigger = false, - MeshSlot = meshSlot, - }); - } + Go = goSlot, + Clips = clips, + DefaultClip = defaultClip, + PlayAutomatically = playAutomatically, + }); + } - /// Mirror of MuParser.ReadMeshCollider2 (opcode 25): isTrigger, then the discarded - /// "convex" bool, then the mesh. - private void ReadMeshCollider2(int goSlot) + private void ReadMeshCollider(int goSlot) + { + reader.SkipBool(); // "convex" bool, ignored (forced true in AddMeshCollider) + int meshSlot = ReadMesh(); + instructions.Add(new AddMeshCollider { - bool isTrigger = reader.ReadBool(); - reader.SkipBool(); // "convex" bool, ignored - int meshSlot = ReadMesh(); - instructions.Add(new AddMeshCollider - { - Go = goSlot, - HasTrigger = true, - IsTrigger = isTrigger, - MeshSlot = meshSlot, - }); - } + Go = goSlot, + HasTrigger = false, + IsTrigger = false, + MeshSlot = meshSlot, + }); + } - /// Mirror of MuParser.ReadSphereCollider (opcode 4). - private void ReadSphereCollider(int goSlot) + private void ReadMeshColliderWithTrigger(int goSlot) + { + bool isTrigger = reader.ReadBool(); + reader.SkipBool(); // "convex" bool, ignored + int meshSlot = ReadMesh(); + instructions.Add(new AddMeshCollider { - float radius = reader.ReadFloat(); - Vector3 center = reader.ReadVector3(); - instructions.Add(new AddSphereCollider - { - Go = goSlot, - HasTrigger = false, - Radius = radius, - Center = center, - }); - } + Go = goSlot, + HasTrigger = true, + IsTrigger = isTrigger, + MeshSlot = meshSlot, + }); + } - /// Mirror of MuParser.ReadSphereCollider2 (opcode 26). - private void ReadSphereCollider2(int goSlot) + private void ReadSphereCollider(int goSlot) + { + float radius = reader.ReadFloat(); + Vector3 center = reader.ReadVector3(); + instructions.Add(new AddSphereCollider { - bool isTrigger = reader.ReadBool(); - float radius = reader.ReadFloat(); - Vector3 center = reader.ReadVector3(); - instructions.Add(new AddSphereCollider - { - Go = goSlot, - HasTrigger = true, - IsTrigger = isTrigger, - Radius = radius, - Center = center, - }); - } + Go = goSlot, + HasTrigger = false, + Radius = radius, + Center = center, + }); + } - /// Mirror of MuParser.ReadCapsuleCollider (opcode 5): radius, direction, center. - private void ReadCapsuleCollider(int goSlot) + private void ReadSphereColliderWithTrigger(int goSlot) + { + bool isTrigger = reader.ReadBool(); + float radius = reader.ReadFloat(); + Vector3 center = reader.ReadVector3(); + instructions.Add(new AddSphereCollider { - float radius = reader.ReadFloat(); - int direction = reader.ReadInt(); - Vector3 center = reader.ReadVector3(); - instructions.Add(new AddCapsuleCollider - { - Go = goSlot, - HasTrigger = false, - Radius = radius, - HasHeight = false, - Direction = direction, - Center = center, - }); - } + Go = goSlot, + HasTrigger = true, + IsTrigger = isTrigger, + Radius = radius, + Center = center, + }); + } - /// Mirror of MuParser.ReadCapsuleCollider2 (opcode 27): isTrigger, radius, height, - /// direction, center. - private void ReadCapsuleCollider2(int goSlot) + private void ReadCapsuleCollider(int goSlot) + { + float radius = reader.ReadFloat(); + int direction = reader.ReadInt(); + Vector3 center = reader.ReadVector3(); + instructions.Add(new AddCapsuleCollider { - bool isTrigger = reader.ReadBool(); - float radius = reader.ReadFloat(); - float height = reader.ReadFloat(); - int direction = reader.ReadInt(); - Vector3 center = reader.ReadVector3(); - instructions.Add(new AddCapsuleCollider - { - Go = goSlot, - HasTrigger = true, - IsTrigger = isTrigger, - Radius = radius, - HasHeight = true, - Height = height, - Direction = direction, - Center = center, - }); - } + Go = goSlot, + HasTrigger = false, + Radius = radius, + HasHeight = false, + Direction = direction, + Center = center, + }); + } - /// Mirror of MuParser.ReadBoxCollider (opcode 6). - private void ReadBoxCollider(int goSlot) + private void ReadCapsuleColliderWithTrigger(int goSlot) + { + bool isTrigger = reader.ReadBool(); + float radius = reader.ReadFloat(); + float height = reader.ReadFloat(); + int direction = reader.ReadInt(); + Vector3 center = reader.ReadVector3(); + instructions.Add(new AddCapsuleCollider { - Vector3 size = reader.ReadVector3(); - Vector3 center = reader.ReadVector3(); - instructions.Add(new AddBoxCollider - { - Go = goSlot, - HasTrigger = false, - Size = size, - Center = center, - }); - } + Go = goSlot, + HasTrigger = true, + IsTrigger = isTrigger, + Radius = radius, + HasHeight = true, + Height = height, + Direction = direction, + Center = center, + }); + } - /// Mirror of MuParser.ReadBoxCollider2 (opcode 28). - private void ReadBoxCollider2(int goSlot) + private void ReadBoxCollider(int goSlot) + { + Vector3 size = reader.ReadVector3(); + Vector3 center = reader.ReadVector3(); + instructions.Add(new AddBoxCollider { - bool isTrigger = reader.ReadBool(); - Vector3 size = reader.ReadVector3(); - Vector3 center = reader.ReadVector3(); - instructions.Add(new AddBoxCollider - { - Go = goSlot, - HasTrigger = true, - IsTrigger = isTrigger, - Size = size, - Center = center, - }); - } + Go = goSlot, + HasTrigger = false, + Size = size, + Center = center, + }); + } - /// Mirror of MuParser.ReadWheelCollider (opcode 29): mass/radius/suspension, the - /// JointSpring triplet, then the two 5-float friction curves (in MuParser's read order). - private void ReadWheelCollider(int goSlot) + private void ReadBoxColliderWithTrigger(int goSlot) + { + bool isTrigger = reader.ReadBool(); + Vector3 size = reader.ReadVector3(); + Vector3 center = reader.ReadVector3(); + instructions.Add(new AddBoxCollider { - float mass = reader.ReadFloat(); - float radius = reader.ReadFloat(); - float suspensionDistance = reader.ReadFloat(); - Vector3 center = reader.ReadVector3(); - float springSpring = reader.ReadFloat(); - float springDamper = reader.ReadFloat(); - float springTarget = reader.ReadFloat(); - - // Friction curve fields in MuParser's exact read order: - // extremumSlip, extremumValue, asymptoteSlip, asymptoteValue, stiffness. - float[] forward = new float[5]; - for (int i = 0; i < 5; i++) - forward[i] = reader.ReadFloat(); - float[] sideways = new float[5]; - for (int i = 0; i < 5; i++) - sideways[i] = reader.ReadFloat(); - - instructions.Add(new AddWheelCollider - { - Go = goSlot, - Mass = mass, - Radius = radius, - SuspensionDistance = suspensionDistance, - Center = center, - SpringSpring = springSpring, - SpringDamper = springDamper, - SpringTarget = springTarget, - Forward = forward, - Sideways = sideways, - }); + Go = goSlot, + HasTrigger = true, + IsTrigger = isTrigger, + Size = size, + Center = center, + }); + } + + private void ReadWheelCollider(int goSlot) + { + float mass = reader.ReadFloat(); + float radius = reader.ReadFloat(); + float suspensionDistance = reader.ReadFloat(); + Vector3 center = reader.ReadVector3(); + float springSpring = reader.ReadFloat(); + float springDamper = reader.ReadFloat(); + float springTarget = reader.ReadFloat(); + + // Friction curve fields in read order: + // extremumSlip, extremumValue, asymptoteSlip, asymptoteValue, stiffness. + float[] forward = new float[5]; + for (int i = 0; i < 5; i++) + forward[i] = reader.ReadFloat(); + float[] sideways = new float[5]; + for (int i = 0; i < 5; i++) + sideways[i] = reader.ReadFloat(); + + instructions.Add(new AddWheelCollider + { + Go = goSlot, + Mass = mass, + Radius = radius, + SuspensionDistance = suspensionDistance, + Center = center, + SpringSpring = springSpring, + SpringDamper = springDamper, + SpringTarget = springTarget, + Forward = forward, + Sideways = sideways, + }); + } + + private void ReadMeshFilter(int goSlot) + { + int meshSlot = ReadMesh(); + instructions.Add(new AddMeshFilter { Go = goSlot, MeshSlot = meshSlot }); + } + + private void ReadMeshRenderer(int goSlot) + { + int rendererSlot = nextSlot++; + + bool hasShadowFlags = version >= 1; + bool castShadows = false; + bool receiveShadows = false; + if (hasShadowFlags) + { + castShadows = reader.ReadBool(); + receiveShadows = reader.ReadBool(); } - /// Mirror of MuParser.ReadMeshFilter (opcode 7). - private void ReadMeshFilter(int goSlot) + instructions.Add(new AddMeshRenderer { - int meshSlot = ReadMesh(); - instructions.Add(new AddMeshFilter { Go = goSlot, MeshSlot = meshSlot }); + Go = goSlot, + Dst = rendererSlot, + HasShadowFlags = hasShadowFlags, + CastShadows = castShadows, + ReceiveShadows = receiveShadows, + }); + + int rendererCount = reader.ReadInt(); + for (int i = 0; i < rendererCount; i++) + { + int materialIndex = reader.ReadInt(); + EnsureMatRef(materialIndex); + matRefs[materialIndex].Renderers.Add(rendererSlot); } + } + + private void ReadSkinnedMeshRenderer(int goSlot) + { + int smrSlot = nextSlot++; - /// Mirror of MuParser.ReadMeshRenderer (opcode 8): shadow flags exist only for - /// version >= 1; a renderer registers itself under every material index it lists (last index - /// wins at replay, preserved by ascending-index AssignMaterial emission). - private void ReadMeshRenderer(int goSlot) + int rendererCount = reader.ReadInt(); + for (int i = 0; i < rendererCount; i++) { - int rendererSlot = nextSlot++; + int materialIndex = reader.ReadInt(); + EnsureMatRef(materialIndex); + // SkinnedMeshRenderer is a Renderer, so it shares the renderer material fan-out. + matRefs[materialIndex].Renderers.Add(smrSlot); + } - bool hasShadowFlags = version >= 1; - bool castShadows = false; - bool receiveShadows = false; - if (hasShadowFlags) - { - castShadows = reader.ReadBool(); - receiveShadows = reader.ReadBool(); - } + Vector3 boundsCenter = reader.ReadVector3(); + Vector3 boundsSize = reader.ReadVector3(); + int quality = reader.ReadInt(); + bool updateWhenOffscreen = reader.ReadBool(); - instructions.Add(new AddMeshRenderer - { - Go = goSlot, - Dst = rendererSlot, - HasShadowFlags = hasShadowFlags, - CastShadows = castShadows, - ReceiveShadows = receiveShadows, - }); + int boneCount = reader.ReadInt(); + var boneNames = new string[boneCount]; + for (int j = 0; j < boneCount; j++) + boneNames[j] = reader.ReadString(); - int rendererCount = reader.ReadInt(); - for (int i = 0; i < rendererCount; i++) - { - int materialIndex = reader.ReadInt(); - EnsureMatRef(materialIndex); - matRefs[materialIndex].Renderers.Add(rendererSlot); - } - } + // Hand the SMR's bone names to ReadMesh so the skinned mesh blob attaches them index-aligned + // with the bind poses it reads (BoneNames[i] <-> BindPoses[i]). Cleared right after so a later + // non-skinned ReadMesh can't pick up stale names. + currentBoneNames = boneNames; + int meshSlot = ReadMesh(); + currentBoneNames = null; - /// Mirror of MuParser.ReadSkinnedMeshRenderer (opcode 9): reads the material - /// fan-out, local bounds / quality / updateWhenOffscreen, the bone NAMES and the mesh; emits an - /// and records a deferred step. The - /// bone names are threaded into (via ) so the - /// skinned mesh blob carries them index-aligned with its bind poses; the SAME list also drives the - /// bone binding in , so hashes and binding stay consistent. - private void ReadSkinnedMeshRenderer(int goSlot) + instructions.Add(new AddSkinnedMeshRenderer { - int smrSlot = nextSlot++; + Go = goSlot, + Dst = smrSlot, + LocalBounds = new Bounds(boundsCenter, boundsSize), + Quality = quality, + UpdateWhenOffscreen = updateWhenOffscreen, + MeshSlot = meshSlot, + }); + + skinnedRenderers.Add(new SkinnedEntry { SmrSlot = smrSlot, BoneNames = boneNames }); + } - int rendererCount = reader.ReadInt(); - for (int i = 0; i < rendererCount; i++) - { - int materialIndex = reader.ReadInt(); - EnsureMatRef(materialIndex); - // SkinnedMeshRenderer is a Renderer, so it shares the renderer material fan-out. - matRefs[materialIndex].Renderers.Add(smrSlot); - } + private void ReadMaterials() + { + // Assumes exactly ONE Materials block per .mu, as all real files have: a second Materials block + // would append into pendingMaterials and shift the second block's material indices. This is a + // documented latent structural assumption, not a bug to fix. + int materialCount = reader.ReadInt(); + for (int i = 0; i < materialCount; i++) + { + PendingMaterial pending = version < 4 ? ReadMaterial() : ReadMaterialV4(); + pending.Slot = nextSlot++; + pendingMaterials.Add(pending); // index i == material index i + } + } - Vector3 boundsCenter = reader.ReadVector3(); - Vector3 boundsSize = reader.ReadVector3(); - int quality = reader.ReadInt(); - bool updateWhenOffscreen = reader.ReadBool(); - - int boneCount = reader.ReadInt(); - var boneNames = new string[boneCount]; - for (int j = 0; j < boneCount; j++) - boneNames[j] = reader.ReadString(); - - // Hand the SMR's bone names to ReadMesh so the skinned mesh blob it builds attaches them - // index-aligned with the bind poses it reads (BoneNames[i] <-> BindPoses[i]). MuParser reads - // this SMR's mesh immediately after its bone names, so the pairing matches the oracle. Cleared - // right after so a later non-skinned ReadMesh can't pick up stale names. - currentBoneNames = boneNames; - int meshSlot = ReadMesh(); - currentBoneNames = null; - - instructions.Add(new AddSkinnedMeshRenderer - { - Go = goSlot, - Dst = smrSlot, - LocalBounds = new Bounds(boundsCenter, boundsSize), - Quality = quality, - UpdateWhenOffscreen = updateWhenOffscreen, - MeshSlot = meshSlot, - }); + private PendingMaterial ReadMaterial() + { + string name = reader.ReadString(); + ShaderType shaderType = (ShaderType)reader.ReadInt(); - skinnedRenderers.Add(new SkinnedEntry { SmrSlot = smrSlot, BoneNames = boneNames }); - } + var pm = new PendingMaterial + { + Name = name, + Shader = new ShaderRef { ByName = false, Type = shaderType }, + }; - /// Mirror of MuParser.ReadMaterials (opcode 10): reads each material into a pending - /// descriptor (deferred emission, since texture urls aren't known yet) and allocates its slot in - /// ascending index order. - private void ReadMaterials() + switch (shaderType) { - // Assumes exactly ONE Materials block per .mu (as all real/stock files have, matching MuParser and - // stock PartReader): a hypothetical second Materials block would append into pendingMaterials and - // shift the second block's material indices, diverging from the oracle's per-block re-indexing. This - // is a documented latent structural assumption, not a bug to fix. - int materialCount = reader.ReadInt(); - for (int i = 0; i < materialCount; i++) - { - PendingMaterial pending = version < 4 ? ReadMaterial() : ReadMaterial4(); - pending.Slot = nextSlot++; - pendingMaterials.Add(pending); // index i == material index i - } + default: // Custom (0), Diffuse (1) and any unknown type read just _MainTex + ReadMaterialTexture(pm, "_MainTex"); + break; + case ShaderType.Specular: + ReadMaterialTexture(pm, "_MainTex"); + AddColor(pm, "_SpecColor", reader.ReadColor()); + AddFloat(pm, "_Shininess", reader.ReadFloat()); + break; + case ShaderType.Bumped: + ReadMaterialTexture(pm, "_MainTex"); + ReadMaterialTexture(pm, "_BumpMap"); + break; + case ShaderType.BumpedSpecular: + ReadMaterialTexture(pm, "_MainTex"); + ReadMaterialTexture(pm, "_BumpMap"); + AddColor(pm, "_SpecColor", reader.ReadColor()); + AddFloat(pm, "_Shininess", reader.ReadFloat()); + break; + case ShaderType.Emissive: + ReadMaterialTexture(pm, "_MainTex"); + ReadMaterialTexture(pm, "_Emissive"); + AddColor(pm, "_EmissiveColor", reader.ReadColor()); + break; + case ShaderType.EmissiveSpecular: + ReadMaterialTexture(pm, "_MainTex"); + AddColor(pm, "_SpecColor", reader.ReadColor()); + AddFloat(pm, "_Shininess", reader.ReadFloat()); + ReadMaterialTexture(pm, "_Emissive"); + AddColor(pm, "_EmissiveColor", reader.ReadColor()); + break; + case ShaderType.EmissiveBumpedSpecular: + ReadMaterialTexture(pm, "_MainTex"); + ReadMaterialTexture(pm, "_BumpMap"); + AddColor(pm, "_SpecColor", reader.ReadColor()); + AddFloat(pm, "_Shininess", reader.ReadFloat()); + ReadMaterialTexture(pm, "_Emissive"); + AddColor(pm, "_EmissiveColor", reader.ReadColor()); + break; + case ShaderType.AlphaCutout: + ReadMaterialTexture(pm, "_MainTex"); + AddFloat(pm, "_Cutoff", reader.ReadFloat()); + break; + case ShaderType.AlphaCutoutBumped: + ReadMaterialTexture(pm, "_MainTex"); + ReadMaterialTexture(pm, "_BumpMap"); + AddFloat(pm, "_Cutoff", reader.ReadFloat()); + break; + case ShaderType.Alpha: + ReadMaterialTexture(pm, "_MainTex"); + break; + case ShaderType.AlphaSpecular: + ReadMaterialTexture(pm, "_MainTex"); + AddFloat(pm, "_Gloss", reader.ReadFloat()); + AddColor(pm, "_SpecColor", reader.ReadColor()); + AddFloat(pm, "_Shininess", reader.ReadFloat()); + break; + case ShaderType.AlphaUnlit: + ReadMaterialTexture(pm, "_MainTex"); + AddColor(pm, "_Color", reader.ReadColor()); + break; + case ShaderType.Unlit: + ReadMaterialTexture(pm, "_MainTex"); + AddColor(pm, "_Color", reader.ReadColor()); + break; + case ShaderType.ParticleAlpha: + ReadMaterialTexture(pm, "_MainTex"); + AddColor(pm, "_Color", reader.ReadColor()); + AddFloat(pm, "_InvFade", reader.ReadFloat()); + break; + case ShaderType.ParticleAdditive: + ReadMaterialTexture(pm, "_MainTex"); + AddColor(pm, "_Color", reader.ReadColor()); + AddFloat(pm, "_InvFade", reader.ReadFloat()); + break; + case ShaderType.BumpedSpecularMap: + ReadMaterialTexture(pm, "_MainTex"); + ReadMaterialTexture(pm, "_BumpMap"); + ReadMaterialTexture(pm, "_SpecMap"); + AddFloat(pm, "_SpecTint", reader.ReadFloat()); + AddFloat(pm, "_Shininess", reader.ReadFloat()); + break; } - /// Mirror of MuParser.ReadMaterial (version < 4): shader is resolved by - /// at replay; the per-ShaderType read order and property NAMES exactly - /// match MuParser's int-property-id setters (each id is Shader.PropertyToID(name)). - private PendingMaterial ReadMaterial() - { - string name = reader.ReadString(); - ShaderType shaderType = (ShaderType)reader.ReadInt(); + return pm; + } - var pm = new PendingMaterial - { - Name = name, - Shader = new ShaderRef { ByName = false, Type = shaderType }, - }; + private PendingMaterial ReadMaterialV4() + { + string matName = reader.ReadString(); + string shaderName = reader.ReadString(); + int propertyCount = reader.ReadInt(); + + var pm = new PendingMaterial + { + Name = matName, + Shader = new ShaderRef { ByName = true, Name = shaderName }, + }; - switch (shaderType) + for (int i = 0; i < propertyCount; i++) + { + string propName = reader.ReadString(); + switch (reader.ReadInt()) { - default: // Custom (0), Diffuse (1) and any unknown type: MuParser's default reads _MainTex - ReadMaterialTexture(pm, "_MainTex"); - break; - case ShaderType.Specular: - ReadMaterialTexture(pm, "_MainTex"); - AddColor(pm, "_SpecColor", reader.ReadColor()); - AddFloat(pm, "_Shininess", reader.ReadFloat()); - break; - case ShaderType.Bumped: - ReadMaterialTexture(pm, "_MainTex"); - ReadMaterialTexture(pm, "_BumpMap"); - break; - case ShaderType.BumpedSpecular: - ReadMaterialTexture(pm, "_MainTex"); - ReadMaterialTexture(pm, "_BumpMap"); - AddColor(pm, "_SpecColor", reader.ReadColor()); - AddFloat(pm, "_Shininess", reader.ReadFloat()); - break; - case ShaderType.Emissive: - ReadMaterialTexture(pm, "_MainTex"); - ReadMaterialTexture(pm, "_Emissive"); - AddColor(pm, "_EmissiveColor", reader.ReadColor()); + case 0: + AddColor(pm, propName, reader.ReadColor()); break; - case ShaderType.EmissiveSpecular: - ReadMaterialTexture(pm, "_MainTex"); - AddColor(pm, "_SpecColor", reader.ReadColor()); - AddFloat(pm, "_Shininess", reader.ReadFloat()); - ReadMaterialTexture(pm, "_Emissive"); - AddColor(pm, "_EmissiveColor", reader.ReadColor()); + case 1: + AddVector(pm, propName, reader.ReadVector4()); break; - case ShaderType.EmissiveBumpedSpecular: - ReadMaterialTexture(pm, "_MainTex"); - ReadMaterialTexture(pm, "_BumpMap"); - AddColor(pm, "_SpecColor", reader.ReadColor()); - AddFloat(pm, "_Shininess", reader.ReadFloat()); - ReadMaterialTexture(pm, "_Emissive"); - AddColor(pm, "_EmissiveColor", reader.ReadColor()); + case 2: + AddFloat(pm, propName, reader.ReadFloat()); break; - case ShaderType.AlphaCutout: - ReadMaterialTexture(pm, "_MainTex"); - AddFloat(pm, "_Cutoff", reader.ReadFloat()); + case 3: + AddFloat(pm, propName, reader.ReadFloat()); break; - case ShaderType.AlphaCutoutBumped: - ReadMaterialTexture(pm, "_MainTex"); - ReadMaterialTexture(pm, "_BumpMap"); - AddFloat(pm, "_Cutoff", reader.ReadFloat()); - break; - case ShaderType.Alpha: - ReadMaterialTexture(pm, "_MainTex"); - break; - case ShaderType.AlphaSpecular: - ReadMaterialTexture(pm, "_MainTex"); - AddFloat(pm, "_Gloss", reader.ReadFloat()); - AddColor(pm, "_SpecColor", reader.ReadColor()); - AddFloat(pm, "_Shininess", reader.ReadFloat()); - break; - case ShaderType.AlphaUnlit: - ReadMaterialTexture(pm, "_MainTex"); - AddColor(pm, "_Color", reader.ReadColor()); - break; - case ShaderType.Unlit: - ReadMaterialTexture(pm, "_MainTex"); - AddColor(pm, "_Color", reader.ReadColor()); - break; - case ShaderType.ParticleAlpha: - ReadMaterialTexture(pm, "_MainTex"); - AddColor(pm, "_Color", reader.ReadColor()); - AddFloat(pm, "_InvFade", reader.ReadFloat()); - break; - case ShaderType.ParticleAdditive: - ReadMaterialTexture(pm, "_MainTex"); - AddColor(pm, "_Color", reader.ReadColor()); - AddFloat(pm, "_InvFade", reader.ReadFloat()); - break; - case ShaderType.BumpedSpecularMap: - ReadMaterialTexture(pm, "_MainTex"); - ReadMaterialTexture(pm, "_BumpMap"); - ReadMaterialTexture(pm, "_SpecMap"); - AddFloat(pm, "_SpecTint", reader.ReadFloat()); - AddFloat(pm, "_Shininess", reader.ReadFloat()); + case 4: + ReadMaterialTexture(pm, propName); break; + // No default: an unknown type code consumes nothing beyond the name. } - - return pm; } - /// Mirror of MuParser.ReadMaterial4 (version >= 4): shader is resolved by name at - /// replay; property type codes 0=Color, 1=Vector, 2 & 3=Float, 4=texture. An unknown type code - /// reads nothing after the property name (matching MuParser's switch with no default). - private PendingMaterial ReadMaterial4() - { - string matName = reader.ReadString(); - string shaderName = reader.ReadString(); - int propertyCount = reader.ReadInt(); + return pm; + } - var pm = new PendingMaterial - { - Name = matName, - Shader = new ShaderRef { ByName = true, Name = shaderName }, - }; + private void ReadMaterialTexture(PendingMaterial pm, string propertyName) + { + int slotIndex = reader.ReadInt(); + Vector2 scale = reader.ReadVector2(); + Vector2 offset = reader.ReadVector2(); - for (int i = 0; i < propertyCount; i++) - { - string propName = reader.ReadString(); - switch (reader.ReadInt()) - { - case 0: - AddColor(pm, propName, reader.ReadColor()); - break; - case 1: - AddVector(pm, propName, reader.ReadVector4()); - break; - case 2: - AddFloat(pm, propName, reader.ReadFloat()); - break; - case 3: - AddFloat(pm, propName, reader.ReadFloat()); - break; - case 4: - ReadMaterialTexture(pm, propName); - break; - // No default: an unknown type code consumes nothing beyond the name, like MuParser. - } - } + var pt = new PendingTexture + { + Name = propertyName, + Scale = scale, + Offset = offset, + Url = null, + IsNormalMap = false, + }; + pm.Textures.Add(pt); + + if (slotIndex != -1) + { + while (slotIndex >= textureSlots.Count) + textureSlots.Add(new List()); + // One texture slot can be referenced by many (material, property) pairs; the resolved url + // is fanned out to every registered PendingTexture in ReadTextures. + textureSlots[slotIndex].Add(pt); + } + } - return pm; + private void ReadTextures() + { + int texCount = reader.ReadInt(); + + // texCount guard: on mismatch, log and return immediately without reading the texture entries, + // so no url is assigned (every PendingTexture keeps Url == null) and the cursor stops right + // after texCount. + if (texCount != textureSlots.Count) + { + logs.Add(new DeferredLog(LogType.Error, "TextureError: " + texCount + " " + textureSlots.Count)); + return; } - /// Mirror of MuParser.ReadMaterialTexture: reads the texture-slot index, scale and - /// offset. Scale/offset are always baked; the url is resolved later (Textures block). A slot of - /// -1 registers no dummy (MuParser's AddTextureDummy skips -1); otherwise the slot table is - /// grown so its Count matches MuParser's textureDummies.Count for the texCount guard. - private void ReadMaterialTexture(PendingMaterial pm, string propertyName) + for (int i = 0; i < texCount; i++) { - int slotIndex = reader.ReadInt(); - Vector2 scale = reader.ReadVector2(); - Vector2 offset = reader.ReadVector2(); + string name = reader.ReadString(); + TextureType textureType = (TextureType)reader.ReadInt(); - var pt = new PendingTexture - { - Name = propertyName, - Scale = scale, - Offset = offset, - Url = null, - IsNormalMap = false, - }; - pm.Textures.Add(pt); + string url = directoryUrl + "/" + Path.GetFileNameWithoutExtension(name); + // IsNormalMap is derived from the TEXTURE entry's type, not from any shader slot name. + bool isNormalMap = textureType == TextureType.NormalMap; - if (slotIndex != -1) + List refs = textureSlots[i]; + for (int j = 0; j < refs.Count; j++) { - while (slotIndex >= textureSlots.Count) - textureSlots.Add(new List()); - // One texture slot can be referenced by many (material, property) pairs; the resolved url - // is fanned out to every registered PendingTexture in ReadTextures. (We keep a direct - // reference to the PendingTexture rather than MuParser's dedup-by-material list; the dedup - // only removed idempotent duplicate SetTexture calls, so the outcome is identical.) - textureSlots[slotIndex].Add(pt); + refs[j].Url = url; + refs[j].IsNormalMap = isNormalMap; } } + } - /// Mirror of MuParser.ReadTextures (opcode 12): resolves texture urls and fans them - /// out to the pending textures. Preserves the texCount != textureDummies.Count guard — on - /// mismatch it logs and returns, leaving every url null (scale/offset still applied). - private void ReadTextures() - { - int texCount = reader.ReadInt(); - - // texCount guard: on mismatch MuParser logs and returns immediately WITHOUT reading the - // texture entries, so no url is ever assigned (every PendingTexture keeps Url == null). We - // reproduce that exactly, including leaving the cursor right after texCount. - if (texCount != textureSlots.Count) - { - logs.Add(new DeferredLog(LogType.Error, "TextureError: " + texCount + " " + textureSlots.Count)); - return; - } + private void ReadLight(int goSlot) + { + int type = reader.ReadInt(); + float intensity = reader.ReadFloat(); + float range = reader.ReadFloat(); + Color color = reader.ReadColor(); + int cullingMask = reader.ReadInt(); - for (int i = 0; i < texCount; i++) - { - string name = reader.ReadString(); - TextureType textureType = (TextureType)reader.ReadInt(); + bool hasSpotAngle = version > 1; + float spotAngle = hasSpotAngle ? reader.ReadFloat() : 0f; - // Url built exactly as MuParser: directoryUrl + "/" + filename-without-extension. - string url = directoryUrl + "/" + Path.GetFileNameWithoutExtension(name); - // IsNormalMap is derived from the TEXTURE entry's type, not from any shader slot name. - bool isNormalMap = textureType == TextureType.NormalMap; + instructions.Add(new AddLight + { + Go = goSlot, + Type = type, + Intensity = intensity, + Range = range, + Color = color, + CullingMask = cullingMask, + HasSpotAngle = hasSpotAngle, + SpotAngle = spotAngle, + }); + } - List refs = textureSlots[i]; - for (int j = 0; j < refs.Count; j++) - { - refs[j].Url = url; - refs[j].IsNormalMap = isNormalMap; - } - } - } + private void ReadTagAndLayer(int goSlot) + { + string tag = reader.ReadString(); + int layer = reader.ReadInt(); + instructions.Add(new SetTagAndLayer { Go = goSlot, Tag = tag, Layer = layer }); + } - /// Mirror of MuParser.ReadLight (opcode 23): spot angle exists only for version > 1. - private void ReadLight(int goSlot) + private void ReadCamera(int goSlot) + { + int clearFlags = reader.ReadInt(); + Color backgroundColor = reader.ReadColor(); + int cullingMask = reader.ReadInt(); + bool orthographic = reader.ReadBool(); + float fieldOfView = reader.ReadFloat(); + float nearClip = reader.ReadFloat(); + float farClip = reader.ReadFloat(); + float depth = reader.ReadFloat(); + + instructions.Add(new AddCamera { - int type = reader.ReadInt(); - float intensity = reader.ReadFloat(); - float range = reader.ReadFloat(); - Color color = reader.ReadColor(); - int cullingMask = reader.ReadInt(); - - bool hasSpotAngle = version > 1; - float spotAngle = hasSpotAngle ? reader.ReadFloat() : 0f; + Go = goSlot, + ClearFlags = clearFlags, + BackgroundColor = backgroundColor, + CullingMask = cullingMask, + Orthographic = orthographic, + FieldOfView = fieldOfView, + NearClip = nearClip, + FarClip = farClip, + Depth = depth, + }); + } - instructions.Add(new AddLight - { - Go = goSlot, - Type = type, - Intensity = intensity, - Range = range, - Color = color, - CullingMask = cullingMask, - HasSpotAngle = hasSpotAngle, - SpotAngle = spotAngle, - }); - } + private void ReadParticles(int goSlot) + { + int emitterSlot = nextSlot++; + + var d = new ParticleEmitterData(); + d.Emit = reader.ReadBool(); + d.Shape = reader.ReadInt(); + // Arguments evaluate left-to-right, so the reads fill x, y, z in order. + d.Shape3D = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.Shape2D = new Vector2(reader.ReadFloat(), reader.ReadFloat()); + d.Shape1D = reader.ReadFloat(); + d.Color = reader.ReadColor(); + d.UseWorldSpace = reader.ReadBool(); + d.MinSize = reader.ReadFloat(); + d.MaxSize = reader.ReadFloat(); + d.MinEnergy = reader.ReadFloat(); + d.MaxEnergy = reader.ReadFloat(); + d.MinEmission = reader.ReadInt(); + d.MaxEmission = reader.ReadInt(); + d.WorldVelocity = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.LocalVelocity = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.RndVelocity = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.EmitterVelocityScale = reader.ReadFloat(); + d.AngularVelocity = reader.ReadFloat(); + d.RndAngularVelocity = reader.ReadFloat(); + d.RndRotation = reader.ReadBool(); + d.DoesAnimateColor = reader.ReadBool(); + var colorAnimation = new Color[5]; // the color animation is always exactly 5 entries + for (int i = 0; i < 5; i++) + colorAnimation[i] = reader.ReadColor(); + d.ColorAnimation = colorAnimation; + d.WorldRotationAxis = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.LocalRotationAxis = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.SizeGrow = reader.ReadFloat(); + d.RndForce = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.Force = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); + d.Damping = reader.ReadFloat(); + d.CastShadows = reader.ReadBool(); + d.RecieveShadows = reader.ReadBool(); + d.LengthScale = reader.ReadFloat(); + d.VelocityScale = reader.ReadFloat(); + d.MaxParticleSize = reader.ReadFloat(); + d.RenderModeCode = reader.ReadInt(); // raw code; AddParticleEmitter maps it to the render mode + d.UvAnimationXTile = reader.ReadInt(); + d.UvAnimationYTile = reader.ReadInt(); + d.UvAnimationCycles = reader.ReadInt(); + + instructions.Add(new AddParticleEmitter { Go = goSlot, Dst = emitterSlot, Data = d }); + + int materialIndex = reader.ReadInt(); + EnsureMatRef(materialIndex); + matRefs[materialIndex].Emitters.Add(emitterSlot); + } - /// Mirror of MuParser.ReadTagAndLayer (opcode 24). Configures the current - /// GameObject slot inline (no new slot). - private void ReadTagAndLayer(int goSlot) - { - string tag = reader.ReadString(); - int layer = reader.ReadInt(); - instructions.Add(new SetTagAndLayer { Go = goSlot, Tag = tag, Layer = layer }); - } + // ---- Mesh parsing -------------------------------------------------------------------------- - /// Mirror of MuParser.ReadCamera (opcode 30). - private void ReadCamera(int goSlot) + /// Reads a mesh and returns the allocated mesh slot. + private int ReadMesh() + { + EntryType entryType = (EntryType)reader.ReadInt(); + if (entryType != EntryType.MeshStart) { - int clearFlags = reader.ReadInt(); - Color backgroundColor = reader.ReadColor(); - int cullingMask = reader.ReadInt(); - bool orthographic = reader.ReadBool(); - float fieldOfView = reader.ReadFloat(); - float nearClip = reader.ReadFloat(); - float farClip = reader.ReadFloat(); - float depth = reader.ReadFloat(); - - instructions.Add(new AddCamera - { - Go = goSlot, - ClearFlags = clearFlags, - BackgroundColor = backgroundColor, - CullingMask = cullingMask, - Orthographic = orthographic, - FieldOfView = fieldOfView, - NearClip = nearClip, - FarClip = farClip, - Depth = depth, - }); + // Corrupt stream: log "Mesh Error" and allocate a slot with NO binding, so locals[slot] + // stays null (a null sharedMesh) and the walk continues without a replay crash. + logs.Add(new DeferredLog(LogType.Error, "Mesh Error")); + return nextSlot++; } - /// Mirror of MuParser.ReadParticles (opcode 31): reads every KSPParticleEmitter - /// field in order (including the 5-element color animation and the raw render-mode int), allocates - /// the emitter slot and registers it under its material index for the deferred fan-out. - private void ReadParticles(int goSlot) - { - int emitterSlot = nextSlot++; - - var d = new ParticleEmitterData(); - d.Emit = reader.ReadBool(); - d.Shape = reader.ReadInt(); - // Component-wise reads: arguments evaluate left-to-right, matching MuParser's x, y, z order. - d.Shape3D = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); - d.Shape2D = new Vector2(reader.ReadFloat(), reader.ReadFloat()); - d.Shape1D = reader.ReadFloat(); - d.Color = reader.ReadColor(); - d.UseWorldSpace = reader.ReadBool(); - d.MinSize = reader.ReadFloat(); - d.MaxSize = reader.ReadFloat(); - d.MinEnergy = reader.ReadFloat(); - d.MaxEnergy = reader.ReadFloat(); - d.MinEmission = reader.ReadInt(); - d.MaxEmission = reader.ReadInt(); - d.WorldVelocity = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); - d.LocalVelocity = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); - d.RndVelocity = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); - d.EmitterVelocityScale = reader.ReadFloat(); - d.AngularVelocity = reader.ReadFloat(); - d.RndAngularVelocity = reader.ReadFloat(); - d.RndRotation = reader.ReadBool(); - d.DoesAnimateColor = reader.ReadBool(); - var colorAnimation = new Color[5]; // MuParser always allocates exactly 5. - for (int i = 0; i < 5; i++) - colorAnimation[i] = reader.ReadColor(); - d.ColorAnimation = colorAnimation; - d.WorldRotationAxis = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); - d.LocalRotationAxis = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); - d.SizeGrow = reader.ReadFloat(); - d.RndForce = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); - d.Force = new Vector3(reader.ReadFloat(), reader.ReadFloat(), reader.ReadFloat()); - d.Damping = reader.ReadFloat(); - d.CastShadows = reader.ReadBool(); - d.RecieveShadows = reader.ReadBool(); - d.LengthScale = reader.ReadFloat(); - d.VelocityScale = reader.ReadFloat(); - d.MaxParticleSize = reader.ReadFloat(); - d.RenderModeCode = reader.ReadInt(); // raw code; AddParticleEmitter maps it to the render mode - d.UvAnimationXTile = reader.ReadInt(); - d.UvAnimationYTile = reader.ReadInt(); - d.UvAnimationCycles = reader.ReadInt(); - - instructions.Add(new AddParticleEmitter { Go = goSlot, Dst = emitterSlot, Data = d }); + int size = reader.ReadInt(); // vertex count for every per-vertex attribute + reader.SkipInt(); // unknown field, skipped - int materialIndex = reader.ReadInt(); - EnsureMatRef(materialIndex); - matRefs[materialIndex].Emitters.Add(emitterSlot); - } + int index = meshIndex++; + string canonicalName = MeshBundleBuilder.Canonicalize($"{fileUrl}#{index}"); - // ---- Mesh parsing -------------------------------------------------------------------------- + var arrays = new MeshBlobBuilder.Arrays(); + var triangles = new List(); - /// Mirror of MuParser.ReadMesh (opcode MeshStart): reads the sub-blocks in file - /// order into a , builds a , records a - /// and returns the allocated mesh slot. Bone weights / bind poses (when - /// present) are stored into the arrays; together with the SMR bone names threaded in via - /// they let emit a fully - /// skinned mesh (blend channels, bind pose and bone metadata). - private int ReadMesh() + EntryType subType; + while ((subType = (EntryType)reader.ReadInt()) != EntryType.MeshEnd) { - EntryType entryType = (EntryType)reader.ReadInt(); - if (entryType != EntryType.MeshStart) + switch (subType) { - // Corrupt stream: MuParser logs "Mesh Error" and returns a null mesh (the component then - // gets a null sharedMesh) while the walk continues. We allocate a slot with NO binding so - // locals[slot] stays null == null mesh, matching that behaviour without a replay crash. - logs.Add(new DeferredLog(LogType.Error, "Mesh Error")); - return nextSlot++; + case EntryType.MeshVertexColors: + { + var colors = new Color32[size]; + reader.FillColor32Buffer(colors, size); + arrays.Colors = colors; + break; + } + case EntryType.MeshVerts: + { + var verts = new Vector3[size]; + reader.FillVector3Buffer(verts, size); + arrays.Vertices = verts; + break; + } + case EntryType.MeshUV: + { + var uv0 = new Vector2[size]; + reader.FillVector2Buffer(uv0, size); + arrays.Uv0 = uv0; + break; + } + case EntryType.MeshUV2: + { + var uv1 = new Vector2[size]; + reader.FillVector2Buffer(uv1, size); + arrays.Uv1 = uv1; + break; + } + case EntryType.MeshNormals: + { + var normals = new Vector3[size]; + reader.FillVector3Buffer(normals, size); + arrays.Normals = normals; + break; + } + case EntryType.MeshTangents: + { + var tangents = new Vector4[size]; + reader.FillVector4Buffer(tangents, size); + arrays.Tangents = tangents; + break; + } + case EntryType.MeshTriangles: + { + int triangleCount = reader.ReadInt(); + var tris = new int[triangleCount]; + reader.FillIntBuffer(tris, triangleCount); + triangles.Add(tris); // one submesh per MeshTriangles block, in encounter order + break; + } + case EntryType.MeshBoneWeights: + { + // One BoneWeight per vertex (four weights + four bone indices), stored so + // MeshBlobBuilder emits the BlendWeights (ch12) and BlendIndices (ch13) vertex + // channels. + var boneWeights = new BoneWeight[size]; + for (int i = 0; i < size; i++) + boneWeights[i] = reader.ReadBoneWeight(); + arrays.BoneWeights = boneWeights; + break; + } + case EntryType.MeshBindPoses: + { + // One bind pose per bone; its length defines the bone count. Stored so the blob + // carries m_BindPose and its bone metadata. + int bindPosesCount = reader.ReadInt(); + var bindPoses = new Matrix4x4[bindPosesCount]; + for (int i = 0; i < bindPosesCount; i++) + bindPoses[i] = reader.ReadMatrix4x4(); + arrays.BindPoses = bindPoses; + break; + } } + } - int size = reader.ReadInt(); // vertex count for every per-vertex attribute - reader.SkipInt(); // unknown field, skipped exactly as MuParser + arrays.SubMeshTriangles = triangles.ToArray(); + + // Skinned mesh: attach the SMR's bone names (set by ReadSkinnedMeshRenderer just before this + // call) index-aligned with the bind poses just read, so BoneNames.Length == BindPoses.Length. + // FromArrays hashes them into m_BoneNameHashes. These are the .mu's LEAF bone names — exactly + // what ResolveBones/FindChildByName binds SkinnedMeshRenderer.bones by at replay. Unity + // natively hashes each bone's FULL transform path, so the emitted hash won't byte-match Unity's + // stored value; that is COSMETIC (binding is by name, and only the per-bone array COUNT is + // structurally required). Full-path reconstruction is a possible future refinement, only if an + // in-KSP issue ever implicates the hash value. + if (arrays.BindPoses != null) + arrays.BoneNames = ReconcileBoneNames(currentBoneNames, arrays.BindPoses.Length, canonicalName); + + MeshBlob blob = MeshBlobBuilder.FromArrays(canonicalName, in arrays, warnSink); + blobs.Add(blob); + + int meshSlot = nextSlot++; + bindings.Add(new MeshBinding(meshSlot, canonicalName)); + return meshSlot; + } - int index = meshIndex++; - string canonicalName = MeshBundleBuilder.Canonicalize($"{fileUrl}#{index}"); + // ---- Finalize (two-pass materials/textures, then bones) ------------------------------------ - var arrays = new MeshBlobBuilder.Arrays(); - var triangles = new List(); + /// + /// Emits the deferred material work. + /// + private void FinalizeMaterials() + { + for (int i = 0; i < pendingMaterials.Count; i++) + { + PendingMaterial pm = pendingMaterials[i]; - EntryType subType; - while ((subType = (EntryType)reader.ReadInt()) != EntryType.MeshEnd) + var textureProps = new TextureProp[pm.Textures.Count]; + for (int j = 0; j < pm.Textures.Count; j++) { - switch (subType) + PendingTexture pt = pm.Textures[j]; + textureProps[j] = new TextureProp { - case EntryType.MeshVertexColors: - { - var colors = new Color32[size]; - reader.FillColor32Buffer(colors, size); - arrays.Colors = colors; - break; - } - case EntryType.MeshVerts: - { - var verts = new Vector3[size]; - reader.FillVector3Buffer(verts, size); - arrays.Vertices = verts; - break; - } - case EntryType.MeshUV: - { - var uv0 = new Vector2[size]; - reader.FillVector2Buffer(uv0, size); - arrays.Uv0 = uv0; - break; - } - case EntryType.MeshUV2: - { - var uv1 = new Vector2[size]; - reader.FillVector2Buffer(uv1, size); - arrays.Uv1 = uv1; - break; - } - case EntryType.MeshNormals: - { - var normals = new Vector3[size]; - reader.FillVector3Buffer(normals, size); - arrays.Normals = normals; - break; - } - case EntryType.MeshTangents: - { - var tangents = new Vector4[size]; - reader.FillVector4Buffer(tangents, size); - arrays.Tangents = tangents; - break; - } - case EntryType.MeshTriangles: - { - int triangleCount = reader.ReadInt(); - var tris = new int[triangleCount]; - reader.FillIntBuffer(tris, triangleCount); - triangles.Add(tris); // one submesh per MeshTriangles block, in encounter order - break; - } - case EntryType.MeshBoneWeights: - { - // Skinned seam: one BoneWeight per vertex (four weights + four bone indices). - // Stored now (was discarded before skin support) so MeshBlobBuilder emits the - // BlendWeights (ch12) and BlendIndices (ch13) vertex channels. Same read order and - // count as the oracle, so cursor parity is preserved. - var boneWeights = new BoneWeight[size]; - for (int i = 0; i < size; i++) - boneWeights[i] = reader.ReadBoneWeight(); - arrays.BoneWeights = boneWeights; - break; - } - case EntryType.MeshBindPoses: - { - // One bind pose per bone; its length defines the bone count. Stored now (was - // discarded before skin support) so the blob carries m_BindPose and its bone - // metadata. Same read order and count as the oracle, so cursor parity is preserved. - int bindPosesCount = reader.ReadInt(); - var bindPoses = new Matrix4x4[bindPosesCount]; - for (int i = 0; i < bindPosesCount; i++) - bindPoses[i] = reader.ReadMatrix4x4(); - arrays.BindPoses = bindPoses; - break; - } - } + Name = pt.Name, + Url = pt.Url, // null when unresolved (slot -1, guard failed, or no Textures block) + IsNormalMap = pt.IsNormalMap, + Scale = pt.Scale, + Offset = pt.Offset, + }; } - arrays.SubMeshTriangles = triangles.ToArray(); - - // Skinned mesh: attach the SMR's bone names (set by ReadSkinnedMeshRenderer just before this - // call) index-aligned with the bind poses just read, so BoneNames.Length == BindPoses.Length. - // FromArrays hashes them into m_BoneNameHashes. These are the .mu's LEAF bone names — exactly - // what ResolveBones/FindChildByName binds SkinnedMeshRenderer.bones by at replay. Unity - // natively hashes each bone's FULL transform path, so the emitted hash won't byte-match Unity's - // stored value; that is COSMETIC (binding is by name, and only the per-bone array COUNT is - // structurally required). Full-path reconstruction is a possible future refinement, only if an - // in-KSP issue ever implicates the hash value. - if (arrays.BindPoses != null) - arrays.BoneNames = ReconcileBoneNames(currentBoneNames, arrays.BindPoses.Length, canonicalName); - - MeshBlob blob = MeshBlobBuilder.FromArrays(canonicalName, in arrays, warnSink); - blobs.Add(blob); - - int meshSlot = nextSlot++; - bindings.Add(new MeshBinding(meshSlot, canonicalName)); - return meshSlot; - } - - // ---- Finalize (two-pass materials/textures, then bones) ------------------------------------ - - /// - /// Emits the deferred material work. MuParser reads the Materials block BEFORE the Textures block, - /// so texture urls aren't known when a material is first parsed; we accumulate pending materials + - /// texture-slot references during the walk and resolve them here. For each material index in - /// ASCENDING order we emit then its — that - /// ordering preserves MuParser's LAST-WINS singular-sharedMaterial semantics (a renderer listed - /// under several material indices ends up with the highest one). - /// - private void FinalizeMaterials() - { - for (int i = 0; i < pendingMaterials.Count; i++) + instructions.Add(new CreateMaterial { - PendingMaterial pm = pendingMaterials[i]; - - var textureProps = new TextureProp[pm.Textures.Count]; - for (int j = 0; j < pm.Textures.Count; j++) - { - PendingTexture pt = pm.Textures[j]; - textureProps[j] = new TextureProp - { - Name = pt.Name, - Url = pt.Url, // null when unresolved (slot -1, guard failed, or no Textures block) - IsNormalMap = pt.IsNormalMap, - Scale = pt.Scale, - Offset = pt.Offset, - }; - } - - instructions.Add(new CreateMaterial - { - Dst = pm.Slot, - Shader = pm.Shader, - ValueProps = pm.Values.ToArray(), - TextureProps = textureProps, - Name = pm.Name, - }); - - // Renderers/emitters that referenced material index i. A material never referenced by any - // renderer (matRefs shorter than pendingMaterials, or a defined-but-unused material) simply - // gets empty fan-out lists — harmless, and it avoids reproducing a potential MuParser - // IndexOutOfRange when the Materials count exceeds the referenced count. - int[] rendererSlots = Array.Empty(); - int[] emitterSlots = Array.Empty(); - if (i < matRefs.Count) - { - MatRef mr = matRefs[i]; - rendererSlots = mr.Renderers.ToArray(); - emitterSlots = mr.Emitters.ToArray(); - } + Dst = pm.Slot, + Shader = pm.Shader, + ValueProps = pm.Values.ToArray(), + TextureProps = textureProps, + Name = pm.Name, + }); - instructions.Add(new AssignMaterial - { - MaterialSlot = pm.Slot, - RendererSlots = rendererSlots, - EmitterSlots = emitterSlots, - }); + // Renderers/emitters that referenced material index i. A material never referenced by any + // renderer (matRefs shorter than pendingMaterials, or a defined-but-unused material) simply + // gets empty fan-out lists — harmless. + int[] rendererSlots = Array.Empty(); + int[] emitterSlots = Array.Empty(); + if (i < matRefs.Count) + { + MatRef mr = matRefs[i]; + rendererSlots = mr.Renderers.ToArray(); + emitterSlots = mr.Emitters.ToArray(); } - } - /// Mirror of MuParser.AffectSkinnedMeshRenderersBones, which Parse runs last: - /// emits one per skinned renderer, resolving bone names from the model - /// root (slot 0). - private void FinalizeBones() - { - for (int i = 0; i < skinnedRenderers.Count; i++) + instructions.Add(new AssignMaterial { - SkinnedEntry se = skinnedRenderers[i]; - instructions.Add(new ResolveBones - { - SmrSlot = se.SmrSlot, - RootSlot = 0, // the root GameObject is always slot 0 - BoneNames = se.BoneNames, - }); - } + MaterialSlot = pm.Slot, + RendererSlots = rendererSlots, + EmitterSlots = emitterSlots, + }); } + } - /// - /// Returns a bone-name array whose length equals the mesh's bind-pose count — the count - /// requires to satisfy Unity's per-bone invariant - /// (m_BindPose / m_BoneNameHashes / m_BonesAABB all equal). In the normal - /// case the SkinnedMeshRenderer supplies exactly one bone name per bind pose and the same array is - /// returned unchanged (no allocation). A few stock/mod .mu files export a - /// SkinnedMeshRenderer whose bone-name list is shorter than (often empty relative to) the - /// mesh's bind poses; MuParser tolerates this (it simply sets a shorter — possibly empty — - /// SkinnedMeshRenderer.bones, so the mesh is not actually deformed), so rather than fail the - /// whole model we pad the mesh's (cosmetic, leaf-name) hash source with empty names to keep the - /// count invariant. Binding is unaffected: still emits - /// from the ORIGINAL SMR name list, so the runtime - /// SkinnedMeshRenderer.bones stays byte-for-byte MuParser-equivalent — only the mesh's - /// ignored hash values gain padding entries. - /// - private string[] ReconcileBoneNames(string[] smrBoneNames, int boneCount, string meshName) + /// Emits one per skinned renderer. + private void FinalizeBones() + { + for (int i = 0; i < skinnedRenderers.Count; i++) { - int have = smrBoneNames?.Length ?? 0; - if (have == boneCount) - return smrBoneNames ?? Array.Empty(); - - var names = new string[boneCount]; - int copy = Math.Min(have, boneCount); - for (int i = 0; i < copy; i++) - names[i] = smrBoneNames[i]; - for (int i = copy; i < boneCount; i++) - names[i] = string.Empty; - - logs.Add(new DeferredLog(LogType.Log, - $"[MuModelCompiler] Model '{fileUrl}' mesh '{meshName}': SkinnedMeshRenderer declares " + - $"{have} bone name(s) but the mesh has {boneCount} bind pose(s). This is an unusual but " + - "valid .mu; reconciling by padding the mesh's cosmetic bone-name/hash list to preserve " + - "Unity's per-bone count invariant. This is benign - bone binding is unaffected.")); - return names; + SkinnedEntry se = skinnedRenderers[i]; + instructions.Add(new ResolveBones + { + SmrSlot = se.SmrSlot, + RootSlot = 0, // the root GameObject is always slot 0 + BoneNames = se.BoneNames, + }); } + } + + /// + /// Returns a bone-name array whose length equals the mesh's bind-pose count. + /// If there aren't enough bones in the mu then it is padded with empty ones + /// until it matches. + /// + private string[] ReconcileBoneNames(string[] smrBoneNames, int boneCount, string meshName) + { + int have = smrBoneNames?.Length ?? 0; + if (have == boneCount) + return smrBoneNames ?? []; + + var names = new string[boneCount]; + int copy = Math.Min(have, boneCount); + for (int i = 0; i < copy; i++) + names[i] = smrBoneNames[i]; + for (int i = copy; i < boneCount; i++) + names[i] = string.Empty; + + return names; + } - // ---- Small helpers ------------------------------------------------------------------------- + // ---- Small helpers ------------------------------------------------------------------------- - private void EnsureMatRef(int index) - { - while (index >= matRefs.Count) - matRefs.Add(new MatRef()); - } + private void EnsureMatRef(int index) + { + while (index >= matRefs.Count) + matRefs.Add(new MatRef()); + } - private static void AddColor(PendingMaterial pm, string name, Color value) => - pm.Values.Add(new ValueProp { Name = name, Kind = ValueProp.KindColor, ColorVal = value }); + private static void AddColor(PendingMaterial pm, string name, Color value) => + pm.Values.Add(new ValueProp { Name = name, Kind = ValueProp.KindColor, ColorVal = value }); - private static void AddFloat(PendingMaterial pm, string name, float value) => - pm.Values.Add(new ValueProp { Name = name, Kind = ValueProp.KindFloat, FloatVal = value }); + private static void AddFloat(PendingMaterial pm, string name, float value) => + pm.Values.Add(new ValueProp { Name = name, Kind = ValueProp.KindFloat, FloatVal = value }); - private static void AddVector(PendingMaterial pm, string name, Vector4 value) => - pm.Values.Add(new ValueProp { Name = name, Kind = ValueProp.KindVector, VecVal = value }); + private static void AddVector(PendingMaterial pm, string name, Vector4 value) => + pm.Values.Add(new ValueProp { Name = name, Kind = ValueProp.KindVector, VecVal = value }); - // ---- Private accumulator types ------------------------------------------------------------- + // ---- Private accumulator types ------------------------------------------------------------- - /// A material parsed but not yet emitted (its texture urls resolve after the walk). - private sealed class PendingMaterial - { - public int Slot; - public string Name; - public ShaderRef Shader; - public readonly List Values = new List(); - public readonly List Textures = new List(); - } + /// A material parsed but not yet emitted (its texture urls resolve after the walk). + private sealed class PendingMaterial + { + public int Slot; + public string Name; + public ShaderRef Shader; + public readonly List Values = []; + public readonly List Textures = []; + } - /// A material texture property whose url is resolved later. A mutable CLASS (not the - /// struct) so the texture-slot table can hold references to it and fill - /// in the url when the Textures block is read. - private sealed class PendingTexture - { - public string Name; - public Vector2 Scale; - public Vector2 Offset; - public string Url; - public bool IsNormalMap; - } + /// A material texture property whose url is resolved later. + private sealed class PendingTexture + { + public string Name; + public Vector2 Scale; + public Vector2 Offset; + public string Url; + public bool IsNormalMap; + } - /// Renderer + emitter slots referencing one material index (mirrors MuParser's - /// MaterialDummy). - private sealed class MatRef - { - public readonly List Renderers = new List(); - public readonly List Emitters = new List(); - } + /// Renderer + emitter slots referencing one material index. + private sealed class MatRef + { + public readonly List Renderers = new List(); + public readonly List Emitters = new List(); + } - /// A skinned renderer awaiting bone resolution (mirrors MuParser's BonesDummy). - private struct SkinnedEntry - { - public int SmrSlot; - public string[] BoneNames; - } + /// A skinned renderer awaiting bone resolution. + private struct SkinnedEntry + { + public int SmrSlot; + public string[] BoneNames; } } diff --git a/KSPCommunityFixes/Performance/FastLoader.cs b/KSPCommunityFixes/Performance/FastLoader.cs index b680a19..e3f390b 100644 --- a/KSPCommunityFixes/Performance/FastLoader.cs +++ b/KSPCommunityFixes/Performance/FastLoader.cs @@ -1,5 +1,4 @@ // #define DEBUG_TEXTURE_CACHE -// #define DEBUG_MODEL_LOAD_ORDER using DDSHeaders; using Expansions; @@ -7,8 +6,6 @@ using KSP.Localization; using KSPAssets; using KSPAssets.Loaders; -using KSPCommunityFixes.Library.Buffers; -using KSPCommunityFixes.Library.Collections; using KSPCommunityFixes.Library.Model; using KSPCommunityFixes.Library.TextureBundle; using System; @@ -164,14 +161,6 @@ internal class KSPCFFastLoader : MonoBehaviour // This roughly limits the max frame time spent replaying models / loading their meshes. private const int MaxModelSpawnsPerFrame = 64; - // v1 tuning knob: cap on native mesh-bundle bytes resident at once. The pump waits before kicking off a - // group's LoadFromMemoryAsync while at least this many bytes are already resident, so the driver can - // Unload earlier groups first. Restores the old streaming loader's ~50 MB-capped bounded-memory - // behavior (regression guard vs loading every group's native copy up front). - private const long MaxResidentModelBundleBytes = 96L * 1024 * 1024; // 96 MB - - private const int ModelGroupQueueCapacity = 4; // groups the compile task may run ahead of the pump; bounds managed bundle-byte pressure (tuning knob) - private static Harmony persistentHarmony; private static string PersistentHarmonyID => typeof(KSPCFFastLoader).FullName; @@ -745,23 +734,8 @@ static IEnumerator FastAssetLoader(List configFileTypes) } } - // load all models yield return gdb.StartCoroutine(ModelDriverCoroutine(modelQueue, allModelFiles, modelAssets.Count, modelTask)); -#if DEBUG_MODEL_LOAD_ORDER - // Optional load-order dump for an old-vs-new diff (enable via the #define at the top of the file). - { - var sb = new System.Text.StringBuilder(1024); - sb.Append("[KSPCF:FastLoader] model load order (").Append(gdb.databaseModelFiles.Count).Append(" files):\n"); - for (int i = 0; i < gdb.databaseModelFiles.Count; i++) - sb.Append(gdb.databaseModelFiles[i].url).Append('\n'); - sb.Append("[KSPCF:FastLoader] modelsByDirectoryUrl first-wins (").Append(modelsByDirectoryUrl.Count).Append(" dirs):\n"); - foreach (var kvp in modelsByDirectoryUrl) - sb.Append(kvp.Key).Append(" -> ").Append(kvp.Value.IsNotNullOrDestroyed() ? kvp.Value.transform.name : "").Append('\n'); - Debug.Log(sb.ToString()); - } -#endif - QualitySettings.asyncUploadTimeSlice = 2; QualitySettings.asyncUploadBufferSize = 32; @@ -870,7 +844,7 @@ static IEnumerator AudioLoader(UrlFile urlFile) #region Asset loader reimplementation (texture/model loader) /// - /// Asset wrapper class, carrier for model files flowing through the background model pipeline + /// Wrapper around a model file to be loaded. /// private class RawAsset { @@ -1196,7 +1170,6 @@ private static ModelLoadRequest CompileOne(RawAsset asset, ThreadLocal> CompileModelGroups( List modelAssets) { @@ -1285,7 +1258,7 @@ private static async Task> CompileModelGroups( using var scope = s_pmCompileModelGroups.Auto(); - // MuModelCompiler has internal shared state, + // MuModelCompiler has internal shared state, so each thread gets its own instance. using var tl = new ThreadLocal(() => new MuModelCompiler()); return modelAssets @@ -1300,7 +1273,7 @@ private static async Task> CompileModelGroups( static readonly ProfilerMarker s_pmLoadModelBundle = new("KSPCF.LoadModelBundleAsync"); - // This task waits for the bundle builder task to complete, then kicks + // Loads each compiled group's mesh bundle and queues its models for the driver. private static IEnumerator PreloadModelBundles( Task> groupTask, BlockingCollection modelQueue) @@ -1404,7 +1377,7 @@ private static IEnumerator ModelDriverCoroutine( while (active.TryPeek(out ModelLoadRequest pending)) { if (pending.Status == ModelLoadRequest.State.Pending) - break; // head not done yet: WAIT, never reorder (load-order parity link #4) + break; active.Dequeue(); try @@ -1422,7 +1395,6 @@ private static IEnumerator ModelDriverCoroutine( gdb.progressFraction = (float)loadedAssetCount / totalAssetCount; gdb.progressTitle = $"Loading model asset {completed}/{totalModelCount}"; - // Done when the producers have finished and everything spawned has been drained. if (modelQueue.IsCompleted && active.Count == 0) break; @@ -1448,8 +1420,6 @@ private static IEnumerator ModelDriverCoroutine( } } - // Exception-wrapping driver for one request (clone of LoadTextureCoroutine): drives the inner - // per-Kind enumerator, mapping any thrown exception to a Failed status + message. private static IEnumerator LoadModelCoroutine(ModelLoadRequest req) { IEnumerator inner = req.ModelKind switch @@ -1493,9 +1463,6 @@ private static IEnumerator LoadModelCoroutine(ModelLoadRequest req) } } - // CompiledMu: load this model's meshes from the group bundle (per-name LoadAssetAsync, NOT - // LoadAllAssetsAsync), then replay the compiled instructions on the main thread. Textures/shaders are - // resolved inside Execute, which is why the driver only runs after all textures are registered. private static IEnumerator LoadCompiledModelCoroutine(ModelLoadRequest req) { CompiledModel cm = req.Compiled; @@ -1531,7 +1498,6 @@ private static IEnumerator LoadCompiledModelCoroutine(ModelLoadRequest req) req.Status = ModelLoadRequest.State.Ready; } - // Dae fallback: reload via the stock DAE loader (see the shared LoadDAE helper). private static IEnumerator LoadDaeModelCoroutine(ModelLoadRequest req) { GameObject go = LoadDAE(req.File); @@ -1550,25 +1516,18 @@ private static IEnumerator LoadDaeModelCoroutine(ModelLoadRequest req) req.Status = ModelLoadRequest.State.Ready; } - // Failed: hard failure. Message was already set in the fold (read or compile failure); keep it. private static IEnumerator LoadFailedModelCoroutine(ModelLoadRequest req) { req.Status = ModelLoadRequest.State.Failed; yield break; } - // Main-thread registration (clone of InsertReadyRequest; replicates RawAsset.LoadAndDisposeMainThread's - // model registration). Called ONLY from the driver's FIFO drain, so it walks modelAssets order. private static void InsertReadyModel(ModelLoadRequest req, HashSet loadedUrls) { + Debug.Log($"Load Model: {req.File.url}"); - // On the MAIN THREAD: emit the compiler's buffered diagnostics (KSP's log handler and the mod - // handlers chained onto Application.logMessageReceived are not thread-safe, so they could not be - // flushed off-thread). Null-safe: Dae has no Compiled. req.Compiled?.FlushLogs(); - Debug.Log($"Load Model: {req.File.url}"); - if (req.Status == ModelLoadRequest.State.Failed) { Debug.LogWarning($"LOAD FAILED: {req.File.url}: {req.FailureMessage}"); @@ -1577,8 +1536,7 @@ private static void InsertReadyModel(ModelLoadRequest req, HashSet loade return; } - // Built-before-check (like the texture dup path): a duplicate url means we already built the - // GameObject, so it must be destroyed. First-wins, matching stock FilesLoader. + // A duplicate url means the GameObject was already built, so it must be destroyed. First-wins. if (!loadedUrls.Add(req.File.url)) { Debug.LogWarning($"Duplicate model asset '{req.File.url}' with extension '{req.File.fileExtension}' won't be loaded"); @@ -1587,7 +1545,6 @@ private static void InsertReadyModel(ModelLoadRequest req, HashSet loade return; } - // Exact replication of RawAsset.LoadAndDisposeMainThread's model registration. GameObject model = req.Result; model.transform.name = req.File.url; model.transform.parent = Instance.transform; @@ -1605,8 +1562,6 @@ private static void InsertReadyModel(ModelLoadRequest req, HashSet loade KSPCFFastLoaderReport.modelsLoaded++; } - // Shared body of the (now-wrapped) RawAsset.LoadDAE, reused by the new Dae path. Reloads the file via - // the stock DAE loader and reproduces the node_collider fixup. private static GameObject LoadDAE(UrlFile file) { // given that this is a quite obsolete thing and that it's mess to reimplement, just call the stock @@ -1622,8 +1577,8 @@ private static GameObject LoadDAE(UrlFile file) { meshFilter.gameObject.AddComponent().sharedMesh = meshFilter.mesh; MeshRenderer component = meshFilter.gameObject.GetComponent(); - UnityEngine.Object.Destroy(meshFilter); - UnityEngine.Object.Destroy(component); + Destroy(meshFilter); + Destroy(component); } } }