From 5cd5a49c99311445c6eb9d1b699ca70fdeb46a93 Mon Sep 17 00:00:00 2001 From: MisakaCirno Date: Fri, 13 Mar 2026 00:36:27 +0800 Subject: [PATCH 1/4] Fix index offsets and base path AI-generated code by Codex. --- SaintCoinach/Graphics/Territory.cs | 44 ++++++++++++++++++++++++++++-- SaintCoinach/IO/IIndexFile.cs | 2 +- SaintCoinach/IO/Index2File.cs | 12 ++++---- SaintCoinach/IO/IndexFile.cs | 10 +++---- SaintCoinach/IO/Pack.cs | 23 ++++++++++++---- 5 files changed, 73 insertions(+), 18 deletions(-) diff --git a/SaintCoinach/Graphics/Territory.cs b/SaintCoinach/Graphics/Territory.cs index b8069066..4d8a9b38 100644 --- a/SaintCoinach/Graphics/Territory.cs +++ b/SaintCoinach/Graphics/Territory.cs @@ -21,14 +21,54 @@ public Territory(Xiv.TerritoryType type) : this(type.Sheet.Collection.PackCollec public Territory(IO.PackCollection packs, string name, string levelPath) { this.Packs = packs; this.Name = name; - var i = levelPath.IndexOf("/level/"); - this.BasePath = "bg/" + levelPath.Substring(0, i + 1); + this.BasePath = ResolveBasePath(levelPath); Build(); } #endregion #region Build + private string ResolveBasePath(string levelPath) { + var normalized = (levelPath ?? string.Empty).Replace('\\', '/').Trim().Trim('/'); + if (normalized.StartsWith("bg/", StringComparison.OrdinalIgnoreCase)) + normalized = normalized.Substring(3); + + var candidates = new List(); + + var levelSegmentIndex = normalized.IndexOf("/level/", StringComparison.OrdinalIgnoreCase); + if (levelSegmentIndex >= 0) + candidates.Add("bg/" + normalized.Substring(0, levelSegmentIndex + 1)); + + if (!string.IsNullOrEmpty(normalized)) + candidates.Add("bg/" + normalized.TrimEnd('/') + "/"); + + if (normalized.EndsWith("/level", StringComparison.OrdinalIgnoreCase)) { + var withoutLevel = normalized.Substring(0, normalized.Length - "/level".Length).TrimEnd('/'); + if (!string.IsNullOrEmpty(withoutLevel)) + candidates.Add("bg/" + withoutLevel + "/"); + } + + candidates.Add("bg/"); + + foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) { + if (LooksLikeValidBasePath(candidate)) { + return candidate; + } + } + + var fallback = candidates[0]; + System.Diagnostics.Debug.WriteLine( + string.Format("Could not verify territory base path candidates. Bg='{0}', Fallback='{1}'", levelPath, fallback)); + return fallback; + } + + private bool LooksLikeValidBasePath(string basePath) { + return Packs.FileExists(basePath + "bgplate/terrain.tera") + || Packs.FileExists(basePath + "level/bg.lgb") + || Packs.FileExists(basePath + "level/planmap.lgb") + || Packs.FileExists(basePath + "level/planevent.lgb"); + } + private void Build() { var terrainPath = BasePath + "bgplate/terrain.tera"; if (Packs.TryGetFile(terrainPath, out var terrainFile)) diff --git a/SaintCoinach/IO/IIndexFile.cs b/SaintCoinach/IO/IIndexFile.cs index a8cfc8a8..95872732 100644 --- a/SaintCoinach/IO/IIndexFile.cs +++ b/SaintCoinach/IO/IIndexFile.cs @@ -8,7 +8,7 @@ namespace SaintCoinach.IO { public interface IIndexFile : IEquatable { PackIdentifier PackId { get; } uint FileKey { get; } - uint Offset { get; } + long Offset { get; } byte DatFile { get; } } } diff --git a/SaintCoinach/IO/Index2File.cs b/SaintCoinach/IO/Index2File.cs index e417291a..3149cd8e 100644 --- a/SaintCoinach/IO/Index2File.cs +++ b/SaintCoinach/IO/Index2File.cs @@ -11,7 +11,7 @@ public class Index2File : IIndexFile { public PackIdentifier PackId { get; private set; } public uint FileKey { get; private set; } - public uint Offset { get; private set; } + public long Offset { get; private set; } /// /// In which .dat* file the data is located. @@ -26,9 +26,11 @@ public Index2File(PackIdentifier packId, BinaryReader reader) { PackId = packId; FileKey = reader.ReadUInt32(); - var baseOffset = reader.ReadInt32(); - DatFile = (byte)((baseOffset & 0x7) >> 1); - Offset = (uint)((baseOffset & 0xFFFFFFF8) << 3); + var baseOffset = reader.ReadUInt32(); + // Index2 stores the same low-nibble flags layout as Index: + // bit0 = flag, bits1-3 = dat file id, upper bits = offset/8. + DatFile = (byte)((baseOffset & 0x000F) >> 1); + Offset = ((long)(baseOffset & 0xFFFFFFF0u)) * 0x08L; } #endregion @@ -36,7 +38,7 @@ public Index2File(PackIdentifier packId, BinaryReader reader) { #region IEquatable Members public override int GetHashCode() { - return (int)(((DatFile << 24) | PackId.GetHashCode()) ^ Offset); + return (int)(((DatFile << 24) | PackId.GetHashCode()) ^ Offset.GetHashCode()); } public override bool Equals(object obj) { if (obj is IIndexFile) diff --git a/SaintCoinach/IO/IndexFile.cs b/SaintCoinach/IO/IndexFile.cs index 2ed7a003..d62cd519 100644 --- a/SaintCoinach/IO/IndexFile.cs +++ b/SaintCoinach/IO/IndexFile.cs @@ -10,7 +10,7 @@ public class IndexFile : IIndexFile { public PackIdentifier PackId { get; private set; } public uint FileKey { get; private set; } public uint DirectoryKey { get; private set; } - public uint Offset { get; private set; } + public long Offset { get; private set; } /// /// In which .dat* file the data is located. @@ -26,9 +26,9 @@ public IndexFile(PackIdentifier packId, BinaryReader reader) { FileKey = reader.ReadUInt32(); DirectoryKey = reader.ReadUInt32(); - var baseOffset = reader.ReadInt32(); - DatFile = (byte)((baseOffset & 0x000F) / 2); - Offset = (uint)(baseOffset - (baseOffset & 0x000F)) * 0x08; + var baseOffset = reader.ReadUInt32(); + DatFile = (byte)((baseOffset & 0x000F) >> 1); + Offset = ((long)(baseOffset & 0xFFFFFFF0u)) * 0x08L; reader.ReadInt32(); // Zero } @@ -38,7 +38,7 @@ public IndexFile(PackIdentifier packId, BinaryReader reader) { #region IEquatable Members public override int GetHashCode() { - return (int)(((DatFile << 24) | PackId.GetHashCode()) ^ Offset); + return (int)(((DatFile << 24) | PackId.GetHashCode()) ^ Offset.GetHashCode()); } public override bool Equals(object obj) { if (obj is IIndexFile) diff --git a/SaintCoinach/IO/Pack.cs b/SaintCoinach/IO/Pack.cs index 23a08668..dc8d30b9 100644 --- a/SaintCoinach/IO/Pack.cs +++ b/SaintCoinach/IO/Pack.cs @@ -23,6 +23,7 @@ public partial class Pack : IEnumerable { private readonly Dictionary, WeakReference> _DataStreams = new Dictionary, WeakReference>(); + private readonly IPackSource _AlternateSource; private bool _KeepInMemory = false; private Dictionary _Buffers = new Dictionary(); @@ -119,9 +120,11 @@ public Pack(PackCollection collection, DirectoryInfo dataDirectory, PackIdentifi var indexPath = Path.Combine(DataDirectory.FullName, id.Expansion, string.Format(IndexFileFormat, Id.TypeKey, Id.ExpansionKey, Id.Number)); var index2Path = Path.Combine(DataDirectory.FullName, id.Expansion, string.Format(Index2FileFormat, Id.TypeKey, Id.ExpansionKey, Id.Number)); - if (IOFile.Exists(indexPath)) + if (IOFile.Exists(indexPath)) { Source = new IndexSource(this, new Index(id, indexPath)); - else if (IOFile.Exists(index2Path)) + if (IOFile.Exists(index2Path)) + _AlternateSource = new Index2Source(this, new Index2(id, index2Path)); + } else if (IOFile.Exists(index2Path)) Source = new Index2Source(this, new Index2(id, index2Path)); else throw new FileNotFoundException(); @@ -132,13 +135,23 @@ public Pack(PackCollection collection, DirectoryInfo dataDirectory, PackIdentifi #region Fields public bool FileExists(string path) { - return Source.FileExists(path); + return Source.FileExists(path) || (_AlternateSource != null && _AlternateSource.FileExists(path)); } public bool TryGetFile(string path, out File value) { - return Source.TryGetFile(path, out value); + if (_AlternateSource != null && _AlternateSource.TryGetFile(path, out value)) { + return true; + } + + if (Source.TryGetFile(path, out value)) + return true; + + value = null; + return false; } public File GetFile(string path) { - return Source.GetFile(path); + if (TryGetFile(path, out var value)) + return value; + throw new FileNotFoundException("Pack file not found '" + path + "'"); } #endregion From 3cff64a9edf3f673ced8e6772fe4286d9c8c3a37 Mon Sep 17 00:00:00 2001 From: MisakaCirno Date: Fri, 13 Mar 2026 00:36:38 +0800 Subject: [PATCH 2/4] Harden SqPack file reading AI-generated code by Codex. --- SaintCoinach/ByteArrayExtensions.cs | 18 +++++-- SaintCoinach/IO/Directory.cs | 23 +++++++- SaintCoinach/IO/File.cs | 83 +++++++++++++++++++++++++++-- SaintCoinach/IO/FileCommonHeader.cs | 15 +++++- SaintCoinach/IO/Index2Source.cs | 22 +++++++- 5 files changed, 149 insertions(+), 12 deletions(-) diff --git a/SaintCoinach/ByteArrayExtensions.cs b/SaintCoinach/ByteArrayExtensions.cs index f5342677..f19100d3 100644 --- a/SaintCoinach/ByteArrayExtensions.cs +++ b/SaintCoinach/ByteArrayExtensions.cs @@ -14,6 +14,8 @@ public static T ToStructure(this byte[] bytes, int offset) where T : struct { public static T ToStructure(this byte[] bytes, ref int offset) where T : struct { var t = typeof(T); var size = Marshal.SizeOf(t); + if (offset < 0 || size < 0 || offset > bytes.Length - size) + throw new System.IO.InvalidDataException($"Structure read out of range. Type={t.Name}, Offset={offset}, Size={size}, BufferLength={bytes.Length}."); IntPtr ptr = Marshal.AllocHGlobal(size); try { Marshal.Copy(bytes, offset, ptr, size); @@ -38,13 +40,21 @@ public static string ReadString(this byte[] buffer, int offset) { return ReadString(buffer, ref offset); } public static string ReadString(this byte[] buffer, ref int offset) { - var strEnd = offset - 1; - while (buffer[++strEnd] != 0) { } + if (offset < 0 || offset >= buffer.Length) + return string.Empty; + + var strEnd = Array.IndexOf(buffer, (byte)0, offset); + if (strEnd < 0) + strEnd = buffer.Length; + var size = strEnd - offset; + if (size <= 0) { + offset = strEnd < buffer.Length ? strEnd + 1 : buffer.Length; + return string.Empty; + } var value = Encoding.ASCII.GetString(buffer, offset, size); - - offset = strEnd + 1; + offset = strEnd < buffer.Length ? strEnd + 1 : buffer.Length; return value; } } diff --git a/SaintCoinach/IO/Directory.cs b/SaintCoinach/IO/Directory.cs index e5e431cf..843c7a09 100644 --- a/SaintCoinach/IO/Directory.cs +++ b/SaintCoinach/IO/Directory.cs @@ -105,7 +105,28 @@ public bool TryGetFile(uint key, out File file) { return true; if (Index.Files.TryGetValue(key, out var index)) { - var theFile = FileFactory.Get(this.Pack, index); + File theFile; + try { + theFile = FileFactory.Get(this.Pack, index); + } catch (System.IO.InvalidDataException ex) { + System.Diagnostics.Debug.WriteLine( + string.Format("Failed to parse file in TryGetFile. Pack={0}, Dat={1}, Offset=0x{2:X}. {3}", + this.Pack.Id, index.DatFile, index.Offset, ex.Message)); + file = null; + return false; + } catch (System.IO.EndOfStreamException ex) { + System.Diagnostics.Debug.WriteLine( + string.Format("Unexpected end of stream in TryGetFile. Pack={0}, Dat={1}, Offset=0x{2:X}. {3}", + this.Pack.Id, index.DatFile, index.Offset, ex.Message)); + file = null; + return false; + } catch (System.NotSupportedException ex) { + System.Diagnostics.Debug.WriteLine( + string.Format("Unsupported file format in TryGetFile. Pack={0}, Dat={1}, Offset=0x{2:X}. {3}", + this.Pack.Id, index.DatFile, index.Offset, ex.Message)); + file = null; + return false; + } _Files.AddOrUpdate(key, k => new WeakReference(theFile), (k, r) => { diff --git a/SaintCoinach/IO/File.cs b/SaintCoinach/IO/File.cs index 694ad1ad..b5fd3288 100644 --- a/SaintCoinach/IO/File.cs +++ b/SaintCoinach/IO/File.cs @@ -71,6 +71,7 @@ protected static void ReadBlock(Stream inStream, Stream outStream) { const int BlockPadding = 0x80; const int CompressionThreshold = 0x7D00; + const int MaxAllowedBlockBytes = 16 * 1024 * 1024; /* * Block: @@ -96,9 +97,17 @@ protected static void ReadBlock(Stream inStream, Stream outStream) { if (magicCheck != Magic) throw new NotSupportedException("Magic number not present (-> don't know how to continue)."); + if (sourceSize <= 0 || rawSize <= 0) + throw new InvalidDataException($"Invalid block size. Source={sourceSize}, Raw={rawSize}."); + + if (sourceSize > MaxAllowedBlockBytes || rawSize > MaxAllowedBlockBytes) + throw new InvalidDataException($"Block too large. Source={sourceSize}, Raw={rawSize}, Limit={MaxAllowedBlockBytes}."); + var isCompressed = sourceSize < CompressionThreshold; var blockSize = isCompressed ? sourceSize : rawSize; + if (blockSize <= 0 || blockSize > MaxAllowedBlockBytes) + throw new InvalidDataException($"Invalid block payload size {blockSize}. Limit={MaxAllowedBlockBytes}."); // An uncompressed block in an ScdOggFile was corrupted due to this // extra padding injecting extra 0s into the output stream. I'm @@ -114,7 +123,7 @@ protected static void ReadBlock(Stream inStream, Stream outStream) { if (isCompressed) { var currentPosition = outStream.Position; - Inflate(buffer, outStream); + Inflate(buffer, outStream, rawSize); var dLen = outStream.Position - currentPosition; if (dLen != rawSize) throw new InvalidDataException("Inflated block does not match indicated size."); @@ -123,9 +132,75 @@ protected static void ReadBlock(Stream inStream, Stream outStream) { } } - private static void Inflate(byte[] buffer, Stream outStream) { - var unc = Ionic.Zlib.DeflateStream.UncompressBuffer(buffer); - outStream.Write(unc, 0, unc.Length); + private static void Inflate(byte[] buffer, Stream outStream, int expectedSize) { + if (expectedSize <= 0) + throw new InvalidDataException($"Invalid expected inflated size {expectedSize}."); + + var temp = new byte[8192]; + var startPos = outStream.CanSeek ? outStream.Position : -1; + + void InflateFrom(Stream decompressor) { + var total = 0; + while (true) { + var read = decompressor.Read(temp, 0, temp.Length); + if (read <= 0) + break; + + total += read; + if (total > expectedSize) + throw new InvalidDataException($"Inflated block exceeds expected size. Expected={expectedSize}, Actual>{total}."); + + outStream.Write(temp, 0, read); + } + } + + bool LooksLikeZlibHeader(byte[] data) { + if (data == null || data.Length < 2) + return false; + + var cmf = data[0]; + var flg = data[1]; + + // RFC1950: compression method must be DEFLATE (8) + if ((cmf & 0x0F) != 8) + return false; + + // RFC1950: window size CINFO <= 7 + if ((cmf >> 4) > 7) + return false; + + // RFC1950: header checksum (CMF*256 + FLG) % 31 == 0 + return ((cmf << 8) + flg) % 31 == 0; + } + + // Pick decompressor by header to avoid flooding first-chance ZlibException in debugger. + if (!LooksLikeZlibHeader(buffer)) { + using (var compressedStream = new MemoryStream(buffer, false)) + using (var deflateStream = new Ionic.Zlib.DeflateStream(compressedStream, Ionic.Zlib.CompressionMode.Decompress, false)) { + InflateFrom(deflateStream); + } + return; + } + + try { + using (var compressedStream = new MemoryStream(buffer, false)) + using (var zlibStream = new Ionic.Zlib.ZlibStream(compressedStream, Ionic.Zlib.CompressionMode.Decompress, false)) { + InflateFrom(zlibStream); + } + return; + } catch (Ionic.Zlib.ZlibException) { + if (startPos >= 0) + outStream.Position = startPos; + } + + try { + using (var compressedStream = new MemoryStream(buffer, false)) + using (var deflateStream = new Ionic.Zlib.DeflateStream(compressedStream, Ionic.Zlib.CompressionMode.Decompress, false)) { + InflateFrom(deflateStream); + } + } catch (Exception ex) { + throw new InvalidDataException("Unable to inflate block with zlib/deflate fallback.", ex); + } } #endregion diff --git a/SaintCoinach/IO/FileCommonHeader.cs b/SaintCoinach/IO/FileCommonHeader.cs index 1658f60f..868e1ef8 100644 --- a/SaintCoinach/IO/FileCommonHeader.cs +++ b/SaintCoinach/IO/FileCommonHeader.cs @@ -48,6 +48,7 @@ private void Read(Stream stream) { const int FileTypeOffset = 0x04; const int FileLengthOffset = 0x10; const int FileLengthShift = 7; + const int MinHeaderLength = 4; if (!stream.CanSeek) throw new NotSupportedException("Stream must be able to seek."); @@ -57,10 +58,20 @@ private void Read(Stream stream) { throw new EndOfStreamException(); var length = BitConverter.ToInt32(_Buffer, 0); + var remaining = stream.Length - stream.Position; + if (length < MinHeaderLength || length > remaining + MinHeaderLength) { + throw new InvalidDataException( + string.Format( + "Invalid SqPack file header length {0} at dat {1} offset 0x{2:X}. Remaining bytes: {3}.", + length, + Index.DatFile, + Index.Offset, + remaining + MinHeaderLength)); + } Array.Resize(ref _Buffer, length); - var remaining = length - 4; - if (stream.Read(_Buffer, 4, remaining) != remaining) + var bodyLength = length - MinHeaderLength; + if (stream.Read(_Buffer, MinHeaderLength, bodyLength) != bodyLength) throw new EndOfStreamException(); FileType = (FileType)BitConverter.ToInt32(_Buffer, FileTypeOffset); diff --git a/SaintCoinach/IO/Index2Source.cs b/SaintCoinach/IO/Index2Source.cs index 0f3f4748..a421497c 100644 --- a/SaintCoinach/IO/Index2Source.cs +++ b/SaintCoinach/IO/Index2Source.cs @@ -61,7 +61,27 @@ public bool TryGetFile(uint hash, out File value) { return true; if (Index.Files.TryGetValue(hash, out var index)) { - value = FileFactory.Get(this.Pack, index); + try { + value = FileFactory.Get(this.Pack, index); + } catch (System.IO.InvalidDataException ex) { + System.Diagnostics.Debug.WriteLine( + string.Format("Failed to parse file in TryGetFile. Pack={0}, Dat={1}, Offset=0x{2:X}. {3}", + this.Pack.Id, index.DatFile, index.Offset, ex.Message)); + value = null; + return false; + } catch (System.IO.EndOfStreamException ex) { + System.Diagnostics.Debug.WriteLine( + string.Format("Unexpected end of stream in TryGetFile. Pack={0}, Dat={1}, Offset=0x{2:X}. {3}", + this.Pack.Id, index.DatFile, index.Offset, ex.Message)); + value = null; + return false; + } catch (System.NotSupportedException ex) { + System.Diagnostics.Debug.WriteLine( + string.Format("Unsupported file format in TryGetFile. Pack={0}, Dat={1}, Offset=0x{2:X}. {3}", + this.Pack.Id, index.DatFile, index.Offset, ex.Message)); + value = null; + return false; + } if (_Files.ContainsKey(hash)) _Files[hash].SetTarget(value); else From 3f3014eae27b2a176d077f59732853e7e80b231f Mon Sep 17 00:00:00 2001 From: MisakaCirno Date: Fri, 13 Mar 2026 00:36:51 +0800 Subject: [PATCH 3/4] Normalize LGB model paths AI-generated code by Codex. --- SaintCoinach/Graphics/Lgb/LgbModelEntry.cs | 37 +++++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/SaintCoinach/Graphics/Lgb/LgbModelEntry.cs b/SaintCoinach/Graphics/Lgb/LgbModelEntry.cs index 7feaa767..307bb239 100644 --- a/SaintCoinach/Graphics/Lgb/LgbModelEntry.cs +++ b/SaintCoinach/Graphics/Lgb/LgbModelEntry.cs @@ -38,18 +38,45 @@ public struct HeaderData { public Pcb.PcbFile CollisionFile { get; private set; } #endregion + private static string NormalizePath(string value, string extension) { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var path = value.Trim().Trim('\0').Replace('\\', '/'); + var roots = new[] { "bg/", "bgcommon/", "common/", "chara/", "vfx/", "cut/" }; + + var start = -1; + foreach (var root in roots) { + var index = path.IndexOf(root, StringComparison.OrdinalIgnoreCase); + if (index >= 0 && (start < 0 || index < start)) + start = index; + } + if (start > 0) + path = path.Substring(start); + + var extIndex = path.IndexOf(extension, StringComparison.OrdinalIgnoreCase); + if (extIndex >= 0) + path = path.Substring(0, extIndex + extension.Length); + + return path; + } + #region Constructor public LgbModelEntry(IO.PackCollection packs, byte[] buffer, int offset) { this.Header = buffer.ToStructure(offset); this.Name = buffer.ReadString(offset + Header.NameOffset); - ModelFilePath = buffer.ReadString(offset + Header.ModelFileOffset); - CollisionFilePath = buffer.ReadString(offset + Header.CollisionFileOffset); + ModelFilePath = NormalizePath(buffer.ReadString(offset + Header.ModelFileOffset), ".mdl"); + CollisionFilePath = NormalizePath(buffer.ReadString(offset + Header.CollisionFileOffset), ".pcb"); if (!string.IsNullOrWhiteSpace(ModelFilePath)) { - SaintCoinach.IO.File mdlFile; - if (packs.TryGetFile(ModelFilePath, out mdlFile)) - this.Model = new TransformedModel(((Graphics.ModelFile)mdlFile).GetModelDefinition(), Header.Translation, Header.Rotation, Header.Scale); + try { + SaintCoinach.IO.File mdlFile; + if (packs.TryGetFile(ModelFilePath, out mdlFile)) + this.Model = new TransformedModel(((Graphics.ModelFile)mdlFile).GetModelDefinition(), Header.Translation, Header.Rotation, Header.Scale); + } catch (Exception ex) { + Debug.WriteLine($"{Name} at 0x{offset:X} model '{ModelFilePath}' failure: {ex.Message}"); + } } if (!string.IsNullOrWhiteSpace(CollisionFilePath)) { From b1987c0d4cfa041a0ec2ca1b6fb1b88c663bac82 Mon Sep 17 00:00:00 2001 From: MisakaCirno Date: Fri, 13 Mar 2026 00:37:03 +0800 Subject: [PATCH 4/4] Stabilize export and texture handling AI-generated code by Codex. --- Godbert/ViewModels/TerritoryViewModel.cs | 62 ++++++++++++++++-------- SaintCoinach/Imaging/ImageConverter.cs | 54 ++++++++++++++++++--- 2 files changed, 90 insertions(+), 26 deletions(-) diff --git a/Godbert/ViewModels/TerritoryViewModel.cs b/Godbert/ViewModels/TerritoryViewModel.cs index 591f675f..89ca6e6f 100644 --- a/Godbert/ViewModels/TerritoryViewModel.cs +++ b/Godbert/ViewModels/TerritoryViewModel.cs @@ -143,6 +143,16 @@ private void _Export(Territory territory, Ookii.Dialogs.Wpf.ProgressDialog progr Dictionary exportedPaths = new Dictionary(); UInt64 vs = 1, vt = 1, vn = 1, i = 0; Matrix IdentityMatrix = Matrix.Identity; + const int VertLineFlushThreshold = 50000; + + void FlushVertLines(bool force = false) { + if (!force && vertStr.Count < VertLineFlushThreshold) + return; + if (vertStr.Count == 0) + return; + System.IO.File.AppendAllLines(_ExportFileName, vertStr); + vertStr.Clear(); + } void ExportMaterials(Material m, string path) { vertStr.Add($"mtllib {path}.mtl"); @@ -164,14 +174,26 @@ void ExportMaterials(Material m, string path) { if (mtlName.Contains("_dummy_")) continue; - var ddsBytes = SaintCoinach.Imaging.ImageConverter.GetDDS(img); + byte[] ddsBytes = null; + var fileExt = ".png"; + try { + ddsBytes = SaintCoinach.Imaging.ImageConverter.GetDDS(img); + fileExt = ddsBytes != null ? ".dds" : ".png"; - var fileExt = ddsBytes != null ? ".dds" : ".png"; - - if (fileExt == ".dds") - System.IO.File.WriteAllBytes($"{_ExportDirectory}/{mtlName}.dds", ddsBytes); - else - SaintCoinach.Imaging.ImageConverter.Convert(img).Save($"{_ExportDirectory}/{mtlName}.png"); + if (fileExt == ".dds") { + System.IO.File.WriteAllBytes($"{_ExportDirectory}/{mtlName}.dds", ddsBytes); + } + else { + using (var convertedImage = SaintCoinach.Imaging.ImageConverter.Convert(img)) { + convertedImage.Save($"{_ExportDirectory}/{mtlName}.png", System.Drawing.Imaging.ImageFormat.Png); + } + } + } + catch (Exception texEx) { + System.Diagnostics.Debug.WriteLine( + $"Failed to export texture '{img.Path}' ({img.Width}x{img.Height}, {img.Format}) for material '{path}': {texEx.Message}"); + continue; + } if (mtlName.Contains("_n.tex")) { @@ -235,20 +257,23 @@ void ExportMesh(ref Mesh mesh, ref Matrix lgbTransform, ref string materialName, vertStr.Add($"vt {v.UV.Value.X} {v.UV.Value.Y * -1.0}".Replace(',', '.')); tempVt++; } + + if (vertStr.Count >= VertLineFlushThreshold) + FlushVertLines(); } vertStr.Add($"g {modelFilePath}_{i.ToString()}_{k.ToString()}"); vertStr.Add($"usemtl {materialName}"); - for (UInt64 j = 0; j + 3 < (UInt64)mesh.Indices.Length + 1; j += 3) { + for (var j = 0; j + 2 < mesh.Indices.Length; j += 3) { vertStr.Add( $"f " + $"{mesh.Indices[j] + vs}/{mesh.Indices[j] + vt}/{mesh.Indices[j] + vn} " + $"{mesh.Indices[j + 1] + vs}/{mesh.Indices[j + 1] + vt}/{mesh.Indices[j + 1] + vn} " + $"{mesh.Indices[j + 2] + vs}/{mesh.Indices[j + 2] + vt}/{mesh.Indices[j + 2] + vn}"); + + if (vertStr.Count >= VertLineFlushThreshold) + FlushVertLines(); } - if (i % 1000 == 0) { - System.IO.File.AppendAllLines(_ExportFileName, vertStr); - vertStr.Clear(); - } + FlushVertLines(); vs += tempVs; vn += tempVn; vt += tempVt; @@ -339,8 +364,7 @@ void ExportSgbModels(SaintCoinach.Graphics.Sgb.SgbFile sgbFile, ref Matrix lgbTr } } - System.IO.File.AppendAllLines(_ExportFileName, vertStr); - vertStr.Clear(); + FlushVertLines(true); vs = 1; vn = 1; vt = 1; i = 0; foreach (var lgb in territory.LgbFiles) { foreach (var lgbGroup in lgb.Groups) { @@ -356,8 +380,7 @@ void ExportSgbModels(SaintCoinach.Graphics.Sgb.SgbFile sgbFile, ref Matrix lgbTr newGroup = false; - System.IO.File.AppendAllLines(_ExportFileName, vertStr); - vertStr.Clear(); + FlushVertLines(true); //vertStr.Add($"o {lgbGroup.Name}"); @@ -461,8 +484,7 @@ void ExportSgbModels(SaintCoinach.Graphics.Sgb.SgbFile sgbFile, ref Matrix lgbTr lightStrs.Clear(); } } - System.IO.File.AppendAllLines(_ExportFileName, vertStr); - vertStr.Clear(); + FlushVertLines(true); System.IO.File.AppendAllLines(lightsFileName, lightStrs); lightStrs.Clear(); System.Windows.Forms.MessageBox.Show("Finished exporting " + territory.Name, "", System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Information); @@ -471,8 +493,8 @@ void ExportSgbModels(SaintCoinach.Graphics.Sgb.SgbFile sgbFile, ref Matrix lgbTr System.Windows.Forms.MessageBox.Show(e.Message, $"Canceled {teriName} export"); } catch (Exception e) { - System.Diagnostics.Debug.WriteLine(e.StackTrace); - System.Windows.Forms.MessageBox.Show(e.StackTrace, $"Unable to export {teriName}"); + System.Diagnostics.Debug.WriteLine(e.ToString()); + System.Windows.Forms.MessageBox.Show($"{e.GetType().FullName}: {e.Message}{Environment.NewLine}{e.StackTrace}", $"Unable to export {teriName}"); } } #endregion diff --git a/SaintCoinach/Imaging/ImageConverter.cs b/SaintCoinach/Imaging/ImageConverter.cs index 9a39a64c..bf871b77 100644 --- a/SaintCoinach/Imaging/ImageConverter.cs +++ b/SaintCoinach/Imaging/ImageConverter.cs @@ -70,6 +70,8 @@ enum DDPF_ENUM : uint { /// to formats useable in .NET /// public class ImageConverter { + private const long MaxDecodedArgbBytes = 256L * 1024L * 1024L; + /// /// Method signature for processing data as stored in SqPack into ARGB. /// @@ -138,7 +140,16 @@ public static byte[] GetA8R8G8B8(byte[] src, ImageFormat format, int width, int if (!Preprocessors.TryGetValue(format, out var proc)) throw new NotSupportedException(string.Format("Unsupported image format {0}", format)); - var argb = new byte[width * height * 4]; + if (width <= 0 || height <= 0) + throw new System.IO.InvalidDataException($"Invalid image dimensions {width}x{height} for format {format}."); + + var pixelCount = (long)width * height; + var byteCount = pixelCount * 4; + if (byteCount <= 0 || byteCount > MaxDecodedArgbBytes) + throw new System.IO.InvalidDataException( + $"Decoded image is too large ({width}x{height}, {byteCount} bytes) for format {format}. Limit={MaxDecodedArgbBytes} bytes."); + + var argb = new byte[(int)byteCount]; proc(src, argb, width, height); return argb; } @@ -148,10 +159,24 @@ public static byte[] GetA8R8G8B8(byte[] src, ImageFormat format, int width, int /// public static byte[] GetDDS(ImageFile file) { - var bytes2 = file.GetData(); - //var offset = bytes2[file.ImageHeader.EndOfHeader]; var width = file.ImageHeader.Width; var height = file.ImageHeader.Height; + var imageFormat = file.ImageHeader.Format; + var addDx10Header = false; + + switch (imageFormat) { + case ImageFormat.Dxt1: + case ImageFormat.Dxt3: + case ImageFormat.Dxt5: + case ImageFormat.BC5: + case ImageFormat.BC7: + break; + default: + return null; + } + + var bytes2 = file.GetData(); + //var offset = bytes2[file.ImageHeader.EndOfHeader]; DDS_HEADER header = new DDS_HEADER(); DDS_PIXELFORMAT format = header.ddspf; @@ -161,7 +186,7 @@ public static byte[] GetDDS(ImageFile file) { header.dwFlags |= (uint)(DDSD_ENUM.DDSD_CAPS | DDSD_ENUM.DDSD_HEIGHT | DDSD_ENUM.DDSD_WIDTH | DDSD_ENUM.DDSD_PIXELFORMAT | DDSD_ENUM.DDSD_LINEARSIZE); header.dwFlags |= (uint)DDSD_ENUM.DDSD_MIPMAPCOUNT; - switch (file.ImageHeader.Format) { + switch (imageFormat) { case ImageFormat.Dxt1: format.dwFourCC = 0x31545844; header.dwPitchOrLinearSize = (uint)(Math.Max((uint)1, ((width + 3) / 4)) * Math.Max((uint)1, ((height + 3) / 4)) * 8); @@ -174,6 +199,17 @@ public static byte[] GetDDS(ImageFile file) { format.dwFourCC = 0x35545844; header.dwPitchOrLinearSize = (uint)(Math.Max((uint)1, ((width + 3) / 4)) * Math.Max((uint)1, ((height + 3) / 4)) * 16); break; + case ImageFormat.BC5: + // 'ATI2' block-compressed normal map format. + format.dwFourCC = 0x32495441; + header.dwPitchOrLinearSize = (uint)(Math.Max((uint)1, ((width + 3) / 4)) * Math.Max((uint)1, ((height + 3) / 4)) * 16); + break; + case ImageFormat.BC7: + // BC7 requires DX10 extension header. + format.dwFourCC = 0x30315844; // "DX10" + header.dwPitchOrLinearSize = (uint)(Math.Max((uint)1, ((width + 3) / 4)) * Math.Max((uint)1, ((height + 3) / 4)) * 16); + addDx10Header = true; + break; /* case ImageFormat.A8R8G8B8_1: case ImageFormat.A8R8G8B8_2: @@ -187,9 +223,7 @@ public static byte[] GetDDS(ImageFile file) { break; */ default: - System.Diagnostics.Debug.WriteLine("Texture format " + file.ImageHeader.Format.ToString() + " DDS export not supported!\n"); return null; - break; } format.dwSize = 32; @@ -214,6 +248,14 @@ public static byte[] GetDDS(ImageFile file) { data.AddRange(System.Text.ASCIIEncoding.UTF8.GetBytes("DDS ")); data.AddRange(headerBytes); + if (addDx10Header) { + // DDS_HEADER_DXT10 + data.AddRange(BitConverter.GetBytes((uint)98)); // DXGI_FORMAT_BC7_UNORM + data.AddRange(BitConverter.GetBytes((uint)3)); // D3D10_RESOURCE_DIMENSION_TEXTURE2D + data.AddRange(BitConverter.GetBytes((uint)0)); // miscFlag + data.AddRange(BitConverter.GetBytes((uint)1)); // arraySize + data.AddRange(BitConverter.GetBytes((uint)0)); // miscFlags2 + } data.AddRange(bytes2); return data.ToArray();