Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 28 additions & 15 deletions src/NwcIO/Internal/Parsing/StreamClassifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,34 @@ public static NwcStreamKind Classify(LcRawStream stream)
return NwcStreamKind.Transforms;
}

// Mesh record IDs are arbitrary. Recognize a vertex buffer before the GUID heuristic.
if (data.Length >= 24 && ReadInt32(data, 4) is 94 or 95 && ReadInt32(data, 12) == 60)
{
var p1 = ReadInt32(data, 16);
var p2 = ReadInt32(data, 20);
var vertexCount = p2 > 0 && p2 % 3 == 0 ? p2 / 3 : p1;
if (vertexCount >= 3 && 24L + 12L * vertexCount <= data.Length)
{
return NwcStreamKind.Geometry;
}
}

// TOC: [int32 recCount][int32 chunkCount][chunkCount x int32 sizes][recCount x int32 lengths]
// Its size and chunk count can also satisfy the GUID-table heuristic.
if (data.Length >= 16)
{
var recCount = ReadInt32(data, 0);
var chunkCount = ReadInt32(data, 4);
if (recCount > 0 && recCount < 1_000_000 && chunkCount > 0 && chunkCount < 5000)
{
var expected = 8 + 4 * chunkCount + 4 * recCount;
if (data.Length == expected || data.Length == expected + 4)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Validate TOC contents before overriding a GUID-table match

The size formula alone also matches a GUID table with header [a=6, count=2]: both formats have 40 bytes. A synthetic container containing that header followed by GUIDs 00112233-4455-6677-8899-aabbccddeeff and ffeeddcc-bbaa-9988-7766-554433221100 reopens with two GUIDs on main/base, but zero on this PR because this branch labels it Toc. GuidTableParser.TryParse accepts that table; its first header word currently has no constrained value. Interpreting these GUID bytes as TOC entries produces negative lengths, so this example is not a valid TOC.

Please validate the catalog entries or use container context before preferring Toc over Guids, and add this regression case. This was reproduced synthetically; it did not occur in the two real models checked (their GUID header has a=1).

{
return NwcStreamKind.Toc;
}
}
}

// GUID table: [a][count] followed by exactly count 16-byte GUIDs.
if (data.Length >= 8 && (data.Length - 8) % 16 == 0)
{
Expand Down Expand Up @@ -87,21 +115,6 @@ public static NwcStreamKind Classify(LcRawStream stream)
return NwcStreamKind.Geometry;
}

// TOC: [int32 recCount][int32 chunkCount][chunkCount x int32 sizes][recCount x int32 lengths]
if (data.Length >= 16)
{
int recCount = ReadInt32(data, 0);
int chunkCount = ReadInt32(data, 4);
if (recCount > 0 && recCount < 1_000_000 && chunkCount > 0 && chunkCount < 5000)
{
int expected = 8 + 4 * chunkCount + 4 * recCount;
if (data.Length == expected || data.Length == expected + 4)
{
return NwcStreamKind.Toc;
}
}
}

// Saved viewpoints and cameras (starts with tag 517 + camera quaternion)
if (data.Length >= 120 && IsSavedViewsStream(data))
{
Expand Down
40 changes: 40 additions & 0 deletions tests/NwcIO.Tests/StreamClassifierTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Buffers.Binary;
using NwcIO.Internal.Container;
using NwcIO.Internal.Parsing;
using Xunit;

namespace NwcIO.Tests;

public class StreamClassifierTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
public void Classify_GuidShapedToc_ReturnsToc(int chunkCount)
{
var recordCount = 3 * chunkCount;
var data = new byte[8 + 4 * chunkCount + 4 * recordCount];
BinaryPrimitives.WriteInt32LittleEndian(data, recordCount);
BinaryPrimitives.WriteInt32LittleEndian(data.AsSpan(4), chunkCount);
for (var i = 0; i < chunkCount; i++)
{
BinaryPrimitives.WriteInt32LittleEndian(data.AsSpan(8 + 4 * i), 180);
}
for (var i = 0; i < recordCount; i++)
{
BinaryPrimitives.WriteInt32LittleEndian(data.AsSpan(8 + 4 * chunkCount + 4 * i), 60);
}

var stream = new LcRawStream
{
Index = chunkCount,
SectionIndex = 0,
FileOffset = 0,
CompressedLength = 0,
GapBefore = 0,
Data = data,
};

Assert.Equal(NwcStreamKind.Toc, StreamClassifier.Classify(stream));
}
}
39 changes: 39 additions & 0 deletions tests/NwcIO.Tests/WriterTests.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,50 @@
using NwcIO.Internal.Container;
using NwcIO.Internal.Parsing;
using NwcIO.Model;
using Xunit;

namespace NwcIO.Tests;

public class WriterTests
{
[Theory]
[InlineData(3, 3, false)]
[InlineData(3, 3, true)]
[InlineData(1, 124, false)]
[InlineData(1, 124, true)]
public void Document_GuidShapedGeometryAndToc_RoundtripPreservesGuids(int meshCount, int vertexCount, bool withGuids)
{
var meshes = Enumerable.Range(1, meshCount).Select(id => new NwcMesh
{
Id = id,
Vertices = Enumerable.Range(0, vertexCount).Select(v => new Vector3d(v, v % 2, id)).ToArray(),
Indices = [0, 1, 2],
}).ToArray();
var guids = withGuids
? new[] { Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"), Guid.Parse("ffeeddcc-bbaa-9988-7766-554433221100") }
: Array.Empty<Guid>();
var doc = NwcDocument.Create(meshes: meshes, guids: guids);
var bytes = doc.ToByteArray();

using var ms = new MemoryStream(bytes);
var reopened = NwcDocument.Open(ms);

Assert.Equal(guids, reopened.Guids);
Assert.Equal(meshCount, reopened.Meshes.Count);
for (var i = 0; i < meshCount; i++)
{
Assert.Equal(meshes[i].Vertices, reopened.Meshes[i].Vertices);
}

var container = NwcContainerReader.Read(bytes);
StreamClassifier.ClassifyAll(container.Streams);
var toc = Assert.Single(container.Streams, s => s.Kind == NwcStreamKind.Toc);
var geometry = Assert.Single(container.Streams, s => s.Kind == NwcStreamKind.Geometry);
Assert.Equal(NwcStreamKind.Toc, StreamClassifier.Classify(toc));
Assert.Equal(NwcStreamKind.Geometry, StreamClassifier.Classify(geometry));
Assert.Equal(withGuids ? 1 : 0, container.Streams.Count(s => s.Kind == NwcStreamKind.Guids));
}

[Fact]
public void ContainerWriter_Roundtrip_RestoresExactStreamBuffers()
{
Expand Down
Loading