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 diff --git a/Ultima/AnimationEdit.cs b/Ultima/AnimationEdit.cs index 0579b355..a9966dc8 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); @@ -262,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(); + } } } @@ -590,26 +602,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; } 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); } diff --git a/Ultima/Art.cs b/Ultima/Art.cs index a7ba3864..b098d2d9 100644 --- a/Ultima/Art.cs +++ b/Ultima/Art.cs @@ -39,8 +39,11 @@ 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; - 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() { @@ -222,8 +225,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); @@ -311,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; + } } /// @@ -384,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 @@ -408,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; + } } /// @@ -459,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 @@ -484,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; + } } /// @@ -524,41 +554,45 @@ 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) + // The file stream is shared; seek and read must not interleave with another thread. + lock (_readLock) { - return false; - } - - if (patched) - { - _patched[index] = true; - } - - 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)) + 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 { - return DecodeStatic(entry, destPtr, width, height, width); + 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 + { + ArrayPool.Shared.Return(buffer); } - } - finally - { - ArrayPool.Shared.Return(buffer); } } @@ -582,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) + // The file stream is shared; seek and read must not interleave with another thread. + lock (_readLock) { - _patched[index] = true; - } - - byte[] buffer = ArrayPool.Shared.Rent(length); - try - { - 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); + } } } @@ -655,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) @@ -933,7 +975,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 +1086,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 +1106,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 +1114,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 +1169,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, diff --git a/Ultima/FileIndex.cs b/Ultima/FileIndex.cs index fafca347..3604ae68 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(); /// @@ -249,22 +264,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 +684,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 +741,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 @@ -809,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) 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); diff --git a/Ultima/Light.cs b/Ultima/Light.cs index 7137023c..042521ac 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]; @@ -174,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; 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/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/RadarCol.cs b/Ultima/RadarCol.cs index b37377ef..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(); @@ -84,10 +87,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 +106,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 > _maxColorIndex) { 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); } } } 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 diff --git a/Ultima/Sound.cs b/Ultima/Sound.cs index 109f28b0..f1851d79 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); @@ -103,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; } @@ -115,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)) { @@ -126,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; } @@ -208,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; } @@ -221,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)) { @@ -230,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; } @@ -261,7 +262,7 @@ public static double GetSoundLength(int soundId) return 0; } - if (_removed[soundId]) + if (soundId >= _removed.Length || _removed[soundId]) { return 0; } @@ -276,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)) { @@ -286,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; } 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); } diff --git a/Ultima/Textures.cs b/Ultima/Textures.cs index 0814803d..f5658248 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]; @@ -57,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/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; 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) 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; } 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; 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/Forms/AnimationEditForm.cs b/UoFiddler.Controls/Forms/AnimationEditForm.cs index ae083ada..32f1c150 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; @@ -1433,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); @@ -1451,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]); @@ -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); @@ -2308,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])) @@ -2542,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; } @@ -2953,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; } @@ -3238,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()) @@ -3924,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/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 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; diff --git a/UoFiddler.Controls/UserControls/GumpControl.cs b/UoFiddler.Controls/UserControls/GumpControl.cs index 64b76262..44012df1 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; @@ -705,9 +708,9 @@ 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); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } int i = SelectedGumpId; @@ -805,7 +808,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 +825,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; } @@ -848,9 +851,9 @@ 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); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Gumps.ReplaceGump(index, bitmap); @@ -1160,9 +1163,9 @@ 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); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Gumps.ReplaceGump(index, bitmap); 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(); } diff --git a/UoFiddler.Controls/UserControls/ItemsControl.cs b/UoFiddler.Controls/UserControls/ItemsControl.cs index 4b1446fe..8d2eea93 100644 --- a/UoFiddler.Controls/UserControls/ItemsControl.cs +++ b/UoFiddler.Controls/UserControls/ItemsControl.cs @@ -623,9 +623,9 @@ 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); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } // Validate image size before replacing @@ -700,9 +700,9 @@ private void ReplaceMultipleSelected() { Bitmap bitmap = new Bitmap(bmpTemp); - if (files[i].Contains(".bmp")) + if (files[i].EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } if (!Art.ValidateStaticSize(bitmap, out int estimatedSize)) @@ -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); + } } } @@ -1382,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; @@ -1412,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) @@ -1531,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]; } @@ -1675,16 +1690,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,15 +1728,24 @@ 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)) { Bitmap bitmap = new Bitmap(bmpTemp); - if (fileName.Contains(".bmp")) + 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. + 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); @@ -1823,6 +1860,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)"); diff --git a/UoFiddler.Controls/UserControls/LandTilesControl.cs b/UoFiddler.Controls/UserControls/LandTilesControl.cs index 0e11bfb9..c738c5e8 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; @@ -501,23 +508,14 @@ 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); + bitmap = Utils.ConvertBmpAndDispose(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; } @@ -574,24 +572,19 @@ private void ReplaceMultipleSelected() { Bitmap bitmap = new Bitmap(bmpTemp); - if (files[i].Contains(".bmp")) + if (files[i].EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(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; } @@ -672,15 +665,23 @@ 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.ConvertBmpAndDispose(bitmap); + } + + if (!CheckLandTileSize(bitmap, Path.GetFileName(dialog.FileName))) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap.Dispose(); + return; } Art.ReplaceLand(index, bitmap); ControlEvents.FireLandTileChangeEvent(this, index); + Options.ChangedUltimaClass["Art"] = true; + if (_showFreeSlots) { SelectedGraphicId = index; @@ -709,8 +710,6 @@ private void OnKeyDownInsert(object sender, KeyEventArgs e) LandTilesTileView.VirtualListSize = _tileList.Count; LandTilesTileView.Invalidate(); SelectedGraphicId = index; - - Options.ChangedUltimaClass["Art"] = true; } } } @@ -1017,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]; } @@ -1130,6 +1129,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)"); @@ -1182,9 +1185,16 @@ 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); + bitmap = Utils.ConvertBmpAndDispose(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); @@ -1200,7 +1210,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 +1227,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 diff --git a/UoFiddler.Controls/UserControls/LightControl.cs b/UoFiddler.Controls/UserControls/LightControl.cs index 3045f734..90ced436 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; @@ -251,9 +252,9 @@ 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); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } int i = (int)listViewLights.SelectedItems[0].Tag; 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) 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) diff --git a/UoFiddler.Controls/UserControls/TexturesControl.cs b/UoFiddler.Controls/UserControls/TexturesControl.cs index 20bfd309..05eadb0d 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)) @@ -402,11 +410,22 @@ 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")) + if (dialog.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Textures.Replace(_selectedTextureId, bitmap); @@ -478,9 +497,9 @@ private void ReplaceMultipleSelected() Bitmap bitmap = new Bitmap(bmpTemp); - if (files[i].Contains(".bmp")) + if (files[i].EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) { - bitmap = Utils.ConvertBmp(bitmap); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } bitmaps.Add(bitmap); @@ -557,9 +576,9 @@ 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); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Textures.Replace(index, bitmap); @@ -719,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]; } @@ -918,9 +937,9 @@ 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); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Textures.Replace(index, bitmap); 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.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(); 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..ae376e78 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryGump.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryGump.cs @@ -29,9 +29,9 @@ 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); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Ultima.Gumps.ReplaceGump(Index, bitmap); diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryHue.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryHue.cs index 2195a7ed..9d8f261a 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryHue.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryHue.cs @@ -16,13 +16,13 @@ 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"; 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/ImportEntryItem.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryItem.cs index c6d883dc..0ac7027a 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryItem.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryItem.cs @@ -29,9 +29,9 @@ 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); + 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 cc916e15..e420b6fa 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryLandTile.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryLandTile.cs @@ -27,9 +27,9 @@ 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); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Ultima.Art.ReplaceLand(Index, bitmap); 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..2e3a9c3e 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"; @@ -29,9 +29,9 @@ 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); + bitmap = Utils.ConvertBmpAndDispose(bitmap); } Ultima.Textures.Replace(Index, bitmap); 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; diff --git a/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs b/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs index d197324a..a7dcfde2 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; } @@ -398,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; @@ -406,6 +409,11 @@ public void TileAdd(int x, int y, int z, ushort id) RecalculateMinMax(tile); } + /// + /// 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 /// @@ -416,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); @@ -480,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); @@ -588,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); } } @@ -611,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); } @@ -672,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); } } @@ -685,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, z, 1)); + Tiles.Add(new MultiTile(id, x, y, ClampZ(z), invisible) { Extra = extra }); CalcSolver(x, y); } @@ -713,7 +722,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; @@ -727,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. @@ -941,7 +969,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 +1369,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; } 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.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 diff --git a/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs b/UoFiddler.Plugin.MultiEditor/UserControls/MultiEditorControl.cs index 7bda5672..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 @@ -181,6 +182,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(); @@ -915,7 +917,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) { @@ -954,7 +957,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; } } @@ -1034,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; @@ -1390,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(); @@ -1679,6 +1682,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 +1775,7 @@ private void TreeViewMultiList_NodeMouseDoubleClick(object sender, TreeNodeMouse textBox_SaveToID.Text = e.Node.Tag.ToString(); } + ClearTileReferences(); UndoList_Clear(); MaxHeightTrackBar.Minimum = _compList.ZMin; @@ -1895,17 +1900,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,17 +2218,61 @@ 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). + /// + 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)) { if (_compList.UndoStep()) { - UpdateTrackBarAfterZChange(); - _selectedTiles.Clear(); + ClearTileReferences(); + SyncControlsToList(); pictureBoxMulti.Invalidate(); } @@ -2243,8 +2283,8 @@ protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { if (_compList.RedoStep()) { - UpdateTrackBarAfterZChange(); - _selectedTiles.Clear(); + ClearTileReferences(); + SyncControlsToList(); pictureBoxMulti.Invalidate(); } @@ -2278,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; } @@ -2334,18 +2374,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); @@ -2405,10 +2442,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) 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(); } 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"); 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(); } 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) 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; } } }