diff --git a/Files/GFL2/GFL2BundleHelper.cs b/Files/GFL2/GFL2BundleHelper.cs new file mode 100644 index 0000000..39aab63 --- /dev/null +++ b/Files/GFL2/GFL2BundleHelper.cs @@ -0,0 +1,1028 @@ +using CtrLibrary; +using SPICA.Formats.Common; +using SPICA.Formats.CtrH3D; +using SPICA.Formats.CtrH3D.Animation; +using SPICA.Formats.CtrH3D.LUT; +using SPICA.Formats.CtrH3D.Model; +using SPICA.Formats.GFL2; +using SPICA.Formats.GFL2.Model; +using SPICA.Formats.GFL2.Motion; +using SPICA.Formats.GFL2.Texture; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Toolbox.Core.IO; + +namespace CtrLibrary.GFL2 +{ + internal static class GFL2BundleHelper + { + internal class Gfl2ContentInfo + { + public HashSet TextureNames = new HashSet(); + public HashSet MaterialNames = new HashSet(); + public HashSet MeshNames = new HashSet(); + public HashSet BoneNames = new HashSet(); + public List TextureBlobs = new List(); + public List MotionBlobs = new List(); + public bool HasModel; + public bool HasModelPack; + public bool HasTextures; + public bool HasMotions; + } + + internal class Gfl2ModelInfo + { + public HashSet TextureNames = new HashSet(); + public HashSet MaterialNames = new HashSet(); + public HashSet MeshNames = new HashSet(); + public HashSet BoneNames = new HashSet(); + } + + internal class Gfl2AssociatedContent + { + public List TextureBlobs = new List(); + public List MotionBlobs = new List(); + } + + internal static bool IsPcContainer(byte[] data) + { + if (data == null || data.Length < 4) + return false; + + if (data[0] == (byte)'P' && data[1] == (byte)'C') + return true; + + if (data[0] == 0x11) + { + byte[] decompressed = TryDecompressLz11(data); + return decompressed != null + && decompressed.Length >= 4 + && decompressed[0] == (byte)'P' + && decompressed[1] == (byte)'C'; + } + + return false; + } + + private static byte[] TryDecompressLz11(byte[] data) + { + try + { + var lz11 = new LZSS_N(); + using var decompressed = lz11.Decompress(new MemoryStream(data)); + return decompressed.ReadAllBytes(); + } + catch + { + return null; + } + } + + internal static Gfl2ContentInfo ScanFile(string filePath) + { + byte[] data = File.ReadAllBytes(filePath); + return ScanBytes(data); + } + + internal static Gfl2ContentInfo ScanBytes(byte[] data) + { + var info = new Gfl2ContentInfo(); + ScanBytesInner(data, 0, info); + return info; + } + + internal static Gfl2ModelInfo BuildModelInfo(GFModel model) + { + var info = new Gfl2ModelInfo(); + if (model == null) + return info; + + foreach (var mat in model.Materials) + { + if (!string.IsNullOrEmpty(mat.MaterialName)) + info.MaterialNames.Add(mat.MaterialName); + + for (int i = 0; i < mat.TextureCoords.Length; i++) + { + string name = mat.TextureCoords[i].Name; + if (!string.IsNullOrEmpty(name)) + info.TextureNames.Add(name); + } + } + + foreach (var mesh in model.Meshes) + { + if (!string.IsNullOrEmpty(mesh.Name)) + info.MeshNames.Add(mesh.Name); + } + + foreach (var bone in model.Skeleton) + { + if (!string.IsNullOrEmpty(bone.Name)) + info.BoneNames.Add(bone.Name); + } + + return info; + } + + internal static Gfl2ModelInfo BuildModelInfo(GFModelPack pack) + { + var info = new Gfl2ModelInfo(); + if (pack == null) + return info; + + foreach (var tex in pack.Textures) + { + if (!string.IsNullOrEmpty(tex.Name)) + info.TextureNames.Add(tex.Name); + } + + foreach (var model in pack.Models) + { + var modelInfo = BuildModelInfo(model); + info.TextureNames.UnionWith(modelInfo.TextureNames); + info.MaterialNames.UnionWith(modelInfo.MaterialNames); + info.MeshNames.UnionWith(modelInfo.MeshNames); + info.BoneNames.UnionWith(modelInfo.BoneNames); + } + + return info; + } + + internal static Gfl2AssociatedContent FindAssociatedContent(string folderPath, string currentFilePath, Gfl2ModelInfo modelInfo) + { + var content = new Gfl2AssociatedContent(); + if (string.IsNullOrEmpty(folderPath) || modelInfo == null) + return content; + + foreach (var file in Directory.GetFiles(folderPath)) + { + if (string.Equals(file, currentFilePath, StringComparison.OrdinalIgnoreCase)) + continue; + + Gfl2ContentInfo info; + try + { + info = ScanFile(file); + } + catch + { + continue; + } + + if (info.HasTextures && Intersects(info.TextureNames, modelInfo.TextureNames)) + content.TextureBlobs.AddRange(info.TextureBlobs); + + if (info.HasMotions && (Intersects(info.BoneNames, modelInfo.BoneNames) + || Intersects(info.MaterialNames, modelInfo.MaterialNames) + || Intersects(info.MeshNames, modelInfo.MeshNames))) + { + content.MotionBlobs.AddRange(info.MotionBlobs); + } + } + + return content; + } + + internal static string FindBestMatchingModel(string folderPath, Gfl2ContentInfo seedInfo) + { + if (string.IsNullOrEmpty(folderPath) || seedInfo == null) + return null; + + int bestScore = 0; + string bestPath = null; + + foreach (var file in Directory.GetFiles(folderPath)) + { + Gfl2ContentInfo info; + try + { + info = ScanFile(file); + } + catch + { + continue; + } + + if (!info.HasModel && !info.HasModelPack) + continue; + + int score = 0; + score += CountIntersection(info.TextureNames, seedInfo.TextureNames); + score += CountIntersection(info.BoneNames, seedInfo.BoneNames); + score += CountIntersection(info.MaterialNames, seedInfo.MaterialNames); + score += CountIntersection(info.MeshNames, seedInfo.MeshNames); + + if (score > bestScore) + { + bestScore = score; + bestPath = file; + } + } + + return bestScore > 0 ? bestPath : null; + } + + internal static H3D LoadModelFromFile(string filePath, out Gfl2ModelInfo modelInfo) + { + modelInfo = new Gfl2ModelInfo(); + if (string.IsNullOrEmpty(filePath)) + return null; + + byte[] data = File.ReadAllBytes(filePath); + if (TryExtractGfModelPack(data, out byte[] packBytes, out List extraTextures, out List motionBlobs)) + { + using var ms = new MemoryStream(packBytes); + using var reader = new BinaryReader(ms); + var pack = new GFModelPack(reader); + if (pack.Textures.Count == 0 && extraTextures.Count > 0) + AppendExternalTextures(pack, extraTextures); + modelInfo = BuildModelInfo(pack); + + var h3d = pack.ToH3D(); + EnsureTextures(h3d, packBytes, extraTextures); + h3d.CopyMaterials(); + AppendMotions(h3d, motionBlobs, h3d.Models.Count > 0 ? h3d.Models[0].Skeleton : null); + return h3d; + } + + if (TryExtractGfModel(data, out byte[] modelBytes, out List textureBlobs, out List motionBlobs2)) + { + using var ms = new MemoryStream(modelBytes); + using var reader = new BinaryReader(ms); + GFModel model = new GFModel(reader, "Model"); + modelInfo = BuildModelInfo(model); + + var h3d = new H3D(); + h3d.Models.Add(model.ToH3DModel()); + AppendLuts(h3d, model); + AppendTextures(h3d, textureBlobs); + h3d.CopyMaterials(); + AppendMotions(h3d, motionBlobs2, h3d.Models[0].Skeleton); + return h3d; + } + + return null; + } + + internal static void AppendTextures(H3D h3d, List blobs) + { + if (h3d == null || blobs == null) + return; + + foreach (var tex in blobs) + { + try + { + using var ms = new MemoryStream(tex); + using var reader = new BinaryReader(ms); + var h3dTex = new GFTexture(reader).ToH3DTexture(); + if (!h3d.Textures.Contains(h3dTex.Name)) + h3d.Textures.Add(h3dTex); + } + catch + { + // Best-effort. + } + } + } + + internal static void AppendMotions(H3D h3d, List motionBlobs, H3DDict skeleton) + { + if (h3d == null || motionBlobs == null || motionBlobs.Count == 0) + return; + + int fallbackIndex = 0; + foreach (var blob in motionBlobs) + { + if (blob == null || blob.Length < 4) + continue; + + try + { + if (IsLikelyGfMotionPack(blob)) + { + using var ms = new MemoryStream(blob); + using var reader = new BinaryReader(ms); + var pack = new GFMotionPack(reader); + foreach (var mot in pack) + AddMotion(h3d, mot, skeleton); + } + else if (IsGfMotion(blob)) + { + using var ms = new MemoryStream(blob); + using var reader = new BinaryReader(ms); + var mot = new GFMotion(reader, fallbackIndex++); + AddMotion(h3d, mot, skeleton); + } + } + catch + { + // Best-effort. + } + } + } + + private static void AddMotion(H3D h3d, GFMotion motion, H3DDict skeleton) + { + if (h3d == null || motion == null) + return; + + string baseName = $"Motion_{motion.Index}"; + + H3DAnimation sklAnim = null; + if (motion.SkeletalAnimation != null && skeleton != null && skeleton.Count > 0) + sklAnim = motion.ToH3DSkeletalAnimation(skeleton); + + H3DMaterialAnim matAnim = motion.ToH3DMaterialAnimation(); + H3DAnimation visAnim = motion.ToH3DVisibilityAnimation(); + + if (sklAnim != null) + { + sklAnim.Name = GetUniqueName(h3d.SkeletalAnimations, baseName); + h3d.SkeletalAnimations.Add(sklAnim); + } + + if (matAnim != null) + { + matAnim.Name = GetUniqueName(h3d.MaterialAnimations, baseName); + h3d.MaterialAnimations.Add(matAnim); + } + + if (visAnim != null) + { + visAnim.Name = GetUniqueName(h3d.VisibilityAnimations, baseName); + h3d.VisibilityAnimations.Add(visAnim); + } + } + + private static string GetUniqueName(H3DDict dict, string baseName) where T : INamed + { + if (dict == null) + return baseName; + string name = baseName; + int suffix = 1; + while (dict.Contains(name)) + { + name = $"{baseName}_{suffix}"; + suffix++; + } + return name; + } + + private static bool Intersects(HashSet a, HashSet b) + { + if (a == null || b == null || a.Count == 0 || b.Count == 0) + return false; + return a.Overlaps(b); + } + + private static int CountIntersection(HashSet a, HashSet b) + { + if (a == null || b == null || a.Count == 0 || b.Count == 0) + return 0; + return a.Count(x => b.Contains(x)); + } + + private static void ScanBytesInner(byte[] data, int depth, Gfl2ContentInfo info) + { + if (depth > 8 || data == null || data.Length < 4) + return; + + if (LooksLikeLz11(data)) + { + data = DecompressLz11(data); + if (data == null || data.Length < 4) + return; + } + + if (IsGfTexture(data)) + { + info.HasTextures = true; + info.TextureBlobs.Add(data); + TryAddTextureName(data, info.TextureNames); + } + + if (IsGfMotion(data)) + { + info.HasMotions = true; + info.MotionBlobs.Add(data); + TryAddMotionNames(data, info); + } + else if (IsLikelyGfMotionPack(data)) + { + info.HasMotions = true; + info.MotionBlobs.Add(data); + TryAddMotionPackNames(data, info); + } + + if (IsGfModel(data)) + { + info.HasModel = true; + TryAddModelNames(data, info); + } + else if (IsValidGfModelPack(data)) + { + info.HasModelPack = true; + TryAddModelPackNames(data, info); + } + + if (!LooksLikeContainer(data)) + return; + + ushort count = (ushort)(data[2] | (data[3] << 8)); + int tableSize = 4 + (count + 1) * 4; + if (tableSize > data.Length) + return; + + List offsets = new List(count + 1); + int prev = 0; + for (int i = 0; i < count + 1; i++) + { + int off = BitConverter.ToInt32(data, 4 + i * 4); + if (off < prev || off > data.Length) + return; + offsets.Add(off); + prev = off; + } + + for (int i = 0; i < count; i++) + { + int start = offsets[i]; + int end = offsets[i + 1]; + if (start < 0 || end < start || end > data.Length) + continue; + byte[] slice = new byte[end - start]; + Buffer.BlockCopy(data, start, slice, 0, slice.Length); + ScanBytesInner(slice, depth + 1, info); + } + } + + private static void TryAddTextureName(byte[] data, HashSet names) + { + try + { + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + var tex = new GFTexture(reader); + if (!string.IsNullOrEmpty(tex.Name)) + names.Add(tex.Name); + } + catch + { + } + } + + private static void TryAddMotionNames(byte[] data, Gfl2ContentInfo info) + { + try + { + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + var mot = new GFMotion(reader, 0); + AddMotionNames(mot, info); + } + catch + { + } + } + + private static void TryAddMotionPackNames(byte[] data, Gfl2ContentInfo info) + { + try + { + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + var pack = new GFMotionPack(reader); + foreach (var mot in pack) + AddMotionNames(mot, info); + } + catch + { + } + } + + private static void AddMotionNames(GFMotion mot, Gfl2ContentInfo info) + { + if (mot?.SkeletalAnimation != null) + { + foreach (var bone in mot.SkeletalAnimation.Bones) + { + if (!string.IsNullOrEmpty(bone.Name)) + info.BoneNames.Add(bone.Name); + } + } + + if (mot?.MaterialAnimation != null) + { + foreach (var mat in mot.MaterialAnimation.Materials) + { + if (!string.IsNullOrEmpty(mat.Name)) + info.MaterialNames.Add(mat.Name); + } + } + + if (mot?.VisibilityAnimation != null) + { + foreach (var vis in mot.VisibilityAnimation.Visibilities) + { + if (!string.IsNullOrEmpty(vis.Name)) + info.MeshNames.Add(vis.Name); + } + } + } + + private static void TryAddModelNames(byte[] data, Gfl2ContentInfo info) + { + try + { + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + var model = new GFModel(reader, "Model"); + var modelInfo = BuildModelInfo(model); + info.TextureNames.UnionWith(modelInfo.TextureNames); + info.MaterialNames.UnionWith(modelInfo.MaterialNames); + info.MeshNames.UnionWith(modelInfo.MeshNames); + info.BoneNames.UnionWith(modelInfo.BoneNames); + } + catch + { + } + } + + private static void TryAddModelPackNames(byte[] data, Gfl2ContentInfo info) + { + try + { + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + var pack = new GFModelPack(reader); + var modelInfo = BuildModelInfo(pack); + info.TextureNames.UnionWith(modelInfo.TextureNames); + info.MaterialNames.UnionWith(modelInfo.MaterialNames); + info.MeshNames.UnionWith(modelInfo.MeshNames); + info.BoneNames.UnionWith(modelInfo.BoneNames); + } + catch + { + } + } + + private static bool LooksLikeContainer(byte[] data) + { + if (data == null || data.Length < 8) + return false; + byte a = data[0]; + byte b = data[1]; + if (a < 0x41 || a > 0x5A || b < 0x41 || b > 0x5A) + return false; + ushort count = (ushort)(data[2] | (data[3] << 8)); + if (count == 0 || count > 0x4000) + return false; + int tableSize = 4 + (count + 1) * 4; + return tableSize <= data.Length; + } + + private static bool IsValidGfModelPack(byte[] data) + { + if (data == null || data.Length < 4) + return false; + if (BitConverter.ToUInt32(data, 0) != 0x00010000) + return false; + if (data.Length < 24) + return false; + + int[] counts = new int[5]; + int total = 0; + for (int i = 0; i < 5; i++) + { + counts[i] = BitConverter.ToInt32(data, 4 + i * 4); + if (counts[i] < 0) + return false; + total += counts[i]; + } + + int ptrBase = 4 + 5 * 4; + int ptrTableBytes = total * 4; + if (ptrBase + ptrTableBytes > data.Length) + return false; + + bool hasModel = false; + int off = ptrBase; + for (int sect = 0; sect < 5; sect++) + { + for (int i = 0; i < counts[sect]; i++) + { + int ptr = BitConverter.ToInt32(data, off + i * 4); + if (ptr <= 0 || ptr >= data.Length) + continue; + if (ptr + 1 >= data.Length) + continue; + int nameLen = data[ptr]; + int nameEnd = ptr + 1 + nameLen; + if (nameEnd + 4 > data.Length) + continue; + int addr = BitConverter.ToInt32(data, nameEnd); + if (addr <= 0 || addr + 4 > data.Length) + continue; + if (sect == 0 && BitConverter.ToUInt32(data, addr) == 0x15122117) + hasModel = true; + } + off += counts[sect] * 4; + } + + return hasModel; + } + + private static bool IsGfModel(byte[] data) + { + return data != null && data.Length >= 4 && BitConverter.ToUInt32(data, 0) == 0x15122117; + } + + private static bool IsGfTexture(byte[] data) + { + return data != null && data.Length >= 4 && BitConverter.ToUInt32(data, 0) == 0x15041213; + } + + private static bool IsGfMotion(byte[] data) + { + return data != null && data.Length >= 4 && BitConverter.ToUInt32(data, 0) == 0x00060000; + } + + private static bool IsLikelyGfMotionPack(byte[] data) + { + if (data == null || data.Length < 8) + return false; + + uint count = BitConverter.ToUInt32(data, 0); + if (count == 0 || count > 0x1000) + return false; + + long tableEnd = 4 + count * 4; + if (tableEnd > data.Length) + return false; + + bool hasEntry = false; + for (int i = 0; i < count; i++) + { + uint addr = BitConverter.ToUInt32(data, 4 + i * 4); + if (addr == 0) + continue; + long pos = 4 + addr; + if (pos + 4 > data.Length || pos < 4) + return false; + if (BitConverter.ToUInt32(data, (int)pos) != 0x00060000) + return false; + hasEntry = true; + } + + return hasEntry; + } + + private static bool LooksLikeLz11(byte[] data) + { + if (data == null || data.Length < 4 || data[0] != 0x11) + return false; + int decodedLen = data[1] | (data[2] << 8) | (data[3] << 16); + return decodedLen > data.Length; + } + + private static byte[] DecompressLz11(byte[] data) + { + if (data == null || data.Length < 4) + return data; + if (data[0] != 0x11) + return data; + int decodedLen = data[1] | (data[2] << 8) | (data[3] << 16); + byte[] output = new byte[decodedLen]; + int outOff = 0; + int inOff = 4; + int mask = 0; + int header = 0; + + while (outOff < decodedLen && inOff < data.Length) + { + mask >>= 1; + if (mask == 0) + { + header = data[inOff++]; + mask = 0x80; + } + + if ((header & mask) == 0) + { + if (inOff >= data.Length) + break; + output[outOff++] = data[inOff++]; + continue; + } + + if (inOff >= data.Length) + break; + int byte1 = data[inOff++]; + int top = byte1 >> 4; + int position; + int length; + + if (top == 0) + { + if (inOff + 1 >= data.Length) + break; + int byte2 = data[inOff++]; + int byte3 = data[inOff++]; + position = ((byte2 & 0xF) << 8) | byte3; + length = (((byte1 & 0xF) << 4) | (byte2 >> 4)) + 0x11; + } + else if (top == 1) + { + if (inOff + 2 >= data.Length) + break; + int byte2 = data[inOff++]; + int byte3 = data[inOff++]; + int byte4 = data[inOff++]; + position = ((byte3 & 0xF) << 8) | byte4; + length = (((byte1 & 0xF) << 12) | (byte2 << 4) | (byte3 >> 4)) + 0x111; + } + else + { + if (inOff >= data.Length) + break; + int byte2 = data[inOff++]; + position = ((byte1 & 0xF) << 8) | byte2; + length = (byte1 >> 4) + 1; + } + + position += 1; + for (int i = 0; i < length && outOff < decodedLen; i++) + { + output[outOff] = output[outOff - position]; + outOff++; + } + } + + return output; + } + + private static void AppendExternalTextures(GFModelPack pack, List textureBlobs) + { + foreach (var tex in textureBlobs) + { + try + { + using var ms = new MemoryStream(tex); + using var reader = new BinaryReader(ms); + pack.Textures.Add(new GFTexture(reader)); + } + catch + { + } + } + } + + private static void EnsureTextures(H3D h3d, byte[] packBytes, List extraTextures) + { + if (h3d == null || h3d.Textures == null) + return; + if (h3d.Textures.Count > 0) + return; + + AppendTexturesFromPackBytes(h3d, packBytes); + if (h3d.Textures.Count == 0 && extraTextures.Count > 0) + AppendTextures(h3d, extraTextures); + } + + private static void AppendTexturesFromPackBytes(H3D h3d, byte[] packBytes) + { + if (h3d == null || packBytes == null || packBytes.Length < 24) + return; + if (BitConverter.ToUInt32(packBytes, 0) != 0x00010000) + return; + + int[] counts = new int[5]; + int total = 0; + for (int i = 0; i < 5; i++) + { + counts[i] = BitConverter.ToInt32(packBytes, 4 + i * 4); + if (counts[i] < 0) + return; + total += counts[i]; + } + + int ptrBase = 4 + 5 * 4; + if (ptrBase + total * 4 > packBytes.Length) + return; + + int off = ptrBase; + for (int sect = 0; sect < 5; sect++) + { + for (int i = 0; i < counts[sect]; i++) + { + int ptr = BitConverter.ToInt32(packBytes, off + i * 4); + if (ptr <= 0 || ptr >= packBytes.Length) + continue; + if (ptr + 1 >= packBytes.Length) + continue; + int nameLen = packBytes[ptr]; + int nameEnd = ptr + 1 + nameLen; + if (nameEnd + 4 > packBytes.Length) + continue; + int addr = BitConverter.ToInt32(packBytes, nameEnd); + if (addr <= 0 || addr + 4 > packBytes.Length) + continue; + if (BitConverter.ToUInt32(packBytes, addr) != 0x15041213) + continue; + + try + { + using var ms = new MemoryStream(packBytes); + using var reader = new BinaryReader(ms); + reader.BaseStream.Seek(addr, SeekOrigin.Begin); + var tex = new GFTexture(reader).ToH3DTexture(); + if (!h3d.Textures.Contains(tex.Name)) + h3d.Textures.Add(tex); + } + catch + { + } + } + off += counts[sect] * 4; + } + } + + private static void AppendLuts(H3D h3d, GFModel model) + { + if (h3d == null || model == null || model.LUTs.Count == 0) + return; + + H3DLUT lut = new H3DLUT(); + lut.Name = "LookupTableSetContentCtrName"; + foreach (var sampler in model.LUTs) + { + lut.Samplers.Add(new H3DLUTSampler() + { + Name = sampler.Name, + Table = sampler.Table + }); + } + h3d.LUTs.Add(lut); + } + + private static bool TryExtractGfModelPack( + byte[] data, + out byte[] packBytes, + out List textureBlobs, + out List motionBlobs) + { + packBytes = null; + textureBlobs = new List(); + motionBlobs = new List(); + if (data == null || data.Length < 4) + return false; + return TryExtractGfModelPackInner(data, 0, textureBlobs, motionBlobs, out packBytes); + } + + private static bool TryExtractGfModelPackInner( + byte[] data, + int depth, + List textureBlobs, + List motionBlobs, + out byte[] packBytes) + { + packBytes = null; + if (depth > 8 || data == null || data.Length < 4) + return false; + + if (LooksLikeLz11(data)) + { + data = DecompressLz11(data); + if (data == null || data.Length < 4) + return false; + } + + if (IsGfTexture(data)) + textureBlobs.Add(data); + + if (IsGfMotion(data) || IsLikelyGfMotionPack(data)) + motionBlobs.Add(data); + + if (IsValidGfModelPack(data)) + { + packBytes = data; + return true; + } + + if (!LooksLikeContainer(data)) + return false; + + ushort count = (ushort)(data[2] | (data[3] << 8)); + int tableSize = 4 + (count + 1) * 4; + if (tableSize > data.Length) + return false; + + List offsets = new List(count + 1); + int prev = 0; + for (int i = 0; i < count + 1; i++) + { + int off = BitConverter.ToInt32(data, 4 + i * 4); + if (off < prev || off > data.Length) + return false; + offsets.Add(off); + prev = off; + } + + for (int i = 0; i < count; i++) + { + int start = offsets[i]; + int end = offsets[i + 1]; + if (start < 0 || end < start || end > data.Length) + continue; + byte[] slice = new byte[end - start]; + Buffer.BlockCopy(data, start, slice, 0, slice.Length); + if (TryExtractGfModelPackInner(slice, depth + 1, textureBlobs, motionBlobs, out packBytes)) + return true; + } + + return false; + } + + private static bool TryExtractGfModel( + byte[] data, + out byte[] modelBytes, + out List textureBlobs, + out List motionBlobs) + { + modelBytes = null; + textureBlobs = new List(); + motionBlobs = new List(); + if (data == null || data.Length < 4) + return false; + return TryExtractGfModelInner(data, 0, textureBlobs, motionBlobs, out modelBytes); + } + + private static bool TryExtractGfModelInner( + byte[] data, + int depth, + List textureBlobs, + List motionBlobs, + out byte[] modelBytes) + { + modelBytes = null; + if (depth > 8 || data == null || data.Length < 4) + return false; + + if (LooksLikeLz11(data)) + { + data = DecompressLz11(data); + if (data == null || data.Length < 4) + return false; + } + + if (IsGfTexture(data)) + textureBlobs.Add(data); + + if (IsGfMotion(data) || IsLikelyGfMotionPack(data)) + motionBlobs.Add(data); + + if (IsGfModel(data)) + { + modelBytes = data; + return true; + } + + if (!LooksLikeContainer(data)) + return false; + + ushort count = (ushort)(data[2] | (data[3] << 8)); + int tableSize = 4 + (count + 1) * 4; + if (tableSize > data.Length) + return false; + + List offsets = new List(count + 1); + int prev = 0; + for (int i = 0; i < count + 1; i++) + { + int off = BitConverter.ToInt32(data, 4 + i * 4); + if (off < prev || off > data.Length) + return false; + offsets.Add(off); + prev = off; + } + + for (int i = 0; i < count; i++) + { + int start = offsets[i]; + int end = offsets[i + 1]; + if (start < 0 || end < start || end > data.Length) + continue; + byte[] slice = new byte[end - start]; + Buffer.BlockCopy(data, start, slice, 0, slice.Length); + if (TryExtractGfModelInner(slice, depth + 1, textureBlobs, motionBlobs, out modelBytes)) + return true; + } + + return false; + } + } +} diff --git a/Files/GFL2/GFModelFile.cs b/Files/GFL2/GFModelFile.cs new file mode 100644 index 0000000..240a5ff --- /dev/null +++ b/Files/GFL2/GFModelFile.cs @@ -0,0 +1,373 @@ +using CtrLibrary; +using CtrLibrary.Bch; +using CtrLibrary.Rendering; +using CtrLibrary.UI; +using GLFrameworkEngine; +using MapStudio.UI; +using SPICA.Formats.Common; +using SPICA.Formats.CtrH3D; +using SPICA.Formats.CtrH3D.LUT; +using SPICA.Formats.CtrH3D.Model; +using SPICA.Formats.CtrH3D.Texture; +using SPICA.Formats.GFL2.Model; +using System; +using System.Collections.Generic; +using System.IO; +using Toolbox.Core; +using Toolbox.Core.IO; +using Toolbox.Core.ViewModels; +using UIFramework; +using OpenTK; + +namespace CtrLibrary.GFL2 +{ + /// + /// Viewer-only support for single GFL2 GFModel (0x15122117). + /// + public class GFModelFile : FileEditor, IFileFormat + { + public string[] Description => new string[] { "GFL2 GFModel" }; + public string[] Extension => new string[] { "*.gfbmdl", "*.gfmdl" }; + public bool CanSave { get; set; } = false; + public File_Info FileInfo { get; set; } + + public H3DRender Render; + public H3D H3DData; + + public override bool DisplayViewport => Render != null && Render.Renderer.Models.Count > 0; + + public GFModelFile() { FileInfo = new File_Info(); } + + public bool Identify(File_Info fileInfo, Stream stream) + { + byte[] data = stream.ToArray(); + if (fileInfo?.ParentArchive != null && GFL2BundleHelper.IsPcContainer(data)) + return false; + return TryExtractGfModel(data, out _, out _); + } + + public override bool CreateNew() + { + return false; + } + + public void Load(Stream stream) + { + byte[] data = stream.ToArray(); + bool isPcContainer = GFL2BundleHelper.IsPcContainer(data); + if (!TryExtractGfModel(data, out byte[] modelBytes, out List textureBlobs)) + throw new Exception("No GFModel found in file."); + + using var ms = new MemoryStream(modelBytes); + using var reader = new BinaryReader(ms); + GFModel model = new GFModel(reader, "Model"); + + var h3d = new H3D(); + h3d.Models.Add(model.ToH3DModel()); + AppendLuts(h3d, model); + AppendTexturesFromBlobs(h3d, textureBlobs); + h3d.CopyMaterials(); + + Load(h3d); + } + + public void Save(Stream stream) + { + throw new NotSupportedException("GFModel viewer-only support (save not implemented)."); + } + + public override List PrepareDocks() + { + List windows = new List(); + windows.Add(Workspace.Outliner); + windows.Add(Workspace.PropertyWindow); + windows.Add(Workspace.ConsoleWindow); + windows.Add(Workspace.ViewportWindow); + windows.Add(Workspace.TimelineWindow); + windows.Add(Workspace.GraphWindow); + return windows; + } + + private void Load(H3D h3d) + { + H3DData = h3d; + Root.TagUI.Tag = h3d; + + Render = new H3DRender(H3DData, null); + AddRender(Render); + + Root.AddChild(SceneLightingUI.Setup(Render)); + + var bch = new BCH(); + bch.Render = Render; + bch.H3DData = H3DData; + + Root.AddChild(new ModelFolder(bch, H3DData, H3DData.Models)); + Root.AddChild(new TextureFolder(Render, H3DData.Textures)); + Root.AddChild(new LUTFolder(Render, H3DData.LUTs)); + + FrameCamera(); + Root.OnSelected += delegate { FrameCamera(); }; + } + + private void FrameCamera() + { + if (Render == null || Render.Renderer.Models.Count == 0) + return; + + var aabb = Render.Renderer.Models[0].GetModelAABB(); + var center = aabb.Center; + + float dimension = 1; + dimension = Math.Max(dimension, Math.Abs(aabb.Size.X)); + dimension = Math.Max(dimension, Math.Abs(aabb.Size.Y)); + dimension = Math.Max(dimension, Math.Abs(aabb.Size.Z)); + dimension *= 2; + + var translation = new Vector3(0, 0, dimension); + GLContext.ActiveContext.Camera.SetPosition(center + translation); + GLContext.ActiveContext.Camera.RotationX = 0; + GLContext.ActiveContext.Camera.RotationY = 0; + GLContext.ActiveContext.Camera.RotationZ = 0; + GLContext.ActiveContext.Camera.UpdateMatrices(); + } + + private static bool TryExtractGfModel( + byte[] data, + out byte[] modelBytes, + out List textureBlobs) + { + modelBytes = null; + textureBlobs = new List(); + if (data == null || data.Length < 4) + return false; + return TryExtractGfModelInner(data, 0, textureBlobs, out modelBytes); + } + + private static bool TryExtractGfModelInner( + byte[] data, + int depth, + List textureBlobs, + out byte[] modelBytes) + { + modelBytes = null; + if (depth > 8 || data == null || data.Length < 4) + return false; + bool foundModel = false; + + if (LooksLikeLz11(data)) + { + data = DecompressLz11(data); + if (data == null || data.Length < 4) + return false; + } + + if (IsGfTexture(data)) + textureBlobs.Add(data); + + if (IsGfModel(data)) + { + modelBytes = data; + foundModel = true; + } + + if (!LooksLikeContainer(data)) + return foundModel; + + ushort count = (ushort)(data[2] | (data[3] << 8)); + int tableSize = 4 + (count + 1) * 4; + if (tableSize > data.Length) + return foundModel; + + List offsets = new List(count + 1); + int prev = 0; + for (int i = 0; i < count + 1; i++) + { + int off = BitConverter.ToInt32(data, 4 + i * 4); + if (off < prev || off > data.Length) + return foundModel; + offsets.Add(off); + prev = off; + } + + for (int i = 0; i < count; i++) + { + int start = offsets[i]; + int end = offsets[i + 1]; + if (start < 0 || end < start || end > data.Length) + continue; + byte[] slice = new byte[end - start]; + Buffer.BlockCopy(data, start, slice, 0, slice.Length); + if (!foundModel) + { + if (TryExtractGfModelInner(slice, depth + 1, textureBlobs, out byte[] foundBytes)) + { + if (modelBytes == null && foundBytes != null) + modelBytes = foundBytes; + foundModel = true; + } + } + else + { + // Keep scanning siblings to collect textures even after the model is found. + TryExtractGfModelInner(slice, depth + 1, textureBlobs, out _); + } + } + + return foundModel; + } + + private static bool LooksLikeContainer(byte[] data) + { + if (data == null || data.Length < 8) + return false; + byte a = data[0]; + byte b = data[1]; + if (a < 0x41 || a > 0x5A || b < 0x41 || b > 0x5A) + return false; + ushort count = (ushort)(data[2] | (data[3] << 8)); + if (count == 0 || count > 0x4000) + return false; + int tableSize = 4 + (count + 1) * 4; + return tableSize <= data.Length; + } + + private static bool IsGfModel(byte[] data) + { + if (data == null || data.Length < 4) + return false; + return BitConverter.ToUInt32(data, 0) == 0x15122117; + } + + private static bool IsGfTexture(byte[] data) + { + if (data == null || data.Length < 4) + return false; + return BitConverter.ToUInt32(data, 0) == 0x15041213; + } + + private static bool LooksLikeLz11(byte[] data) + { + if (data == null || data.Length < 4 || data[0] != 0x11) + return false; + int decodedLen = data[1] | (data[2] << 8) | (data[3] << 16); + return decodedLen > data.Length; + } + + private static byte[] DecompressLz11(byte[] data) + { + if (data == null || data.Length < 4) + return data; + if (data[0] != 0x11) + return data; + int decodedLen = data[1] | (data[2] << 8) | (data[3] << 16); + byte[] output = new byte[decodedLen]; + int outOff = 0; + int inOff = 4; + int mask = 0; + int header = 0; + + while (outOff < decodedLen && inOff < data.Length) + { + mask >>= 1; + if (mask == 0) + { + header = data[inOff++]; + mask = 0x80; + } + + if ((header & mask) == 0) + { + if (inOff >= data.Length) + break; + output[outOff++] = data[inOff++]; + continue; + } + + if (inOff >= data.Length) + break; + int byte1 = data[inOff++]; + int top = byte1 >> 4; + int position; + int length; + + if (top == 0) + { + if (inOff + 1 >= data.Length) + break; + int byte2 = data[inOff++]; + int byte3 = data[inOff++]; + position = ((byte2 & 0xF) << 8) | byte3; + length = (((byte1 & 0xF) << 4) | (byte2 >> 4)) + 0x11; + } + else if (top == 1) + { + if (inOff + 2 >= data.Length) + break; + int byte2 = data[inOff++]; + int byte3 = data[inOff++]; + int byte4 = data[inOff++]; + position = ((byte3 & 0xF) << 8) | byte4; + length = (((byte1 & 0xF) << 12) | (byte2 << 4) | (byte3 >> 4)) + 0x111; + } + else + { + if (inOff >= data.Length) + break; + int byte2 = data[inOff++]; + position = ((byte1 & 0xF) << 8) | byte2; + length = (byte1 >> 4) + 1; + } + + position += 1; + for (int i = 0; i < length && outOff < decodedLen; i++) + { + output[outOff] = output[outOff - position]; + outOff++; + } + } + + return output; + } + + private static void AppendTexturesFromBlobs(H3D h3d, List blobs) + { + if (h3d == null) + return; + + foreach (var tex in blobs) + { + try + { + using var ms = new MemoryStream(tex); + using var reader = new BinaryReader(ms); + var h3dTex = new SPICA.Formats.GFL2.Texture.GFTexture(reader).ToH3DTexture(); + if (!h3d.Textures.Contains(h3dTex.Name)) + h3d.Textures.Add(h3dTex); + } + catch + { + // Best-effort. + } + } + } + + private static void AppendLuts(H3D h3d, GFModel model) + { + if (h3d == null || model == null || model.LUTs.Count == 0) + return; + + H3DLUT lut = new H3DLUT(); + lut.Name = "LookupTableSetContentCtrName"; + foreach (var sampler in model.LUTs) + { + lut.Samplers.Add(new H3DLUTSampler() + { + Name = sampler.Name, + Table = sampler.Table + }); + } + h3d.LUTs.Add(lut); + } + } +} diff --git a/Files/GFL2/GFModelPackFile.cs b/Files/GFL2/GFModelPackFile.cs new file mode 100644 index 0000000..419fb39 --- /dev/null +++ b/Files/GFL2/GFModelPackFile.cs @@ -0,0 +1,691 @@ +using CtrLibrary; +using CtrLibrary.Rendering; +using CtrLibrary.Bch; +using CtrLibrary.UI; +using GLFrameworkEngine; +using MapStudio.UI; +using SPICA.Formats.Common; +using SPICA.Formats.CtrH3D; +using SPICA.Formats.CtrH3D.Animation; +using SPICA.Formats.CtrH3D.LUT; +using SPICA.Formats.CtrH3D.Model; +using SPICA.Formats.CtrH3D.Texture; +using SPICA.Formats.GFL2; +using SPICA.Formats.GFL2.Motion; +using System; +using System.Collections.Generic; +using System.IO; +using Toolbox.Core; +using Toolbox.Core.IO; +using Toolbox.Core.ViewModels; +using UIFramework; +using OpenTK; + +namespace CtrLibrary.GFL2 +{ + /// + /// Viewer-only support for GFL2 GFModelPack (0x00010000). + /// + public class GFModelPackFile : FileEditor, IFileFormat + { + public string[] Description => new string[] { "GFL2 GFModelPack" }; + public string[] Extension => new string[] { "*.gfmodelpack", "*.gfmodel", "*.gfbmdl" }; + public bool CanSave { get; set; } = false; + public File_Info FileInfo { get; set; } + + public H3DRender Render; + public H3D H3DData; + + public override bool DisplayViewport => Render != null && Render.Renderer.Models.Count > 0; + + public GFModelPackFile() { FileInfo = new File_Info(); } + + public bool Identify(File_Info fileInfo, Stream stream) + { + byte[] data = stream.ToArray(); + if (fileInfo?.ParentArchive != null && GFL2BundleHelper.IsPcContainer(data)) + return false; + return TryExtractGfModelPack(data, out _, out _, out _); + } + + public override bool CreateNew() + { + return false; + } + + public void Load(Stream stream) + { + byte[] data = stream.ToArray(); + bool isPcContainer = GFL2BundleHelper.IsPcContainer(data); + if (!TryExtractGfModelPack(data, out byte[] packBytes, out List extraTextures, out List motionBlobs)) + throw new Exception("No GFModelPack found in file."); + + using var ms = new MemoryStream(packBytes); + using var reader = new BinaryReader(ms); + GFModelPack pack = new GFModelPack(reader); + if (pack.Textures.Count == 0 && extraTextures.Count > 0) + AppendExternalTextures(pack, extraTextures); + var h3d = pack.ToH3D(); + EnsureTextures(h3d, packBytes, extraTextures); + h3d.CopyMaterials(); + AppendAnimations(h3d, motionBlobs); + + Load(h3d); + } + + public void Save(Stream stream) + { + throw new NotSupportedException("GFModelPack viewer-only support (save not implemented)."); + } + + public override List PrepareDocks() + { + List windows = new List(); + windows.Add(Workspace.Outliner); + windows.Add(Workspace.PropertyWindow); + windows.Add(Workspace.ConsoleWindow); + windows.Add(Workspace.ViewportWindow); + windows.Add(Workspace.TimelineWindow); + windows.Add(Workspace.GraphWindow); + return windows; + } + + private void Load(H3D h3d) + { + H3DData = h3d; + Root.TagUI.Tag = h3d; + + Render = new H3DRender(H3DData, null); + AddRender(Render); + + Root.AddChild(SceneLightingUI.Setup(Render)); + + var bch = new BCH(); + bch.Render = Render; + bch.H3DData = H3DData; + + Root.AddChild(new ModelFolder(bch, H3DData, H3DData.Models)); + Root.AddChild(new TextureFolder(Render, H3DData.Textures)); + Root.AddChild(new LUTFolder(Render, H3DData.LUTs)); + AddAnimationGroups(H3DData); + + FrameCamera(); + Root.OnSelected += delegate { FrameCamera(); }; + } + + private void FrameCamera() + { + if (Render == null || Render.Renderer.Models.Count == 0) + return; + + var aabb = Render.Renderer.Models[0].GetModelAABB(); + var center = aabb.Center; + + float dimension = 1; + dimension = Math.Max(dimension, Math.Abs(aabb.Size.X)); + dimension = Math.Max(dimension, Math.Abs(aabb.Size.Y)); + dimension = Math.Max(dimension, Math.Abs(aabb.Size.Z)); + dimension *= 2; + + var translation = new Vector3(0, 0, dimension); + GLContext.ActiveContext.Camera.SetPosition(center + translation); + GLContext.ActiveContext.Camera.RotationX = 0; + GLContext.ActiveContext.Camera.RotationY = 0; + GLContext.ActiveContext.Camera.RotationZ = 0; + GLContext.ActiveContext.Camera.UpdateMatrices(); + } + + private static bool TryExtractGfModelPack( + byte[] data, + out byte[] packBytes, + out List textureBlobs, + out List motionBlobs) + { + packBytes = null; + textureBlobs = new List(); + motionBlobs = new List(); + if (data == null || data.Length < 4) + return false; + return TryExtractGfModelPackInner(data, 0, textureBlobs, motionBlobs, out packBytes); + } + + private static bool TryExtractGfModelPackInner( + byte[] data, + int depth, + List textureBlobs, + List motionBlobs, + out byte[] packBytes) + { + packBytes = null; + if (depth > 8 || data == null || data.Length < 4) + return false; + bool foundPack = false; + + if (LooksLikeLz11(data)) + { + data = DecompressLz11(data); + if (data == null || data.Length < 4) + return false; + } + + if (IsGfTexture(data)) + { + textureBlobs.Add(data); + } + + if (IsGfMotion(data) || IsLikelyGfMotionPack(data)) + { + motionBlobs.Add(data); + } + + if (IsValidGfModelPack(data)) + { + packBytes = data; + foundPack = true; + } + + if (!LooksLikeContainer(data)) + return foundPack; + + string magic = $"{(char)data[0]}{(char)data[1]}"; + ushort count = (ushort)(data[2] | (data[3] << 8)); + int tableSize = 4 + (count + 1) * 4; + if (tableSize > data.Length) + return foundPack; + + List offsets = new List(count + 1); + int prev = 0; + for (int i = 0; i < count + 1; i++) + { + int off = BitConverter.ToInt32(data, 4 + i * 4); + if (off < prev || off > data.Length) + return foundPack; + offsets.Add(off); + prev = off; + } + + List order = new List(); + if (magic == "CP" && count >= 2) + order.Add(1); + for (int i = 0; i < count; i++) + { + if (!order.Contains(i)) + order.Add(i); + } + + foreach (int i in order) + { + int start = offsets[i]; + int end = offsets[i + 1]; + if (start < 0 || end < start || end > data.Length) + continue; + byte[] slice = new byte[end - start]; + Buffer.BlockCopy(data, start, slice, 0, slice.Length); + if (!foundPack) + { + if (TryExtractGfModelPackInner(slice, depth + 1, textureBlobs, motionBlobs, out byte[] foundBytes)) + { + if (packBytes == null && foundBytes != null) + packBytes = foundBytes; + foundPack = true; + } + } + else + { + // Keep scanning siblings to collect motions/textures even after the pack is found. + TryExtractGfModelPackInner(slice, depth + 1, textureBlobs, motionBlobs, out _); + } + } + + return foundPack; + } + + private static bool LooksLikeContainer(byte[] data) + { + if (data == null || data.Length < 8) + return false; + byte a = data[0]; + byte b = data[1]; + if (a < 0x41 || a > 0x5A || b < 0x41 || b > 0x5A) + return false; + ushort count = (ushort)(data[2] | (data[3] << 8)); + if (count == 0 || count > 0x4000) + return false; + int tableSize = 4 + (count + 1) * 4; + return tableSize <= data.Length; + } + + private static bool IsValidGfModelPack(byte[] data) + { + if (data == null || data.Length < 4) + return false; + if (BitConverter.ToUInt32(data, 0) != 0x00010000) + return false; + if (data.Length < 24) + return false; + + int[] counts = new int[5]; + int total = 0; + for (int i = 0; i < 5; i++) + { + counts[i] = BitConverter.ToInt32(data, 4 + i * 4); + if (counts[i] < 0) + return false; + total += counts[i]; + } + + int ptrBase = 4 + 5 * 4; + int ptrTableBytes = total * 4; + if (ptrBase + ptrTableBytes > data.Length) + return false; + + bool hasModel = false; + int off = ptrBase; + for (int sect = 0; sect < 5; sect++) + { + for (int i = 0; i < counts[sect]; i++) + { + int ptr = BitConverter.ToInt32(data, off + i * 4); + if (ptr <= 0 || ptr >= data.Length) + continue; + if (ptr + 1 >= data.Length) + continue; + int nameLen = data[ptr]; + int nameEnd = ptr + 1 + nameLen; + if (nameEnd + 4 > data.Length) + continue; + int addr = BitConverter.ToInt32(data, nameEnd); + if (addr <= 0 || addr + 4 > data.Length) + continue; + if (sect == 0 && BitConverter.ToUInt32(data, addr) == 0x15122117) + hasModel = true; + } + off += counts[sect] * 4; + } + + return hasModel; + } + + private static bool IsGfTexture(byte[] data) + { + if (data == null || data.Length < 4) + return false; + return BitConverter.ToUInt32(data, 0) == 0x15041213; + } + + private static bool IsGfMotion(byte[] data) + { + if (data == null || data.Length < 4) + return false; + return BitConverter.ToUInt32(data, 0) == 0x00060000; + } + + private static bool IsLikelyGfMotionPack(byte[] data) + { + if (data == null || data.Length < 8) + return false; + + uint count = BitConverter.ToUInt32(data, 0); + if (count == 0 || count > 0x1000) + return false; + + long tableEnd = 4 + count * 4; + if (tableEnd > data.Length) + return false; + + bool hasEntry = false; + for (int i = 0; i < count; i++) + { + uint addr = BitConverter.ToUInt32(data, 4 + i * 4); + if (addr == 0) + continue; + long pos = 4 + addr; + if (pos + 4 > data.Length || pos < 4) + return false; + if (BitConverter.ToUInt32(data, (int)pos) != 0x00060000) + return false; + hasEntry = true; + } + + return hasEntry; + } + + private static bool LooksLikeLz11(byte[] data) + { + if (data == null || data.Length < 4 || data[0] != 0x11) + return false; + int decodedLen = data[1] | (data[2] << 8) | (data[3] << 16); + return decodedLen > data.Length; + } + + private static byte[] DecompressLz11(byte[] data) + { + if (data == null || data.Length < 4) + return data; + if (data[0] != 0x11) + return data; + int decodedLen = data[1] | (data[2] << 8) | (data[3] << 16); + byte[] output = new byte[decodedLen]; + int outOff = 0; + int inOff = 4; + int mask = 0; + int header = 0; + + while (outOff < decodedLen && inOff < data.Length) + { + mask >>= 1; + if (mask == 0) + { + header = data[inOff++]; + mask = 0x80; + } + + if ((header & mask) == 0) + { + if (inOff >= data.Length) + break; + output[outOff++] = data[inOff++]; + continue; + } + + if (inOff >= data.Length) + break; + int byte1 = data[inOff++]; + int top = byte1 >> 4; + int position; + int length; + + if (top == 0) + { + if (inOff + 1 >= data.Length) + break; + int byte2 = data[inOff++]; + int byte3 = data[inOff++]; + position = ((byte2 & 0xF) << 8) | byte3; + length = (((byte1 & 0xF) << 4) | (byte2 >> 4)) + 0x11; + } + else if (top == 1) + { + if (inOff + 2 >= data.Length) + break; + int byte2 = data[inOff++]; + int byte3 = data[inOff++]; + int byte4 = data[inOff++]; + position = ((byte3 & 0xF) << 8) | byte4; + length = (((byte1 & 0xF) << 12) | (byte2 << 4) | (byte3 >> 4)) + 0x111; + } + else + { + if (inOff >= data.Length) + break; + int byte2 = data[inOff++]; + position = ((byte1 & 0xF) << 8) | byte2; + length = (byte1 >> 4) + 1; + } + + position += 1; + for (int i = 0; i < length && outOff < decodedLen; i++) + { + output[outOff] = output[outOff - position]; + outOff++; + } + } + + return output; + } + + private static void AppendExternalTextures(GFModelPack pack, List textureBlobs) + { + foreach (var tex in textureBlobs) + { + try + { + using var ms = new MemoryStream(tex); + using var reader = new BinaryReader(ms); + pack.Textures.Add(new SPICA.Formats.GFL2.Texture.GFTexture(reader)); + } + catch + { + // Best-effort: ignore malformed texture blobs. + } + } + } + + private static void EnsureTextures(H3D h3d, byte[] packBytes, List extraTextures) + { + if (h3d == null || h3d.Textures == null) + return; + if (h3d.Textures.Count > 0) + return; + + AppendTexturesFromPackBytes(h3d, packBytes); + if (h3d.Textures.Count == 0 && extraTextures.Count > 0) + AppendTexturesFromBlobs(h3d, extraTextures); + } + + private static void AppendTexturesFromPackBytes(H3D h3d, byte[] packBytes) + { + if (h3d == null || packBytes == null || packBytes.Length < 24) + return; + if (BitConverter.ToUInt32(packBytes, 0) != 0x00010000) + return; + + int[] counts = new int[5]; + int total = 0; + for (int i = 0; i < 5; i++) + { + counts[i] = BitConverter.ToInt32(packBytes, 4 + i * 4); + if (counts[i] < 0) + return; + total += counts[i]; + } + + int ptrBase = 4 + 5 * 4; + if (ptrBase + total * 4 > packBytes.Length) + return; + + int off = ptrBase; + for (int sect = 0; sect < 5; sect++) + { + for (int i = 0; i < counts[sect]; i++) + { + int ptr = BitConverter.ToInt32(packBytes, off + i * 4); + if (ptr <= 0 || ptr >= packBytes.Length) + continue; + if (ptr + 1 >= packBytes.Length) + continue; + int nameLen = packBytes[ptr]; + int nameEnd = ptr + 1 + nameLen; + if (nameEnd + 4 > packBytes.Length) + continue; + int addr = BitConverter.ToInt32(packBytes, nameEnd); + if (addr <= 0 || addr + 4 > packBytes.Length) + continue; + if (BitConverter.ToUInt32(packBytes, addr) != 0x15041213) + continue; + + try + { + using var ms = new MemoryStream(packBytes); + using var reader = new BinaryReader(ms); + reader.BaseStream.Seek(addr, SeekOrigin.Begin); + var tex = new SPICA.Formats.GFL2.Texture.GFTexture(reader).ToH3DTexture(); + if (!h3d.Textures.Contains(tex.Name)) + h3d.Textures.Add(tex); + } + catch + { + // Best-effort. + } + } + off += counts[sect] * 4; + } + } + + private static void AppendTexturesFromBlobs(H3D h3d, List blobs) + { + foreach (var tex in blobs) + { + try + { + using var ms = new MemoryStream(tex); + using var reader = new BinaryReader(ms); + var h3dTex = new SPICA.Formats.GFL2.Texture.GFTexture(reader).ToH3DTexture(); + if (!h3d.Textures.Contains(h3dTex.Name)) + h3d.Textures.Add(h3dTex); + } + catch + { + // Best-effort. + } + } + } + + private void AddAnimationGroups(H3D h3d) + { + if (h3d == null) + return; + + AddNodeGroupSimple(h3d.SkeletalAnimations, BCH.H3DGroupType.SkeletalAnim); + AddNodeGroupSimple(h3d.MaterialAnimations, BCH.H3DGroupType.MaterialAnim); + AddNodeGroupSimple(h3d.VisibilityAnimations, BCH.H3DGroupType.VisibiltyAnim); + } + + private void AddNodeGroupSimple(H3DDict subSections, BCH.H3DGroupType type) + where T : INamed + { + if (subSections == null || subSections.Count == 0) + return; + + var folder = new BCH.H3DGroupNode(type, subSections); + + foreach (var item in subSections) + { + if (item is H3DAnimation) + folder.AddChild(new AnimationNode(subSections, item)); + else + folder.AddChild(new BCH.NodeSection(subSections, item)); + } + + Root.AddChild(folder); + } + + private class AnimationNode : BCH.NodeSection where T : INamed + { + public AnimationNode(H3DDict subSections, object section) : base(subSections, section) + { + if (section is not H3DAnimation animation) + return; + + var wrapper = new AnimationWrapper(animation); + Tag = wrapper; + + this.OnHeaderRenamed += delegate + { + wrapper.Root.Header = this.Header; + }; + wrapper.Root.OnHeaderRenamed += delegate + { + this.Header = wrapper.Root.Header; + }; + + var propertyUI = new BchAnimPropertyUI(); + this.TagUI.UIDrawer += delegate + { + propertyUI.Render(wrapper, null); + }; + + this.OnSelected += delegate + { + if (Tag is AnimationWrapper) + ((AnimationWrapper)Tag).AnimationSet(); + }; + } + } + + private static void AppendAnimations(H3D h3d, List motionBlobs) + { + if (h3d == null || motionBlobs == null || motionBlobs.Count == 0) + return; + + var skeleton = h3d.Models.Count > 0 ? h3d.Models[0].Skeleton : null; + int fallbackIndex = 0; + + foreach (var blob in motionBlobs) + { + if (blob == null || blob.Length < 4) + continue; + + try + { + if (IsLikelyGfMotionPack(blob)) + { + using var ms = new MemoryStream(blob); + using var reader = new BinaryReader(ms); + var pack = new GFMotionPack(reader); + foreach (var mot in pack) + AddMotion(h3d, mot, skeleton); + } + else if (IsGfMotion(blob)) + { + using var ms = new MemoryStream(blob); + using var reader = new BinaryReader(ms); + var mot = new GFMotion(reader, fallbackIndex++); + AddMotion(h3d, mot, skeleton); + } + } + catch + { + // Best-effort: ignore malformed motions. + } + } + } + + private static void AddMotion(H3D h3d, GFMotion motion, H3DDict skeleton) + { + if (h3d == null || motion == null) + return; + + string baseName = $"Motion_{motion.Index}"; + + H3DAnimation sklAnim = null; + if (skeleton != null && skeleton.Count > 0) + sklAnim = motion.ToH3DSkeletalAnimation(skeleton); + + H3DMaterialAnim matAnim = motion.ToH3DMaterialAnimation(); + H3DAnimation visAnim = motion.ToH3DVisibilityAnimation(); + + if (sklAnim != null) + { + sklAnim.Name = GetUniqueName(h3d.SkeletalAnimations, baseName); + h3d.SkeletalAnimations.Add(sklAnim); + } + + if (matAnim != null) + { + matAnim.Name = GetUniqueName(h3d.MaterialAnimations, baseName); + h3d.MaterialAnimations.Add(matAnim); + } + + if (visAnim != null) + { + visAnim.Name = GetUniqueName(h3d.VisibilityAnimations, baseName); + h3d.VisibilityAnimations.Add(visAnim); + } + } + + private static string GetUniqueName(H3DDict dict, string baseName) where T : INamed + { + if (dict == null) + return baseName; + string name = baseName; + int suffix = 1; + while (dict.Contains(name)) + { + name = $"{baseName}_{suffix}"; + suffix++; + } + return name; + } + } +} diff --git a/Files/GFL2/GFMotionPackFile.cs b/Files/GFL2/GFMotionPackFile.cs new file mode 100644 index 0000000..89c0b5f --- /dev/null +++ b/Files/GFL2/GFMotionPackFile.cs @@ -0,0 +1,512 @@ +using CtrLibrary; +using CtrLibrary.Bch; +using CtrLibrary.UI; +using CtrLibrary.Rendering; +using GLFrameworkEngine; +using MapStudio.UI; +using SPICA.Formats.Common; +using SPICA.Formats.CtrH3D; +using SPICA.Formats.CtrH3D.Animation; +using SPICA.Formats.CtrH3D.LUT; +using SPICA.Formats.CtrH3D.Model; +using SPICA.Formats.CtrH3D.Texture; +using SPICA.Formats.GFL2; +using SPICA.Formats.GFL2.Model; +using SPICA.Formats.GFL2.Motion; +using System; +using System.Collections.Generic; +using System.IO; +using System.Numerics; +using Toolbox.Core; +using Toolbox.Core.IO; +using UIFramework; + +namespace CtrLibrary.GFL2 +{ + /// + /// Viewer-only support for GFL2 GFMotion (0x00060000) and motion packs. + /// + public class GFMotionPackFile : FileEditor, IFileFormat + { + public string[] Description => new string[] { "GFL2 GFMotion" }; + public string[] Extension => new string[] { "*.gfmot", "*.gfmotion" }; + public bool CanSave { get; set; } = false; + public File_Info FileInfo { get; set; } + + public H3DRender Render; + public H3D H3DData; + + public override bool DisplayViewport => Render != null && Render.Renderer.Models.Count > 0; + + public GFMotionPackFile() { FileInfo = new File_Info(); } + + public bool Identify(File_Info fileInfo, Stream stream) + { + byte[] data = stream.ToArray(); + if (fileInfo?.ParentArchive != null && GFL2BundleHelper.IsPcContainer(data)) + return false; + return TryExtractMotions(data, out _); + } + + public override bool CreateNew() + { + return false; + } + + public void Load(Stream stream) + { + byte[] data = stream.ToArray(); + bool isPcContainer = GFL2BundleHelper.IsPcContainer(data); + if (!TryExtractMotions(data, out List motionBlobs)) + throw new Exception("No GFMotion data found in file."); + + var h3d = new H3D(); + var model = H3DRender.GetFirstVisibleModel(); + AppendAnimations(h3d, motionBlobs, model?.Skeleton); + + Load(h3d); + } + + public void Save(Stream stream) + { + throw new NotSupportedException("GFMotion viewer-only support (save not implemented)."); + } + + public override List PrepareDocks() + { + List windows = new List(); + windows.Add(Workspace.Outliner); + windows.Add(Workspace.PropertyWindow); + windows.Add(Workspace.ConsoleWindow); + windows.Add(Workspace.ViewportWindow); + windows.Add(Workspace.TimelineWindow); + windows.Add(Workspace.GraphWindow); + return windows; + } + + private void Load(H3D h3d) + { + H3DData = h3d; + Root.TagUI.Tag = h3d; + + if (h3d.Models.Count > 0) + { + Render = new H3DRender(H3DData, null); + AddRender(Render); + + Root.AddChild(SceneLightingUI.Setup(Render)); + + var bch = new BCH(); + bch.Render = Render; + bch.H3DData = H3DData; + + Root.AddChild(new ModelFolder(bch, H3DData, H3DData.Models)); + Root.AddChild(new TextureFolder(Render, H3DData.Textures)); + Root.AddChild(new LUTFolder(Render, H3DData.LUTs)); + AddAnimationGroups(h3d); + + FrameCamera(); + Root.OnSelected += delegate { FrameCamera(); }; + } + else + { + AddAnimationGroups(h3d); + } + } + + private void AddAnimationGroups(H3D h3d) + { + if (h3d == null) + return; + + AddNodeGroupSimple(h3d.SkeletalAnimations, BCH.H3DGroupType.SkeletalAnim); + AddNodeGroupSimple(h3d.MaterialAnimations, BCH.H3DGroupType.MaterialAnim); + AddNodeGroupSimple(h3d.VisibilityAnimations, BCH.H3DGroupType.VisibiltyAnim); + } + + private void AddNodeGroupSimple(H3DDict subSections, BCH.H3DGroupType type) + where T : INamed + { + if (subSections == null || subSections.Count == 0) + return; + + var folder = new BCH.H3DGroupNode(type, subSections); + + foreach (var item in subSections) + { + if (item is H3DAnimation) + folder.AddChild(new AnimationNode(subSections, item)); + else + folder.AddChild(new BCH.NodeSection(subSections, item)); + } + + Root.AddChild(folder); + } + + private class AnimationNode : BCH.NodeSection where T : INamed + { + public AnimationNode(H3DDict subSections, object section) : base(subSections, section) + { + if (section is not H3DAnimation animation) + return; + + var wrapper = new AnimationWrapper(animation); + Tag = wrapper; + + this.OnHeaderRenamed += delegate + { + wrapper.Root.Header = this.Header; + }; + wrapper.Root.OnHeaderRenamed += delegate + { + this.Header = wrapper.Root.Header; + }; + + var propertyUI = new BchAnimPropertyUI(); + this.TagUI.UIDrawer += delegate + { + propertyUI.Render(wrapper, null); + }; + + this.OnSelected += delegate + { + if (Tag is AnimationWrapper) + ((AnimationWrapper)Tag).AnimationSet(); + }; + } + } + + private void FrameCamera() + { + if (Render == null || Render.Renderer.Models.Count == 0) + return; + + var aabb = Render.Renderer.Models[0].GetModelAABB(); + var center = aabb.Center; + + float dimension = 1; + dimension = Math.Max(dimension, Math.Abs(aabb.Size.X)); + dimension = Math.Max(dimension, Math.Abs(aabb.Size.Y)); + dimension = Math.Max(dimension, Math.Abs(aabb.Size.Z)); + dimension *= 2; + + var translation = new OpenTK.Vector3(0, 0, dimension); + GLContext.ActiveContext.Camera.SetPosition(center + translation); + GLContext.ActiveContext.Camera.RotationX = 0; + GLContext.ActiveContext.Camera.RotationY = 0; + GLContext.ActiveContext.Camera.RotationZ = 0; + GLContext.ActiveContext.Camera.UpdateMatrices(); + } + + private static bool TryExtractMotions(byte[] data, out List motionBlobs) + { + motionBlobs = new List(); + if (data == null || data.Length < 4) + return false; + return TryExtractMotionsInner(data, 0, motionBlobs); + } + + private static bool TryExtractMotionsInner(byte[] data, int depth, List motionBlobs) + { + if (depth > 8 || data == null || data.Length < 4) + return false; + + bool found = false; + + if (LooksLikeLz11(data)) + { + data = DecompressLz11(data); + if (data == null || data.Length < 4) + return false; + } + + if (IsGfMotion(data) || IsLikelyGfMotionPack(data)) + { + motionBlobs.Add(data); + found = true; + } + + if (!LooksLikeContainer(data)) + return found; + + ushort count = (ushort)(data[2] | (data[3] << 8)); + int tableSize = 4 + (count + 1) * 4; + if (tableSize > data.Length) + return found; + + List offsets = new List(count + 1); + int prev = 0; + for (int i = 0; i < count + 1; i++) + { + int off = BitConverter.ToInt32(data, 4 + i * 4); + if (off < prev || off > data.Length) + return found; + offsets.Add(off); + prev = off; + } + + for (int i = 0; i < count; i++) + { + int start = offsets[i]; + int end = offsets[i + 1]; + if (start < 0 || end < start || end > data.Length) + continue; + byte[] slice = new byte[end - start]; + Buffer.BlockCopy(data, start, slice, 0, slice.Length); + if (TryExtractMotionsInner(slice, depth + 1, motionBlobs)) + found = true; + } + + return found; + } + + private static bool IsGfMotion(byte[] data) + { + if (data == null || data.Length < 4) + return false; + return BitConverter.ToUInt32(data, 0) == 0x00060000; + } + + private static bool IsLikelyGfMotionPack(byte[] data) + { + if (data == null || data.Length < 8) + return false; + + uint count = BitConverter.ToUInt32(data, 0); + if (count == 0 || count > 0x1000) + return false; + + long tableEnd = 4 + count * 4; + if (tableEnd > data.Length) + return false; + + bool hasEntry = false; + for (int i = 0; i < count; i++) + { + uint addr = BitConverter.ToUInt32(data, 4 + i * 4); + if (addr == 0) + continue; + long pos = 4 + addr; + if (pos + 4 > data.Length || pos < 4) + return false; + if (BitConverter.ToUInt32(data, (int)pos) != 0x00060000) + return false; + hasEntry = true; + } + + return hasEntry; + } + + private static bool LooksLikeContainer(byte[] data) + { + if (data == null || data.Length < 8) + return false; + byte a = data[0]; + byte b = data[1]; + if (a < 0x41 || a > 0x5A || b < 0x41 || b > 0x5A) + return false; + ushort count = (ushort)(data[2] | (data[3] << 8)); + if (count == 0 || count > 0x4000) + return false; + int tableSize = 4 + (count + 1) * 4; + return tableSize <= data.Length; + } + + private static bool LooksLikeLz11(byte[] data) + { + if (data == null || data.Length < 4 || data[0] != 0x11) + return false; + int decodedLen = data[1] | (data[2] << 8) | (data[3] << 16); + return decodedLen > data.Length; + } + + private static byte[] DecompressLz11(byte[] data) + { + if (data == null || data.Length < 4) + return data; + if (data[0] != 0x11) + return data; + int decodedLen = data[1] | (data[2] << 8) | (data[3] << 16); + byte[] output = new byte[decodedLen]; + int outOff = 0; + int inOff = 4; + int mask = 0; + int header = 0; + + while (outOff < decodedLen && inOff < data.Length) + { + mask >>= 1; + if (mask == 0) + { + header = data[inOff++]; + mask = 0x80; + } + + if ((header & mask) == 0) + { + if (inOff >= data.Length) + break; + output[outOff++] = data[inOff++]; + continue; + } + + if (inOff >= data.Length) + break; + int byte1 = data[inOff++]; + int top = byte1 >> 4; + int position; + int length; + + if (top == 0) + { + if (inOff + 1 >= data.Length) + break; + int byte2 = data[inOff++]; + int byte3 = data[inOff++]; + position = ((byte2 & 0xF) << 8) | byte3; + length = (((byte1 & 0xF) << 4) | (byte2 >> 4)) + 0x11; + } + else if (top == 1) + { + if (inOff + 2 >= data.Length) + break; + int byte2 = data[inOff++]; + int byte3 = data[inOff++]; + int byte4 = data[inOff++]; + position = ((byte3 & 0xF) << 8) | byte4; + length = (((byte1 & 0xF) << 12) | (byte2 << 4) | (byte3 >> 4)) + 0x111; + } + else + { + if (inOff >= data.Length) + break; + int byte2 = data[inOff++]; + position = ((byte1 & 0xF) << 8) | byte2; + length = (byte1 >> 4) + 1; + } + + position += 1; + for (int i = 0; i < length && outOff < decodedLen; i++) + { + output[outOff] = output[outOff - position]; + outOff++; + } + } + + return output; + } + + private static void AppendAnimations(H3D h3d, List motionBlobs, H3DDict skeleton) + { + if (h3d == null || motionBlobs == null || motionBlobs.Count == 0) + return; + + int fallbackIndex = 0; + foreach (var blob in motionBlobs) + { + if (blob == null || blob.Length < 4) + continue; + + try + { + if (IsLikelyGfMotionPack(blob)) + { + using var ms = new MemoryStream(blob); + using var reader = new BinaryReader(ms); + var pack = new GFMotionPack(reader); + foreach (var mot in pack) + AddMotion(h3d, mot, skeleton); + } + else if (IsGfMotion(blob)) + { + using var ms = new MemoryStream(blob); + using var reader = new BinaryReader(ms); + var mot = new GFMotion(reader, fallbackIndex++); + AddMotion(h3d, mot, skeleton); + } + } + catch + { + // Best-effort: ignore malformed motions. + } + } + } + + private static void AddMotion(H3D h3d, GFMotion motion, H3DDict skeleton) + { + if (h3d == null || motion == null) + return; + + string baseName = $"Motion_{motion.Index}"; + + H3DAnimation sklAnim = null; + if (motion.SkeletalAnimation != null) + { + if (skeleton != null && skeleton.Count > 0) + sklAnim = motion.ToH3DSkeletalAnimation(skeleton); + else + sklAnim = motion.ToH3DSkeletalAnimation(BuildSkeleton(motion)); + } + + H3DMaterialAnim matAnim = motion.ToH3DMaterialAnimation(); + H3DAnimation visAnim = motion.ToH3DVisibilityAnimation(); + + if (sklAnim != null) + { + sklAnim.Name = GetUniqueName(h3d.SkeletalAnimations, baseName); + h3d.SkeletalAnimations.Add(sklAnim); + } + + if (matAnim != null) + { + matAnim.Name = GetUniqueName(h3d.MaterialAnimations, baseName); + h3d.MaterialAnimations.Add(matAnim); + } + + if (visAnim != null) + { + visAnim.Name = GetUniqueName(h3d.VisibilityAnimations, baseName); + h3d.VisibilityAnimations.Add(visAnim); + } + } + + private static string GetUniqueName(H3DDict dict, string baseName) where T : INamed + { + if (dict == null) + return baseName; + string name = baseName; + int suffix = 1; + while (dict.Contains(name)) + { + name = $"{baseName}_{suffix}"; + suffix++; + } + return name; + } + + private static List BuildSkeleton(GFMotion motion) + { + var skeleton = new List(); + if (motion?.SkeletalAnimation == null) + return skeleton; + + foreach (var bone in motion.SkeletalAnimation.Bones) + { + if (skeleton.Exists(x => x.Name == bone.Name)) + continue; + + skeleton.Add(new GFBone() + { + Name = bone.Name, + Parent = string.Empty, + Flags = 1, + Scale = Vector3.One, + Rotation = Vector3.Zero, + Translation = Vector3.Zero + }); + } + + return skeleton; + } + } +} diff --git a/Files/GFL2/GFTexturePackFile.cs b/Files/GFL2/GFTexturePackFile.cs new file mode 100644 index 0000000..e14c2ae --- /dev/null +++ b/Files/GFL2/GFTexturePackFile.cs @@ -0,0 +1,369 @@ +using CtrLibrary; +using CtrLibrary.Bch; +using CtrLibrary.Rendering; +using CtrLibrary.UI; +using GLFrameworkEngine; +using MapStudio.UI; +using SPICA.Formats.CtrH3D; +using SPICA.Formats.CtrH3D.Animation; +using SPICA.Formats.CtrH3D.LUT; +using SPICA.Formats.CtrH3D.Model; +using SPICA.Formats.CtrH3D.Texture; +using System; +using System.Collections.Generic; +using System.IO; +using Toolbox.Core; +using Toolbox.Core.IO; +using Toolbox.Core.ViewModels; +using UIFramework; + +namespace CtrLibrary.GFL2 +{ + /// + /// Viewer-only support for GFL2 GFTexture blocks and texture packs. + /// + public class GFTexturePackFile : FileEditor, IFileFormat + { + public string[] Description => new string[] { "GFL2 GFTexture" }; + public string[] Extension => new string[] { "*.gftex", "*.gftx", "*.gftexture" }; + public bool CanSave { get; set; } = false; + public File_Info FileInfo { get; set; } + + public H3DRender Render; + public H3D H3DData; + + public override bool DisplayViewport => Render != null && Render.Renderer.Models.Count > 0; + + public GFTexturePackFile() { FileInfo = new File_Info(); } + + public bool Identify(File_Info fileInfo, Stream stream) + { + byte[] data = stream.ToArray(); + if (fileInfo?.ParentArchive != null && GFL2BundleHelper.IsPcContainer(data)) + return false; + return TryExtractTextures(data, out _); + } + + public override bool CreateNew() + { + return false; + } + + public void Load(Stream stream) + { + byte[] data = stream.ToArray(); + bool isPcContainer = GFL2BundleHelper.IsPcContainer(data); + if (!TryExtractTextures(data, out List textureBlobs)) + throw new Exception("No GFTexture data found in file."); + + var h3d = new H3D(); + GFL2BundleHelper.AppendTextures(h3d, textureBlobs); + + Load(h3d); + } + + public void Save(Stream stream) + { + throw new NotSupportedException("GFTexture viewer-only support (save not implemented)."); + } + + public override List PrepareDocks() + { + List windows = new List(); + windows.Add(Workspace.Outliner); + windows.Add(Workspace.PropertyWindow); + windows.Add(Workspace.ConsoleWindow); + windows.Add(Workspace.ViewportWindow); + windows.Add(Workspace.TimelineWindow); + windows.Add(Workspace.GraphWindow); + return windows; + } + + private void Load(H3D h3d) + { + H3DData = h3d; + Root.TagUI.Tag = h3d; + + Render = new H3DRender(H3DData, null); + AddRender(Render); + + if (H3DData.Models.Count > 0) + { + Root.AddChild(SceneLightingUI.Setup(Render)); + + var bch = new BCH(); + bch.Render = Render; + bch.H3DData = H3DData; + + Root.AddChild(new ModelFolder(bch, H3DData, H3DData.Models)); + Root.AddChild(new TextureFolder(Render, H3DData.Textures)); + Root.AddChild(new LUTFolder(Render, H3DData.LUTs)); + AddAnimationGroups(H3DData); + + FrameCamera(); + Root.OnSelected += delegate { FrameCamera(); }; + } + else + { + Root.AddChild(new TextureFolder(Render, H3DData.Textures)); + } + } + + private void AddAnimationGroups(H3D h3d) + { + if (h3d == null) + return; + + AddNodeGroupSimple(h3d.SkeletalAnimations, BCH.H3DGroupType.SkeletalAnim); + AddNodeGroupSimple(h3d.MaterialAnimations, BCH.H3DGroupType.MaterialAnim); + AddNodeGroupSimple(h3d.VisibilityAnimations, BCH.H3DGroupType.VisibiltyAnim); + } + + private void AddNodeGroupSimple(H3DDict subSections, BCH.H3DGroupType type) + where T : SPICA.Formats.Common.INamed + { + if (subSections == null || subSections.Count == 0) + return; + + var folder = new BCH.H3DGroupNode(type, subSections); + + foreach (var item in subSections) + { + if (item is H3DAnimation) + folder.AddChild(new AnimationNode(subSections, item)); + else + folder.AddChild(new BCH.NodeSection(subSections, item)); + } + + Root.AddChild(folder); + } + + private class AnimationNode : BCH.NodeSection where T : SPICA.Formats.Common.INamed + { + public AnimationNode(H3DDict subSections, object section) : base(subSections, section) + { + if (section is not H3DAnimation animation) + return; + + var wrapper = new AnimationWrapper(animation); + Tag = wrapper; + + this.OnHeaderRenamed += delegate + { + wrapper.Root.Header = this.Header; + }; + wrapper.Root.OnHeaderRenamed += delegate + { + this.Header = wrapper.Root.Header; + }; + + var propertyUI = new BchAnimPropertyUI(); + this.TagUI.UIDrawer += delegate + { + propertyUI.Render(wrapper, null); + }; + + this.OnSelected += delegate + { + if (Tag is AnimationWrapper) + ((AnimationWrapper)Tag).AnimationSet(); + }; + } + } + + private void FrameCamera() + { + if (Render == null || Render.Renderer.Models.Count == 0) + return; + + var aabb = Render.Renderer.Models[0].GetModelAABB(); + var center = aabb.Center; + + float dimension = 1; + dimension = Math.Max(dimension, Math.Abs(aabb.Size.X)); + dimension = Math.Max(dimension, Math.Abs(aabb.Size.Y)); + dimension = Math.Max(dimension, Math.Abs(aabb.Size.Z)); + dimension *= 2; + + var translation = new OpenTK.Vector3(0, 0, dimension); + GLContext.ActiveContext.Camera.SetPosition(center + translation); + GLContext.ActiveContext.Camera.RotationX = 0; + GLContext.ActiveContext.Camera.RotationY = 0; + GLContext.ActiveContext.Camera.RotationZ = 0; + GLContext.ActiveContext.Camera.UpdateMatrices(); + } + + private static bool TryExtractTextures(byte[] data, out List textureBlobs) + { + textureBlobs = new List(); + if (data == null || data.Length < 4) + return false; + return TryExtractTexturesInner(data, 0, textureBlobs); + } + + private static bool TryExtractTexturesInner(byte[] data, int depth, List textureBlobs) + { + if (depth > 8 || data == null || data.Length < 4) + return false; + + bool found = false; + + if (LooksLikeLz11(data)) + { + data = DecompressLz11(data); + if (data == null || data.Length < 4) + return false; + } + + if (IsGfTexture(data)) + { + textureBlobs.Add(data); + found = true; + } + + if (!LooksLikeContainer(data)) + return found; + + ushort count = (ushort)(data[2] | (data[3] << 8)); + int tableSize = 4 + (count + 1) * 4; + if (tableSize > data.Length) + return found; + + List offsets = new List(count + 1); + int prev = 0; + for (int i = 0; i < count + 1; i++) + { + int off = BitConverter.ToInt32(data, 4 + i * 4); + if (off < prev || off > data.Length) + return found; + offsets.Add(off); + prev = off; + } + + for (int i = 0; i < count; i++) + { + int start = offsets[i]; + int end = offsets[i + 1]; + if (start < 0 || end < start || end > data.Length) + continue; + byte[] slice = new byte[end - start]; + Buffer.BlockCopy(data, start, slice, 0, slice.Length); + if (TryExtractTexturesInner(slice, depth + 1, textureBlobs)) + found = true; + } + + return found; + } + + private static bool LooksLikeContainer(byte[] data) + { + if (data == null || data.Length < 8) + return false; + byte a = data[0]; + byte b = data[1]; + if (a < 0x41 || a > 0x5A || b < 0x41 || b > 0x5A) + return false; + ushort count = (ushort)(data[2] | (data[3] << 8)); + if (count == 0 || count > 0x4000) + return false; + int tableSize = 4 + (count + 1) * 4; + return tableSize <= data.Length; + } + + private static bool IsGfTexture(byte[] data) + { + if (data == null || data.Length < 4) + return false; + return BitConverter.ToUInt32(data, 0) == 0x15041213; + } + + private static bool LooksLikeLz11(byte[] data) + { + if (data == null || data.Length < 4 || data[0] != 0x11) + return false; + int decodedLen = data[1] | (data[2] << 8) | (data[3] << 16); + return decodedLen > data.Length; + } + + private static byte[] DecompressLz11(byte[] data) + { + if (data == null || data.Length < 4) + return data; + if (data[0] != 0x11) + return data; + int decodedLen = data[1] | (data[2] << 8) | (data[3] << 16); + byte[] output = new byte[decodedLen]; + int outOff = 0; + int inOff = 4; + int mask = 0; + int header = 0; + + while (outOff < decodedLen && inOff < data.Length) + { + mask >>= 1; + if (mask == 0) + { + header = data[inOff++]; + mask = 0x80; + } + + if ((header & mask) == 0) + { + if (inOff >= data.Length) + break; + output[outOff++] = data[inOff++]; + continue; + } + + if (inOff >= data.Length) + break; + int byte1 = data[inOff++]; + int top = byte1 >> 4; + int position; + int length; + + if (top == 0) + { + if (inOff + 1 >= data.Length) + break; + int byte2 = data[inOff++]; + int byte3 = data[inOff++]; + position = ((byte2 & 0xF) << 8) | byte3; + length = (((byte1 & 0xF) << 4) | (byte2 >> 4)) + 0x11; + } + else if (top == 1) + { + if (inOff + 2 >= data.Length) + break; + int byte2 = data[inOff++]; + int byte3 = data[inOff++]; + int byte4 = data[inOff++]; + position = ((byte3 & 0xF) << 8) | byte4; + length = (((byte1 & 0xF) << 12) | (byte2 << 4) | (byte3 >> 4)) + 0x111; + } + else + { + if (inOff >= data.Length) + break; + int byte2 = data[inOff++]; + position = ((byte1 & 0xF) << 8) | byte2; + length = (byte1 >> 4) + 1; + } + + position += 1; + for (int i = 0; i < length && outOff < decodedLen; i++) + { + output[outOff] = output[outOff - position]; + outOff++; + } + } + + return output; + } + + private static void AppendTexturesFromBlobs(H3D h3d, List blobs) + { + GFL2BundleHelper.AppendTextures(h3d, blobs); + } + } +} diff --git a/Files/Garc/GARC.cs b/Files/Garc/GARC.cs index c027d6c..2338a14 100644 --- a/Files/Garc/GARC.cs +++ b/Files/Garc/GARC.cs @@ -1,319 +1,450 @@ -using CtrLibrary; -using Syroot.BinaryData; -using System; -using System.Collections.Generic; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using Toolbox.Core; -using Toolbox.Core.IO; -using UIFramework; - -namespace FirstPlugin -{ - public class GARC : MapStudio.UI.FileEditor, IArchiveFile, IFileFormat, IDisposable - { - public bool CanSave { get; set; } = true; - - public string[] Description { get; set; } = new string[] { "GARC" }; - public string[] Extension { get; set; } = new string[] { "*.garc" }; - - public File_Info FileInfo { get; set; } - - public bool CanAddFiles { get; set; } = true; - public bool CanRenameFiles { get; set; } = true; - public bool CanReplaceFiles { get; set; } = true; - public bool CanDeleteFiles { get; set; } = true; - - public IEnumerable Files => files; - - // private - private Header header; - private FatoHeader fatoHeader; - private List fatoOffsets; - - private FatbHeader fatbHeader; - private List fatbEntries; - - private FimbHeader fimbHeader; - - private List files = new List(); - - public bool AddFile(ArchiveFileInfo archiveFileInfo) - { - files.Add(new GARC4FileInfo(this) - { - FileData = archiveFileInfo.FileData, - FileName = archiveFileInfo.FileName, - }); - return true; - } - - public void ClearFiles() => files.Clear(); - - public bool DeleteFile(ArchiveFileInfo archiveFileInfo) - { - return files.Remove((GARC4FileInfo)archiveFileInfo); - } - - public bool Identify(File_Info fileInfo, Stream stream) - { - using (FileReader reader = new FileReader(stream, true)) - return reader.CheckSignature(4, "CRAG"); - } - - private static Dictionary _knownFiles = new Dictionary - { - ["BCH"] = ".bch", - ["PC"] = ".pc", - ["PF"] = ".pf", - ["PF"] = ".pf", - ["PB"] = ".pb", - ["PT"] = ".pt", - ["PK"] = ".pk", - ["CGFX"] = ".cgfx", - }; - - /// - /// Prepares the dock layouts to be used for the file format. - /// - public override List PrepareDocks() - { - List windows = new List(); - windows.Add(Workspace.Outliner); - windows.Add(Workspace.PropertyWindow); - return windows; - } - - public void Dispose() - { - FileInfo.Stream?.Dispose(); - } - - //Parse/save code from - //https://github.com/IcySon55/Kuriimu/blob/3f05ffc993e0908929e92373c455acb633b8f28d/src/archive/archive_nintendo/Garc4Manager.cs - - public void Load(Stream stream) - { - files.Clear(); - - FileInfo.Stream = stream; - FileInfo.KeepOpen = true; - using (FileReader reader = new FileReader(stream, true)) - { - //header - header = reader.ReadStruct
(); - //FATO - fatoHeader = reader.ReadStruct(); - reader.BaseStream.Position = reader.BaseStream.Position + 3 & ~3; - fatoOffsets = reader.ReadUInt32s(fatoHeader.entryCount).ToList(); - - //FATB - fatbHeader = reader.ReadStruct(); - fatbEntries = reader.ReadMultipleStructs((int)fatbHeader.entryCount); - - //FIMB - fimbHeader = reader.ReadStruct(); - - for (int i = 0; i < fatbHeader.entryCount; i++) - { - reader.BaseStream.Position = fatbEntries[i].offset + header.dataOffset; - var mag = reader.ReadByte(); - var extension = (mag == 0x11) ? ".lz11" : ""; - if (extension == ".lz11") - { - reader.Seek(4); - var magS = reader.ReadString(2); - extension = _knownFiles.ContainsKey(magS) ? _knownFiles[magS] : ""; - } - if (extension == "") - { - reader.BaseStream.Position--; - var magS = reader.ReadString(2); - extension = _knownFiles.ContainsKey(magS) ? _knownFiles[magS] : ".bin"; - - if (extension == ".bin") - { - reader.BaseStream.Position -= 2; - magS = reader.ReadString(4); - extension = _knownFiles.ContainsKey(magS) ? _knownFiles[magS] : ".bin"; - } - } - - var size = (fatbEntries[i].size == 0) ? fatbEntries[i].endOffset - fatbEntries[i].offset : fatbEntries[i].size; - files.Add(new GARC4FileInfo(this) - { - IsLZ11 = (mag == 0x11), - FileName = $"{i:00000000}" + extension, - FileData = new SubStream(reader.BaseStream, fatbEntries[i].offset + header.dataOffset, size) - }); - } - } - } - public void Save(Stream stream) - { - using (FileWriter writer = new FileWriter(stream)) - { - //Save file data - for (int i = 0; i < files.Count; i++) - { - files[i].SaveFileFormat(); - } - - //filesize - //largestFilesize - writer.BaseStream.Position = 0x1c; - - //FATO - writer.WriteStruct(fatoHeader); - writer.Write((ushort)0xFFFF); - writer.Write(fatoOffsets); - - //FATB - writer.WriteStruct(fatbHeader); - uint offset = 0; - for (int i = 0; i < files.Count; i++) - { - fatbEntries[i].offset = offset; - offset += (uint)files[i].FileSize; - offset = (uint)(offset + 3 & ~3); - fatbEntries[i].endOffset = offset; - fatbEntries[i].size = (uint)files[i].FileSize; - - writer.WriteStruct(fatbEntries[i]); - } - - var fimbOffset = writer.BaseStream.Position; - writer.BaseStream.Position += 0xc; - var dataOffset = writer.BaseStream.Position; - - //Writing FileData - uint largestFileSize = 0; - for (int i = 0; i < files.Count; i++) - { - if (files[i].FileSize > largestFileSize) largestFileSize = (uint)files[i].FileSize; - files[i].CompressedStream.CopyTo(writer.BaseStream); - writer.AlignBytes(4, 0xff); - } - - //FIMB - fimbHeader.dataSize = (uint)writer.BaseStream.Length - (uint)dataOffset; - writer.BaseStream.Position = fimbOffset; - writer.WriteStruct(fimbHeader); - - //Header - header.fileSize = (uint)writer.BaseStream.Length; - header.largestFileSize = largestFileSize; - writer.BaseStream.Position = 0; - writer.WriteStruct(header); - } - } - - public class GARC4FileInfo : ArchiveFileInfo - { - public GARC ArchiveFile; - - public uint FileSize => (uint)CompressedStream.Length; - - public bool IsLZ11; - - public GARC4FileInfo(GARC garc) - { - ArchiveFile = garc; - ParentArchiveFile = garc; - } - - public Stream CompressedStream - { - get { return base.FileData; } - } - - public override Stream FileData - { - get { return DecompressBlock(); } - set - { - base.FileData = value; - } - } - - private Stream DecompressBlock() - { - byte[] data = base.FileData.ToArray(); - - if (IsLZ11) - { - LZSS_N lz11 = new LZSS_N(); - return lz11.Decompress(new MemoryStream(data)); - } - - return new MemoryStream(data); - } - - public override Stream CompressData(Stream decompressed) - { - if (IsLZ11) - { - LZSS_N lz11 = new LZSS_N(); - return lz11.Compress(decompressed); - } - - return base.CompressData(decompressed); - } - } - - #region Sections - - [StructLayout(LayoutKind.Sequential, Pack = 1)] - public class Header - { - public Magic magic; - public uint headerSize; - public ushort byteOrder; - public ushort version; - public uint secCount; - public uint dataOffset; - public uint fileSize; - public uint largestFileSize; - } - - //File Allocation Table Offsets - [StructLayout(LayoutKind.Sequential, Pack = 1)] - public class FatoHeader - { - public Magic magic; - public uint headerSize; - public ushort entryCount; - //ushort padding with 0xff - } - - //FATB - [StructLayout(LayoutKind.Sequential, Pack = 1)] - public class FatbHeader - { - public Magic magic; - public uint headerSize; - public uint entryCount; - } - - [StructLayout(LayoutKind.Sequential, Pack = 1)] - public class FatbEntry - { - public uint unk1; - public uint offset; - public uint endOffset; - public uint size; - } - - //FIMB - [StructLayout(LayoutKind.Sequential, Pack = 1)] - public class FimbHeader - { - public Magic magic; - public uint headerSize; - public uint dataSize; - } - - #endregion - } -} +using CtrLibrary; +using Syroot.BinaryData; +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Toolbox.Core; +using Toolbox.Core.IO; +using UIFramework; + +namespace FirstPlugin +{ + public class GARC : MapStudio.UI.FileEditor, IArchiveFile, IFileFormat, IDisposable + { + public bool CanSave { get; set; } = true; + + public string[] Description { get; set; } = new string[] { "GARC" }; + public string[] Extension { get; set; } = new string[] { "*.garc" }; + + public File_Info FileInfo { get; set; } + + public bool CanAddFiles { get; set; } = true; + public bool CanRenameFiles { get; set; } = true; + public bool CanReplaceFiles { get; set; } = true; + public bool CanDeleteFiles { get; set; } = true; + + public IEnumerable Files => files; + + // private + private Header header; + private FatoHeader fatoHeader; + private List fatoOffsets; + + private FatbHeader fatbHeader; + private List fatbEntries; + + private FimbHeader fimbHeader; + + private List files = new List(); + + public bool AddFile(ArchiveFileInfo archiveFileInfo) + { + files.Add(new GARC4FileInfo(this) + { + FileData = archiveFileInfo.FileData, + FileName = archiveFileInfo.FileName, + }); + return true; + } + + public void ClearFiles() => files.Clear(); + + public bool DeleteFile(ArchiveFileInfo archiveFileInfo) + { + return files.Remove((GARC4FileInfo)archiveFileInfo); + } + + public bool Identify(File_Info fileInfo, Stream stream) + { + using (FileReader reader = new FileReader(stream, true)) + return reader.CheckSignature(4, "CRAG"); + } + + private static Dictionary _knownFiles = new Dictionary + { + ["BCH"] = ".bch", + ["PC"] = ".pc", + ["PF"] = ".pf", + ["PF"] = ".pf", + ["PB"] = ".pb", + ["PT"] = ".pt", + ["PK"] = ".pk", + ["CGFX"] = ".cgfx", + }; + + /// + /// Prepares the dock layouts to be used for the file format. + /// + public override List PrepareDocks() + { + List windows = new List(); + windows.Add(Workspace.Outliner); + windows.Add(Workspace.PropertyWindow); + return windows; + } + + public void Dispose() + { + FileInfo.Stream?.Dispose(); + } + + //Parse/save code from + //https://github.com/IcySon55/Kuriimu/blob/3f05ffc993e0908929e92373c455acb633b8f28d/src/archive/archive_nintendo/Garc4Manager.cs + + public void Load(Stream stream) + { + files.Clear(); + + FileInfo.Stream = stream; + FileInfo.KeepOpen = true; + using (FileReader reader = new FileReader(stream, true)) + { + //header + ByteOrder byteOrder = DetectByteOrder(reader); + reader.ByteOrder = byteOrder; + reader.BaseStream.Seek(0, SeekOrigin.Begin); + header = reader.ReadStruct
(); + //FATO + reader.BaseStream.Position = header.headerSize; + fatoHeader = reader.ReadStruct(); + reader.BaseStream.Position = reader.BaseStream.Position + 3 & ~3; + fatoOffsets = reader.ReadUInt32s(fatoHeader.entryCount).ToList(); + + //FATB + fatbHeader = reader.ReadStruct(); + // Some GARC variants report a larger FATB entryCount than FATO; in practice + // FATO's entryCount is the usable file count. + uint usableCount = fatbHeader.entryCount; + if (fatoHeader.entryCount > 0 && fatbHeader.entryCount > fatoHeader.entryCount) + usableCount = fatoHeader.entryCount; + int entryCount = ValidateEntryCount(reader, usableCount); + fatbEntries = reader.ReadMultipleStructs(entryCount); + + //FIMB + fimbHeader = reader.ReadStruct(); + + for (int i = 0; i < entryCount; i++) + { + var size = (fatbEntries[i].size == 0) ? fatbEntries[i].endOffset - fatbEntries[i].offset : fatbEntries[i].size; + long entryPos = (long)fatbEntries[i].offset + header.dataOffset; + if (size == 0) + continue; + if (entryPos < 0 || entryPos >= reader.BaseStream.Length) + continue; + if (entryPos + size > reader.BaseStream.Length) + continue; + + reader.BaseStream.Position = entryPos; + byte[] headerBytes = reader.ReadBytes((int)Math.Min(8, size)); + if (headerBytes.Length == 0) + continue; + + var mag = headerBytes[0]; + var extension = ""; + bool isLz11 = false; + if (mag == 0x11) + { + reader.BaseStream.Position = entryPos; + isLz11 = IsLikelyLz11(reader, fatbEntries[i]); + extension = isLz11 ? ".lz11" : ""; + } + if (extension == ".lz11") + { + reader.Seek(4); + var magS = reader.ReadString(2); + extension = _knownFiles.ContainsKey(magS) ? _knownFiles[magS] : ""; + } + if (extension == "") + { + reader.BaseStream.Position = entryPos; + var magS = reader.ReadString(2); + extension = _knownFiles.ContainsKey(magS) ? _knownFiles[magS] : ".bin"; + + if (extension == ".bin") + { + reader.BaseStream.Position = entryPos; + magS = reader.ReadString(4); + extension = _knownFiles.ContainsKey(magS) ? _knownFiles[magS] : ".bin"; + } + } + + string twoCcMagic = null; + if (headerBytes.Length >= 2) + { + byte a = headerBytes[0]; + byte b = headerBytes[1]; + if (a >= 0x41 && a <= 0x5A && b >= 0x41 && b <= 0x5A) + twoCcMagic = $"{(char)a}{(char)b}"; + } + + string name = $"{i:00000000}" + extension; + if (extension == ".bin" && !string.IsNullOrEmpty(twoCcMagic)) + name += "." + twoCcMagic; + files.Add(new GARC4FileInfo(this) + { + IsLZ11 = isLz11, + FileName = name, + FileData = new SubStream(reader.BaseStream, entryPos, size) + }); + } + } + } + public void Save(Stream stream) + { + using (FileWriter writer = new FileWriter(stream)) + { + //Save file data + for (int i = 0; i < files.Count; i++) + { + files[i].SaveFileFormat(); + } + + //filesize + //largestFilesize + writer.BaseStream.Position = 0x1c; + + //FATO + writer.WriteStruct(fatoHeader); + writer.Write((ushort)0xFFFF); + writer.Write(fatoOffsets); + + //FATB + writer.WriteStruct(fatbHeader); + uint offset = 0; + for (int i = 0; i < files.Count; i++) + { + fatbEntries[i].offset = offset; + offset += (uint)files[i].FileSize; + offset = (uint)(offset + 3 & ~3); + fatbEntries[i].endOffset = offset; + fatbEntries[i].size = (uint)files[i].FileSize; + + writer.WriteStruct(fatbEntries[i]); + } + + var fimbOffset = writer.BaseStream.Position; + writer.BaseStream.Position += 0xc; + var dataOffset = writer.BaseStream.Position; + + //Writing FileData + uint largestFileSize = 0; + for (int i = 0; i < files.Count; i++) + { + if (files[i].FileSize > largestFileSize) largestFileSize = (uint)files[i].FileSize; + files[i].CompressedStream.CopyTo(writer.BaseStream); + writer.AlignBytes(4, 0xff); + } + + //FIMB + fimbHeader.dataSize = (uint)writer.BaseStream.Length - (uint)dataOffset; + writer.BaseStream.Position = fimbOffset; + writer.WriteStruct(fimbHeader); + + //Header + header.fileSize = (uint)writer.BaseStream.Length; + header.largestFileSize = largestFileSize; + writer.BaseStream.Position = 0; + writer.WriteStruct(header); + } + } + + public class GARC4FileInfo : ArchiveFileInfo + { + public GARC ArchiveFile; + + public uint FileSize => (uint)CompressedStream.Length; + + public bool IsLZ11; + + public GARC4FileInfo(GARC garc) + { + ArchiveFile = garc; + ParentArchiveFile = garc; + } + + public Stream CompressedStream + { + get { return base.FileData; } + } + + public override Stream FileData + { + get { return DecompressBlock(); } + set + { + base.FileData = value; + decompressedCache = null; + } + } + + private byte[] decompressedCache; + + private Stream DecompressBlock() + { + if (decompressedCache != null) + return new MemoryStream(decompressedCache, false); + + byte[] data = base.FileData.ToArray(); + + if (IsLZ11) + { + LZSS_N lz11 = new LZSS_N(); + using var decompressed = lz11.Decompress(new MemoryStream(data)); + decompressedCache = decompressed.ToArray(); + return new MemoryStream(decompressedCache, false); + } + + decompressedCache = data; + return new MemoryStream(decompressedCache, false); + } + + public override Stream CompressData(Stream decompressed) + { + if (IsLZ11) + { + LZSS_N lz11 = new LZSS_N(); + return lz11.Compress(decompressed); + } + + return base.CompressData(decompressed); + } + } + + #region Sections + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public class Header + { + public Magic magic; + public uint headerSize; + public ushort byteOrder; + public ushort version; + public uint secCount; + public uint dataOffset; + public uint fileSize; + public uint largestFileSize; + } + + //File Allocation Table Offsets + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public class FatoHeader + { + public Magic magic; + public uint headerSize; + public ushort entryCount; + //ushort padding with 0xff + } + + //FATB + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public class FatbHeader + { + public Magic magic; + public uint headerSize; + public uint entryCount; + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public class FatbEntry + { + public uint unk1; + public uint offset; + public uint endOffset; + public uint size; + } + + //FIMB + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public class FimbHeader + { + public Magic magic; + public uint headerSize; + public uint dataSize; + } + + #endregion + + private static bool IsLikelyLz11(FileReader reader, FatbEntry entry) + { + long start = reader.BaseStream.Position; + try + { + uint size = entry.size == 0 ? entry.endOffset - entry.offset : entry.size; + if (size < 8) + return false; + if (reader.ReadByte() != 0x11) + return false; + + int decodedLen = reader.ReadByte() | (reader.ReadByte() << 8) | (reader.ReadByte() << 16); + if (decodedLen <= 0) + return false; + if (decodedLen > 0x2000000) // sanity cap + return false; + + long remaining = (long)size - 4; + if (remaining <= 0) + return false; + if (decodedLen < remaining / 2) + return false; + + return true; + } + finally + { + reader.BaseStream.Position = start; + } + } + + private static ByteOrder DetectByteOrder(FileReader reader) + { + long start = reader.BaseStream.Position; + try + { + reader.BaseStream.Seek(0, SeekOrigin.Begin); + byte[] headerBytes = reader.ReadBytes(0x1C); + if (headerBytes.Length < 0x0A) + throw new InvalidDataException("GARC header too small."); + + ushort leOrder = (ushort)(headerBytes[8] | (headerBytes[9] << 8)); + ushort beOrder = (ushort)((headerBytes[8] << 8) | headerBytes[9]); + + if (leOrder == 0xFEFF || leOrder == 0xFFFE) + return leOrder == 0xFEFF ? ByteOrder.LittleEndian : ByteOrder.BigEndian; + if (beOrder == 0xFEFF || beOrder == 0xFFFE) + return beOrder == 0xFEFF ? ByteOrder.BigEndian : ByteOrder.LittleEndian; + + uint leHeaderSize = BitConverter.ToUInt32(headerBytes, 4); + uint beHeaderSize = (uint)((headerBytes[4] << 24) | (headerBytes[5] << 16) | (headerBytes[6] << 8) | headerBytes[7]); + + if (leHeaderSize == 0x1C) + return ByteOrder.LittleEndian; + if (beHeaderSize == 0x1C) + return ByteOrder.BigEndian; + + throw new InvalidDataException("Unable to detect GARC byte order."); + } + finally + { + reader.BaseStream.Seek(start, SeekOrigin.Begin); + } + } + + private static int ValidateEntryCount(FileReader reader, uint entryCount) + { + if (entryCount > int.MaxValue) + throw new InvalidDataException($"GARC entry count too large: {entryCount}."); + + int count = (int)entryCount; + int entrySize = Marshal.SizeOf(); + long remaining = reader.BaseStream.Length - reader.BaseStream.Position; + long maxCount = remaining / entrySize; + + if (count < 0 || count > maxCount) + throw new InvalidDataException($"GARC entry count out of range: {count} (max {maxCount})."); + + return count; + } + } +} diff --git a/Files/TwoCC/TwoCC.cs b/Files/TwoCC/TwoCC.cs index 6d0f05f..a276e57 100644 --- a/Files/TwoCC/TwoCC.cs +++ b/Files/TwoCC/TwoCC.cs @@ -1,158 +1,326 @@ -using CtrLibrary; -using Syroot.BinaryData; -using System; -using System.Collections.Generic; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using Toolbox.Core; -using Toolbox.Core.IO; -using UIFramework; - -namespace FirstPlugin -{ - public class TwoCC : MapStudio.UI.FileEditor, IArchiveFile, IFileFormat - { - public bool CanSave { get; set; } = true; - - public string[] Description { get; set; } = new string[] { ".pc" }; - public string[] Extension { get; set; } = new string[] { "*.pc" }; - - public File_Info FileInfo { get; set; } - - public bool CanAddFiles { get; set; } = true; - public bool CanRenameFiles { get; set; } = true; - public bool CanReplaceFiles { get; set; } = true; - public bool CanDeleteFiles { get; set; } = true; - - public IEnumerable Files => files; - - private List files = new List(); - - public bool AddFile(ArchiveFileInfo archiveFileInfo) - { - files.Add(new TFileInfo(this) - { - FileData = archiveFileInfo.FileData, - FileName = archiveFileInfo.FileName, - }); - return true; - } - - public void ClearFiles() => files.Clear(); - - public bool DeleteFile(ArchiveFileInfo archiveFileInfo) - { - return files.Remove((TFileInfo)archiveFileInfo); - } - - private string[] MAGIC = new string[] { "AD", "BB", "BM", "BS", "CM", "CP", "GR", "NA", "MM", "PB", "PC", "PK", "PT", "PF" }; - - public bool Identify(File_Info fileInfo, Stream stream) - { - using (FileReader reader = new FileReader(stream, true)) - for (int i = 0; i < MAGIC.Length; i++) - if (reader.CheckSignature(2, MAGIC[i])) - return true; - - return false; - } - - /// - /// Prepares the dock layouts to be used for the file format. - /// - public override List PrepareDocks() - { - List windows = new List(); - windows.Add(Workspace.Outliner); - windows.Add(Workspace.PropertyWindow); - return windows; - } - - private string Identifier; - - public void Load(Stream stream) - { - using (FileReader reader = new FileReader(stream)) - { - Identifier = reader.ReadMagic(0, 2); - ushort numSections = reader.ReadUInt16(); - for (int i = 0; i < numSections; i++) - { - reader.Seek(4 + (i * 4), SeekOrigin.Begin); - uint startOffset = reader.ReadUInt32(); - uint endOffset = reader.ReadUInt32(); - - reader.Seek(startOffset, SeekOrigin.Begin); - byte[] data = reader.ReadBytes((int)(endOffset - startOffset)); - - string ext = SARC_Parser.GuessFileExtension(data); - files.Add(new TFileInfo(this) - { - FileName = $"File{i}{ext}", - IsLZ11 = data.Length > 0 ? data[0] == 0x11 : false, - FileData = new MemoryStream(data), - }); - } - } - } - - public void Save(Stream stream) - { - using (FileWriter writer = new FileWriter(stream)) - { - foreach (var file in files) - file.SaveFileFormat(); - - writer.WriteSignature(Identifier); - writer.Write((ushort)files.Count); - writer.Write(8 * files.Count); //reserved for file start/end offsets - - writer.Align(128); - for (int i = 0; i < files.Count; i++) - { - long startOffset = writer.Position; - - using (writer.TemporarySeek(4 + (i * 4), SeekOrigin.Begin)) - { - //Write start and end offsets - writer.Write((uint)startOffset); - //Last end offset - if (i == files.Count - 1) - writer.Write((uint)(startOffset + files[i].CompressedStream.Length)); - } - files[i].CompressedStream.CopyTo(writer.BaseStream); - } - } - } - - public class TFileInfo : ArchiveFileInfo - { - public TwoCC ArchiveFile; - - public bool IsLZ11; - - public Stream CompressedStream => base.FileData; - - public TFileInfo(TwoCC arc) - { - ArchiveFile = arc; - } - - public override Stream FileData - { - get { return DecompressBlock(); } - set - { - base.FileData = value; - } - } - - private Stream DecompressBlock() - { - byte[] data = base.FileData.ToArray(); - return new MemoryStream(data); - } - } - } -} +using CtrLibrary; +using Syroot.BinaryData; +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Toolbox.Core; +using Toolbox.Core.IO; +using UIFramework; + +namespace FirstPlugin +{ + public class TwoCC : MapStudio.UI.FileEditor, IArchiveFile, IFileFormat + { + public bool CanSave { get; set; } = true; + + public string[] Description { get; set; } = new string[] { ".pc" }; + public string[] Extension { get; set; } = new string[] { "*.pc" }; + + // Prefer opening 2CC containers (AC/PC/CP/etc) as archives before other + // format detectors try to recursively scan and auto-open payloads. + public int Priority => -1; + + public File_Info FileInfo { get; set; } + + public bool CanAddFiles { get; set; } = true; + public bool CanRenameFiles { get; set; } = true; + public bool CanReplaceFiles { get; set; } = true; + public bool CanDeleteFiles { get; set; } = true; + + public IEnumerable Files => files; + + private List files = new List(); + + public bool AddFile(ArchiveFileInfo archiveFileInfo) + { + files.Add(new TFileInfo(this) + { + FileData = archiveFileInfo.FileData, + FileName = archiveFileInfo.FileName, + }); + return true; + } + + public void ClearFiles() => files.Clear(); + + public bool DeleteFile(ArchiveFileInfo archiveFileInfo) + { + return files.Remove((TFileInfo)archiveFileInfo); + } + + private static readonly string[] MAGIC = new string[] + { + "AC", "AD", "AE", "AS", + "BB", "BG", "BM", "BS", + "CM", "CP", + "EA", "ED", + "GR", + "MM", + "NA", + "PB", "PC", "PF", "PK", "PT", + "TR", + "ZI", "ZS", + }; + + public bool Identify(File_Info fileInfo, Stream stream) + { + using (FileReader reader = new FileReader(stream, true)) + for (int i = 0; i < MAGIC.Length; i++) + if (reader.CheckSignature(2, MAGIC[i])) + { + if (MAGIC[i] == "PC" && fileInfo.ParentArchive == null && ContainsGfl2Payload(reader, "PC", false)) + return false; + if (MAGIC[i] == "CP" && ContainsGfl2Payload(reader, "CP", true)) + return false; + return true; + } + + return false; + } + + /// + /// Prepares the dock layouts to be used for the file format. + /// + public override List PrepareDocks() + { + List windows = new List(); + windows.Add(Workspace.Outliner); + windows.Add(Workspace.PropertyWindow); + return windows; + } + + private string Identifier; + + public void Load(Stream stream) + { + using (FileReader reader = new FileReader(stream)) + { + Identifier = reader.ReadMagic(0, 2); + ushort numSections = reader.ReadUInt16(); + for (int i = 0; i < numSections; i++) + { + reader.Seek(4 + (i * 4), SeekOrigin.Begin); + uint startOffset = reader.ReadUInt32(); + uint endOffset = reader.ReadUInt32(); + + reader.Seek(startOffset, SeekOrigin.Begin); + byte[] data = reader.ReadBytes((int)(endOffset - startOffset)); + + string ext = SARC_Parser.GuessFileExtension(data); + files.Add(new TFileInfo(this) + { + FileName = $"File{i}{ext}", + IsLZ11 = data.Length > 0 ? data[0] == 0x11 : false, + FileData = new MemoryStream(data), + }); + } + } + } + + private static bool ContainsGfl2Payload(FileReader reader, string expectedMagic, bool deepScan) + { + long startPos = reader.BaseStream.Position; + try + { + byte[] data = reader.BaseStream.ToArray(); + if (deepScan) + return ContainsGfl2PayloadDeep(data, expectedMagic, 0); + return ContainsGfl2PayloadShallow(data, expectedMagic); + } + catch + { + return false; + } + finally + { + reader.BaseStream.Seek(startPos, SeekOrigin.Begin); + } + } + + private static bool ContainsGfl2PayloadShallow(byte[] data, string expectedMagic) + { + if (!LooksLikeContainer(data, expectedMagic)) + return false; + + ushort count = (ushort)(data[2] | (data[3] << 8)); + int tableSize = 4 + (count + 1) * 4; + if (tableSize > data.Length) + return false; + + for (int i = 0; i < count; i++) + { + int start = BitConverter.ToInt32(data, 4 + i * 4); + int end = BitConverter.ToInt32(data, 4 + (i + 1) * 4); + if (end <= start || end > data.Length || start < 0) + continue; + + if (start + 4 > data.Length) + continue; + + uint entryMagic = BitConverter.ToUInt32(data, start); + if (IsGfl2Magic(entryMagic)) + return true; + } + + return false; + } + + private static bool ContainsGfl2PayloadDeep(byte[] data, string expectedMagic, int depth) + { + if (depth > 8 || data == null || data.Length < 4) + return false; + + if (LooksLikeLz11(data)) + { + data = TryDecompressLz11(data); + if (data == null || data.Length < 4) + return false; + } + + uint magic = BitConverter.ToUInt32(data, 0); + if (IsGfl2Magic(magic)) + return true; + + if (!LooksLikeContainer(data, expectedMagic) && !LooksLikeContainer(data, null)) + return false; + + ushort count = (ushort)(data[2] | (data[3] << 8)); + int tableSize = 4 + (count + 1) * 4; + if (tableSize > data.Length) + return false; + + int prev = 0; + for (int i = 0; i < count + 1; i++) + { + int off = BitConverter.ToInt32(data, 4 + i * 4); + if (off < prev || off > data.Length) + return false; + prev = off; + } + + for (int i = 0; i < count; i++) + { + int start = BitConverter.ToInt32(data, 4 + i * 4); + int end = BitConverter.ToInt32(data, 4 + (i + 1) * 4); + if (end <= start || end > data.Length || start < 0) + continue; + + int size = end - start; + if (size < 4) + continue; + + byte[] slice = new byte[size]; + Buffer.BlockCopy(data, start, slice, 0, size); + if (ContainsGfl2PayloadDeep(slice, expectedMagic, depth + 1)) + return true; + } + + return false; + } + + private static bool LooksLikeContainer(byte[] data, string expectedMagic) + { + if (data == null || data.Length < 4) + return false; + + string magic = $"{(char)data[0]}{(char)data[1]}"; + if (!string.IsNullOrEmpty(expectedMagic)) + return magic == expectedMagic; + + for (int i = 0; i < MAGIC.Length; i++) + { + if (MAGIC[i] == magic) + return true; + } + + return false; + } + + private static bool LooksLikeLz11(byte[] data) + { + return data.Length >= 4 && data[0] == 0x11; + } + + private static byte[] TryDecompressLz11(byte[] data) + { + try + { + var lz11 = new LZSS_N(); + using var decompressed = lz11.Decompress(new MemoryStream(data)); + return decompressed.ToArray(); + } + catch + { + return null; + } + } + + private static bool IsGfl2Magic(uint magic) + { + return magic == 0x15122117 + || magic == 0x00010000 + || magic == 0x15041213 + || magic == 0x00060000; + } + + public void Save(Stream stream) + { + using (FileWriter writer = new FileWriter(stream)) + { + foreach (var file in files) + file.SaveFileFormat(); + + writer.WriteSignature(Identifier); + writer.Write((ushort)files.Count); + writer.Write(8 * files.Count); //reserved for file start/end offsets + + writer.Align(128); + for (int i = 0; i < files.Count; i++) + { + long startOffset = writer.Position; + + using (writer.TemporarySeek(4 + (i * 4), SeekOrigin.Begin)) + { + //Write start and end offsets + writer.Write((uint)startOffset); + //Last end offset + if (i == files.Count - 1) + writer.Write((uint)(startOffset + files[i].CompressedStream.Length)); + } + files[i].CompressedStream.CopyTo(writer.BaseStream); + } + } + } + + public class TFileInfo : ArchiveFileInfo + { + public TwoCC ArchiveFile; + + public bool IsLZ11; + + public Stream CompressedStream => base.FileData; + + public TFileInfo(TwoCC arc) + { + ArchiveFile = arc; + } + + public override Stream FileData + { + get { return DecompressBlock(); } + set + { + base.FileData = value; + } + } + + private Stream DecompressBlock() + { + byte[] data = base.FileData.ToArray(); + return new MemoryStream(data); + } + } + } +}