From 424bd39ab8b70671a9f05c06dd22e6854aaed1c8 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:55:55 +0100 Subject: [PATCH 01/53] Write gump 9834 with an 8 digit UOP name The reader also accepts the old 7 digit name. --- Ultima/FileIndex.cs | 40 +++++++++++++++++----------- Ultima/Uop/LegacyMulFileConverter.cs | 12 +++------ 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/Ultima/FileIndex.cs b/Ultima/FileIndex.cs index fafca347..a855ea36 100644 --- a/Ultima/FileIndex.cs +++ b/Ultima/FileIndex.cs @@ -249,22 +249,22 @@ public FileIndex(string idxFile, string mulFile, int file) } } - /// - /// True when the entry carries the high bit that sets - /// to mark it as coming from verdata. - /// - /// - /// A length of -1 is the "unused entry" filler that real .idx files are padded with, and it - /// has that same high bit set. Without the -1 test every unused entry looks like a verdata - /// patch of length 0x7FFFFFFF, and the reader then tries to pull that many bytes out of - /// - which, with no verdata.mul present, is Stream.Null. - /// A real patch never reaches 0x7FFFFFFF bytes, so the two cases cannot be confused. - /// - private static bool IsVerdataPatched(IEntry e) - { - return e.Length != -1 && (e.Length & (1 << 31)) != 0; - } - + /// + /// True when the entry carries the high bit that sets + /// to mark it as coming from verdata. + /// + /// + /// A length of -1 is the "unused entry" filler that real .idx files are padded with, and it + /// has that same high bit set. Without the -1 test every unused entry looks like a verdata + /// patch of length 0x7FFFFFFF, and the reader then tries to pull that many bytes out of + /// - which, with no verdata.mul present, is Stream.Null. + /// A real patch never reaches 0x7FFFFFFF bytes, so the two cases cannot be confused. + /// + private static bool IsVerdataPatched(IEntry e) + { + return e.Length != -1 && (e.Length & (1 << 31)) != 0; + } + public Stream Seek(int index, out int length, out int extra, out bool patched) { if (FileAccessor is null) @@ -669,6 +669,8 @@ public IEntry GetEntry(int index) public class UopFileAccessor : IFileAccessor { + private const int _legacyGumpId = 9834; + public Entry6D[] Index { get; } public FileStream Stream { get; set; } @@ -724,6 +726,12 @@ public UopFileAccessor(string path, string uopEntryExtension, int length, int id hashes.TryAdd(hash, i); } + // Older clients stored gump 9834 under a 7 digit name. + if (uopPattern == "gumpartlegacymul" && length > _legacyGumpId) + { + hashes.TryAdd(UopUtils.HashFileName($"build/{uopPattern}/{_legacyGumpId:D7}{uopEntryExtension}"), _legacyGumpId); + } + br.BaseStream.Seek(nextBlock, SeekOrigin.Begin); // There are no invalid entries in .uop so we have to initialize all entries diff --git a/Ultima/Uop/LegacyMulFileConverter.cs b/Ultima/Uop/LegacyMulFileConverter.cs index fe3c7d2b..c9e81d19 100644 --- a/Ultima/Uop/LegacyMulFileConverter.cs +++ b/Ultima/Uop/LegacyMulFileConverter.cs @@ -357,14 +357,10 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi entryHeader = BuildEntryHeader(entryHeaderTimestamp); } - // 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 - // (even if this seems so much like a typo from someone from the UO development team :P) - if ((type == FileType.GumpartLegacyMul) && (idxEntries[j].Id == 9834)) - { - identifier = HashLittle2(string.Format(hashFormat[1], idxEntries[j].Id)); - } - else if (type == FileType.MultiCollection && idxEntries[j].Id == _housingBinSentinelId) + // Some older clients stored gump 9834 as 0009834.tga (7 digits). Current clients use + // 8 digits for every gumpart entry, and that is the only name the reader looks up + // first, so always write 8 digits. FromUop and FileIndex still accept the old name. + if (type == FileType.MultiCollection && idxEntries[j].Id == _housingBinSentinelId) { identifier = _housingBinIdentifier; } From 0fa8fdf1c2d760d6b857629569a6987454fb770e Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:59:11 +0100 Subject: [PATCH 02/53] Keep the multi tile extra field when the multi editor saves --- Ultima/MultiComponentList.cs | 2 +- .../Classes/MultiEditorComponentList.cs | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Ultima/MultiComponentList.cs b/Ultima/MultiComponentList.cs index 8911bdce..a599aea8 100644 --- a/Ultima/MultiComponentList.cs +++ b/Ultima/MultiComponentList.cs @@ -810,7 +810,7 @@ public MultiComponentList(MTileList[][] newTiles, int count, int width, int heig SortedTiles[counter].OffsetY = (short)(y - _center.Y); SortedTiles[counter].OffsetZ = mTile.Z; SortedTiles[counter].Flags = mTile.Flag; - SortedTiles[counter].Unk1 = 0; + SortedTiles[counter].Unk1 = mTile.Unk1; if (SortedTiles[counter].OffsetX < _min.X) { diff --git a/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs b/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs index d197324a..5ec34c46 100644 --- a/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs +++ b/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs @@ -76,7 +76,10 @@ public MultiEditorComponentList(MultiComponentList list, MultiEditorControl pare x, y, list.Tiles[x][y][i].Z, - list.Tiles[x][y][i].Flag)); + list.Tiles[x][y][i].Flag) + { + Extra = list.Tiles[x][y][i].Unk1 + }); } Tiles.Add(new FloorTile(x, y, _parent.DrawFloorZ)); @@ -150,7 +153,7 @@ public MultiComponentList ConvertToSdk() continue; } - tiles[tile.X][tile.Y].Add(tile.Id, (sbyte)tile.Z, tile.Invisible ? (sbyte)0 : (sbyte)1, 0); + tiles[tile.X][tile.Y].Add(tile.Id, (sbyte)tile.Z, tile.Invisible ? (sbyte)0 : (sbyte)1, tile.Extra); ++count; } @@ -713,7 +716,7 @@ private void RestoreSnapshot(UndoStruct entry) { Tiles.Add(tile.IsVirtualFloor ? new FloorTile(tile.X, tile.Y, tile.Z) - : new MultiTile(tile.Id, tile.X, tile.Y, tile.Z, tile.Invisible)); + : new MultiTile(tile.Id, tile.X, tile.Y, tile.Z, tile.Invisible) { Extra = tile.Extra }); } _modified = true; @@ -941,7 +944,7 @@ private UndoStruct SnapshotCurrent(string action) { entry.Tiles.Add(tile.IsVirtualFloor ? new FloorTile(tile.X, tile.Y, tile.Z) - : new MultiTile(tile.Id, tile.X, tile.Y, tile.Z, tile.Invisible)); + : new MultiTile(tile.Id, tile.X, tile.Y, tile.Z, tile.Invisible) { Extra = tile.Extra }); } return entry; @@ -1341,6 +1344,11 @@ public int Z public bool Invisible { get; set; } + /// + /// The multi.mul extra field (MultiTileEntry.Unk1), kept so a save writes it back. + /// + public int Extra { get; set; } + public bool Walkable { get; private set; } public bool DoubleSurface { get; private set; } From 5bd9667e969746797db7df832b4d2c155a0f249e Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:59:38 +0100 Subject: [PATCH 03/53] Import animation palettes starting at slot 0 Invalid values show an error instead of throwing. --- UoFiddler.Controls/Forms/AnimationEditForm.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/UoFiddler.Controls/Forms/AnimationEditForm.cs b/UoFiddler.Controls/Forms/AnimationEditForm.cs index ae083ada..7f157ef7 100644 --- a/UoFiddler.Controls/Forms/AnimationEditForm.cs +++ b/UoFiddler.Controls/Forms/AnimationEditForm.cs @@ -13,6 +13,7 @@ using System.Collections.Generic; using System.Drawing; using System.Drawing.Imaging; +using System.Globalization; using System.IO; using System.Linq; using System.Windows.Forms; @@ -1651,14 +1652,16 @@ private void OnClickImportPalette(object sender, EventArgs e) continue; } - i++; - if (i >= Animations.PaletteCapacity) { break; } - palette[i] = ushort.Parse(line); + if (!ushort.TryParse(line, NumberStyles.Integer, CultureInfo.InvariantCulture, out palette[i])) + { + MessageBox.Show($"Invalid palette value \"{line}\".", "Import Palette", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } // My Soulblighter Modification // Convert color 0,0,0 to 0,0,8 @@ -1668,6 +1671,8 @@ private void OnClickImportPalette(object sender, EventArgs e) palette[i] = 32769; } // End of Soulblighter Modification + + i++; } edit.ReplacePalette(palette); From 1e3016873c04f9c60a08052dc59f295dcd8b5bb3 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:00:38 +0100 Subject: [PATCH 04/53] Read speech entries longer than 128 bytes in full --- Ultima/SpeechList.cs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/Ultima/SpeechList.cs b/Ultima/SpeechList.cs index 65af5137..b59a3814 100644 --- a/Ultima/SpeechList.cs +++ b/Ultima/SpeechList.cs @@ -11,8 +11,6 @@ public sealed class SpeechList { public static List Entries { get; private set; } - private static readonly byte[] _buffer = new byte[128]; - static SpeechList() { Initialize(); @@ -42,16 +40,11 @@ public static void Initialize() while (bin.BaseStream.Length != bin.BaseStream.Position) { var id = BinaryPrimitives.ReverseEndianness(bin.ReadInt16()); - var length = BinaryPrimitives.ReverseEndianness(bin.ReadInt16()); - - if (length > 128) - { - length = 128; - } + int length = BinaryPrimitives.ReverseEndianness(bin.ReadUInt16()); - _ = bin.Read(_buffer, 0, length); + byte[] text = bin.ReadBytes(length); - string keyword = Encoding.UTF8.GetString(_buffer, 0, length); + string keyword = Encoding.UTF8.GetString(text); Entries.Add(new SpeechEntry(id, keyword, order)); @@ -75,7 +68,7 @@ public static void SaveSpeechList(string fileName) { bin.Write(BinaryPrimitives.ReverseEndianness(entry.Id)); byte[] utf8String = Encoding.UTF8.GetBytes(entry.KeyWord); - var length = (short)utf8String.Length; + var length = (ushort)utf8String.Length; bin.Write(BinaryPrimitives.ReverseEndianness(length)); bin.Write(utf8String); } From 4b49de97b0e4455554f27702886fe0945f0df2ec Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:01:52 +0100 Subject: [PATCH 05/53] Keep the animdata tail and empty records on save A short last chunk and records with no frames are written back as loaded. --- Ultima/Animdata.cs | 161 ++++++++++++++++++++++++--------------------- 1 file changed, 87 insertions(+), 74 deletions(-) diff --git a/Ultima/Animdata.cs b/Ultima/Animdata.cs index b5c23b42..53085fdf 100644 --- a/Ultima/Animdata.cs +++ b/Ultima/Animdata.cs @@ -7,15 +7,21 @@ namespace Ultima { public sealed class Animdata { + private const int _recordSize = 64 + 4; + private static int[] _header; private static byte[] _unknown; + // Records with a frame count of 0 that still hold data. They are not entries, but Save writes + // them back unchanged. + private static Dictionary _emptyRecords = new Dictionary(); + public static Dictionary AnimData { get; set; } /// - /// Entry slots in the loaded file, 8 per chunk. + /// Entry slots in the loaded file, 8 per chunk. The last chunk can be short. /// - public static int SlotCount => (_header?.Length ?? 0) * 8; + public static int SlotCount { get; private set; } static Animdata() { @@ -28,6 +34,10 @@ static Animdata() public static void Initialize() { AnimData = new Dictionary(); + _emptyRecords = new Dictionary(); + _header = []; + _unknown = null; + SlotCount = 0; string path = Files.GetFilePath("animdata.mul"); if (path == null) @@ -35,56 +45,54 @@ public static void Initialize() return; } - using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var bin = new BinaryReader(fs)) + byte[] file = File.ReadAllBytes(path); + var headers = new List(); + int pos = 0; + int id = 0; + + // Chunks are a 4 byte header and 8 records. The last chunk can hold fewer records, + // and can end in a part of a record, which is kept as is. + while (file.Length - pos >= 4 + _recordSize) { - unsafe + headers.Add(BitConverter.ToInt32(file, pos)); + pos += 4; + + for (int i = 0; i < 8 && file.Length - pos >= _recordSize; ++i, ++id) { - int id = 0; - int h = 0; + ReadRecord(file, pos, id); + pos += _recordSize; + } + } - _header = new int[bin.BaseStream.Length / (4 + (8 * (64 + 4)))]; + _header = headers.ToArray(); + SlotCount = id; - while (h < _header.Length) - { - _header[h++] = bin.ReadInt32(); // chunk header + if (pos < file.Length) + { + _unknown = file.AsSpan(pos).ToArray(); + } + } - // Read 8 tiles - byte[] buffer = bin.ReadBytes(544); + private static void ReadRecord(byte[] file, int pos, int id) + { + byte frameCount = file[pos + 65]; + if (frameCount == 0) + { + if (file.AsSpan(pos, _recordSize).ContainsAnyExcept((byte)0)) + { + _emptyRecords[id] = file.AsSpan(pos, _recordSize).ToArray(); + } - fixed (byte* buf = buffer) - { - byte* data = buf; - - for (int i = 0; i < 8; ++i, ++id) - { - sbyte[] frame = new sbyte[64]; - - for (int j = 0; j < 64; ++j) - { - frame[j] = (sbyte)*data++; - } - - byte unk = *data++; - byte frameCount = *data++; - byte frameInterval = *data++; - byte frameStart = *data++; - - if (frameCount > 0) - { - AnimData[id] = new AnimdataEntry(frame, unk, frameCount, frameInterval, frameStart); - } - } - } - } + return; + } - var remaining = (int)(bin.BaseStream.Length - bin.BaseStream.Position); - if (remaining > 0) - { - _unknown = bin.ReadBytes(remaining); - } - } + sbyte[] frame = new sbyte[64]; + for (int j = 0; j < 64; ++j) + { + frame[j] = (sbyte)file[pos + j]; } + + AnimData[id] = new AnimdataEntry(frame, file[pos + 64], frameCount, file[pos + 66], file[pos + 67]); } /// @@ -100,49 +108,54 @@ public static void Save(string path) { string fileName = Path.Combine(path, "animdata.mul"); + int maxId = AnimData.Count > 0 ? AnimData.Keys.Max() : -1; + + // Keep the loaded slot count, including a short last chunk. Growing the file rounds it up + // to whole chunks, and the partial record that ended the old file no longer fits. + int slots = SlotCount; + bool grown = maxId >= slots; + if (grown) + { + slots = (maxId + 8) & ~7; + } + using (var fs = new FileStream(fileName, FileMode.Create, FileAccess.Write, FileShare.Write)) using (var bin = new BinaryWriter(fs)) { - int id = 0; int h = 0; - int maxId = AnimData.Keys.Max(); - while (id <= maxId) + for (int id = 0; id < slots; ++id) { - int headerChunk = h < _header.Length ? _header[h++] : Random.Shared.Next(); - bin.Write(headerChunk); - for (int i = 0; i < 8; ++i, ++id) + if ((id & 7) == 0) + { + int headerChunk = h < _header.Length ? _header[h] : Random.Shared.Next(); + ++h; + bin.Write(headerChunk); + } + + AnimdataEntry animdataEntry = GetAnimData(id); + if (animdataEntry != null) { - AnimdataEntry animdataEntry = GetAnimData(id); for (int j = 0; j < 64; ++j) { - if (animdataEntry != null) - { - bin.Write(animdataEntry.FrameData[j]); - } - else - { - bin.Write((sbyte)0); - } + bin.Write(animdataEntry.FrameData[j]); } - if (animdataEntry != null) - { - bin.Write(animdataEntry.Unknown); - bin.Write(animdataEntry.FrameCount); - bin.Write(animdataEntry.FrameInterval); - bin.Write(animdataEntry.FrameStart); - } - else - { - bin.Write((byte)0); - bin.Write((byte)0); - bin.Write((byte)0); - bin.Write((byte)0); - } + bin.Write(animdataEntry.Unknown); + bin.Write(animdataEntry.FrameCount); + bin.Write(animdataEntry.FrameInterval); + bin.Write(animdataEntry.FrameStart); + } + else if (_emptyRecords.TryGetValue(id, out byte[] raw)) + { + bin.Write(raw); + } + else + { + bin.Write(new byte[_recordSize]); } } - if (_unknown != null) + if (_unknown != null && !grown) { bin.Write(_unknown); } From 2708f78d8f268c553c4fb651c643f2715ce4edc2 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:02:23 +0100 Subject: [PATCH 06/53] Save skills even when the list was never opened Close the old skills file on reload. --- Ultima/Skills.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Ultima/Skills.cs b/Ultima/Skills.cs index 8d13a160..9278889d 100644 --- a/Ultima/Skills.cs +++ b/Ultima/Skills.cs @@ -40,6 +40,7 @@ public static List SkillEntries /// public static void Reload() { + _fileIndex?.Dispose(); _fileIndex = new FileIndex("skills.idx", "skills.mul", 16); _skillEntries = new List(); for (int i = 0; i < _fileIndex.IndexLength; ++i) @@ -106,9 +107,11 @@ public static void Save(string path) using (var binidx = new BinaryWriter(fsidx)) using (var binmul = new BinaryWriter(fsmul)) { - for (int i = 0; i < _fileIndex.IndexLength; ++i) + List entries = SkillEntries; + long count = System.Math.Max(_fileIndex.IndexLength, entries.Count); + for (int i = 0; i < count; ++i) { - SkillInfo skill = (i < _skillEntries.Count) ? _skillEntries[i] : null; + SkillInfo skill = (i < entries.Count) ? entries[i] : null; if (skill == null) { binidx.Write(-1); // lookup From 75e7a42ac47e73aae76a4d4bd5ef0b44ae1cc3d3 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:02:50 +0100 Subject: [PATCH 07/53] Merge radarcol CSV imports into the loaded colors Rows that are not numbers are skipped instead of setting id 0. --- Ultima/RadarCol.cs | 58 ++++++++++++++-------------------------------- 1 file changed, 17 insertions(+), 41 deletions(-) diff --git a/Ultima/RadarCol.cs b/Ultima/RadarCol.cs index b37377ef..7c5c8dde 100644 --- a/Ultima/RadarCol.cs +++ b/Ultima/RadarCol.cs @@ -84,10 +84,11 @@ public static void ImportFromCSV(string fileName) return; } + // Rows update the loaded table; ids past its end grow it. + var colors = (ushort[])(Colors ?? []).Clone(); + using (var sr = new StreamReader(fileName)) { - int count = 0; - while (sr.ReadLine() is { } line) { line = line.Trim(); @@ -102,63 +103,38 @@ public static void ImportFromCSV(string fileName) continue; } - ++count; - } - - Colors = new ushort[count]; - } - - using (var sr = new StreamReader(fileName)) - { - while (sr.ReadLine() is { } line) - { - line = line.Trim(); - - if (line.Length == 0 || line.StartsWith("#")) + string[] split = line.Split(';'); + if (split.Length < 2 || !TryConvertStringToInt(split[0], out int id) || !TryConvertStringToInt(split[1], out int color)) { continue; } - if (line.StartsWith("ID;")) + if (id < 0 || id > 0xFFFF) { continue; } - try - { - string[] split = line.Split(';'); - if (split.Length < 2) - { - continue; - } - - int id = ConvertStringToInt(split[0]); - int color = ConvertStringToInt(split[1]); - Colors[id] = (ushort)color; - } - catch + if (id >= colors.Length) { - // TODO: ignored? - // ignored + Array.Resize(ref colors, id + 1); } + + colors[id] = (ushort)color; } } + + Colors = colors; } - private static int ConvertStringToInt(string text) + private static bool TryConvertStringToInt(string text, out int result) { - int result; - if (text.Contains("0x")) - { - string convert = text.Replace("0x", ""); - int.TryParse(convert, NumberStyles.HexNumber, null, out result); - } - else + text = text.Trim(); + if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) { - int.TryParse(text, NumberStyles.Integer, null, out result); + return int.TryParse(text.AsSpan(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out result); } - return result; + return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out result); } } } From 527c216d296d0b02184d6be38aaf173fbb299fbc Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:04:46 +0100 Subject: [PATCH 08/53] Fix static art dedup for images of different sizes Row offsets are written as ushort, without the unused padding after the table. --- Ultima/Art.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Ultima/Art.cs b/Ultima/Art.cs index a7ba3864..ce9d17fd 100644 --- a/Ultima/Art.cs +++ b/Ultima/Art.cs @@ -40,7 +40,8 @@ private struct ImageData // the previous List + SHA256-bytes-in-each-entry layout, // which made CompareSaveImages an O(n²) linear scan. private static Dictionary _landImageData; - private static Dictionary _staticImageData; + // Keyed by size as well: the pixel hash alone matches images of different sizes with the same bytes. + private static Dictionary<(UInt128 Hash, int Width, int Height), ImageData> _staticImageData; static Art() { @@ -933,7 +934,7 @@ private static unsafe Bitmap LoadLand(Stream stream, int length) public static unsafe void Save(string path) { _landImageData = new Dictionary(); - _staticImageData = new Dictionary(); + _staticImageData = new Dictionary<(UInt128 Hash, int Width, int Height), ImageData>(); string idx = Path.Combine(path, "artidx.mul"); string mul = Path.Combine(path, "art.mul"); @@ -1044,7 +1045,7 @@ public static unsafe void Save(string path) try { UInt128 hash = bd.Hash128(); - if (_staticImageData.TryGetValue(hash, out ImageData existing)) + if (_staticImageData.TryGetValue((hash, bmp.Width, bmp.Height), out ImageData existing)) { binidx.Write(existing.Position); // lookup binidx.Write(existing.Length); @@ -1064,7 +1065,7 @@ public static unsafe void Save(string path) int width = 0; for (int i = 0; i < bmp.Height; ++i) // fill lookup { - binmul.Write(width); + binmul.Write((ushort)width); } for (int y = 0; y < bmp.Height; ++y, line += delta) @@ -1072,7 +1073,7 @@ public static unsafe void Save(string path) ushort* cur = line; width = (int)(binmul.BaseStream.Position - streamLoc) / 2; binmul.BaseStream.Seek(lookup + (y * 2), SeekOrigin.Begin); - binmul.Write(width); + binmul.Write((ushort)width); binmul.BaseStream.Seek(streamLoc + (width * 2), SeekOrigin.Begin); int i = 0; int x = 0; @@ -1127,7 +1128,7 @@ public static unsafe void Save(string path) binidx.Write(length); binidx.Write(0); - _staticImageData[hash] = new ImageData + _staticImageData[(hash, bmp.Width, bmp.Height)] = new ImageData { Position = start, Length = length, From 95be1bd87d0c47df0f95b380e713f3c311c7e4fe Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:05:09 +0100 Subject: [PATCH 09/53] Close the previous client files on reload --- Ultima/AnimationEdit.cs | 7 +++++++ Ultima/Light.cs | 1 + Ultima/Multis.cs | 1 + Ultima/Sound.cs | 1 + Ultima/Textures.cs | 1 + 5 files changed, 11 insertions(+) diff --git a/Ultima/AnimationEdit.cs b/Ultima/AnimationEdit.cs index 0579b355..a5a7fba4 100644 --- a/Ultima/AnimationEdit.cs +++ b/Ultima/AnimationEdit.cs @@ -65,6 +65,13 @@ private static void InitializeCache() /// public static void Reload() { + _fileIndex?.Dispose(); + _fileIndex2?.Dispose(); + _fileIndex3?.Dispose(); + _fileIndex4?.Dispose(); + _fileIndex5?.Dispose(); + _fileIndex6?.Dispose(); + _fileIndex = new FileIndex("Anim.idx", "Anim.mul", 6); _fileIndex2 = new FileIndex("Anim2.idx", "Anim2.mul", -1); _fileIndex3 = new FileIndex("Anim3.idx", "Anim3.mul", -1); diff --git a/Ultima/Light.cs b/Ultima/Light.cs index 7137023c..fd825414 100644 --- a/Ultima/Light.cs +++ b/Ultima/Light.cs @@ -16,6 +16,7 @@ public sealed class Light /// public static void Reload() { + _fileIndex?.Dispose(); _fileIndex = new FileIndex("lightidx.mul", "light.mul", 100, -1); _cache = new Bitmap[100]; _removed = new bool[100]; diff --git a/Ultima/Multis.cs b/Ultima/Multis.cs index d805c6e3..ed2c2a0e 100644 --- a/Ultima/Multis.cs +++ b/Ultima/Multis.cs @@ -66,6 +66,7 @@ public enum ImportType /// public static void Reload() { + _fileIndex?.Dispose(); _fileIndex = new FileIndex("Multi.idx", "Multi.mul", MaximumMultiIndex, 14); _components = new MultiComponentList[MaximumMultiIndex]; ReloadUop(); diff --git a/Ultima/Sound.cs b/Ultima/Sound.cs index 109f28b0..5035188e 100644 --- a/Ultima/Sound.cs +++ b/Ultima/Sound.cs @@ -47,6 +47,7 @@ public static void Initialize() { _cache = new UoSound[0xFFF]; _removed = new bool[0xFFF]; + _fileIndex?.Dispose(); _fileIndex = new FileIndex("soundidx.mul", "sound.mul", "soundLegacyMUL.uop", 0xFFF, 8, ".dat", -1, false); var reg = new Regex(@"(\d{1,4}) \x7B(\d{1,4})\x7D (\d{1,3})", RegexOptions.Compiled); diff --git a/Ultima/Textures.cs b/Ultima/Textures.cs index 0814803d..1d256d84 100644 --- a/Ultima/Textures.cs +++ b/Ultima/Textures.cs @@ -30,6 +30,7 @@ private struct Checksums /// public static void Reload() { + _fileIndex?.Dispose(); _fileIndex = new FileIndex("Texidx.mul", "Texmaps.mul", 0x4000, 10); _cache = new Bitmap[0x4000]; _removed = new bool[0x4000]; From 04c5ce6f55f98d4158b939b083d840ee199c3828 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:07:43 +0100 Subject: [PATCH 10/53] Clip animation frame runs to the frame bounds Entries too short for a palette load as empty instead of decoding stale bytes. --- Ultima/AnimationEdit.cs | 23 ++++++++++------ Ultima/Animations.cs | 61 ++++++++++++++++++++--------------------- 2 files changed, 44 insertions(+), 40 deletions(-) diff --git a/Ultima/AnimationEdit.cs b/Ultima/AnimationEdit.cs index a5a7fba4..a79b891c 100644 --- a/Ultima/AnimationEdit.cs +++ b/Ultima/AnimationEdit.cs @@ -597,26 +597,31 @@ public unsafe Bitmap[] GetFrames() var bmp = new Bitmap(width, height, PixelFormat.Format16bppArgb1555); BitmapData bd = bmp.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format16bppArgb1555); - var line = (ushort*)bd.Scan0; + var pixels = (ushort*)bd.Scan0; int delta = bd.Stride >> 1; int xBase = frame.Center.X - 0x200; int yBase = frame.Center.Y + height - 0x200; - line += xBase; - line += yBase * delta; - + // Runs are clipped to the frame, so a bad header cannot write outside the bitmap. for (int j = 0; j < frame.RawData.Length; ++j) { FrameEdit.Raw raw = frame.RawData[j]; - ushort* cur = line + (((raw.offsetY) * delta) + ((raw.offsetX) & 0x3FF)); - ushort* end = cur + (raw.run); + int y = yBase + raw.offsetY; + if (y < 0 || y >= height) + { + continue; + } - int ii = 0; - while (cur < end) + ushort* row = pixels + (y * delta); + int x = xBase + (raw.offsetX & 0x3FF); + for (int k = 0; k < raw.run; ++k, ++x) { - *cur++ = Palette[raw.data[ii++]]; + if (x >= 0 && x < width) + { + row[x] = Palette[raw.data[k]]; + } } } diff --git a/Ultima/Animations.cs b/Ultima/Animations.cs index a861c174..ff2e8053 100644 --- a/Ultima/Animations.cs +++ b/Ultima/Animations.cs @@ -1,3 +1,4 @@ +using System; using System.Drawing; using System.Drawing.Imaging; using System.IO; @@ -143,9 +144,15 @@ public static AnimationFrame[] GetAnimation(int body, int action, int direction, _streamBuffer = new byte[length]; } - _ = stream.Read(_streamBuffer, 0, length); + int read = stream.ReadAtLeast(_streamBuffer.AsSpan(0, length), length, throwOnEndOfStream: false); - var memoryStream = new MemoryStream(_streamBuffer, false); + // Too short to hold a palette and a frame count: decoding it would read stale buffer bytes. + if (read < (PaletteCapacity * 2) + 4) + { + return null; + } + + var memoryStream = new MemoryStream(_streamBuffer, 0, read, false); bool flip = direction > 4; AnimationFrame[] frames; @@ -711,54 +718,46 @@ public unsafe AnimationFrame(ushort[] palette, BinaryReader bin, bool flip) var bmp = new Bitmap(width, height, PixelFormat.Format16bppArgb1555); BitmapData bd = bmp.LockBits( new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format16bppArgb1555); - var line = (ushort*)bd.Scan0; + var pixels = (ushort*)bd.Scan0; int delta = bd.Stride >> 1; - int header; - int xBase = xCenter - 0x200; int yBase = (yCenter + height) - 0x200; - if (!flip) + try { - line += xBase; - line += yBase * delta; - + int header; while ((header = bin.ReadInt32()) != 0x7FFF7FFF) { header ^= _doubleXor; - ushort* cur = line + ((((header >> 12) & 0x3FF) * delta) + ((header >> 22) & 0x3FF)); - ushort* end = cur + (header & 0xFFF); - while (cur < end) + // Runs are clipped to the frame, so a bad header cannot write outside the bitmap. + int y = yBase + ((header >> 12) & 0x3FF); + int x = xBase + ((header >> 22) & 0x3FF); + int run = header & 0xFFF; + bool rowInside = y >= 0 && y < height; + ushort* row = pixels + (y * delta); + + for (int k = 0; k < run; ++k, ++x) { - *cur++ = palette[bin.ReadByte()]; + ushort color = palette[bin.ReadByte()]; + if (rowInside && x >= 0 && x < width) + { + row[flip ? width - 1 - x : x] = color; + } } } } - else + finally { - line -= xBase - width + 1; - line += yBase * delta; - - while ((header = bin.ReadInt32()) != 0x7FFF7FFF) - { - header ^= _doubleXor; - - ushort* cur = line + ((((header >> 12) & 0x3FF) * delta) - ((header >> 22) & 0x3FF)); - ushort* end = cur - (header & 0xFFF); - - while (cur > end) - { - *cur-- = palette[bin.ReadByte()]; - } - } + bmp.UnlockBits(bd); + } + if (flip) + { xCenter = width - xCenter; } - bmp.UnlockBits(bd); - Center = new Point(xCenter, yCenter); Bitmap = bmp; } From 09b2a1feb3549123893519e9fec799082870e30c Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:09:09 +0100 Subject: [PATCH 11/53] Reject land tiles that are not 44x44 Replace from folder inserts into the tile list instead of overwriting the next id. --- Ultima/Art.cs | 7 ++ .../UserControls/LandTilesControl.cs | 77 +++++++++++++------ 2 files changed, 59 insertions(+), 25 deletions(-) diff --git a/Ultima/Art.cs b/Ultima/Art.cs index ce9d17fd..6f6dd182 100644 --- a/Ultima/Art.cs +++ b/Ultima/Art.cs @@ -223,8 +223,15 @@ public static void ReplaceStatic(int index, Bitmap bmp) /// /// /// + /// Thrown when the bitmap is not 44x44 public static void ReplaceLand(int index, Bitmap bmp) { + // Save encodes land as a fixed 44x44 block straight from the bitmap's memory. + if (bmp != null && (bmp.Width != 44 || bmp.Height != 44)) + { + throw new ArgumentException($"Land tiles must be 44x44 pixels, got {bmp.Width}x{bmp.Height}.", nameof(bmp)); + } + index &= 0x3FFF; _replaced[index] = bmp; _cache.Remove(index); diff --git a/UoFiddler.Controls/UserControls/LandTilesControl.cs b/UoFiddler.Controls/UserControls/LandTilesControl.cs index 0e11bfb9..a6a289c9 100644 --- a/UoFiddler.Controls/UserControls/LandTilesControl.cs +++ b/UoFiddler.Controls/UserControls/LandTilesControl.cs @@ -506,18 +506,9 @@ private void OnClickReplace(object sender, EventArgs e) bitmap = Utils.ConvertBmp(bitmap); } - // Validate image size (land tiles should be 44x44 but check anyway) - if (!Art.ValidateStaticSize(bitmap, out int estimatedSize)) + if (!CheckLandTileSize(bitmap, Path.GetFileName(dialog.FileName))) { - MessageBox.Show( - $"Image is too large for MUL format!\n\n" + - $"Image dimensions: {bitmap.Width}x{bitmap.Height}\n" + - $"Estimated encoded size: {estimatedSize:N0} ushorts\n" + - $"Maximum allowed: 65,535 ushorts\n\n" + - $"Note: Land tiles should typically be 44x44 pixels.", - "Image Too Large", - MessageBoxButtons.OK, - MessageBoxIcon.Warning); + bitmap.Dispose(); return; } @@ -579,19 +570,14 @@ private void ReplaceMultipleSelected() bitmap = Utils.ConvertBmp(bitmap); } - if (!Art.ValidateStaticSize(bitmap, out int estimatedSize)) + if (!CheckLandTileSize(bitmap, Path.GetFileName(files[i]))) { bitmap.Dispose(); - MessageBox.Show( - $"Image is too large for MUL format!\n\n" + - $"File: {Path.GetFileName(files[i])}\n" + - $"Estimated encoded size: {estimatedSize:N0} ushorts\n" + - $"Maximum allowed: 65,535 ushorts\n\n" + - $"Note: Land tiles should typically be 44x44 pixels.\n" + - $"No changes made.", - "Image Too Large", - MessageBoxButtons.OK, - MessageBoxIcon.Warning); + foreach (var loaded in bitmaps) + { + loaded.Dispose(); + } + return; } @@ -677,10 +663,18 @@ private void OnKeyDownInsert(object sender, KeyEventArgs e) bitmap = Utils.ConvertBmp(bitmap); } + if (!CheckLandTileSize(bitmap, Path.GetFileName(dialog.FileName))) + { + bitmap.Dispose(); + return; + } + Art.ReplaceLand(index, bitmap); ControlEvents.FireLandTileChangeEvent(this, index); + Options.ChangedUltimaClass["Art"] = true; + if (_showFreeSlots) { SelectedGraphicId = index; @@ -709,8 +703,6 @@ private void OnKeyDownInsert(object sender, KeyEventArgs e) LandTilesTileView.VirtualListSize = _tileList.Count; LandTilesTileView.Invalidate(); SelectedGraphicId = index; - - Options.ChangedUltimaClass["Art"] = true; } } } @@ -1130,6 +1122,10 @@ private void OnClickReplaceFromFolder(object sender, EventArgs e) AddSingleLandTile(file, index); replacedLines.Add($" 0x{index:X4} {name}"); } + catch (ArgumentException ex) + { + skippedLines.Add($" {name} ({ex.Message})"); + } catch { skippedLines.Add($" {name} (failed to load image)"); @@ -1187,6 +1183,13 @@ private void AddSingleLandTile(string fileName, int index) bitmap = Utils.ConvertBmp(bitmap); } + if (bitmap.Width != LandTileSize || bitmap.Height != LandTileSize) + { + string size = $"{bitmap.Width}x{bitmap.Height}"; + bitmap.Dispose(); + throw new ArgumentException($"{size}, land tiles must be {LandTileSize}x{LandTileSize}"); + } + Art.ReplaceLand(index, bitmap); ControlEvents.FireLandTileChangeEvent(this, index); @@ -1200,7 +1203,12 @@ private void AddSingleLandTile(string fileName, int index) continue; } - _tileList[i] = index; + // Insert before the first larger id; the id may already be listed + if (_tileList[i] != index) + { + _tileList.Insert(i, index); + } + done = true; break; } @@ -1212,6 +1220,25 @@ private void AddSingleLandTile(string fileName, int index) } } + /// + /// Land art is a fixed 44x44 block; anything else would be written out as garbage. + /// + private bool CheckLandTileSize(Bitmap bitmap, string source) + { + if (bitmap.Width == LandTileSize && bitmap.Height == LandTileSize) + { + return true; + } + + MessageBox.Show( + $"Invalid land tile dimensions!\n\n" + + $"{source}: {bitmap.Width}x{bitmap.Height}\n" + + $"Land tiles must be {LandTileSize}x{LandTileSize} pixels.\n\n" + + "No changes made.", + "Invalid Size", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return false; + } + public void UpdateTileView() { LandTilesTileView.TileBorderColor = Options.RemoveTileBorder From 5c2c2957c9e5f376c905a4585c612bb267699d84 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:09:59 +0100 Subject: [PATCH 12/53] Check texture size on single replace --- Ultima/Textures.cs | 7 +++++++ UoFiddler.Controls/UserControls/TexturesControl.cs | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/Ultima/Textures.cs b/Ultima/Textures.cs index 1d256d84..f5658248 100644 --- a/Ultima/Textures.cs +++ b/Ultima/Textures.cs @@ -58,8 +58,15 @@ public static void Remove(int index) /// /// /// + /// Thrown when the bitmap is not 64x64 or 128x128 public static void Replace(int index, Bitmap bmp) { + // Save writes the raw pixels and flags the size as 64 or 128; other sizes would be misread. + if (bmp != null && !((bmp.Width == 64 && bmp.Height == 64) || (bmp.Width == 128 && bmp.Height == 128))) + { + throw new ArgumentException($"Textures must be 64x64 or 128x128 pixels, got {bmp.Width}x{bmp.Height}.", nameof(bmp)); + } + _cache[index] = bmp; _removed[index] = false; _patched.Remove(index); diff --git a/UoFiddler.Controls/UserControls/TexturesControl.cs b/UoFiddler.Controls/UserControls/TexturesControl.cs index 20bfd309..7dabea06 100644 --- a/UoFiddler.Controls/UserControls/TexturesControl.cs +++ b/UoFiddler.Controls/UserControls/TexturesControl.cs @@ -402,6 +402,17 @@ private void OnClickReplace(object sender, EventArgs e) using (var bmpTemp = new Bitmap(dialog.FileName)) { + if (!IsValidTextureSize(bmpTemp)) + { + MessageBox.Show( + $"Invalid texture dimensions!\n\n" + + $"File: {Path.GetFileName(dialog.FileName)} ({bmpTemp.Width}x{bmpTemp.Height})\n" + + $"Textures must be 64x64 or 128x128 pixels.\n\n" + + "No changes made.", + "Invalid Size", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + Bitmap bitmap = new Bitmap(bmpTemp); if (dialog.FileName.Contains(".bmp")) From 6c81b789f3d69e1c0b23adfd8378bb3491de2320 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:09:59 +0100 Subject: [PATCH 13/53] Skip oversized images when adding items instead of throwing --- .../UserControls/ItemsControl.cs | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/UoFiddler.Controls/UserControls/ItemsControl.cs b/UoFiddler.Controls/UserControls/ItemsControl.cs index 4b1446fe..e38c2574 100644 --- a/UoFiddler.Controls/UserControls/ItemsControl.cs +++ b/UoFiddler.Controls/UserControls/ItemsControl.cs @@ -834,7 +834,14 @@ private void OnKeyDownInsertText(object sender, KeyEventArgs e) return; } - AddSingleItem(dialog.FileName, index); + try + { + AddSingleItem(dialog.FileName, index); + } + catch (ArgumentException ex) + { + MessageBox.Show($"{Path.GetFileName(dialog.FileName)}: {ex.Message}\n\nNo changes made.", "Insert", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } } } @@ -1675,16 +1682,29 @@ private void ReplaceStartingFromText_KeyDown(object sender, KeyEventArgs e) return; } + var failed = new List(); for (int i = 0; i < dialog.FileNames.Length; i++) { var currentIdx = index + i; if (IsIndexValid(currentIdx)) { - AddSingleItem(dialog.FileNames[i], currentIdx); + try + { + AddSingleItem(dialog.FileNames[i], currentIdx); + } + catch (ArgumentException ex) + { + failed.Add($"0x{currentIdx:X4} {Path.GetFileName(dialog.FileNames[i])}: {ex.Message}"); + } } } + if (failed.Count > 0) + { + MessageBox.Show($"Skipped {failed.Count} file(s):\n\n{string.Join("\n", failed)}", "Replace", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + ItemsTileView.VirtualListSize = _itemList.Count; ItemsTileView.Invalidate(); @@ -1700,6 +1720,7 @@ private void ReplaceStartingFromText_KeyDown(object sender, KeyEventArgs e) /// /// Filename of the image to add. /// Index where the static item will be added. + /// Thrown when the image is too large for the MUL format private void AddSingleItem(string fileName, int index) { using (var bmpTemp = new Bitmap(fileName)) @@ -1711,6 +1732,14 @@ private void AddSingleItem(string fileName, int index) bitmap = Utils.ConvertBmp(bitmap); } + // Checked before anything changes, so a failed file leaves the list as it was. + if (!Art.ValidateStaticSize(bitmap, out int estimatedSize)) + { + string size = $"{bitmap.Width}x{bitmap.Height}"; + bitmap.Dispose(); + throw new ArgumentException($"image too large: {size}, {estimatedSize:N0} of 65,535 ushorts"); + } + Art.ReplaceStatic(index, bitmap); ControlEvents.FireItemChangeEvent(this, index); @@ -1823,6 +1852,10 @@ private void OnClickReplaceFromFolder(object sender, EventArgs e) AddSingleItem(file, index); replacedLines.Add($" 0x{index:X4} {name}"); } + catch (ArgumentException ex) + { + skippedLines.Add($" {name} ({ex.Message})"); + } catch { skippedLines.Add($" {name} (failed to load image)"); From 220562f3d8db75236f8ece1ea445373d4b3bbb8d Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:10:30 +0100 Subject: [PATCH 14/53] Limit gump insert to ids inside the file --- UoFiddler.Controls/UserControls/GumpControl.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UoFiddler.Controls/UserControls/GumpControl.cs b/UoFiddler.Controls/UserControls/GumpControl.cs index 64b76262..f1e5a0f6 100644 --- a/UoFiddler.Controls/UserControls/GumpControl.cs +++ b/UoFiddler.Controls/UserControls/GumpControl.cs @@ -805,7 +805,7 @@ private void OnClickFindFree(object sender, EventArgs e) private void OnTextChanged_InsertAt(object sender, EventArgs e) { Color invalidColor = Options.DarkMode ? Color.OrangeRed : Color.Red; - if (Utils.ConvertStringToInt(InsertText.Text, out int index, 0, Gumps.GetCount())) + if (Utils.ConvertStringToInt(InsertText.Text, out int index, 0, Gumps.GetCount() - 1)) { InsertText.ForeColor = Gumps.IsValidIndex(index) ? invalidColor : SystemColors.ControlText; } @@ -822,7 +822,7 @@ private void OnKeydown_InsertText(object sender, KeyEventArgs e) return; } - if (!Utils.ConvertStringToInt(InsertText.Text, out int index, 0, Gumps.GetCount())) + if (!Utils.ConvertStringToInt(InsertText.Text, out int index, 0, Gumps.GetCount() - 1)) { return; } From 47e26deda7cd9e49b26d1501f530234b3ed7723d Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:10:31 +0100 Subject: [PATCH 15/53] Copy hue values on replace instead of sharing the object Marks hues as changed and refreshes other tabs. --- UoFiddler.Controls/UserControls/HuesControl.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/UoFiddler.Controls/UserControls/HuesControl.cs b/UoFiddler.Controls/UserControls/HuesControl.cs index 6687cf25..c1a25e33 100644 --- a/UoFiddler.Controls/UserControls/HuesControl.cs +++ b/UoFiddler.Controls/UserControls/HuesControl.cs @@ -259,13 +259,23 @@ private void OnKeyDownReplace(object sender, KeyEventArgs e) return; } - if (!Utils.ConvertStringToInt(ReplaceText.Text, out int index, 0, Hues.List.Length)) + if (!Utils.ConvertStringToInt(ReplaceText.Text, out int index, 0, Hues.List.Length - 1)) { return; } contextMenuStrip1.Close(); - Hues.List[_selected] = Hues.List[index]; + + // Copy the values: sharing the source Hue object would tie both slots together. + Hue source = Hues.List[index]; + Hue target = Hues.List[_selected]; + source.Colors.CopyTo(target.Colors, 0); + target.Name = source.Name; + target.TableStart = source.TableStart; + target.TableEnd = source.TableEnd; + + ControlEvents.FireHueChangeEvent(); + Options.ChangedUltimaClass["Hues"] = true; pictureBox.Invalidate(); } From f2752b86ecd7c5f092948582eba09de40e77ae7e Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:10:31 +0100 Subject: [PATCH 16/53] Stop adding a sound when no valid file is chosen --- UoFiddler.Controls/UserControls/SoundsControl.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/UoFiddler.Controls/UserControls/SoundsControl.cs b/UoFiddler.Controls/UserControls/SoundsControl.cs index 47555dc5..77d0d8c8 100644 --- a/UoFiddler.Controls/UserControls/SoundsControl.cs +++ b/UoFiddler.Controls/UserControls/SoundsControl.cs @@ -553,6 +553,7 @@ private void OnClickReplace(object sender, EventArgs e) if (!File.Exists(file)) { MessageBox.Show("Invalid Filename", "Add/Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); + return; } if (name.Length > 32) From 5dd3820446ef8cdfe6617324484cd75ffeb11586 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:10:31 +0100 Subject: [PATCH 17/53] Pass the multi id, not the row, to the multi change event --- UoFiddler.Controls/UserControls/MultisControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UoFiddler.Controls/UserControls/MultisControl.cs b/UoFiddler.Controls/UserControls/MultisControl.cs index 8ca277cc..714957c3 100644 --- a/UoFiddler.Controls/UserControls/MultisControl.cs +++ b/UoFiddler.Controls/UserControls/MultisControl.cs @@ -349,7 +349,7 @@ public void ChangeMulti(int id, MultiComponentList multi) _refMarker.SelectMulRow(pos); _refMarker.AfterSelect_Multi(this, EventArgs.Empty); - ControlEvents.FireMultiChangeEvent(this, pos); + ControlEvents.FireMultiChangeEvent(this, id); } private void AfterSelect_Multi(object sender, EventArgs e) From 49a1c88fa959b77149fc674f042d02528a1163c1 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:10:54 +0100 Subject: [PATCH 18/53] Refresh the cliloc grid after delete and insert CSV import keeps text that contains ';'. --- UoFiddler.Controls/UserControls/ClilocControl.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/UoFiddler.Controls/UserControls/ClilocControl.cs b/UoFiddler.Controls/UserControls/ClilocControl.cs index 794489d5..8150f2bf 100644 --- a/UoFiddler.Controls/UserControls/ClilocControl.cs +++ b/UoFiddler.Controls/UserControls/ClilocControl.cs @@ -390,6 +390,7 @@ private void OnClick_DeleteEntry(object sender, EventArgs e) } _cliloc.Entries.RemoveAt(dataGridView1.SelectedCells[0].OwningRow.Index); + _source.ResetBindings(false); dataGridView1.Invalidate(); Options.ChangedUltimaClass["CliLoc"] = true; } @@ -510,6 +511,7 @@ public void AddEntry(int number) { _cliloc.Entries.Insert(index, new StringEntry(number, "", StringEntry.CliLocFlag.Custom)); + _source.ResetBindings(false); dataGridView1.Invalidate(); dataGridView1.Rows[index].Selected = true; dataGridView1.FirstDisplayedScrollingRowIndex = index; @@ -591,14 +593,17 @@ private void OnClickImportCSV(object sender, EventArgs e) try { - string[] split = line.Split(';'); - if (split.Length < 3) + // Number;Text;Flag - the text itself can contain ';', so it is everything + // between the first and the last separator. + int first = line.IndexOf(';'); + int last = line.LastIndexOf(';'); + if (first < 0 || last <= first) { continue; } - int id = int.Parse(split[0].Trim()); - string text = split[1].Trim(); + int id = int.Parse(line.AsSpan(0, first).Trim()); + string text = line.Substring(first + 1, last - first - 1).Trim(); int index = 0; bool handled = false; From cfc43472c4bde8d9a93f88dc5683f501fc03a715 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:13:52 +0100 Subject: [PATCH 19/53] Subscribe the gump tab to events only once --- UoFiddler.Controls/UserControls/GumpControl.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/UoFiddler.Controls/UserControls/GumpControl.cs b/UoFiddler.Controls/UserControls/GumpControl.cs index f1e5a0f6..687045fe 100644 --- a/UoFiddler.Controls/UserControls/GumpControl.cs +++ b/UoFiddler.Controls/UserControls/GumpControl.cs @@ -50,6 +50,7 @@ private sealed record GumpEntry(string Name, string[] Tags); private static GumpControl _refMarker; private bool _loaded; + private bool _eventsSubscribed; private bool _showFreeSlots; private Dictionary _gumpEntries = new(); private string _activeNameFilter = string.Empty; @@ -222,11 +223,13 @@ protected override void OnLoad(EventArgs e) PopulateListBox(true); LoadGumpXml(); - if (!_loaded) + // Reload clears _loaded, so a separate flag keeps the handlers from being added twice. + if (!_eventsSubscribed) { ControlEvents.FilePathChangeEvent += OnFilePathChangeEvent; ControlEvents.GumpChangeEvent += OnGumpChangeEvent; ControlEvents.PreviewBackgroundColorChangeEvent += OnPreviewBackgroundColorChanged; + _eventsSubscribed = true; } _loaded = true; From 23beaa0c9b11fd9f8ade9e4f127e274f8dce16f2 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:13:52 +0100 Subject: [PATCH 20/53] Subscribe the animdata tab to events only once Show the first entry's frames after load; cap frames at 64 and offsets at a signed byte. --- .../UserControls/AnimDataControl.cs | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/UoFiddler.Controls/UserControls/AnimDataControl.cs b/UoFiddler.Controls/UserControls/AnimDataControl.cs index 7e0ad8ce..13e3b374 100644 --- a/UoFiddler.Controls/UserControls/AnimDataControl.cs +++ b/UoFiddler.Controls/UserControls/AnimDataControl.cs @@ -37,6 +37,7 @@ public AnimDataControl() private static AnimDataControl _refMarker; private static bool _loaded; + private bool _eventsSubscribed; private Animdata.AnimdataEntry _selAnimdataEntry; private int _currentSelect; private int _currentFrame; @@ -246,18 +247,24 @@ private void OnLoad(object sender, EventArgs e) treeView1.EndUpdate(); + // Loaded before the first selection, so CurrentSelect fills the frame list and points + // the editor at an entry of the file that was just read. + _loaded = true; + _currentSelect = -1; + _selAnimdataEntry = null; + treeViewFrames.Nodes.Clear(); + if (treeView1.Nodes.Count > 0) { treeView1.SelectedNode = treeView1.Nodes[0]; - _currentSelect = (int)treeView1.Nodes[0].Tag; + CurrentSelect = (int)treeView1.Nodes[0].Tag; } - if (!_loaded) + if (!_eventsSubscribed) { ControlEvents.FilePathChangeEvent += OnFilePathChangeEvent; + _eventsSubscribed = true; } - - _loaded = true; } } @@ -495,7 +502,21 @@ private void OnClickAdd(object sender, EventArgs e) return; } - _selAnimdataEntry.FrameData[_selAnimdataEntry.FrameCount] = (sbyte)(index - CurrentSelect); + // A record holds 64 frames, each stored as a signed byte offset from the base graphic. + if (_selAnimdataEntry.FrameCount >= _selAnimdataEntry.FrameData.Length) + { + MessageBox.Show($"An entry can hold at most {_selAnimdataEntry.FrameData.Length} frames.", "Add Frame", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + int offset = index - CurrentSelect; + if (offset < sbyte.MinValue || offset > sbyte.MaxValue) + { + MessageBox.Show($"Frames must be within {sbyte.MinValue} to {sbyte.MaxValue} ids of the base graphic.", "Add Frame", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + _selAnimdataEntry.FrameData[_selAnimdataEntry.FrameCount] = (sbyte)offset; _selAnimdataEntry.FrameCount++; TreeNode node = new TreeNode From c64795bb64410ced7dfe692e36f7a6da25d05dd2 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:17:05 +0100 Subject: [PATCH 21/53] Load the chosen profile when Enter is pressed --- UoFiddler/Forms/LoadProfileForm.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/UoFiddler/Forms/LoadProfileForm.cs b/UoFiddler/Forms/LoadProfileForm.cs index 38e74810..cf3efa43 100644 --- a/UoFiddler/Forms/LoadProfileForm.cs +++ b/UoFiddler/Forms/LoadProfileForm.cs @@ -76,6 +76,8 @@ private void LoadSelectedProfile() _log.LogInformation("Loading profile: {ProfileName}", Options.ProfileName); FiddlerOptions.LoadProfile($"{_profiles[comboBoxLoad.SelectedIndex]}.xml"); + // The Enter key path closes the form without a button, so set the result here. + DialogResult = DialogResult.OK; Close(); } From a44c8bddd13eb1ac7415750a4b630a65d00899e9 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:17:06 +0100 Subject: [PATCH 22/53] Tolerate missing profile attributes and plugins folder Missing or invalid values keep their defaults instead of stopping startup. --- UoFiddler/Classes/FiddlerOptions.cs | 56 ++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/UoFiddler/Classes/FiddlerOptions.cs b/UoFiddler/Classes/FiddlerOptions.cs index 15b3d568..f562fee5 100644 --- a/UoFiddler/Classes/FiddlerOptions.cs +++ b/UoFiddler/Classes/FiddlerOptions.cs @@ -82,7 +82,10 @@ public static void Startup() MoveFiles(di.GetFiles("DynamicItems.xml", SearchOption.TopDirectoryOnly), Options.AppDataPath); di = new DirectoryInfo(Path.Combine(Application.StartupPath, "plugins")); - MoveFiles(di.GetFiles("*.xml", SearchOption.TopDirectoryOnly), plugInPath); + if (di.Exists) + { + MoveFiles(di.GetFiles("*.xml", SearchOption.TopDirectoryOnly), plugInPath); + } string fileName = Path.Combine(Options.AppDataPath, "Options_default.xml"); if (!File.Exists(fileName)) @@ -299,6 +302,17 @@ public static void SaveProfile() _log.LogInformation("SaveProfile - done {Filename}", fileName); } + // Profile attributes can be missing in older or hand edited profiles; keep the fallback then. + private static bool ReadBool(XmlElement elem, string attribute, bool fallback) + { + return bool.TryParse(elem.GetAttribute(attribute), out bool value) ? value : fallback; + } + + private static int ReadInt(XmlElement elem, string attribute, int fallback) + { + return int.TryParse(elem.GetAttribute(attribute), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int value) ? value : fallback; + } + public static void LoadProfile(string filename) { _log.LogInformation("LoadProfile - start: {Filename}", filename); @@ -313,7 +327,13 @@ public static void LoadProfile(string filename) XmlDocument dom = new XmlDocument(); dom.Load(fileName); XmlElement xOptions = dom["Options"]; - XmlElement elem = (XmlElement)xOptions?.SelectSingleNode("OutputPath"); + if (xOptions == null) + { + _log.LogWarning("LoadProfile: no Options element in {Filename}", filename); + return; + } + + XmlElement elem = (XmlElement)xOptions.SelectSingleNode("OutputPath"); if (elem != null) { Options.OutputPath = elem.GetAttribute("path"); @@ -330,14 +350,14 @@ public static void LoadProfile(string filename) elem = (XmlElement)xOptions.SelectSingleNode("ItemSize"); if (elem != null) { - Options.ArtItemSizeWidth = int.Parse(elem.GetAttribute("width")); - Options.ArtItemSizeHeight = int.Parse(elem.GetAttribute("height")); + Options.ArtItemSizeWidth = ReadInt(elem, "width", Options.ArtItemSizeWidth); + Options.ArtItemSizeHeight = ReadInt(elem, "height", Options.ArtItemSizeHeight); } elem = (XmlElement)xOptions.SelectSingleNode("ItemClip"); if (elem != null) { - Options.ArtItemClip = bool.Parse(elem.GetAttribute("active")); + Options.ArtItemClip = ReadBool(elem, "active", Options.ArtItemClip); } elem = (XmlElement)xOptions.SelectSingleNode("SaveFormat"); @@ -349,7 +369,7 @@ public static void LoadProfile(string filename) elem = (XmlElement)xOptions.SelectSingleNode("CacheData"); if (elem != null) { - Files.CacheData = bool.Parse(elem.GetAttribute("active")); + Files.CacheData = ReadBool(elem, "active", Files.CacheData); } elem = (XmlElement)xOptions.SelectSingleNode("TileFocusColor"); @@ -367,7 +387,7 @@ public static void LoadProfile(string filename) elem = (XmlElement)xOptions.SelectSingleNode("RemoveTileBorder"); if (elem != null) { - Options.RemoveTileBorder = bool.Parse(elem.GetAttribute("active")); + Options.RemoveTileBorder = ReadBool(elem, "active", Options.RemoveTileBorder); } elem = (XmlElement)xOptions.SelectSingleNode("PreviewBackgroundColor"); @@ -402,7 +422,7 @@ public static void LoadProfile(string filename) } elem = (XmlElement)xOptions.SelectSingleNode("NewMapSize"); - if (elem != null && bool.Parse(elem.GetAttribute("active"))) + if (elem != null && ReadBool(elem, "active", false)) { Map.Felucca.Width = 7168; Map.Trammel.Width = 7168; @@ -411,19 +431,19 @@ public static void LoadProfile(string filename) elem = (XmlElement)xOptions.SelectSingleNode("UseMapDiff"); if (elem != null) { - Map.StartUpSetDiff(bool.Parse(elem.GetAttribute("active"))); + Map.StartUpSetDiff(ReadBool(elem, "active", false)); } elem = (XmlElement)xOptions.SelectSingleNode("PolSoundIdOffset"); if (elem != null) { - Options.PolSoundIdOffset = bool.Parse(elem.GetAttribute("active")); + Options.PolSoundIdOffset = ReadBool(elem, "active", Options.PolSoundIdOffset); } elem = (XmlElement)xOptions.SelectSingleNode("UpdateCheck"); if (elem != null) { - UpdateCheckOnStart = bool.Parse(elem.GetAttribute("active")); + UpdateCheckOnStart = ReadBool(elem, "active", UpdateCheckOnStart); } elem = (XmlElement)xOptions.SelectSingleNode("SendCharToLoc"); @@ -481,17 +501,19 @@ public static void LoadProfile(string filename) foreach (XmlElement xTab in xOptions.SelectNodes("TabView")) { - int viewTab = Convert.ToInt32(xTab.GetAttribute("tab")); - Options.ChangedViewState[viewTab] = false; + if (int.TryParse(xTab.GetAttribute("tab"), out int viewTab)) + { + Options.ChangedViewState[viewTab] = false; + } } elem = (XmlElement)xOptions.SelectSingleNode("ViewState"); if (elem != null) { - StoreFormState = bool.Parse(elem.GetAttribute("Active")); - MaximisedForm = bool.Parse(elem.GetAttribute("Maximised")); - FormPosition = new Point(int.Parse(elem.GetAttribute("PositionX")), int.Parse(elem.GetAttribute("PositionY"))); - FormSize = new Size(int.Parse(elem.GetAttribute("Width")), int.Parse(elem.GetAttribute("Height"))); + StoreFormState = ReadBool(elem, "Active", StoreFormState); + MaximisedForm = ReadBool(elem, "Maximised", MaximisedForm); + FormPosition = new Point(ReadInt(elem, "PositionX", FormPosition.X), ReadInt(elem, "PositionY", FormPosition.Y)); + FormSize = new Size(ReadInt(elem, "Width", FormSize.Width), ReadInt(elem, "Height", FormSize.Height)); } elem = (XmlElement)xOptions.SelectSingleNode("TileDataDirectlySaveOnChange"); From cc4cc708957f996f30c525ea6ae517f0354083f9 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:17:58 +0100 Subject: [PATCH 23/53] Subscribe plugins to events in Initialize, not the constructor Unsubscribe in Unload. --- UoFiddler.Plugin.ExamplePlugin/ExamplePluginBase.cs | 9 ++++----- UoFiddler.Plugin.MultiEditor/MultiEditorPluginBase.cs | 10 ++++------ UoFiddler.Plugin.SendItem/SendItem.cs | 11 +++++------ 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/UoFiddler.Plugin.ExamplePlugin/ExamplePluginBase.cs b/UoFiddler.Plugin.ExamplePlugin/ExamplePluginBase.cs index 5ecd39b7..0d539fc2 100644 --- a/UoFiddler.Plugin.ExamplePlugin/ExamplePluginBase.cs +++ b/UoFiddler.Plugin.ExamplePlugin/ExamplePluginBase.cs @@ -27,11 +27,6 @@ public class ExamplePluginBase : PluginBase { private const string _itemDescFileName = "itemdesc.cfg"; - public ExamplePluginBase() - { - PluginEvents.ModifyItemsControlContextMenuEvent += EventsModifyItemsControlContextMenuEvent; - } - /// /// Name of the plugin /// @@ -61,10 +56,14 @@ public override void Initialize() { // make something useful _ = Files.RootDir; + + // Subscribe here, not in the constructor: the Manage Plugins form constructs disabled plugins too. + PluginEvents.ModifyItemsControlContextMenuEvent += EventsModifyItemsControlContextMenuEvent; } public override void Unload() { + PluginEvents.ModifyItemsControlContextMenuEvent -= EventsModifyItemsControlContextMenuEvent; } public override void ModifyTabPages(TabControl tabControl) diff --git a/UoFiddler.Plugin.MultiEditor/MultiEditorPluginBase.cs b/UoFiddler.Plugin.MultiEditor/MultiEditorPluginBase.cs index 90b55e5f..954b8e9d 100644 --- a/UoFiddler.Plugin.MultiEditor/MultiEditorPluginBase.cs +++ b/UoFiddler.Plugin.MultiEditor/MultiEditorPluginBase.cs @@ -21,11 +21,6 @@ public class MultiEditorPluginBase : PluginBase { private UserControls.MultiEditorControl _multiEditorControl; - public MultiEditorPluginBase() - { - PluginEvents.ModifyItemsControlContextMenuEvent += EventsModifyItemsControlContextMenuEvent; - } - /// /// Author of the plugin /// @@ -54,12 +49,15 @@ public MultiEditorPluginBase() public override void Unload() { // fired in Fiddler OnClosing + PluginEvents.ModifyItemsControlContextMenuEvent -= EventsModifyItemsControlContextMenuEvent; } public override void Initialize() { - // fired on fiddler startup + // fired on fiddler startup; the Manage Plugins form also constructs disabled plugins, + // so events are subscribed here and not in the constructor _ = Files.RootDir; + PluginEvents.ModifyItemsControlContextMenuEvent += EventsModifyItemsControlContextMenuEvent; } public override void ModifyPluginToolStrip(ToolStripDropDownButton toolStrip) diff --git a/UoFiddler.Plugin.SendItem/SendItem.cs b/UoFiddler.Plugin.SendItem/SendItem.cs index 50dd0f52..fbe8ba75 100644 --- a/UoFiddler.Plugin.SendItem/SendItem.cs +++ b/UoFiddler.Plugin.SendItem/SendItem.cs @@ -26,12 +26,6 @@ namespace UoFiddler.Plugin.SendItem { public class SendItemPluginBase : PluginBase { - public SendItemPluginBase() - { - _refMarker = this; - Events.ModifyItemsControlContextMenuEvent += EventsModifyItemsControlContextMenuEvent; - } - private static SendItemPluginBase _refMarker; private static bool _overrideClick; @@ -79,6 +73,10 @@ public override void Initialize() { _ = Files.RootDir; + // Done here, not in the constructor: the Manage Plugins form constructs disabled plugins too. + _refMarker = this; + Events.ModifyItemsControlContextMenuEvent += EventsModifyItemsControlContextMenuEvent; + LoadXml(); ChangeOverrideClick(OverrideClick, true); } @@ -90,6 +88,7 @@ private void PlugOnDoubleClick(object sender, MouseEventArgs e) public override void Unload() { + Events.ModifyItemsControlContextMenuEvent -= EventsModifyItemsControlContextMenuEvent; SaveXml(); } From eaf5de67abb06f68e34f735f124dd5a92995b8a9 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:17:58 +0100 Subject: [PATCH 24/53] Show the profile's plugin choice in Manage Plugins Instances made only for the list are dropped on close and never unloaded. --- UoFiddler/Forms/ManagePluginsForm.cs | 42 +++++++++++++++++----------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/UoFiddler/Forms/ManagePluginsForm.cs b/UoFiddler/Forms/ManagePluginsForm.cs index 7738ddea..52f88dcf 100644 --- a/UoFiddler/Forms/ManagePluginsForm.cs +++ b/UoFiddler/Forms/ManagePluginsForm.cs @@ -10,6 +10,7 @@ ***************************************************************************/ using System; +using System.Collections.Generic; using System.Drawing; using System.Windows.Forms; using Microsoft.Extensions.Logging; @@ -23,6 +24,10 @@ public partial class ManagePluginsForm : Form { private static readonly ILogger _log = AppLog.For(typeof(ManagePluginsForm)); + // Instances made only to show a plugin's name and details. They are never initialized, + // so they are dropped again on close and never unloaded. + private readonly HashSet _temporaryInstances = new HashSet(); + public ManagePluginsForm() { InitializeComponent(); @@ -30,14 +35,16 @@ public ManagePluginsForm() foreach (AvailablePlugin plugin in GlobalPlugins.Plugins.AvailablePlugins) { - bool loaded = true; if (plugin.Instance == null) { _log.LogInformation("ManagePlugins - creating plugin instance: {Plugin} path: {AssemblyPath}", plugin.Type, plugin.AssemblyPath); plugin.CreateInstance(); - loaded = false; + _temporaryInstances.Add(plugin); } - checkedListBox1.Items.Add(plugin.Instance.Name, loaded); + + // The profile decides what is enabled; a change here takes effect on the next start. + bool enabled = Options.PluginsToLoad?.Contains(plugin.Type.ToString()) == true; + checkedListBox1.Items.Add(plugin.Instance.Name, enabled); } } @@ -74,23 +81,24 @@ private void OnClosing(object sender, FormClosingEventArgs e) { foreach (AvailablePlugin plug in GlobalPlugins.Plugins.AvailablePlugins) { - if (Options.PluginsToLoad?.Contains(plug.Type.ToString()) == false) - { - if (checkedListBox1.CheckedItems.Contains(plug.Instance.Name)) - { - _log.LogInformation("ManagePlugins - adding plugin to profile: {Plugin}", plug.Type.ToString()); - Options.PluginsToLoad.Add(plug.Type.ToString()); - } + string type = plug.Type.ToString(); + bool isChecked = checkedListBox1.CheckedItems.Contains(plug.Instance.Name); + bool enabled = Options.PluginsToLoad?.Contains(type) == true; - plug.Instance = null; + if (Options.PluginsToLoad != null && isChecked && !enabled) + { + _log.LogInformation("ManagePlugins - adding plugin to profile: {Plugin}", type); + Options.PluginsToLoad.Add(type); } - else + else if (Options.PluginsToLoad != null && !isChecked && enabled) { - if (!checkedListBox1.CheckedItems.Contains(plug.Instance.Name)) - { - _log.LogInformation("ManagePlugins - removing plugin from profile: {Plugin}", plug.Type.ToString()); - Options.PluginsToLoad.Remove(plug.Type.ToString()); - } + _log.LogInformation("ManagePlugins - removing plugin from profile: {Plugin}", type); + Options.PluginsToLoad.Remove(type); + } + + if (_temporaryInstances.Contains(plug)) + { + plug.Instance = null; } } } From 880ef6748a7b6a4d1d9307b4f14ea60cbcd54243 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:18:29 +0100 Subject: [PATCH 25/53] Load the land tile and texture lists only once A cross-tab Select before the tab was opened listed every tile twice. --- UoFiddler.Controls/UserControls/LandTilesControl.cs | 7 +++++++ UoFiddler.Controls/UserControls/TexturesControl.cs | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/UoFiddler.Controls/UserControls/LandTilesControl.cs b/UoFiddler.Controls/UserControls/LandTilesControl.cs index a6a289c9..df908401 100644 --- a/UoFiddler.Controls/UserControls/LandTilesControl.cs +++ b/UoFiddler.Controls/UserControls/LandTilesControl.cs @@ -178,11 +178,18 @@ private void OnLoad(object sender, EventArgs e) return; } + // A cross-tab Select loads the tab before its own Load event fires. + if (IsLoaded && (!(e is MyEventArgs args) || args.Type != MyEventArgs.Types.ForceReload)) + { + return; + } + using (new WaitCursorScope(this)) { Options.LoadedUltimaClass["TileData"] = true; Options.LoadedUltimaClass["Art"] = true; + _tileList.Clear(); _showFreeSlots = false; showFreeSlotsToolStripMenuItem.Checked = false; diff --git a/UoFiddler.Controls/UserControls/TexturesControl.cs b/UoFiddler.Controls/UserControls/TexturesControl.cs index 7dabea06..8b4fdff1 100644 --- a/UoFiddler.Controls/UserControls/TexturesControl.cs +++ b/UoFiddler.Controls/UserControls/TexturesControl.cs @@ -104,7 +104,7 @@ private void Reload() _selectedTextureId = -1; - OnLoad(this, EventArgs.Empty); + OnLoad(this, new MyEventArgs(MyEventArgs.Types.ForceReload)); } private void OnLoad(object sender, EventArgs e) @@ -114,10 +114,18 @@ private void OnLoad(object sender, EventArgs e) return; } + // A cross-tab Select loads the tab before its own Load event fires. + if (_loaded && (!(e is MyEventArgs args) || args.Type != MyEventArgs.Types.ForceReload)) + { + return; + } + using (new WaitCursorScope(this)) { Options.LoadedUltimaClass["Texture"] = true; + _textureList.Clear(); + for (int i = 0; i < Textures.GetIdxLength(); ++i) { if (Textures.TestTexture(i)) From 28e6c4114e9961b859818a8ccf4404359bb70e25 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:19:38 +0100 Subject: [PATCH 26/53] Serialize art reads on the shared file stream --- Ultima/Art.cs | 286 ++++++++++++++++++++++++++++---------------------- 1 file changed, 160 insertions(+), 126 deletions(-) diff --git a/Ultima/Art.cs b/Ultima/Art.cs index 6f6dd182..b098d2d9 100644 --- a/Ultima/Art.cs +++ b/Ultima/Art.cs @@ -39,6 +39,8 @@ private struct ImageData // M3.5: dedup index keyed by xxHash128 of the bitmap pixels. Replaces // the previous List + SHA256-bytes-in-each-entry layout, // which made CompareSaveImages an O(n²) linear scan. + private static readonly object _readLock = new object(); + private static Dictionary _landImageData; // Keyed by size as well: the pixel hash alone matches images of different sizes with the same bytes. private static Dictionary<(UInt128 Hash, int Width, int Height), ImageData> _staticImageData; @@ -319,20 +321,24 @@ public static bool IsValidStatic(int index) return true; } - Stream stream = _fileIndex.Seek(index, out int _, out int _, out bool _); - - if (stream == null) + // The file stream is shared; seek and read must not interleave with another thread. + lock (_readLock) { - return false; - } + Stream stream = _fileIndex.Seek(index, out int _, out int _, out bool _); - stream.Seek(4, SeekOrigin.Current); - stream.ReadExactly(_validBuffer, 0, 4); + if (stream == null) + { + return false; + } - short width = (short)(_validBuffer[0] | (_validBuffer[1] << 8)); - short height = (short)(_validBuffer[2] | (_validBuffer[3] << 8)); + stream.Seek(4, SeekOrigin.Current); + stream.ReadExactly(_validBuffer, 0, 4); - return width > 0 && height > 0; + short width = (short)(_validBuffer[0] | (_validBuffer[1] << 8)); + short height = (short)(_validBuffer[2] | (_validBuffer[3] << 8)); + + return width > 0 && height > 0; + } } /// @@ -392,23 +398,27 @@ public static Bitmap GetLand(int index, out bool patched) return cached; } - Stream stream = _fileIndex.Seek(index, out int length, out int _, out patched); - if (stream == null) + // The file stream is shared; seek and read must not interleave with another thread. + lock (_readLock) { - return null; - } + Stream stream = _fileIndex.Seek(index, out int length, out int _, out patched); + if (stream == null) + { + return null; + } - if (patched) - { - _patched[index] = true; - } + if (patched) + { + _patched[index] = true; + } - Bitmap bmp = LoadLand(stream, length); - if (Files.CacheData && bmp != null) - { - _cache.Set(index, bmp); + Bitmap bmp = LoadLand(stream, length); + if (Files.CacheData && bmp != null) + { + _cache.Set(index, bmp); + } + return bmp; } - return bmp; } // ReSharper disable once UnusedMember.Global @@ -416,15 +426,19 @@ public static byte[] GetRawLand(int index) { index &= 0x3FFF; - Stream stream = _fileIndex.Seek(index, out int length, out int _, out bool _); - if (stream == null) + // The file stream is shared; seek and read must not interleave with another thread. + lock (_readLock) { - return null; - } + Stream stream = _fileIndex.Seek(index, out int length, out int _, out bool _); + if (stream == null) + { + return null; + } - var buffer = new byte[length]; - stream.ReadExactly(buffer, 0, length); - return buffer; + var buffer = new byte[length]; + stream.ReadExactly(buffer, 0, length); + return buffer; + } } /// @@ -467,23 +481,27 @@ public static Bitmap GetStatic(int index, out bool patched, bool checkMaxId = tr return cached; } - Stream stream = _fileIndex.Seek(index, out int length, out int _, out patched); - if (stream == null) + // The file stream is shared; seek and read must not interleave with another thread. + lock (_readLock) { - return null; - } + Stream stream = _fileIndex.Seek(index, out int length, out int _, out patched); + if (stream == null) + { + return null; + } - if (patched) - { - _patched[index] = true; - } + if (patched) + { + _patched[index] = true; + } - Bitmap bmp = LoadStatic(stream, length); - if (Files.CacheData && bmp != null) - { - _cache.Set(index, bmp); + Bitmap bmp = LoadStatic(stream, length); + if (Files.CacheData && bmp != null) + { + _cache.Set(index, bmp); + } + return bmp; } - return bmp; } // ReSharper disable once UnusedMember.Global @@ -492,15 +510,19 @@ public static byte[] GetRawStatic(int index) index = GetLegalItemId(index); index += 0x4000; - Stream stream = _fileIndex.Seek(index, out int length, out int _, out bool _); - if (stream == null) + // The file stream is shared; seek and read must not interleave with another thread. + lock (_readLock) { - return null; - } + Stream stream = _fileIndex.Seek(index, out int length, out int _, out bool _); + if (stream == null) + { + return null; + } - var buffer = new byte[length]; - stream.ReadExactly(buffer, 0, length); - return buffer; + var buffer = new byte[length]; + stream.ReadExactly(buffer, 0, length); + return buffer; + } } /// @@ -532,42 +554,46 @@ public static unsafe bool TryGetStaticPixels(int index, Span destination return false; } - Stream stream = _fileIndex.Seek(index, out int length, out _, out patched); - if (stream == null) - { - return false; - } - - if (patched) - { - _patched[index] = true; - } - - byte[] buffer = ArrayPool.Shared.Rent(length); - try + // The file stream is shared; seek and read must not interleave with another thread. + lock (_readLock) { - stream.ReadExactly(buffer, 0, length); - - ReadOnlySpan entry = buffer.AsSpan(0, length); - if (!TryReadStaticSize(entry, out width, out height)) + Stream stream = _fileIndex.Seek(index, out int length, out _, out patched); + if (stream == null) { return false; } - if (destination.Length < width * height) + if (patched) { - return false; + _patched[index] = true; } - fixed (ushort* destPtr = destination) + byte[] buffer = ArrayPool.Shared.Rent(length); + try + { + stream.ReadExactly(buffer, 0, length); + + ReadOnlySpan entry = buffer.AsSpan(0, length); + if (!TryReadStaticSize(entry, out width, out height)) + { + return false; + } + + if (destination.Length < width * height) + { + return false; + } + + fixed (ushort* destPtr = destination) + { + return DecodeStatic(entry, destPtr, width, height, width); + } + } + finally { - return DecodeStatic(entry, destPtr, width, height, width); + ArrayPool.Shared.Return(buffer); } } - finally - { - ArrayPool.Shared.Return(buffer); - } } /// @@ -590,60 +616,64 @@ public static unsafe bool TryGetLandPixels(int index, Span destination, return false; } - Stream stream = _fileIndex.Seek(index, out int length, out _, out patched); - if (stream == null || length < LandTileBytes) - { - return false; - } - - if (patched) - { - _patched[index] = true; - } - - byte[] buffer = ArrayPool.Shared.Rent(length); - try + // The file stream is shared; seek and read must not interleave with another thread. + lock (_readLock) { - stream.ReadExactly(buffer, 0, length); + Stream stream = _fileIndex.Seek(index, out int length, out _, out patched); + if (stream == null || length < LandTileBytes) + { + return false; + } - destination.Slice(0, 44 * 44).Clear(); + if (patched) + { + _patched[index] = true; + } - fixed (byte* binData = buffer) - fixed (ushort* destPtr = destination) + byte[] buffer = ArrayPool.Shared.Rent(length); + try { - var bdata = (ushort*)binData; - int xOffset = 21; - int xRun = 2; - ushort* line = destPtr; + stream.ReadExactly(buffer, 0, length); - for (int y = 0; y < 22; ++y, --xOffset, xRun += 2, line += 44) + destination.Slice(0, 44 * 44).Clear(); + + fixed (byte* binData = buffer) + fixed (ushort* destPtr = destination) { - ushort* cur = line + xOffset; - ushort* end = cur + xRun; - while (cur < end) + var bdata = (ushort*)binData; + int xOffset = 21; + int xRun = 2; + ushort* line = destPtr; + + for (int y = 0; y < 22; ++y, --xOffset, xRun += 2, line += 44) { - *cur++ = (ushort)(*bdata++ | 0x8000); + ushort* cur = line + xOffset; + ushort* end = cur + xRun; + while (cur < end) + { + *cur++ = (ushort)(*bdata++ | 0x8000); + } } - } - xOffset = 0; - xRun = 44; - for (int y = 0; y < 22; ++y, ++xOffset, xRun -= 2, line += 44) - { - ushort* cur = line + xOffset; - ushort* end = cur + xRun; - while (cur < end) + xOffset = 0; + xRun = 44; + for (int y = 0; y < 22; ++y, ++xOffset, xRun -= 2, line += 44) { - *cur++ = (ushort)(*bdata++ | 0x8000); + ushort* cur = line + xOffset; + ushort* end = cur + xRun; + while (cur < end) + { + *cur++ = (ushort)(*bdata++ | 0x8000); + } } } - } - return true; - } - finally - { - ArrayPool.Shared.Return(buffer); + return true; + } + finally + { + ArrayPool.Shared.Return(buffer); + } } } @@ -663,18 +693,22 @@ public static bool TryGetStaticDimensions(int index, out int width, out int heig return false; } - Stream stream = _fileIndex.Seek(index, out int length, out _, out _); - if (stream == null || length < 8) + // The file stream is shared; seek and read must not interleave with another thread. + lock (_readLock) { - return false; - } + Stream stream = _fileIndex.Seek(index, out int length, out _, out _); + if (stream == null || length < 8) + { + return false; + } - // Header layout: 4 unknown ushorts then width, height as ushorts at offset 4 and 6. - Span header = stackalloc byte[8]; - stream.ReadExactly(header); - width = header[4] | (header[5] << 8); - height = header[6] | (header[7] << 8); - return width > 0 && height > 0; + // Header layout: 4 unknown ushorts then width, height as ushorts at offset 4 and 6. + Span header = stackalloc byte[8]; + stream.ReadExactly(header); + width = header[4] | (header[5] << 8); + height = header[6] | (header[7] << 8); + return width > 0 && height > 0; + } } public static unsafe void Measure(Bitmap bmp, out int xMin, out int yMin, out int xMax, out int yMax) From d193e24e25566a96037cfae71facde70b696d5cd Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:19:38 +0100 Subject: [PATCH 27/53] Preload items from a copy of the list and report errors --- UoFiddler.Controls/UserControls/ItemsControl.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/UoFiddler.Controls/UserControls/ItemsControl.cs b/UoFiddler.Controls/UserControls/ItemsControl.cs index e38c2574..a8431e8d 100644 --- a/UoFiddler.Controls/UserControls/ItemsControl.cs +++ b/UoFiddler.Controls/UserControls/ItemsControl.cs @@ -1389,16 +1389,19 @@ private void OnClickPreLoad(object sender, EventArgs e) ProgressBar.Step = 1; ProgressBar.Value = 1; ProgressBar.Visible = true; - PreLoader.RunWorkerAsync(); + + // The worker gets its own copy: the list can change while it runs. + PreLoader.RunWorkerAsync(_itemList.ToArray()); } private void PreLoaderDoWork(object sender, DoWorkEventArgs e) { - int total = _itemList.Count; + var items = (int[])e.Argument; + int total = items.Length; int reportEvery = Math.Max(1, total / 200); int sinceReport = 0; int done = 0; - foreach (int item in _itemList) + foreach (int item in items) { Art.GetStatic(item); ++done; @@ -1419,6 +1422,11 @@ private void PreLoaderProgressChanged(object sender, ProgressChangedEventArgs e) private void PreLoaderCompleted(object sender, RunWorkerCompletedEventArgs e) { ProgressBar.Visible = false; + + if (e.Error != null) + { + MessageBox.Show($"Preloading items stopped: {e.Error.Message}", "Preload", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } } private void ItemsTileView_DrawItem(object sender, TileViewControl.DrawTileListItemEventArgs e) From 7e2020af0d6ec6f6ed9143805a2f9669494f0547 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:20:15 +0100 Subject: [PATCH 28/53] Leave multi editor shortcuts to text boxes that have focus --- .../UserControls/MultiEditorControl.cs | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs b/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs index 7bda5672..7d2298ee 100644 --- a/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs +++ b/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs @@ -2222,9 +2222,24 @@ private void PictureBoxDrawTiles_Select() } } + /// + /// True when keyboard focus is in a text or number box, which needs its own keys + /// (minus, brackets, arrows, Ctrl+C/V/Z). + /// + private bool IsTextInputFocused() + { + Control focused = ActiveControl; + while (focused is ContainerControl { ActiveControl: not null } container) + { + focused = container.ActiveControl; + } + + return focused is TextBoxBase or UpDownBase or ComboBox; + } + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { - if (_compList != null) + if (_compList != null && (keyData == Keys.Escape || !IsTextInputFocused())) { // Undo / Redo if (keyData == (Keys.Control | Keys.Z)) @@ -2334,18 +2349,15 @@ protected override bool ProcessCmdKey(ref Message msg, Keys keyData) return true; } - if (ActiveControl is not TextBox && ActiveControl is not NumericUpDown) - { - if (keyData == Keys.S) { BTN_Select_Click(null, EventArgs.Empty); return true; } - if (keyData == Keys.D) { BTN_Draw_Click(null, EventArgs.Empty); return true; } - if (keyData == Keys.R) { BTN_Remove_Click(null, EventArgs.Empty); return true; } - if (keyData == Keys.E) { BTN_Z_Click(null, EventArgs.Empty); return true; } - if (keyData == Keys.F) { BTN_Floor.Checked = !BTN_Floor.Checked; return true; } - if (keyData == Keys.P) { BTN_Pipette_Click(null, EventArgs.Empty); return true; } - if (keyData == Keys.T) { BTN_Trans_Clicked(null, EventArgs.Empty); return true; } - if (keyData == Keys.B) { BTN_RectFill_Click(null, EventArgs.Empty); return true; } - if (keyData == Keys.L) { BTN_LineDraw_Click(null, EventArgs.Empty); return true; } - } + if (keyData == Keys.S) { BTN_Select_Click(null, EventArgs.Empty); return true; } + if (keyData == Keys.D) { BTN_Draw_Click(null, EventArgs.Empty); return true; } + if (keyData == Keys.R) { BTN_Remove_Click(null, EventArgs.Empty); return true; } + if (keyData == Keys.E) { BTN_Z_Click(null, EventArgs.Empty); return true; } + if (keyData == Keys.F) { BTN_Floor.Checked = !BTN_Floor.Checked; return true; } + if (keyData == Keys.P) { BTN_Pipette_Click(null, EventArgs.Empty); return true; } + if (keyData == Keys.T) { BTN_Trans_Clicked(null, EventArgs.Empty); return true; } + if (keyData == Keys.B) { BTN_RectFill_Click(null, EventArgs.Empty); return true; } + if (keyData == Keys.L) { BTN_LineDraw_Click(null, EventArgs.Empty); return true; } } return base.ProcessCmdKey(ref msg, keyData); From 6c8cda24b567791c3b5ea53c111785376033b673 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:21:14 +0100 Subject: [PATCH 29/53] Clear multi editor selection after undo, redo and open Moves and removes ignore tiles no longer in the multi; Z is clamped to a signed byte on add. --- .../Classes/MultiEditorComponentList.cs | 28 ++++++---- .../UserControls/MultiEditorControl.cs | 53 +++++++++++++------ 2 files changed, 55 insertions(+), 26 deletions(-) diff --git a/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs b/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs index 5ec34c46..048c6345 100644 --- a/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs +++ b/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs @@ -401,7 +401,7 @@ public void TileAdd(int x, int y, int z, ushort id) } AddToUndoList("Add Tile"); - MultiTile tile = new MultiTile(id, x, y, z, 1); + MultiTile tile = new MultiTile(id, x, y, ClampZ(z), 1); Tiles.Add(tile); CalcSolver(x, y); _modified = true; @@ -412,6 +412,11 @@ public void TileAdd(int x, int y, int z, ushort id) /// /// Moves given /// + /// + /// Z is saved as a signed byte; larger values would wrap around. + /// + private static int ClampZ(int z) => Math.Clamp(z, sbyte.MinValue, sbyte.MaxValue); + public void TileMove(MultiTile tile, int newX, int newY) { if (Width == 0 || Height == 0) @@ -419,13 +424,14 @@ public void TileMove(MultiTile tile, int newX, int newY) return; } - AddToUndoList("Move Tile"); - - if (tile == null) + // A tile from before an undo or another multi is no longer in the list. + if (tile == null || newX < 0 || newX >= Width || newY < 0 || newY >= Height || !Tiles.Contains(tile)) { return; } + AddToUndoList("Move Tile"); + tile.X = newX; tile.Y = newY; CalcSolver(newX, newY); @@ -483,13 +489,13 @@ public void TileRemove(MultiTile tile) return; } - AddToUndoList("Remove Tile"); - - if (tile == null) + if (tile == null || !Tiles.Contains(tile)) { return; } + AddToUndoList("Remove Tile"); + Tiles.Remove(tile); _modified = true; RecalculateMinMax(tile); @@ -591,7 +597,7 @@ public void TileAddRect(int x0, int y0, int x1, int y1, int z, ushort id) continue; } - Tiles.Add(new MultiTile(id, x, y, z, 1)); + Tiles.Add(new MultiTile(id, x, y, ClampZ(z), 1)); CalcSolver(x, y); } } @@ -614,7 +620,7 @@ public void TileAddLine(int x0, int y0, int x1, int y1, int z, ushort id) continue; } - Tiles.Add(new MultiTile(id, x, y, z, 1)); + Tiles.Add(new MultiTile(id, x, y, ClampZ(z), 1)); CalcSolver(x, y); } @@ -675,7 +681,7 @@ public void TileAddBrush(int cx, int cy, int z, ushort id, int brushSize) continue; } - Tiles.Add(new MultiTile(id, x, y, z, 1)); + Tiles.Add(new MultiTile(id, x, y, ClampZ(z), 1)); CalcSolver(x, y); } } @@ -698,7 +704,7 @@ public void TileAddBatch(List<(ushort id, int x, int y, int z)> tiles) continue; } - Tiles.Add(new MultiTile(id, x, y, z, 1)); + Tiles.Add(new MultiTile(id, x, y, ClampZ(z), 1)); CalcSolver(x, y); } diff --git a/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs b/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs index 7d2298ee..be017501 100644 --- a/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs +++ b/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs @@ -181,6 +181,7 @@ private void BTN_CreateBlank_Click(object sender, EventArgs e) int height = (int)numericUpDown_Size_Height.Value; _compList = new MultiEditorComponentList(width, height, this); + ClearTileReferences(); UndoList_Clear(); @@ -1679,6 +1680,7 @@ private void TreeViewMultiList_LoadFromFile(Multis.ImportType importType) MultiComponentList multi = Multis.LoadFromFile(dialog.FileName, importType); _compList = new MultiEditorComponentList(multi, this); + ClearTileReferences(); UndoList_Clear(); @@ -1771,6 +1773,7 @@ private void TreeViewMultiList_NodeMouseDoubleClick(object sender, TreeNodeMouse textBox_SaveToID.Text = e.Node.Tag.ToString(); } + ClearTileReferences(); UndoList_Clear(); MaxHeightTrackBar.Minimum = _compList.ZMin; @@ -1895,17 +1898,8 @@ private void Undo_onClick(object sender, EventArgs e) int undo = (int)item.Tag; _compList.Undo(undo); - MaxHeightTrackBar.Minimum = _compList.ZMin; - MaxHeightTrackBar.Maximum = _compList.ZMax; - MaxHeightTrackBar.Value = _compList.ZMax; - - numericUpDown_Size_Width.Value = _compList.Width; - numericUpDown_Size_Height.Value = _compList.Height; - - numericUpDown_Selected_X.Maximum = _compList.Width - 1; - numericUpDown_Selected_Y.Maximum = _compList.Height - 1; - - ScrollbarsSetValue(); + ClearTileReferences(); + SyncControlsToList(); pictureBoxMulti.Invalidate(); } @@ -2222,6 +2216,35 @@ private void PictureBoxDrawTiles_Select() } } + /// + /// Drops every reference to tiles of the previous list. Undo, redo and opening a multi + /// rebuild the tiles as new objects, so old references would edit tiles no longer shown. + /// + private void ClearTileReferences() + { + _selectedTiles.Clear(); + _overlayList.Clear(); + HoverTile = null; + SelectedTile = null; + _isPasteMode = false; + } + + /// + /// Brings the size, height and selection limits in line with the current list. + /// + private void SyncControlsToList() + { + UpdateTrackBarAfterZChange(); + + numericUpDown_Size_Width.Value = _compList.Width; + numericUpDown_Size_Height.Value = _compList.Height; + + numericUpDown_Selected_X.Maximum = _compList.Width - 1; + numericUpDown_Selected_Y.Maximum = _compList.Height - 1; + + ScrollbarsSetValue(); + } + /// /// True when keyboard focus is in a text or number box, which needs its own keys /// (minus, brackets, arrows, Ctrl+C/V/Z). @@ -2246,8 +2269,8 @@ protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { if (_compList.UndoStep()) { - UpdateTrackBarAfterZChange(); - _selectedTiles.Clear(); + ClearTileReferences(); + SyncControlsToList(); pictureBoxMulti.Invalidate(); } @@ -2258,8 +2281,8 @@ protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { if (_compList.RedoStep()) { - UpdateTrackBarAfterZChange(); - _selectedTiles.Clear(); + ClearTileReferences(); + SyncControlsToList(); pictureBoxMulti.Invalidate(); } From 333ea7bc5fed72faed69e3041d53279a2a6ac135 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:21:33 +0100 Subject: [PATCH 30/53] Apply zoom to the multi editor marquee selection --- .../UserControls/MultiEditorControl.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs b/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs index be017501..238952f2 100644 --- a/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs +++ b/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs @@ -916,7 +916,8 @@ private void PictureBoxMultiOnMouseMove(object sender, MouseEventArgs e) } else if (_isMarqueeDragging) { - _marqueeEnd = new Point(e.X + hScrollBar.Value, e.Y + vScrollBar.Value); + // Same zoom-aware space as the tiles, so the rectangle selects what it covers. + _marqueeEnd = ToScrolledSpace(e.Location); } else if (_isRectFillDragging && _compList != null) { @@ -955,7 +956,7 @@ private void PictureBoxMultiOnMouseDown(object sender, MouseEventArgs e) { // Start marquee drag over empty space _isMarqueeDragging = true; - _marqueeStart = new Point(e.X + hScrollBar.Value, e.Y + vScrollBar.Value); + _marqueeStart = ToScrolledSpace(e.Location); _marqueeEnd = _marqueeStart; } } From c4d385a8113a24404558648eaed8bb913f1fcaf0 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:21:59 +0100 Subject: [PATCH 31/53] Resolve mass import paths against the XML file's folder Missing attributes no longer stop the load. --- UoFiddler.Plugin.MassImport/Forms/MassImportForm.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/UoFiddler.Plugin.MassImport/Forms/MassImportForm.cs b/UoFiddler.Plugin.MassImport/Forms/MassImportForm.cs index 4176accf..e84c1626 100644 --- a/UoFiddler.Plugin.MassImport/Forms/MassImportForm.cs +++ b/UoFiddler.Plugin.MassImport/Forms/MassImportForm.cs @@ -174,6 +174,9 @@ private void LoadXMLOnClick(object sender, EventArgs e) int currBoxLength = 0; + // Relative file paths are relative to the XML file, not to the working directory. + string baseDirectory = Path.GetDirectoryName(Path.GetFullPath(fileName)); + foreach (XmlNode xNode in dom.SelectSingleNode("MassImport")) { if (xNode.NodeType == XmlNodeType.Comment) @@ -213,13 +216,14 @@ private void LoadXMLOnClick(object sender, EventArgs e) break; } - entry.File = xNode.Attributes["file"].InnerText; + // A missing attribute reads as empty: no file, an invalid index, or not a removal. + entry.File = xNode.Attributes?["file"]?.InnerText ?? string.Empty; if (!string.IsNullOrEmpty(entry.File)) { - entry.File = Path.GetFullPath(entry.File); + entry.File = Path.GetFullPath(entry.File, baseDirectory); } - if (Utils.ConvertStringToInt(xNode.Attributes["index"].InnerText, out int temp)) + if (Utils.ConvertStringToInt(xNode.Attributes?["index"]?.InnerText ?? string.Empty, out int temp)) { entry.Index = temp; } @@ -228,7 +232,7 @@ private void LoadXMLOnClick(object sender, EventArgs e) entry.Index = -1; } - entry.Remove = bool.Parse(xNode.Attributes["remove"].InnerText); + entry.Remove = bool.TryParse(xNode.Attributes?["remove"]?.InnerText, out bool remove) && remove; entry.Valid = true; string message = entry.Test(); From 4536eebc458427708be938cc0a0db3dd3530bd63 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:21:59 +0100 Subject: [PATCH 32/53] Fix mass import index limits for hues, textures and multis --- UoFiddler.Plugin.MassImport/Imports/ImportEntryHue.cs | 2 +- UoFiddler.Plugin.MassImport/Imports/ImportEntryMulti.cs | 2 +- UoFiddler.Plugin.MassImport/Imports/ImportEntryTexture.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryHue.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryHue.cs index 2195a7ed..86013818 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryHue.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryHue.cs @@ -16,7 +16,7 @@ namespace UoFiddler.Plugin.MassImport.Imports { public class ImportEntryHue : ImportEntry { - public override int MaxIndex => 3000; + public override int MaxIndex => Ultima.Hues.List.Length - 1; public override string Name => "Hue"; diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryMulti.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryMulti.cs index 30376072..711b9ad1 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryMulti.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryMulti.cs @@ -19,7 +19,7 @@ namespace UoFiddler.Plugin.MassImport.Imports { internal class ImportEntryMulti : ImportEntry { - public override int MaxIndex => Multis.MaximumMultiIndex; + public override int MaxIndex => Multis.MaximumMultiIndex - 1; public override string Name => "Multi"; diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTexture.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTexture.cs index ba4792c3..55c65563 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTexture.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTexture.cs @@ -17,7 +17,7 @@ namespace UoFiddler.Plugin.MassImport.Imports { public class ImportEntryTexture : ImportEntry { - public override int MaxIndex => Ultima.Textures.GetIdxLength(); + public override int MaxIndex => Ultima.Textures.GetIdxLength() - 1; public override string Name => "Texture"; From 9bb2b4f917a2f3185e0e3d08258e1c09a1c3d4a3 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:22:56 +0100 Subject: [PATCH 33/53] Fix inverted light cache condition --- Ultima/Light.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Ultima/Light.cs b/Ultima/Light.cs index fd825414..042521ac 100644 --- a/Ultima/Light.cs +++ b/Ultima/Light.cs @@ -175,9 +175,9 @@ public static unsafe Bitmap GetLight(int index) bmp.UnlockBits(bd); } - if (!Files.CacheData) + if (Files.CacheData) { - return _cache[index] = bmp; + _cache[index] = bmp; } return bmp; From 99d4d017e98112380f33d6bc4053b3a8e3773a52 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:22:56 +0100 Subject: [PATCH 34/53] Load an unreadable gump file as empty instead of null --- Ultima/FileIndex.cs | 19 +++++++++++++++++-- Ultima/Gumps.cs | 3 ++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Ultima/FileIndex.cs b/Ultima/FileIndex.cs index a855ea36..070b7c63 100644 --- a/Ultima/FileIndex.cs +++ b/Ultima/FileIndex.cs @@ -15,11 +15,26 @@ public sealed class FileIndex : IDisposable public long IdxLength { get => FileAccessor?.IdxLength ?? 0; } public IEntry this[int index] { - get => FileAccessor[index]; + get => FileAccessor?[index]; // Let the accessor cast: it knows whether it stores Entry3D or Entry6D. - set => FileAccessor[index] = value; + set + { + if (FileAccessor != null) + { + FileAccessor[index] = value; + } + } } + private FileIndex() + { + } + + /// + /// An index with no backing file: every entry reads as missing. + /// + public static FileIndex CreateEmpty() => new FileIndex(); + private readonly Lock _entryWriteLock = new(); /// diff --git a/Ultima/Gumps.cs b/Ultima/Gumps.cs index 239000cb..3910f2d1 100644 --- a/Ultima/Gumps.cs +++ b/Ultima/Gumps.cs @@ -101,7 +101,8 @@ public static void Reload() } catch { - _fileIndex = null; + // An unreadable file loads as no gumps, so reads return nothing instead of throwing. + _fileIndex = FileIndex.CreateEmpty(); _indexLength = _maxGumpIndex; _cache?.Clear(); _cache ??= new LruBitmapCache(Files.CacheCapacityGumps); From fdcbfa2478687fff02f5a8c2d6cb4d0819f417ac Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:23:28 +0100 Subject: [PATCH 35/53] Guard sound reads against missing files and bad ids --- Ultima/Sound.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Ultima/Sound.cs b/Ultima/Sound.cs index 5035188e..4afcae80 100644 --- a/Ultima/Sound.cs +++ b/Ultima/Sound.cs @@ -104,7 +104,7 @@ public static UoSound GetSound(int soundId, out bool translated) return null; } - if (_removed[soundId]) + if (soundId >= _removed.Length || _removed[soundId]) { return null; } @@ -116,7 +116,7 @@ public static UoSound GetSound(int soundId, out bool translated) Stream stream = _fileIndex.Seek(soundId, out int length, out int _, out bool _); - if (_fileIndex[soundId].Lookup < 0 || length <= 0) + if ((_fileIndex[soundId]?.Lookup ?? -1) < 0 || length <= 0) { if (!_translations.TryGetValue(soundId, out soundId)) { @@ -127,7 +127,7 @@ public static UoSound GetSound(int soundId, out bool translated) stream = _fileIndex.Seek(soundId, out length, out int _, out bool _); } - if (stream == null) + if (stream == null || length < _mulNameLength) { return null; } @@ -209,7 +209,7 @@ public static bool IsValidSound(int soundId, out string name, out bool translate return false; } - if (_removed[soundId]) + if (soundId >= _removed.Length || _removed[soundId]) { return false; } @@ -222,7 +222,7 @@ public static bool IsValidSound(int soundId, out string name, out bool translate Stream stream = _fileIndex.Seek(soundId, out int length, out _, out _); - if (_fileIndex[soundId].Lookup < 0 || length <= 0) + if ((_fileIndex[soundId]?.Lookup ?? -1) < 0 || length <= 0) { if (!_translations.TryGetValue(soundId, out soundId)) { @@ -262,7 +262,7 @@ public static double GetSoundLength(int soundId) return 0; } - if (_removed[soundId]) + if (soundId >= _removed.Length || _removed[soundId]) { return 0; } @@ -277,7 +277,7 @@ public static double GetSoundLength(int soundId) else { Stream stream = _fileIndex.Seek(soundId, out int length, out int _, out bool _); - if (_fileIndex[soundId].Lookup < 0 || length <= 0) + if ((_fileIndex[soundId]?.Lookup ?? -1) < 0 || length <= 0) { if (!_translations.TryGetValue(soundId, out soundId)) { @@ -287,7 +287,7 @@ public static double GetSoundLength(int soundId) stream = _fileIndex.Seek(soundId, out length, out int _, out bool _); } - if (stream == null) + if (stream == null || length < _mulNameLength) { return 0; } From 084109c66820d24da5b397748df2cd2b24b92447 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:23:42 +0100 Subject: [PATCH 36/53] Clear the compression flag on verdata patched UOP entries --- Ultima/FileIndex.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Ultima/FileIndex.cs b/Ultima/FileIndex.cs index 070b7c63..3604ae68 100644 --- a/Ultima/FileIndex.cs +++ b/Ultima/FileIndex.cs @@ -832,6 +832,10 @@ public void ApplyPatch(Entry5D patch) Index[patch.Index].Lookup = patch.Lookup; Index[patch.Index].Length = patch.Length | (1 << 31); Index[patch.Index].Extra = patch.Extra; + + // Verdata payloads are stored uncompressed, whatever the UOP entry they replace used. + Index[patch.Index].Flag = CompressionFlag.None; + Index[patch.Index].DecompressedLength = patch.Length; } public IEntry GetEntry(int index) From c114a8a744a577fe5728b8494c2e3c8c4a29ae20 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:24:11 +0100 Subject: [PATCH 37/53] Skip map diff blocks outside the map size --- Ultima/TileMatrixPatch.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Ultima/TileMatrixPatch.cs b/Ultima/TileMatrixPatch.cs index dcfdce36..5de6fcc2 100644 --- a/Ultima/TileMatrixPatch.cs +++ b/Ultima/TileMatrixPatch.cs @@ -165,6 +165,13 @@ private int PatchLand(TileMatrix matrix, string dataPath, string indexPath) int x = blockId / matrix.BlockHeight; int y = blockId % matrix.BlockHeight; + // A diff for a block outside the configured map size is skipped, not applied. + if (blockId < 0 || x >= matrix.BlockWidth) + { + fsData.Seek(4 + (64 * 3), SeekOrigin.Current); + continue; + } + fsData.Seek(4, SeekOrigin.Current); var tiles = new Tile[64]; @@ -216,6 +223,12 @@ private int PatchStatics(TileMatrix matrix, string dataPath, string indexPath, s lookupReader.ReadInt32(); // Extra + // A diff for a block outside the configured map size is skipped, not applied. + if (blockId < 0 || blockX >= matrix.BlockWidth) + { + continue; + } + if (offset < 0 || length <= 0) { if (StaticBlocks[blockX] == null) From d4e4d97aad9e5ebab5f0e4785ec840e3ef2886fb Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:24:42 +0100 Subject: [PATCH 38/53] Read blocks of a missing map UOP chunk as empty Later chunks keep their place instead of shifting. --- Ultima/TileMatrix.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Ultima/TileMatrix.cs b/Ultima/TileMatrix.cs index f6972a02..fb10f98a 100644 --- a/Ultima/TileMatrix.cs +++ b/Ultima/TileMatrix.cs @@ -361,19 +361,29 @@ private unsafe HuedTile[][][] ReadStaticBlock(int x, int y) private void ReadUOPFiles(string pattern) { UOPFiles = MapUopReader.ReadEntryTable(_map, pattern); + + _uopFullChunk = 0; + foreach (MapUopEntry t in UOPFiles) + { + _uopFullChunk = Math.Max(_uopFullChunk, t.Length); + } } + private long _uopFullChunk; + private long CalculateOffsetFromUOP(long offset) { long pos = 0; + // An absent chunk still covers its blocks: give it the size of a full chunk so the blocks + // after it keep their place, and read the ones inside it as empty. foreach (MapUopEntry t in UOPFiles) { - long currentPosition = pos + t.Length; + long currentPosition = pos + (t.IsPresent ? t.Length : _uopFullChunk); if (offset < currentPosition) { - return t.Offset + (offset - pos); + return t.IsPresent ? t.Offset + (offset - pos) : UOPLength; } pos = currentPosition; From e0f0e703ec38f3d736cfaeb44ea7cb4fc1ceb0f3 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:25:51 +0100 Subject: [PATCH 39/53] Match image file extensions case-insensitively --- UoFiddler.Controls/Forms/AnimationEditForm.cs | 20 +++++++++---------- .../UserControls/GumpControl.cs | 6 +++--- .../UserControls/ItemsControl.cs | 6 +++--- .../UserControls/LandTilesControl.cs | 8 ++++---- .../UserControls/LightControl.cs | 2 +- .../UserControls/TexturesControl.cs | 8 ++++---- .../Imports/ImportEntry.cs | 2 +- .../Imports/ImportEntryGump.cs | 2 +- .../Imports/ImportEntryItem.cs | 2 +- .../Imports/ImportEntryLandTile.cs | 2 +- .../Imports/ImportEntryTexture.cs | 2 +- 11 files changed, 30 insertions(+), 30 deletions(-) diff --git a/UoFiddler.Controls/Forms/AnimationEditForm.cs b/UoFiddler.Controls/Forms/AnimationEditForm.cs index 7f157ef7..32f1c150 100644 --- a/UoFiddler.Controls/Forms/AnimationEditForm.cs +++ b/UoFiddler.Controls/Forms/AnimationEditForm.cs @@ -1434,11 +1434,11 @@ private void OnClickAdd(object sender, EventArgs e) using (var bmpTemp = new Bitmap(fileName)) { // Copying a GIF keeps only its first frame - Bitmap bitmap = fileName.Contains(".gif") ? bmpTemp : new Bitmap(bmpTemp); + Bitmap bitmap = fileName.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ? bmpTemp : new Bitmap(bmpTemp); - if (fileName.Contains(".bmp") || fileName.Contains(".tiff") || - fileName.Contains(".png") || fileName.Contains(".jpeg") || - fileName.Contains(".jpg")) + if (fileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase) || fileName.EndsWith(".tiff", StringComparison.OrdinalIgnoreCase) || + fileName.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || fileName.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) || + fileName.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase)) { bitmap = ConvertBmpAnim(bitmap, (int)numericUpDownRed.Value, (int)numericUpDownGreen.Value, (int)numericUpDownBlue.Value); @@ -1452,7 +1452,7 @@ private void OnClickAdd(object sender, EventArgs e) } //Gif Especial Properties - if (fileName.Contains(".gif")) + if (fileName.EndsWith(".gif", StringComparison.OrdinalIgnoreCase)) { FrameDimension dimension = new FrameDimension(bitmap.FrameDimensionsList[0]); @@ -2313,7 +2313,7 @@ private void AddFilesAllDirections(OpenFileDialog dialog) if (edit != null) { // Gif Especial Properties - if (dialog.FileNames[w].Contains(".gif")) + if (dialog.FileNames[w].EndsWith(".gif", StringComparison.OrdinalIgnoreCase)) { // dialog.Filename replaced by dialog.FileNames[w] using (Bitmap bitmap = new Bitmap(dialog.FileNames[w])) @@ -2547,7 +2547,7 @@ private void AddSelectedFiles(OpenFileDialog dialog, Color customConvert) // TODO: fix checking file extension // Gif Especial Properties - if (!dialog.FileNames[w].Contains(".gif")) + if (!dialog.FileNames[w].EndsWith(".gif", StringComparison.OrdinalIgnoreCase)) { continue; } @@ -2958,7 +2958,7 @@ private void AddWithCanvasToolStripMenuItem_Click(object sender, EventArgs e) // TODO: fix checking file extension // Gif Especial Properties - if (!dialog.FileNames[w].Contains(".gif")) + if (!dialog.FileNames[w].EndsWith(".gif", StringComparison.OrdinalIgnoreCase)) { continue; } @@ -3243,7 +3243,7 @@ private void AddDirectionsAddWithCanvasUniqueImageToolStripMenuItem_Click(object if (edit != null) { // Gif Especial Properties - if (dialog.FileName.Contains(".gif")) + if (dialog.FileName.EndsWith(".gif", StringComparison.OrdinalIgnoreCase)) { using (Bitmap bmpTemp = new Bitmap(dialog.FileName)) using (MemoryStream ms = new MemoryStream()) @@ -3929,7 +3929,7 @@ private void AllDirectionsAddWithCanvasKRFrameEditorColorCorrectorToolStripMenuI if (edit != null) { //Gif Especial Properties - if (dialog.FileName.Contains(".gif")) + if (dialog.FileName.EndsWith(".gif", StringComparison.OrdinalIgnoreCase)) { using (Bitmap bmpTemp = new Bitmap(dialog.FileName)) using (MemoryStream ms = new MemoryStream()) diff --git a/UoFiddler.Controls/UserControls/GumpControl.cs b/UoFiddler.Controls/UserControls/GumpControl.cs index 687045fe..506e0cc5 100644 --- a/UoFiddler.Controls/UserControls/GumpControl.cs +++ b/UoFiddler.Controls/UserControls/GumpControl.cs @@ -708,7 +708,7 @@ private void OnClickReplace(object sender, EventArgs e) { Bitmap bitmap = new Bitmap(bmpTemp); - if (dialog.FileName.Contains(".bmp")) + if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } @@ -851,7 +851,7 @@ private void OnKeydown_InsertText(object sender, KeyEventArgs e) { Bitmap bitmap = new Bitmap(bmpTemp); - if (dialog.FileName.Contains(".bmp")) + if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } @@ -1163,7 +1163,7 @@ private void AddSingleGump(string fileName, int index) { Bitmap bitmap = new Bitmap(bmpTemp); - if (fileName.Contains(".bmp")) + if (fileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } diff --git a/UoFiddler.Controls/UserControls/ItemsControl.cs b/UoFiddler.Controls/UserControls/ItemsControl.cs index a8431e8d..d18c7c85 100644 --- a/UoFiddler.Controls/UserControls/ItemsControl.cs +++ b/UoFiddler.Controls/UserControls/ItemsControl.cs @@ -623,7 +623,7 @@ private void OnClickReplace(object sender, EventArgs e) { Bitmap bitmap = new Bitmap(bmpTemp); - if (dialog.FileName.Contains(".bmp")) + if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } @@ -700,7 +700,7 @@ private void ReplaceMultipleSelected() { Bitmap bitmap = new Bitmap(bmpTemp); - if (files[i].Contains(".bmp")) + if (files[i].EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } @@ -1735,7 +1735,7 @@ private void AddSingleItem(string fileName, int index) { Bitmap bitmap = new Bitmap(bmpTemp); - if (fileName.Contains(".bmp")) + if (fileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } diff --git a/UoFiddler.Controls/UserControls/LandTilesControl.cs b/UoFiddler.Controls/UserControls/LandTilesControl.cs index df908401..b8621e12 100644 --- a/UoFiddler.Controls/UserControls/LandTilesControl.cs +++ b/UoFiddler.Controls/UserControls/LandTilesControl.cs @@ -508,7 +508,7 @@ private void OnClickReplace(object sender, EventArgs e) { Bitmap bitmap = new Bitmap(bmpTemp); - if (dialog.FileName.Contains(".bmp")) + if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } @@ -572,7 +572,7 @@ private void ReplaceMultipleSelected() { Bitmap bitmap = new Bitmap(bmpTemp); - if (files[i].Contains(".bmp")) + if (files[i].EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } @@ -665,7 +665,7 @@ private void OnKeyDownInsert(object sender, KeyEventArgs e) { Bitmap bitmap = new Bitmap(bmpTemp); - if (dialog.FileName.Contains(".bmp")) + if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } @@ -1185,7 +1185,7 @@ private void AddSingleLandTile(string fileName, int index) { Bitmap bitmap = new Bitmap(bmpTemp); - if (fileName.Contains(".bmp")) + if (fileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } diff --git a/UoFiddler.Controls/UserControls/LightControl.cs b/UoFiddler.Controls/UserControls/LightControl.cs index 3045f734..e652a0dc 100644 --- a/UoFiddler.Controls/UserControls/LightControl.cs +++ b/UoFiddler.Controls/UserControls/LightControl.cs @@ -251,7 +251,7 @@ private void OnClickReplace(object sender, EventArgs e) { var bitmap = new Bitmap(bmpTemp); - if (dialog.FileName.Contains(".bmp")) + if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } diff --git a/UoFiddler.Controls/UserControls/TexturesControl.cs b/UoFiddler.Controls/UserControls/TexturesControl.cs index 8b4fdff1..a65ae96d 100644 --- a/UoFiddler.Controls/UserControls/TexturesControl.cs +++ b/UoFiddler.Controls/UserControls/TexturesControl.cs @@ -423,7 +423,7 @@ private void OnClickReplace(object sender, EventArgs e) Bitmap bitmap = new Bitmap(bmpTemp); - if (dialog.FileName.Contains(".bmp")) + if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } @@ -497,7 +497,7 @@ private void ReplaceMultipleSelected() Bitmap bitmap = new Bitmap(bmpTemp); - if (files[i].Contains(".bmp")) + if (files[i].EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } @@ -576,7 +576,7 @@ private void OnKeyDownInsert(object sender, KeyEventArgs e) { Bitmap bitmap = new Bitmap(bmpTemp); - if (dialog.FileName.Contains(".bmp")) + if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } @@ -937,7 +937,7 @@ private void AddSingleTexture(string fileName, int index) { Bitmap bitmap = new Bitmap(bmpTemp); - if (fileName.Contains(".bmp")) + if (fileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs index ed2d95e2..e01772f4 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs @@ -33,7 +33,7 @@ public abstract class ImportEntry protected virtual void TestFile(ref string message) { - if (File.Contains(".bmp") || File.Contains(".tiff") || File.Contains(".png") || File.Contains(".jpg") || File.Contains(".jpeg")) + if (File.EndsWith(".bmp", System.StringComparison.OrdinalIgnoreCase) || File.EndsWith(".tiff", System.StringComparison.OrdinalIgnoreCase) || File.EndsWith(".png", System.StringComparison.OrdinalIgnoreCase) || File.EndsWith(".jpg", System.StringComparison.OrdinalIgnoreCase) || File.EndsWith(".jpeg", System.StringComparison.OrdinalIgnoreCase)) { return; } diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryGump.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryGump.cs index cfcb65c7..b20dd4e1 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryGump.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryGump.cs @@ -29,7 +29,7 @@ public override void Import(bool direct, ref Dictionary changedCla { Bitmap bitmap = new Bitmap(bmpTemp); - if (File.Contains(".bmp")) + if (File.EndsWith(".bmp", System.StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryItem.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryItem.cs index c6d883dc..ba3208cc 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryItem.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryItem.cs @@ -29,7 +29,7 @@ public override void Import(bool direct, ref Dictionary changedCla { Bitmap bitmap = new Bitmap(bmpTemp); - if (File.Contains(".bmp")) + if (File.EndsWith(".bmp", System.StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryLandTile.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryLandTile.cs index cc916e15..faab300b 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryLandTile.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryLandTile.cs @@ -27,7 +27,7 @@ public override void Import(bool direct, ref Dictionary changedCla { Bitmap bitmap = new Bitmap(bmpTemp); - if (File.Contains(".bmp")) + if (File.EndsWith(".bmp", System.StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTexture.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTexture.cs index 55c65563..91a99601 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTexture.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTexture.cs @@ -29,7 +29,7 @@ public override void Import(bool direct, ref Dictionary changedCla { Bitmap bitmap = new Bitmap(bmpTemp); - if (File.Contains(".bmp")) + if (File.EndsWith(".bmp", System.StringComparison.OrdinalIgnoreCase)) { bitmap = Utils.ConvertBmp(bitmap); } From bef93aad6fc3328f807a5acbd1f528d39761dc5e Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:26:00 +0100 Subject: [PATCH 40/53] Fix off-by-one in item and land tile selection index checks --- UoFiddler.Controls/UserControls/ItemsControl.cs | 2 +- UoFiddler.Controls/UserControls/LandTilesControl.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/UoFiddler.Controls/UserControls/ItemsControl.cs b/UoFiddler.Controls/UserControls/ItemsControl.cs index d18c7c85..bc785167 100644 --- a/UoFiddler.Controls/UserControls/ItemsControl.cs +++ b/UoFiddler.Controls/UserControls/ItemsControl.cs @@ -1546,7 +1546,7 @@ private void UpdateSelection(int itemIndex) return; } - SelectedGraphicId = itemIndex < 0 || itemIndex > _itemList.Count + SelectedGraphicId = itemIndex < 0 || itemIndex >= _itemList.Count ? _itemList[0] : _itemList[itemIndex]; } diff --git a/UoFiddler.Controls/UserControls/LandTilesControl.cs b/UoFiddler.Controls/UserControls/LandTilesControl.cs index b8621e12..4ceec023 100644 --- a/UoFiddler.Controls/UserControls/LandTilesControl.cs +++ b/UoFiddler.Controls/UserControls/LandTilesControl.cs @@ -1016,7 +1016,7 @@ private void LandTilesTileView_ItemSelectionChanged(object sender, ListViewItemS return; } - SelectedGraphicId = e.ItemIndex < 0 || e.ItemIndex > _tileList.Count + SelectedGraphicId = e.ItemIndex < 0 || e.ItemIndex >= _tileList.Count ? _tileList[0] : _tileList[e.ItemIndex]; } From b1d49d4c19e602c1c757b6252d71dc0adf3f1e02 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:26:19 +0100 Subject: [PATCH 41/53] Fix light preview row stride and brightness overflow --- UoFiddler.Controls/UserControls/LightControl.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/UoFiddler.Controls/UserControls/LightControl.cs b/UoFiddler.Controls/UserControls/LightControl.cs index e652a0dc..e29521b5 100644 --- a/UoFiddler.Controls/UserControls/LightControl.cs +++ b/UoFiddler.Controls/UserControls/LightControl.cs @@ -156,7 +156,7 @@ private unsafe Bitmap GetImage() int lightStartY = 30 + (bit.Height / 2) - (lightHeight / 2); int lightEndX = lightStartX + lightWidth; - int lightEndY = lightStartY + lightWidth; + int lightEndY = lightStartY + lightHeight; for (int y = 0; y < bd.Height; ++y) { @@ -170,7 +170,8 @@ private unsafe Bitmap GetImage() if (x >= lightStartX && x < lightEndX && y >= lightStartY && y < lightEndY) { - int offset = ((y - lightStartY) * lightHeight) + (x - lightStartX); + // Light data is stored row by row, lightWidth bytes per row. + int offset = ((y - lightStartY) * lightWidth) + (x - lightStartX); if (offset < light.Length) { lightC = light[offset]; @@ -187,9 +188,9 @@ private unsafe Bitmap GetImage() r /= 3; g /= 3; b /= 3; - r += (byte)(r * lightC); - g += (byte)(g * lightC); - b += (byte)(b * lightC); + r = (byte)Math.Min(255, r + (r * lightC)); + g = (byte)Math.Min(255, g + (g * lightC)); + b = (byte)Math.Min(255, b + (b * lightC)); *imgPtr++ = b; *imgPtr++ = g; From 462f5382061328b3453315bd6680be9d926bfe01 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:26:41 +0100 Subject: [PATCH 42/53] Clear directions a .vd import leaves empty --- Ultima/AnimationEdit.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Ultima/AnimationEdit.cs b/Ultima/AnimationEdit.cs index a79b891c..a9966dc8 100644 --- a/Ultima/AnimationEdit.cs +++ b/Ultima/AnimationEdit.cs @@ -269,14 +269,19 @@ public static void LoadFromVD(int fileType, int body, BinaryReader bin) entries[i].Extra = bin.ReadInt32(); } - foreach (Entry3D entry in entries) + for (int i = 0; i < entries.Length; ++i, ++index) { + Entry3D entry = entries[i]; if ((entry.Lookup > 0) && (entry.Lookup < bin.BaseStream.Length) && (entry.Length > 0)) { bin.BaseStream.Seek(entry.Lookup, SeekOrigin.Begin); cache[index] = new AnimIdx(bin, entry.Extra); } - ++index; + else + { + // Empty in the .vd: drop what the body had there, as LoadActionFromVD does. + GetAnimation(fileType, body, i / 5, i % 5).ClearFrames(); + } } } From b0d4d986c08f4e942d44f3277367d6f0d15c59df Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:27:01 +0100 Subject: [PATCH 43/53] Make the multi editor Dynamic flag and undo jumps undoable Jumping back in the undo list keeps the skipped states for redo. --- .../Classes/MultiEditorComponentList.cs | 23 +++++++++++++++++-- .../UserControls/MultiEditorControl.cs | 5 +--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs b/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs index 048c6345..4e25c0f8 100644 --- a/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs +++ b/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs @@ -736,8 +736,27 @@ public void Undo(int index) return; } - RestoreSnapshot(UndoList[index]); - RedoList.Clear(); + // Step back one entry at a time, so every state passed over, the current one included, + // ends up on the redo stack and can be redone. + for (int i = 0; i <= index; ++i) + { + UndoStep(); + } + } + + /// + /// Sets a tile's Dynamic (invisible) flag as an undoable change. + /// + public void TileSetInvisible(MultiTile tile, bool invisible) + { + if (tile == null || tile.Invisible == invisible || !Tiles.Contains(tile)) + { + return; + } + + AddToUndoList("Dynamic"); + tile.Invisible = invisible; + _modified = true; } /// Single-step undo (Ctrl+Z). Pushes current state to redo stack. diff --git a/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs b/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs index 238952f2..058d401e 100644 --- a/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs +++ b/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs @@ -2441,10 +2441,7 @@ private void BTN_DynamicCheckBox_Changed(object sender, EventArgs e) return; } - if (SelectedTile.Invisible != DynamiccheckBox.Checked) - { - SelectedTile.Invisible = DynamiccheckBox.Checked; - } + _compList.TileSetInvisible(SelectedTile, DynamiccheckBox.Checked); } private void BTN_ShowWalkables_Click(object sender, EventArgs e) From 04393b23b420364534410d84de3c4c22c35fc6d3 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:34:19 +0100 Subject: [PATCH 44/53] Match the UoFiddler folder casing in the release workflow --- .github/workflows/build-and-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 601bf1c7..7ba3009c 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -48,7 +48,7 @@ jobs: uses: actions/upload-artifact@v7.0.2 with: name: ${{ env.Asset_Name }}.zip - path: ./UOFiddler/bin/Release/ + path: ./UoFiddler/bin/Release/ release: name: Release From cb985788c351058bcfc0aba35fc5e36eabe6654a Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:34:41 +0100 Subject: [PATCH 45/53] Save the normal window bounds and restore them on any screen --- UoFiddler/Forms/MainForm.cs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/UoFiddler/Forms/MainForm.cs b/UoFiddler/Forms/MainForm.cs index 5aeb9300..3cc9f83f 100644 --- a/UoFiddler/Forms/MainForm.cs +++ b/UoFiddler/Forms/MainForm.cs @@ -533,9 +533,11 @@ private void OnClosing(object sender, FormClosingEventArgs e) } } + // RestoreBounds holds the normal size and position while the form is maximized or minimized. + Rectangle bounds = WindowState == FormWindowState.Normal ? Bounds : RestoreBounds; FiddlerOptions.MaximisedForm = WindowState == FormWindowState.Maximized; - FiddlerOptions.FormPosition = Location; - FiddlerOptions.FormSize = Size; + FiddlerOptions.FormPosition = bounds.Location; + FiddlerOptions.FormSize = bounds.Size; _log.LogInformation("MainForm - OnClosing - unloading plugins"); GlobalPlugins.Plugins.ClosePlugins(); @@ -545,17 +547,17 @@ private void OnClosing(object sender, FormClosingEventArgs e) private static bool IsOkFormStateLocation(Point loc, Size size) { - if (loc.X < 0 || loc.Y < 0) + // Any screen will do, so a window closed on a second monitor opens there again. + var bounds = new Rectangle(loc, size); + foreach (Screen screen in Screen.AllScreens) { - return false; - } - - if (loc.X + size.Width > Screen.PrimaryScreen.WorkingArea.Width) - { - return false; + if (screen.WorkingArea.Contains(bounds)) + { + return true; + } } - return loc.Y + size.Height <= Screen.PrimaryScreen.WorkingArea.Height; + return false; } private void ToggleView(object sender, EventArgs e) From 2fbbcd45189dc291390845de0a33d7d807231b17 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:35:20 +0100 Subject: [PATCH 46/53] Dispose the source bitmap after converting imported .bmp files --- UoFiddler.Controls/Classes/Utils.cs | 11 +++++++++++ UoFiddler.Controls/UserControls/GumpControl.cs | 6 +++--- UoFiddler.Controls/UserControls/ItemsControl.cs | 6 +++--- UoFiddler.Controls/UserControls/LandTilesControl.cs | 8 ++++---- UoFiddler.Controls/UserControls/LightControl.cs | 2 +- UoFiddler.Controls/UserControls/TexturesControl.cs | 8 ++++---- .../Imports/ImportEntryGump.cs | 2 +- .../Imports/ImportEntryItem.cs | 2 +- .../Imports/ImportEntryLandTile.cs | 2 +- .../Imports/ImportEntryTexture.cs | 2 +- 10 files changed, 30 insertions(+), 19 deletions(-) diff --git a/UoFiddler.Controls/Classes/Utils.cs b/UoFiddler.Controls/Classes/Utils.cs index 9ee7c287..bb2bcda0 100644 --- a/UoFiddler.Controls/Classes/Utils.cs +++ b/UoFiddler.Controls/Classes/Utils.cs @@ -86,6 +86,17 @@ public static string FormatExportId(int id) : id.ToString(); } + /// + /// for callers that replace their bitmap with the result: the input is + /// disposed, since ConvertBmp always returns a new bitmap. + /// + public static Bitmap ConvertBmpAndDispose(Bitmap bmp) + { + Bitmap converted = ConvertBmp(bmp); + bmp.Dispose(); + return converted; + } + public static unsafe Bitmap ConvertBmp(Bitmap bmp) { BitmapData bd = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadOnly, PixelFormat.Format16bppArgb1555); diff --git a/UoFiddler.Controls/UserControls/GumpControl.cs b/UoFiddler.Controls/UserControls/GumpControl.cs index 506e0cc5..44012df1 100644 --- a/UoFiddler.Controls/UserControls/GumpControl.cs +++ b/UoFiddler.Controls/UserControls/GumpControl.cs @@ -710,7 +710,7 @@ private void OnClickReplace(object sender, EventArgs e) if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } int i = SelectedGumpId; @@ -853,7 +853,7 @@ private void OnKeydown_InsertText(object sender, KeyEventArgs e) if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Gumps.ReplaceGump(index, bitmap); @@ -1165,7 +1165,7 @@ private void AddSingleGump(string fileName, int index) if (fileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Gumps.ReplaceGump(index, bitmap); diff --git a/UoFiddler.Controls/UserControls/ItemsControl.cs b/UoFiddler.Controls/UserControls/ItemsControl.cs index bc785167..8d2eea93 100644 --- a/UoFiddler.Controls/UserControls/ItemsControl.cs +++ b/UoFiddler.Controls/UserControls/ItemsControl.cs @@ -625,7 +625,7 @@ private void OnClickReplace(object sender, EventArgs e) if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } // Validate image size before replacing @@ -702,7 +702,7 @@ private void ReplaceMultipleSelected() if (files[i].EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } if (!Art.ValidateStaticSize(bitmap, out int estimatedSize)) @@ -1737,7 +1737,7 @@ private void AddSingleItem(string fileName, int index) if (fileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } // Checked before anything changes, so a failed file leaves the list as it was. diff --git a/UoFiddler.Controls/UserControls/LandTilesControl.cs b/UoFiddler.Controls/UserControls/LandTilesControl.cs index 4ceec023..c738c5e8 100644 --- a/UoFiddler.Controls/UserControls/LandTilesControl.cs +++ b/UoFiddler.Controls/UserControls/LandTilesControl.cs @@ -510,7 +510,7 @@ private void OnClickReplace(object sender, EventArgs e) if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } if (!CheckLandTileSize(bitmap, Path.GetFileName(dialog.FileName))) @@ -574,7 +574,7 @@ private void ReplaceMultipleSelected() if (files[i].EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } if (!CheckLandTileSize(bitmap, Path.GetFileName(files[i]))) @@ -667,7 +667,7 @@ private void OnKeyDownInsert(object sender, KeyEventArgs e) if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } if (!CheckLandTileSize(bitmap, Path.GetFileName(dialog.FileName))) @@ -1187,7 +1187,7 @@ private void AddSingleLandTile(string fileName, int index) if (fileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } if (bitmap.Width != LandTileSize || bitmap.Height != LandTileSize) diff --git a/UoFiddler.Controls/UserControls/LightControl.cs b/UoFiddler.Controls/UserControls/LightControl.cs index e29521b5..90ced436 100644 --- a/UoFiddler.Controls/UserControls/LightControl.cs +++ b/UoFiddler.Controls/UserControls/LightControl.cs @@ -254,7 +254,7 @@ private void OnClickReplace(object sender, EventArgs e) if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } int i = (int)listViewLights.SelectedItems[0].Tag; diff --git a/UoFiddler.Controls/UserControls/TexturesControl.cs b/UoFiddler.Controls/UserControls/TexturesControl.cs index a65ae96d..322e0a39 100644 --- a/UoFiddler.Controls/UserControls/TexturesControl.cs +++ b/UoFiddler.Controls/UserControls/TexturesControl.cs @@ -425,7 +425,7 @@ private void OnClickReplace(object sender, EventArgs e) if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Textures.Replace(_selectedTextureId, bitmap); @@ -499,7 +499,7 @@ private void ReplaceMultipleSelected() if (files[i].EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } bitmaps.Add(bitmap); @@ -578,7 +578,7 @@ private void OnKeyDownInsert(object sender, KeyEventArgs e) if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Textures.Replace(index, bitmap); @@ -939,7 +939,7 @@ private void AddSingleTexture(string fileName, int index) if (fileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Textures.Replace(index, bitmap); diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryGump.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryGump.cs index b20dd4e1..ae376e78 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryGump.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryGump.cs @@ -31,7 +31,7 @@ public override void Import(bool direct, ref Dictionary changedCla if (File.EndsWith(".bmp", System.StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Ultima.Gumps.ReplaceGump(Index, bitmap); diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryItem.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryItem.cs index ba3208cc..0ac7027a 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryItem.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryItem.cs @@ -31,7 +31,7 @@ public override void Import(bool direct, ref Dictionary changedCla if (File.EndsWith(".bmp", System.StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Ultima.Art.ReplaceStatic(Index, bitmap); diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryLandTile.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryLandTile.cs index faab300b..e420b6fa 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryLandTile.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryLandTile.cs @@ -29,7 +29,7 @@ public override void Import(bool direct, ref Dictionary changedCla if (File.EndsWith(".bmp", System.StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Ultima.Art.ReplaceLand(Index, bitmap); diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTexture.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTexture.cs index 91a99601..2e3a9c3e 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTexture.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTexture.cs @@ -31,7 +31,7 @@ public override void Import(bool direct, ref Dictionary changedCla if (File.EndsWith(".bmp", System.StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Ultima.Textures.Replace(Index, bitmap); From bb295432f010d6517bd76b616bc15126d44fda0b Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:39:54 +0100 Subject: [PATCH 47/53] Set the multi editor assembly version to 1.7.0 --- .../UoFiddler.Plugin.MultiEditor.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UoFiddler.Plugin.MultiEditor/UoFiddler.Plugin.MultiEditor.csproj b/UoFiddler.Plugin.MultiEditor/UoFiddler.Plugin.MultiEditor.csproj index 3ddff6b9..544c5c33 100644 --- a/UoFiddler.Plugin.MultiEditor/UoFiddler.Plugin.MultiEditor.csproj +++ b/UoFiddler.Plugin.MultiEditor/UoFiddler.Plugin.MultiEditor.csproj @@ -7,8 +7,8 @@ MultiEditor MultiEditor Copyright © 2026 - 1.5.0.0 - 1.5.0.0 + 1.7.0.0 + 1.7.0.0 true false From 7af555a69b179fa28bd5a15628ac6194aa73961f Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:46:51 +0100 Subject: [PATCH 48/53] Accept radarcol CSV rows for item ids above 0xBFFF --- Ultima/RadarCol.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Ultima/RadarCol.cs b/Ultima/RadarCol.cs index 7c5c8dde..31990b20 100644 --- a/Ultima/RadarCol.cs +++ b/Ultima/RadarCol.cs @@ -8,6 +8,9 @@ namespace Ultima { public sealed class RadarCol { + // 0x4000 land colors followed by one color per item id up to 0xFFFF. + private const int _maxColorIndex = 0x4000 + 0x10000 - 1; + static RadarCol() { Initialize(); @@ -109,7 +112,7 @@ public static void ImportFromCSV(string fileName) continue; } - if (id < 0 || id > 0xFFFF) + if (id < 0 || id > _maxColorIndex) { continue; } From 0b3e261238d8c82a33fd88ad20189c7252f98638 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:46:51 +0100 Subject: [PATCH 49/53] Check the sound name length in IsValidSound --- Ultima/Sound.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Ultima/Sound.cs b/Ultima/Sound.cs index 4afcae80..f1851d79 100644 --- a/Ultima/Sound.cs +++ b/Ultima/Sound.cs @@ -231,10 +231,10 @@ public static bool IsValidSound(int soundId, out string name, out bool translate translated = true; - stream = _fileIndex.Seek(soundId, out _, out _, out _); + stream = _fileIndex.Seek(soundId, out length, out _, out _); } - if (stream == null) + if (stream == null || length < _mulNameLength) { return false; } From a49d98d53da0347fd44c8ef0e88ef03438e348c9 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:46:51 +0100 Subject: [PATCH 50/53] Fix off-by-one in texture selection index check --- UoFiddler.Controls/UserControls/TexturesControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UoFiddler.Controls/UserControls/TexturesControl.cs b/UoFiddler.Controls/UserControls/TexturesControl.cs index 322e0a39..05eadb0d 100644 --- a/UoFiddler.Controls/UserControls/TexturesControl.cs +++ b/UoFiddler.Controls/UserControls/TexturesControl.cs @@ -738,7 +738,7 @@ private void TextureTileView_ItemSelectionChanged(object sender, ListViewItemSel return; } - SelectedTextureId = e.ItemIndex < 0 || e.ItemIndex > _textureList.Count + SelectedTextureId = e.ItemIndex < 0 || e.ItemIndex >= _textureList.Count ? _textureList[0] : _textureList[e.ItemIndex]; } From e1c4012a0ec01404d29623bf0a7414e905a645e8 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:46:52 +0100 Subject: [PATCH 51/53] Keep the extra field and Dynamic flag when pasting multi tiles --- .../Classes/MultiEditorComponentList.cs | 12 ++++++------ .../UserControls/MultiEditorControl.cs | 9 +++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs b/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs index 4e25c0f8..a7dcfde2 100644 --- a/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs +++ b/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs @@ -409,14 +409,14 @@ public void TileAdd(int x, int y, int z, ushort id) RecalculateMinMax(tile); } - /// - /// Moves given - /// /// /// Z is saved as a signed byte; larger values would wrap around. /// private static int ClampZ(int z) => Math.Clamp(z, sbyte.MinValue, sbyte.MaxValue); + /// + /// Moves given + /// public void TileMove(MultiTile tile, int newX, int newY) { if (Width == 0 || Height == 0) @@ -694,17 +694,17 @@ public void TileAddBrush(int cx, int cy, int z, ushort id, int brushSize) /// /// Adds a batch of tiles in a single undo step (used by copy/paste). /// - public void TileAddBatch(List<(ushort id, int x, int y, int z)> tiles) + public void TileAddBatch(List<(ushort id, int x, int y, int z, bool invisible, int extra)> tiles) { AddToUndoList("Paste"); - foreach (var (id, x, y, z) in tiles) + foreach (var (id, x, y, z, invisible, extra) in tiles) { if (x < 0 || x >= Width || y < 0 || y >= Height) { continue; } - Tiles.Add(new MultiTile(id, x, y, ClampZ(z), 1)); + Tiles.Add(new MultiTile(id, x, y, ClampZ(z), invisible) { Extra = extra }); CalcSolver(x, y); } diff --git a/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs b/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs index 058d401e..63383c3d 100644 --- a/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs +++ b/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs @@ -48,7 +48,8 @@ public partial class MultiEditorControl : UserControl private Point _marqueeEnd; // Copy/Paste - private List<(ushort id, int dx, int dy, int dz)> _clipboard; + // Dynamic flag and extra field travel with the tile, so a paste matches what was copied. + private List<(ushort id, int dx, int dy, int dz, bool invisible, int extra)> _clipboard; private bool _isPasteMode; // Zoom @@ -1036,7 +1037,7 @@ private void PictureBoxMultiOnMouseUp(object sender, MouseEventArgs e) if (_isPasteMode && e.Button == MouseButtons.Left && _clipboard != null) { ConvertCoords(_mouseLoc, out int px, out int py, out int pz); - var batch = _clipboard.Select(c => (c.id, px + c.dx, py + c.dy, pz + c.dz)).ToList(); + var batch = _clipboard.Select(c => (c.id, px + c.dx, py + c.dy, pz + c.dz, c.invisible, c.extra)).ToList(); _compList.TileAddBatch(batch); _isPasteMode = false; MaxHeightTrackBar.Minimum = _compList.ZMin; @@ -1392,7 +1393,7 @@ private void PictureBoxMultiOnPaint(object sender, PaintEventArgs e) if (_isPasteMode && _clipboard != null) { ConvertCoords(_mouseLoc, out int px, out int py, out int pz); - foreach (var (id, dx, dy, dz) in _clipboard) + foreach (var (id, dx, dy, dz, _, _) in _clipboard) { var ghost = new MultiTile(id, px + dx, py + dy, pz + dz, 1); Bitmap ghostBmp = ghost.GetBitmap(); @@ -2317,7 +2318,7 @@ protected override bool ProcessCmdKey(ref Message msg, Keys keyData) int ox = _selectedTiles.Min(t => t.X); int oy = _selectedTiles.Min(t => t.Y); int oz = _selectedTiles.Min(t => t.Z); - _clipboard = _selectedTiles.Select(t => (t.Id, t.X - ox, t.Y - oy, t.Z - oz)).ToList(); + _clipboard = _selectedTiles.Select(t => (t.Id, t.X - ox, t.Y - oy, t.Z - oz, t.Invisible, t.Extra)).ToList(); return true; } From e6414d10db64124452cff801574a086d071afedf Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:46:52 +0100 Subject: [PATCH 52/53] Count a missing map UOP chunk when measuring the map size --- Ultima/Uop/MapUopReader.cs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Ultima/Uop/MapUopReader.cs b/Ultima/Uop/MapUopReader.cs index 36c09f08..f17b784c 100644 --- a/Ultima/Uop/MapUopReader.cs +++ b/Ultima/Uop/MapUopReader.cs @@ -150,13 +150,29 @@ public static MapUopEntry[] ReadEntryTable(Stream stream, string pattern) /// Total payload bytes the container holds. Divided by 196 this is the number of map blocks /// it carries, which is normally one more than the facet actually has. /// + /// + /// An absent chunk before the last present one counts as a full chunk, as the reader in + /// TileMatrix addresses it. Absent chunks at the end add nothing. + /// public static long TotalPayloadLength(MapUopEntry[] entries) { + int fullChunk = 0; + int lastPresent = -1; + + for (int i = 0; i < entries.Length; i++) + { + if (entries[i].IsPresent) + { + fullChunk = Math.Max(fullChunk, entries[i].Length); + lastPresent = i; + } + } + long total = 0; - foreach (MapUopEntry entry in entries) + for (int i = 0; i <= lastPresent; i++) { - total += entry.Length; + total += entries[i].IsPresent ? entries[i].Length : fullChunk; } return total; From 576836d43bec128dedf162dacf11d74837250d94 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:46:52 +0100 Subject: [PATCH 53/53] Match mass import .csv and .txt extensions case-insensitively --- UoFiddler.Plugin.MassImport/Imports/ImportEntryHue.cs | 2 +- UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataItem.cs | 2 +- UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataLand.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryHue.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryHue.cs index 86013818..9d8f261a 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryHue.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryHue.cs @@ -22,7 +22,7 @@ public class ImportEntryHue : ImportEntry protected override void TestFile(ref string message) { - if (!File.Contains(".txt")) + if (!File.EndsWith(".txt", System.StringComparison.OrdinalIgnoreCase)) { message += " Invalid file format"; Valid = false; diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataItem.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataItem.cs index 2c3a3c6b..a85e3a7f 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataItem.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataItem.cs @@ -28,7 +28,7 @@ public class ImportEntryTileDataItem : ImportEntry protected override void TestFile(ref string message) { - if (!File.Contains(".csv")) + if (!File.EndsWith(".csv", System.StringComparison.OrdinalIgnoreCase)) { message += " Invalid File format"; Valid = false; diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataLand.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataLand.cs index fff173fe..24434f98 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataLand.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataLand.cs @@ -26,7 +26,7 @@ public class ImportEntryTileDataLand : ImportEntry protected override void TestFile(ref string message) { - if (!File.Contains(".csv")) + if (!File.EndsWith(".csv", System.StringComparison.OrdinalIgnoreCase)) { message += " Invalid file format"; Valid = false;