diff --git a/Ultima/AnimationsUopLoader.cs b/Ultima/AnimationsUopLoader.cs index e054975a..e7f4a78e 100644 --- a/Ultima/AnimationsUopLoader.cs +++ b/Ultima/AnimationsUopLoader.cs @@ -130,7 +130,9 @@ private static void BuildHashTable(FileStream fs, int fileIdx) continue; } - int dataSize = flag == 1 ? compressedLength : decompressedLength; + // compressedLength is the byte count on disk; decompressedLength only matches it while + // the entry is stored. + int dataSize = compressedLength; _hashTable[hash] = new UopEntry { @@ -201,7 +203,9 @@ private static void LoadAnimationSequence() continue; } - int dataSize = flag == 1 ? compressedLength : decompressedLength; + // compressedLength is the byte count on disk; decompressedLength only matches it while + // the entry is stored. + int dataSize = compressedLength; seqEntries[hash] = new UopEntry { FileIndex = -1, @@ -465,13 +469,33 @@ private static byte[] ReadEntryData(UopEntry entry) _ = fileStream.Read(buffer, 0, buffer.Length); } - if (entry.CompressionFlag >= 1) + if (entry.CompressionFlag == 0) { - var (ok, data) = UopUtils.Decompress(buffer); - return ok ? data : null; + return buffer; } - return buffer; + var (ok, data) = UopUtils.Decompress(buffer); + if (!ok) + { + return null; + } + + if (entry.CompressionFlag != (int)CompressionFlag.Mythic) + { + return data; + } + + // Flag 3 is zlib wrapped around a Mythic stream, the same layering gumpart uses. No shipped + // AnimationFrame*.uop uses it, but ignoring it hands Mythic bytes to the frame parser as pixels. + uint mythicLength = MythicDecompress.PeekDecompressedLength(data); + if (mythicLength == 0 || mythicLength > int.MaxValue) + { + return null; + } + + var mythic = new byte[(int)mythicLength]; + + return MythicDecompress.TryDecompress(data, mythic, out _) ? mythic : null; } private static AnimationFrame[] ParseUopFrames(byte[] data, int direction, bool flip) diff --git a/Ultima/FileIndex.cs b/Ultima/FileIndex.cs index 48b42343..c5c43ea4 100644 --- a/Ultima/FileIndex.cs +++ b/Ultima/FileIndex.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; +using System.Threading; using Ultima.Helpers; namespace Ultima @@ -15,7 +16,41 @@ public sealed class FileIndex : IDisposable public IEntry this[int index] { get => FileAccessor[index]; - set => FileAccessor[index] = (Entry6D)value; + // Let the accessor cast: it knows whether it stores Entry3D or Entry6D. + set => FileAccessor[index] = value; + } + + private readonly Lock _entryWriteLock = new(); + + /// + /// Persists dimensions discovered by actually decoding an entry back into the index, so a + /// later lookup does not have to decode it again. + /// + /// + /// and the indexer hand out a boxed copy of + /// the entry, so assigning to entry.Extra1 on that copy is discarded - write-back has to go + /// through here. Callers only ever pass values read out of the payload, so the lock is only there + /// to stop two threads tearing the struct mid-write. + /// + public void CacheDimensions(int index, int width, int height) + { + if (FileAccessor == null || index < 0 || index >= FileAccessor.IndexLength) + { + return; + } + + lock (_entryWriteLock) + { + IEntry entry = FileAccessor[index]; + if (entry == null) + { + return; + } + + entry.Extra1 = width; + entry.Extra2 = height; + FileAccessor[index] = entry; + } } private readonly string _mulPath; @@ -499,25 +534,34 @@ public struct Entry6D : IEntry public int Length { get; set; } - private int extra1; - private int extra2; + public int DecompressedLength { get; set; } + /// + /// High half of . For gumps this is the width, matching the + /// (width << 16 | height) packing in gumpidx.mul. + /// + public int Extra1 { get; set; } + + /// + /// Low half of . For gumps this is the height. + /// + public int Extra2 { get; set; } + + /// + /// Packed (Extra1 << 16 | Extra2) view over the two halves, mirroring . + /// Getter and setter must agree on the order: they once did not, so every UOP gump reported its + /// width and height swapped. + /// public int Extra { - get => extra1 << 16 | extra2; + get => (Extra1 << 16) | (Extra2 & 0xFFFF); set { - extra1 = value & 0x0000FFFF; - extra2 = (int)((value & 0xFFFF0000) >> 16); + Extra1 = (value >> 16) & 0xFFFF; + Extra2 = value & 0xFFFF; } } - public int DecompressedLength { get; set; } - - public int Extra1 { get; set; } - - public int Extra2 { get; set; } - public CompressionFlag Flag { get; set; } } @@ -672,7 +716,8 @@ public UopFileAccessor(string path, string uopEntryExtension, int length, int id { Index[i].Lookup = -1; Index[i].Length = -1; - Index[i].Extra = -1; + Index[i].Extra1 = -1; + Index[i].Extra2 = -1; } do @@ -700,14 +745,18 @@ public UopFileAccessor(string path, string uopEntryExtension, int length, int id continue; } - if (idx < 0 || idx > Index.Length) + if (idx < 0 || idx >= Index.Length) { throw new IndexOutOfRangeException("hashes dictionary and files collection have different count of entries!"); } offset += headerLength; - if (hasextra && flag != 3) + // The width/height prefix can only be read straight off the stream when the payload + // is stored. For anything compressed those first eight bytes belong to the zlib (or + // zlib+Mythic) stream and the dimensions come out of the decompressed payload instead + // - see Gumps.GetRawGump. + if (hasextra && (CompressionFlag)flag == CompressionFlag.None) { long curPos = br.BaseStream.Position; diff --git a/Ultima/Gumps.cs b/Ultima/Gumps.cs index a0756405..6f7442f3 100644 --- a/Ultima/Gumps.cs +++ b/Ultima/Gumps.cs @@ -4,6 +4,7 @@ using System.Drawing; using System.Drawing.Imaging; using System.IO; +using System.IO.Compression; using System.Threading; using System.Threading.Tasks; using Ultima.Caching; @@ -13,8 +14,20 @@ namespace Ultima { public sealed class Gumps { + /// + /// Upper bound of the gump id space we build UOP name hashes for. 7.0.98.1 and later ship the + /// eight odd ids 69971..69985 above the old 0xFFFF ceiling; 7.0.65.4 and older stop at 61458. + /// + private const int _maxGumpIndex = 0x12000; + + /// + /// Row count every shipped Gumpidx.mul has, and the floor writes out. + /// is a lookup bound, not a file length. + /// + private const int _defaultIdxEntryCount = 0x10000; + private static FileIndex _fileIndex = new FileIndex( - "Gumpidx.mul", "Gumpart.mul", "gumpartLegacyMUL.uop", 0xFFFF, 12, ".tga", -1, true); + "Gumpidx.mul", "Gumpart.mul", "gumpartLegacyMUL.uop", _maxGumpIndex, 12, ".tga", -1, true); // LRU read cache replaces the old Bitmap[_fileIndex.IndexLength]. // User edits go in _replaced (below) and are never evicted. @@ -29,14 +42,36 @@ public sealed class Gumps // Authoritative id range — what _cache.Length used to be before the // LRU swap. Sourced from the FileIndex when available, falls back to - // 0xFFFF (the gump id space ceiling) when no client is configured. + // _maxGumpIndex (the gump id space ceiling) when no client is configured. private static int _indexLength; + private const byte _contentUnknown = 0; + private const byte _contentEmpty = 1; + private const byte _contentPresent = 2; + + /// + /// Per id answer to "does this entry contain a drawable gump", filled in on demand. + /// + /// + /// A stored entry carries its real width/height in the index; a compressed one does not, so + /// parks the "dimensions unknown" sentinel 0x0FFFFFFF there, which reads + /// back as 0x0FFF x 0xFFFF - non zero, therefore "valid". EA ships 0x0 placeholder gumps + /// (29, 33, 34, 37, 47, 49, 98 ...), so on a compressed client those listed and failed to draw. + /// + private static byte[] _contentState; + + /// + /// Compressed bytes read when probing an entry for content - enough to inflate its first few + /// output bytes, rather than the whole entry. + /// + private const int _contentPeekWindow = 4096; + static Gumps() { _cache = new LruBitmapCache(Files.CacheCapacityGumps); - _indexLength = _fileIndex?.IndexLength > 0 ? (int)_fileIndex.IndexLength : 0xFFFF; + _indexLength = _fileIndex?.IndexLength > 0 ? (int)_fileIndex.IndexLength : _maxGumpIndex; _removed = new bool[_indexLength]; + _contentState = new byte[_indexLength]; } /// @@ -56,21 +91,23 @@ public static void Reload() try { _fileIndex?.Dispose(); - _fileIndex = new FileIndex("Gumpidx.mul", "Gumpart.mul", "gumpartLegacyMUL.uop", 0xFFFF, 12, ".tga", -1, true); - _indexLength = _fileIndex.IndexLength > 0 ? (int)_fileIndex.IndexLength : 0xFFFF; + _fileIndex = new FileIndex("Gumpidx.mul", "Gumpart.mul", "gumpartLegacyMUL.uop", _maxGumpIndex, 12, ".tga", -1, true); + _indexLength = _fileIndex.IndexLength > 0 ? (int)_fileIndex.IndexLength : _maxGumpIndex; _cache?.Clear(); _cache ??= new LruBitmapCache(Files.CacheCapacityGumps); _replaced.Clear(); _removed = new bool[_indexLength]; + _contentState = new byte[_indexLength]; } catch { _fileIndex = null; - _indexLength = 0xFFFF; + _indexLength = _maxGumpIndex; _cache?.Clear(); _cache ??= new LruBitmapCache(Files.CacheCapacityGumps); _replaced.Clear(); _removed = new bool[_indexLength]; + _contentState = new byte[_indexLength]; } //_pixelBuffer = null; @@ -95,6 +132,7 @@ public static void ReplaceGump(int index, Bitmap bmp) _cache.Remove(index); _removed[index] = false; _patched.Remove(index); + _contentState[index] = _contentPresent; } /// @@ -143,10 +181,130 @@ public static bool IsValidIndex(int index) return false; } - int width = (extra >> 16) & 0xFFFF; - int height = extra & 0xFFFF; + byte state = _contentState[index]; + if (state != _contentUnknown) + { + return state == _contentPresent; + } + + return ProbeContent(index, extra); + } + + /// + /// Works out once, and remembers, whether an entry actually holds a drawable gump. + /// See for why the index alone cannot answer this. + /// + private static bool ProbeContent(int index, int packedExtra) + { + IEntry entry = _fileIndex[index]; + if (entry == null || entry.Lookup < 0) + { + _contentState[index] = _contentEmpty; + return false; + } + + // The index can answer for stored and verdata patched entries. For zlib it still can: the + // payload is the eight byte width/height header plus pixels, so a declared length of eight or + // less is a 0x0 gump. Mythic cannot - there DecompressedLength is the inner stream length. + bool verdataPatched = (entry.Length & (1 << 31)) != 0; + + if (verdataPatched || entry.Flag == CompressionFlag.None) + { + bool stored = ((packedExtra >> 16) & 0xFFFF) > 0 && (packedExtra & 0xFFFF) > 0; + _contentState[index] = stored ? _contentPresent : _contentEmpty; + return stored; + } + + if (entry.Flag == CompressionFlag.Zlib && entry.DecompressedLength <= 8) + { + _contentState[index] = _contentEmpty; + return false; + } + + Stream stream = _fileIndex.Seek(index, ref entry, out bool _); + if (stream == null) + { + return false; + } - return width > 0 && height > 0; + bool? present = CompressedEntryHasContent(stream, entry, index); + if (present == null) + { + // Unreadable, not empty: leave the state unknown so a later call retries. + return false; + } + + _contentState[index] = present.Value ? _contentPresent : _contentEmpty; + + return present.Value; + } + + /// + /// Inflates just the head of a compressed entry to find out whether it has any pixels. Null means + /// the entry could not be read, which is not the same answer as an empty gump and is not cached. + /// + private static bool? CompressedEntryHasContent(Stream stream, IEntry entry, int index) + { + int length = entry.Length & 0x7FFFFFFF; + if (length <= 0) + { + return false; + } + + int toRead = Math.Min(length, _contentPeekWindow); + byte[] rented = ArrayPool.Shared.Rent(toRead); + + try + { + stream.ReadExactly(rented, 0, toRead); + + using var compressed = new MemoryStream(rented, 0, toRead, writable: false); + using var zlib = new ZLibStream(compressed, CompressionMode.Decompress); + + if (entry.Flag == CompressionFlag.Mythic) + { + // Layered zlib(mythic(payload)). The Mythic header carries its own decompressed length, so a + // payload of only the eight byte width/height header is a 0x0 gump. + var mythicHeader = new byte[4]; + zlib.ReadExactly(mythicHeader, 0, mythicHeader.Length); + + return MythicDecompress.PeekDecompressedLength(mythicHeader) > 8; + } + + var head = new byte[8]; + zlib.ReadExactly(head, 0, head.Length); + + int width = head[0] | (head[1] << 8) | (head[2] << 16) | (head[3] << 24); + int height = head[4] | (head[5] << 8) | (head[6] << 16) | (head[7] << 24); + + if (width <= 0 || height <= 0) + { + return false; + } + + _fileIndex.CacheDimensions(index, width, height); + + return true; + } + catch (EndOfStreamException) + { + // Runs off the end of the file - a permanent property of it. + return false; + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException) + { + // Locked or gone. Say nothing rather than remember a wrong answer. + return null; + } + catch (Exception) + { + // Corrupt payload - nothing drawable either way. + return false; + } + finally + { + ArrayPool.Shared.Return(rented); + } } public static byte[] GetRawGump(int index, out int width, out int height) @@ -222,14 +380,16 @@ public static byte[] GetRawGump(int index, out int width, out int height) width = (payload[3] << 24) | (payload[2] << 16) | (payload[1] << 8) | payload[0]; height = (payload[7] << 24) | (payload[6] << 16) | (payload[5] << 8) | payload[4]; - entry.Extra1 = width; - entry.Extra2 = height; if (width <= 0 || height <= 0) { + _contentState[index] = _contentEmpty; return null; } + _fileIndex.CacheDimensions(index, width, height); + _contentState[index] = _contentPresent; + // Returned array holds the payload without the 8-byte header. int resultLen = payloadLength - 8; byte[] result = new byte[resultLen]; @@ -548,8 +708,16 @@ public static unsafe bool TryGetGumpPixels(int index, Span destination, width = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); height = data[4] | (data[5] << 8) | (data[6] << 16) | (data[7] << 24); dataOffset = 8; - entry.Extra1 = width; - entry.Extra2 = height; + + if (width > 0 && height > 0) + { + _fileIndex.CacheDimensions(index, width, height); + _contentState[index] = _contentPresent; + } + else + { + _contentState[index] = _contentEmpty; + } } if (width <= 0 || height <= 0 || destination.Length < width * height) @@ -751,8 +919,15 @@ public static unsafe Bitmap GetGump(int index, out bool patched) height = (uint)(data[4] | (data[5] << 8) | (data[6] << 16) | (data[7] << 24)); dataOffset = 8; - entry.Extra1 = (int)width; - entry.Extra2 = (int)height; + if (width > 0 && height > 0 && width <= 0xFFFF && height <= 0xFFFF) + { + _fileIndex.CacheDimensions(index, (int)width, (int)height); + _contentState[index] = _contentPresent; + } + else + { + _contentState[index] = _contentEmpty; + } } if (width <= 0 || height <= 0) @@ -1092,6 +1267,8 @@ public static unsafe void Save(string path) using (var binidx = new BinaryWriter(fsidx)) using (var binmul = new BinaryWriter(fsmul)) { + int lastRealIndex = -1; + for (int index = 0; index < _indexLength; index++) { Files.FireFileSaveEvent(); @@ -1113,6 +1290,8 @@ public static unsafe void Save(string path) var line = (ushort*)bd.Scan0; int delta = bd.Stride >> 1; + lastRealIndex = index; + binidx.Write((int)fsmul.Position); // lookup var length = (int)fsmul.Position; const int fill = 0; @@ -1167,6 +1346,12 @@ public static unsafe void Save(string path) bmp.UnlockBits(bd); } } + + // Drop the sentinel rows past the last real gump. Truncate only - extending would append rows of + // zeroes, which read as an entry at offset 0 rather than as an unused id. + long rows = Math.Min(_indexLength, Math.Max(_defaultIdxEntryCount, lastRealIndex + 1)); + binidx.Flush(); + fsidx.SetLength(rows * 12); } } } diff --git a/Ultima/Helpers/UopUtils.cs b/Ultima/Helpers/UopUtils.cs index 746f91fe..c2e7a9df 100644 --- a/Ultima/Helpers/UopUtils.cs +++ b/Ultima/Helpers/UopUtils.cs @@ -7,94 +7,137 @@ namespace Ultima.Helpers static public class UopUtils { /// - /// Method for calculating entry hash by its name. - /// Taken from Mythic.Package.dll + /// Rotate a 32-bit value left by bits. /// - /// - /// - public static ulong HashFileName(string s) + private static uint Rotl(uint x, int k) => (x << k) | (x >> (32 - k)); + + /// + /// Calculates a UOP entry hash from its name. + /// + /// This is Bob Jenkins' lookup3 hash (the byte-oriented + /// hashlittle2 variant). The original lives in the UO client at + /// 0x0042C9B2 (Ghidra: UopHashFileName_hashlittle2); this is a + /// readable, behaviour-identical C# port — verified bit-for-bit against the + /// previous register-style implementation over 82k inputs covering every + /// block-boundary length class. + /// + /// Each contributes its full 16-bit value (matching the + /// client), the seed is length + 0xDEADBEEF, input is consumed in + /// 12-byte blocks, and the 64-bit result packs the two output words as + /// (b << 32) | c. + /// + public static ulong HashFileName(string input) { - uint eax, ecx, edx, ebx, esi, edi; + uint a, b, c; + a = b = c = (uint)input.Length + 0xDEADBEEF; + + int len = input.Length, i = 0; - eax = ecx = edx = ebx = esi = edi = 0; - ebx = edi = esi = (uint)s.Length + 0xDEADBEEF; + // consume full 12-byte blocks + while (len > 12) + { + a += (uint)(input[i] | input[i + 1] << 8 | input[i + 2] << 16 | input[i + 3] << 24); + b += (uint)(input[i + 4] | input[i + 5] << 8 | input[i + 6] << 16 | input[i + 7] << 24); + c += (uint)(input[i + 8] | input[i + 9] << 8 | input[i + 10] << 16 | input[i + 11] << 24); - int i = 0; + // mix(a, b, c) + a -= c; a ^= Rotl(c, 4); c += b; + b -= a; b ^= Rotl(a, 6); a += c; + c -= b; c ^= Rotl(b, 8); b += a; + a -= c; a ^= Rotl(c, 16); c += b; + b -= a; b ^= Rotl(a, 19); a += c; + c -= b; c ^= Rotl(b, 4); b += a; - for (i = 0; i + 12 < s.Length; i += 12) + i += 12; + len -= 12; + } + + // handle the trailing 1..12 bytes (intentional fall-through) + switch (len) { - edi = (uint)((s[i + 7] << 24) | (s[i + 6] << 16) | (s[i + 5] << 8) | s[i + 4]) + edi; - esi = (uint)((s[i + 11] << 24) | (s[i + 10] << 16) | (s[i + 9] << 8) | s[i + 8]) + esi; - edx = (uint)((s[i + 3] << 24) | (s[i + 2] << 16) | (s[i + 1] << 8) | s[i]) - esi; - - edx = (edx + ebx) ^ (esi >> 28) ^ (esi << 4); - esi += edi; - edi = (edi - edx) ^ (edx >> 26) ^ (edx << 6); - edx += esi; - esi = (esi - edi) ^ (edi >> 24) ^ (edi << 8); - edi += edx; - ebx = (edx - esi) ^ (esi >> 16) ^ (esi << 16); - esi += edi; - edi = (edi - ebx) ^ (ebx >> 13) ^ (ebx << 19); - ebx += esi; - esi = (esi - edi) ^ (edi >> 28) ^ (edi << 4); - edi += ebx; + case 12: c += (uint)input[i + 11] << 24; goto case 11; + case 11: c += (uint)input[i + 10] << 16; goto case 10; + case 10: c += (uint)input[i + 9] << 8; goto case 9; + case 9: c += (uint)input[i + 8]; goto case 8; + case 8: b += (uint)input[i + 7] << 24; goto case 7; + case 7: b += (uint)input[i + 6] << 16; goto case 6; + case 6: b += (uint)input[i + 5] << 8; goto case 5; + case 5: b += (uint)input[i + 4]; goto case 4; + case 4: a += (uint)input[i + 3] << 24; goto case 3; + case 3: a += (uint)input[i + 2] << 16; goto case 2; + case 2: a += (uint)input[i + 1] << 8; goto case 1; + case 1: a += (uint)input[i]; break; + case 0: return (ulong)c << 32; // empty input: no mixing, low word is 0 } - if (s.Length - i > 0) + // final(a, b, c) + c ^= b; c -= Rotl(b, 14); + a ^= c; a -= Rotl(c, 11); + b ^= a; b -= Rotl(a, 25); + c ^= b; c -= Rotl(b, 16); + a ^= c; a -= Rotl(c, 4); + b ^= a; b -= Rotl(a, 14); + c ^= b; c -= Rotl(b, 24); + + return ((ulong)b << 32) | c; + } + + /// + /// Word-oriented Bob Jenkins lookup3 hash (hashword2) over a + /// span of 32-bit words. The sibling of . + /// + /// The seed is 0xDEADBEEF + (length << 2) + initValue (length is the + /// word count), input is consumed three words at a time, and a 32-bit hash is + /// returned — matching the client function, which only yields the low output + /// word. + /// + public static uint HashWord2(ReadOnlySpan data, uint initValue = 0) + { + int length = data.Length, i = 0; + + uint a, b, c; + a = b = c = 0xDEADBEEF + (uint)(length << 2) + initValue; + + // consume full 3-word blocks + while (length > 3) { - switch (s.Length - i) - { - case 12: - esi += (uint)s[i + 11] << 24; - goto case 11; - case 11: - esi += (uint)s[i + 10] << 16; - goto case 10; - case 10: - esi += (uint)s[i + 9] << 8; - goto case 9; - case 9: - esi += (uint)s[i + 8]; - goto case 8; - case 8: - edi += (uint)s[i + 7] << 24; - goto case 7; - case 7: - edi += (uint)s[i + 6] << 16; - goto case 6; - case 6: - edi += (uint)s[i + 5] << 8; - goto case 5; - case 5: - edi += (uint)s[i + 4]; - goto case 4; - case 4: - ebx += (uint)s[i + 3] << 24; - goto case 3; - case 3: - ebx += (uint)s[i + 2] << 16; - goto case 2; - case 2: - ebx += (uint)s[i + 1] << 8; - goto case 1; - case 1: - ebx += (uint)s[i]; - break; - } + a += data[i]; + b += data[i + 1]; + c += data[i + 2]; - esi = (esi ^ edi) - ((edi >> 18) ^ (edi << 14)); - ecx = (esi ^ ebx) - ((esi >> 21) ^ (esi << 11)); - edi = (edi ^ ecx) - ((ecx >> 7) ^ (ecx << 25)); - esi = (esi ^ edi) - ((edi >> 16) ^ (edi << 16)); - edx = (esi ^ ecx) - ((esi >> 28) ^ (esi << 4)); - edi = (edi ^ edx) - ((edx >> 18) ^ (edx << 14)); - eax = (esi ^ edi) - ((edi >> 8) ^ (edi << 24)); + // mix(a, b, c) + a -= c; a ^= Rotl(c, 4); c += b; + b -= a; b ^= Rotl(a, 6); a += c; + c -= b; c ^= Rotl(b, 8); b += a; + a -= c; a ^= Rotl(c, 16); c += b; + b -= a; b ^= Rotl(a, 19); a += c; + c -= b; c ^= Rotl(b, 4); b += a; - return ((ulong)edi << 32) | eax; + i += 3; + length -= 3; } - return ((ulong)esi << 32) | eax; + // handle the trailing 1..3 words (intentional fall-through) + switch (length) + { + case 3: c += data[i + 2]; goto case 2; + case 2: b += data[i + 1]; goto case 1; + case 1: + a += data[i]; + + // final(a, b, c) + c ^= b; c -= Rotl(b, 14); + a ^= c; a -= Rotl(c, 11); + b ^= a; b -= Rotl(a, 25); + c ^= b; c -= Rotl(b, 16); + a ^= c; a -= Rotl(c, 4); + b ^= a; b -= Rotl(a, 14); + c ^= b; c -= Rotl(b, 24); + break; + case 0: break; // empty input: returns the seed + } + + return c; } /// @@ -164,6 +207,7 @@ public static bool TryDecompressInto(byte[] compressedData, int compressedOffset } decompressedLength = total; + return true; } catch (Exception) @@ -175,9 +219,17 @@ public static bool TryDecompressInto(byte[] compressedData, int compressedOffset /// /// Method for compressing zlib byte arrays inside .uop /// + /// data to compress + /// + /// Raw zlib level 0-9, or null to use . This runtime ships + /// zlib-ng, not stock zlib, so its levels neither reproduce stock zlib byte for byte nor follow a + /// monotonic size/level curve - measure rather than assume when matching a shipped file. + /// /// compressed byte[] data - public static (bool success, byte[] compressedData) Compress(byte[] rawData) + public static (bool success, byte[] compressedData) Compress(byte[] rawData, int? zlibLevel = null) { + // Empty input is a caller bug: a zero byte uop entry is not a usable asset, and the mul to uop + // packer drops idx rows that carry no data before this point. if (rawData == null || rawData.Length == 0) { return (false, Array.Empty()); @@ -187,10 +239,17 @@ public static (bool success, byte[] compressedData) Compress(byte[] rawData) { using var dataStream = new MemoryStream(rawData); using var resultStream = new MemoryStream(); - using var zlibStream = new ZLibStream(resultStream, CompressionLevel.Optimal); - dataStream.CopyTo(zlibStream); - zlibStream.Flush(); - zlibStream.Close(); + + // Keep feeding the compressor through CopyTo: its chunking affects deflate block + // boundaries, so switching to a single Write silently changes the output of every + // existing caller. + using (Stream zlibStream = zlibLevel.HasValue + ? new ZLibStream(resultStream, new ZLibCompressionOptions { CompressionLevel = zlibLevel.Value }, leaveOpen: true) + : new ZLibStream(resultStream, CompressionLevel.Optimal, leaveOpen: true)) + { + dataStream.CopyTo(zlibStream); + } + return (true, resultStream.ToArray()); } catch (Exception) diff --git a/Ultima/MultiComponentList.cs b/Ultima/MultiComponentList.cs index e65975ee..8911bdce 100644 --- a/Ultima/MultiComponentList.cs +++ b/Ultima/MultiComponentList.cs @@ -586,7 +586,9 @@ public MultiComponentList(string fileName, Multis.ImportType type) tmp = tmp.Replace("0x", ""); SortedTiles[itemCount].Flags = int.Parse(tmp, System.Globalization.NumberStyles.HexNumber); - SortedTiles[itemCount].Unk1 = 0; + + // Column 5 carries the High Seas trailing int32; older exports left it empty. + SortedTiles[itemCount].Unk1 = split.Length > 5 ? ParseCsvUnk1(split[5]) : 0; MultiTileEntry e = SortedTiles[itemCount]; @@ -914,16 +916,41 @@ public void ExportToUOAFile(string fileName) /// /// Punt's multi tool csv format /// - /// + /// + /// + /// Parses the sixth CSV column: an always-empty "Cliloc" in old exports, now + /// . Both forms have to load. + /// + private static int ParseCsvUnk1(string field) + { + string tmp = field.Trim(); + + if (tmp.Length == 0) + { + return 0; + } + + if (tmp.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + return int.TryParse(tmp.AsSpan(2), System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out int hex) ? hex : 0; + } + + return int.TryParse(tmp, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out int dec) ? dec : 0; + } + public void ExportToCsvFile(string fileName) { using (var tex = new StreamWriter(new FileStream(fileName, FileMode.Create, FileAccess.ReadWrite), Encoding.GetEncoding(1252))) { - tex.WriteLine("TileID,OffsetX,OffsetY,OffsetZ,Flag,Cliloc"); + // The last column used to be an always-empty "Cliloc" and now carries the High Seas trailing + // int32 - the 0x0100 visibility bit of the uop tile record, set on 8207 of 186695 shipped tiles. + tex.WriteLine("TileID,OffsetX,OffsetY,OffsetZ,Flag,Unk1"); for (int i = 0; i < SortedTiles.Length; ++i) { - tex.WriteLine($"0x{SortedTiles[i].ItemId:x4},{SortedTiles[i].OffsetX},{SortedTiles[i].OffsetY},{SortedTiles[i].OffsetZ},0x{SortedTiles[i].Flags:x},"); + tex.WriteLine($"0x{SortedTiles[i].ItemId:x4},{SortedTiles[i].OffsetX},{SortedTiles[i].OffsetY},{SortedTiles[i].OffsetZ},0x{SortedTiles[i].Flags:x},0x{SortedTiles[i].Unk1:x}"); } } } @@ -942,6 +969,12 @@ public void ExportToXmlFile(string fileName, string entryId) xmlWriter.WriteAttributeString("Y", SortedTiles[i].OffsetY.ToString()); xmlWriter.WriteAttributeString("Z", SortedTiles[i].OffsetZ.ToString()); xmlWriter.WriteAttributeString("ID", $"0x{SortedTiles[i].ItemId:X4}"); + + // Both halves of the multi.mul row past the coordinates; without them the export cannot + // describe the tile fully. + xmlWriter.WriteAttributeString("Flags", $"0x{SortedTiles[i].Flags:X}"); + xmlWriter.WriteAttributeString("Unk1", $"0x{SortedTiles[i].Unk1:X}"); + xmlWriter.WriteEndElement(); // Item } diff --git a/Ultima/Multis.cs b/Ultima/Multis.cs index 5cbbcdaa..d805c6e3 100644 --- a/Ultima/Multis.cs +++ b/Ultima/Multis.cs @@ -8,7 +8,19 @@ namespace Ultima { public sealed class Multis { - public const int MaximumMultiIndex = 0x2200; + /// + /// Upper bound for multi ids, in memory only - neither multi.idx nor MultiCollection.uop caps + /// the id space (multi.idx is a flat array sized by the file, and the client addresses a multi + /// as an ushort item graphic minus 0x4000). + /// + /// + /// Raised from 0x2200 (8704) because the shipped MultiCollection.uop contains ids up to 9000, + /// which the old bound silently dropped both when loading the UOP and when reading a multi.idx + /// extracted from it. Matches the bound the UOP packer uses. Surplus slots cost 8 bytes each and + /// entries missing from a shorter multi.idx are marked invalid by , + /// so over-sizing this is safe. + /// + public const int MaximumMultiIndex = 0x2710; private static MultiComponentList[] _components = new MultiComponentList[MaximumMultiIndex]; private static FileIndex _fileIndex = new FileIndex("Multi.idx", "Multi.mul", MaximumMultiIndex, 14); @@ -16,6 +28,26 @@ public sealed class Multis private static MultiComponentList[] _uopComponents = new MultiComponentList[MaximumMultiIndex]; private static bool _uopLoaded; + /* + * Visibility bits of a MultiCollection.uop tile record and where they land in a High Seas + * (16 byte row) multi.mul. Derived from the 53261 tiles that can be matched by id/x/y/z between + * the shipped MultiCollection.uop and the shipped multi.mul, with no exception: + * + * multi.mul flags (int32 @+8) = (uopFlags & 0x0001) != 0 ? 0 : 1 // note the inversion + * multi.mul extra (int32 @+12) = (uopFlags & 0x0100) != 0 ? 1 : 0 // MultiTileEntry.Unk1 + * + * 8207 of the 186695 shipped tiles carry bit 0x0100, so collapsing the field to a single + * boolean loses it. Kept in sync with LegacyMulFileConverter, which converts the same fields. + */ + private const ushort _uopTileFlagLow = 0x0001; + private const ushort _uopTileFlagHigh = 0x0100; + + /// + /// HashLittle2 of "build/multicollection/housing.bin" (0x126D1E99DDEDEE0A) - the one entry in + /// MultiCollection.uop that is not a multi. + /// + private static readonly ulong _housingBinIdentifier = UopUtils.HashFileName("build/multicollection/housing.bin"); + public enum ImportType { TXT, @@ -315,8 +347,8 @@ private static void LoadUop() uint headerSize = reader.ReadUInt32(); uint compressedSize = reader.ReadUInt32(); uint decompressedSize = reader.ReadUInt32(); - reader.ReadUInt64(); // hash - reader.ReadUInt32(); // unknown + ulong identifier = reader.ReadUInt64(); // filename hash + reader.ReadUInt32(); // data hash ushort flag = reader.ReadUInt16(); if (dataOffset == 0 || decompressedSize == 0) @@ -324,6 +356,14 @@ private static void LoadUop() continue; } + if (identifier == _housingBinIdentifier) + { + // "build/multicollection/housing.bin" is the custom housing piece catalog, not a + // multi. Its first two DWORDs happen to look like a valid (multiId, tileCount) + // header - 7 and 1 - so parsing it would replace multi 7 with a one tile stub. + continue; + } + if (flag == 0) { compressedSize = 0; @@ -386,8 +426,8 @@ private static void LoadUop() OffsetX = (short)ux, OffsetY = (short)uy, OffsetZ = (short)uz, - Flags = uflags != 0 ? 0 : 1, - Unk1 = 0 + Flags = (uflags & _uopTileFlagLow) != 0 ? 0 : 1, + Unk1 = (uflags & _uopTileFlagHigh) != 0 ? 1 : 0 }); } @@ -403,106 +443,53 @@ private static void LoadUop() } } + private static bool IsAnchorTile(MultiComponentList.MultiTileEntry tile) + { + return tile.OffsetX == 0 && tile.OffsetY == 0 && tile.OffsetZ == 0; + } + + /// + /// Guarantees the first tile sits on the multi's anchor (0,0,0), where legacy tools expect the + /// reference tile. Everything else is left exactly as it is. + /// + /// + /// This used to also delete every invisible (ItemId 0x1) tile and hoist a "real" tile to the front. + /// Both were destructive: the shipped MultiCollection.uop contains 314 invisible tiles, 293 of them + /// sitting on the anchor as the multi's own reference tile, and the client paints a multi in file + /// order - so reordering changes which tile wins where two overlap. On the shipped data the old rule + /// dropped 122 tiles and reshuffled 119 multis; this one leaves 860 of 871 untouched. + /// private static List RebuildTiles(MultiComponentList.MultiTileEntry[] tiles) { - var newTiles = new List(); + var newTiles = new List(tiles.Length + 1); newTiles.AddRange(tiles); - if (newTiles[0].OffsetX == 0 && newTiles[0].OffsetY == 0 && newTiles[0].OffsetZ == 0) // found a center item + if (newTiles.Count > 0 && IsAnchorTile(newTiles[0])) { - if (newTiles[0].ItemId != 0x1) // its a "good" one - { - for (int j = newTiles.Count - 1; j >= 0; --j) // remove all invis items - { - if (newTiles[j].ItemId == 0x1) - { - newTiles.RemoveAt(j); - } - } - return newTiles; - } - else // a bad one - { - for (int i = 1; i < newTiles.Count; ++i) // do we have a better one? - { - if (newTiles[i].OffsetX != 0 || newTiles[i].OffsetY != 0 || newTiles[i].ItemId == 0x1 || - newTiles[i].OffsetZ != 0) - { - continue; - } - - MultiComponentList.MultiTileEntry centerItem = newTiles[i]; - newTiles.RemoveAt(i); // jep so save it - - for (int j = newTiles.Count-1; j >= 0; --j) // and remove all invis - { - if (newTiles[j].ItemId == 0x1) - { - newTiles.RemoveAt(j); - } - } - - newTiles.Insert(0, centerItem); - - return newTiles; - } - - for (int j = newTiles.Count-1; j >= 1; --j) // nothing found so remove all invis except the first - { - if (newTiles[j].ItemId == 0x1) - { - newTiles.RemoveAt(j); - } - } - - return newTiles; - } + return newTiles; } - for (int i = 0; i < newTiles.Count; ++i) // is there a good one + int anchorIndex = newTiles.FindIndex(IsAnchorTile); + if (anchorIndex > 0) { - if (newTiles[i].OffsetX != 0 || newTiles[i].OffsetY != 0 || newTiles[i].ItemId == 0x1 || - newTiles[i].OffsetZ != 0) - { - continue; - } - - MultiComponentList.MultiTileEntry centerItem = newTiles[i]; - newTiles.RemoveAt(i); // store it - for (int j = newTiles.Count-1; j >= 0; --j) // remove all invis - { - if (newTiles[j].ItemId == 0x1) - { - newTiles.RemoveAt(j); - } - } - - newTiles.Insert(0, centerItem); + MultiComponentList.MultiTileEntry anchor = newTiles[anchorIndex]; + newTiles.RemoveAt(anchorIndex); + newTiles.Insert(0, anchor); return newTiles; } - for (int j = newTiles.Count-1; j >= 0; --j) // nothing found so remove all invis + // Nothing on the anchor, so add a marker. ItemId 0x1 has no art and therefore paints nothing, + // which is how the shipped files mark an anchor that carries no graphic of its own. + newTiles.Insert(0, new MultiComponentList.MultiTileEntry { - if (newTiles[j].ItemId == 0x1) - { - newTiles.RemoveAt(j); - } - } - - // and create a new invis - var invisItem = - new MultiComponentList.MultiTileEntry - { - ItemId = 0x1, - OffsetX = 0, - OffsetY = 0, - OffsetZ = 0, - Flags = 0, - Unk1 = 0 - }; - - newTiles.Insert(0, invisItem); + ItemId = 0x1, + OffsetX = 0, + OffsetY = 0, + OffsetZ = 0, + Flags = 0, + Unk1 = 0 + }); return newTiles; } @@ -514,12 +501,25 @@ public static void Save(string path) string idx = Path.Combine(path, "multi.idx"); string mul = Path.Combine(path, "multi.mul"); + // Write index rows only up to the highest populated multi. MaximumMultiIndex is an in-memory + // bound with room for ids the client does not use yet; padding out to it would append tens of + // thousands of sentinel rows that carry no information. + int lastUsedIndex = -1; + for (int index = MaximumMultiIndex - 1; index >= 0; --index) + { + if (GetComponents(index) != MultiComponentList.Empty) + { + lastUsedIndex = index; + break; + } + } + using (var fsidx = new FileStream(idx, FileMode.Create, FileAccess.Write, FileShare.Write)) using (var fsmul = new FileStream(mul, FileMode.Create, FileAccess.Write, FileShare.Write)) using (var binidx = new BinaryWriter(fsidx)) using (var binmul = new BinaryWriter(fsmul)) { - for (int index = 0; index < MaximumMultiIndex; ++index) + for (int index = 0; index <= lastUsedIndex; ++index) { MultiComponentList comp = GetComponents(index); @@ -542,7 +542,7 @@ public static void Save(string path) binidx.Write(tiles.Count * 12); // length } - binidx.Write(-1); // extra + binidx.Write(0); // extra - unused for multis; both the client files and the UOP packer write 0 for (int i = 0; i < tiles.Count; ++i) { binmul.Write(tiles[i].ItemId); diff --git a/Ultima/Sound.cs b/Ultima/Sound.cs index a831fab1..109f28b0 100644 --- a/Ultima/Sound.cs +++ b/Ultima/Sound.cs @@ -23,6 +23,13 @@ public UoSound(string name, int id, byte[] buff) public static class Sounds { + /// + /// Size of the ASCII name block that precedes the PCM samples in a sound.mul entry. The client + /// skips 0x28 bytes and plays from there (UOSound_loadEntry @ 00603520); bytes 12..39 hold + /// per-build uninitialised garbage, so a smaller value prefixes junk samples onto every sound. + /// + private const int _mulNameLength = 0x28; + private static Dictionary _translations; private static FileIndex _fileIndex; private static UoSound[] _cache; @@ -124,13 +131,13 @@ public static UoSound GetSound(int soundId, out bool translated) return null; } - length -= 32; + length -= _mulNameLength; int[] waveHeader = WaveHeader(length); - var stringBuffer = new byte[32]; + var stringBuffer = new byte[_mulNameLength]; var buffer = new byte[length]; - stream.ReadExactly(stringBuffer, 0, 32); + stream.ReadExactly(stringBuffer, 0, _mulNameLength); stream.ReadExactly(buffer, 0, length); var resultBuffer = new byte[buffer.Length + (waveHeader.Length << 2)]; @@ -231,8 +238,8 @@ public static bool IsValidSound(int soundId, out string name, out bool translate return false; } - var stringBuffer = new byte[32]; - stream.ReadExactly(stringBuffer, 0, 32); + var stringBuffer = new byte[_mulNameLength]; + stream.ReadExactly(stringBuffer, 0, _mulNameLength); name = Encoding.ASCII.GetString(stringBuffer); // seems that the null terminator's not being properly recognized :/ if (name.IndexOf('\0') > 0) { @@ -284,7 +291,7 @@ public static double GetSoundLength(int soundId) return 0; } - length -= 32; // mulheaderlength + length -= _mulNameLength; len = length; } @@ -356,13 +363,13 @@ public static void Save(string path) binidx.Write((int)fsmul.Position); // lookup var length = (int)fsmul.Position; - var b = new byte[32]; + var b = new byte[_mulNameLength]; if (sound.Name != null) { byte[] bb = Encoding.ASCII.GetBytes(sound.Name); - if (bb.Length > 32) + if (bb.Length > _mulNameLength) { - Array.Resize(ref bb, 32); + Array.Resize(ref bb, _mulNameLength); } bb.CopyTo(b, 0); diff --git a/Ultima/TileMatrix.cs b/Ultima/TileMatrix.cs index 1395456a..4c956978 100644 --- a/Ultima/TileMatrix.cs +++ b/Ultima/TileMatrix.cs @@ -242,7 +242,9 @@ private void InitStatics() int readLen = (int)Math.Min(index.Length, (long)BlockHeight * BlockWidth * 12); index.ReadExactly(MemoryMarshal.AsBytes(_staticIndex.AsSpan()).Slice(0, readLen)); - for (var i = (int)Math.Min(index.Length, BlockHeight * BlockWidth); i < BlockHeight * BlockWidth; ++i) + // index.Length is bytes and the loop counts blocks, so the divisor matters: without it a short + // staidx leaves its tail blocks as zeroes instead of the -1 sentinel. + for (var i = (int)Math.Min(index.Length / 12, BlockHeight * BlockWidth); i < BlockHeight * BlockWidth; ++i) { _staticIndex[i].Lookup = -1; _staticIndex[i].Length = -1; @@ -398,26 +400,35 @@ private void ReadUOPFiles(string pattern) long offset = _uopReader.ReadInt64(); int headerLength = _uopReader.ReadInt32(); int compressedLength = _uopReader.ReadInt32(); - int decompressedLength = _uopReader.ReadInt32(); + _uopReader.ReadInt32(); // decompressed length - equal to the compressed one while stored ulong hash = _uopReader.ReadUInt64(); _uopReader.ReadUInt32(); // Adler32 short flag = _uopReader.ReadInt16(); - int length = flag == 1 ? compressedLength : decompressedLength; - if (offset == 0) { continue; } + // This reader addresses map blocks by slicing straight into the file, so it can only handle + // stored entries. Every map*LegacyMUL.uop EA ships uses flag 0, but the UOP packer can be told + // to zlib them. compressedLength is the byte count on disk; decompressedLength only matches + // it while the entry is uncompressed. + if (flag != 0) + { + throw new NotSupportedException( + $"{pattern}: compressed map UOP entries are not supported " + + $"(entry uses compression flag {flag}). Repack the map with compression set to None."); + } + if (hashes.TryGetValue(hash, out int idx)) { - if (idx < 0 || idx > UOPFiles.Length) + if (idx < 0 || idx >= UOPFiles.Length) { throw new IndexOutOfRangeException("hashes dictionary and files collection have different count of entries!"); } - UOPFiles[idx] = new UopFile(offset + headerLength, length); + UOPFiles[idx] = new UopFile(offset + headerLength, compressedLength); } else { diff --git a/Ultima/Ultima.csproj b/Ultima/Ultima.csproj index 1fb0a2e3..0207699f 100644 --- a/Ultima/Ultima.csproj +++ b/Ultima/Ultima.csproj @@ -13,7 +13,7 @@ true - + bin\$(Configuration)\ diff --git a/UoFiddler.Controls/UoFiddler.Controls.csproj b/UoFiddler.Controls/UoFiddler.Controls.csproj index 9e6204e4..9d095b36 100644 --- a/UoFiddler.Controls/UoFiddler.Controls.csproj +++ b/UoFiddler.Controls/UoFiddler.Controls.csproj @@ -434,7 +434,7 @@ - + \ No newline at end of file diff --git a/UoFiddler.Controls/UserControls/GumpControl.Designer.cs b/UoFiddler.Controls/UserControls/GumpControl.Designer.cs index 7e45fd4c..b99d5b44 100644 --- a/UoFiddler.Controls/UserControls/GumpControl.Designer.cs +++ b/UoFiddler.Controls/UserControls/GumpControl.Designer.cs @@ -42,7 +42,7 @@ private void InitializeComponent() components = new System.ComponentModel.Container(); System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(GumpControl)); splitContainer1 = new System.Windows.Forms.SplitContainer(); - listBox = new System.Windows.Forms.ListBox(); + listView = new System.Windows.Forms.ListView(); contextMenuStrip = new System.Windows.Forms.ContextMenuStrip(components); showFreeSlotsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); findNextFreeSlotToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); @@ -112,7 +112,7 @@ private void InitializeComponent() // // splitContainer1.Panel1 // - splitContainer1.Panel1.Controls.Add(listBox); + splitContainer1.Panel1.Controls.Add(listView); splitContainer1.Panel1.Controls.Add(filterToolStrip); splitContainer1.Panel1.Controls.Add(topMenuToolStrip); // @@ -125,23 +125,26 @@ private void InitializeComponent() splitContainer1.SplitterWidth = 5; splitContainer1.TabIndex = 0; // - // listBox - // - listBox.ContextMenuStrip = contextMenuStrip; - listBox.Dock = System.Windows.Forms.DockStyle.Fill; - listBox.DrawMode = System.Windows.Forms.DrawMode.OwnerDrawFixed; - listBox.FormattingEnabled = true; - listBox.IntegralHeight = false; - listBox.ItemHeight = 75; - listBox.Location = new System.Drawing.Point(0, 50); - listBox.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - listBox.Name = "listBox"; - listBox.Size = new System.Drawing.Size(289, 380); - listBox.TabIndex = 0; - listBox.DrawItem += ListBox_DrawItem; - listBox.MeasureItem += ListBox_MeasureItem; - listBox.SelectedIndexChanged += ListBox_SelectedIndexChanged; - listBox.KeyUp += Gump_KeyUp; + // listView + // + listView.ContextMenuStrip = contextMenuStrip; + listView.Dock = System.Windows.Forms.DockStyle.Fill; + listView.FullRowSelect = true; + listView.HeaderStyle = System.Windows.Forms.ColumnHeaderStyle.None; + listView.HideSelection = false; + listView.Location = new System.Drawing.Point(0, 50); + listView.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + listView.MultiSelect = false; + listView.Name = "listView"; + listView.OwnerDraw = true; + listView.Size = new System.Drawing.Size(289, 380); + listView.TabIndex = 0; + listView.View = System.Windows.Forms.View.Details; + listView.VirtualMode = true; + listView.DrawItem += ListView_DrawItem; + listView.RetrieveVirtualItem += ListView_RetrieveVirtualItem; + listView.SelectedIndexChanged += ListView_SelectedIndexChanged; + listView.KeyUp += Gump_KeyUp; // // contextMenuStrip // @@ -531,7 +534,7 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripTextBox InsertText; private System.Windows.Forms.ToolStripMenuItem insertToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem jumpToMaleFemale; - private System.Windows.Forms.ListBox listBox; + private System.Windows.Forms.ListView listView; private System.Windows.Forms.PictureBox pictureBox; private System.Windows.Forms.ToolStripButton Preload; private System.ComponentModel.BackgroundWorker PreLoader; diff --git a/UoFiddler.Controls/UserControls/GumpControl.cs b/UoFiddler.Controls/UserControls/GumpControl.cs index 5ddec3b0..8cd8f5c5 100644 --- a/UoFiddler.Controls/UserControls/GumpControl.cs +++ b/UoFiddler.Controls/UserControls/GumpControl.cs @@ -32,6 +32,7 @@ public GumpControl() InitializeComponent(); SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint, true); + ConfigureListView(); if (!Files.CacheData) { Preload.Visible = false; @@ -53,6 +54,24 @@ private sealed record GumpEntry(string Name, string[] Tags); private string _activeNameFilter = string.Empty; private readonly HashSet _activeTagFilters = new(StringComparer.OrdinalIgnoreCase); + /// + /// Gump ids currently listed, ascending. The list is virtual, so this is the only place a row's + /// identity lives - with free slots hidden a row's position is not its id. + /// + private readonly List _ids = new(); + + /// + /// Row height. A list view has no ItemHeight of its own, so it comes from the image list. + /// + private const int _rowHeight = 75; + + /// + /// Row currently selected, or -1. Kept here because + /// does not carry the selection for a virtual owner drawn list view - the item being painted is a + /// fresh one built in , so every row read as selected. + /// + private int _selectedPosition = -1; + private static readonly string[] _layerTags = { "", // 0x00 @@ -82,6 +101,91 @@ private sealed record GumpEntry(string Name, string[] Tags); "leg-armor", // 0x18 }; + /// + /// Sets up the virtual list. It replaced a ListBox because the gump id space (0x12000) is larger + /// than the 16 bit item index a list box exposes through LB_ITEMFROMPOINT and its scroll bar, so + /// with free slots shown every row above 65535 aliased onto a row near the start of the list. + /// + private void ConfigureListView() + { + listView.SmallImageList = new ImageList { ImageSize = new Size(1, _rowHeight) }; + listView.Columns.Add(new ColumnHeader { Width = listView.ClientSize.Width }); + listView.ClientSizeChanged += (_, _) => listView.Columns[0].Width = listView.ClientSize.Width; + + // A row is a 105 pixel thumbnail plus a name and a tag line, so width past that is empty + // background. Keep the pane where the user put it when the form resizes, and clamp it. + splitContainer1.FixedPanel = FixedPanel.Panel1; + splitContainer1.SplitterMoved += (_, _) => ClampListWidth(); + splitContainer1.SizeChanged += (_, _) => ClampListWidth(); + ClampListWidth(); + } + + /// + /// Widest the list pane may get, whether by dragging the splitter or by the form growing. + /// + private const int _maxListWidth = 450; + + private void ClampListWidth() + { + if (splitContainer1.Width <= 0) + { + return; + } + + int max = Math.Min(_maxListWidth, + splitContainer1.Width - splitContainer1.Panel2MinSize - splitContainer1.SplitterWidth); + + // Assigning outside the panels' own bounds throws; leave a pane too small to clamp alone. + if (max < splitContainer1.Panel1MinSize || splitContainer1.SplitterDistance <= max) + { + return; + } + + splitContainer1.SplitterDistance = max; + } + + /// Gump id of the selected row, or -1 when nothing is selected. + private int SelectedGumpId + { + get + { + int position = listView.SelectedIndices.Count > 0 ? listView.SelectedIndices[0] : -1; + + return position >= 0 && position < _ids.Count ? _ids[position] : -1; + } + } + + private void SelectPosition(int position) + { + if (position < 0 || position >= _ids.Count) + { + return; + } + + listView.SelectedIndices.Clear(); + listView.SelectedIndices.Add(position); + _selectedPosition = position; + listView.EnsureVisible(position); + } + + /// + /// Adds an id to the listed set, keeping it ascending, and selects it. Does nothing but select + /// when the id is already listed. + /// + private void InsertId(int id) + { + int position = _ids.BinarySearch(id); + if (position < 0) + { + position = ~position; + _ids.Insert(position, id); + listView.VirtualListSize = _ids.Count; + } + + SelectPosition(position); + listView.Invalidate(); + } + /// /// Reload when loaded (file changed) /// @@ -130,13 +234,14 @@ protected override void OnLoad(EventArgs e) private void PopulateListBox(bool showOnlyValid) { - listBox.BeginUpdate(); - listBox.Items.Clear(); + listView.BeginUpdate(); + listView.SelectedIndices.Clear(); + _selectedPosition = -1; + _ids.Clear(); bool hasNameFilter = _activeNameFilter.Length > 0; bool hasTagFilter = _activeTagFilters.Count > 0; - List cache = new List(); for (int i = 0; i < Gumps.GetCount(); ++i) { if (showOnlyValid && !Gumps.IsValidIndex(i)) @@ -165,16 +270,14 @@ private void PopulateListBox(bool showOnlyValid) } } - cache.Add(i); + _ids.Add(i); } - listBox.Items.AddRange(cache.ToArray()); - listBox.EndUpdate(); + listView.VirtualListSize = _ids.Count; + listView.EndUpdate(); + listView.Invalidate(); - if (listBox.Items.Count > 0) - { - listBox.SelectedIndex = 0; - } + SelectPosition(0); } private void LoadGumpXml() @@ -324,44 +427,22 @@ private void OnGumpChangeEvent(object sender, int index) if (Gumps.IsValidIndex(index)) { - bool done = false; - for (int i = 0; i < listBox.Items.Count; ++i) - { - int j = int.Parse(listBox.Items[i].ToString()); - if (j > index) - { - listBox.Items.Insert(i, index); - listBox.SelectedIndex = i; - done = true; - break; - } - - if (j == index) - { - done = true; - break; - } - } + InsertId(index); - if (!done) - { - listBox.Items.Add(index); - } + return; } - else - { - for (int i = 0; i < listBox.Items.Count; ++i) - { - int j = int.Parse(listBox.Items[i].ToString()); - if (j == index) - { - listBox.Items.RemoveAt(i); - break; - } - } - listBox.Invalidate(); + // Gone. With free slots shown the row stays, it just draws as free. + int position = _ids.BinarySearch(index); + if (position >= 0 && !_showFreeSlots) + { + listView.SelectedIndices.Clear(); + _selectedPosition = -1; + _ids.RemoveAt(position); + listView.VirtualListSize = _ids.Count; } + + listView.Invalidate(); } private void ChangeBackgroundColorToolStripMenuItem_Click(object sender, EventArgs e) @@ -375,16 +456,26 @@ private void ChangeBackgroundColorToolStripMenuItem_Click(object sender, EventAr ControlEvents.FirePreviewBackgroundColorChangeEvent(); } - private void ListBox_DrawItem(object sender, DrawItemEventArgs e) + private void ListView_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e) { - if (e.Index < 0) + // Everything visible is painted in ListView_DrawItem; the text is here so the row still has + // an accessible name and a keyboard type-ahead target. + e.Item = (uint)e.ItemIndex < (uint)_ids.Count + ? new ListViewItem(_ids[e.ItemIndex].ToString()) + : new ListViewItem(string.Empty); + } + + private void ListView_DrawItem(object sender, DrawListViewItemEventArgs e) + { + if ((uint)e.ItemIndex >= (uint)_ids.Count) { return; } Brush fontBrush = Brushes.Gray; - int i = int.Parse(listBox.Items[e.Index].ToString()); + bool isSelected = e.ItemIndex == _selectedPosition; + int i = _ids[e.ItemIndex]; bool hasEntry = _gumpEntries.TryGetValue(i, out GumpEntry entry); if (Gumps.IsValidIndex(i)) @@ -397,7 +488,7 @@ private void ListBox_DrawItem(object sender, DrawItemEventArgs e) int width = bmp.Width > 100 ? 100 : bmp.Width; int height = bmp.Height > thumbMaxH ? thumbMaxH : bmp.Height; - if (listBox.SelectedIndex == e.Index) + if (isSelected) { e.Graphics.FillRectangle(Brushes.LightSteelBlue, e.Bounds.X, e.Bounds.Y, 105, e.Bounds.Height); } @@ -414,7 +505,7 @@ private void ListBox_DrawItem(object sender, DrawItemEventArgs e) } else { - if (listBox.SelectedIndex == e.Index) + if (isSelected) { e.Graphics.FillRectangle(Brushes.LightSteelBlue, e.Bounds.X, e.Bounds.Y, 105, e.Bounds.Height); } @@ -445,19 +536,19 @@ private void ListBox_DrawItem(object sender, DrawItemEventArgs e) } } - private void ListBox_MeasureItem(object sender, MeasureItemEventArgs e) + private void ListView_SelectedIndexChanged(object sender, EventArgs e) { - e.ItemHeight = 75; - } + _selectedPosition = listView.SelectedIndices.Count > 0 ? listView.SelectedIndices[0] : -1; - private void ListBox_SelectedIndexChanged(object sender, EventArgs e) - { - if (listBox.SelectedIndex == -1) + int i = SelectedGumpId; + if (i < 0) { + pictureBox.BackgroundImage = null; + listView.Invalidate(); + return; } - int i = int.Parse(listBox.Items[listBox.SelectedIndex].ToString()); pictureBox.BackColor = Options.PreviewBackgroundColor; if (Gumps.IsValidIndex(i)) { @@ -478,18 +569,18 @@ private void ListBox_SelectedIndexChanged(object sender, EventArgs e) pictureBox.BackgroundImage = null; } - listBox.Invalidate(); + listView.Invalidate(); JumpToMaleFemaleInvalidate(); } private void JumpToMaleFemaleInvalidate() { - if (listBox.SelectedIndex == -1) + int gumpId = SelectedGumpId; + if (gumpId < 0) { return; } - int gumpId = (int)listBox.SelectedItem; if (gumpId >= 50000) { if (gumpId >= 60000) @@ -512,7 +603,7 @@ private void JumpToMaleFemaleInvalidate() private void OnClickReplace(object sender, EventArgs e) { - if (listBox.SelectedItems.Count != 1) + if (SelectedGumpId < 0) { return; } @@ -537,14 +628,14 @@ private void OnClickReplace(object sender, EventArgs e) bitmap = Utils.ConvertBmp(bitmap); } - int i = int.Parse(listBox.Items[listBox.SelectedIndex].ToString()); + int i = SelectedGumpId; Gumps.ReplaceGump(i, bitmap); ControlEvents.FireGumpChangeEvent(this, i); - listBox.Invalidate(); - ListBox_SelectedIndexChanged(this, EventArgs.Empty); + listView.Invalidate(); + ListView_SelectedIndexChanged(this, EventArgs.Empty); Options.ChangedUltimaClass["Gumps"] = true; } @@ -573,7 +664,12 @@ private void OnClickSave(object sender, EventArgs e) private void OnClickRemove(object sender, EventArgs e) { - int i = int.Parse(listBox.Items[listBox.SelectedIndex].ToString()); + int i = SelectedGumpId; + if (i < 0) + { + return; + } + DialogResult result = MessageBox.Show($"Are you sure to remove {i}", "Remove", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2); if (result != DialogResult.Yes) @@ -585,23 +681,36 @@ private void OnClickRemove(object sender, EventArgs e) ControlEvents.FireGumpChangeEvent(this, i); if (!_showFreeSlots) { - listBox.Items.RemoveAt(listBox.SelectedIndex); + int position = _ids.BinarySearch(i); + if (position >= 0) + { + listView.SelectedIndices.Clear(); + _selectedPosition = -1; + _ids.RemoveAt(position); + listView.VirtualListSize = _ids.Count; + } } pictureBox.BackgroundImage = null; - listBox.Invalidate(); + listView.Invalidate(); Options.ChangedUltimaClass["Gumps"] = true; } private void OnClickFindFree(object sender, EventArgs e) { - int id = int.Parse(listBox.Items[listBox.SelectedIndex].ToString()); - ++id; - for (int i = listBox.SelectedIndex + 1; i < listBox.Items.Count; ++i, ++id) + int position = listView.SelectedIndices.Count > 0 ? listView.SelectedIndices[0] : -1; + if (position < 0) { - if (id < int.Parse(listBox.Items[i].ToString())) + return; + } + + int id = _ids[position] + 1; + for (int i = position + 1; i < _ids.Count; ++i, ++id) + { + // A gap in the listed ids is a free slot when free slots are hidden. + if (id < _ids[i]) { - listBox.SelectedIndex = i; + SelectPosition(i); break; } @@ -610,9 +719,9 @@ private void OnClickFindFree(object sender, EventArgs e) continue; } - if (!Gumps.IsValidIndex(int.Parse(listBox.Items[i].ToString()))) + if (!Gumps.IsValidIndex(_ids[i])) { - listBox.SelectedIndex = i; + SelectPosition(i); break; } } @@ -673,38 +782,7 @@ private void OnKeydown_InsertText(object sender, KeyEventArgs e) ControlEvents.FireGumpChangeEvent(this, index); - bool done = false; - for (int i = 0; i < listBox.Items.Count; ++i) - { - int j = int.Parse(listBox.Items[i].ToString()); - if (j > index) - { - listBox.Items.Insert(i, index); - listBox.SelectedIndex = i; - done = true; - break; - } - - if (!_showFreeSlots) - { - continue; - } - - if (j != i) - { - continue; - } - - Search(index); - done = true; - break; - } - - if (!done) - { - listBox.Items.Add(index); - listBox.SelectedIndex = listBox.Items.Count - 1; - } + InsertId(index); Options.ChangedUltimaClass["Gumps"] = true; } @@ -713,30 +791,36 @@ private void OnKeydown_InsertText(object sender, KeyEventArgs e) private void Extract_Image_ClickBmp(object sender, EventArgs e) { - int i = int.Parse(listBox.Items[listBox.SelectedIndex].ToString()); + int i = SelectedGumpId; ExportGumpImage(i, ImageFormat.Bmp); } private void Extract_Image_ClickTiff(object sender, EventArgs e) { - int i = int.Parse(listBox.Items[listBox.SelectedIndex].ToString()); + int i = SelectedGumpId; ExportGumpImage(i, ImageFormat.Tiff); } private void Extract_Image_ClickJpg(object sender, EventArgs e) { - int i = int.Parse(listBox.Items[listBox.SelectedIndex].ToString()); + int i = SelectedGumpId; ExportGumpImage(i, ImageFormat.Jpeg); } private void Extract_Image_ClickPng(object sender, EventArgs e) { - int i = int.Parse(listBox.Items[listBox.SelectedIndex].ToString()); + int i = SelectedGumpId; ExportGumpImage(i, ImageFormat.Png); } private static void ExportGumpImage(int index, ImageFormat imageFormat) { + // The menu entries reach this with the selected id, which is -1 when nothing is selected. + if (index < 0 || !Gumps.IsValidIndex(index)) + { + return; + } + string fileExtension = Utils.GetFileExtensionFor(imageFormat); string fileName = Path.Combine(Options.OutputPath, $"Gump {Utils.FormatExportId(index)}.{fileExtension}"); @@ -788,9 +872,8 @@ private void ExportAllGumps(ImageFormat imageFormat) using (new WaitCursorScope(this)) { - for (int i = 0; i < listBox.Items.Count; ++i) + foreach (int index in _ids) { - int index = int.Parse(listBox.Items[i].ToString()); if (index < 0) { continue; @@ -874,17 +957,17 @@ public static bool HasGumpId(int gumpId) _refMarker.OnLoad(EventArgs.Empty); } - return _refMarker.listBox.Items.Cast().Any(id => (int)id == gumpId); + return _refMarker._ids.BinarySearch(gumpId) >= 0; } private void JumpToMaleFemale_Click(object sender, EventArgs e) { - if (listBox.SelectedIndex == -1) + int gumpId = SelectedGumpId; + if (gumpId < 0) { return; } - int gumpId = (int)listBox.SelectedItem; gumpId = gumpId < 60000 ? (gumpId % 10000) + 60000 : (gumpId % 10000) + 50000; Select(gumpId); @@ -897,21 +980,15 @@ public static bool Search(int graphic) _refMarker.OnLoad(EventArgs.Empty); } - for (int i = 0; i < _refMarker.listBox.Items.Count; ++i) + int position = _refMarker._ids.BinarySearch(graphic); + if (position < 0) { - object id = _refMarker.listBox.Items[i]; - if ((int)id != graphic) - { - continue; - } - - _refMarker.listBox.SelectedIndex = i; - _refMarker.listBox.TopIndex = i; - - return true; + return false; } - return false; + _refMarker.SelectPosition(position); + + return true; } private void Gump_KeyUp(object sender, KeyEventArgs e) @@ -1017,37 +1094,7 @@ private void AddSingleGump(string fileName, int index) ControlEvents.FireGumpChangeEvent(this, index); - bool done = false; - for (int i = 0; i < listBox.Items.Count; ++i) - { - int j = int.Parse(listBox.Items[i].ToString()); - if (j > index) - { - listBox.Items.Insert(i, index); - listBox.SelectedIndex = i; - done = true; - break; - } - - if (!_showFreeSlots) - { - continue; - } - - if (j != i) - { - continue; - } - - done = true; - break; - } - - if (!done) - { - listBox.Items.Add(index); - listBox.SelectedIndex = listBox.Items.Count - 1; - } + InsertId(index); } } diff --git a/UoFiddler.Plugin.Compare/Classes/SecondArt.cs b/UoFiddler.Plugin.Compare/Classes/SecondArt.cs index 9fdca8c2..4994c070 100644 --- a/UoFiddler.Plugin.Compare/Classes/SecondArt.cs +++ b/UoFiddler.Plugin.Compare/Classes/SecondArt.cs @@ -23,9 +23,36 @@ public static void SetFileIndex(string idxPath, string mulPath) public static void SetFileIndex(string idxPath, string mulPath, string uopPath) { - _fileIndex = new SecondFileIndex(idxPath, mulPath, uopPath, 0x14000, ".tga", 0x13FDC, false); + // Build first: a bad UOP throws out of the ctor and leaves the previous index usable. + var newIndex = new SecondFileIndex(idxPath, mulPath, uopPath, 0x14000, ".tga", 0x13FDC, false); + + SecondFileIndex oldIndex = _fileIndex; + Bitmap[] oldCache = _cache; + + _fileIndex = newIndex; _cache = new Bitmap[0x14000]; + _streamBuffer = null; + + // Order matters: the cache hands out its own Bitmap instances, and three tabs park them in + // PictureBox.BackgroundImage. Let the subscribers drop those references before we dispose. FileIndexChanged?.Invoke(); + + oldIndex?.Dispose(); + DisposeCache(oldCache); + } + + private static void DisposeCache(Bitmap[] cache) + { + if (cache == null) + { + return; + } + + for (int i = 0; i < cache.Length; ++i) + { + cache[i]?.Dispose(); + cache[i] = null; + } } public static int GetMaxItemId() @@ -64,7 +91,8 @@ private static ushort GetLegalItemId(int itemId) private static int GetIdxLength() { - return (int)(_fileIndex.IdxLength / 12); + // Reached through the public GetMaxItemId/IsUOAHS, which callers may hit before a load. + return _fileIndex == null ? 0 : (int)(_fileIndex.IdxLength / 12); } public static bool IsUOAHS() @@ -74,6 +102,11 @@ public static bool IsUOAHS() public static bool IsValidStatic(int index) { + if (_fileIndex == null || _cache == null) + { + return false; + } + index = GetLegalItemId(index); index += 0x4000; @@ -105,6 +138,11 @@ public static bool IsValidStatic(int index) public static Bitmap GetStatic(int index) { + if (_fileIndex == null || _cache == null) + { + return null; + } + index = GetLegalItemId(index); index += 0x4000; @@ -154,8 +192,9 @@ private static unsafe Bitmap LoadStatic(Stream stream, int length) _streamBuffer = new byte[length]; } + // Do not close the stream: it is the index's cached handle, shared by every entry, and + // SecondFileIndex.EnsureOpen would have to re-open the file for the next tile. stream.ReadExactly(_streamBuffer, 0, length); - stream.Close(); fixed (byte* data = _streamBuffer) { @@ -220,12 +259,22 @@ private static unsafe Bitmap LoadStatic(Stream stream, int length) public static bool IsValidLand(int index) { + if (_fileIndex == null || _cache == null) + { + return false; + } + index &= 0x3FFF; return _cache[index] != null || _fileIndex.Valid(index, out _, out _); } public static Bitmap GetLand(int index) { + if (_fileIndex == null || _cache == null) + { + return null; + } + index &= 0x3FFF; if (_cache[index] != null) @@ -269,8 +318,8 @@ private static unsafe Bitmap LoadLand(Stream stream, int length) _streamBuffer = new byte[length]; } + // See LoadStatic: the stream belongs to the index and stays open. stream.ReadExactly(_streamBuffer, 0, length); - stream.Close(); fixed (byte* binData = _streamBuffer) { ushort* bdata = (ushort*)binData; diff --git a/UoFiddler.Plugin.Compare/Classes/SecondFileAccessor.cs b/UoFiddler.Plugin.Compare/Classes/SecondFileAccessor.cs index 1e4e445e..7e04b1ef 100644 --- a/UoFiddler.Plugin.Compare/Classes/SecondFileAccessor.cs +++ b/UoFiddler.Plugin.Compare/Classes/SecondFileAccessor.cs @@ -78,23 +78,34 @@ public struct SecondEntry6D : SecondIEntry { public int Lookup { get; set; } public int Length { get; set; } + public int DecompressedLength { get; set; } + + /// + /// High half of . For gumps this is the width, matching the + /// (width << 16 | height) packing in gumpidx.mul. + /// + public int Extra1 { get; set; } - private int _extra1Backing; - private int _extra2Backing; + /// + /// Low half of . For gumps this is the height. + /// + public int Extra2 { get; set; } + /// + /// Packed (Extra1 << 16 | Extra2) view over the two halves, mirroring . + /// Extra1/Extra2 are the only storage: they used to sit beside a separate pair of backing fields + /// that Extra alone wrote to, so a write through one view was invisible to the other. + /// public int Extra { - get => (_extra1Backing << 16) | _extra2Backing; + get => (Extra1 << 16) | (Extra2 & 0xFFFF); set { - _extra1Backing = (value >> 16) & 0xFFFF; - _extra2Backing = value & 0xFFFF; + Extra1 = (value >> 16) & 0xFFFF; + Extra2 = value & 0xFFFF; } } - public int DecompressedLength { get; set; } - public int Extra1 { get; set; } - public int Extra2 { get; set; } public SecondCompressionFlag Flag { get; set; } } @@ -171,7 +182,9 @@ public SecondUopFileAccessor(string path, string uopEntryExtension, int length, var fileInfo = new FileInfo(path); string uopPattern = fileInfo.Name.Replace(fileInfo.Extension, "").ToLowerInvariant(); - using (var br = new BinaryReader(Stream, System.Text.Encoding.Default, leaveOpen: true)) + // leaveOpen: this ctor caches Stream on the instance for later + // SecondFileIndex.Seek calls; disposing the BinaryReader must not close it. + using (var br = new BinaryReader(Stream, System.Text.Encoding.UTF8, leaveOpen: true)) { br.BaseStream.Seek(0, SeekOrigin.Begin); @@ -196,12 +209,15 @@ public SecondUopFileAccessor(string path, string uopEntryExtension, int length, br.BaseStream.Seek(nextBlock, SeekOrigin.Begin); - // UOP entries are sparse; pre-mark all as invalid. + // UOP entries are sparse; pre-mark all as invalid. Extra1/Extra2 are set directly + // rather than through Extra, whose setter masks each half to 16 bits - the readers + // test Extra1 == -1, and Extra still reads back as -1 either way. for (var i = 0; i < Index.Length; i++) { Index[i].Lookup = -1; Index[i].Length = -1; - Index[i].Extra = -1; + Index[i].Extra1 = -1; + Index[i].Extra2 = -1; } do @@ -229,14 +245,18 @@ public SecondUopFileAccessor(string path, string uopEntryExtension, int length, continue; } - if (idx < 0 || idx > Index.Length) + if (idx < 0 || idx >= Index.Length) { throw new IndexOutOfRangeException("hashes dictionary and files collection have different count of entries!"); } offset += headerLength; - if (hasExtra && flag != 3) + // The width/height prefix can only be read straight off the stream when the payload + // is stored. For anything compressed those first eight bytes belong to the zlib (or + // zlib+Mythic) stream and the dimensions come out of the decompressed payload instead + // - see SecondGump.ReadEntryPayload. + if (hasExtra && (SecondCompressionFlag)flag == SecondCompressionFlag.None) { long curPos = br.BaseStream.Position; br.BaseStream.Seek(offset, SeekOrigin.Begin); diff --git a/UoFiddler.Plugin.Compare/Classes/SecondFileIndex.cs b/UoFiddler.Plugin.Compare/Classes/SecondFileIndex.cs index 1efd6746..613de8a2 100644 --- a/UoFiddler.Plugin.Compare/Classes/SecondFileIndex.cs +++ b/UoFiddler.Plugin.Compare/Classes/SecondFileIndex.cs @@ -9,19 +9,69 @@ * ***************************************************************************/ +using System; using System.IO; +using System.Threading; namespace UoFiddler.Plugin.Compare.Classes { - public sealed class SecondFileIndex + public sealed class SecondFileIndex : IDisposable { private readonly string _mulPath; + private readonly Lock _entryWriteLock = new(); public SecondIFileAccessor FileAccessor { get; } public long IdxLength => FileAccessor?.IdxLength ?? 0; public int IndexLength => FileAccessor?.IndexLength ?? 0; + /// + /// Entry accessor. The accessor itself does the SecondEntry3D / SecondEntry6D cast, because + /// only it knows which one it stores. + /// + public SecondIEntry this[int index] + { + get => FileAccessor?[index]; + set + { + if (FileAccessor != null) + { + FileAccessor[index] = value; + } + } + } + + /// + /// Persists dimensions discovered by actually decoding an entry back into the index, so a + /// later lookup does not have to decode it again. + /// + /// + /// and the indexer hand out a boxed copy of + /// the entry, so assigning to entry.Extra1 on that copy is discarded - write-back has to + /// go through here. Callers only ever pass values read out of the payload, so the lock is only + /// there to stop two threads tearing the struct mid-write. + /// + public void CacheDimensions(int index, int width, int height) + { + if (FileAccessor == null || index < 0 || index >= FileAccessor.IndexLength) + { + return; + } + + lock (_entryWriteLock) + { + SecondIEntry entry = FileAccessor[index]; + if (entry == null) + { + return; + } + + entry.Extra1 = width; + entry.Extra2 = height; + FileAccessor[index] = entry; + } + } + public SecondFileIndex(string idxFile, string mulFile, int length) : this(idxFile, mulFile, null, length, ".dat", -1, false) { @@ -76,27 +126,21 @@ public Stream Seek(int index, out int length, out int extra) return null; } - if (FileAccessor.Stream?.CanRead != true || !FileAccessor.Stream.CanSeek) - { - FileAccessor.Stream = _mulPath == null - ? null - : new FileStream(_mulPath, FileMode.Open, FileAccess.Read, FileShare.Read); - } - - if (FileAccessor.Stream == null) + FileStream stream = EnsureOpen(); + if (stream == null) { length = extra = 0; return null; } - if (FileAccessor.Stream.Length < e.Lookup) + if (stream.Length < e.Lookup) { length = extra = 0; return null; } - FileAccessor.Stream.Seek(e.Lookup, SeekOrigin.Begin); - return FileAccessor.Stream; + stream.Seek(e.Lookup, SeekOrigin.Begin); + return stream; } public Stream Seek(int index, ref SecondIEntry entry) @@ -126,25 +170,57 @@ public Stream Seek(int index, ref SecondIEntry entry) return null; } - if (FileAccessor.Stream?.CanRead != true || !FileAccessor.Stream.CanSeek) + FileStream stream = EnsureOpen(); + if (stream == null) { - FileAccessor.Stream = _mulPath == null - ? null - : new FileStream(_mulPath, FileMode.Open, FileAccess.Read, FileShare.Read); + return null; } - if (FileAccessor.Stream == null) + if (stream.Length < e.Lookup) { return null; } - if (FileAccessor.Stream.Length < e.Lookup) + stream.Seek(e.Lookup, SeekOrigin.Begin); + return stream; + } + + /// + /// Returns the cached FileAccessor.Stream, re-opening it only when genuinely required (null or + /// disposed). Replaces the per-call CanRead/CanSeek probe that used to be duplicated in every + /// Seek/Valid overload. + /// + private FileStream EnsureOpen() + { + FileStream stream = FileAccessor.Stream; + if (stream != null && stream.CanRead && stream.CanSeek) + { + return stream; + } + + if (_mulPath == null) { + FileAccessor.Stream = null; return null; } - FileAccessor.Stream.Seek(e.Lookup, SeekOrigin.Begin); - return FileAccessor.Stream; + stream = new FileStream(_mulPath, FileMode.Open, FileAccess.Read, FileShare.Read); + FileAccessor.Stream = stream; + return stream; + } + + /// + /// Releases the underlying .mul / .uop FileStream so the next access re-opens fresh. Additive - + /// a stale reference to a disposed index keeps working because handles + /// a disposed FileAccessor.Stream gracefully. + /// + public void Dispose() + { + FileAccessor?.Stream?.Dispose(); + if (FileAccessor != null) + { + FileAccessor.Stream = null; + } } public bool Valid(int index, out int length, out int extra) @@ -177,12 +253,14 @@ public bool Valid(int index, out int length, out int extra) return false; } - if (FileAccessor.Stream?.CanRead != true || !FileAccessor.Stream.CanSeek) + FileStream stream = EnsureOpen(); + if (stream == null) { - FileAccessor.Stream = new FileStream(_mulPath, FileMode.Open, FileAccess.Read, FileShare.Read); + length = extra = 0; + return false; } - if (FileAccessor.Stream.Length < e.Lookup) + if (stream.Length < e.Lookup) { length = extra = 0; return false; diff --git a/UoFiddler.Plugin.Compare/Classes/SecondGump.cs b/UoFiddler.Plugin.Compare/Classes/SecondGump.cs index 873a8687..632a8d42 100644 --- a/UoFiddler.Plugin.Compare/Classes/SecondGump.cs +++ b/UoFiddler.Plugin.Compare/Classes/SecondGump.cs @@ -14,6 +14,7 @@ using System.Drawing; using System.Drawing.Imaging; using System.IO; +using System.IO.Compression; using Ultima; using Ultima.Helpers; @@ -21,11 +22,42 @@ namespace UoFiddler.Plugin.Compare.Classes { internal static class SecondGump { + /// + /// Gump id ceiling, matching Ultima.Gumps. Ids 69971..69985 ship in 7.0.98.1 and later, + /// above the old 0xFFFF bound - with that bound no UOP name hash was ever generated for them, + /// so they did not exist as far as this reader was concerned. + /// + private const int _maxGumpIndex = 0x12000; + private static SecondFileIndex _fileIndex; - private static Bitmap[] _cache = new Bitmap[0x10000]; + private static Bitmap[] _cache = Array.Empty(); private static byte[] _streamBuffer; + // Authoritative id range for this index; 0 until a second client is loaded. + private static int _indexLength; + + private const byte _contentUnknown = 0; + private const byte _contentEmpty = 1; + private const byte _contentPresent = 2; + + /// + /// Per id answer to "does this entry contain a drawable gump", filled in on demand. + /// + /// + /// A stored entry carries its real width/height in the index; a compressed one does not, so + /// parks the "dimensions unknown" sentinel 0x0FFFFFFF there, which + /// reads back as 0x0FFF x 0xFFFF - non zero, therefore "valid". EA ships 0x0 placeholder gumps + /// (29, 33, 34, 37, 47, 49, 98 ...), so on a compressed client those listed and failed to draw. + /// + private static byte[] _contentState = Array.Empty(); + + /// + /// Compressed bytes read when probing an entry for content - enough to inflate its first few + /// output bytes, rather than the whole entry. + /// + private const int _contentPeekWindow = 4096; + public static void SetFileIndex(string idxPath, string mulPath) { SetFileIndex(idxPath, mulPath, null); @@ -33,8 +65,44 @@ public static void SetFileIndex(string idxPath, string mulPath) public static void SetFileIndex(string idxPath, string mulPath, string uopPath) { - _fileIndex = new SecondFileIndex(idxPath, mulPath, uopPath, 0xFFFF, ".tga", -1, true); - _cache = new Bitmap[0x10000]; + // Build first: a bad UOP throws out of the ctor and leaves the previous index usable. + var newIndex = new SecondFileIndex(idxPath, mulPath, uopPath, _maxGumpIndex, ".tga", -1, true); + + SecondFileIndex oldIndex = _fileIndex; + Bitmap[] oldCache = _cache; + + _fileIndex = newIndex; + _indexLength = newIndex.IndexLength; + _cache = new Bitmap[_indexLength]; + _contentState = new byte[_indexLength]; + _streamBuffer = null; + + // Callers must have dropped any bitmap they still hold (see CompareGumpControl.Load_Click) + // before we get here - these instances are the cached ones, not copies. + oldIndex?.Dispose(); + DisposeCache(oldCache); + } + + private static void DisposeCache(Bitmap[] cache) + { + if (cache == null) + { + return; + } + + for (int i = 0; i < cache.Length; ++i) + { + cache[i]?.Dispose(); + cache[i] = null; + } + } + + /// + /// Number of gump ids this index covers, 0 when no second client is loaded. + /// + public static int GetCount() + { + return _indexLength; } public static bool IsValidIndex(int index) @@ -44,7 +112,7 @@ public static bool IsValidIndex(int index) return false; } - if (index < 0 || index >= _cache.Length) + if (index < 0 || index > _indexLength - 1) { return false; } @@ -54,27 +122,147 @@ public static bool IsValidIndex(int index) return true; } - SecondIEntry entry = null; - if (_fileIndex.Seek(index, ref entry) == null || entry == null) + if (!_fileIndex.Valid(index, out int _, out int extra)) { return false; } - // Compressed UOP entries don't carry width/height in the idx — the - // dimensions live inside the decompressed payload. Defer the check. - if (entry.Flag >= SecondCompressionFlag.Zlib) + if (extra == -1) + { + return false; + } + + byte state = _contentState[index]; + if (state != _contentUnknown) + { + return state == _contentPresent; + } + + return ProbeContent(index, extra); + } + + /// + /// Works out once, and remembers, whether an entry actually holds a drawable gump. + /// See for why the index alone cannot answer this. + /// + /// + /// Mirrors Ultima.Gumps.ProbeContent minus the verdata branch - this reader never applies + /// verdata patches, so an entry's length high bit is never set. Keep the two in sync. + /// + private static bool ProbeContent(int index, int packedExtra) + { + SecondIEntry entry = _fileIndex[index]; + if (entry == null || entry.Lookup < 0) + { + _contentState[index] = _contentEmpty; + return false; + } + + // The index can answer for stored entries. For zlib it still can: the payload is the eight + // byte width/height header plus pixels, so a declared length of eight or less is a 0x0 gump. + // Mythic cannot - there DecompressedLength is the inner stream length. + if (entry.Flag == SecondCompressionFlag.None) + { + bool stored = ((packedExtra >> 16) & 0xFFFF) > 0 && (packedExtra & 0xFFFF) > 0; + _contentState[index] = stored ? _contentPresent : _contentEmpty; + return stored; + } + + if (entry.Flag == SecondCompressionFlag.Zlib && entry.DecompressedLength <= 8) + { + _contentState[index] = _contentEmpty; + return false; + } + + Stream stream = _fileIndex.Seek(index, ref entry); + if (stream == null) { - return entry.Length > 0; + return false; } - if (entry.Extra == -1) + bool? present = CompressedEntryHasContent(stream, entry, index); + if (present == null) { + // Unreadable, not empty: leave the state unknown so a later call retries. return false; } - int width = entry.Extra1; - int height = entry.Extra2; - return width > 0 && height > 0; + _contentState[index] = present.Value ? _contentPresent : _contentEmpty; + + return present.Value; + } + + /// + /// Inflates just the head of a compressed entry to find out whether it has any pixels. Null means + /// the entry could not be read, which is not the same answer as an empty gump and is not cached. + /// + /// + /// Line for line the same logic as Ultima.Gumps.CompressedEntryHasContent, which is private + /// and typed against Ultima.IEntry. Keep the two in sync: if the two sides disagree about + /// which ids are valid, the compare tabs report differences that do not exist. + /// + private static bool? CompressedEntryHasContent(Stream stream, SecondIEntry entry, int index) + { + int length = entry.Length & 0x7FFFFFFF; + if (length <= 0) + { + return false; + } + + int toRead = Math.Min(length, _contentPeekWindow); + byte[] rented = ArrayPool.Shared.Rent(toRead); + + try + { + stream.ReadExactly(rented, 0, toRead); + + using var compressed = new MemoryStream(rented, 0, toRead, writable: false); + using var zlib = new ZLibStream(compressed, CompressionMode.Decompress); + + if (entry.Flag == SecondCompressionFlag.Mythic) + { + // Layered zlib(mythic(payload)). The Mythic header carries its own decompressed length, + // so a payload of only the eight byte width/height header is a 0x0 gump. + var mythicHeader = new byte[4]; + zlib.ReadExactly(mythicHeader, 0, mythicHeader.Length); + + return MythicDecompress.PeekDecompressedLength(mythicHeader) > 8; + } + + var head = new byte[8]; + zlib.ReadExactly(head, 0, head.Length); + + int width = head[0] | (head[1] << 8) | (head[2] << 16) | (head[3] << 24); + int height = head[4] | (head[5] << 8) | (head[6] << 16) | (head[7] << 24); + + if (width <= 0 || height <= 0) + { + return false; + } + + _fileIndex.CacheDimensions(index, width, height); + + return true; + } + catch (EndOfStreamException) + { + // Runs off the end of the file - a permanent property of it. + return false; + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException) + { + // Locked or gone. Say nothing rather than remember a wrong answer. + return null; + } + catch (Exception) + { + // Corrupt payload - nothing drawable either way. + return false; + } + finally + { + ArrayPool.Shared.Return(rented); + } } public static byte[] GetRawGump(int index, out int width, out int height) @@ -87,6 +275,11 @@ public static byte[] GetRawGump(int index, out int width, out int height) return null; } + if (index < 0 || index >= _indexLength) + { + return null; + } + SecondIEntry entry = null; Stream stream = _fileIndex.Seek(index, ref entry); if (stream == null || entry == null) @@ -94,7 +287,12 @@ public static byte[] GetRawGump(int index, out int width, out int height) return null; } - int payloadLength = ReadEntryPayload(stream, entry, out width, out height); + if (entry.Extra1 == -1) + { + return null; + } + + int payloadLength = ReadEntryPayload(index, stream, entry, out width, out height); if (payloadLength <= 0 || width <= 0 || height <= 0) { return null; @@ -113,7 +311,7 @@ public static unsafe Bitmap GetGump(int index) return null; } - if (index < 0 || index >= _cache.Length) + if (index < 0 || index >= _indexLength) { return null; } @@ -130,7 +328,7 @@ public static unsafe Bitmap GetGump(int index) return null; } - int payloadLength = ReadEntryPayload(stream, entry, out int width, out int height); + int payloadLength = ReadEntryPayload(index, stream, entry, out int width, out int height); if (payloadLength <= 0 || width <= 0 || height <= 0) { return null; @@ -181,9 +379,9 @@ public static unsafe Bitmap GetGump(int index) /// Reads the pixel-RLE payload for a gump entry into , /// transparently handling uncompressed MUL/UOP and zlib/Mythic-compressed UOP layouts. /// Returns the number of valid bytes at the start of . - private static int ReadEntryPayload(Stream stream, SecondIEntry entry, out int width, out int height) + private static int ReadEntryPayload(int index, Stream stream, SecondIEntry entry, out int width, out int height) { - int length = entry.Length; + int length = entry.Length & 0x7FFFFFFF; if (length <= 0) { width = height = -1; @@ -247,8 +445,17 @@ private static int ReadEntryPayload(Stream stream, SecondIEntry entry, out int w width = (payload[3] << 24) | (payload[2] << 16) | (payload[1] << 8) | payload[0]; height = (payload[7] << 24) | (payload[6] << 16) | (payload[5] << 8) | payload[4]; - entry.Extra1 = width; - entry.Extra2 = height; + + if (width <= 0 || height <= 0) + { + _contentState[index] = _contentEmpty; + return 0; + } + + // Write-back has to go through the index: `entry` is a boxed copy, so assigning to + // entry.Extra1 here would be discarded. + _fileIndex.CacheDimensions(index, width, height); + _contentState[index] = _contentPresent; int rleLen = payloadLength - 8; if (_streamBuffer.Length < rleLen) diff --git a/UoFiddler.Plugin.Compare/Classes/SecondTexture.cs b/UoFiddler.Plugin.Compare/Classes/SecondTexture.cs index 3cd0e67f..958169f7 100644 --- a/UoFiddler.Plugin.Compare/Classes/SecondTexture.cs +++ b/UoFiddler.Plugin.Compare/Classes/SecondTexture.cs @@ -13,8 +13,28 @@ internal static class SecondTexture public static void SetFileIndex(string idxPath, string mulPath) { - _fileIndex = new SecondFileIndex(idxPath, mulPath, 0x4000); + // Build first so a failure leaves the previous index usable. + var newIndex = new SecondFileIndex(idxPath, mulPath, 0x4000); + + SecondFileIndex oldIndex = _fileIndex; + Bitmap[] oldCache = _cache; + + _fileIndex = newIndex; _cache = new Bitmap[0x4000]; + _streamBuffer = null; + + // The caller must have dropped any bitmap it still holds (see CompareTextureControl) before + // we get here - GetTexture hands out the cached instance, not a copy. + oldIndex?.Dispose(); + + if (oldCache != null) + { + for (int i = 0; i < oldCache.Length; ++i) + { + oldCache[i]?.Dispose(); + oldCache[i] = null; + } + } } // public static int GetIdxLength() diff --git a/UoFiddler.Plugin.Compare/UserControls/CompareGumpControl.cs b/UoFiddler.Plugin.Compare/UserControls/CompareGumpControl.cs index b615b313..a66a33f5 100644 --- a/UoFiddler.Plugin.Compare/UserControls/CompareGumpControl.cs +++ b/UoFiddler.Plugin.Compare/UserControls/CompareGumpControl.cs @@ -38,6 +38,12 @@ public CompareGumpControl() private bool _syncingSelection; private bool _loaded; + /// + /// Number of gump ids the two panes cover. Driven by the loaded clients rather than a literal: + /// Gumps reaches 0x12000 now, and ids 69971..69985 ship in 7.0.98.1 and later. + /// + private int _idRange; + private void OnLoad(object sender, EventArgs e) { using (new WaitCursorScope(this)) @@ -47,11 +53,9 @@ private void OnLoad(object sender, EventArgs e) ConfigureTileView(tileView1); ConfigureTileView(tileView2); - _displayIndices.Clear(); - for (int i = 0; i < 0x10000; i++) - { - _displayIndices.Add(i); - } + // Reload() re-enters OnLoad, so take the max of both sides - otherwise a range already + // extended by a larger second client would shrink back. + RebuildDisplayIndices(); tileView1.VirtualListSize = _displayIndices.Count; tileView2.VirtualListSize = 0; @@ -332,15 +336,46 @@ private void Load_Click(object sender, EventArgs e) using (new WaitCursorScope(this)) { + // SetFileIndex disposes the outgoing bitmap cache, and this box holds one of its + // instances (GetGump returns the cached bitmap, not a copy). + pictureBox2.BackgroundImage = null; + SecondGump.SetFileIndex(resolvedIdx, resolvedMul, resolvedUop); LoadSecond(); } } + /// + /// Fills with every id both clients can cover. + /// + private void RebuildDisplayIndices() + { + _idRange = Math.Max(Gumps.GetCount(), SecondGump.GetCount()); + + _displayIndices.Clear(); + for (int i = 0; i < _idRange; i++) + { + _displayIndices.Add(i); + } + } + private void LoadSecond() { _compare.Clear(); + + // Rebuild rather than extend: the list may currently hold a filtered "differences only" + // set, and a second client with a larger id space widens the range for both panes. + RebuildDisplayIndices(); + + tileView1.VirtualListSize = _displayIndices.Count; tileView2.VirtualListSize = _displayIndices.Count; + + if (checkBox1.Checked) + { + // Re-apply the filter against the client that was just loaded. + ShowDiff_OnClick(this, EventArgs.Empty); + } + tileView1.Invalidate(); } @@ -395,7 +430,7 @@ private void ShowDiff_OnClick(object sender, EventArgs e) _displayIndices.Clear(); if (checkBox1.Checked) { - for (int i = 0; i < 0x10000; i++) + for (int i = 0; i < _idRange; i++) { if (!Compare(i)) { @@ -405,7 +440,7 @@ private void ShowDiff_OnClick(object sender, EventArgs e) } else { - for (int i = 0; i < 0x10000; i++) + for (int i = 0; i < _idRange; i++) { _displayIndices.Add(i); } diff --git a/UoFiddler.Plugin.Compare/UserControls/CompareItemControl.cs b/UoFiddler.Plugin.Compare/UserControls/CompareItemControl.cs index 4263f5eb..b0bad495 100644 --- a/UoFiddler.Plugin.Compare/UserControls/CompareItemControl.cs +++ b/UoFiddler.Plugin.Compare/UserControls/CompareItemControl.cs @@ -145,6 +145,11 @@ private void OnSecondArtChanged() return; } + // Raised before SecondArt disposes the outgoing bitmap cache - drop the instance this box + // holds (GetStatic returns the cached bitmap, not a copy). Also reached when another tab, + // e.g. Compare TileData, re-points the shared SecondArt index. + pictureBoxSec.BackgroundImage = null; + _compare.Clear(); tileViewOrg.Invalidate(); tileViewSec.Invalidate(); diff --git a/UoFiddler.Plugin.Compare/UserControls/CompareLandControl.cs b/UoFiddler.Plugin.Compare/UserControls/CompareLandControl.cs index 3f21a7c8..abc4d277 100644 --- a/UoFiddler.Plugin.Compare/UserControls/CompareLandControl.cs +++ b/UoFiddler.Plugin.Compare/UserControls/CompareLandControl.cs @@ -144,6 +144,11 @@ private void OnSecondArtChanged() return; } + // Raised before SecondArt disposes the outgoing bitmap cache - drop the instance this box + // holds (GetLand returns the cached bitmap, not a copy). Also reached when another tab, + // e.g. Compare TileData, re-points the shared SecondArt index. + pictureBoxSec.BackgroundImage = null; + _compare.Clear(); tileViewOrg.Invalidate(); tileViewSec.Invalidate(); diff --git a/UoFiddler.Plugin.Compare/UserControls/CompareMapControl.cs b/UoFiddler.Plugin.Compare/UserControls/CompareMapControl.cs index 09541acd..476567be 100644 --- a/UoFiddler.Plugin.Compare/UserControls/CompareMapControl.cs +++ b/UoFiddler.Plugin.Compare/UserControls/CompareMapControl.cs @@ -645,12 +645,33 @@ private void ChangeMap() string path = toolStripTextBox1.Text; - if (Directory.Exists(path)) + try { - _currentMap = Map.Custom = new Map(path, _originalMap.FileIndex, _currentMapId, _originalMap.Width, _originalMap.Height); + if (Directory.Exists(path)) + { + _currentMap = Map.Custom = new Map(path, _originalMap.FileIndex, _currentMapId, _originalMap.Width, _originalMap.Height); + } + + // The map files are not touched by the Map ctor - TileMatrix is built lazily and only + // parses the .uop on the first block read, which happens here. A compressed map UOP + // throws NotSupportedException from that parse, and every later access rethrows. + CalculateDiffs(); } + catch (Exception ex) when (ex is NotSupportedException or IOException or ArgumentException + or IndexOutOfRangeException) + { + // Leave nothing half-loaded behind: OnPaint and OnMouseMove would re-enter TileMatrix + // and throw again on the UI thread. + _currentMap = Map.Custom = null; + _diffMasks = null; + _diffWidthBlocks = 0; + _diffHeightBlocks = 0; - CalculateDiffs(); + showMap1ToolStripMenuItem.Checked = true; + showMap2ToolStripMenuItem.Checked = false; + + MessageBox.Show(ex.Message, "Map could not be loaded", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } pictureBox.Invalidate(); } diff --git a/UoFiddler.Plugin.Compare/UserControls/CompareTextureControl.cs b/UoFiddler.Plugin.Compare/UserControls/CompareTextureControl.cs index eefaf6d8..fe33ee5d 100644 --- a/UoFiddler.Plugin.Compare/UserControls/CompareTextureControl.cs +++ b/UoFiddler.Plugin.Compare/UserControls/CompareTextureControl.cs @@ -259,6 +259,10 @@ private void OnClickLoadSecond(object sender, EventArgs e) return; } + // SetFileIndex disposes the outgoing bitmap cache, and this box holds one of its + // instances (GetTexture returns the cached bitmap, not a copy). + pictureBoxSec.BackgroundImage = null; + SecondTexture.SetFileIndex(file2, file); LoadSecond(); } diff --git a/UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs b/UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs index 6c7c217c..aa1039a6 100644 --- a/UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs +++ b/UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.IO; using System.IO.Compression; +using Microsoft.Extensions.Logging; using Ultima; using Ultima.Helpers; +using UoFiddler.Controls.Classes; namespace UoFiddler.Plugin.UopPacker.Classes { @@ -47,28 +49,184 @@ private static BinaryWriter OpenOutput(string path) : new BinaryWriter(new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)); } - // Identifier for "build/multicollection/housing.bin" inside MultiCollection.uop. - private const ulong _housingBinIdentifier = 0x126D1E99DDEDEE0A; + /// + /// Tiles whose multi.mul flags/extra carry bits the uop visibility word cannot represent. + /// Thread static so parallel conversions do not mix counts. + /// + [ThreadStatic] + private static int _unrepresentableMultiFlagTiles; + + /// + /// Idx rows dropped because they carry no data. + /// + [ThreadStatic] + private static int _emptyIdxEntriesSkipped; + + /// + /// Idx rows dropped because they point outside the mul. + /// + [ThreadStatic] + private static int _outOfRangeIdxEntriesSkipped; + + /// + /// Identifier for "build/multicollection/housing.bin" inside MultiCollection.uop + /// (0x126D1E99DDEDEE0A). + /// + private static readonly ulong _housingBinIdentifier = UopUtils.HashFileName(_housingBinEntryName); + + private const string _housingBinEntryName = "build/multicollection/housing.bin"; + + /// + /// Bytes of map terrain per map*LegacyMUL.uop entry: 4096 blocks of 196 bytes. Every shipped map + /// UOP uses it, so a facet's last entry runs past the end of the mul by up to one chunk. + /// + private const int _mapChunkSize = 0xC4000; // Sentinel Id used to mark a synthetic entry that should be written from housing.bin. private const int _housingBinSentinelId = -1; + /// + /// zlib level used for MultiCollection.uop entries, chosen to land as close as possible to the + /// size the client's own packer produced. + /// + /// + /// The shipped file's 872 entries total 522 746 compressed bytes. .NET does not use stock zlib + /// (it ships zlib-ng), so its level mapping is not monotonic and does not reproduce stock zlib + /// byte for byte. Re-compressing those payloads through this runtime measures: + /// level 6 / 546 505 (+4.5%), level 9 / + /// 538 500 (+3.0%), level 8 525 894 (+0.6%), + /// and level 7 523 601 (+0.2%) - the closest available. Any level produces a valid file; this + /// only affects size, so it is safe to revisit if a future runtime shifts the mapping. + /// + private const int _multiCollectionZlibLevel = 7; + // // MUL -> UOP // - public static void ToUop(string inFile, string inFileIdx, string outFile, FileType type, int typeIndex, CompressionFlag compressionFlag = CompressionFlag.None, string housingBinFile = "", IProgress progress = null) + public static void ToUop(string inFile, string inFileIdx, string outFile, FileType type, int typeIndex, CompressionFlag compressionFlag = CompressionFlag.None, string housingBinFile = "", IProgress progress = null, string componentsFile = "") { - // Same for all UOP files - const long firstTable = 0x200; - const int tableSize = 0x64; + if (type == FileType.MultiCollection) + { + if (compressionFlag == CompressionFlag.Mythic) + { + throw new ArgumentException( + "MultiCollection.uop does not support Mythic compression - the client only accepts stored (0) or zlib (1). Use Zlib.", + nameof(compressionFlag)); + } -#pragma warning disable 162 - // Sanity, in case firstTable is customized by you! - if (firstTable < 0x28) + if (string.IsNullOrWhiteSpace(housingBinFile) || !File.Exists(housingBinFile)) + { + throw new FileNotFoundException( + "MultiCollection.uop must contain build/multicollection/housing.bin (the custom housing piece catalog). " + + "Extract it from the original UOP first and pass it to the packer.", + string.IsNullOrWhiteSpace(housingBinFile) ? "housing.bin" : housingBinFile); + } + } + + MultiComponentSidecar.Table componentTable = type == FileType.MultiCollection + ? MultiComponentSidecar.Load(MultiComponentSidecar.ResolvePath(inFile, componentsFile)) + : null; + + if (type == FileType.MultiCollection && componentTable == null) + { + // Not fatal - a shard may have no component ids. The UI confirms first; this covers other callers. + AppLog.For(typeof(LegacyMulFileConverter)).LogWarning( + "No multi component sidecar at {Path} - every tile in {OutFile} will be written with zero " + + "component ids, so boats lose their tiller man, hatch and planks and customisable houses lose their doors.", + MultiComponentSidecar.ResolvePath(inFile, componentsFile), outFile); + } + + _unrepresentableMultiFlagTiles = 0; + _emptyIdxEntriesSkipped = 0; + _outOfRangeIdxEntriesSkipped = 0; + + try { - throw new Exception("At least 0x28 bytes are needed for the header."); + WriteUop(inFile, inFileIdx, outFile, type, typeIndex, compressionFlag, housingBinFile, progress, componentTable); } -#pragma warning restore 162 + catch + { + // Never leave a half written UOP behind - it would look like a usable file. + TryDelete(outFile); + throw; + } + + ReportComponentSidecarProblems(componentTable); + + if (_emptyIdxEntriesSkipped > 0) + { + AppLog.For(typeof(LegacyMulFileConverter)).LogWarning( + "{Count} rows in {IdxFile} have a valid offset but a zero length. A zero byte entry is not a " + + "usable asset, so those ids were left out of {OutFile} - unpacking it writes them back as the " + + "-1 unused sentinel the client itself uses.", + _emptyIdxEntriesSkipped, inFileIdx, outFile); + } + + if (_outOfRangeIdxEntriesSkipped > 0) + { + AppLog.For(typeof(LegacyMulFileConverter)).LogWarning( + "{Count} rows in {IdxFile} point past the end of {InFile}. Those ids were left out of {OutFile} " + + "rather than packed from truncated data.", + _outOfRangeIdxEntriesSkipped, inFileIdx, inFile, outFile); + } + + if (_unrepresentableMultiFlagTiles > 0) + { + AppLog.For(typeof(LegacyMulFileConverter)).LogWarning( + "{Count} tiles in {InFile} carry multi.mul flag or extra bits outside the 0/1 range EA uses. " + + "MultiCollection.uop stores a single 16 bit visibility word, so only the visible and 0x0100 bits " + + "survive and the remaining bits were dropped.", + _unrepresentableMultiFlagTiles, inFile); + } + } + + private static void TryDelete(string path) + { + try + { + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException) + { + // Nothing useful to do; the original failure is the one that matters. + } + catch (UnauthorizedAccessException) + { + // As above. + } + } + + private static void WriteUop(string inFile, string inFileIdx, string outFile, FileType type, int typeIndex, CompressionFlag compressionFlag, string housingBinFile, IProgress progress, MultiComponentSidecar.Table componentTable) + { + /* + * The shipped client files come in two shapes, and which shape a type uses depends on the client + * build rather than on the type alone. Measured over ten installs from 6.0.1.10 to the current + * live client: + * + * version 4, 100 entries per block, first block right behind the 0x28 byte header, every entry + * prefixed by a 12 byte header - MultiCollection and tileart in every build that has them, + * sound from 7.0.65.4 on, gumpart from 7.0.114.4 on. + * + * version 5, 1000 entries per block, a large gap before the first block, entry headers of + * 135..137 bytes whose last 128 bytes are high entropy (a signature block we cannot reproduce) + * - art and maps in every build, and sound/gumpart in the older ones. + * + * We target the newest client's shape. The declared block capacity has to agree with the blocks + * actually written. + */ + bool version4Layout = type == FileType.GumpartLegacyMul + || type == FileType.SoundLegacyMul + || type == FileType.MultiCollection; + + int tableSize = version4Layout ? 0x64 : 0x3E8; + long firstTable = version4Layout ? 0x28 : 0x200; + + // Stamped once per file, not per entry, so a repack of the same input is byte identical. The + // shipped files vary it per entry (a build machine timestamp), but nothing reads it back. + long entryHeaderTimestamp = DateTime.UtcNow.ToFileTimeUtc(); using (BinaryReader reader = OpenInput(inFile)) using (BinaryReader readerIdx = OpenInput(inFileIdx)) @@ -78,9 +236,9 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy if (type == FileType.MapLegacyMul) { - // No IDX file, just group the data into 0xC4000 long chunks + // No IDX file, just group the data into _mapChunkSize long chunks int length = (int)reader.BaseStream.Length; - idxEntries = new List((int)Math.Ceiling((double)length / 0xC4000)); + idxEntries = new List((int)Math.Ceiling((double)length / _mapChunkSize)); int position = 0; int id = 0; @@ -91,13 +249,13 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy { Id = id++, Offset = position, - Size = 0xC4000, + Size = _mapChunkSize, Extra = 0 }; idxEntries.Add(e); - position += 0xC4000; + position += _mapChunkSize; } } else @@ -105,13 +263,33 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy int idxEntryCount = (int)(readerIdx.BaseStream.Length / 12); idxEntries = new List(idxEntryCount); + long mulLength = reader.BaseStream.Length; + for (int i = 0; i < idxEntryCount; ++i) { int offset = readerIdx.ReadInt32(); + int size = readerIdx.ReadInt32(); + int extra = readerIdx.ReadInt32(); + // A negative offset is the unused id marker, and what FromUop writes back for an unused id. if (offset < 0) { - readerIdx.BaseStream.Seek(8, SeekOrigin.Current); // skip + continue; + } + + // Some patched muls mark unused ids with a zero length instead. A zero byte asset is not a + // thing, so drop those rows rather than pack empty uop entries; unpacking restores the -1. + if (size <= 0) + { + ++_emptyIdxEntriesSkipped; + continue; + } + + // ReadBytes returns a short array at EOF instead of throwing, so a row that + // points past the end of the mul would otherwise be packed from truncated data. + if (offset >= mulLength || (long)offset + size > mulLength) + { + ++_outOfRangeIdxEntriesSkipped; continue; } @@ -119,8 +297,8 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy { Id = i, Offset = offset, - Size = readerIdx.ReadInt32(), - Extra = readerIdx.ReadInt32() + Size = size, + Extra = extra }; idxEntries.Add(e); @@ -140,22 +318,19 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy // File header writer.Write(0x50594D); // MYP - writer.Write(type == FileType.GumpartLegacyMul ? 4 : 5); // version + writer.Write(version4Layout ? 4 : 5); // version writer.Write(0xFD23EC43); // format timestamp? - writer.Write(type == FileType.GumpartLegacyMul ? (long)0x28 : firstTable); // first table + writer.Write(firstTable); // first table writer.Write(tableSize); // table size writer.Write(idxEntries.Count); // file count - writer.Write(0); // modified count? - writer.Write(0); // ? - writer.Write(0); // ? + writer.Write(0); // modified count? (wseq, version 5 only) + writer.Write(0); // ? (cseq, version 5 only) + writer.Write(0); // reserved // Padding - if (type != FileType.GumpartLegacyMul) + for (long i = 0x28; i < firstTable; ++i) { - for (int i = 0x28; i < firstTable; ++i) - { - writer.Write((byte)0); - } + writer.Write((byte)0); } int tableCount = (int)Math.Ceiling((double)idxEntries.Count / tableSize); @@ -177,7 +352,7 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy // Table header writer.Write(idxEnd - idxStart); writer.Write((long)0); // next table, filled in later - writer.Seek(34 * tableSize, SeekOrigin.Current); // table entries, filled in later + writer.Seek(_tableEntrySize * tableSize, SeekOrigin.Current); // table entries, filled in later // Data int tableIdx = 0; @@ -199,6 +374,23 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy tableEntries[tableIdx].Offset = writer.BaseStream.Position; tableEntries[tableIdx].DecompressedSize = data.Length; tableEntries[tableIdx].CompressionFlag = (short)compressionFlag; + tableEntries[tableIdx].HeaderLength = 0; + + /* + * Every entry of every shipped version 4 UOP carries a 12 byte header block in front + * of its payload, and the 32 bit hash field of the table entry is the Adler32 of those + * 12 bytes, not of the payload - verified over 48897 version 4 entries of the current + * client plus 7.0.50.0, 100% header Adler32 and 0% payload Adler32. Version 5 entries + * use a hash we cannot reproduce, so they keep the payload Adler32 and a zero length + * header, which real clients accept. + */ + byte[] entryHeader = null; + if (version4Layout) + { + entryHeader = BuildEntryHeader(entryHeaderTimestamp); + writer.Write(entryHeader); + tableEntries[tableIdx].HeaderLength = entryHeader.Length; + } // hash 906142efe9fdb38a, which is file 0009834.tga (and no others, as 7.0.59.5) use a different name format (7 digits instead of 8); // if in newer versions more of these files will have adopted that format, someone should update this list of exceptions @@ -218,17 +410,17 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy if (type == FileType.MultiCollection && idxEntries[j].Id != _housingBinSentinelId) { - byte[] multiData = BuildMultiUopEntryFromMul(data, idxEntries[j].Id); + byte[] multiData = BuildMultiUopEntryFromMul(data, idxEntries[j].Id, componentTable); tableEntries[tableIdx].DecompressedSize = multiData.Length; tableEntries[tableIdx].Size = multiData.Length; if (compressionFlag >= CompressionFlag.Zlib) { - var result = UopUtils.Compress(multiData); + var result = UopUtils.Compress(multiData, _multiCollectionZlibLevel); if (!result.success) { - return; + throw new InvalidDataException($"Compression failed for multi {idxEntries[j].Id}."); } multiData = result.compressedData; tableEntries[tableIdx].Size = multiData.Length; @@ -276,8 +468,7 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy var result = UopUtils.Compress(gumpArtData); if (!result.success) { - // Handle error - return; + throw new InvalidDataException($"Compression failed for gump {idxEntries[j].Id}."); } tableEntries[tableIdx].Size = result.compressedData.Length; @@ -294,10 +485,10 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy if (compressionFlag >= CompressionFlag.Zlib) { - var result = UopUtils.Compress(binData); + var result = UopUtils.Compress(binData, _multiCollectionZlibLevel); if (!result.success) { - return; + throw new InvalidDataException("Compression failed for housing.bin."); } binData = result.compressedData; tableEntries[tableIdx].Size = binData.Length; @@ -308,9 +499,37 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy } else { - tableEntries[tableIdx].Size = data.Length; - tableEntries[tableIdx].Hash = HashAdler32(data); - writer.Write(data); + // Art / Map / Sound. The compression flag was already stamped on the entry above, so + // the data has to actually be compressed here - otherwise the entry claims zlib over + // raw bytes and neither the client nor FromUop can read it back. + byte[] payload = data; + + if (compressionFlag == CompressionFlag.Mythic) + { + throw new ArgumentException( + $"Mythic compression is only implemented for {nameof(FileType.GumpartLegacyMul)}, not for {type}.", + nameof(compressionFlag)); + } + + if (compressionFlag == CompressionFlag.Zlib) + { + var result = UopUtils.Compress(payload); + if (!result.success) + { + throw new InvalidDataException($"Compression failed for chunk {idxEntries[j].Id}."); + } + + payload = result.compressedData; + } + + tableEntries[tableIdx].Size = payload.Length; + tableEntries[tableIdx].Hash = HashAdler32(payload); + writer.Write(payload); + } + + if (entryHeader != null) + { + tableEntries[tableIdx].Hash = HashAdler32(entryHeader); } if (totalEntries > 0) @@ -329,12 +548,12 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy // Go back and fix table header if (i < tableCount - 1) { - writer.BaseStream.Seek(thisTable + 4, SeekOrigin.Begin); + writer.BaseStream.Seek(thisTable + _nextBlockOffsetField, SeekOrigin.Begin); writer.Write(nextTable); } else { - writer.BaseStream.Seek(thisTable + 12, SeekOrigin.Begin); + writer.BaseStream.Seek(thisTable + _blockHeaderSize, SeekOrigin.Begin); // No need to fix the next table address, it's the last } @@ -344,7 +563,7 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy for (int j = idxStart; j < idxEnd; ++j, ++tableIdx) { writer.Write(tableEntries[tableIdx].Offset); - writer.Write(0); // header length + writer.Write(tableEntries[tableIdx].HeaderLength); // header length writer.Write(tableEntries[tableIdx].Size); // compressed size writer.Write(tableEntries[tableIdx].DecompressedSize); // decompressed size writer.Write(tableEntries[tableIdx].Identifier); @@ -363,12 +582,64 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy } } - private static readonly byte[] _emptyTableEntry = new byte[8 + 4 + 4 + 4 + 8 + 4 + 2]; + private static void ReportComponentSidecarProblems(MultiComponentSidecar.Table componentTable) + { + if (componentTable == null) + { + return; + } + + ILogger logger = AppLog.For(typeof(LegacyMulFileConverter)); + + logger.LogInformation("UopPacker merged {RowCount} component rows from {Path}", + componentTable.RowCount, componentTable.Path); + + foreach (string problem in componentTable.Problems) + { + logger.LogWarning("UopPacker component sidecar: {Problem}", problem); + } + } + + /// + /// Entry count that makes classify an artidx.mul as High Seas. + /// Kept in sync with the 0x13FDC threshold in Ultima/Art.cs. + /// + private const int _uoahsArtIdxEntryCount = 0x13FDC; + + /// + /// On disk size of one entry in a block's entry table: + /// offset(8) headerLength(4) compressedSize(4) decompressedSize(4) identifier(8) hash(4) flag(2). + /// + private const int _tableEntrySize = 8 + 4 + 4 + 4 + 8 + 4 + 2; + + /// Size of a block header: usedEntryCount(4) nextBlockOffset(8). + private const int _blockHeaderSize = 4 + 8; + + /// Offset of the next-block pointer inside a block header. + private const int _nextBlockOffsetField = 4; + + private static readonly byte[] _emptyTableEntry = new byte[_tableEntrySize]; + + /// + /// The 12 byte block the client writes in front of every entry payload in a version 4 UOP: + /// two constant shorts (3, 8) followed by a FILETIME. The (3, 8) pair holds across every + /// version 4 entry of every install checked, from 7.0.50.0 to the current client. + /// + private static byte[] BuildEntryHeader(long fileTimeUtc) + { + byte[] header = new byte[12]; + + BinaryPrimitives.WriteUInt16LittleEndian(header, 3); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(2), 8); + BinaryPrimitives.WriteInt64LittleEndian(header.AsSpan(4), fileTimeUtc); + + return header; + } // // UOP -> MUL // - public void FromUop(string inFile, string outFile, string outFileIdx, FileType type, int typeIndex, string housingBinFile = "", IProgress progress = null) + public void FromUop(string inFile, string outFile, string outFileIdx, FileType type, int typeIndex, string housingBinFile = "", IProgress progress = null, string componentsFile = "") { Dictionary chunkIds = new Dictionary(); Dictionary chunkIds2 = new Dictionary(); @@ -390,9 +661,17 @@ public void FromUop(string inFile, string outFile, string outFileIdx, FileType t bool[] used = new bool[maxId]; + // multi.mul rows have nowhere to put the per tile component ids, so they go beside it. + string componentsPath = type != FileType.MultiCollection + ? null + : string.IsNullOrWhiteSpace(componentsFile) + ? MultiComponentSidecar.GetDefaultPath(outFile) + : componentsFile; + using (BinaryReader reader = OpenInput(inFile)) using (BinaryWriter mulWriter = OpenOutput(outFile)) using (BinaryWriter idxWriter = OpenOutput(outFileIdx)) + using (MultiComponentSidecar.Writer componentWriter = string.IsNullOrWhiteSpace(componentsPath) ? null : MultiComponentSidecar.CreateWriter(componentsPath)) { if (reader.ReadInt32() != 0x50594D) // MYP { @@ -446,11 +725,15 @@ public void FromUop(string inFile, string outFile, string outFileIdx, FileType t } // extract housing.bin file (not really needed for muls to work but needed later to pack files back to uop) - if ((type == FileType.MultiCollection) && (offsets[i].Identifier == 0x126D1E99DDEDEE0A) && !string.IsNullOrWhiteSpace(housingBinFile)) + if ((type == FileType.MultiCollection) && (offsets[i].Identifier == _housingBinIdentifier)) { - // MultiCollection.uop has the file "build/multicollection/housing.bin", which has to be handled separately - using (BinaryWriter writerBin = OpenOutput(housingBinFile)) + // MultiCollection.uop has the file "build/multicollection/housing.bin", which has to be + // handled separately. It has no id in the hash lookup, so it must be consumed here even + // when no output path was given - otherwise it falls through as an unknown identifier. + if (!string.IsNullOrWhiteSpace(housingBinFile)) { + using BinaryWriter writerBin = OpenOutput(housingBinFile); + stream.Seek(offsets[i].Offset + offsets[i].HeaderLength, SeekOrigin.Begin); byte[] binData = reader.ReadBytes(offsets[i].Size); @@ -532,7 +815,9 @@ public void FromUop(string inFile, string outFile, string outFileIdx, FileType t if (type == FileType.MapLegacyMul) { // Write this chunk on the right position (no IDX file to point to it) - mulWriter.Seek(chunkId * 0xC4000, SeekOrigin.Begin); + // Through BaseStream: BinaryWriter.Seek only takes an int, and a large + // custom facet can push the offset past int.MaxValue. + mulWriter.BaseStream.Seek((long)chunkId * _mapChunkSize, SeekOrigin.Begin); mulWriter.Write(chunkData); } else @@ -567,7 +852,7 @@ public void FromUop(string inFile, string outFile, string outFileIdx, FileType t case FileType.MultiCollection: { long startPosition = mulWriter.BaseStream.Position; - WriteMultiUopEntryToMul(mulWriter, chunkData); + WriteMultiUopEntryToMul(mulWriter, chunkData, chunkId, componentWriter); long endPosition = mulWriter.BaseStream.Position; idxWriter.Write((int)(endPosition - startPosition)); // Size @@ -626,6 +911,17 @@ public void FromUop(string inFile, string outFile, string outFileIdx, FileType t } } + /* + * Art is the exception: Art.IsUOAHS() classifies a client by the entry count of artidx.mul + * (>= 0x13FDC means High Seas), and that also picks the multi.mul row size and the tiledata + * layout. The highest populated art id in the shipped UOPs is around 62700, so padding only to + * the highest used entry downgrades every unpacked art set to pre-Stygian-Abyss limits. + */ + if (type == FileType.ArtLegacyMul) + { + padCount = Math.Max(padCount, _uoahsArtIdxEntryCount); + } + for (int i = 0; i < padCount; ++i) { if (used[i]) @@ -668,11 +964,29 @@ private static void CheckAndFixMapFiles(string outFile, FileType type, int typeI using (var mapFile = File.Open(outFile, FileMode.Open, FileAccess.ReadWrite)) { - var sizeDiff = mapFile.Length - expectedSize; - if (sizeDiff > 0) + long sizeDiff = mapFile.Length - expectedSize; + if (sizeDiff <= 0) + { + return; + } + + /* + * The overshoot we are here to remove is chunk padding: the UOP stores the map in 0xC4000 byte + * chunks, so the last one runs past the end of the facet by less than a chunk (752 640 bytes for + * map2, 1 372 for map4, nothing for map0/1). Anything larger is a custom map that is genuinely + * bigger than the stock facet, and truncating it would throw away real terrain. + */ + if (sizeDiff >= _mapChunkSize) { - mapFile.SetLength(mapFile.Length - sizeDiff); + AppLog.For(typeof(LegacyMulFileConverter)).LogInformation( + "{OutFile} is {Actual:N0} bytes, {Diff:N0} more than the stock facet {Index} size of {Expected:N0}. " + + "That is more than one {ChunkSize:N0} byte chunk of padding, so it looks like a custom map and was left untrimmed.", + outFile, mapFile.Length, sizeDiff, typeIndex, expectedSize, _mapChunkSize); + + return; } + + mapFile.SetLength(expectedSize); } } @@ -705,7 +1019,7 @@ private static string[] GetHashFormat(FileType type, int typeIndex, out int maxI { case FileType.ArtLegacyMul: { - maxId = 0x13FDC; // UOFiddler requires this exact index length to recognize UOHS art files + maxId = _uoahsArtIdxEntryCount; return ["build/artlegacymul/{0:00000000}.tga", string.Empty]; } case FileType.GumpartLegacyMul: @@ -735,75 +1049,10 @@ private static string[] GetHashFormat(FileType type, int typeIndex, out int maxI } } - // - // Hash functions (EA didn't write these, see http://burtleburtle.net/bob/c/lookup3.c) - // - private static ulong HashLittle2(string s) - { - int length = s.Length; - - uint a, b, c; - a = b = c = 0xDEADBEEF + (uint)length; - - int k = 0; - - while (length > 12) - { - a += s[k]; - a += (uint)s[k + 1] << 8; - a += (uint)s[k + 2] << 16; - a += (uint)s[k + 3] << 24; - b += s[k + 4]; - b += (uint)s[k + 5] << 8; - b += (uint)s[k + 6] << 16; - b += (uint)s[k + 7] << 24; - c += s[k + 8]; - c += (uint)s[k + 9] << 8; - c += (uint)s[k + 10] << 16; - c += (uint)s[k + 11] << 24; - - a -= c; a ^= c << 4 | c >> 28; c += b; - b -= a; b ^= a << 6 | a >> 26; a += c; - c -= b; c ^= b << 8 | b >> 24; b += a; - a -= c; a ^= c << 16 | c >> 16; c += b; - b -= a; b ^= a << 19 | a >> 13; a += c; - c -= b; c ^= b << 4 | b >> 28; b += a; - - length -= 12; - k += 12; - } - - if (length == 0) - { - return (ulong)b << 32 | c; - } - - switch (length) - { - case 12: c += (uint)s[k + 11] << 24; goto case 11; - case 11: c += (uint)s[k + 10] << 16; goto case 10; - case 10: c += (uint)s[k + 9] << 8; goto case 9; - case 9: c += s[k + 8]; goto case 8; - case 8: b += (uint)s[k + 7] << 24; goto case 7; - case 7: b += (uint)s[k + 6] << 16; goto case 6; - case 6: b += (uint)s[k + 5] << 8; goto case 5; - case 5: b += s[k + 4]; goto case 4; - case 4: a += (uint)s[k + 3] << 24; goto case 3; - case 3: a += (uint)s[k + 2] << 16; goto case 2; - case 2: a += (uint)s[k + 1] << 8; goto case 1; - case 1: a += s[k]; break; - } - - c ^= b; c -= b << 14 | b >> 18; - a ^= c; a -= c << 11 | c >> 21; - b ^= a; b -= a << 25 | a >> 7; - c ^= b; c -= b << 16 | b >> 16; - a ^= c; a -= c << 4 | c >> 28; - b ^= a; b -= a << 14 | a >> 18; - c ^= b; c -= b << 24 | b >> 8; - - return (ulong)b << 32 | c; - } + /// + /// Jenkins lookup3 hashlittle2 over a UOP entry path - see . + /// + private static ulong HashLittle2(string input) => UopUtils.HashFileName(input); private static uint HashAdler32(byte[] d) { @@ -819,73 +1068,163 @@ private static uint HashAdler32(byte[] d) return b << 16 | a; } - private static void WriteMultiUopEntryToMul(BinaryWriter mulWriter, byte[] chunkData) + /* + * MUL row layout: [itemId:2][x:2][y:2][z:2][flag:4][extra:4] = 16 bytes (High Seas / 7.0.9+) + * UOP tile: [itemId:2][x:2][y:2][z:2][flag:2][componentCount:4] = 14 bytes, followed by + * componentCount 32 bit component ids. + * + * The two flag fields map like this - derived from the 53261 tiles that can be matched by + * id/x/y/z between the shipped MultiCollection.uop and the shipped multi.mul, with no exception: + * + * mul flag = (uopFlag & 0x0001) != 0 ? 0 : 1 + * mul extra = (uopFlag & 0x0100) != 0 ? 1 : 0 + * + * So the "unknown" trailing int32 of the High Seas mul row is where bit 0x0100 lives. In the + * shipped file 8207 of 186695 tiles have it set; folding it into bit 0 (or dropping it) loses it. + */ + private const ushort _uopTileFlagLow = 0x0001; + private const ushort _uopTileFlagHigh = 0x0100; + private const int _mulRowSize = 16; + private const int _uopTileSize = 14; + + private static void WriteMultiUopEntryToMul(BinaryWriter mulWriter, byte[] chunkData, int multiId, MultiComponentSidecar.Writer componentWriter) { - Span span = chunkData.AsSpan(); - uint count = BinaryPrimitives.ReadUInt32LittleEndian(span[4..]); - span = span[8..]; + ReadOnlySpan data = chunkData.AsSpan(); + + if (data.Length < 8) + { + throw new InvalidDataException($"Multi {multiId}: entry is {data.Length} bytes, too short to hold a header."); + } + + uint count = BinaryPrimitives.ReadUInt32LittleEndian(data[4..]); + int position = 8; for (int i = 0; i < count; i++) { - ushort itemId = BinaryPrimitives.ReadUInt16LittleEndian(span); - short x = BinaryPrimitives.ReadInt16LittleEndian(span[2..]); - short y = BinaryPrimitives.ReadInt16LittleEndian(span[4..]); - short z = BinaryPrimitives.ReadInt16LittleEndian(span[6..]); + if (position + _uopTileSize > data.Length) + { + throw new InvalidDataException( + $"Multi {multiId}: tile {i} of {count} runs past the end of the {data.Length} byte entry."); + } - // this probably is just tiledata but needs further investigation - ushort flagValue = BinaryPrimitives.ReadUInt16LittleEndian(span[8..]); - uint clilocsCount = BinaryPrimitives.ReadUInt32LittleEndian(span[10..]); + ReadOnlySpan tile = data[position..]; - int skip = (int)Math.Min(clilocsCount, int.MaxValue) * 4; // bypass binary block - span = span[(14 + skip)..]; + ushort itemId = BinaryPrimitives.ReadUInt16LittleEndian(tile); + short x = BinaryPrimitives.ReadInt16LittleEndian(tile[2..]); + short y = BinaryPrimitives.ReadInt16LittleEndian(tile[4..]); + short z = BinaryPrimitives.ReadInt16LittleEndian(tile[6..]); + ushort flagValue = BinaryPrimitives.ReadUInt16LittleEndian(tile[8..]); + uint componentCount = BinaryPrimitives.ReadUInt32LittleEndian(tile[10..]); + + long tileSize = _uopTileSize + (long)componentCount * 4; + if (position + tileSize > data.Length) + { + throw new InvalidDataException( + $"Multi {multiId}: tile {i} claims {componentCount} component ids, which runs past the end of the {data.Length} byte entry."); + } + + if (componentCount > 0 && componentWriter != null) + { + uint[] componentIds = new uint[componentCount]; + for (int c = 0; c < componentIds.Length; ++c) + { + componentIds[c] = BinaryPrimitives.ReadUInt32LittleEndian(tile[(_uopTileSize + c * 4)..]); + } + + componentWriter.Write(multiId, i, itemId, x, y, z, componentIds); + } + + position += (int)tileSize; mulWriter.Write(itemId); mulWriter.Write(x); mulWriter.Write(y); mulWriter.Write(z); - mulWriter.Write(flagValue != 0 ? 0 : 1); - mulWriter.Write(0); + mulWriter.Write((flagValue & _uopTileFlagLow) != 0 ? 0 : 1); + mulWriter.Write((flagValue & _uopTileFlagHigh) != 0 ? 1 : 0); } } - // MUL row layout: [itemId:2][x:2][y:2][z:2][flag:4][extra:4] = 16 bytes - // UOP component: [itemId:2][x:2][y:2][z:2][flag:2][clilocsCount:4] = 14 bytes - private static byte[] BuildMultiUopEntryFromMul(byte[] mulData, int multiId) + private static byte[] BuildMultiUopEntryFromMul(byte[] mulData, int multiId, MultiComponentSidecar.Table components) { - const int mulRowSize = 16; - const int uopComponentSize = 14; + if (mulData.Length % _mulRowSize != 0) + { + throw new InvalidDataException( + $"Multi {multiId}: {mulData.Length} bytes is not a whole number of 16 byte rows. " + + "MultiCollection.uop can only be built from a High Seas (7.0.9+) multi.mul; " + + "the older 12 byte row format is not supported."); + } + + int tileCount = mulData.Length / _mulRowSize; + + // Component ids make the tile records variable length, so resolve them before sizing the buffer. + uint[][] componentIds = new uint[tileCount][]; + int totalComponents = 0; + + ReadOnlySpan source = mulData.AsSpan(); + + for (int i = 0; i < tileCount; i++) + { + ReadOnlySpan row = source[(i * _mulRowSize)..]; + + componentIds[i] = components?.GetComponentIds( + multiId, + i, + BinaryPrimitives.ReadUInt16LittleEndian(row), + BinaryPrimitives.ReadInt16LittleEndian(row[2..]), + BinaryPrimitives.ReadInt16LittleEndian(row[4..]), + BinaryPrimitives.ReadInt16LittleEndian(row[6..])) ?? Array.Empty(); + + totalComponents += componentIds[i].Length; + } - int componentCount = mulData.Length / mulRowSize; - byte[] result = new byte[8 + componentCount * uopComponentSize]; + byte[] result = new byte[8 + tileCount * _uopTileSize + totalComponents * 4]; Span dst = result.AsSpan(); BinaryPrimitives.WriteUInt32LittleEndian(dst, (uint)multiId); - BinaryPrimitives.WriteUInt32LittleEndian(dst[4..], (uint)componentCount); + BinaryPrimitives.WriteUInt32LittleEndian(dst[4..], (uint)tileCount); dst = dst[8..]; - ReadOnlySpan src = mulData.AsSpan(); - - for (int i = 0; i < componentCount; i++) + for (int i = 0; i < tileCount; i++) { - ushort itemId = BinaryPrimitives.ReadUInt16LittleEndian(src); - short x = BinaryPrimitives.ReadInt16LittleEndian(src[2..]); - short y = BinaryPrimitives.ReadInt16LittleEndian(src[4..]); - short z = BinaryPrimitives.ReadInt16LittleEndian(src[6..]); - int mulFlag = BinaryPrimitives.ReadInt32LittleEndian(src[8..]); - // extra int32 at src[12..16] is discarded + ReadOnlySpan row = source[(i * _mulRowSize)..]; + + ushort itemId = BinaryPrimitives.ReadUInt16LittleEndian(row); + short x = BinaryPrimitives.ReadInt16LittleEndian(row[2..]); + short y = BinaryPrimitives.ReadInt16LittleEndian(row[4..]); + short z = BinaryPrimitives.ReadInt16LittleEndian(row[6..]); + int mulFlag = BinaryPrimitives.ReadInt32LittleEndian(row[8..]); + int mulExtra = BinaryPrimitives.ReadInt32LittleEndian(row[12..]); + + /* + * The uop side has a single 16 bit visibility word where the mul has two 32 bit ints, so only + * the two bits EA uses survive. Lossless for every real file - across ten installs multi.mul + * flags and extra are only ever 0 or 1 - but a hand authored mul using the community bit + * assignments (0x2 Trim, 0x8 Door, 0x20 Wall, ...) has nowhere to put them, so count and report. + */ + if ((mulFlag & ~1) != 0 || (mulExtra & ~1) != 0) + { + ++_unrepresentableMultiFlagTiles; + } + + // Exact inverse of WriteMultiUopEntryToMul. + ushort uopFlag = (ushort)((mulFlag == 0 ? _uopTileFlagLow : 0) | (mulExtra != 0 ? _uopTileFlagHigh : 0)); - // Inverse of WriteMultiUopEntryToMul: mul==1 -> visible (uop flag 0), otherwise invisible (uop flag 1). - ushort uopFlag = (ushort)(mulFlag == 1 ? 0 : 1); + uint[] ids = componentIds[i]; BinaryPrimitives.WriteUInt16LittleEndian(dst, itemId); BinaryPrimitives.WriteInt16LittleEndian(dst[2..], x); BinaryPrimitives.WriteInt16LittleEndian(dst[4..], y); BinaryPrimitives.WriteInt16LittleEndian(dst[6..], z); BinaryPrimitives.WriteUInt16LittleEndian(dst[8..], uopFlag); - BinaryPrimitives.WriteUInt32LittleEndian(dst[10..], 0u); // clilocsCount + BinaryPrimitives.WriteUInt32LittleEndian(dst[10..], (uint)ids.Length); + + for (int c = 0; c < ids.Length; ++c) + { + BinaryPrimitives.WriteUInt32LittleEndian(dst[(_uopTileSize + c * 4)..], ids[c]); + } - src = src[mulRowSize..]; - dst = dst[uopComponentSize..]; + dst = dst[(_uopTileSize + ids.Length * 4)..]; } return result; diff --git a/UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs b/UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs new file mode 100644 index 00000000..dde978b1 --- /dev/null +++ b/UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs @@ -0,0 +1,327 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +namespace UoFiddler.Plugin.UopPacker.Classes +{ + /// + /// Side storage for the per tile component ids carried by MultiCollection.uop entries. + /// + /// + /// A MultiCollection tile record is [itemId:2][x:2][y:2][z:2][flag:2][componentCount:4] followed by + /// componentCount 32 bit component ids. A multi.mul row is a fixed 16 bytes and has nowhere to put + /// those ids, so they are written next to the mul/idx pair instead and merged back in when packing. + /// + /// In the shipped client file 3200 of 186695 tiles carry ids, drawn from a shared vocabulary of only + /// 59 values (119404 - 119462) reused across 304 multis, so ids for newly authored multis can be + /// written by hand. + /// + /// A component id marks a tile's interactive role within the multi, not its graphic and not a cliloc: + /// every tile carrying 119405 is a "tiller man" in tiledata, every 119406 is a "hatch", 119404 is the + /// hull (mast/deck), 119407/119408 are the planks, and 119453/119454 sit on doors. All 24 boat multis + /// (6 hulls x 4 facings) share the same 119404-119408 signature, and 1121 of the 1273 item ids that + /// carry a component always carry the same one. That is why dropping them breaks a client: a boat + /// without its tiller man cannot be steered and a house door stops being a door. + /// + public static class MultiComponentSidecar + { + /// + /// Companion file for a multi.mul, e.g. "multi.mul" -> "multi-components.txt". + /// + public static string GetDefaultPath(string mulPath) + { + if (string.IsNullOrWhiteSpace(mulPath)) + { + return string.Empty; + } + + string directory = Path.GetDirectoryName(mulPath); + string name = Path.GetFileNameWithoutExtension(mulPath) + "-components.txt"; + + return string.IsNullOrEmpty(directory) ? name : Path.Combine(directory, name); + } + + /// + /// The sidecar a pack of will read: + /// when given, otherwise the conventional "<mul>-components.txt" next to the mul. + /// + public static string ResolvePath(string mulPath, string componentsFile) + { + return string.IsNullOrWhiteSpace(componentsFile) ? GetDefaultPath(mulPath) : componentsFile; + } + + /// + /// What a pack would find at the sidecar path, for callers that want to warn before anything is + /// written - packing without one gives every tile a component count of zero. + /// + public static Status Probe(string mulPath, string componentsFile = "") + { + string path = ResolvePath(mulPath, componentsFile); + + Table table = Load(path); + + return table == null + ? new Status(path, false, 0, 0) + : new Status(path, true, table.RowCount, table.ComponentCount); + } + + /// Outcome of . + public readonly struct Status + { + internal Status(string path, bool exists, int rowCount, int componentCount) + { + Path = path; + Exists = exists; + RowCount = rowCount; + ComponentCount = componentCount; + } + + public string Path { get; } + + public bool Exists { get; } + + public int RowCount { get; } + + public int ComponentCount { get; } + + /// True when packing would drop every component id. + public bool IsEmpty => !Exists || ComponentCount == 0; + } + + private static readonly string[] _header = + { + "# MultiCollection.uop per tile component ids, written by UOFiddler.", + "# multi.mul cannot store these, so they live here and are merged back in when packing.", + "# Format: multiId,tileIndex,itemId,x,y,z,componentId[,componentId...]", + "# itemId/x/y/z only identify the tile; a row whose tile no longer matches multi.mul is skipped.", + "#", + "# A component id marks a tile's interactive role, e.g. 119404 hull, 119405 tiller man,", + "# 119406 hatch, 119407/119408 planks, 119453/119454 doors. Only 59 ids exist (119404-119462)", + "# and they are shared by every multi, so new multis can reuse them." + }; + + public static Writer CreateWriter(string path) => new Writer(path); + + /// + /// Streams component rows out while a multi.mul is being written. + /// + public sealed class Writer : IDisposable + { + private readonly StreamWriter _writer; + + public int RowCount { get; private set; } + + public int ComponentCount { get; private set; } + + internal Writer(string path) + { + _writer = new StreamWriter(new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None), new UTF8Encoding(false)); + + foreach (string line in _header) + { + _writer.WriteLine(line); + } + } + + public void Write(int multiId, int tileIndex, ushort itemId, short x, short y, short z, ReadOnlySpan componentIds) + { + if (componentIds.Length == 0) + { + return; + } + + var sb = new StringBuilder(64); + sb.Append(multiId.ToString(CultureInfo.InvariantCulture)).Append(','); + sb.Append(tileIndex.ToString(CultureInfo.InvariantCulture)).Append(','); + sb.Append("0x").Append(itemId.ToString("X4", CultureInfo.InvariantCulture)).Append(','); + sb.Append(x.ToString(CultureInfo.InvariantCulture)).Append(','); + sb.Append(y.ToString(CultureInfo.InvariantCulture)).Append(','); + sb.Append(z.ToString(CultureInfo.InvariantCulture)); + + foreach (uint id in componentIds) + { + sb.Append(',').Append(id.ToString(CultureInfo.InvariantCulture)); + } + + _writer.WriteLine(sb.ToString()); + + ++RowCount; + ComponentCount += componentIds.Length; + } + + public void Dispose() => _writer.Dispose(); + } + + /// + /// Loads a sidecar file. Returns null when is empty or does not exist, + /// in which case every tile is packed with a component count of zero. + /// + public static Table Load(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return null; + } + + var rows = new Dictionary<(int MultiId, int TileIndex), Row>(); + var malformed = new List(); + + int lineNumber = 0; + foreach (string rawLine in File.ReadLines(path)) + { + ++lineNumber; + + string line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') + { + continue; + } + + string[] fields = line.Split(','); + if (fields.Length < 7) + { + malformed.Add($"line {lineNumber}: expected at least 7 fields, got {fields.Length}"); + continue; + } + + if (!TryParse(fields[0], out long multiId) || + !TryParse(fields[1], out long tileIndex) || + !TryParse(fields[2], out long itemId) || + !TryParse(fields[3], out long x) || + !TryParse(fields[4], out long y) || + !TryParse(fields[5], out long z)) + { + malformed.Add($"line {lineNumber}: could not parse tile identity"); + continue; + } + + var ids = new uint[fields.Length - 6]; + bool ok = true; + + for (int i = 0; i < ids.Length; ++i) + { + if (!TryParse(fields[6 + i], out long id) || id < 0 || id > uint.MaxValue) + { + malformed.Add($"line {lineNumber}: could not parse component id '{fields[6 + i].Trim()}'"); + ok = false; + break; + } + + ids[i] = (uint)id; + } + + if (!ok) + { + continue; + } + + rows[((int)multiId, (int)tileIndex)] = new Row((ushort)itemId, (short)x, (short)y, (short)z, ids); + } + + return new Table(path, rows, malformed); + } + + internal readonly struct Row + { + public Row(ushort itemId, short x, short y, short z, uint[] componentIds) + { + ItemId = itemId; + X = x; + Y = y; + Z = z; + ComponentIds = componentIds; + } + + public ushort ItemId { get; } + public short X { get; } + public short Y { get; } + public short Z { get; } + public uint[] ComponentIds { get; } + } + + public sealed class Table + { + private static readonly uint[] _none = Array.Empty(); + + private readonly Dictionary<(int MultiId, int TileIndex), Row> _rows; + private readonly List _problems; + + internal Table(string path, Dictionary<(int MultiId, int TileIndex), Row> rows, List malformed) + { + Path = path; + _rows = rows; + _problems = malformed; + } + + public string Path { get; } + + public int RowCount => _rows.Count; + + /// Total number of component ids across every row. + public int ComponentCount + { + get + { + int total = 0; + foreach (Row row in _rows.Values) + { + total += row.ComponentIds.Length; + } + + return total; + } + } + + /// Malformed lines plus rows whose tile identity no longer matches multi.mul. + public IReadOnlyList Problems => _problems; + + /// + /// Component ids for a tile, or an empty span when the sidecar has no entry for it. A row whose + /// itemId/x/y/z disagree with the mul row is dropped and recorded in - + /// that happens when a multi's tile list was re-authored after the sidecar was written. + /// + public uint[] GetComponentIds(int multiId, int tileIndex, ushort itemId, short x, short y, short z) + { + if (!_rows.TryGetValue((multiId, tileIndex), out Row row)) + { + return _none; + } + + if (row.ItemId != itemId || row.X != x || row.Y != y || row.Z != z) + { + _problems.Add( + $"multi {multiId} tile {tileIndex}: sidecar describes 0x{row.ItemId:X4} at ({row.X},{row.Y},{row.Z}) " + + $"but multi.mul has 0x{itemId:X4} at ({x},{y},{z}) - component ids dropped"); + return _none; + } + + return row.ComponentIds; + } + } + + private static bool TryParse(string field, out long value) + { + ReadOnlySpan span = field.AsSpan().Trim(); + + if (span.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + return long.TryParse(span[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value); + } + + return long.TryParse(span, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + } + } +} diff --git a/UoFiddler.Plugin.UopPacker/UoFiddler.Plugin.UopPacker.csproj b/UoFiddler.Plugin.UopPacker/UoFiddler.Plugin.UopPacker.csproj index 4d659c53..fe76a625 100644 --- a/UoFiddler.Plugin.UopPacker/UoFiddler.Plugin.UopPacker.csproj +++ b/UoFiddler.Plugin.UopPacker/UoFiddler.Plugin.UopPacker.csproj @@ -23,7 +23,7 @@ Off - + diff --git a/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs b/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs index 574ae46c..2c568e39 100644 --- a/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs +++ b/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs @@ -108,6 +108,14 @@ private void UpdatePackAllCompressionVisibility() packAllHousingBinBtn.Visible = show; } + /// + /// Names the batch tab uses for the multi pair. They deliberately match the single file tab, so a + /// MultiCollection.uop extracted in one mode can be repacked by the other. + /// + private const string _batchMultiMulName = "multi.mul"; + + private const string _batchMultiIdxName = "multi.idx"; + private static (string mul, string idx, string uop) GetConventionalNames(FileType type, int mapIndex) { return type switch @@ -138,6 +146,22 @@ private void RefreshMulTypeUi() inidx.Enabled = inidxbtn.Enabled = !isMap; mulMapIndex.Enabled = isMap; + // Every entry of every shipped MultiCollection.uop is zlib compressed. Packing it uncompressed + // produces a file several times larger than the original, and Mythic is not a valid compression + // for this type at all, so the choice is fixed rather than merely defaulted. + if (isMulti) + { + compressionBox.SelectedItem = nameof(CompressionFlag.Zlib); + } + else if (type == FileType.ArtLegacyMul || type == FileType.MapLegacyMul || type == FileType.SoundLegacyMul) + { + // Every art, map and sound entry of every shipped client is stored uncompressed, and + // UOFiddler's own map reader can only address stored entries. Default accordingly. + compressionBox.SelectedItem = nameof(CompressionFlag.None); + } + + compressionBox.Enabled = !isMulti; + inhousingbin.Visible = inhousingbinbtn.Visible = labelHousingBin.Visible = isMulti; // Previously-picked paths belong to the old type; clear them so the user can't accidentally @@ -175,7 +199,7 @@ private void RefreshUopTypeUi() string preview = idxName != null ? $"{mulName}, {idxName}" : mulName; if (isMulti) { - preview += ", housing.bin"; + preview += $", housing.bin, {Path.GetFileName(MultiComponentSidecar.GetDefaultPath(mulName))}"; } outputFilesLabel.Text = "Will create: " + preview; } @@ -262,11 +286,22 @@ private async void ToUop(object sender, EventArgs e) if (fileType == FileType.MultiCollection) { housingBin = inhousingbin.Text; - if (!string.IsNullOrWhiteSpace(housingBin) && !File.Exists(housingBin)) + if (string.IsNullOrWhiteSpace(housingBin)) + { + MessageBox.Show("You must specify the input housing.bin. MultiCollection.uop is incomplete without it - extract it from the original UOP first."); + return; + } + + if (!File.Exists(housingBin)) { MessageBox.Show("The input housing.bin does not exist"); return; } + + if (!ConfirmComponentSidecar(inmul.Text)) + { + return; + } } var (_, _, uopName) = GetConventionalNames(fileType, (int)mulMapIndex.Value); @@ -293,6 +328,28 @@ private async void ToUop(object sender, EventArgs e) Enum.TryParse(compressionBox.SelectedItem.ToString(), out selectedCompressionMethod); } + if (fileType == FileType.MultiCollection) + { + // Not negotiable: every entry of every shipped MultiCollection.uop is zlib compressed. + selectedCompressionMethod = CompressionFlag.Zlib; + } + else if (selectedCompressionMethod != CompressionFlag.None + && (fileType == FileType.ArtLegacyMul || fileType == FileType.MapLegacyMul || fileType == FileType.SoundLegacyMul)) + { + var prompt = MessageBox.Show( + $"Every {fileType} entry in every shipped client is stored uncompressed.\n\n" + + "A compressed map UOP cannot be read back by UOFiddler at all, and compressed art or " + + "sound entries are outside anything the client has been observed to accept.\n\n" + + "Use " + selectedCompressionMethod + " anyway?", + "Unusual compression for this file type", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning); + if (prompt != DialogResult.Yes) + { + return; + } + } + bool succeeded = false; string inMul = inmul.Text; int mapIdx = (int)mulMapIndex.Value; @@ -471,6 +528,7 @@ private async void ToMul(object sender, EventArgs e) if (!string.IsNullOrEmpty(housingBinPath)) { written.Add(Path.GetFileName(housingBinPath)); + written.Add(Path.GetFileName(MultiComponentSidecar.GetDefaultPath(outMulPath))); } FileSavedDialog.Show(FindForm(), outfolder.Text, @@ -603,6 +661,36 @@ private void Pack(string inputBase, string outputBase, string inFile, string inI } } + /// + /// multi.mul has nowhere to store a tile's component ids, so UOFiddler keeps them in a text + /// sidecar next to the mul. Packing without it succeeds but gives every tile zero components - + /// boats lose their tiller man, hatch and planks, houses lose their doors. Ask first. + /// + private static bool ConfirmComponentSidecar(string mulPath) + { + MultiComponentSidecar.Status status = MultiComponentSidecar.Probe(mulPath); + + if (!status.IsEmpty) + { + return true; + } + + string detail = status.Exists + ? $"The component sidecar\n\n{status.Path}\n\nexists but contains no component ids." + : $"No component sidecar was found at\n\n{status.Path}"; + + var prompt = MessageBox.Show( + detail + + "\n\nEvery tile will be packed with zero components. Boats will lose their tiller man, " + + "hatch and planks, and customisable houses will lose their doors.\n\n" + + "Extract the original MultiCollection.uop first to produce the sidecar.\n\nPack anyway?", + "Multi component ids will be dropped", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning); + + return prompt == DialogResult.Yes; + } + private static void LogConverterError(Exception ex, string operation, string input, string output, FileType type) { ILogger logger = AppLog.For(typeof(UopPackerControl)); @@ -687,6 +775,12 @@ private async void StartFolderButtonClick(object sender, EventArgs e) } } + string batchMultiMul = Path.Combine(inputBase, _batchMultiMulName); + if (File.Exists(batchMultiMul) && !ConfirmComponentSidecar(batchMultiMul)) + { + return; + } + await RunPackAllAsync(inputBase, outputBase, gumpCompression, housingBinPath); } else @@ -712,7 +806,7 @@ await Task.Run(() => Extract(inputBase, outputBase, "artLegacyMUL.uop", "art.mul", "artidx.mul", FileType.ArtLegacyMul, 0, Per(), statusProgress); ++fileIndex; Extract(inputBase, outputBase, "gumpartLegacyMUL.uop", "gumpart.mul", "gumpidx.mul", FileType.GumpartLegacyMul, 0, Per(), statusProgress); ++fileIndex; Extract(inputBase, outputBase, "soundLegacyMUL.uop", "sound.mul", "soundidx.mul", FileType.SoundLegacyMul, 0, Per(), statusProgress); ++fileIndex; - Extract(inputBase, outputBase, "MultiCollection.uop", "multi-unpacked.mul", "multi-unpacked.idx", FileType.MultiCollection, 0, Per(), statusProgress, "housing.bin"); ++fileIndex; + Extract(inputBase, outputBase, "MultiCollection.uop", _batchMultiMulName, _batchMultiIdxName, FileType.MultiCollection, 0, Per(), statusProgress, "housing.bin"); ++fileIndex; for (int i = 0; i <= 5; ++i) { @@ -750,7 +844,7 @@ await Task.Run(() => Pack(inputBase, outputBase, "art.mul", "artidx.mul", "artLegacyMUL.uop", FileType.ArtLegacyMul, 0, CompressionFlag.None, Per(), statusProgress); ++fileIndex; Pack(inputBase, outputBase, "gumpart.mul", "gumpidx.mul", "gumpartLegacyMUL.uop", FileType.GumpartLegacyMul, 0, gumpCompression, Per(), statusProgress); ++fileIndex; Pack(inputBase, outputBase, "sound.mul", "soundidx.mul", "soundLegacyMUL.uop", FileType.SoundLegacyMul, 0, CompressionFlag.None, Per(), statusProgress); ++fileIndex; - Pack(inputBase, outputBase, "multi-unpacked.mul", "multi-unpacked.idx", "MultiCollection.uop", FileType.MultiCollection, 0, CompressionFlag.Zlib, Per(), statusProgress, housingBinPath); ++fileIndex; + Pack(inputBase, outputBase, _batchMultiMulName, _batchMultiIdxName, "MultiCollection.uop", FileType.MultiCollection, 0, CompressionFlag.Zlib, Per(), statusProgress, housingBinPath); ++fileIndex; for (int i = 0; i <= 5; ++i) { diff --git a/UoFiddler/Forms/AboutBoxForm.resx b/UoFiddler/Forms/AboutBoxForm.resx index 6dcac7c1..3cf41a32 100644 --- a/UoFiddler/Forms/AboutBoxForm.resx +++ b/UoFiddler/Forms/AboutBoxForm.resx @@ -118,7 +118,28 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Version 4.22.2 + Version 4.23.0 +- Multi tile flags survive a UOP <> mul round trip: the visibility word maps onto both `multi.mul` int32s instead of one boolean (8207 of 186695 shipped tiles used to lose a bit) +- Per-tile component ids are kept in a `multi-components.txt` sidecar, so repacking no longer strips a boat's tiller man, hatch and planks or a house's doors +- Loading a multi no longer deletes invisible tiles or reorders the tile list (this dropped 122 tiles and reshuffled 119 multis on shipped data) +- `housing.bin` is no longer parsed as multi 7, and the multi id ceiling now covers the ids up to 9000 that ship in MultiCollection.uop +- MultiCollection.uop is written in the client's real shape: version 4 container, 12-byte entry headers, header Adler32, zlib level 7 +- UOP gump width and height are no longer swapped +- EA's 0x0 placeholder gumps no longer list as valid (and then fail to draw) on compressed clients +- Gump ids up to 0x12000 are supported, including 69971..69985 shipped by 7.0.98.1 and later +- Compressed UOP entries are read at their real on-disk size; animations, maps and gumps were being read short +- Animation frames using zlib-wrapped Mythic compression are now decoded instead of parsed as pixels +- Compressed map UOPs are rejected with a clear message instead of silently misread +- Sounds no longer get 8 bytes of junk prefixed as samples (the name block is 0x28 bytes, not 32) +- UOP Packer: container layout matches the newest client per file type, and repacking the same input is now byte-identical +- UOP Packer: empty or out-of-range idx rows are skipped and logged instead of packed from truncated data +- UOP Packer: unpacked art keeps High Seas status, and large custom maps are no longer truncated to the stock facet size +- UOP Packer: compression is validated per type, failures delete the partial file, and missing `housing.bin` / component sidecar are prompted for +- Compare plugin: matches the main reader, so no more phantom differences; wider id range, released file handles and bitmap caches, and unreadable maps report once instead of throwing from paint handlers +- Multi CSV/XML exports carry the trailing High Seas int32; `gumpidx.mul` and `multi.idx` are truncated at the last real entry instead of padded +- UOP name hashing rewritten as a readable lookup3 port, verified bit-for-bit over 82k inputs + +Version 4.22.2 - Add export option to thumbnail list in animation tab. Version 4.22.1 diff --git a/UoFiddler/UoFiddler.csproj b/UoFiddler/UoFiddler.csproj index a9d2b685..ad555456 100644 --- a/UoFiddler/UoFiddler.csproj +++ b/UoFiddler/UoFiddler.csproj @@ -9,9 +9,9 @@ UoFiddler UoFiddler Copyright © 2026 - 4.22.2 - 4.22.2 - 4.22.2 + 4.23.0 + 4.23.0 + 4.23.0 true @@ -153,10 +153,10 @@ - - + + - +