Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
531dc65
Add CAF animation file support for USD export
Markemp Nov 30, 2025
a632224
Fix CAF controller chunk parsing and quaternion decompression
Markemp Nov 30, 2025
cffe33b
Add test
Markemp Nov 30, 2025
f235d60
829 controller caf animation (WIP)
Markemp Nov 30, 2025
abc80fd
Add Star Citizen #ivo CAF animation chunk support
Markemp Dec 2, 2025
1a8f4b8
Fix CAF animation time key normalization
Markemp Dec 2, 2025
a1fb0c6
Add bounds checking for material and normals array access in USD rend…
Markemp Dec 2, 2025
fae601a
Document USD skinning bone mapping issue for Star Citizen .skin files
Markemp Dec 2, 2025
9ffffe3
Fix USD skinning bone-to-vertex mapping for Star Citizen .skin files
Markemp Dec 2, 2025
f35e4a9
Fix material ID lookup for Ivo nodes with more nodes than mesh subsets
Markemp Dec 2, 2025
3c16f76
Fix duplicate material prims in USD export
Markemp Dec 2, 2025
f80e6a7
Add Normalmap texture type mapping
Markemp Dec 3, 2025
fee6495
Add manual tests
Markemp Dec 3, 2025
22484c3
Add null check for SubMaterials in USD material creation
Markemp Dec 3, 2025
8ea740c
Fix duplicate GeomSubset prims in USD export
Markemp Dec 3, 2025
3954ec6
Move ManualRenderTests to separate ManualTests namespace
Markemp Dec 4, 2025
c4e5213
Add ChunkIvoDBAMetadata version 901 support
Markemp Dec 4, 2025
8685622
Add ChunkIvoDBAMetadata version 901 support with debug logging
Markemp Dec 4, 2025
3c5bcc1
Fix glTF coordinate system to match USD/Blender orientation
Markemp Dec 6, 2025
3581fd0
Add additive animation support for CAF and DBA exports
Markemp Dec 6, 2025
2785fae
Update claude
Markemp Dec 6, 2025
397fc55
Fix 0x801 compiled bones matrix interpretation for ArcheAge models
Markemp Dec 6, 2025
770bc0f
Fix USD animation rest pose translation extraction
Markemp Dec 7, 2025
ce52b39
Mark ChunkController_829 as vetted
Markemp Dec 7, 2025
efceb6e
Add USD integration tests for Armored Warfare chicken
Markemp Dec 7, 2025
d1b69ec
Mark ArcheAge animations as vetted in DEVNOTES
Markemp Dec 7, 2025
7582a02
Add DBA in-place streaming mode support for KCD2 animations
Markemp Dec 8, 2025
3199b27
Process .cal animation definition files
Markemp Dec 8, 2025
4dc9b8e
Update documentation with animation support details
Markemp Dec 8, 2025
6460b30
Add CAF animation support and fixes to glTF renderer
Markemp Dec 8, 2025
62a9eff
Gltf updates (WIP)
Markemp Dec 8, 2025
2b8d83a
Add simple bone map datastream support (0x9D51C5EE)
Markemp Dec 15, 2025
e416b04
Animation work (WIP)
Markemp Dec 17, 2025
a2dbcce
Fix UTF-8 decoding error when reading bone names (#226)
Markemp Dec 19, 2025
2a002ff
Refactor Ivo animations (WIP)
Markemp Dec 19, 2025
6c82fe3
Collada animation improvements and separate file export
Markemp Dec 19, 2025
08945cd
Add devnotes to sln. Update integration tests
Markemp Dec 19, 2025
4bafc68
Add turret gltf test
Markemp Dec 19, 2025
42a0885
Fix Ivo format skeletal animation and bone mapping
Markemp Dec 20, 2025
ac3401f
glTF: Attach meshes to skeleton nodes for Ivo format
Markemp Dec 20, 2025
52cb0bc
glTF: Fix Ivo format detection and improve hierarchy
Markemp Dec 20, 2025
667bc2f
Add Ivo DBA animation support to USD renderer
Markemp Dec 20, 2025
3d59ecc
USD: Fix double transform for Ivo format skinned meshes
Markemp Dec 20, 2025
54c68a2
Fix USD mesh placement for skeletal animation
Markemp Dec 20, 2025
3d60d30
Add ivo animations to gltf exports (WIP)
Markemp Dec 20, 2025
ad09a92
Normals work (WIP)
Markemp Dec 21, 2025
c96be46
Fix Ivo DBA animation parsing and position handling
Markemp Dec 25, 2025
b590108
Animation work for SC (WIP)
Markemp Dec 27, 2025
a082c1d
Add motion params 925 chunk. cleanup.
Markemp Dec 30, 2025
a6a68c1
Rename SC 4.5 unknown chunk types with descriptive names
Frozandero Jan 5, 2026
5d1852c
more changes
Frozandero Jan 5, 2026
3fff51c
improve speed hopefully
Frozandero Jan 5, 2026
997d61a
remove defensive sorting
Frozandero Jan 5, 2026
1fe0a75
IvoMtlNameVariant
Frozandero Jan 9, 2026
a8fb4d3
Merge branch 'release/v2.0' into feature/sc-4.5-chunk-type-names
Frozandero Jan 11, 2026
57da8da
Revert "IvoMtlNameVariant"
Frozandero Jan 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 87 additions & 23 deletions CgfConverter/CryEngine/CryEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,52 +118,97 @@ 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;

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<int, List<MeshSubset>>();
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<MeshSubset>();
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,
Expand All @@ -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);
}
}
}
Expand All @@ -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<int, List<ChunkNode>>();
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<ChunkNode>();
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<ChunkNode>();

if (objectChunk is ChunkHelper helper)
{
Expand Down Expand Up @@ -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());

Expand Down
7 changes: 7 additions & 0 deletions CgfConverter/CryEngineCore/Chunks/Chunk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ public static Chunk New(ChunkType chunkType, uint version)
ChunkType.IvoAnimInfo => Chunk.New<ChunkIvoAnimInfo>(version),
ChunkType.IvoDBAData => Chunk.New<ChunkIvoDBAData>(version),
ChunkType.IvoDBAMetadata => Chunk.New<ChunkIvoDBAMetadata>(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(),
};
}
Expand Down
12 changes: 9 additions & 3 deletions CgfConverter/CryEngineCore/Chunks/ChunkIvoSkinMesh_900.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Expand Down
26 changes: 25 additions & 1 deletion CgfConverter/CryEngineCore/Chunks/ChunkMtlName_900.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,34 @@ namespace CgfConverter.CryEngineCore;

internal sealed class ChunkMtlName_900 : ChunkMtlName
{
/// <summary>
/// Material indices used by this mesh (references into SubMaterials array).
/// These are the MatIDs that geometry subsets reference.
/// </summary>
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;
}
}
3 changes: 3 additions & 0 deletions CgfConverter/CryEngineCore/Chunks/ChunkNodeMeshCombo_900.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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++)
{
Expand Down
11 changes: 7 additions & 4 deletions CgfConverter/CryEngineCore/Model.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down
9 changes: 9 additions & 0 deletions CgfConverter/Enums/Enums.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions CgfConverter/Renderers/Collada/ColladaModelRenderer.Materials.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -321,4 +322,31 @@ private string GetMaterialName(string matKey, string submatName)

return $"{matfileName}_mtl_{submatName}".Replace(' ', '_');
}

/// <summary>
/// Safely gets the submaterial name from a node's materials, with fallback for null or missing materials.
/// </summary>
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}";
}
}
3 changes: 2 additions & 1 deletion CgfConverter/Renderers/Collada/ColladaModelRenderer.Nodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down