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