diff --git a/CgfConverter/CryEngine/CryEngine.cs b/CgfConverter/CryEngine/CryEngine.cs index 0a8c616d..c0e11f01 100644 --- a/CgfConverter/CryEngine/CryEngine.cs +++ b/CgfConverter/CryEngine/CryEngine.cs @@ -118,8 +118,14 @@ private void BuildNodeStructure() // Create node chunks from the first model. If there is a nodemeshcombo chunk, use // that for the nodes. If not (skin and chr files), create a dummy root node. // Can be zero or multiple nodes, but all reference the same geometry. + if (Models.Count == 0) + { + Log.W("No models loaded - cannot build node structure"); + return; + } + bool hasValidNodeMeshCombo = false; - var comboChunk = (ChunkNodeMeshCombo?)Models[index: 0].ChunkMap.Values.FirstOrDefault(c => c.ChunkType == ChunkType.NodeMeshCombo); + var comboChunk = (ChunkNodeMeshCombo?)Models[0].ChunkMap.Values.FirstOrDefault(c => c.ChunkType == ChunkType.NodeMeshCombo); if (comboChunk is not null && comboChunk.NumberOfNodes != 0) hasValidNodeMeshCombo = true; @@ -127,43 +133,82 @@ private void BuildNodeStructure() if (hasValidNodeMeshCombo) { // SkinMesh has the mesh and meshsubset info, as well as all the datastreams - var skinMesh = Models.Count > 1 - ? Models[1].ChunkMap.Values.FirstOrDefault(x => x.ChunkType == ChunkType.IvoSkin || x.ChunkType == ChunkType.IvoSkin2) as ChunkIvoSkinMesh - : null; + // First try to find IvoSkin in the second model (companion .cgam file) + // If not found, also check the first model in case of combined files + ChunkIvoSkinMesh? skinMesh = null; + if (Models.Count > 1) + { + skinMesh = Models[1].ChunkMap.Values.FirstOrDefault(x => x.ChunkType == ChunkType.IvoSkin || x.ChunkType == ChunkType.IvoSkin2) as ChunkIvoSkinMesh; + } + if (skinMesh is null) + { + skinMesh = Models[0].ChunkMap.Values.FirstOrDefault(x => x.ChunkType == ChunkType.IvoSkin || x.ChunkType == ChunkType.IvoSkin2) as ChunkIvoSkinMesh; + } var geometryMeshDetails = skinMesh?.MeshDetails; var stringTable = comboChunk?.NodeNames ?? []; var materialTable = comboChunk?.MaterialIndices ?? []; - var materialFileName = Materials.Keys.First(); + var materialFileName = Materials.Keys.FirstOrDefault() ?? "default"; + + // Check if stringTable has correct number of entries + if (stringTable.Count < comboChunk.NumberOfNodes) + { + Log.W($"NodeMeshCombo has {comboChunk.NumberOfNodes} nodes but only {stringTable.Count} names in string table"); + } // create node chunks - foreach (var node in comboChunk.NodeMeshCombos) + if (comboChunk.NodeMeshCombos is null || comboChunk.NodeMeshCombos.Count == 0) { - var index = comboChunk.NodeMeshCombos.IndexOf(node); + Log.W("NodeMeshCombo has no node data"); + return; + } - // Create meshsubsets for this node. This is all meshSubsets where the meshParent equals the node index - var subsets = skinMesh?.MeshSubsets.Where(x => x.NodeParentIndex == index).ToList() ?? []; + // Pre-group mesh subsets by NodeParentIndex to avoid O(n*m) lookups in the loop + var subsetsByNodeIndex = new Dictionary>(); + if (skinMesh?.MeshSubsets != null) + { + foreach (var subset in skinMesh.MeshSubsets) + { + int nodeIndex = (int)(subset.NodeParentIndex ?? 0); + if (!subsetsByNodeIndex.TryGetValue(nodeIndex, out var list)) + { + list = new List(); + subsetsByNodeIndex[nodeIndex] = list; + } + list.Add(subset); + } + } + + for (int index = 0; index < comboChunk.NodeMeshCombos.Count; index++) + { + var node = comboChunk.NodeMeshCombos[index]; + + // Get meshsubsets for this node from pre-grouped dictionary (O(1) lookup) + var subsets = subsetsByNodeIndex.TryGetValue(index, out var nodeSubsets) ? nodeSubsets : []; ChunkMesh chunkMesh = new ChunkMesh_802(); - var hasGeometry = subsets.Count != 0; + var hasGeometry = subsets.Count != 0 && geometryMeshDetails is not null && skinMesh is not null; // Create a meshchunk for nodes with geometry if (hasGeometry) { - chunkMesh.ScalingVectors = geometryMeshDetails.ScalingBoundingBox; + chunkMesh.ScalingVectors = geometryMeshDetails!.ScalingBoundingBox; chunkMesh.MaxBound = node.BoundingBoxMax; chunkMesh.MinBound = node.BoundingBoxMin; - chunkMesh.NumVertices = (int)skinMesh.MeshDetails.NumberOfVertices; + chunkMesh.NumVertices = (int)skinMesh!.MeshDetails.NumberOfVertices; chunkMesh.NumIndices = (int)skinMesh.MeshDetails.NumberOfIndices; chunkMesh.NumVertSubsets = skinMesh.MeshDetails.NumberOfSubmeshes; chunkMesh.GeometryInfo = BuildNodeGeometryInfo(skinMesh, subsets); } + // Use node name from string table if available, otherwise generate a name + var nodeName = index < stringTable.Count ? stringTable[index] : $"node_{index}"; + var newNode = new ChunkNode_823 { - Name = stringTable[index], + Name = nodeName, ObjectNodeID = -1, ParentNodeIndex = node.ParentIndex, ParentNodeID = node.ParentIndex == 0xffff ? -1 : node.ParentIndex, @@ -177,26 +222,29 @@ private void BuildNodeStructure() MaterialFileName = materialFileName }; - if (hasGeometry) + if (hasGeometry && Materials.Count > 0) newNode.Materials = Materials.Values.First(); Nodes.Add(newNode); } // build node hierarchy - foreach (var node in Nodes) + for (int index = 0; index < Nodes.Count; index++) { - var index = Nodes.IndexOf(node); - if (node.ParentNodeIndex != 0xFFFF) + var node = Nodes[index]; + if (node.ParentNodeIndex != 0xFFFF && node.ParentNodeIndex >= 0 && node.ParentNodeIndex < Nodes.Count) node.ParentNode = Nodes[node.ParentNodeIndex]; - else + else if (node.ParentNodeIndex == 0xFFFF || node.ParentNodeIndex == -1) RootNode = node; // hopefully there is just one + else + Log.W($"Node {node.Name} has invalid parent index {node.ParentNodeIndex}"); // Add all child nodes to Children. A child is where the parent index is current index - var childNodes = Nodes.Where(x => x.ParentNodeIndex == index); - foreach (var child in childNodes) + // Optimize: iterate once instead of using LINQ Where which creates intermediate collections + foreach (var childNode in Nodes) { - node.Children.Add(child); + if (childNode.ParentNodeIndex == index) + node.Children.Add(childNode); } } } @@ -223,13 +271,29 @@ private void BuildNodeStructure() } else // Traditional Crydata. Build geometry info from the models. { + // Pre-group children by parent ID to avoid O(n²) lookups + var childrenByParentId = new Dictionary>(); + foreach (var childNode in Models[0].NodeMap.Values) + { + if (childNode.ParentNodeID != -1 && childNode.ParentNodeID != ~0) + { + if (!childrenByParentId.TryGetValue(childNode.ParentNodeID, out var children)) + { + children = new List(); + childrenByParentId[childNode.ParentNodeID] = children; + } + children.Add(childNode); + } + } + // Separate datastream for each node. // For each ChunkNode in model[0], add it to the Nodes list. foreach (var node in Models[0].NodeMap.Values) { // Add helper or mesh data to the node var objectChunk = Models[0].ChunkMap[node.ObjectNodeID]; - node.Children = Models[0].NodeMap.Values.Where(x => x.ParentNodeID == node.ID).ToList(); + // Get children from pre-grouped dictionary (O(1) lookup) + node.Children = childrenByParentId.TryGetValue(node.ID, out var nodeChildren) ? nodeChildren : new List(); if (objectChunk is ChunkHelper helper) { @@ -1282,7 +1346,7 @@ private void AssignMaterialsToNodes(bool mtlFilesProvided = true) return result; } - public bool IsIvoFile => Models.First().FileSignature?.Equals("#ivo") ?? false; + public bool IsIvoFile => Models.Count > 0 && (Models[0].FileSignature?.Equals("#ivo") ?? false); public static bool SupportsFile(string name) => validExtensions.Contains(Path.GetExtension(name).ToLowerInvariant()); diff --git a/CgfConverter/CryEngineCore/Chunks/Chunk.cs b/CgfConverter/CryEngineCore/Chunks/Chunk.cs index c83e26aa..56e08544 100644 --- a/CgfConverter/CryEngineCore/Chunks/Chunk.cs +++ b/CgfConverter/CryEngineCore/Chunks/Chunk.cs @@ -81,6 +81,13 @@ public static Chunk New(ChunkType chunkType, uint version) ChunkType.IvoAnimInfo => Chunk.New(version), ChunkType.IvoDBAData => Chunk.New(version), ChunkType.IvoDBAMetadata => Chunk.New(version), + // Star Citizen 4.5+ new chunk types - safely skipped (metadata/LOD data not needed for rendering) + ChunkType.IvoAssetMetadata => new ChunkUnknown(), // Asset GUIDs - skip + ChunkType.IvoLodDistances => new ChunkUnknown(), // LOD thresholds - skip (we export LOD0) + ChunkType.IvoLodMeshData => new ChunkUnknown(), // LOD1-4 meshes - skip (we export LOD0) + ChunkType.IvoBoundingData => new ChunkUnknown(), // Bounding data - skip + ChunkType.IvoChunkTerminator => new ChunkUnknown(), // EOF marker - skip + ChunkType.IvoMtlNameVariant => new ChunkUnknown(), // Material variant - skip (rare) _ => new ChunkUnknown(), }; } diff --git a/CgfConverter/CryEngineCore/Chunks/ChunkIvoSkinMesh_900.cs b/CgfConverter/CryEngineCore/Chunks/ChunkIvoSkinMesh_900.cs index b75f5238..4dae6056 100644 --- a/CgfConverter/CryEngineCore/Chunks/ChunkIvoSkinMesh_900.cs +++ b/CgfConverter/CryEngineCore/Chunks/ChunkIvoSkinMesh_900.cs @@ -27,7 +27,9 @@ public override void Read(BinaryReader b) } bool hasReadIndex = false; - while (b.BaseStream.Position != b.BaseStream.Length) // Read to end. + // Calculate chunk end position. Use Size if available, otherwise read to end of file. + long chunkEndPosition = Size > 0 ? Offset + Size : b.BaseStream.Length; + while (b.BaseStream.Position < chunkEndPosition) // Read to end of chunk. { var datastreamType = b.ReadUInt32(); var ivoDataStreamType = (DatastreamType)datastreamType; @@ -347,8 +349,12 @@ public override void Read(BinaryReader b) b.AlignTo(8); break; default: - HelperMethods.Log(LogLevelEnum.Warning, $"***** Unknown DataStream Type 0x{(int)ivoDataStreamType:X8} *****"); - b.BaseStream.Position = b.BaseStream.Position + 4; + // Unknown datastream type - skip the 4 bytes we just read (the type) and continue + // The chunk size-based loop will handle reaching the end of the chunk + HelperMethods.Log(LogLevelEnum.Warning, $"***** Unknown DataStream Type 0x{datastreamType:X8} at position 0x{b.BaseStream.Position - 4:X} - skipping and continuing *****"); + // Position is already advanced by ReadUInt32(), so we just continue + // Align to 8 bytes in case the next datastream expects alignment + b.AlignTo(8); break; } } diff --git a/CgfConverter/CryEngineCore/Chunks/ChunkMtlName_900.cs b/CgfConverter/CryEngineCore/Chunks/ChunkMtlName_900.cs index f9345168..aacec450 100644 --- a/CgfConverter/CryEngineCore/Chunks/ChunkMtlName_900.cs +++ b/CgfConverter/CryEngineCore/Chunks/ChunkMtlName_900.cs @@ -5,10 +5,34 @@ namespace CgfConverter.CryEngineCore; internal sealed class ChunkMtlName_900 : ChunkMtlName { + /// + /// Material indices used by this mesh (references into SubMaterials array). + /// These are the MatIDs that geometry subsets reference. + /// + public ushort[] MaterialIndices { get; private set; } = []; + public override void Read(BinaryReader b) { base.Read(b); + + // 128 bytes: Material file path (null-terminated, padded) Name = b.ReadFString(128); - NumChildren = 0; + + // 4 bytes: Number of material indices + NumChildren = b.ReadUInt32(); + + // 32 bytes: Reserved/padding (all zeros) + SkipBytes(b, 32); + + // NumChildren * 2 bytes: Material indices (uint16 each) + // These are indices into the SubMaterials array of the referenced material file + MaterialIndices = new ushort[NumChildren]; + for (int i = 0; i < NumChildren; i++) + { + MaterialIndices[i] = b.ReadUInt16(); + } + + // Set MatType based on whether we have children + MatType = NumChildren == 0 ? MtlNameType.Single : MtlNameType.Library; } } diff --git a/CgfConverter/CryEngineCore/Chunks/ChunkNodeMeshCombo_900.cs b/CgfConverter/CryEngineCore/Chunks/ChunkNodeMeshCombo_900.cs index b91ee4f8..b62f90dc 100644 --- a/CgfConverter/CryEngineCore/Chunks/ChunkNodeMeshCombo_900.cs +++ b/CgfConverter/CryEngineCore/Chunks/ChunkNodeMeshCombo_900.cs @@ -19,6 +19,9 @@ public override void Read(BinaryReader b) Unknown1 = b.ReadInt32(); // related to number of nodes Unknown3 = b.ReadInt32(); // 0 if no mesh chunk for this node + // SC 4.5+ has 32 bytes of padding after the header + SkipBytes(b, 32); + NodeMeshCombos = []; for (int i = 0; i < NumberOfNodes; i++) { diff --git a/CgfConverter/CryEngineCore/Model.cs b/CgfConverter/CryEngineCore/Model.cs index 841578de..d4575f5b 100644 --- a/CgfConverter/CryEngineCore/Model.cs +++ b/CgfConverter/CryEngineCore/Model.cs @@ -189,11 +189,14 @@ private void ReadChunkTable(BinaryReader b) chunkHeaders.Add(header); } - // Set sizes for versions that don't have sizes - for (int i = 0; i < NumChunks; i++) + // Set sizes for versions that don't have sizes (x0744 and x0900 chunk headers don't include size) + // Chunks are stored in offset order in the chunk table, so we can calculate sizes directly + if (FileVersion == FileVersion.x0744 || FileVersion == FileVersion.x0900) { - if (FileVersion == FileVersion.x0744 && i < NumChunks - 2) - chunkHeaders[i].Size = chunkHeaders[i + 1].Offset - chunkHeaders[i].Offset; + for (int i = 0; i < chunkHeaders.Count - 1; i++) + { + chunkHeaders[i].Size = (uint)(chunkHeaders[i + 1].Offset - chunkHeaders[i].Offset); + } } } diff --git a/CgfConverter/Enums/Enums.cs b/CgfConverter/Enums/Enums.cs index d10a960e..91048608 100644 --- a/CgfConverter/Enums/Enums.cs +++ b/CgfConverter/Enums/Enums.cs @@ -115,6 +115,15 @@ public enum ChunkType : uint // complete IvoDBAData = 0x194FBC50, // #dba animation data blocks (was SpeedInfoSC) IvoDBAMetadata = 0xF7351608, // DBA metadata/string table + // Star Citizen 4.5+ new chunk types (introduced Jan 2026) + // Analysis: These chunks can be safely skipped - they're metadata or LOD data + IvoAssetMetadata = 0xBE5E493E, // SC 4.5 - Asset metadata: counts + 2x 128-bit GUIDs (128 bytes) + IvoLodDistances = 0x9351756F, // SC 4.5 - LOD distance thresholds: count + float array + IvoLodMeshData = 0x58DE1772, // SC 4.5 - LOD mesh data for LOD1-4 (can be very large ~50MB+) + IvoBoundingData = 0x2B7ECF9F, // SC 4.5 - Bounding/animation data (floats + sparse data) + IvoChunkTerminator = 0xE0181074, // SC 4.5 - End-of-file marker in .cgam files (0 bytes) + IvoMtlNameVariant = 0x83353533, // SC 4.5 - Material name variant (rare, similar to MtlNameIvo320) + BinaryXmlDataSC = 0xcccbf004, } diff --git a/CgfConverter/Renderers/Collada/ColladaModelRenderer.Geometry.cs b/CgfConverter/Renderers/Collada/ColladaModelRenderer.Geometry.cs index ee42f780..27593ddb 100644 --- a/CgfConverter/Renderers/Collada/ColladaModelRenderer.Geometry.cs +++ b/CgfConverter/Renderers/Collada/ColladaModelRenderer.Geometry.cs @@ -237,10 +237,11 @@ public void WriteGeometries() for (int j = 0; j < numberOfMeshSubsets; j++) // Need to make a new Triangles entry for each submesh. { + var submatName = GetSafeSubmaterialName(nodeChunk, subsets[j].MatID); triangles[j] = new ColladaTriangles { Count = subsets[j].NumIndices / 3, - Material = GetMaterialName(nodeChunk.MaterialFileName, nodeChunk.Materials.SubMaterials[subsets[j].MatID].Name) + "-material" + Material = GetMaterialName(nodeChunk.MaterialFileName, submatName) + "-material" }; // Create the inputs. vertex, normal, texcoord, color diff --git a/CgfConverter/Renderers/Collada/ColladaModelRenderer.Materials.cs b/CgfConverter/Renderers/Collada/ColladaModelRenderer.Materials.cs index 6ab4d8d5..ff3997bd 100644 --- a/CgfConverter/Renderers/Collada/ColladaModelRenderer.Materials.cs +++ b/CgfConverter/Renderers/Collada/ColladaModelRenderer.Materials.cs @@ -1,4 +1,5 @@ using CgfConverter.Collada; +using CgfConverter.CryEngineCore; using CgfConverter.Models.Materials; using CgfConverter.Renderers.Collada.Collada.Collada_B_Rep.Surfaces; using CgfConverter.Renderers.Collada.Collada.Collada_Core.Extensibility; @@ -321,4 +322,31 @@ private string GetMaterialName(string matKey, string submatName) return $"{matfileName}_mtl_{submatName}".Replace(' ', '_'); } + + /// + /// Safely gets the submaterial name from a node's materials, with fallback for null or missing materials. + /// + private string GetSafeSubmaterialName(ChunkNode node, int matId) + { + if (node.Materials?.SubMaterials is null) + { + Log.W($"Node '{node.Name}' has no materials assigned, using default material name for MatID {matId}"); + return $"default_mat_{matId}"; + } + + if (matId < 0 || matId >= node.Materials.SubMaterials.Length) + { + Log.W($"Node '{node.Name}' has MatID {matId} out of bounds (SubMaterials count: {node.Materials.SubMaterials.Length}), using default material name"); + return $"default_mat_{matId}"; + } + + var submat = node.Materials.SubMaterials[matId]; + if (submat is null) + { + Log.W($"Node '{node.Name}' has null submaterial at MatID {matId}, using default material name"); + return $"default_mat_{matId}"; + } + + return submat.Name ?? $"unnamed_mat_{matId}"; + } } diff --git a/CgfConverter/Renderers/Collada/ColladaModelRenderer.Nodes.cs b/CgfConverter/Renderers/Collada/ColladaModelRenderer.Nodes.cs index 96d2dd96..99118444 100644 --- a/CgfConverter/Renderers/Collada/ColladaModelRenderer.Nodes.cs +++ b/CgfConverter/Renderers/Collada/ColladaModelRenderer.Nodes.cs @@ -107,7 +107,8 @@ private ColladaInstanceMaterialGeometry[] CreateInstanceMaterials(ChunkNode node foreach (var index in matIndices) { - var matName = GetMaterialName(node.MaterialFileName, node.Materials.SubMaterials[index].Name); + var submatName = GetSafeSubmaterialName(node, index); + var matName = GetMaterialName(node.MaterialFileName, submatName); ColladaInstanceMaterialGeometry instanceMaterial = new(); instanceMaterial.Target = $"#{matName}-material"; instanceMaterial.Symbol = $"{matName}-material";