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
-
+
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 @@
-
-
+
+
-
+