diff --git a/ModVerify.slnx b/ModVerify.slnx
index 917a44fd..c1bd00df 100644
--- a/ModVerify.slnx
+++ b/ModVerify.slnx
@@ -21,7 +21,12 @@
+
+
+
+
+
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Metadata/ChunkType.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/AloChunkType.cs
similarity index 90%
rename from src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Metadata/ChunkType.cs
rename to src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/AloChunkType.cs
index 70af55dd..2a88e557 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Metadata/ChunkType.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/AloChunkType.cs
@@ -1,6 +1,6 @@
-namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Metadata;
+namespace PG.StarWarsGame.Files.ALO.Binary;
-public enum ChunkType
+public enum AloChunkType
{
Unknown,
Name = 0x0,
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Identifier/AloContentInfoIdentifier.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Identifier/AloContentInfoIdentifier.cs
index 6fce5c07..e2da2859 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Identifier/AloContentInfoIdentifier.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Identifier/AloContentInfoIdentifier.cs
@@ -1,7 +1,7 @@
using System.IO;
using PG.StarWarsGame.Files.ALO.Files;
using PG.StarWarsGame.Files.Binary;
-using PG.StarWarsGame.Files.ChunkFiles.Binary.Metadata;
+using PG.StarWarsGame.Files.ChunkFiles.Binary;
using PG.StarWarsGame.Files.ChunkFiles.Binary.Reader;
namespace PG.StarWarsGame.Files.ALO.Binary.Identifier;
@@ -14,19 +14,19 @@ public AloContentInfo GetContentInfo(Stream stream)
var chunk = chunkReader.ReadChunk();
- switch ((ChunkType)chunk.Type)
+ switch ((AloChunkType)chunk.Type)
{
- case ChunkType.Skeleton:
- case ChunkType.Mesh:
- case ChunkType.Light:
- return FromModel(chunk.Size, chunkReader);
- case ChunkType.Connections:
+ case AloChunkType.Skeleton:
+ case AloChunkType.Mesh:
+ case AloChunkType.Light:
+ return FromModel(chunk.BodySize, chunkReader);
+ case AloChunkType.Connections:
return FromConnection(chunkReader);
- case ChunkType.Particle:
+ case AloChunkType.Particle:
return new AloContentInfo(AloType.Particle, AloVersion.V1);
- case ChunkType.ParticleUaW:
+ case AloChunkType.ParticleUaW:
return new AloContentInfo(AloType.Particle, AloVersion.V2);
- case ChunkType.Animation:
+ case AloChunkType.Animation:
return FromAnimation(chunkReader);
default:
throw new BinaryCorruptedException("Unable to get ALO content information.");
@@ -38,17 +38,17 @@ private static AloContentInfo FromAnimation(ChunkReader chunkReader)
var chunk = chunkReader.TryReadChunk();
while (chunk.HasValue)
{
- switch ((ChunkType)chunk.Value.Type)
+ switch ((AloChunkType)chunk.Value.Type)
{
- case ChunkType.AnimationInformation:
- return chunk.Value.Size switch
+ case AloChunkType.AnimationInformation:
+ return chunk.Value.BodySize switch
{
36 => new AloContentInfo(AloType.Animation, AloVersion.V2),
18 => new AloContentInfo(AloType.Animation, AloVersion.V1),
_ => throw new BinaryCorruptedException("Invalid ALA animation.")
};
default:
- chunkReader.Skip(chunk.Value.Size);
+ chunkReader.Skip(chunk.Value.BodySize);
break;
}
chunk = chunkReader.TryReadChunk();
@@ -61,14 +61,14 @@ private static AloContentInfo FromConnection(ChunkReader chunkReader)
var chunk = chunkReader.TryReadChunk();
while (chunk.HasValue)
{
- switch ((ChunkType)chunk.Value.Type)
+ switch ((AloChunkType)chunk.Value.Type)
{
- case ChunkType.ProxyConnection:
- case ChunkType.ObjectConnection:
- case ChunkType.ConnectionCounts:
- chunkReader.Skip(chunk.Value.Size);
+ case AloChunkType.ProxyConnection:
+ case AloChunkType.ObjectConnection:
+ case AloChunkType.ConnectionCounts:
+ chunkReader.Skip(chunk.Value.BodySize);
break;
- case ChunkType.Dazzle:
+ case AloChunkType.Dazzle:
return new AloContentInfo(AloType.Model, AloVersion.V2);
default:
throw new BinaryCorruptedException("Invalid ALO model.");
@@ -85,14 +85,14 @@ private static AloContentInfo FromModel(int size, ChunkReader chunkReader)
var chunk = chunkReader.TryReadChunk();
if (chunk is null)
throw new BinaryCorruptedException("Unable to get ALO content information.");
- switch ((ChunkType)chunk.Value.Type)
+ switch ((AloChunkType)chunk.Value.Type)
{
- case ChunkType.Connections:
+ case AloChunkType.Connections:
return FromConnection(chunkReader);
- case ChunkType.Skeleton:
- case ChunkType.Mesh:
- case ChunkType.Light:
- return FromModel(chunk.Value.Size, chunkReader);
+ case AloChunkType.Skeleton:
+ case AloChunkType.Mesh:
+ case AloChunkType.Light:
+ return FromModel(chunk.Value.BodySize, chunkReader);
default:
throw new BinaryCorruptedException("Invalid ALO model.");
}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/Animations/AnimationReaderBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/Animations/AnimationReaderBase.cs
index 632caf37..d42e0ba5 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/Animations/AnimationReaderBase.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/Animations/AnimationReaderBase.cs
@@ -4,7 +4,7 @@
using PG.StarWarsGame.Files.ALO.Data;
using PG.StarWarsGame.Files.ALO.Services;
using PG.StarWarsGame.Files.Binary;
-using PG.StarWarsGame.Files.ChunkFiles.Binary.Metadata;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
namespace PG.StarWarsGame.Files.ALO.Binary.Reader.Animations;
@@ -27,11 +27,11 @@ public sealed override AlamoAnimation Read()
{
var chunk = ChunkReader.ReadChunk(ref actualSize);
ReadAnimation(chunk, ref info, bones);
- actualSize += chunk.Size;
+ actualSize += chunk.BodySize;
- } while (actualSize < rootChunk.Size);
+ } while (actualSize < rootChunk.BodySize);
- if (actualSize != rootChunk.Size)
+ if (actualSize != rootChunk.BodySize)
throw new BinaryCorruptedException();
if (info.NumberBones != bones.Count)
@@ -54,13 +54,14 @@ protected virtual void ReadAnimation(
switch (chunk.Type)
{
case (int)AnimationChunkTypes.AnimationInfo:
- animationInformation = ReadAnimationInfo(chunk.Size);
+ ThrowIfChunkSizeTooLargeException(chunk);
+ animationInformation = ReadAnimationInfo(chunk.BodySize);
break;
case (int)AnimationChunkTypes.BoneData:
- ReadBonesData(chunk.Size, bones);
+ ReadBonesData(chunk.BodySize, bones);
break;
default:
- ChunkReader.Skip(chunk.Size);
+ ChunkReader.Skip(chunk.BodySize);
break;
}
}
@@ -70,10 +71,11 @@ protected virtual void ReadBoneDataCore(ChunkMetadata chunk, List bones)
{
var chunk = ChunkReader.ReadChunk(ref actualSize);
ReadBoneDataCore(chunk, bones);
- actualSize += chunk.Size;
+ actualSize += chunk.BodySize;
} while (actualSize < chunkSize);
@@ -108,13 +110,13 @@ private void ReadBoneInfo(int chunkSize, List bones)
switch (chunk.Type)
{
case (int)AnimationChunkTypes.BoneName:
- name = ChunkReader.ReadString(chunk.Size, Encoding.ASCII, true, ref actualSize);
+ name = ChunkReader.ReadString(chunk.BodySize, Encoding.ASCII, true, ref actualSize);
break;
case (int)AnimationChunkTypes.BoneIndex:
index = ChunkReader.ReadDword(ref actualSize);
break;
default:
- ChunkReader.Skip(chunk.Size, ref actualSize);
+ ChunkReader.Skip(chunk.BodySize, ref actualSize);
break;
}
@@ -156,7 +158,7 @@ private AnimationInformationData ReadAnimationInfo(int chunkSize)
info.ScaleBlockSize = ChunkReader.ReadDword(ref actualSize);
break;
default:
- ChunkReader.Skip(chunk.Size, ref actualSize);
+ ChunkReader.Skip(chunk.BodySize, ref actualSize);
break;
}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/Models/ModelFileReader.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/Models/ModelFileReader.cs
index 13da425c..6c5bae14 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/Models/ModelFileReader.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/Models/ModelFileReader.cs
@@ -25,16 +25,16 @@ public override AlamoModel Read()
switch (chunk.Value.Type)
{
case (int)ModelChunkTypes.Skeleton:
- ReadSkeleton(chunk.Value.Size, bones);
+ ReadSkeleton(chunk.Value.BodySize, bones);
break;
case (int)ModelChunkTypes.Mesh:
- ReadMesh(chunk.Value.Size, textures, shaders);
+ ReadMesh(chunk.Value.BodySize, textures, shaders);
break;
case (int)ModelChunkTypes.Connections:
- ReadConnections(chunk.Value.Size, proxies);
+ ReadConnections(chunk.Value.BodySize, proxies);
break;
default:
- ChunkReader.Skip(chunk.Value.Size);
+ ChunkReader.Skip(chunk.Value.BodySize);
break;
}
@@ -59,14 +59,14 @@ private void ReadConnections(int size, HashSet proxies)
do
{
var chunk = ChunkReader.ReadChunk(ref actualSize);
-
+ ThrowIfChunkSizeTooLargeException(chunk);
if (chunk.Type == (int)ModelChunkTypes.ProxyConnection)
{
- ReadProxy(chunk.Size, out var proxy, ref actualSize);
+ ReadProxy(chunk.BodySize, out var proxy, ref actualSize);
proxies.Add(proxy);
}
else
- ChunkReader.Skip(chunk.Size, ref actualSize);
+ ChunkReader.Skip(chunk.BodySize, ref actualSize);
} while (actualSize < size);
@@ -84,9 +84,9 @@ private void ReadProxy(int size, out string proxy, ref int readSize)
var chunk = ChunkReader.ReadMiniChunk(ref actualSize);
if (chunk.Type == 5)
- proxy = ChunkReader.ReadString(chunk.Size, Encoding.ASCII, true, ref actualSize);
+ proxy = ChunkReader.ReadString(chunk.BodySize, Encoding.ASCII, true, ref actualSize);
else
- ChunkReader.Skip(chunk.Size, ref actualSize);
+ ChunkReader.Skip(chunk.BodySize, ref actualSize);
} while (actualSize < size);
@@ -110,9 +110,9 @@ private void ReadMesh(int size, ISet textures, ISet shaders)
var chunk = ChunkReader.ReadChunk(ref actualSize);
if (chunk.Type == (int)ModelChunkTypes.SubMeshMaterialInformation)
- ReadSubMeshMaterialInformation(chunk.Size, textures, shaders, ref actualSize);
+ ReadSubMeshMaterialInformation(chunk.BodySize, textures, shaders, ref actualSize);
else
- ChunkReader.Skip(chunk.Size, ref actualSize);
+ ChunkReader.Skip(chunk.BodySize, ref actualSize);
} while (actualSize < size);
@@ -132,15 +132,16 @@ private void ReadSubMeshMaterialInformation(int size, ISet textures, ISe
{
case (int)ModelChunkTypes.ShaderFileName:
{
- var shader = ChunkReader.ReadString(chunk.Size, Encoding.ASCII, true, ref actualSize);
+ var shader = ChunkReader.ReadString(chunk.BodySize, Encoding.ASCII, true, ref actualSize);
shaders.Add(shader);
break;
}
case (int)ModelChunkTypes.ShaderTexture:
- ReadShaderTexture(chunk.Size, textures, ref actualSize);
+ ThrowIfChunkSizeTooLargeException(chunk);
+ ReadShaderTexture(chunk.BodySize, textures, ref actualSize);
break;
default:
- ChunkReader.Skip(chunk.Size, ref actualSize);
+ ChunkReader.Skip(chunk.BodySize, ref actualSize);
break;
}
@@ -162,11 +163,11 @@ private void ReadShaderTexture(int size, ISet textures, ref int readSize
if (mini.Type == 2)
{
- var texture = ChunkReader.ReadString(mini.Size, Encoding.ASCII, true, ref actualTextureChunkSize);
+ var texture = ChunkReader.ReadString(mini.BodySize, Encoding.ASCII, true, ref actualTextureChunkSize);
textures.Add(texture);
}
else
- ChunkReader.Skip(mini.Size, ref actualTextureChunkSize);
+ ChunkReader.Skip(mini.BodySize, ref actualTextureChunkSize);
} while (actualTextureChunkSize != size);
@@ -191,7 +192,7 @@ private void ReadSkeleton(int size, IList bones)
var boneCountChunk = ChunkReader.ReadChunk(ref actualSize);
- Debug.Assert(boneCountChunk is { Size: 128, Type: (int)ModelChunkTypes.BoneCount });
+ Debug.Assert(boneCountChunk is { BodySize: 128, Type: (int)ModelChunkTypes.BoneCount });
var boneCount = ChunkReader.ReadDword(ref actualSize);
@@ -201,24 +202,24 @@ private void ReadSkeleton(int size, IList bones)
{
var bone = ChunkReader.ReadChunk(ref actualSize);
- Debug.Assert(bone is { Type: (int)ModelChunkTypes.Bone, IsContainer: true });
+ Debug.Assert(bone is { Type: (int)ModelChunkTypes.Bone, HasChildrenHint: true });
var boneReadSize = 0;
- while (boneReadSize < bone.Size)
+ while (boneReadSize < bone.BodySize)
{
var innerBoneChunk = ChunkReader.ReadChunk(ref boneReadSize);
if (innerBoneChunk.Type == (int)ModelChunkTypes.BoneName)
{
- var nameSize = innerBoneChunk.Size;
+ var nameSize = innerBoneChunk.BodySize;
var name = ChunkReader.ReadString(nameSize, Encoding.ASCII, true, ref boneReadSize);
bones.Add(name);
}
else
{
- ChunkReader.Skip(innerBoneChunk.Size, ref boneReadSize);
+ ChunkReader.Skip(innerBoneChunk.BodySize, ref boneReadSize);
}
}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/Particles/ParticleReaderV1.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/Particles/ParticleReaderV1.cs
index ed6de4b6..bbca3813 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/Particles/ParticleReaderV1.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/Binary/Reader/Particles/ParticleReaderV1.cs
@@ -30,23 +30,23 @@ public override AlamoParticle Read()
switch (chunk.Type)
{
case (int)ParticleChunkType.Name:
- ReadName(chunk.Size, out name);
+ ReadName(chunk.BodySize, out name);
break;
case (int)ParticleChunkType.Emitters:
- ReadEmitters(chunk.Size, textures);
+ ReadEmitters(chunk.BodySize, textures);
break;
default:
- ChunkReader.Skip(chunk.Size);
+ ChunkReader.Skip(chunk.BodySize);
break;
}
- actualSize += chunk.Size;
+ actualSize += chunk.BodySize;
- } while (actualSize < rootChunk.Size);
+ } while (actualSize < rootChunk.BodySize);
- if (actualSize != rootChunk.Size)
+ if (actualSize != rootChunk.BodySize)
throw new BinaryCorruptedException();
if (string.IsNullOrEmpty(name))
@@ -70,9 +70,9 @@ private void ReadEmitters(int size, HashSet textures)
if (chunk.Type != (int)ParticleChunkType.Emitter)
throw new BinaryCorruptedException("Unable to read particle");
- ReadEmitter(chunk.Size, textures);
+ ReadEmitter(chunk.BodySize, textures);
- actualSize += chunk.Size;
+ actualSize += chunk.BodySize;
} while (actualSize < size);
@@ -92,24 +92,24 @@ private void ReadEmitter(int chunkSize, HashSet textures)
if (chunk.Type == (int)ParticleChunkType.Properties)
{
var shader = ChunkReader.ReadDword();
- ChunkReader.Skip(chunk.Size - sizeof(uint));
+ ChunkReader.Skip(chunk.BodySize - sizeof(uint));
}
else if (chunk.Type == (int)ParticleChunkType.ColorTextureName)
{
- var texture = ChunkReader.ReadString(chunk.Size, Encoding.ASCII, true);
+ var texture = ChunkReader.ReadString(chunk.BodySize, Encoding.ASCII, true);
textures.Add(texture);
}
else if (chunk.Type == (int)ParticleChunkType.BumpTextureName)
{
- var bump = ChunkReader.ReadString(chunk.Size, Encoding.ASCII, true);
+ var bump = ChunkReader.ReadString(chunk.BodySize, Encoding.ASCII, true);
textures.Add(bump);
}
else
{
- ChunkReader.Skip(chunk.Size);
+ ChunkReader.Skip(chunk.BodySize);
}
- actualSize += chunk.Size;
+ actualSize += chunk.BodySize;
} while (actualSize < chunkSize);
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/ChunkFactoryTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/ChunkFactoryTest.cs
new file mode 100644
index 00000000..a5228afb
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/ChunkFactoryTest.cs
@@ -0,0 +1,290 @@
+using System;
+using PG.StarWarsGame.Files.ChunkFiles.Binary;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+using Xunit;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary;
+
+public class ChunkFactoryTest
+{
+ #region Data
+
+ [Fact]
+ public void Data_CreatesDataChunk()
+ {
+ var chunk = ChunkFactory.Data(0x01, [0xAA, 0xBB]);
+ Assert.IsType(chunk);
+ Assert.Equal(0x01u, chunk.Info.Type);
+ Assert.Equal(2, chunk.Info.BodySize);
+ Assert.False(chunk.Info.HasChildrenHint);
+ Assert.Equal(new byte[] { 0xAA, 0xBB }, chunk.Data.ToArray());
+ }
+
+ [Fact]
+ public void Data_ThrowsOnNull()
+ {
+ Assert.Throws(() => ChunkFactory.Data(1, null!));
+ }
+
+ [Fact]
+ public void Data_AllowsEmptyArray()
+ {
+ var chunk = ChunkFactory.Data(1, []);
+ Assert.Equal(0, chunk.Data.Length);
+ }
+
+ [Fact]
+ public void Data_SingleByte()
+ {
+ var chunk = ChunkFactory.Data(0xFF, [0x42]);
+ Assert.Equal(1, chunk.Info.BodySize);
+ Assert.Equal(new byte[] { 0x42 }, chunk.Data.ToArray());
+ }
+
+ #endregion
+
+ #region Raw
+
+ [Fact]
+ public void Raw_CreatesRawChunk()
+ {
+ var chunk = ChunkFactory.Raw(0x02, 0x8000_0003u, [1, 2, 3]);
+ Assert.IsType(chunk);
+ Assert.Equal(0x02u, chunk.Info.Type);
+ Assert.Equal(0x8000_0003u, chunk.Info.RawSize);
+ Assert.Equal(new byte[] { 1, 2, 3 }, chunk.Data.ToArray());
+ }
+
+ [Fact]
+ public void Raw_ThrowsOnNull()
+ {
+ Assert.Throws(() => ChunkFactory.Raw(1, 0, null!));
+ }
+
+ [Fact]
+ public void Raw_AllowsEmptyData()
+ {
+ var chunk = ChunkFactory.Raw(0x01, 0, []);
+ Assert.Equal(0, chunk.Data.Length);
+ }
+
+ [Fact]
+ public void Raw_ThrowsOnSizeMismatch()
+ {
+ Assert.Throws(() => ChunkFactory.Raw(1, 5, [0xAA]));
+ }
+
+ [Fact]
+ public void Raw_WithBit31Set_PreservesBit31()
+ {
+ var chunk = ChunkFactory.Raw(1, 0x8000_0002u, [0xAA, 0xBB]);
+ Assert.True(chunk.Info.HasChildrenHint);
+ Assert.Equal(2, chunk.Info.BodySize);
+ }
+
+ [Fact]
+ public void Raw_WithBit31Clear_NoBit31()
+ {
+ var chunk = ChunkFactory.Raw(1, 2, [0xAA, 0xBB]);
+ Assert.False(chunk.Info.HasChildrenHint);
+ }
+
+ #endregion
+
+ #region Mini
+
+ [Fact]
+ public void Mini_CreatesMiniChunk()
+ {
+ var chunk = ChunkFactory.Mini(0x05, [0xCC]);
+ Assert.IsType(chunk);
+ Assert.Equal(0x05, chunk.Info.Type);
+ Assert.Equal(1, chunk.Info.BodySize);
+ Assert.Equal(new byte[] { 0xCC }, chunk.Data.ToArray());
+ }
+
+ [Fact]
+ public void Mini_ThrowsOnNull()
+ {
+ Assert.Throws(() => ChunkFactory.Mini(1, null!));
+ }
+
+ [Fact]
+ public void Mini_ThrowsWhenDataExceeds255()
+ {
+ var data = new byte[256];
+ Assert.Throws(() => ChunkFactory.Mini(1, data));
+ }
+
+ [Fact]
+ public void Mini_AllowsMaxLength255()
+ {
+ var data = new byte[255];
+ var chunk = ChunkFactory.Mini(1, data);
+ Assert.Equal(255, chunk.Info.BodySize);
+ }
+
+ [Fact]
+ public void Mini_AllowsEmptyArray()
+ {
+ var chunk = ChunkFactory.Mini(1, []);
+ Assert.Equal(0, chunk.Data.Length);
+ }
+
+ [Fact]
+ public void Mini_SingleByte()
+ {
+ var chunk = ChunkFactory.Mini(0, [0x01]);
+ Assert.Equal(1, chunk.Info.BodySize);
+ }
+
+ #endregion
+
+ #region Node (RootChunk)
+
+ [Fact]
+ public void Node_RootChunk_CreatesNodeChunk()
+ {
+ var child = ChunkFactory.Raw(1, 2, [0xAA, 0xBB]);
+ var node = ChunkFactory.Node(0x10, child);
+
+ Assert.IsType(node);
+ Assert.Equal(0x10u, node.Info.Type);
+ Assert.True(node.Info.HasChildrenHint);
+ Assert.Single(node.Children);
+ }
+
+ [Fact]
+ public void Node_RootChunk_ThrowsOnNull()
+ {
+ Assert.Throws(() => ChunkFactory.Node(1, (RootChunk[])null!));
+ }
+
+ [Fact]
+ public void Node_RootChunk_AllowsEmpty()
+ {
+ var node = ChunkFactory.Node(1, Array.Empty());
+ Assert.Empty(node.Children);
+ }
+
+ [Fact]
+ public void Node_RootChunk_MultipleChildren()
+ {
+ var c1 = ChunkFactory.Raw(1, 1, [0xAA]);
+ var c2 = ChunkFactory.Raw(2, 2, [0xBB, 0xCC]);
+ var node = ChunkFactory.Node(0x10, c1, c2);
+
+ Assert.Equal(2, node.Children.Count);
+ Assert.True(node.Info.HasChildrenHint);
+ Assert.Equal(c1.Size + c2.Size, node.Info.BodySize);
+ }
+
+ [Fact]
+ public void Node_RootChunk_NestedNodes()
+ {
+ var leaf = ChunkFactory.Raw(1, 1, [0xAA]);
+ var inner = ChunkFactory.Node(2u, leaf);
+ var outer = ChunkFactory.Node(3u, inner);
+
+ Assert.Single(outer.Children);
+ Assert.IsType(outer.Children[0]);
+ }
+
+ [Fact]
+ public void Node_RootChunk_SetsCorrectMetadataSize()
+ {
+ var child = ChunkFactory.Raw(1, 2, [0xAA, 0xBB]);
+ var node = ChunkFactory.Node(0x10, child);
+
+ // BodySize should equal child's total Size (header + data)
+ Assert.Equal(child.Size, node.Info.BodySize);
+ }
+
+ #endregion
+
+ #region Node (MiniChunk)
+
+ [Fact]
+ public void Node_MiniChunk_CreatesMiniNodeChunk()
+ {
+ var child = ChunkFactory.Mini(1, [0xAA]);
+ var node = ChunkFactory.Node(0x20, child);
+
+ Assert.IsType(node);
+ Assert.Equal(0x20u, node.Info.Type);
+ Assert.False(node.Info.HasChildrenHint);
+ Assert.Single(node.Children);
+ }
+
+ [Fact]
+ public void Node_MiniChunk_ThrowsOnNull()
+ {
+ Assert.Throws(() => ChunkFactory.Node(1, (MiniChunk[])null!));
+ }
+
+ [Fact]
+ public void Node_MiniChunk_AllowsEmpty()
+ {
+ var node = ChunkFactory.Node(1, Array.Empty());
+ Assert.Empty(node.Children);
+ }
+
+ [Fact]
+ public void Node_MiniChunk_MultipleChildren()
+ {
+ var c1 = ChunkFactory.Mini(1, [0xAA]);
+ var c2 = ChunkFactory.Mini(2, [0xBB]);
+ var node = ChunkFactory.Node(0x20, c1, c2);
+
+ Assert.Equal(2, node.Children.Count);
+ Assert.False(node.Info.HasChildrenHint);
+ }
+
+ [Fact]
+ public void Node_MiniChunk_SetsCorrectMetadataSize()
+ {
+ var child = ChunkFactory.Mini(1, [0xAA]);
+ var node = ChunkFactory.Node(0x20, child);
+
+ Assert.Equal(child.Size, node.Info.BodySize);
+ }
+
+ #endregion
+
+ #region File
+
+ [Fact]
+ public void File_CreatesChunkFile()
+ {
+ var root = ChunkFactory.Raw(1, 1, [0xAA]);
+ var file = ChunkFactory.File(root);
+
+ Assert.IsType(file);
+ Assert.Single(file.RootChunks);
+ }
+
+ [Fact]
+ public void File_ThrowsOnNull()
+ {
+ Assert.Throws(() => ChunkFactory.File(null!));
+ }
+
+ [Fact]
+ public void File_AllowsEmpty()
+ {
+ var file = ChunkFactory.File();
+ Assert.Empty(file.RootChunks);
+ }
+
+ [Fact]
+ public void File_MultipleRoots()
+ {
+ var r1 = ChunkFactory.Raw(1, 1, [0xAA]);
+ var r2 = ChunkFactory.Raw(2, 2, [0xBB, 0xCC]);
+ var file = ChunkFactory.File(r1, r2);
+
+ Assert.Equal(2, file.RootChunks.Count);
+ }
+
+ #endregion
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/ChunkFileIntegrationTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/ChunkFileIntegrationTest.cs
new file mode 100644
index 00000000..e571243c
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/ChunkFileIntegrationTest.cs
@@ -0,0 +1,140 @@
+using System.IO;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Reader;
+using Xunit;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary;
+
+///
+/// Integration test that reads the binary representation of
+/// using and reconstructs the original chunk tree structure,
+/// asserting that every chunk type, size, and data value matches the expected layout.
+///
+public class ChunkFileIntegrationTest
+{
+ ///
+ /// Reads the full test chunk file from and
+ /// verifies that the reconstructed chunk tree matches the documented structure exactly.
+ ///
+ /// Root[0]: DataChunk(type=0x01, data=[0xAA, 0xBB, 0xCC])
+ /// Root[1]: NodeChunk(type=0x10)
+ /// Child[0]: DataChunk(type=0x02, data=[0x11, 0x22])
+ /// Child[1]: NodeChunk(type=0x20)
+ /// Child[0]: DataChunk(type=0x03, data=[0x33])
+ /// Child[2]: MiniNodeChunk(type=0x30)
+ /// Mini[0]: MiniChunk(type=0x04, data=[0x44, 0x55])
+ /// Mini[1]: MiniChunk(type=0x05, data=[0x66])
+ /// Root[2]: DataChunk(type=0x06, data=[0x77, 0x88, 0x99, 0xDD])
+ /// Root[3]: DataChunk(type=0x07, data=[])
+ /// Root[4]: NodeChunk(type=0x11, children=[])
+ /// Root[5]: MiniNodeChunk(type=0x31)
+ /// Mini[0]: MiniChunk(type=0x08, data=[])
+ /// Root[6]: MiniNodeChunk(type=0x32, children=[])
+ ///
+ ///
+ [Fact]
+ public void Read_ReconstructsFullChunkTree()
+ {
+ var bytes = TestChunkFileData.ExpectedBytes;
+ using var stream = new MemoryStream(bytes.ToArray());
+ using var reader = new ChunkReader(stream, leaveOpen: true);
+
+ // Root[0]: DataChunk(type=0x01, data=[0xAA, 0xBB, 0xCC])
+ var root0 = reader.ReadChunk();
+ Assert.Equal(0x01u, root0.Type);
+ Assert.False(root0.HasChildrenHint);
+ Assert.Equal(3, root0.BodySize);
+ Assert.Equal([0xAA, 0xBB, 0xCC], reader.ReadData(root0));
+
+ // Root[1]: NodeChunk(type=0x10) with 3 children
+ var root1 = reader.ReadChunk();
+ Assert.Equal(0x10u, root1.Type);
+ Assert.True(root1.HasChildrenHint);
+ var root1Read = 0;
+
+ // Root[1] Child[0]: DataChunk(type=0x02, data=[0x11, 0x22])
+ var r1c0 = reader.ReadChunk(ref root1Read);
+ Assert.Equal(0x02u, r1c0.Type);
+ Assert.False(r1c0.HasChildrenHint);
+ Assert.Equal([0x11, 0x22], reader.ReadData(r1c0));
+ root1Read += r1c0.BodySize;
+
+ // Root[1] Child[1]: NodeChunk(type=0x20) with 1 child
+ var r1c1 = reader.ReadChunk(ref root1Read);
+ Assert.Equal(0x20u, r1c1.Type);
+ Assert.True(r1c1.HasChildrenHint);
+ var r1c1Read = 0;
+
+ // Root[1] Child[1] Child[0]: DataChunk(type=0x03, data=[0x33])
+ var r1c1c0 = reader.ReadChunk(ref r1c1Read);
+ Assert.Equal(0x03u, r1c1c0.Type);
+ Assert.False(r1c1c0.HasChildrenHint);
+ Assert.Equal([0x33], reader.ReadData(r1c1c0));
+ r1c1Read += r1c1c0.BodySize;
+ Assert.Equal(r1c1.BodySize, r1c1Read);
+ root1Read += r1c1.BodySize;
+
+ // Root[1] Child[2]: MiniNodeChunk(type=0x30) with 2 mini-chunks
+ var r1c2 = reader.ReadChunk(ref root1Read);
+ Assert.Equal(0x30u, r1c2.Type);
+ Assert.False(r1c2.HasChildrenHint);
+ var r1c2Read = 0;
+
+ // Root[1] Child[2] Mini[0]: MiniChunk(type=0x04, data=[0x44, 0x55])
+ var r1c2m0 = reader.ReadMiniChunk(ref r1c2Read);
+ Assert.Equal(0x04, r1c2m0.Type);
+ Assert.Equal([0x44, 0x55], reader.ReadData(r1c2m0));
+ r1c2Read += r1c2m0.BodySize;
+
+ // Root[1] Child[2] Mini[1]: MiniChunk(type=0x05, data=[0x66])
+ var r1c2m1 = reader.ReadMiniChunk(ref r1c2Read);
+ Assert.Equal(0x05, r1c2m1.Type);
+ Assert.Equal([0x66], reader.ReadData(r1c2m1));
+ r1c2Read += r1c2m1.BodySize;
+
+ Assert.Equal(r1c2.BodySize, r1c2Read);
+ root1Read += r1c2.BodySize;
+ Assert.Equal(root1.BodySize, root1Read);
+
+ // Root[2]: DataChunk(type=0x06, data=[0x77, 0x88, 0x99, 0xDD])
+ var root2 = reader.ReadChunk();
+ Assert.Equal(0x06u, root2.Type);
+ Assert.False(root2.HasChildrenHint);
+ Assert.Equal([0x77, 0x88, 0x99, 0xDD], reader.ReadData(root2));
+
+ // Root[3]: DataChunk(type=0x07, data=[])
+ var root3 = reader.ReadChunk();
+ Assert.Equal(0x07u, root3.Type);
+ Assert.False(root3.HasChildrenHint);
+ Assert.Equal(0, root3.BodySize);
+ Assert.Equal([], reader.ReadData(root3));
+
+ // Root[4]: NodeChunk(type=0x11, children=[])
+ var root4 = reader.ReadChunk();
+ Assert.Equal(0x11u, root4.Type);
+ Assert.True(root4.HasChildrenHint);
+ Assert.Equal(0, root4.BodySize);
+
+ // Root[5]: MiniNodeChunk(type=0x31) with 1 empty mini-chunk
+ var root5 = reader.ReadChunk();
+ Assert.Equal(0x31u, root5.Type);
+ Assert.False(root5.HasChildrenHint);
+ var root5Read = 0;
+
+ // Root[5] Mini[0]: MiniChunk(type=0x08, data=[])
+ var r5m0 = reader.ReadMiniChunk(ref root5Read);
+ Assert.Equal(0x08, r5m0.Type);
+ Assert.Equal(0, r5m0.BodySize);
+ Assert.Equal([], reader.ReadData(r5m0));
+ root5Read += r5m0.BodySize;
+ Assert.Equal(root5.BodySize, root5Read);
+
+ // Root[6]: MiniNodeChunk(type=0x32, children=[])
+ var root6 = reader.ReadChunk();
+ Assert.Equal(0x32u, root6.Type);
+ Assert.False(root6.HasChildrenHint);
+ Assert.Equal(0, root6.BodySize);
+
+ // Verify entire stream was consumed
+ Assert.Equal(stream.Length, stream.Position);
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/ChunkFileTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/ChunkFileTest.cs
new file mode 100644
index 00000000..6fcbb5f1
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/ChunkFileTest.cs
@@ -0,0 +1,96 @@
+using System;
+using System.IO;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using Xunit;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary.Model;
+
+public class ChunkFileTest
+{
+ private static RawChunk CreateRoot(uint type, byte[] data)
+ {
+ return new RawChunk(new ChunkMetadata(type, (uint)data.Length), data);
+ }
+
+ [Fact]
+ public void Ctor_ValidArgs_SetsProperties()
+ {
+ var root = CreateRoot(1, [0xAA]);
+ var file = new ChunkFile([root]);
+
+ Assert.Single(file.RootChunks);
+ Assert.Same(root, file.RootChunks[0]);
+ }
+
+ [Fact]
+ public void Ctor_ThrowsOnNull()
+ {
+ Assert.Throws(() => new ChunkFile(null!));
+ }
+
+ [Fact]
+ public void Ctor_AllowsEmpty()
+ {
+ var file = new ChunkFile([]);
+ Assert.Empty(file.RootChunks);
+ }
+
+ [Fact]
+ public void Size_SumsRootChunkSizes()
+ {
+ var r1 = CreateRoot(1, [0xAA]);
+ var r2 = CreateRoot(2, [0xBB, 0xCC]);
+ var file = new ChunkFile([r1, r2]);
+
+ Assert.Equal(r1.Size + r2.Size, file.Size);
+ }
+
+ [Fact]
+ public void Bytes_ReturnsCorrectBinary()
+ {
+ var r1 = CreateRoot(1, [0xAA]);
+ var file = new ChunkFile([r1]);
+
+ var bytes = file.Bytes;
+ Assert.Equal(file.Size, bytes.Length);
+ Assert.Equal(r1.Bytes, bytes);
+ }
+
+ [Fact]
+ public void GetBytes_WritesExactByteSequence()
+ {
+ // type=0x01, size=1: [0x01,0x00,0x00,0x00, 0x01,0x00,0x00,0x00, 0xAA]
+ // type=0x02, size=2: [0x02,0x00,0x00,0x00, 0x02,0x00,0x00,0x00, 0xBB,0xCC]
+ var r1 = CreateRoot(1, [0xAA]);
+ var r2 = CreateRoot(2, [0xBB, 0xCC]);
+ var file = new ChunkFile([r1, r2]);
+
+ Assert.Equal(new byte[]
+ {
+ 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0xAA,
+ 0x02, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0xBB, 0xCC,
+ }, file.Bytes);
+ }
+
+ [Fact]
+ public void WriteTo_WritesToStream()
+ {
+ var r1 = CreateRoot(1, [0xAA]);
+ var file = new ChunkFile([r1]);
+
+ using var ms = new MemoryStream();
+ file.WriteTo(ms);
+
+ Assert.Equal(file.Bytes, ms.ToArray());
+ }
+
+ [Fact]
+ public void WriteTo_ThrowsOnNullStream()
+ {
+ var r1 = CreateRoot(1, [0xAA]);
+ var file = new ChunkFile([r1]);
+
+ Assert.Throws(() => file.WriteTo(null!));
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/DataChunkTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/DataChunkTest.cs
new file mode 100644
index 00000000..815053b0
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/DataChunkTest.cs
@@ -0,0 +1,104 @@
+using System;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using Xunit;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary.Model;
+
+public class DataChunkTest
+{
+ [Fact]
+ public void Ctor_ValidArgs_SetsProperties()
+ {
+ var data = new byte[] { 1, 2, 3 };
+ var info = new ChunkMetadata(0x10, 3);
+ var chunk = new DataChunk(info, data);
+
+ Assert.Equal(info, chunk.Info);
+ Assert.Equal(data, chunk.Data.ToArray());
+ }
+
+ [Fact]
+ public void Ctor_AllowsEmptyData()
+ {
+ var info = new ChunkMetadata(0x10, 0);
+ var chunk = new DataChunk(info, ReadOnlyMemory.Empty);
+ Assert.Equal(0, chunk.Data.Length);
+ }
+
+ [Fact]
+ public void Ctor_ThrowsWhenBit31Set()
+ {
+ var data = new byte[] { 1, 2, 3 };
+ var info = new ChunkMetadata(0x10, 0x8000_0003u);
+ Assert.Throws(() => new DataChunk(info, data));
+ }
+
+ [Fact]
+ public void Ctor_ThrowsWhenSizeMismatch()
+ {
+ var data = new byte[] { 1, 2, 3 };
+ var info = new ChunkMetadata(0x10, 5);
+ Assert.Throws(() => new DataChunk(info, data));
+ }
+
+ [Fact]
+ public void Size_IncludesHeaderAndData()
+ {
+ var data = new byte[] { 1, 2, 3 };
+ var info = new ChunkMetadata(0x10, 3);
+ var chunk = new DataChunk(info, data);
+
+ // Header is 8 bytes (sizeof ChunkMetadata) + 3 bytes data
+ Assert.Equal(8 + 3, chunk.Size);
+ }
+
+ [Fact]
+ public void GetBytes_EmptyData_WritesHeaderOnly()
+ {
+ var info = new ChunkMetadata(0x05, 0);
+ var chunk = new DataChunk(info, ReadOnlyMemory.Empty);
+
+ var bytes = chunk.Bytes;
+ Assert.Equal(8, bytes.Length);
+
+ // Type (LE)
+ Assert.Equal(0x05, bytes[0]);
+ Assert.Equal(0x00, bytes[1]);
+ Assert.Equal(0x00, bytes[2]);
+ Assert.Equal(0x00, bytes[3]);
+
+ // Size = 0 (LE)
+ Assert.Equal(0x00, bytes[4]);
+ Assert.Equal(0x00, bytes[5]);
+ Assert.Equal(0x00, bytes[6]);
+ Assert.Equal(0x00, bytes[7]);
+ }
+
+ [Fact]
+ public void GetBytes_WritesCorrectBinary()
+ {
+ var data = new byte[] { 0xAA, 0xBB };
+ var info = new ChunkMetadata(0x01, 2);
+ var chunk = new DataChunk(info, data);
+
+ var bytes = chunk.Bytes;
+ Assert.Equal(10, bytes.Length);
+
+ // Type (LE)
+ Assert.Equal(0x01, bytes[0]);
+ Assert.Equal(0x00, bytes[1]);
+ Assert.Equal(0x00, bytes[2]);
+ Assert.Equal(0x00, bytes[3]);
+
+ // Size (LE) - no bit 31
+ Assert.Equal(0x02, bytes[4]);
+ Assert.Equal(0x00, bytes[5]);
+ Assert.Equal(0x00, bytes[6]);
+ Assert.Equal(0x00, bytes[7]);
+
+ // Data
+ Assert.Equal(0xAA, bytes[8]);
+ Assert.Equal(0xBB, bytes[9]);
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/Metadata/ChunkMetadataTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/Metadata/ChunkMetadataTest.cs
new file mode 100644
index 00000000..6429bbec
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/Metadata/ChunkMetadataTest.cs
@@ -0,0 +1,90 @@
+using System.Runtime.CompilerServices;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using Xunit;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary.Model.Metadata;
+
+public class ChunkMetadataTest
+{
+ [Fact]
+ public void SizeOf_Is8Bytes()
+ {
+ Assert.Equal(8, Unsafe.SizeOf());
+ }
+
+ [Fact]
+ public void Ctor_SetsTypeAndRawSize()
+ {
+ var meta = new ChunkMetadata(0x1234u, 0x5678u);
+ Assert.Equal(0x1234u, meta.Type);
+ Assert.Equal(0x5678u, meta.RawSize);
+ }
+
+ [Fact]
+ public void Default_HasZeroValues()
+ {
+ var meta = default(ChunkMetadata);
+ Assert.Equal(0u, meta.Type);
+ Assert.Equal(0u, meta.RawSize);
+ Assert.False(meta.HasChildrenHint);
+ Assert.Equal(0, meta.BodySize);
+ Assert.Equal((uint)meta.BodySize, meta.RawSize);
+ }
+
+ [Fact]
+ public void HasChildrenHint_ReturnsFalse_WhenBit31NotSet()
+ {
+ var meta = new ChunkMetadata(1, 0x7FFF_FFFFu);
+ Assert.Equal(0x7FFF_FFFFu, meta.RawSize);
+ Assert.False(meta.HasChildrenHint);
+ }
+
+ [Fact]
+ public void HasChildrenHint_ReturnsTrue_WhenBit31Set()
+ {
+ var meta = new ChunkMetadata(1, 0x8000_0000u);
+ Assert.Equal(0x8000_0000u, meta.RawSize);
+ Assert.True(meta.HasChildrenHint);
+ }
+
+ [Fact]
+ public void HasChildrenHint_ReturnsTrue_WhenAllBitsSet()
+ {
+ var meta = new ChunkMetadata(1, 0xFFFF_FFFFu);
+ Assert.Equal(0xFFFF_FFFFu, meta.RawSize);
+ Assert.True(meta.HasChildrenHint);
+ }
+
+ [Fact]
+ public void BodySize_MasksBit31Off()
+ {
+ var meta = new ChunkMetadata(1, 0x8000_0005u);
+ Assert.Equal(0x8000_0005u, meta.RawSize);
+ Assert.Equal(5, meta.BodySize);
+ }
+
+ [Fact]
+ public void BodySize_ReturnsRawSize_WhenBit31NotSet()
+ {
+ var meta = new ChunkMetadata(1, 100u);
+ Assert.Equal(100u, meta.RawSize);
+ Assert.Equal(100, meta.BodySize);
+ Assert.Equal((uint)meta.BodySize, meta.RawSize);
+ }
+
+ [Fact]
+ public void BodySize_ReturnsMaxValue_WhenAllLower31BitsSet()
+ {
+ var meta = new ChunkMetadata(1, 0xFFFF_FFFFu);
+ Assert.Equal(0xFFFF_FFFFu, meta.RawSize);
+ Assert.Equal(int.MaxValue, meta.BodySize);
+ }
+
+ [Fact]
+ public void BodySize_ReturnsZero_WhenOnlyBit31Set()
+ {
+ var meta = new ChunkMetadata(1, 0x8000_0000u);
+ Assert.Equal(0x8000_0000u, meta.RawSize);
+ Assert.Equal(0, meta.BodySize);
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/Metadata/MiniChunkMetadataTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/Metadata/MiniChunkMetadataTest.cs
new file mode 100644
index 00000000..20b25107
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/Metadata/MiniChunkMetadataTest.cs
@@ -0,0 +1,38 @@
+using System.Runtime.CompilerServices;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using Xunit;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary.Model.Metadata;
+
+public class MiniChunkMetadataTest
+{
+ [Fact]
+ public void SizeOf_Is2Bytes()
+ {
+ Assert.Equal(2, Unsafe.SizeOf());
+ }
+
+ [Fact]
+ public void Ctor_SetsTypeAndBodySize()
+ {
+ var meta = new MiniChunkMetadata(0x0A, 0xFF);
+ Assert.Equal(0x0A, meta.Type);
+ Assert.Equal(0xFF, meta.BodySize);
+ }
+
+ [Fact]
+ public void Default_HasZeroValues()
+ {
+ var meta = default(MiniChunkMetadata);
+ Assert.Equal(0, meta.Type);
+ Assert.Equal(0, meta.BodySize);
+ }
+
+ [Fact]
+ public void Ctor_MaxValues()
+ {
+ var meta = new MiniChunkMetadata(byte.MaxValue, byte.MaxValue);
+ Assert.Equal(byte.MaxValue, meta.Type);
+ Assert.Equal(byte.MaxValue, meta.BodySize);
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/MiniChunkTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/MiniChunkTest.cs
new file mode 100644
index 00000000..e9ef0c8b
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/MiniChunkTest.cs
@@ -0,0 +1,85 @@
+using System;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using Xunit;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary.Model;
+
+public class MiniChunkTest
+{
+ [Fact]
+ public void Ctor_ValidArgs_SetsProperties()
+ {
+ var data = new byte[] { 0xAA, 0xBB };
+ var info = new MiniChunkMetadata(0x05, 2);
+ var chunk = new MiniChunk(info, data);
+
+ Assert.Equal(info, chunk.Info);
+ Assert.Equal(data, chunk.Data.ToArray());
+ }
+
+ [Fact]
+ public void Ctor_AllowsEmptyData()
+ {
+ var info = new MiniChunkMetadata(0x05, 0);
+ var chunk = new MiniChunk(info, ReadOnlyMemory.Empty);
+ Assert.Equal(0, chunk.Data.Length);
+ }
+
+ [Fact]
+ public void Ctor_ThrowsWhenSizeMismatch()
+ {
+ var data = new byte[] { 1, 2, 3 };
+ var info = new MiniChunkMetadata(0x05, 5);
+ Assert.Throws(() => new MiniChunk(info, data));
+ }
+
+ [Fact]
+ public void Size_IncludesHeaderAndData()
+ {
+ var data = new byte[] { 1, 2, 3 };
+ var info = new MiniChunkMetadata(0x05, 3);
+ var chunk = new MiniChunk(info, data);
+
+ // Header is 2 bytes (sizeof MiniChunkMetadata) + 3 bytes data
+ Assert.Equal(2 + 3, chunk.Size);
+ }
+
+ [Fact]
+ public void GetBytes_EmptyData_WritesHeaderOnly()
+ {
+ var info = new MiniChunkMetadata(0x0B, 0);
+ var chunk = new MiniChunk(info, ReadOnlyMemory.Empty);
+
+ var bytes = chunk.Bytes;
+ Assert.Equal(2, bytes.Length);
+
+ Assert.Equal(0x0B, bytes[0]); // Type
+ Assert.Equal(0x00, bytes[1]); // Size = 0
+ }
+
+ [Fact]
+ public void GetBytes_WritesCorrectBinary()
+ {
+ var data = new byte[] { 0xDD };
+ var info = new MiniChunkMetadata(0x0A, 1);
+ var chunk = new MiniChunk(info, data);
+
+ var bytes = chunk.Bytes;
+ Assert.Equal(3, bytes.Length);
+
+ Assert.Equal(0x0A, bytes[0]); // Type
+ Assert.Equal(0x01, bytes[1]); // Size
+ Assert.Equal(0xDD, bytes[2]); // Data
+ }
+
+ [Fact]
+ public void IsChunk_ButNotRootChunk()
+ {
+ var data = new byte[] { 1 };
+ var info = new MiniChunkMetadata(1, 1);
+ var chunk = new MiniChunk(info, data);
+ Assert.IsType(chunk, false);
+ Assert.IsNotType(chunk, exactMatch: false);
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/MiniNodeChunkTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/MiniNodeChunkTest.cs
new file mode 100644
index 00000000..20055bd7
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/MiniNodeChunkTest.cs
@@ -0,0 +1,131 @@
+using System;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using Xunit;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary.Model;
+
+public class MiniNodeChunkTest
+{
+ private static MiniChunk CreateMiniChild(byte type, byte[] data)
+ {
+ return new MiniChunk(new MiniChunkMetadata(type, (byte)data.Length), data);
+ }
+
+ [Fact]
+ public void Ctor_ValidArgs_SetsProperties()
+ {
+ var child = CreateMiniChild(1, [0xAA]);
+ var info = new ChunkMetadata(0x30, (uint)child.Size);
+ var chunk = new MiniNodeChunk(info, [child]);
+
+ Assert.Equal(info, chunk.Info);
+ Assert.Single(chunk.Children);
+ Assert.Same(child, chunk.Children[0]);
+ }
+
+ [Fact]
+ public void Ctor_ThrowsOnNullChildren()
+ {
+ var info = new ChunkMetadata(0x30, 0);
+ Assert.Throws(() => new MiniNodeChunk(info, null!));
+ }
+
+ [Fact]
+ public void Ctor_AllowsEmptyChildren()
+ {
+ var info = new ChunkMetadata(0x30, 0);
+ var chunk = new MiniNodeChunk(info, []);
+ Assert.Empty(chunk.Children);
+ }
+
+ [Fact]
+ public void Ctor_ThrowsWhenSizeMismatch()
+ {
+ var child = CreateMiniChild(1, [0xAA]);
+ var info = new ChunkMetadata(0x30, 999);
+ Assert.Throws(() => new MiniNodeChunk(info, [child]));
+ }
+
+ [Fact]
+ public void Ctor_ThrowsWhenRawSizeExceedsIntMax()
+ {
+ var child = CreateMiniChild(1, [0xAA]);
+ // RawSize with bit 31 set exceeds int.MaxValue
+ var info = new ChunkMetadata(0x30, 0x8000_0000u | (uint)child.Size);
+ Assert.Throws(() => new MiniNodeChunk(info, [child]));
+ }
+
+ [Fact]
+ public void Size_IncludesHeaderAndChildren()
+ {
+ var child = CreateMiniChild(1, [0xAA]);
+ var info = new ChunkMetadata(0x30, (uint)child.Size);
+ var chunk = new MiniNodeChunk(info, [child]);
+
+ Assert.Equal(8 + child.Size, chunk.Size);
+ }
+
+ [Fact]
+ public void GetBytes_WritesExactByteSequence()
+ {
+ // child: type=0x01, data=[0xAA] => MiniChunk header [01 01] + [AA] = 3 bytes
+ var child = CreateMiniChild(1, [0xAA]);
+ // MiniNodeChunk header: type=0x30, RawSize=3
+ var info = new ChunkMetadata(0x30, (uint)child.Size);
+ var chunk = new MiniNodeChunk(info, [child]);
+
+ var bytes = chunk.Bytes;
+
+ byte[] expected =
+ [
+ // MiniNodeChunk header (8 bytes): type=0x00000030, RawSize=0x00000003
+ 0x30, 0x00, 0x00, 0x00,
+ 0x03, 0x00, 0x00, 0x00,
+ // child header: type=0x01, size=0x01
+ 0x01, 0x01,
+ // child data
+ 0xAA
+ ];
+ Assert.Equal(expected, bytes);
+ }
+
+ [Fact]
+ public void GetBytes_EmptyChildren_WritesHeaderOnly()
+ {
+ var info = new ChunkMetadata(0x30, 0);
+ var chunk = new MiniNodeChunk(info, []);
+
+ var bytes = chunk.Bytes;
+
+ byte[] expected =
+ [
+ // MiniNodeChunk header: type=0x00000030, RawSize=0x00000000
+ 0x30, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00
+ ];
+ Assert.Equal(expected, bytes);
+ }
+
+ [Fact]
+ public void IsRootChunk()
+ {
+ var child = CreateMiniChild(1, [0xAA]);
+ var info = new ChunkMetadata(0x30, (uint)child.Size);
+ var chunk = new MiniNodeChunk(info, [child]);
+ Assert.IsType(chunk, false);
+ }
+
+ [Fact]
+ public void MultipleChildren()
+ {
+ var c1 = CreateMiniChild(1, [0xAA]);
+ var c2 = CreateMiniChild(2, [0xBB, 0xCC]);
+ var totalChildSize = c1.Size + c2.Size;
+ var info = new ChunkMetadata(0x30, (uint)totalChildSize);
+ var chunk = new MiniNodeChunk(info, [c1, c2]);
+
+ Assert.Equal(2, chunk.Children.Count);
+ Assert.Equal(8 + totalChildSize, chunk.Size);
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/NodeChunkTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/NodeChunkTest.cs
new file mode 100644
index 00000000..d334b5d3
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/NodeChunkTest.cs
@@ -0,0 +1,131 @@
+using System;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using Xunit;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary.Model;
+
+public class NodeChunkTest
+{
+ private static RawChunk CreateChild(uint type, byte[] data)
+ {
+ return new RawChunk(new ChunkMetadata(type, (uint)data.Length), data);
+ }
+
+ [Fact]
+ public void Ctor_ValidArgs_SetsProperties()
+ {
+ var child = CreateChild(1, [0xAA]);
+ var info = new ChunkMetadata(0x20, 0x8000_0000u | (uint)child.Size);
+ var chunk = new NodeChunk(info, [child]);
+
+ Assert.Equal(info, chunk.Info);
+ Assert.Single(chunk.Children);
+ Assert.Same(child, chunk.Children[0]);
+ }
+
+ [Fact]
+ public void Ctor_ThrowsWhenBit31NotSet()
+ {
+ var child = CreateChild(1, [0xAA]);
+ var info = new ChunkMetadata(0x20, (uint)child.Size);
+ Assert.Throws(() => new NodeChunk(info, [child]));
+ }
+
+ [Fact]
+ public void Ctor_ThrowsOnNullChildren()
+ {
+ var info = new ChunkMetadata(0x20, 0x8000_0000u);
+ Assert.Throws(() => new NodeChunk(info, null!));
+ }
+
+ [Fact]
+ public void Ctor_AllowsEmptyChildren()
+ {
+ var info = new ChunkMetadata(0x20, 0x8000_0000u);
+ var chunk = new NodeChunk(info, []);
+ Assert.Empty(chunk.Children);
+ }
+
+ [Fact]
+ public void Ctor_ThrowsWhenSizeMismatch()
+ {
+ var child = CreateChild(1, [0xAA]);
+ var info = new ChunkMetadata(0x20, 0x8000_0000u | 999u);
+ Assert.Throws(() => new NodeChunk(info, [child]));
+ }
+
+ [Fact]
+ public void Size_IncludesHeaderAndChildren()
+ {
+ var child = CreateChild(1, [0xAA]);
+ var info = new ChunkMetadata(0x20, 0x8000_0000u | (uint)child.Size);
+ var chunk = new NodeChunk(info, [child]);
+
+ Assert.Equal(8 + child.Size, chunk.Size);
+ }
+
+ [Fact]
+ public void GetBytes_WritesExactByteSequence()
+ {
+ // child: type=0x01, data=[0xFF] => RawChunk header [01 00 00 00 01 00 00 00] + [FF] = 9 bytes
+ var child = CreateChild(1, [0xFF]);
+ // NodeChunk header: type=0x20, RawSize = 0x8000_0000 | 9 = 0x80000009
+ var info = new ChunkMetadata(0x20, 0x8000_0000u | (uint)child.Size);
+ var chunk = new NodeChunk(info, [child]);
+
+ var bytes = chunk.Bytes;
+
+ byte[] expected =
+ [
+ // NodeChunk header (8 bytes): type=0x00000020, RawSize=0x80000009
+ 0x20, 0x00, 0x00, 0x00,
+ 0x09, 0x00, 0x00, 0x80,
+ // child header: type=0x00000001, RawSize=0x00000001
+ 0x01, 0x00, 0x00, 0x00,
+ 0x01, 0x00, 0x00, 0x00,
+ // child data
+ 0xFF
+ ];
+ Assert.Equal(expected, bytes);
+ }
+
+ [Fact]
+ public void GetBytes_EmptyChildren_WritesHeaderOnly()
+ {
+ var info = new ChunkMetadata(0x20, 0x8000_0000u);
+ var chunk = new NodeChunk(info, []);
+
+ var bytes = chunk.Bytes;
+
+ byte[] expected =
+ [
+ // NodeChunk header: type=0x00000020, RawSize=0x80000000
+ 0x20, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x80
+ ];
+ Assert.Equal(expected, bytes);
+ }
+
+ [Fact]
+ public void MultipleChildren()
+ {
+ var c1 = CreateChild(1, [0xAA]);
+ var c2 = CreateChild(2, [0xBB, 0xCC]);
+ var totalChildSize = c1.Size + c2.Size;
+ var info = new ChunkMetadata(0x20, 0x8000_0000u | (uint)totalChildSize);
+ var chunk = new NodeChunk(info, [c1, c2]);
+
+ Assert.Equal(2, chunk.Children.Count);
+ Assert.Equal(8 + totalChildSize, chunk.Size);
+ }
+
+ [Fact]
+ public void IsRootChunk()
+ {
+ var child = CreateChild(1, [0xAA]);
+ var info = new ChunkMetadata(0x20, 0x8000_0000u | (uint)child.Size);
+ var chunk = new NodeChunk(info, [child]);
+ Assert.IsType(chunk, false);
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/RawChunkTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/RawChunkTest.cs
new file mode 100644
index 00000000..806bda13
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Model/RawChunkTest.cs
@@ -0,0 +1,111 @@
+using System;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using Xunit;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary.Model;
+
+public class RawChunkTest
+{
+ [Fact]
+ public void Ctor_ValidArgs_SetsProperties()
+ {
+ var data = new byte[] { 1, 2, 3 };
+ var info = new ChunkMetadata(0x10, 3);
+ var chunk = new RawChunk(info, data);
+
+ Assert.Equal(info, chunk.Info);
+ Assert.Equal(data, chunk.Data.ToArray());
+ }
+
+ [Fact]
+ public void Ctor_AllowsBit31Set()
+ {
+ var data = new byte[] { 1, 2, 3 };
+ var info = new ChunkMetadata(0x10, 0x8000_0003u);
+ var chunk = new RawChunk(info, data);
+ Assert.True(chunk.Info.HasChildrenHint);
+ }
+
+ [Fact]
+ public void Ctor_AllowsEmptyData()
+ {
+ var info = new ChunkMetadata(0x10, 0);
+ var chunk = new RawChunk(info, ReadOnlyMemory.Empty);
+ Assert.Equal(0, chunk.Data.Length);
+ }
+
+ [Fact]
+ public void Ctor_ThrowsWhenSizeMismatch()
+ {
+ var data = new byte[] { 1, 2, 3 };
+ var info = new ChunkMetadata(0x10, 5);
+ Assert.Throws(() => new RawChunk(info, data));
+ }
+
+ [Fact]
+ public void Size_IncludesHeaderAndData()
+ {
+ var data = new byte[] { 1, 2, 3 };
+ var info = new ChunkMetadata(0x10, 3);
+ var chunk = new RawChunk(info, data);
+ Assert.Equal(8 + 3, chunk.Size);
+ }
+
+ [Fact]
+ public void GetBytes_EmptyData_WritesHeaderOnly()
+ {
+ var info = new ChunkMetadata(0x03, 0);
+ var chunk = new RawChunk(info, ReadOnlyMemory.Empty);
+
+ var bytes = chunk.Bytes;
+ Assert.Equal(8, bytes.Length);
+
+ // Type (LE)
+ Assert.Equal(0x03, bytes[0]);
+ Assert.Equal(0x00, bytes[1]);
+ Assert.Equal(0x00, bytes[2]);
+ Assert.Equal(0x00, bytes[3]);
+
+ // Size = 0 (LE)
+ Assert.Equal(0x00, bytes[4]);
+ Assert.Equal(0x00, bytes[5]);
+ Assert.Equal(0x00, bytes[6]);
+ Assert.Equal(0x00, bytes[7]);
+ }
+
+ [Fact]
+ public void GetBytes_WritesRawSizeIncludingBit31()
+ {
+ var data = new byte[] { 0xCC };
+ var info = new ChunkMetadata(0x02, 0x8000_0001u);
+ var chunk = new RawChunk(info, data);
+
+ var bytes = chunk.Bytes;
+ Assert.Equal(9, bytes.Length);
+
+ // Type (LE)
+ Assert.Equal(0x02, bytes[0]);
+ Assert.Equal(0x00, bytes[1]);
+ Assert.Equal(0x00, bytes[2]);
+ Assert.Equal(0x00, bytes[3]);
+
+ // RawSize (LE) - bit 31 preserved
+ Assert.Equal(0x01, bytes[4]);
+ Assert.Equal(0x00, bytes[5]);
+ Assert.Equal(0x00, bytes[6]);
+ Assert.Equal(0x80, bytes[7]);
+
+ // Data
+ Assert.Equal(0xCC, bytes[8]);
+ }
+
+ [Fact]
+ public void IsRootChunk()
+ {
+ var data = new byte[] { 1 };
+ var info = new ChunkMetadata(1, 1);
+ var chunk = new RawChunk(info, data);
+ Assert.IsAssignableFrom(chunk);
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/RawChunkEquivalenceTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/RawChunkEquivalenceTest.cs
new file mode 100644
index 00000000..62d939b8
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/RawChunkEquivalenceTest.cs
@@ -0,0 +1,173 @@
+using System;
+using PG.StarWarsGame.Files.ChunkFiles.Binary;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+using Xunit;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary;
+
+///
+/// Systematically verifies that every structured chunk type produces the exact same binary
+/// as a constructed
+/// from the equivalent header and body bytes.
+///
+public class RawChunkEquivalenceTest
+{
+ #region DataChunk
+
+ [Fact]
+ public void DataChunk_WithData_MatchesRaw()
+ {
+ var structured = ChunkFactory.Data(0x01, [0xAA, 0xBB, 0xCC]);
+ var raw = ChunkFactory.Raw(0x01, 3, [0xAA, 0xBB, 0xCC]);
+
+ Assert.Equal(structured.Bytes, raw.Bytes);
+ }
+
+ [Fact]
+ public void DataChunk_SingleByte_MatchesRaw()
+ {
+ var structured = ChunkFactory.Data(0xFF, [0x42]);
+ var raw = ChunkFactory.Raw(0xFF, 1, [0x42]);
+
+ Assert.Equal(structured.Bytes, raw.Bytes);
+ }
+
+ [Fact]
+ public void DataChunk_EmptyData_MatchesRaw()
+ {
+ var structured = ChunkFactory.Data(0x07, []);
+ var raw = ChunkFactory.Raw(0x07, 0, []);
+
+ Assert.Equal(structured.Bytes, raw.Bytes);
+ }
+
+ #endregion
+
+ #region MiniNodeChunk
+
+ [Fact]
+ public void MiniNodeChunk_WithChildren_MatchesRaw()
+ {
+ var structured = ChunkFactory.Node(0x30u,
+ ChunkFactory.Mini(0x04, [0x44, 0x55]),
+ ChunkFactory.Mini(0x05, [0x66]));
+
+ // Body = mini0.Bytes + mini1.Bytes = [0x04,0x02,0x44,0x55] + [0x05,0x01,0x66]
+ var body = new byte[] { 0x04, 0x02, 0x44, 0x55, 0x05, 0x01, 0x66 };
+ var raw = ChunkFactory.Raw(0x30, (uint)body.Length, body);
+
+ Assert.Equal(structured.Bytes, raw.Bytes);
+ }
+
+ [Fact]
+ public void MiniNodeChunk_SingleChild_MatchesRaw()
+ {
+ var structured = ChunkFactory.Node(0x31u, ChunkFactory.Mini(0x08, []));
+
+ var body = new byte[] { 0x08, 0x00 };
+ var raw = ChunkFactory.Raw(0x31, (uint)body.Length, body);
+
+ Assert.Equal(structured.Bytes, raw.Bytes);
+ }
+
+ [Fact]
+ public void MiniNodeChunk_EmptyChildren_MatchesRaw()
+ {
+ var structured = ChunkFactory.Node(0x32u, (MiniChunk[])[]);
+ var raw = ChunkFactory.Raw(0x32, 0u, []);
+
+ Assert.Equal(structured.Bytes, raw.Bytes);
+ }
+
+ #endregion
+
+ #region NodeChunk
+
+ [Fact]
+ public void NodeChunk_WithDataChildren_MatchesRaw()
+ {
+ var structured = ChunkFactory.Node(0x20u,
+ ChunkFactory.Data(0x03, [0x33]));
+
+ // Body = DataChunk(0x03, [0x33]).Bytes = 8-byte header + [0x33]
+ var childBytes = ChunkFactory.Data(0x03, [0x33]).Bytes;
+ var raw = ChunkFactory.Raw(0x20, 0x8000_0000u | (uint)childBytes.Length, childBytes);
+
+ Assert.Equal(structured.Bytes, raw.Bytes);
+ }
+
+ [Fact]
+ public void NodeChunk_WithMultipleDataChildren_MatchesRaw()
+ {
+ var child0 = ChunkFactory.Data(0x02, [0x11, 0x22]);
+ var child1 = ChunkFactory.Data(0x03, [0x33]);
+ var structured = ChunkFactory.Node(0x10u, child0, child1);
+
+ var body = new byte[child0.Size + child1.Size];
+ child0.GetBytes(body);
+ child1.GetBytes(((Span)body).Slice(child0.Size));
+ var raw = ChunkFactory.Raw(0x10, 0x8000_0000u | (uint)body.Length, body);
+
+ Assert.Equal(structured.Bytes, raw.Bytes);
+ }
+
+ [Fact]
+ public void NodeChunk_EmptyChildren_MatchesRaw()
+ {
+ var structured = ChunkFactory.Node(0x11u, (RootChunk[])[]);
+ var raw = ChunkFactory.Raw(0x11, 0x8000_0000u, []);
+
+ Assert.Equal(structured.Bytes, raw.Bytes);
+ }
+
+ [Fact]
+ public void NodeChunk_WithNestedNodeChild_MatchesRaw()
+ {
+ var grandchild = ChunkFactory.Data(0x03, [0x33]);
+ var innerNode = ChunkFactory.Node(0x20u, grandchild);
+ var structured = ChunkFactory.Node(0x10u, innerNode);
+
+ var innerBytes = innerNode.Bytes;
+ var raw = ChunkFactory.Raw(0x10, 0x8000_0000u | (uint)innerBytes.Length, innerBytes);
+
+ Assert.Equal(structured.Bytes, raw.Bytes);
+ }
+
+ [Fact]
+ public void NodeChunk_WithMiniNodeChild_MatchesRaw()
+ {
+ var miniNode = ChunkFactory.Node(0x30u,
+ ChunkFactory.Mini(0x04, [0x44, 0x55]),
+ ChunkFactory.Mini(0x05, [0x66]));
+ var structured = ChunkFactory.Node(0x10u, miniNode);
+
+ var miniNodeBytes = miniNode.Bytes;
+ var raw = ChunkFactory.Raw(0x10, 0x8000_0000u | (uint)miniNodeBytes.Length, miniNodeBytes);
+
+ Assert.Equal(structured.Bytes, raw.Bytes);
+ }
+
+ #endregion
+
+ #region ChunkFile
+
+ [Fact]
+ public void ChunkFile_StructuredFile_MatchesExpectedBytes()
+ {
+ Assert.Equal(TestChunkFileData.ExpectedBytes.ToArray(), TestChunkFileData.StructuredFile.Bytes);
+ }
+
+ [Fact]
+ public void ChunkFile_StructuredFile_HasExpectedSize()
+ {
+ Assert.Equal(TestChunkFileData.ExpectedBytes.Length, TestChunkFileData.StructuredFile.Size);
+ }
+
+ [Fact]
+ public void ChunkFile_StructuredFile_HasExpectedRootCount()
+ {
+ Assert.Equal(7, TestChunkFileData.StructuredFile.RootChunks.Count);
+ }
+
+ #endregion
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Reader/ChunkFileReaderBaseTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Reader/ChunkFileReaderBaseTest.cs
new file mode 100644
index 00000000..ca2d4acb
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Reader/ChunkFileReaderBaseTest.cs
@@ -0,0 +1,84 @@
+using System;
+using System.IO;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Reader;
+using PG.StarWarsGame.Files.ChunkFiles.Data;
+using Xunit;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary.Reader;
+
+public abstract class ChunkFileReaderBaseTest
+ where TReader : ChunkFileReaderBase
+ where TData : IChunkData
+{
+ protected abstract TReader CreateReader(Stream stream, bool leaveStreamOpen = false);
+
+ protected abstract byte[] CreateValidStreamContent();
+
+ protected abstract void AssertReadResult(TData result);
+
+ protected abstract void CallThrowIfChunkSizeTooLarge(TReader reader, ChunkMetadata chunk);
+
+ [Fact]
+ public void Read_ReturnsChunkData()
+ {
+ var content = CreateValidStreamContent();
+ using var reader = CreateReader(new MemoryStream(content));
+ var result = reader.Read();
+ AssertReadResult(result);
+ }
+
+ [Fact]
+ public void Read_IChunkFileReader_ReturnsIChunkData()
+ {
+ var content = CreateValidStreamContent();
+ using var reader = CreateReader(new MemoryStream(content));
+ IChunkFileReader interfaceReader = reader;
+ var result = interfaceReader.Read();
+ Assert.IsType(result);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Dispose_StreamBehaviorDependsOnLeaveOpen(bool leaveOpen)
+ {
+ var ms = new MemoryStream([1, 2, 3]);
+ var reader = CreateReader(ms, leaveStreamOpen: leaveOpen);
+ reader.Dispose();
+
+ if (leaveOpen)
+ {
+ ms.Position = 0;
+ Assert.Equal(1, ms.ReadByte());
+ }
+ else
+ {
+ Assert.Throws(() => ms.ReadByte());
+ }
+ }
+
+ [Fact]
+ public void ThrowIfChunkSizeTooLarge_DoesNotThrow_ForNormalSize()
+ {
+ using var reader = CreateReader(new MemoryStream(new byte[8]));
+ var meta = new ChunkMetadata(1, 100);
+ CallThrowIfChunkSizeTooLarge(reader, meta);
+ }
+
+ [Fact]
+ public void ThrowIfChunkSizeTooLarge_Throws_WhenRawSizeExceedsIntMax()
+ {
+ using var reader = CreateReader(new MemoryStream(new byte[8]));
+ var meta = new ChunkMetadata(1, 0x8000_0001u);
+ Assert.Throws(() => CallThrowIfChunkSizeTooLarge(reader, meta));
+ }
+
+ [Fact]
+ public void ThrowIfChunkSizeTooLarge_DoesNotThrow_AtExactIntMax()
+ {
+ using var reader = CreateReader(new MemoryStream(new byte[8]));
+ var meta = new ChunkMetadata(1, int.MaxValue);
+ CallThrowIfChunkSizeTooLarge(reader, meta);
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Reader/ChunkReaderTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Reader/ChunkReaderTest.cs
new file mode 100644
index 00000000..0e2be661
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Reader/ChunkReaderTest.cs
@@ -0,0 +1,402 @@
+using System;
+using System.IO;
+using System.Text;
+using PG.StarWarsGame.Files.Binary;
+using PG.StarWarsGame.Files.ChunkFiles.Binary;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Reader;
+using Xunit;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary.Reader;
+
+public class ChunkReaderTest
+{
+ private static MemoryStream CreateStream(byte[] data) => new(data);
+
+ private static byte[] WriteChunkHeader(uint type, uint rawSize)
+ {
+ var bytes = new byte[8];
+ BitConverter.GetBytes(type).CopyTo(bytes, 0);
+ BitConverter.GetBytes(rawSize).CopyTo(bytes, 4);
+ return bytes;
+ }
+
+ [Fact]
+ public void Ctor_ThrowsOnNullStream()
+ {
+ Assert.Throws(() => new ChunkReader(null!));
+ }
+
+ [Fact]
+ public void ReadChunk_ReadsHeaderCorrectly()
+ {
+ var header = WriteChunkHeader(0x01, 0x0A);
+ using var reader = new ChunkReader(CreateStream(header));
+
+ var meta = reader.ReadChunk();
+ Assert.Equal(0x01u, meta.Type);
+ Assert.Equal(0x0Au, meta.RawSize);
+ }
+
+ [Fact]
+ public void ReadChunk_WithRefBytes_IncrementsBy8()
+ {
+ var header = WriteChunkHeader(0x01, 0x0A);
+ using var reader = new ChunkReader(CreateStream(header));
+
+ var readBytes = 0;
+ var meta = reader.ReadChunk(ref readBytes);
+ Assert.Equal(8, readBytes);
+ Assert.Equal(0x01u, meta.Type);
+ }
+
+ [Fact]
+ public void ReadChunk_ThrowsAtEndOfStream()
+ {
+ using var reader = new ChunkReader(CreateStream([]));
+ Assert.Throws(() => reader.ReadChunk());
+ }
+
+ [Fact]
+ public void ReadChunk_ThrowsOnPartialHeader()
+ {
+ // Only 4 bytes instead of the required 8
+ using var reader = new ChunkReader(CreateStream([0x01, 0x00, 0x00, 0x00]));
+ Assert.Throws(() => reader.ReadChunk());
+ }
+
+ [Fact]
+ public void ReadMiniChunk_ThrowsAtEndOfStream()
+ {
+ using var reader = new ChunkReader(CreateStream([]));
+ Assert.Throws(() => { var rb = 0; reader.ReadMiniChunk(ref rb); });
+ }
+
+ [Fact]
+ public void ReadMiniChunk_ThrowsOnPartialHeader()
+ {
+ // Only 1 byte instead of the required 2
+ using var reader = new ChunkReader(CreateStream([0x05]));
+ Assert.Throws(() => { var rb = 0; reader.ReadMiniChunk(ref rb); });
+ }
+
+ [Fact]
+ public void ReadMiniChunk_ReadsHeaderCorrectly()
+ {
+ var data = new byte[] { 0x05, 0x03 };
+ using var reader = new ChunkReader(CreateStream(data));
+
+ var readBytes = 0;
+ var meta = reader.ReadMiniChunk(ref readBytes);
+ Assert.Equal(0x05, meta.Type);
+ Assert.Equal(0x03, meta.BodySize);
+ Assert.Equal(2, readBytes);
+ }
+
+ [Fact]
+ public void ReadData_ChunkMetadata_ReadsBody()
+ {
+ var header = WriteChunkHeader(0x01, 3);
+ var body = new byte[] { 0xAA, 0xBB, 0xCC };
+ var stream = new byte[header.Length + body.Length];
+ header.CopyTo(stream, 0);
+ body.CopyTo(stream, header.Length);
+
+ using var reader = new ChunkReader(CreateStream(stream));
+ var meta = reader.ReadChunk();
+ var data = reader.ReadData(meta);
+
+ Assert.Equal(body, data);
+ }
+
+ [Fact]
+ public void ReadData_ChunkMetadata_ThrowsForContainerChunk()
+ {
+ var header = WriteChunkHeader(0x01, 0x8000_0003u);
+ using var reader = new ChunkReader(CreateStream(header));
+ var meta = reader.ReadChunk();
+
+ Assert.Throws(() => reader.ReadData(meta));
+ }
+
+ [Fact]
+ public void ReadData_ChunkMetadata_WithRefBytes_IncrementsCorrectly()
+ {
+ var header = WriteChunkHeader(0x01, 3);
+ var body = new byte[] { 0xAA, 0xBB, 0xCC };
+ var stream = new byte[header.Length + body.Length];
+ header.CopyTo(stream, 0);
+ body.CopyTo(stream, header.Length);
+
+ using var reader = new ChunkReader(CreateStream(stream));
+ var meta = reader.ReadChunk();
+ var readBytes = 0;
+ var data = reader.ReadData(meta, ref readBytes);
+
+ Assert.Equal(body, data);
+ Assert.Equal(3, readBytes);
+ }
+
+ [Fact]
+ public void ReadData_MiniChunkMetadata_ReadsBody()
+ {
+ var miniHeader = new byte[] { 0x05, 0x02 };
+ var body = new byte[] { 0xDD, 0xEE };
+ var stream = new byte[miniHeader.Length + body.Length];
+ miniHeader.CopyTo(stream, 0);
+ body.CopyTo(stream, miniHeader.Length);
+
+ using var reader = new ChunkReader(CreateStream(stream));
+ var rb = 0;
+ var meta = reader.ReadMiniChunk(ref rb);
+ var data = reader.ReadData(meta);
+
+ Assert.Equal(body, data);
+ }
+
+ [Fact]
+ public void ReadData_MiniChunkMetadata_WithRefBytes_IncrementsCorrectly()
+ {
+ var miniHeader = new byte[] { 0x05, 0x02 };
+ var body = new byte[] { 0xDD, 0xEE };
+ var stream = new byte[miniHeader.Length + body.Length];
+ miniHeader.CopyTo(stream, 0);
+ body.CopyTo(stream, miniHeader.Length);
+
+ using var reader = new ChunkReader(CreateStream(stream));
+ var rb = 0;
+ var meta = reader.ReadMiniChunk(ref rb);
+ rb = 0;
+ var data = reader.ReadData(meta, ref rb);
+
+ Assert.Equal(body, data);
+ Assert.Equal(2, rb);
+ }
+
+ [Fact]
+ public void ReadData_BySize_ReadsCorrectBytes()
+ {
+ var data = new byte[] { 0x01, 0x02, 0x03, 0x04 };
+ using var reader = new ChunkReader(CreateStream(data));
+
+ var result = reader.ReadData(3);
+ Assert.Equal(new byte[] { 0x01, 0x02, 0x03 }, result);
+ }
+
+ [Fact]
+ public void ReadData_BySize_ReturnsAvailableBytes_WhenSizeExceedsStream()
+ {
+ var data = new byte[] { 0x01, 0x02 };
+ using var reader = new ChunkReader(CreateStream(data));
+ var result = reader.ReadData(10);
+ Assert.Equal(data, result);
+ }
+
+ [Fact]
+ public void ReadData_BySize_ThrowsOnNegative()
+ {
+ using var reader = new ChunkReader(CreateStream([0x01]));
+ Assert.Throws(() => reader.ReadData(-1));
+ }
+
+ [Fact]
+ public void ReadDword_ReadsUInt32()
+ {
+ var data = BitConverter.GetBytes(0x12345678u);
+ using var reader = new ChunkReader(CreateStream(data));
+
+ var value = reader.ReadDword();
+ Assert.Equal(0x12345678u, value);
+ }
+
+ [Fact]
+ public void ReadDword_WithRefBytes_IncrementsBy4()
+ {
+ var data = BitConverter.GetBytes(42u);
+ using var reader = new ChunkReader(CreateStream(data));
+
+ var readBytes = 0;
+ var value = reader.ReadDword(ref readBytes);
+ Assert.Equal(42u, value);
+ Assert.Equal(4, readBytes);
+ }
+
+ [Fact]
+ public void ReadFloat_WithRefBytes_IncrementsBy4()
+ {
+ var data = BitConverter.GetBytes(3.14f);
+ using var reader = new ChunkReader(CreateStream(data));
+
+ var readBytes = 0;
+ var value = reader.ReadFloat(ref readBytes);
+ Assert.Equal(3.14f, value);
+ Assert.Equal(4, readBytes);
+ }
+
+ [Fact]
+ public void Skip_AdvancesStreamPosition()
+ {
+ var data = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 };
+ using var reader = new ChunkReader(CreateStream(data));
+
+ reader.Skip(3);
+ var result = reader.ReadData(2);
+ Assert.Equal(new byte[] { 0x04, 0x05 }, result);
+ }
+
+ [Fact]
+ public void Skip_WithRefBytes_IncrementsCounter()
+ {
+ var data = new byte[] { 0x01, 0x02, 0x03 };
+ using var reader = new ChunkReader(CreateStream(data));
+
+ var readBytes = 0;
+ reader.Skip(2, ref readBytes);
+ Assert.Equal(2, readBytes);
+ }
+
+ [Fact]
+ public void TryReadChunk_ReturnsNull_AtEndOfStream()
+ {
+ using var reader = new ChunkReader(CreateStream([]));
+ var result = reader.TryReadChunk();
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void TryReadChunk_ReturnsMetadata_WhenDataAvailable()
+ {
+ var header = WriteChunkHeader(0x42, 0x10);
+ using var reader = new ChunkReader(CreateStream(header));
+
+ var result = reader.TryReadChunk();
+ Assert.NotNull(result);
+ Assert.Equal(0x42u, result.Value.Type);
+ Assert.Equal(0x10u, result.Value.RawSize);
+ }
+
+ [Fact]
+ public void TryReadChunk_WithRefBytes_IncrementsBy8()
+ {
+ var header = WriteChunkHeader(0x42, 0x10);
+ using var reader = new ChunkReader(CreateStream(header));
+
+ var readBytes = 0;
+ var result = reader.TryReadChunk(ref readBytes);
+ Assert.NotNull(result);
+ Assert.Equal(8, readBytes);
+ }
+
+ [Fact]
+ public void TryReadChunk_WithRefBytes_DoesNotIncrement_AtEnd()
+ {
+ using var reader = new ChunkReader(CreateStream([]));
+
+ var readBytes = 5;
+ var result = reader.TryReadChunk(ref readBytes);
+ Assert.Null(result);
+ Assert.Equal(5, readBytes);
+ }
+
+ #region ReadString
+
+ [Fact]
+ public void ReadString_ReadsCorrectly()
+ {
+ const string text = "Hello";
+ var encoded = Encoding.ASCII.GetBytes(text);
+ using var reader = new ChunkReader(CreateStream(encoded));
+
+ var readBytes = 0;
+ var result = reader.ReadString(encoded.Length, Encoding.ASCII, false, ref readBytes);
+ Assert.Equal(text, result);
+ Assert.Equal(encoded.Length, readBytes);
+ }
+
+ [Fact]
+ public void ReadString_ZeroTerminated_TrimsAtNull()
+ {
+ var encoded = "Hi\0X"u8.ToArray();
+ using var reader = new ChunkReader(CreateStream(encoded));
+
+ var result = reader.ReadString(encoded.Length, Encoding.ASCII, true);
+ Assert.Equal("Hi", result);
+ }
+
+ [Fact]
+ public void ReadString_WithoutRefBytes_Works()
+ {
+ const string text = "AB";
+ var encoded = Encoding.ASCII.GetBytes(text);
+ using var reader = new ChunkReader(CreateStream(encoded));
+
+ var result = reader.ReadString(encoded.Length, Encoding.ASCII, false);
+ Assert.Equal(text, result);
+ }
+
+ [Fact]
+ public void ReadString_ThrowsBinaryCorrupted_WhenSizeExceedsStream()
+ {
+ var encoded = "Hi"u8.ToArray();
+ using var reader = new ChunkReader(CreateStream(encoded));
+ Assert.Throws(() => reader.ReadString(10, Encoding.ASCII, false));
+ }
+
+ [Fact]
+ public void ReadString_ZeroTerminated_ThrowsBinaryCorrupted_WhenNoNullTerminator()
+ {
+ // No null terminator present — the reader cannot find the terminator and throws
+ var encoded = "Hello"u8.ToArray();
+ using var reader = new ChunkReader(CreateStream(encoded));
+
+ Assert.Throws(() =>
+ reader.ReadString(encoded.Length, Encoding.ASCII, zeroTerminated: true));
+ }
+
+ [Fact]
+ public void ReadString_ZeroTerminated_ThrowsBinaryCorrupted_WhenSizeExceedsStream()
+ {
+ var encoded = "Hi"u8.ToArray();
+ using var reader = new ChunkReader(CreateStream(encoded));
+ Assert.Throws(() => reader.ReadString(10, Encoding.ASCII, zeroTerminated: true));
+ }
+
+ #endregion
+
+ [Fact]
+ public void Dispose_DisposesUnderlyingStream()
+ {
+ var ms = CreateStream([1, 2, 3]);
+ var reader = new ChunkReader(ms);
+ reader.Dispose();
+
+ Assert.Throws(() => ms.ReadByte());
+ }
+
+ [Fact]
+ public void Dispose_LeaveOpen_KeepsStreamOpen()
+ {
+ var ms = CreateStream([1, 2, 3]);
+ var reader = new ChunkReader(ms, leaveOpen: true);
+ reader.Dispose();
+
+ // Stream should still be usable
+ ms.Position = 0;
+ Assert.Equal(1, ms.ReadByte());
+ }
+
+ [Fact]
+ public void ReadChunk_Roundtrip_WithChunkFactory()
+ {
+ var original = ChunkFactory.Data(0x42, [1, 2, 3]);
+ var bytes = original.Bytes;
+
+ using var reader = new ChunkReader(CreateStream(bytes));
+ var meta = reader.ReadChunk();
+ var data = reader.ReadData(meta);
+
+ Assert.Equal(0x42u, meta.Type);
+ Assert.Equal(3, meta.BodySize);
+ Assert.False(meta.HasChildrenHint);
+ Assert.Equal(new byte[] { 1, 2, 3 }, data);
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Reader/TestChunkFileReader.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Reader/TestChunkFileReader.cs
new file mode 100644
index 00000000..38f9465a
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Reader/TestChunkFileReader.cs
@@ -0,0 +1,23 @@
+using System.IO;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Reader;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary.Reader;
+
+public sealed class TestChunkFileReader(Stream stream, bool leaveStreamOpen = false)
+ : ChunkFileReaderBase(stream, leaveStreamOpen)
+{
+ public new string? FileName => base.FileName;
+
+ public override TestChunkFileReaderTest.TestChunkData Read()
+ {
+ var meta = ChunkReader.ReadChunk();
+ var data = ChunkReader.ReadData(meta);
+ return new TestChunkFileReaderTest.TestChunkData(data);
+ }
+
+ public void CallThrowIfChunkSizeTooLarge(ChunkMetadata chunk)
+ {
+ ThrowIfChunkSizeTooLargeException(chunk);
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Reader/TestChunkFileReaderTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Reader/TestChunkFileReaderTest.cs
new file mode 100644
index 00000000..0c957c73
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/Reader/TestChunkFileReaderTest.cs
@@ -0,0 +1,68 @@
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using PG.StarWarsGame.Files.ChunkFiles.Data;
+using System;
+using System.IO;
+using Xunit;
+using static PG.StarWarsGame.Files.ChunkFiles.Test.Binary.Reader.TestChunkFileReaderTest;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary.Reader;
+
+public sealed class TestChunkFileReaderTest : ChunkFileReaderBaseTest
+{
+ public sealed class TestChunkData(byte[] data) : IChunkData
+ {
+ public byte[] Data { get; } = data;
+
+ public void Dispose() { }
+ }
+
+ protected override TestChunkFileReader CreateReader(Stream stream, bool leaveStreamOpen = false)
+ {
+ return new TestChunkFileReader(stream, leaveStreamOpen);
+ }
+
+ protected override byte[] CreateValidStreamContent()
+ {
+ var header = new byte[8];
+ BitConverter.GetBytes(0x01u).CopyTo(header, 0);
+ BitConverter.GetBytes(3u).CopyTo(header, 4);
+ var body = new byte[] { 0xAA, 0xBB, 0xCC };
+ var stream = new byte[header.Length + body.Length];
+ header.CopyTo(stream, 0);
+ body.CopyTo(stream, header.Length);
+ return stream;
+ }
+
+ protected override void AssertReadResult(TestChunkData result)
+ {
+ Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, result.Data);
+ }
+
+ protected override void CallThrowIfChunkSizeTooLarge(TestChunkFileReader reader, ChunkMetadata chunk)
+ {
+ reader.CallThrowIfChunkSizeTooLarge(chunk);
+ }
+
+ [Fact]
+ public void FileName_IsNullForMemoryStream()
+ {
+ using var reader = CreateReader(new MemoryStream());
+ Assert.Null(reader.FileName);
+ }
+
+ [Fact]
+ public void FileName_IsPopulatedFromFileStream()
+ {
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ using var fs = File.OpenRead(tempFile);
+ using var reader = CreateReader(fs);
+ Assert.Equal(tempFile, reader.FileName);
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/TestChunkFileData.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/TestChunkFileData.cs
new file mode 100644
index 00000000..972e9417
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/Binary/TestChunkFileData.cs
@@ -0,0 +1,89 @@
+using System;
+using PG.StarWarsGame.Files.ChunkFiles.Binary;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Test.Binary;
+
+///
+/// Provides a reusable, complex test chunk file built using .
+/// The file structure is:
+///
+/// Root[0]: DataChunk(type=0x01, data=[0xAA, 0xBB, 0xCC])
+/// Root[1]: NodeChunk(type=0x10)
+/// Child[0]: DataChunk(type=0x02, data=[0x11, 0x22])
+/// Child[1]: NodeChunk(type=0x20)
+/// Child[0]: DataChunk(type=0x03, data=[0x33])
+/// Child[2]: MiniNodeChunk(type=0x30)
+/// Mini[0]: MiniChunk(type=0x04, data=[0x44, 0x55])
+/// Mini[1]: MiniChunk(type=0x05, data=[0x66])
+/// Root[2]: DataChunk(type=0x06, data=[0x77, 0x88, 0x99, 0xDD])
+/// Root[3]: DataChunk(type=0x07, data=[]) -- empty data chunk
+/// Root[4]: NodeChunk(type=0x11, children=[]) -- empty node chunk
+/// Root[5]: MiniNodeChunk(type=0x31) -- mini-node chunk with one empty mini-chunk
+/// Mini[0]: MiniChunk(type=0x08, data=[]) -- empty mini-chunk
+/// Root[6]: MiniNodeChunk(type=0x32, children=[]) -- empty mini-node chunk
+///
+///
+public static class TestChunkFileData
+{
+ ///
+ /// Gets a complex chunk file built using structured chunk types (NodeChunk, MiniNodeChunk, and DataChunk leaves),
+ /// including chunks with empty data, a MiniNodeChunk with one empty MiniChunk, and an empty MiniNodeChunk to exercise those allowed edge cases.
+ ///
+ public static ChunkFile StructuredFile { get; } = BuildStructuredFile();
+
+ ///
+ /// Gets the expected binary representation of the test chunk file,
+ /// matching the binary output of .
+ ///
+ public static ReadOnlyMemory ExpectedBytes { get; } = new byte[]
+ {
+ // Root[0]: DataChunk(type=0x01, data=[0xAA, 0xBB, 0xCC])
+ 0x01, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0xAA, 0xBB, 0xCC,
+ // Root[1]: NodeChunk(type=0x10, bodySize=0x2A | bit31)
+ 0x10, 0x00, 0x00, 0x00, 0x2A, 0x00, 0x00, 0x80,
+ // Child[0]: DataChunk(type=0x02, data=[0x11, 0x22])
+ 0x02, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x11, 0x22,
+ // Child[1]: NodeChunk(type=0x20, bodySize=0x09 | bit31)
+ 0x20, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x80,
+ // Grandchild: DataChunk(type=0x03, data=[0x33])
+ 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x33,
+ // Child[2]: MiniNodeChunk(type=0x30, bodySize=7)
+ 0x30, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00,
+ // Mini[0]: MiniChunk(type=0x04, data=[0x44, 0x55])
+ 0x04, 0x02, 0x44, 0x55,
+ // Mini[1]: MiniChunk(type=0x05, data=[0x66])
+ 0x05, 0x01, 0x66,
+ // Root[2]: DataChunk(type=0x06, data=[0x77, 0x88, 0x99, 0xDD])
+ 0x06, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x77, 0x88, 0x99, 0xDD,
+ // Root[3]: DataChunk(type=0x07, data=[])
+ 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ // Root[4]: NodeChunk(type=0x11, children=[])
+ 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
+ // Root[5]: MiniNodeChunk(type=0x31) with Mini[0]: MiniChunk(type=0x08, data=[])
+ 0x31, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
+ 0x08, 0x00,
+ // Root[6]: MiniNodeChunk(type=0x32, children=[])
+ 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ };
+
+ private static ChunkFile BuildStructuredFile()
+ {
+ return ChunkFactory.File(
+ ChunkFactory.Data(0x01, [0xAA, 0xBB, 0xCC]),
+ ChunkFactory.Node(0x10u,
+ ChunkFactory.Data(0x02, [0x11, 0x22]),
+ ChunkFactory.Node(0x20u,
+ ChunkFactory.Data(0x03, [0x33])),
+ ChunkFactory.Node(0x30u,
+ ChunkFactory.Mini(0x04, [0x44, 0x55]),
+ ChunkFactory.Mini(0x05, [0x66]))),
+ ChunkFactory.Data(0x06, [0x77, 0x88, 0x99, 0xDD]),
+ ChunkFactory.Data(0x07, []),
+ ChunkFactory.Node(0x11u, (RootChunk[])[]),
+ ChunkFactory.Node(0x31u, ChunkFactory.Mini(0x08, [])),
+ ChunkFactory.Node(0x32u, (MiniChunk[])[])
+ );
+ }
+
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/PG.StarWarsGame.Files.ChunkFiles.Test.csproj b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/PG.StarWarsGame.Files.ChunkFiles.Test.csproj
new file mode 100644
index 00000000..e1181964
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles.Test/PG.StarWarsGame.Files.ChunkFiles.Test.csproj
@@ -0,0 +1,37 @@
+
+
+ net8.0;net10.0
+ $(TargetFrameworks);net481
+ true
+
+
+ false
+ true
+ Exe
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/ChunkFactory.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/ChunkFactory.cs
new file mode 100644
index 00000000..d384d069
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/ChunkFactory.cs
@@ -0,0 +1,126 @@
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using System;
+using System.Diagnostics;
+using System.Linq;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Binary;
+
+///
+/// Provides factory methods for creating chunks and chunk files.
+///
+///
+/// Five chunk types are supported:
+///
+/// - — Regular chunk containing binary data.
+/// - — Chunk with a smaller, 2-byte header for data up to 255 bytes.
+/// - — Container chunk holding regular child chunks.
+/// - — Container chunk holding mini-chunk children.
+/// - — Chunk holding raw binary data.
+///
+///
+public static class ChunkFactory
+{
+ ///
+ /// Creates a data chunk with the specified type and binary data.
+ ///
+ /// The chunk type identifier.
+ /// The binary data to store in the chunk.
+ /// A new .
+ /// is .
+ public static DataChunk Data(uint type, byte[] data)
+ {
+ if (data == null)
+ throw new ArgumentNullException(nameof(data));
+ var metadata = new ChunkMetadata(type, (uint)data.Length);
+ return new DataChunk(metadata, data);
+ }
+
+ ///
+ /// Creates a raw chunk that stores its body as an uninterpreted byte blob.
+ ///
+ /// The chunk type identifier.
+ /// The raw size value written to the header. The high bit 31, set or unset, is not interpreted.
+ /// The raw body data.
+ /// A new .
+ /// is .
+ public static RawChunk Raw(uint type, uint rawSize, byte[] data)
+ {
+ if (data == null)
+ throw new ArgumentNullException(nameof(data));
+ var metadata = new ChunkMetadata(type, rawSize);
+ return new RawChunk(metadata, data);
+ }
+
+ ///
+ /// Creates a mini-chunk with the specified type and binary data.
+ ///
+ /// The mini-chunk type identifier.
+ /// The binary data to store. Maximum length is 255 bytes.
+ /// A new .
+ /// is .
+ /// The length of exceeds 255 bytes.
+ public static MiniChunk Mini(byte type, byte[] data)
+ {
+ if (data == null)
+ throw new ArgumentNullException(nameof(data));
+
+ if (data.Length > byte.MaxValue)
+ throw new ArgumentException($"Mini-chunk data cannot exceed {byte.MaxValue} bytes. Provided: {data.Length}.",
+ nameof(data));
+
+ var metadata = new MiniChunkMetadata(type, (byte)data.Length);
+ return new MiniChunk(metadata, data);
+ }
+
+ ///
+ /// Creates a node chunk containing regular child chunks.
+ ///
+ /// The chunk type identifier.
+ /// The child chunks.
+ /// A new with bit 31 set in the size field.
+ /// is .
+ /// The total size of the child chunks exceeds 2GB.
+ public static NodeChunk Node(uint type, params RootChunk[] children)
+ {
+ if (children == null)
+ throw new ArgumentNullException(nameof(children));
+
+ var size = (uint)children.Sum(c => c.Size);
+ if (size > int.MaxValue)
+ throw new InvalidOperationException("Chunk nodes cannot contain chunks with a total content size larger than 2GB.");
+ var metadata = new ChunkMetadata(type, size | 0x8000_0000u);
+ Debug.Assert((int)metadata.RawSize < 0);
+ return new NodeChunk(metadata, children);
+ }
+
+ ///
+ /// Creates a node chunk containing mini-chunk children.
+ ///
+ /// The chunk type identifier.
+ /// The mini-chunk children.
+ /// A new with bit 31 cleared in the size field.
+ /// is .
+ public static MiniNodeChunk Node(uint type, params MiniChunk[] children)
+ {
+ if (children == null)
+ throw new ArgumentNullException(nameof(children));
+
+ var size = (uint)children.Sum(c => c.Size);
+ var metadata = new ChunkMetadata(type, size);
+ return new MiniNodeChunk(metadata, children);
+ }
+
+ ///
+ /// Creates a chunk file containing the specified root chunks.
+ ///
+ /// The top-level chunks.
+ /// A new .
+ /// is .
+ public static ChunkFile File(params RootChunk[] rootChunks)
+ {
+ if (rootChunks == null)
+ throw new ArgumentNullException(nameof(rootChunks));
+ return new ChunkFile(rootChunks);
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Metadata/ChunkMetadata.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Metadata/ChunkMetadata.cs
deleted file mode 100644
index df47dc23..00000000
--- a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Metadata/ChunkMetadata.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Metadata;
-
-public readonly struct ChunkMetadata
-{
- public readonly int Type;
- public readonly int Size;
-
- private ChunkMetadata(int type, int size, bool isContainer, bool isMiniChunk)
- {
- Type = type;
- Size = size;
- IsMiniChunk = isMiniChunk;
- IsContainer = isContainer;
- }
-
- public bool IsContainer { get; }
-
- public bool IsMiniChunk { get; }
-
- public static ChunkMetadata FromContainer(int type, int size)
- {
- return new ChunkMetadata(type, size, true, false);
- }
-
- public static ChunkMetadata FromData(int type, int size, bool isMini = false)
- {
- return new ChunkMetadata(type, size, false, isMini);
- }
-}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/Chunk.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/Chunk.cs
new file mode 100644
index 00000000..10296133
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/Chunk.cs
@@ -0,0 +1,30 @@
+using System;
+using PG.StarWarsGame.Files.Binary;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+
+
+///
+/// Base class for all chunk types in a chunked file.
+///
+public abstract class Chunk : IBinary
+{
+ ///
+ /// Gets the total size of this chunk in bytes, including the header.
+ ///
+ public abstract int Size { get; }
+
+ ///
+ public byte[] Bytes
+ {
+ get
+ {
+ var bytes = new byte[Size];
+ GetBytes(bytes);
+ return bytes;
+ }
+ }
+
+ ///
+ public abstract void GetBytes(Span bytes);
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/ChunkFile.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/ChunkFile.cs
new file mode 100644
index 00000000..d9346444
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/ChunkFile.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using PG.StarWarsGame.Files.Binary.File;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+
+///
+/// Represents a chunked file containing one or more root-level chunks.
+///
+public sealed class ChunkFile : IBinaryFile
+{
+ ///
+ /// Gets the root-level chunks in this file.
+ ///
+ public IReadOnlyList RootChunks { get; }
+
+ ///
+ /// Gets the total file size in bytes.
+ ///
+ public int Size => RootChunks.Sum(c => c.Size);
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The root-level chunks.
+ /// is .
+ public ChunkFile(IReadOnlyList rootChunks)
+ {
+ RootChunks = rootChunks ?? throw new ArgumentNullException(nameof(rootChunks));
+ }
+
+ ///
+ /// Gets the file's binary representation as a byte array.
+ ///
+ public byte[] Bytes
+ {
+ get
+ {
+ var bytes = new byte[Size];
+ GetBytes(bytes);
+ return bytes;
+ }
+ }
+
+ ///
+ /// Writes the file's binary representation into the specified span.
+ ///
+ ///
+ /// The destination span. Must be at least bytes long.
+ ///
+ public void GetBytes(Span bytes)
+ {
+ var offset = 0;
+ foreach (var chunk in RootChunks)
+ {
+ chunk.GetBytes(bytes[offset..]);
+ offset += chunk.Size;
+ }
+ }
+
+ ///
+ /// Writes the file's binary representation to a stream.
+ ///
+ /// The destination stream.
+ /// is .
+ public void WriteTo(Stream stream)
+ {
+ if (stream == null)
+ throw new ArgumentNullException(nameof(stream));
+ stream.Write(Bytes, 0, Size);
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/DataChunk.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/DataChunk.cs
new file mode 100644
index 00000000..89afff91
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/DataChunk.cs
@@ -0,0 +1,56 @@
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using System;
+using System.Buffers.Binary;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+
+///
+/// A chunk containing binary data.
+///
+public sealed class DataChunk : RootChunk
+{
+ ///
+ /// Gets the chunk metadata.
+ ///
+ public ChunkMetadata Info { get; }
+
+ ///
+ /// Gets the chunk's binary data payload.
+ ///
+ public ReadOnlyMemory Data { get; }
+
+ ///
+ public override unsafe int Size => sizeof(ChunkMetadata) + Data.Length;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The chunk metadata. Must not have bit 31 set.
+ /// The binary data payload.
+ ///
+ /// has bit 31 set, or
+ /// body size does not match the data length.
+ ///
+ public DataChunk(ChunkMetadata info, ReadOnlyMemory data)
+ {
+ if (info.HasChildrenHint)
+ throw new ArgumentException(
+ "DataChunk metadata must not have bit 31 set.", nameof(info));
+
+ if (info.BodySize != data.Length)
+ throw new ArgumentException(
+ $"Metadata size ({info.BodySize}) does not match data length ({data.Length}).",
+ nameof(info));
+
+ Info = info;
+ Data = data;
+ }
+
+ ///
+ public override unsafe void GetBytes(Span bytes)
+ {
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes, Info.Type);
+ BinaryPrimitives.WriteInt32LittleEndian(bytes[sizeof(uint)..], Data.Length);
+ Data.Span.CopyTo(bytes[sizeof(ChunkMetadata)..]);
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/Metadata/ChunkMetadata.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/Metadata/ChunkMetadata.cs
new file mode 100644
index 00000000..a415e595
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/Metadata/ChunkMetadata.cs
@@ -0,0 +1,48 @@
+using System.Diagnostics;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+
+///
+/// Describes the header of a regular chunk.
+///
+///
+/// A regular chunk header is 8 bytes: 4 bytes for the type and 4 bytes for the size.
+/// Bit 31 of the size field indicates whether the chunk body contains child chunks.
+///
+[DebuggerDisplay("Type: 0x{Type:X8}, Size: {BodySize}, HasChildrenHint: {HasChildrenHint}")]
+public readonly struct ChunkMetadata
+{
+ ///
+ /// The chunk type identifier.
+ ///
+ public readonly uint Type;
+
+ ///
+ /// The raw size value as stored in the chunk header, including bit 31.
+ ///
+ public readonly uint RawSize;
+
+ ///
+ /// Gets a value indicating whether bit 31 of is set.
+ /// This is a hint that the body contains child chunks, not a guarantee.
+ ///
+ public bool HasChildrenHint => (int)RawSize < 0;
+
+ ///
+ /// Gets the size of the chunk body in bytes with bit 31 masked off.
+ ///
+ public int BodySize => (int)(RawSize & 0x7FFF_FFFF);
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The chunk type identifier.
+ ///
+ /// The raw size value. Bit 31 should be set if the chunk contains child chunks.
+ ///
+ public ChunkMetadata(uint type, uint rawSize)
+ {
+ Type = type;
+ RawSize = rawSize;
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/Metadata/MiniChunkMetadata.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/Metadata/MiniChunkMetadata.cs
new file mode 100644
index 00000000..9b702219
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/Metadata/MiniChunkMetadata.cs
@@ -0,0 +1,35 @@
+using System.Diagnostics;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+
+///
+/// Describes the header of a mini-chunk.
+///
+///
+/// A mini-chunk header is 2 bytes: 1 byte for the type and 1 byte for the size.
+/// Mini-chunks cannot contain children.
+///
+[DebuggerDisplay("Type: 0x{Type:X2}, Size: {BodySize}")]
+public readonly struct MiniChunkMetadata
+{
+ ///
+ /// The mini-chunk type identifier.
+ ///
+ public readonly byte Type;
+
+ ///
+ /// The size of the mini-chunk body in bytes.
+ ///
+ public readonly byte BodySize;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The mini-chunk type identifier.
+ /// The size of the mini-chunk body in bytes.
+ public MiniChunkMetadata(byte type, byte size)
+ {
+ Type = type;
+ BodySize = size;
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/MiniChunk.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/MiniChunk.cs
new file mode 100644
index 00000000..cde78a5f
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/MiniChunk.cs
@@ -0,0 +1,59 @@
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using System;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+
+///
+/// A mini-chunk containing binary data with a compact 2-byte header.
+///
+///
+///
+/// Mini-chunks use a 2-byte header (1 byte type, 1 byte size) instead of the standard 8-byte header.
+/// They can only appear as children of a .
+///
+///
+/// A mini chunk cannot be used as a root chunk or as a child of a .
+///
+///
+public sealed class MiniChunk : Chunk
+{
+ ///
+ /// Gets the mini-chunk metadata.
+ ///
+ public MiniChunkMetadata Info { get; }
+
+ ///
+ /// Gets the mini-chunk's binary data payload.
+ ///
+ public ReadOnlyMemory Data { get; }
+
+ ///
+ public override unsafe int Size => sizeof(MiniChunkMetadata) + Data.Length;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The mini-chunk metadata.
+ /// The binary data payload. Maximum length is 255 bytes.
+ ///
+ /// size does not match the data length.
+ ///
+ public MiniChunk(MiniChunkMetadata info, ReadOnlyMemory data)
+ {
+ if (info.BodySize != data.Length)
+ throw new ArgumentException(
+ $"Metadata size ({info.BodySize}) does not match data length ({data.Length}).",
+ nameof(info));
+
+ Info = info;
+ Data = data;
+ }
+
+ ///
+ public override unsafe void GetBytes(Span bytes)
+ {
+ bytes[0] = Info.Type;
+ bytes[1] = Info.BodySize;
+ Data.Span.CopyTo(bytes[sizeof(MiniChunkMetadata)..]);
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/MiniNodeChunk.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/MiniNodeChunk.cs
new file mode 100644
index 00000000..8e5b50f5
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/MiniNodeChunk.cs
@@ -0,0 +1,29 @@
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using System;
+using System.Collections.Generic;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+
+///
+/// A chunk containing mini-chunk children.
+///
+///
+///
+/// This chunk type holds exclusively children.
+/// Bit 31 of the size field is not used as a container flag.
+///
+///
+public sealed class MiniNodeChunk : NodeChunkBase
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The chunk metadata. Must not have bit 31 set.
+ /// The mini-chunk children.
+ /// Chunks larger than 2GB are not supported.
+ public MiniNodeChunk(ChunkMetadata info, IReadOnlyList children) : base(info, children)
+ {
+ if (info.RawSize > int.MaxValue)
+ throw new NotSupportedException("Chunks larger than int32.MaxValue bytes are not supported.");
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/NodeChunk.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/NodeChunk.cs
new file mode 100644
index 00000000..eabc9e18
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/NodeChunk.cs
@@ -0,0 +1,27 @@
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using System;
+using System.Collections.Generic;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+
+///
+/// A chunk containing regular child chunks.
+///
+///
+/// Bit 31 of the size field must be set.
+///
+public sealed class NodeChunk : NodeChunkBase
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The chunk metadata. Must have bit 31 set.
+ /// The child chunks.
+ /// does not have bit 31 set.
+ public NodeChunk(ChunkMetadata info, IReadOnlyList children) : base(info, children)
+ {
+ if (!info.HasChildrenHint)
+ throw new ArgumentException(
+ "NodeChunk metadata must have bit 31 set.", nameof(info));
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/NodeChunkBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/NodeChunkBase.cs
new file mode 100644
index 00000000..e654025b
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/NodeChunkBase.cs
@@ -0,0 +1,65 @@
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+
+///
+/// Base class for chunks that contain child chunks of type .
+///
+/// The type of child chunk this node contains.
+public abstract class NodeChunkBase : RootChunk where TChild : Chunk
+{
+ ///
+ /// Gets the chunk metadata.
+ ///
+ public ChunkMetadata Info { get; }
+
+ ///
+ /// Gets the child chunks.
+ ///
+ public IReadOnlyList Children { get; }
+
+ ///
+ public override unsafe int Size => sizeof(ChunkMetadata) + Children.Sum(c => c.Size);
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The chunk metadata.
+ /// The child chunks.
+ /// is .
+ ///
+ /// body size does not match the sum of children sizes.
+ ///
+ protected NodeChunkBase(ChunkMetadata info, IReadOnlyList children)
+ {
+ if (children == null)
+ throw new ArgumentNullException(nameof(children));
+
+ var actualSize = children.Sum(c => c.Size);
+ if (info.BodySize != actualSize)
+ throw new ArgumentException(
+ $"Metadata size ({info.BodySize}) does not match sum of children sizes ({actualSize}).",
+ nameof(info));
+
+ Info = info;
+ Children = children;
+ }
+
+ ///
+ public override unsafe void GetBytes(Span bytes)
+ {
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes, Info.Type);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes[sizeof(uint)..], Info.RawSize);
+
+ var offset = sizeof(ChunkMetadata);
+ foreach (var child in Children)
+ {
+ child.GetBytes(bytes[offset..]);
+ offset += child.Size;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/RawChunk.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/RawChunk.cs
new file mode 100644
index 00000000..e4ec33fc
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/RawChunk.cs
@@ -0,0 +1,68 @@
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using System;
+using System.Buffers.Binary;
+
+namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+
+///
+/// A chunk that stores raw binary data without interpreting its contents.
+///
+///
+///
+/// This chunk stores its body as a raw byte blob. The body may contain
+/// any content: raw data, child chunks, mini-chunks, or any combination.
+/// No structural interpretation or validation is performed on the body.
+///
+///
+/// This is useful for:
+///
+///
+/// - Round-tripping unknown or unparsed chunks.
+/// - Creating chunks from pre-serialized data.
+///
+///
+/// The metadata is written as-is, including bit 31 of the size field.
+///
+///
+public sealed class RawChunk : RootChunk
+{
+ ///
+ /// Gets the chunk metadata.
+ ///
+ public ChunkMetadata Info { get; }
+
+ ///
+ /// Gets the raw body data.
+ ///
+ public ReadOnlyMemory Data { get; }
+
+ ///
+ public override unsafe int Size => sizeof(ChunkMetadata) + Data.Length;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The chunk metadata, written as-is. No validation is performed on bit 31.
+ /// The raw body data.
+ ///
+ /// body size does not match the data length.
+ ///
+ public RawChunk(ChunkMetadata info, ReadOnlyMemory data)
+ {
+ if (info.BodySize != data.Length)
+ throw new ArgumentException(
+ $"Metadata size ({info.BodySize}) does not match data length ({data.Length}).",
+ nameof(info));
+
+ Info = info;
+ Data = data;
+ }
+
+ ///
+ public override unsafe void GetBytes(Span bytes)
+ {
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes, Info.Type);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes[sizeof(uint)..], Info.RawSize);
+ Data.Span.CopyTo(bytes[sizeof(ChunkMetadata)..]);
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/RootChunk.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/RootChunk.cs
new file mode 100644
index 00000000..e079bf06
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Model/RootChunk.cs
@@ -0,0 +1,7 @@
+namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+
+///
+/// Base class for chunks that can appear as root-level elements in a
+/// or as children in a .
+///
+public abstract class RootChunk : Chunk;
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/ChunkFileReaderBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/ChunkFileReaderBase.cs
index 01d085ac..f001d112 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/ChunkFileReaderBase.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/ChunkFileReaderBase.cs
@@ -1,23 +1,69 @@
-using System.IO;
+using System;
+using System.IO;
using AnakinRaW.CommonUtilities;
+using PG.Commons.Utilities;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
using PG.StarWarsGame.Files.ChunkFiles.Data;
namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Reader;
-public abstract class ChunkFileReaderBase(Stream stream) : DisposableObject, IChunkFileReader where T : IChunkData
+///
+/// Represents the base class for reading chunk files in a binary format.
+///
+/// The type of chunk data being read.
+/// The input stream from which the chunk file is read.
+///
+/// A boolean value indicating whether the input stream should remain open after the reader is disposed.
+/// Default value is .
+///
+public abstract class ChunkFileReaderBase(Stream stream, bool leaveStreamOpen = false)
+ : DisposableObject, IChunkFileReader where T : IChunkData
{
- protected readonly ChunkReader ChunkReader = new(stream);
+ ///
+ /// The instance used to read chunk data from the input stream.
+ ///
+ ///
+ /// This reader is initialized with the provided stream and the option to leave the stream open after disposal.
+ ///
+ protected readonly ChunkReader ChunkReader = new(stream, leaveStreamOpen);
+ ///
+ public string? FileName { get; } = stream.TryGetFilePath();
+
+ ///
public abstract T Read();
+ ///
IChunkData IChunkFileReader.Read()
{
return Read();
}
+ ///
+ /// Releases the resources used by the instance, including
+ /// the associated .
+ ///
protected override void DisposeResources()
{
base.DisposeResources();
ChunkReader.Dispose();
}
+
+ ///
+ /// Validates the raw binary size of the specified chunk and throws an exception if the size exceeds the maximum allowed value.
+ ///
+ ///
+ /// This method should not be used for , as their raw size always exceeds
+ /// due to the presence of the high bit flag in the size field, which indicates that the chunk has child chunks.
+ ///
+ /// The metadata of the chunk to validate.
+ ///
+ /// Thrown when the raw binary size of the chunk exceeds , as such sizes are not supported.
+ ///
+ protected void ThrowIfChunkSizeTooLargeException(ChunkMetadata chunk)
+ {
+ if (chunk.RawSize > int.MaxValue)
+ throw new NotSupportedException("Chunk sizes larger than int.MaxValue are not supported.");
+ }
}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/ChunkReader.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/ChunkReader.cs
index 742924dd..5b67f1da 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/ChunkReader.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/ChunkReader.cs
@@ -3,14 +3,37 @@
using System.Text;
using AnakinRaW.CommonUtilities;
using PG.StarWarsGame.Files.Binary;
-using PG.StarWarsGame.Files.ChunkFiles.Binary.Metadata;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Reader;
+///
+/// Reads chunks and their data from a stream.
+///
+///
+///
+/// The reader operates sequentially on the underlying stream. It reads chunk headers,
+/// mini-chunk headers, and raw data. It does not build a tree structure — callers are
+/// responsible for interpreting chunk relationships based on the file format specification.
+///
+///
+/// Several methods accept a ref int readBytes parameter that tracks the number
+/// of bytes read. This is useful for parsing chunk bodies where the caller needs to know
+/// when the body has been fully consumed.
+///
+///
public class ChunkReader : DisposableObject
{
private readonly PetroglyphBinaryReader _binaryReader;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The stream to read from.
+ ///
+ /// to leave the stream open after this reader is disposed; otherwise, .
+ ///
+ /// is .
public ChunkReader(Stream stream, bool leaveOpen = false)
{
if (stream == null)
@@ -18,108 +41,290 @@ public ChunkReader(Stream stream, bool leaveOpen = false)
_binaryReader = new PetroglyphBinaryReader(stream, leaveOpen);
}
+ ///
+ /// Reads an 8-byte chunk header from the stream.
+ ///
+ /// The chunk metadata.
+ /// The end of the stream is reached before the header could be read.
public ChunkMetadata ReadChunk()
{
- var type = _binaryReader.ReadInt32();
- var rawSize = _binaryReader.ReadInt32();
-
- var isContainer = (rawSize & 0x80000000) != 0;
- var size = rawSize & 0x7FFFFFFF;
-
- return isContainer ? ChunkMetadata.FromContainer(type, size) : ChunkMetadata.FromData(type, size);
+ var type = _binaryReader.ReadUInt32();
+ var rawSize = _binaryReader.ReadUInt32();
+ return new ChunkMetadata(type, rawSize);
}
+ ///
+ /// Reads an 8-byte chunk header from the stream and advances the byte counter.
+ ///
+ ///
+ /// Holds the number of bytes read so far.
+ /// When this method returns, it is incremented by 8.
+ ///
+ /// The chunk metadata.
+ /// The end of the stream is reached before the header could be read.
public ChunkMetadata ReadChunk(ref int readBytes)
{
var chunk = ReadChunk();
- readBytes += 8;
+ IncrementReadBytesByChunkSize(ref readBytes);
return chunk;
}
- public ChunkMetadata ReadMiniChunk(ref int readBytes)
+ ///
+ /// Reads a 2-byte mini-chunk header from the stream and advances the byte counter.
+ ///
+ ///
+ /// Holds the number of bytes read so far.
+ /// When this method returns, it is incremented by 2.
+ ///
+ /// The mini-chunk metadata.
+ /// The end of the stream is reached before the header could be read.
+ public MiniChunkMetadata ReadMiniChunk(ref int readBytes)
{
var type = _binaryReader.ReadByte();
var size = _binaryReader.ReadByte();
+ IncrementReadBytesByMiniChunkSize(ref readBytes);
+ return new MiniChunkMetadata(type, size);
+ }
+
+ ///
+ /// Reads the body data of a chunk.
+ ///
+ /// The chunk whose body to read.
+ /// The chunk body as a byte array.
+ /// Attempt to read data from a node chunk.
+ /// The end of the stream is reached before the body could be read.
+ public byte[] ReadData(ChunkMetadata chunk)
+ {
+ if (chunk.RawSize > int.MaxValue)
+ throw new InvalidOperationException("Cannot to read data from container chunk.");
+ return _binaryReader.ReadBytes(chunk.BodySize);
+ }
+
+ ///
+ /// Reads the body data of a mini chunk.
+ ///
+ /// The mini chunk whose body to read.
+ /// The chunk body as a byte array.
+ /// The end of the stream is reached before the body could be read.
+ public byte[] ReadData(MiniChunkMetadata chunk)
+ {
+ return _binaryReader.ReadBytes(chunk.BodySize);
+ }
+
+ ///
+ /// Reads the specified number of bytes from the stream.
+ ///
+ /// The number of bytes to read.
+ /// The data as a byte array.
+ /// is negative.
+ /// The end of the stream is reached before all bytes could be read.
+ public byte[] ReadData(int size)
+ {
+ return size < 0
+ ? throw new ArgumentOutOfRangeException(nameof(size), "size cannot be negative")
+ : _binaryReader.ReadBytes(size);
+ }
- readBytes += 2;
+ ///
+ /// Reads the body data of a chunk and advances the byte counter.
+ ///
+ /// The chunk whose body to read.
+ ///
+ /// Holds the number of bytes read so far.
+ /// When this method returns, it is incremented by the body size of .
+ ///
+ /// The chunk body as a byte array.
+ /// Attempt to read data from a node chunk.
+ /// The end of the stream is reached before the body could be read.
+ public byte[] ReadData(ChunkMetadata chunk, ref int readBytes)
+ {
+ if (chunk.RawSize >= int.MaxValue)
+ throw new InvalidOperationException("Cannot to read data from container chunk.");
+
+ var data = _binaryReader.ReadBytes(chunk.BodySize);
+ readBytes += chunk.BodySize;
+ return data;
+ }
- return ChunkMetadata.FromData(type, size, true);
+ ///
+ /// Reads the body data of a mini chunk and advances the byte counter.
+ ///
+ /// The mini chunk whose body to read.
+ ///
+ /// Holds the number of bytes read so far.
+ /// When this method returns, it is incremented by the body size of .
+ ///
+ /// The chunk body as a byte array.
+ /// The end of the stream is reached before the body could be read.
+ public byte[] ReadData(MiniChunkMetadata chunk, ref int readBytes)
+ {
+ var data = _binaryReader.ReadBytes(chunk.BodySize);
+ readBytes += chunk.BodySize;
+ return data;
}
- public uint ReadDword(ref int readSize)
+ ///
+ /// Reads a 4-byte unsigned integer from the stream and advances the byte counter.
+ ///
+ ///
+ /// Holds the number of bytes read so far.
+ /// When this method returns, it is incremented by 4.
+ ///
+ /// The value read.
+ /// The end of the stream is reached before the value could be read.
+ public uint ReadDword(ref int readBytes)
{
var value = _binaryReader.ReadUInt32();
- readSize += sizeof(uint);
+ readBytes += sizeof(uint);
return value;
}
- public float ReadFloat(ref int readSize)
+ ///
+ /// Reads a 4-byte floating-point value from the stream and advances the byte counter.
+ ///
+ ///
+ /// Holds the number of bytes read so far.
+ /// When this method returns, it is incremented by 4.
+ ///
+ /// The value read.
+ /// The end of the stream is reached before the value could be read.
+ public float ReadFloat(ref int readBytes)
{
var value = _binaryReader.ReadSingle();
- readSize += sizeof(float);
+ readBytes += sizeof(float);
return value;
}
+ ///
+ /// Reads a 4-byte unsigned integer from the stream.
+ ///
+ /// The value read.
+ /// The end of the stream is reached before the value could be read.
public uint ReadDword()
- {
+ {
return _binaryReader.ReadUInt32();
}
+ ///
+ /// Advances the stream position by the specified number of bytes
+ /// and advances the byte counter.
+ ///
+ /// The number of bytes to skip.
+ /// Incremented by .
public void Skip(int bytesToSkip, ref int readBytes)
{
_binaryReader.BaseStream.Seek(bytesToSkip, SeekOrigin.Current);
readBytes += bytesToSkip;
}
+ ///
+ /// Advances the stream position by the specified number of bytes.
+ ///
+ /// The number of bytes to skip.
public void Skip(int bytesToSkip)
{
_binaryReader.BaseStream.Seek(bytesToSkip, SeekOrigin.Current);
}
- public string ReadString(int size, Encoding encoding, bool zeroTerminated, ref int readSize)
+ ///
+ /// Reads a string from the stream and advances the byte counter.
+ ///
+ /// The number of bytes to read for the string.
+ /// The character encoding to use.
+ ///
+ /// to trim the string at the first null character;otherwise, .
+ ///
+ ///
+ /// Holds the number of bytes read so far.
+ /// When this method returns, it is incremented by .
+ ///
+ /// The decoded string.
+ /// The string data could not be decoded.
+ /// The end of the stream is reached before all bytes could be read.
+ public string ReadString(int size, Encoding encoding, bool zeroTerminated, ref int readBytes)
{
var value = ReadString(encoding, size, zeroTerminated);
- readSize += size;
+ readBytes += size;
return value;
}
+ ///
+ /// Reads a string from the stream.
+ ///
+ /// The number of bytes to read for the string.
+ /// The character encoding to use.
+ ///
+ /// to trim the string at the first null character;otherwise, .
+ ///
+ /// The decoded string.The string data could not be decoded.
+ /// The end of the stream is reached before all bytes could be read.
public string ReadString(int size, Encoding encoding, bool zeroTerminated)
{
var value = ReadString(encoding, size, zeroTerminated);
return value;
}
- private string ReadString(Encoding encoding, int size, bool zeroTerminated)
- {
- try
- {
- return _binaryReader.ReadString(encoding, size, zeroTerminated);
- }
- catch (Exception e)
- {
- throw new BinaryCorruptedException($"Unable to read string: {e.Message}", e);
- }
- }
-
+ ///
+ /// Reads a chunk header from the stream if data is available.
+ ///
+ ///
+ /// The chunk metadata, or if the stream is at the end.
+ ///
public ChunkMetadata? TryReadChunk()
{
var _ = 0;
return TryReadChunk(ref _);
}
- public ChunkMetadata? TryReadChunk(ref int size)
+ ///
+ /// Reads a chunk header from the stream if data is available
+ /// and advances the byte counter.
+ ///
+ ///
+ /// Holds the number of bytes read so far.
+ /// When this method returns and a chunk was read, it is incremented by 8.
+ ///
+ ///
+ /// The chunk metadata, or if the stream is at the end.
+ ///
+ public ChunkMetadata? TryReadChunk(ref int readBytes)
{
if (_binaryReader.BaseStream.Position == _binaryReader.BaseStream.Length)
return null;
-
+
var chunk = ReadChunk();
- size += 8;
+ IncrementReadBytesByChunkSize(ref readBytes);
return chunk;
}
+ ///
+ /// Releases the resources used by the .
+ ///
protected override void DisposeResources()
{
base.DisposeResources();
_binaryReader.Dispose();
}
+
+ private static unsafe void IncrementReadBytesByChunkSize(ref int readBytes)
+ {
+ readBytes += sizeof(ChunkMetadata);
+ }
+
+ private static unsafe void IncrementReadBytesByMiniChunkSize(ref int readBytes)
+ {
+ readBytes += sizeof(MiniChunkMetadata);
+ }
+
+ private string ReadString(Encoding encoding, int size, bool zeroTerminated)
+ {
+ try
+ {
+ return _binaryReader.ReadString(encoding, size, zeroTerminated);
+ }
+ catch (Exception e)
+ {
+ throw new BinaryCorruptedException($"Unable to read string: {e.Message}", e);
+ }
+ }
}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/IChunkFileReader.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/IChunkFileReader.cs
index 49d525a1..cdc3379f 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/IChunkFileReader.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Binary/Reader/IChunkFileReader.cs
@@ -1,14 +1,32 @@
using System;
+using PG.StarWarsGame.Files.Binary;
using PG.StarWarsGame.Files.ChunkFiles.Data;
namespace PG.StarWarsGame.Files.ChunkFiles.Binary.Reader;
+///
+/// Reads a chunked file from a stream and produces a parsed representation.
+///
public interface IChunkFileReader : IDisposable
{
+ ///
+ /// Reads the chunked file and returns its parsed data.
+ ///
+ /// The parsed chunk data.
+ /// The stream contains invalid or unexpected data.
IChunkData Read();
}
+///
+/// Reads a chunked file from a stream and produces a strongly-typed parsed representation.
+///
+/// The type of chunk data produced by this reader.
public interface IChunkFileReader : IChunkFileReader where T : IChunkData
{
+ ///
+ /// Reads the chunked file and returns its parsed data.
+ ///
+ /// The parsed chunk data of type .
+ /// The stream contains invalid or unexpected data.
new T Read();
}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj
index 7767e2bb..36070cfd 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj
@@ -15,6 +15,7 @@
true
snupkg
preview
+ true
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Properties/AssemblyAttributes.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Properties/AssemblyAttributes.cs
new file mode 100644
index 00000000..6e1a5430
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/Properties/AssemblyAttributes.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly:InternalsVisibleTo("PG.StarWarsGame.Files.ChunkFiles.Test")]
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED.Test/CommonDatTestBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED.Test/CommonDatTestBase.cs
new file mode 100644
index 00000000..85589e41
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED.Test/CommonDatTestBase.cs
@@ -0,0 +1,13 @@
+using AnakinRaW.CommonUtilities.Testing;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace PG.StarWarsGame.Files.TED.Test;
+
+public class CommonDatTestBase : TestBaseWithFileSystem
+{
+ protected override void SetupServices(IServiceCollection serviceCollection)
+ {
+ base.SetupServices(serviceCollection);
+ serviceCollection.SupportTED();
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED.Test/PG.StarWarsGame.Files.TED.Test.csproj b/src/PetroglyphTools/PG.StarWarsGame.Files.TED.Test/PG.StarWarsGame.Files.TED.Test.csproj
new file mode 100644
index 00000000..acb477d9
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED.Test/PG.StarWarsGame.Files.TED.Test.csproj
@@ -0,0 +1,38 @@
+
+
+
+ net8.0;net10.0
+ $(TargetFrameworks);net481
+ true
+
+
+ false
+ true
+ Exe
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED.Test/Services/TedFileServiceTest.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED.Test/Services/TedFileServiceTest.cs
new file mode 100644
index 00000000..49871310
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED.Test/Services/TedFileServiceTest.cs
@@ -0,0 +1,52 @@
+using System.IO;
+using System.IO.Abstractions;
+using AnakinRaW.CommonUtilities.Hashing;
+using AnakinRaW.CommonUtilities.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using PG.Commons;
+using PG.StarWarsGame.Files.TED.Services;
+using Testably.Abstractions;
+using Xunit;
+
+namespace PG.StarWarsGame.Files.TED.Test.Services;
+
+public class TedFileServiceTest : TestBaseWithFileSystem
+{
+ private readonly TedFileService _service;
+
+ protected override IFileSystem CreateFileSystem()
+ {
+ return new RealFileSystem();
+ }
+
+ public TedFileServiceTest()
+ {
+ _service = new TedFileService(ServiceProvider);
+ }
+
+ protected override void SetupServices(IServiceCollection serviceCollection)
+ {
+ base.SetupServices(serviceCollection);
+ base.SetupServices(serviceCollection);
+ serviceCollection.AddSingleton(sp => new HashingService(sp));
+ PetroglyphCommons.ContributeServices(serviceCollection);
+ serviceCollection.SupportTED();
+ }
+
+ [Fact]
+ public void Foo()
+ {
+ const string path = @"C:\Program Files (x86)\Steam\steamapps\workshop\content\32470\1129810972\Data\Art\Maps\_land_planet_felucia_00.ted";
+ using var tedFs = FileSystem.FileStream.New(path, FileMode.Open);
+ using var dest = FileSystem.FileStream.New("c:/test/map.ted", FileMode.Create);
+ _service.RemoveMapPreview(tedFs, dest, true, out var bytes);
+
+ if (bytes is not null)
+ {
+ using var img = FileSystem.FileStream.New("c:/test/img.dds", FileMode.Create);
+ using var streamWriter = new BinaryWriter(img);
+ streamWriter.Write(bytes);
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Reader/ITedFileReader.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Reader/ITedFileReader.cs
new file mode 100644
index 00000000..619e29cb
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Reader/ITedFileReader.cs
@@ -0,0 +1,6 @@
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Reader;
+using PG.StarWarsGame.Files.TED.Data;
+
+namespace PG.StarWarsGame.Files.TED.Binary.Reader;
+
+internal interface ITedFileReader : IChunkFileReader;
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Reader/ITedFileReaderFactory.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Reader/ITedFileReaderFactory.cs
new file mode 100644
index 00000000..a0cc0824
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Reader/ITedFileReaderFactory.cs
@@ -0,0 +1,8 @@
+using System.IO;
+
+namespace PG.StarWarsGame.Files.TED.Binary.Reader;
+
+internal interface ITedFileReaderFactory
+{
+ ITedFileReader GetReader(Stream dataStream);
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Reader/TedFileReader.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Reader/TedFileReader.cs
new file mode 100644
index 00000000..42e1a936
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Reader/TedFileReader.cs
@@ -0,0 +1,169 @@
+using PG.StarWarsGame.Files.Binary;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Reader;
+using PG.StarWarsGame.Files.TED.Data;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Numerics;
+
+namespace PG.StarWarsGame.Files.TED.Binary.Reader;
+
+internal sealed class TedFileReader(TedLoadOptions loadOptions, Stream stream)
+ : ChunkFileReaderBase(stream), ITedFileReader
+{
+ protected TedLoadOptions LoadOptions { get; } = loadOptions;
+
+ public override IMapData Read()
+ {
+ ChunkMetadata? chunk;
+ MapInfo? mapInfo = null;
+
+ byte[]? previewImageData = null;
+ int? previewImageDataSize = null;
+ List startPositionMarkers = [];
+
+ var chunkCount = 0;
+ while ((chunk = ChunkReader.TryReadChunk()) is not null)
+ {
+ if (chunkCount++ == 0 && chunk.Value.Type != (uint)TedChunkType.MapInfo)
+ throw new BinaryCorruptedException($"The first chunk of a TED file must be of type {TedChunkType.MapInfo}.");
+
+ switch ((TedChunkType)chunk.Value.Type)
+ {
+ case TedChunkType.MapInfo:
+ mapInfo = ReadMapInformation(chunk.Value);
+ break;
+ case TedChunkType.StartPositions:
+ ReadStartPositionMarkers(chunk.Value, startPositionMarkers);
+ break;
+ case TedChunkType.MapPreviewImageData:
+ ReadMapPreviewImageData(chunk.Value, out previewImageDataSize, out previewImageData);
+ break;
+ default:
+ throw new BinaryCorruptedException($"Unknown chunk type: {chunk.Value.Type}.");
+ }
+ }
+
+ if (mapInfo is null)
+ throw new BinaryCorruptedException("The TED file does not have map information data.");
+
+ mapInfo = mapInfo with
+ {
+ EmbeddedPreviewTextureFile = previewImageData,
+ EmbeddedPreviewTextureFileSize = previewImageDataSize,
+ StartPositionMarkers = startPositionMarkers
+ };
+
+ return new MapData(mapInfo);
+ }
+
+ private void ReadStartPositionMarkers(ChunkMetadata chunk, List startPositionMarkers)
+ {
+ var readBytes = 0;
+ var positionsCount = (int)ChunkReader.ReadDword(ref readBytes);
+
+ startPositionMarkers.Clear();
+
+ for (var i = 0; i < positionsCount; i++)
+ {
+ var x = ChunkReader.ReadFloat(ref readBytes);
+ var y = ChunkReader.ReadFloat(ref readBytes);
+ startPositionMarkers.Add(new Vector2(x, y));
+ }
+
+ if (readBytes != chunk.BodySize)
+ throw new BinaryCorruptedException(
+ $"Unable to read Start Position Markers chunk. Expected {chunk.BodySize} bytes, but read {readBytes} bytes.");
+ }
+
+ private void ReadMapPreviewImageData(ChunkMetadata chunk, out int? previewImageDataSize, out byte[]? previewImageData)
+ {
+ Debug.Assert(!chunk.HasChildrenHint);
+
+ previewImageDataSize = chunk.BodySize;
+
+ if (LoadOptions.HasFlag(TedLoadOptions.PreviewImageData))
+ {
+ previewImageData = ChunkReader.ReadData(chunk);
+ return;
+ }
+
+ ChunkReader.Skip(chunk.BodySize);
+ previewImageData = null;
+ }
+
+ private MapInfo ReadMapInformation(ChunkMetadata chunk)
+ {
+ var readBytes = 0;
+
+ var mapFileName = FileName ?? "[NO FILE NAME]";
+ int version = 0;
+ var type = -1;
+ var playerCount = 1;
+ var mapLevels = 1;
+ var supportedWeather = 1;
+ var factionOwner = 0;
+ var mapType = 0;
+ var supportedSystems = 0;
+ var mapName = string.Empty;
+ var planetName = string.Empty;
+ var gameTypes = string.Empty;
+ var customMap = false;
+ var width = 0.0f;
+ var height = 0.0f;
+ var newMultiplayerMarkerSystem = false;
+
+ MiniChunkMetadata? miniChunk;
+ while ((miniChunk = ChunkReader.ReadMiniChunk(ref readBytes)) is not null)
+ {
+ switch ((MapInfoChunkType)miniChunk.Value.Type)
+ {
+ case MapInfoChunkType.Version:
+ version = (int)ChunkReader.ReadDword(ref readBytes);
+ Debug.Assert(version == 513); // 01 02 00 00 (LE)
+ break;
+ }
+ }
+
+ if (version == 0)
+ throw new BinaryCorruptedException("Map Information chunk is missing the version mini-chunk.");
+
+ if (readBytes != chunk.BodySize)
+ throw new BinaryCorruptedException(
+ $"Unable to read Map Information chunk. Expected {chunk.BodySize} bytes, but read {readBytes} bytes.");
+
+ if (string.IsNullOrEmpty(mapName))
+ mapName = mapFileName;
+
+ return new MapInfo
+ {
+ MapFileName = mapFileName,
+ Version = version,
+ Type = (MapMode)type,
+ MaxPlayers = playerCount,
+ Levels = mapLevels,
+ Terrain = (MapEnvironmentType)mapType,
+ Owner = (MapOwnerType)factionOwner,
+ MapName = mapName,
+ PlanetName = planetName,
+ GameTypes = gameTypes.ToUpper(), // The game actually uses the current locale
+ CustomMap = customMap,
+ MapSize = new Vector2(width, height),
+ NewMultiplayerMarkerSystem = newMultiplayerMarkerSystem
+ };
+ }
+}
+
+
+[Flags]
+public enum TedLoadOptions
+{
+ ///
+ /// Loads the entire file.
+ ///
+ Full = 0,
+ PreviewImageData = 1,
+ XRefs = 2
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Reader/TedFileReaderFactory.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Reader/TedFileReaderFactory.cs
new file mode 100644
index 00000000..9bde79cb
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Reader/TedFileReaderFactory.cs
@@ -0,0 +1,12 @@
+using System;
+using System.IO;
+
+namespace PG.StarWarsGame.Files.TED.Binary.Reader;
+
+internal class TedFileReaderFactory(IServiceProvider serviceProvider) : ITedFileReaderFactory
+{
+ public ITedFileReader GetReader(Stream dataStream)
+ {
+ return new TedFileReader(TedLoadOptions.Full, dataStream);
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/TedChunkType.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/TedChunkType.cs
new file mode 100644
index 00000000..a8308562
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/TedChunkType.cs
@@ -0,0 +1,27 @@
+namespace PG.StarWarsGame.Files.TED.Binary;
+
+internal enum TedChunkType : uint
+{
+ MapInfo = 0x0,
+ StartPositions = 0x3,
+ MapPreviewImageData = 0x13,
+}
+
+internal enum MapInfoChunkType : uint
+{
+ Version = 0,
+ Type = 1,
+ PlayerCount = 2,
+ MapLevels = 3,
+ SupportedWeather = 4,
+ FactionOwner = 5,
+ MapType = 6,
+ SupportedSystems = 7,
+ MapName = 8,
+ PlanetName = 9,
+ GameTypes = 10,
+ CustomMap = 11,
+ Width = 16,
+ Height = 17,
+ NewMultiplayerMarkerSystem = 18,
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Writer/MapPreviewExtractor.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Writer/MapPreviewExtractor.cs
new file mode 100644
index 00000000..3a873ba5
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Binary/Writer/MapPreviewExtractor.cs
@@ -0,0 +1,58 @@
+using System;
+using System.IO;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Model.Metadata;
+using PG.StarWarsGame.Files.ChunkFiles.Binary.Reader;
+
+namespace PG.StarWarsGame.Files.TED.Binary.Writer;
+
+internal sealed class MapPreviewExtractor
+{
+ public bool ContainsPreviewPicture(Stream tedStream)
+ {
+ if (tedStream == null)
+ throw new ArgumentNullException(nameof(tedStream));
+ using var reader = new ChunkReader(tedStream, true);
+ ChunkMetadata? chunkInfo;
+ while ((chunkInfo = reader.TryReadChunk()) != null)
+ {
+ if (chunkInfo.Value.Type == 0x13)
+ return true;
+ reader.Skip(chunkInfo.Value.BodySize);
+ }
+ return false;
+ }
+
+ public bool ExtractPreview(Stream tedStream, Stream destination, bool extract, out byte[]? previewImageBytes)
+ {
+ if (tedStream == null)
+ throw new ArgumentNullException(nameof(tedStream));
+ if (destination == null)
+ throw new ArgumentNullException(nameof(destination));
+
+ previewImageBytes = null;
+ var extracted = false;
+
+ using var reader = new ChunkReader(tedStream, true);
+
+ ChunkMetadata? chunkInfo;
+ while ((chunkInfo = reader.TryReadChunk()) != null)
+ {
+ if (chunkInfo.Value.Type == 0x13)
+ {
+ extracted = true;
+ if (extract)
+ previewImageBytes = reader.ReadData(chunkInfo.Value.BodySize);
+ else
+ reader.Skip(chunkInfo.Value.BodySize);
+ }
+ else
+ {
+ var chunk = new RawChunk(chunkInfo.Value, reader.ReadData(chunkInfo.Value.BodySize));
+ destination.Write(chunk.Bytes, 0, chunk.Bytes.Length);
+ }
+ }
+
+ return extracted;
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Data/IMapData.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Data/IMapData.cs
new file mode 100644
index 00000000..1bafc19e
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Data/IMapData.cs
@@ -0,0 +1,139 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AnakinRaW.CommonUtilities;
+using PG.StarWarsGame.Files.ChunkFiles.Data;
+
+namespace PG.StarWarsGame.Files.TED.Data;
+
+public interface IMapData : IChunkData
+{
+ public MapInfo MapInfo { get; }
+}
+
+public class MapData : DisposableObject, IMapData
+{
+ public MapInfo MapInfo { get; }
+
+ public MapData(MapInfo mapInfo)
+ {
+ MapInfo = mapInfo ?? throw new ArgumentNullException(nameof(mapInfo));
+ }
+
+ protected override void DisposeResources()
+ {
+ base.DisposeResources();
+ MapInfo.Dispose();
+ }
+}
+
+public sealed record MapInfo : IDisposable
+{
+ private byte[]? _previewImage;
+
+ public int Version { get; init; }
+
+ public string MapFileName
+ {
+ get;
+ init => field = value ?? throw new ArgumentNullException(nameof(value));
+ } = string.Empty;
+
+ public string MapName
+ {
+ get;
+ init => field = value ?? throw new ArgumentNullException(nameof(value));
+ } = string.Empty;
+
+ public string DisplayName => $"({MaxPlayers}) {MapName}";
+
+ public string PlanetName
+ {
+ get;
+ init => field = value ?? throw new ArgumentNullException(nameof(value));
+ } = string.Empty;
+
+ public string GameTypes
+ {
+ get;
+ init => field = value ?? throw new ArgumentNullException(nameof(value));
+ } = string.Empty;
+
+ public MapMode Type { get; init; }
+
+ public MapEnvironmentType Terrain { get; init; }
+
+ public MapOwnerType Owner { get; init; }
+
+ public int MaxPlayers { get; init; }
+
+ public int Levels { get; init; }
+
+ public bool CustomMap { get; init; }
+
+ public bool NewMultiplayerMarkerSystem { get; init; }
+
+ public Vector2 MapSize { get; init; }
+
+ public IReadOnlyList StartPositionMarkers
+ {
+ get;
+ init => field = value ?? throw new ArgumentNullException(nameof(value));
+ } = [];
+
+ public byte[]? EmbeddedPreviewTextureFile
+ {
+ get
+ {
+ if (_previewImage is null)
+ return null;
+ return (byte[])_previewImage.Clone();
+ }
+ init => _previewImage = value;
+ }
+
+ public int? EmbeddedPreviewTextureFileSize { get; init; }
+
+ // We use the presence of the file size to determine if a preview image exists,
+ // as the file data itself may not have been loaded.
+ public bool PreviewImageExists => EmbeddedPreviewTextureFileSize.HasValue;
+
+ public void Dispose()
+ {
+ if (_previewImage is not null)
+ {
+ Array.Clear(_previewImage, 0, _previewImage.Length);
+ _previewImage = null;
+ }
+ }
+}
+
+
+// TODO: What happens if a map has 0 (Galactic) set???
+public enum MapMode
+{
+ Land = 1,
+ Space = 2
+}
+
+public enum MapEnvironmentType
+{
+ Temperate = 0x0,
+ Arctic = 0x1,
+ Desert = 0x2,
+ Forest = 0x3,
+ Swamp = 0x4,
+ Volcanic = 0x5,
+ Urban = 0x6,
+ Space = 0x7,
+}
+
+public enum MapOwnerType
+{
+ Rebel = 0x0,
+ Empire = 0x1,
+ Pirate = 0x2,
+ Branched = 0x3,
+ Underworld = 0x4,
+ Hutts = 0x5,
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Files/ITedFile.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Files/ITedFile.cs
new file mode 100644
index 00000000..76f610d9
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Files/ITedFile.cs
@@ -0,0 +1,6 @@
+using PG.StarWarsGame.Files.ChunkFiles.Files;
+using PG.StarWarsGame.Files.TED.Data;
+
+namespace PG.StarWarsGame.Files.TED.Files;
+
+public interface ITedFile : IChunkFile;
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Files/TedFile.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Files/TedFile.cs
new file mode 100644
index 00000000..5b075d66
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Files/TedFile.cs
@@ -0,0 +1,7 @@
+using System;
+using PG.StarWarsGame.Files.TED.Data;
+
+namespace PG.StarWarsGame.Files.TED.Files;
+
+public sealed class TedFile(IMapData data, TedFileInformation fileInformation, IServiceProvider serviceProvider)
+ : PetroglyphFileHolder(data, fileInformation, serviceProvider), ITedFile;
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Files/TedFileInformation.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Files/TedFileInformation.cs
new file mode 100644
index 00000000..22a532e8
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Files/TedFileInformation.cs
@@ -0,0 +1,19 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+namespace PG.StarWarsGame.Files.TED.Files;
+
+public sealed record TedFileInformation : PetroglyphMegPackableFileInformation
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The file path of the alo file.
+ /// Information whether this file info is created from a meg data entry.
+ /// is null.
+ /// is empty.
+ [SetsRequiredMembers]
+ public TedFileInformation(string path, bool isInMeg) : base(path, isInMeg)
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/PG.StarWarsGame.Files.TED.csproj b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/PG.StarWarsGame.Files.TED.csproj
new file mode 100644
index 00000000..0071f913
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/PG.StarWarsGame.Files.TED.csproj
@@ -0,0 +1,27 @@
+
+
+ netstandard2.0;netstandard2.1;net10.0
+ PG.StarWarsGame.Files.TED
+ PG.StarWarsGame.Files.TED
+ AlamoEngineTools.PG.StarWarsGame.Files.TED
+ alamo,petroglyph,glyphx
+
+
+
+ true
+
+
+ true
+ snupkg
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Properties/AssemblyAttributes.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Properties/AssemblyAttributes.cs
new file mode 100644
index 00000000..d4ae6b11
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Properties/AssemblyAttributes.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly:InternalsVisibleTo("PG.StarWarsGame.Files.TED.Test")]
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Services/ITedFileService.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Services/ITedFileService.cs
new file mode 100644
index 00000000..fa5ef8fe
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Services/ITedFileService.cs
@@ -0,0 +1,16 @@
+using System.IO;
+using System.IO.Abstractions;
+using PG.StarWarsGame.Files.TED.Files;
+
+namespace PG.StarWarsGame.Files.TED.Services;
+
+public interface ITedFileService
+{
+ bool RemoveMapPreview(Stream tedStream, FileSystemStream destination, bool extract, out byte[]? previewImageBytes);
+
+ bool ContainsPreviewImage(Stream tedStream);
+
+ ITedFile Load(string path);
+
+ ITedFile Load(Stream stream);
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Services/TedFileService.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Services/TedFileService.cs
new file mode 100644
index 00000000..361fcd19
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/Services/TedFileService.cs
@@ -0,0 +1,53 @@
+using PG.Commons.Services;
+using PG.Commons.Utilities;
+using PG.StarWarsGame.Files.TED.Files;
+using System;
+using System.IO;
+using System.IO.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+using PG.StarWarsGame.Files.TED.Binary.Reader;
+using PG.StarWarsGame.Files.TED.Binary.Writer;
+
+namespace PG.StarWarsGame.Files.TED.Services;
+
+internal class TedFileService(IServiceProvider serviceProvider) : ServiceBase(serviceProvider), ITedFileService
+{
+ public bool RemoveMapPreview(Stream tedStream, FileSystemStream destination, bool extract, out byte[]? previewImageBytes)
+ {
+ if (tedStream == null)
+ throw new ArgumentNullException(nameof(tedStream));
+ if (destination == null)
+ throw new ArgumentNullException(nameof(destination));
+
+ var previewExtractor = new MapPreviewExtractor();
+ return previewExtractor.ExtractPreview(tedStream, destination, extract, out previewImageBytes);
+ }
+
+ public bool ContainsPreviewImage(Stream tedStream)
+ {
+ if (tedStream == null)
+ throw new ArgumentNullException(nameof(tedStream));
+ var previewExtractor = new MapPreviewExtractor();
+ return previewExtractor.ContainsPreviewPicture(tedStream);
+ }
+
+ public ITedFile Load(string path)
+ {
+ using var fileStream = FileSystem.FileStream.New(path, FileMode.Open, FileAccess.Read, FileShare.Read);
+ return Load(fileStream);
+ }
+
+ public ITedFile Load(Stream stream)
+ {
+ if (stream == null)
+ throw new ArgumentNullException(nameof(stream));
+
+ using var reader = Services.GetRequiredService().GetReader(stream);
+ var map = reader.Read();
+
+ var filePath = stream.GetFilePath(out var isInMeg);
+ var fileInfo = new TedFileInformation(filePath, isInMeg);
+
+ return new TedFile(map, fileInfo, Services);
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.TED/TedServiceContribution.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/TedServiceContribution.cs
new file mode 100644
index 00000000..d2ba314c
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.TED/TedServiceContribution.cs
@@ -0,0 +1,14 @@
+using Microsoft.Extensions.DependencyInjection;
+using PG.StarWarsGame.Files.TED.Binary.Reader;
+using PG.StarWarsGame.Files.TED.Services;
+
+namespace PG.StarWarsGame.Files.TED;
+
+public static class TedServiceContribution
+{
+ public static void SupportTED(this IServiceCollection serviceCollection)
+ {
+ serviceCollection.AddSingleton(sp => new TedFileReaderFactory(sp));
+ serviceCollection.AddSingleton(sp => new TedFileService(sp));
+ }
+}
\ No newline at end of file