From 6ccc71701e3356a5a9d6c5b046a2f5a1d7e465f3 Mon Sep 17 00:00:00 2001
From: AsY!um- <377468+AsYlum-@users.noreply.github.com>
Date: Fri, 14 Aug 2026 16:25:03 +0200
Subject: [PATCH 1/7] Fix MultiCollection.uop packing and multi.mul round-trip
- Map the uop tile visibility word onto both multi.mul int32s in both
directions (0x0001 -> flags, 0x0100 -> extra) instead of collapsing
them into one boolean; 8207 of 186695 shipped tiles carry 0x0100
- Skip build/multicollection/housing.bin when loading the uop - its
first two DWORDs parse as a valid (multiId 7, 1 tile) header
- RebuildTiles now only guarantees a tile on the anchor: it no longer
deletes invisible (0x1) tiles or reorders the list, which on shipped
data dropped 122 tiles and reshuffled 119 multis
- Raise MaximumMultiIndex to 0x2710; the shipped uop holds ids up to
9000, which the old 0x2200 bound silently dropped
- Write multi.idx only up to the highest populated multi, with extra 0
instead of -1, matching the client files and the packer
- Keep per-tile component ids in a multi-components.txt sidecar so a
mul -> uop repack does not strip a boat's tiller man, hatch and
planks or a customisable house's doors
- Write MultiCollection.uop as a version 4 container (first block at
0x28) with the 12 byte per-entry header, and set the entry hash to
the Adler32 of that header as the shipped files do
- Compress multi entries at zlib level 7 to land within 0.2% of the
size the client's own packer produced
- Require housing.bin and force Zlib for MultiCollection; reject
Mythic, throw on compression failure and delete a partial output
instead of leaving a half written uop behind
---
Ultima/Helpers/UopUtils.cs | 24 +-
Ultima/Multis.cs | 185 ++++-----
.../Classes/LegacyMulFileConverter.cs | 387 ++++++++++++++----
.../Classes/MultiComponentSidecar.cs | 265 ++++++++++++
.../UserControls/UopPackerControl.cs | 27 +-
5 files changed, 715 insertions(+), 173 deletions(-)
create mode 100644 UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs
diff --git a/Ultima/Helpers/UopUtils.cs b/Ultima/Helpers/UopUtils.cs
index 746f91fe..843a0fe7 100644
--- a/Ultima/Helpers/UopUtils.cs
+++ b/Ultima/Helpers/UopUtils.cs
@@ -175,8 +175,15 @@ 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 . The client's own
+ /// packer used stock zlib, i.e. level 6: re-compressing the 872 entries of the shipped
+ /// MultiCollection.uop at level 6 reproduces its 522 746 compressed bytes exactly, while
+ /// Optimal produces 5.6% more (and level 9 is still 2.4% more).
+ ///
/// compressed byte[] data
- public static (bool success, byte[] compressedData) Compress(byte[] rawData)
+ public static (bool success, byte[] compressedData) Compress(byte[] rawData, int? zlibLevel = null)
{
if (rawData == null || rawData.Length == 0)
{
@@ -187,10 +194,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/Multis.cs b/Ultima/Multis.cs
index 5cbbcdaa..a36de8b6 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,23 @@ 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" - the one entry that is not a multi.
+ private const ulong _housingBinIdentifier = 0x126D1E99DDEDEE0A;
+
public enum ImportType
{
TXT,
@@ -315,8 +344,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 +353,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 +423,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 +440,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 +498,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 +539,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/UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs b/UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs
index 6c7c217c..ee13b69b 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
{
@@ -53,22 +55,97 @@ private static BinaryWriter OpenOutput(string path)
// 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));
+ }
+
+ 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(string.IsNullOrWhiteSpace(componentsFile)
+ ? MultiComponentSidecar.GetDefaultPath(inFile)
+ : componentsFile)
+ : null;
-#pragma warning disable 162
- // Sanity, in case firstTable is customized by you!
- if (firstTable < 0x28)
+ 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);
+ }
+
+ 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)
+ {
+ const int tableSize = 0x64;
+
+ /*
+ * The shipped client files come in two shapes: version 4 with 100 entries per block and the
+ * first block right behind the 0x28 byte header (MultiCollection, gumpart, sound, tileart,
+ * AnimationSequence), and version 5 with 1000 entries per block and a large gap before the
+ * first block (art, maps). We only ever write 100 entry blocks, so anything using that layout
+ * has to declare version 4 as well - MultiCollection.uop in particular, which was previously
+ * written as a version 5 header with a version 4 body.
+ */
+ bool version4Layout = type == FileType.GumpartLegacyMul || type == FileType.MultiCollection;
+ long firstTable = version4Layout ? 0x28 : 0x200;
using (BinaryReader reader = OpenInput(inFile))
using (BinaryReader readerIdx = OpenInput(inFileIdx))
@@ -140,22 +217,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);
@@ -199,6 +273,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 against 48897 entries across MultiCollection,
+ * tileart, AnimationSequence, soundLegacyMUL and gumpartLegacyMUL). Reproduce that for
+ * MultiCollection; the remaining types keep their old behaviour for now, which does not
+ * match the shipped files either.
+ */
+ byte[] entryHeader = null;
+ if (type == FileType.MultiCollection)
+ {
+ entryHeader = BuildEntryHeader();
+ 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 +309,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 +367,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 +384,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 +398,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)
@@ -344,7 +462,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 +481,46 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy
}
}
+ 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);
+ }
+ }
+
private static readonly byte[] _emptyTableEntry = new byte[8 + 4 + 4 + 4 + 8 + 4 + 2];
+ ///
+ /// 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. Constant across all 48897 entries of the
+ /// five shipped version 4 UOPs.
+ ///
+ private static byte[] BuildEntryHeader()
+ {
+ byte[] header = new byte[12];
+
+ BinaryPrimitives.WriteUInt16LittleEndian(header, 3);
+ BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(2), 8);
+ BinaryPrimitives.WriteInt64LittleEndian(header.AsSpan(4), DateTime.UtcNow.ToFileTimeUtc());
+
+ 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 +542,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 +606,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);
@@ -567,7 +731,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
@@ -819,73 +983,152 @@ 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.");
+ }
+
+ ReadOnlySpan tile = data[position..];
+
+ 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)..]);
+ }
- // this probably is just tiledata but needs further investigation
- ushort flagValue = BinaryPrimitives.ReadUInt16LittleEndian(span[8..]);
- uint clilocsCount = BinaryPrimitives.ReadUInt32LittleEndian(span[10..]);
+ componentWriter.Write(multiId, i, itemId, x, y, z, componentIds);
+ }
- int skip = (int)Math.Min(clilocsCount, int.MaxValue) * 4; // bypass binary block
- span = span[(14 + skip)..];
+ 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;
- int componentCount = mulData.Length / mulRowSize;
- byte[] result = new byte[8 + componentCount * uopComponentSize];
+ // 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;
+ }
+
+ 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..]);
+
+ // 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..720cc105
--- /dev/null
+++ b/UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs
@@ -0,0 +1,265 @@
+/***************************************************************************
+ *
+ * $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);
+ }
+
+ 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;
+
+ /// 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/UserControls/UopPackerControl.cs b/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs
index 574ae46c..517bf6ff 100644
--- a/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs
+++ b/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs
@@ -138,6 +138,16 @@ 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);
+ }
+
+ 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 +185,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,7 +272,13 @@ 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;
@@ -293,6 +309,12 @@ 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;
+ }
+
bool succeeded = false;
string inMul = inmul.Text;
int mapIdx = (int)mulMapIndex.Value;
@@ -471,6 +493,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,
From 0bf6987c03671f9c674d53e97fd9116025e4485d Mon Sep 17 00:00:00 2001
From: AsY!um- <377468+AsYlum-@users.noreply.github.com>
Date: Tue, 18 Aug 2026 21:11:27 +0200
Subject: [PATCH 2/7] Fix UOP entry sizing, gump dimensions and packer
container layout
- Use the compressed length as the on-disk byte count for animation,
map and gump uop entries; decompressedLength only equals it while
the entry is stored, so compressed entries were read short
- Decode animation frames stored as flag 3 (zlib wrapped Mythic)
instead of handing Mythic bytes to the frame parser as pixels
- Fix Entry6D.Extra: getter and setter disagreed on the packing order,
so every UOP gump reported its width and height swapped
- Add FileIndex.CacheDimensions - Seek and the indexer hand out a boxed
copy, so writing dimensions to entry.Extra1 was silently discarded
- Probe compressed gump entries for real content, so EA's 0x0
placeholder gumps (29, 33, 34, 37, 47, 49, 98 ...) stop listing as
valid and failing to draw on a compressed client
- Raise the gump id ceiling to 0x12000: 7.0.98.1 and later ship ids
69971..69985 above the old 0xFFFF bound. Gumps.Save now truncates
gumpidx.mul to the last real row rather than padding with zeroes
- Read and write the full 0x28 byte sound name block; the previous 32
bytes prefixed 8 bytes of per-build junk onto every sound
- Fix the staidx tail loop in TileMatrix, which compared bytes against
a block count and left short-file tail blocks zeroed instead of -1
- Reject compressed map uop entries with a clear message - the block
reader slices straight into the file and can only handle stored ones
- Packer: pick the container shape the newest client uses (sound moves
to version 4), write 1000 entry blocks for version 5, emit the
version 4 entry header for every type using that layout, and stamp
one timestamp per file so a repack of the same input is identical
- Packer: drop idx rows that are empty or point past the end of the
mul rather than packing truncated data, and log what was dropped
- Packer: pad artidx.mul to the 0x13FDC High Seas threshold so an
unpacked art set is not downgraded to pre-Stygian-Abyss limits
- Packer: seek map chunks through BaseStream (a large custom facet
overflows int) and leave a genuinely larger custom map untrimmed
- Export and import the trailing High Seas int32 of a multi tile in
the CSV and XML formats, replacing the always-empty Cliloc column
- Reuse UopUtils.HashFileName in the packer instead of a second copy
of hashlittle2
- UopPacker UI: default compression per file type, warn before an
unusual choice, and confirm before packing multis with no component
sidecar; batch mode now uses multi.mul/multi.idx so single-file and
batch modes interoperate
---
Ultima/AnimationsUopLoader.cs | 36 +-
Ultima/FileIndex.cs | 79 ++++-
Ultima/Gumps.cs | 215 +++++++++++-
Ultima/Helpers/UopUtils.cs | 9 +-
Ultima/MultiComponentList.cs | 41 ++-
Ultima/Multis.cs | 7 +-
Ultima/Sound.cs | 25 +-
Ultima/TileMatrix.cs | 23 +-
.../Classes/LegacyMulFileConverter.cs | 314 ++++++++++++------
.../Classes/MultiComponentSidecar.cs | 62 ++++
.../UserControls/UopPackerControl.cs | 75 ++++-
11 files changed, 714 insertions(+), 172 deletions(-)
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 843a0fe7..d3fdd0b4 100644
--- a/Ultima/Helpers/UopUtils.cs
+++ b/Ultima/Helpers/UopUtils.cs
@@ -177,14 +177,15 @@ public static bool TryDecompressInto(byte[] compressedData, int compressedOffset
///
/// data to compress
///
- /// Raw zlib level 0-9, or null to use . The client's own
- /// packer used stock zlib, i.e. level 6: re-compressing the 872 entries of the shipped
- /// MultiCollection.uop at level 6 reproduces its 522 746 compressed bytes exactly, while
- /// Optimal produces 5.6% more (and level 9 is still 2.4% more).
+ /// 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, 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());
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 a36de8b6..d805c6e3 100644
--- a/Ultima/Multis.cs
+++ b/Ultima/Multis.cs
@@ -42,8 +42,11 @@ public sealed class Multis
private const ushort _uopTileFlagLow = 0x0001;
private const ushort _uopTileFlagHigh = 0x0100;
- /// HashLittle2 of "build/multicollection/housing.bin" - the one entry that is not a multi.
- private const ulong _housingBinIdentifier = 0x126D1E99DDEDEE0A;
+ ///
+ /// 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
{
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/UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs b/UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs
index ee13b69b..aa1039a6 100644
--- a/UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs
+++ b/UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs
@@ -49,8 +49,38 @@ 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;
@@ -94,11 +124,22 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy
}
MultiComponentSidecar.Table componentTable = type == FileType.MultiCollection
- ? MultiComponentSidecar.Load(string.IsNullOrWhiteSpace(componentsFile)
- ? MultiComponentSidecar.GetDefaultPath(inFile)
- : componentsFile)
+ ? 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
{
WriteUop(inFile, inFileIdx, outFile, type, typeIndex, compressionFlag, housingBinFile, progress, componentTable);
@@ -111,6 +152,32 @@ public static void ToUop(string inFile, string inFileIdx, string outFile, FileTy
}
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)
@@ -134,19 +201,33 @@ private static void TryDelete(string path)
private static void WriteUop(string inFile, string inFileIdx, string outFile, FileType type, int typeIndex, CompressionFlag compressionFlag, string housingBinFile, IProgress progress, MultiComponentSidecar.Table componentTable)
{
- const int tableSize = 0x64;
-
/*
- * The shipped client files come in two shapes: version 4 with 100 entries per block and the
- * first block right behind the 0x28 byte header (MultiCollection, gumpart, sound, tileart,
- * AnimationSequence), and version 5 with 1000 entries per block and a large gap before the
- * first block (art, maps). We only ever write 100 entry blocks, so anything using that layout
- * has to declare version 4 as well - MultiCollection.uop in particular, which was previously
- * written as a version 5 header with a version 4 body.
+ * 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.MultiCollection;
+ 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))
using (BinaryWriter writer = OpenOutput(outFile))
@@ -155,9 +236,9 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
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;
@@ -168,13 +249,13 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
{
Id = id++,
Offset = position,
- Size = 0xC4000,
+ Size = _mapChunkSize,
Extra = 0
};
idxEntries.Add(e);
- position += 0xC4000;
+ position += _mapChunkSize;
}
}
else
@@ -182,13 +263,33 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
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;
}
@@ -196,8 +297,8 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
{
Id = i,
Offset = offset,
- Size = readerIdx.ReadInt32(),
- Extra = readerIdx.ReadInt32()
+ Size = size,
+ Extra = extra
};
idxEntries.Add(e);
@@ -251,7 +352,7 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
// 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;
@@ -278,15 +379,15 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
/*
* 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 against 48897 entries across MultiCollection,
- * tileart, AnimationSequence, soundLegacyMUL and gumpartLegacyMUL). Reproduce that for
- * MultiCollection; the remaining types keep their old behaviour for now, which does not
- * match the shipped files either.
+ * 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 (type == FileType.MultiCollection)
+ if (version4Layout)
{
- entryHeader = BuildEntryHeader();
+ entryHeader = BuildEntryHeader(entryHeaderTimestamp);
writer.Write(entryHeader);
tableEntries[tableIdx].HeaderLength = entryHeader.Length;
}
@@ -447,12 +548,12 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
// 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
}
@@ -499,20 +600,38 @@ private static void ReportComponentSidecarProblems(MultiComponentSidecar.Table c
}
}
- private static readonly byte[] _emptyTableEntry = new byte[8 + 4 + 4 + 4 + 8 + 4 + 2];
+ ///
+ /// 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. Constant across all 48897 entries of the
- /// five shipped version 4 UOPs.
+ /// 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()
+ 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), DateTime.UtcNow.ToFileTimeUtc());
+ BinaryPrimitives.WriteInt64LittleEndian(header.AsSpan(4), fileTimeUtc);
return header;
}
@@ -696,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
@@ -790,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])
@@ -832,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);
}
}
@@ -869,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:
@@ -899,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)
{
@@ -1111,6 +1196,17 @@ private static byte[] BuildMultiUopEntryFromMul(byte[] mulData, int multiId, Mul
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));
diff --git a/UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs b/UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs
index 720cc105..dde978b1 100644
--- a/UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs
+++ b/UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs
@@ -54,6 +54,53 @@ public static string GetDefaultPath(string mulPath)
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.",
@@ -223,6 +270,21 @@ internal Table(string path, Dictionary<(int MultiId, int TileIndex), Row> rows,
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;
diff --git a/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs b/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs
index 517bf6ff..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
@@ -145,6 +153,12 @@ private void RefreshMulTypeUi()
{
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;
@@ -283,6 +297,11 @@ private async void ToUop(object sender, EventArgs e)
MessageBox.Show("The input housing.bin does not exist");
return;
}
+
+ if (!ConfirmComponentSidecar(inmul.Text))
+ {
+ return;
+ }
}
var (_, _, uopName) = GetConventionalNames(fileType, (int)mulMapIndex.Value);
@@ -314,6 +333,22 @@ private async void ToUop(object sender, EventArgs e)
// 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;
@@ -626,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));
@@ -710,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
@@ -735,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)
{
@@ -773,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)
{
From 841a54c4094463a3ada80cfe27dbc3573d5c9991 Mon Sep 17 00:00:00 2001
From: AsY!um- <377468+AsYlum-@users.noreply.github.com>
Date: Tue, 18 Aug 2026 21:16:27 +0200
Subject: [PATCH 3/7] Rewrite UOP name hashing as a readable lookup3 port
- Replace the register-named transcription of HashFileName with a
direct port of Bob Jenkins' lookup3 hashlittle2, the function the
client runs at 0x0042C9B2. Behaviour is unchanged - verified
bit-for-bit against the previous implementation over 82k inputs
covering every block-boundary length class
- Add HashWord2, the word-oriented lookup3 sibling used for 32 bit
word input, returning the client's low output word
---
Ultima/Helpers/UopUtils.cs | 194 +++++++++++++++++++++++--------------
1 file changed, 119 insertions(+), 75 deletions(-)
diff --git a/Ultima/Helpers/UopUtils.cs b/Ultima/Helpers/UopUtils.cs
index d3fdd0b4..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];
+
+ // 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;
+
+ i += 3;
+ length -= 3;
+ }
- 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));
+ // 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];
- return ((ulong)edi << 32) | eax;
+ // 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 ((ulong)esi << 32) | eax;
+ return c;
}
///
@@ -164,6 +207,7 @@ public static bool TryDecompressInto(byte[] compressedData, int compressedOffset
}
decompressedLength = total;
+
return true;
}
catch (Exception)
From 0d2a71d7c0522738d4f233db1c2aa3f00edad33a Mon Sep 17 00:00:00 2001
From: AsY!um- <377468+AsYlum-@users.noreply.github.com>
Date: Tue, 18 Aug 2026 22:19:31 +0200
Subject: [PATCH 4/7] Align Compare plugin with the UOP reader changes
- Mirror the gump content probe and dimension write-back from
Ultima.Gumps, so both sides agree on which ids are valid; otherwise
the compare tabs report differences that do not exist
- Raise the second client's gump ceiling to 0x12000 and drive the
compared id range from both loaded clients instead of a hardcoded
0x10000, so ids above 0xFFFF are reachable
- Fix SecondEntry6D.Extra, which had a second pair of backing fields
that only Extra wrote to, making a write through one view invisible
to the other
- Make SecondFileIndex disposable and re-open the mul stream in one
place; SecondArt no longer closes the index's shared handle after
every tile, which forced a re-open for the next one
- Dispose the outgoing index and bitmap cache when a second client is
loaded, after the tabs drop the cached instances they parked in
PictureBox.BackgroundImage
- Guard the SecondArt and SecondGump entry points against being called
before a second client is loaded
- Report an unreadable or compressed map uop once in a message box and
unload it, instead of rethrowing from OnPaint and OnMouseMove
- Re-apply the "differences only" filter after loading a second client
---
UoFiddler.Plugin.Compare/Classes/SecondArt.cs | 57 +++-
.../Classes/SecondFileAccessor.cs | 46 +++-
.../Classes/SecondFileIndex.cs | 124 +++++++--
.../Classes/SecondGump.cs | 249 ++++++++++++++++--
.../Classes/SecondTexture.cs | 22 +-
.../UserControls/CompareGumpControl.cs | 49 +++-
.../UserControls/CompareItemControl.cs | 5 +
.../UserControls/CompareLandControl.cs | 5 +
.../UserControls/CompareMapControl.cs | 27 +-
.../UserControls/CompareTextureControl.cs | 4 +
10 files changed, 516 insertions(+), 72 deletions(-)
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();
}
From 66c5d2613814af802c35501ec96a61a26d83013c Mon Sep 17 00:00:00 2001
From: AsY!um- <377468+AsYlum-@users.noreply.github.com>
Date: Tue, 18 Aug 2026 23:21:07 +0200
Subject: [PATCH 5/7] Rebuild the gump list on a virtual list view
- A list box addresses rows through a 16 bit index in LB_ITEMFROMPOINT
and in its scroll bar, so once the gump id ceiling moved to 0x12000
every row above 65535 aliased onto a row near the start of the list.
With free slots shown the list is 73728 rows, so scrolling or paging
into its top jumped back to around row 8192
- Row identity now lives in an ascending list of ids rather than in the
control's items, keeping a row position and a gump id distinct when
free slots are hidden
- HasGumpId and Search binary search that list instead of walking every
row - HasGumpId ran on each selection change for ids >= 50000
- The four insert paths collapse into one that keeps the list ordered
- Paint the selection from the tracked row: the item handed to DrawItem
is a fresh one built in RetrieveVirtualItem, so its State never
carries the selection and every row drew as selected
- Keep the list pane out of the form's resizing (FixedPanel.Panel1, as
the other tabs do) and clamp it to 450 pixels; a row is a 105 pixel
thumbnail plus two lines of text, the rest was empty background
- Guard the extract image entries against an empty selection
---
.../UserControls/GumpControl.Designer.cs | 43 ++-
.../UserControls/GumpControl.cs | 361 ++++++++++--------
2 files changed, 227 insertions(+), 177 deletions(-)
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