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;